#!/usr/bin/env python3 """ DaVinci Resolve MCP Server (Compound Tools) 32 compound tools covering 100% of the DaVinci Resolve Scripting API (336 methods) plus Fusion Fuse, DCTL, and Resolve-page Script authoring tools. Each tool groups related operations via an 'action' parameter. Usage: python src/server.py # Start the MCP server python src/server.py --full # Start the 341-tool granular server instead """ VERSION = "2.36.0" import base64 import os import sys import json import logging import math import platform import re import shutil import struct import subprocess import tempfile import threading import time import zlib from typing import Dict, Any, Optional, List, Tuple # ─── Path Setup ─────────────────────────────────────────────────────────────── current_dir = os.path.dirname(os.path.abspath(__file__)) project_dir = os.path.dirname(current_dir) # Add src and project to path for p in [current_dir, project_dir]: if p not in sys.path: sys.path.insert(0, p) # Platform-specific Resolve paths from src.utils.cdl import normalize_cdl_payload from src.utils.mcp_stdio import run_fastmcp_stdio from src.utils.api_truth import lookup_api_truth, VERIFIED_ON as _API_TRUTH_VERIFIED_ON from src.utils.contracts import validate as _validate_params from src.utils.cut_ir import build_cut_list as _build_cut_list from src.utils.page_lock import open_page_serialized as _open_page_serialized from src.utils.proc import safe_run from src.utils.readback import verify_by_readback, verification_stats as _verification_stats from src.utils.update_check import ( check_for_updates, clear_update_prompt_preferences, get_cached_update_status, get_update_mode, ignore_update_version, set_update_mode, snooze_update_prompt, start_background_update_check, update_prompt_decision, update_state_path, ) from src.utils.media_analysis import ( HOST_CHAT_PATHS_PROVIDER, HOST_CHAT_VISION_PROVIDERS, DEFAULT_VISION_ANALYSIS_PROMPT, VISION_SCHEMA_REFERENCE, analysis_index_status, build_plan as build_media_analysis_plan, build_coverage_report as build_media_analysis_coverage_report, build_analysis_index, cleanup_artifacts as cleanup_media_analysis_artifacts, commit_visual_analysis, detect_capabilities as detect_media_analysis_capabilities, execute_plan_async as execute_media_analysis_plan_async, install_guidance as media_analysis_install_guidance, load_report as load_media_analysis_report, query_analysis_index, resolve_output_root as resolve_media_analysis_output_root, slugify, summarize_reports as summarize_media_analysis_reports, ) from src.utils.sync_detection import detect_sync_events_for_records as detect_media_sync_events from src.utils.media_analysis_jobs import ( MEDIA_EXTENSIONS, batch_job_status as media_analysis_batch_job_status, cancel_batch_job as cancel_media_analysis_batch_job, create_batch_job as create_media_analysis_batch_job, list_batch_jobs as list_media_analysis_batch_jobs, resume_batch_job as resume_media_analysis_batch_job, run_batch_job_slice as run_media_analysis_batch_job_slice, ) from src.utils.platform import get_resolve_paths, get_resolve_plugin_paths from src.utils import fuse_templates, dctl_templates, script_templates from src.utils.timeline_title_text import ( candidate_title_property_keys as _candidate_title_property_keys, plain_to_minimal_styled_xml as _plain_to_minimal_styled_xml, timeline_item_get_property_map as _timeline_item_get_property_map, ) from src.utils.multicam import build_multicam_setup_plan from src.utils.fusion_group_settings import ( FUSION_COMMIT_CHECKLIST, FUSION_GROUP_GUARDRAILS, default_backup_path, parse_setting_file, splice_inputs_block, ) from src.utils import analysis_runs as _analysis_runs from src.utils import brain_edits as _brain_edits from src.utils import media_pool_changes as _media_pool_changes from src.utils import timeline_versioning as _timeline_versioning from src.utils import project_spec as _project_spec from src.utils import project_lint as _project_lint from src.utils import clip_query as _clip_query from src.utils import destructive_hook as _destructive_hook from src.utils.destructive_hook import destructive_op as _destructive_op paths = get_resolve_paths() RESOLVE_API_PATH = paths["api_path"] RESOLVE_LIB_PATH = paths["lib_path"] RESOLVE_MODULES_PATH = paths["modules_path"] os.environ["RESOLVE_SCRIPT_API"] = RESOLVE_API_PATH os.environ["RESOLVE_SCRIPT_LIB"] = RESOLVE_LIB_PATH if RESOLVE_MODULES_PATH not in sys.path: sys.path.append(RESOLVE_MODULES_PATH) # ─── Logging ────────────────────────────────────────────────────────────────── log_dir = os.path.join(project_dir, "logs") os.makedirs(log_dir, exist_ok=True) logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", handlers=[logging.FileHandler(os.path.join(log_dir, "server.log"))] ) logger = logging.getLogger("resolve-mcp") # ─── MCP Server ─────────────────────────────────────────────────────────────── from mcp.server.fastmcp import Context, FastMCP, Image from mcp import types as mcp_types mcp = FastMCP( "DaVinciResolveMCP", instructions=( "DaVinci Resolve MCP Server — controls Resolve via its Scripting API. " "Tools automatically launch Resolve if it is not running (may take up to 60s on first call). " "If a tool returns a connection error, Resolve Studio may not be installed or external scripting is disabled." ), ) READ_ONLY_TOOL = mcp_types.ToolAnnotations( readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False, ) WRITE_TOOL = mcp_types.ToolAnnotations( readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=False, ) IDEMPOTENT_WRITE_TOOL = mcp_types.ToolAnnotations( readOnlyHint=False, destructiveHint=False, idempotentHint=True, openWorldHint=False, ) DESTRUCTIVE_TOOL = mcp_types.ToolAnnotations( readOnlyHint=False, destructiveHint=True, idempotentHint=False, openWorldHint=False, ) EXTERNAL_READ_TOOL = mcp_types.ToolAnnotations( readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=True, ) EXTERNAL_WRITE_TOOL = mcp_types.ToolAnnotations( readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True, ) EXTERNAL_DESTRUCTIVE_TOOL = mcp_types.ToolAnnotations( readOnlyHint=False, destructiveHint=True, idempotentHint=False, openWorldHint=True, ) def _annotations_for_tool_name(tool_name: str) -> mcp_types.ToolAnnotations: """Infer conservative MCP client-safety hints for compound action tools.""" name = (tool_name or "").lower() external_tools = ( "layout_presets", "render_presets", "render", "media_storage", "media_pool", "folder", "media_pool_item", "gallery_stills", "fuse_plugin", "dctl", "script_plugin", ) destructive_tools = ( "resolve_control", "project_manager", "project_manager_folders", "project_manager_cloud", "project_manager_database", "project_settings", "timeline", "timeline_markers", "timeline_ai", "timeline_item", "timeline_item_markers", "timeline_item_fusion", "timeline_item_color", "timeline_item_takes", "timeline_versioning", "gallery", "graph", "color_group", "fusion_comp", ) if name == "media_analysis": return EXTERNAL_WRITE_TOOL if name in external_tools: return EXTERNAL_DESTRUCTIVE_TOOL if name in destructive_tools: return DESTRUCTIVE_TOOL return WRITE_TOOL _original_mcp_tool = mcp.tool def _tool_with_default_annotations( name=None, title=None, description=None, annotations=None, icons=None, meta=None, structured_output=None, ): """Default unannotated compound tools to explicit MCP safety hints.""" def decorator(func): tool_name = name or getattr(func, "__name__", "") return _original_mcp_tool( name=name, title=title, description=description, annotations=annotations or _annotations_for_tool_name(tool_name), icons=icons, meta=meta, structured_output=structured_output, )(func) return decorator mcp.tool = _tool_with_default_annotations @mcp.prompt() def davinci_resolve_workflow() -> str: """Recommended agent workflow for this DaVinci Resolve MCP server.""" return """Use this DaVinci Resolve MCP server as a guarded post-production control surface. Core pattern: - Prefer the 32 compound tools and their action names over raw scripting. - Start by probing state: resolve_control.get_version/get_page, project_manager.get_current, timeline.get_current, and media_pool.probe_media_pool. - Before mutating timelines, media pools, render settings, grades, projects, databases, or extensions, prefer the matching probe, capabilities, boundary_report, safe_*, or dry_run action when one exists. - Preserve source media integrity. Never transcode, proxy, rewrite, move, rename, or create derivatives of source media unless the user explicitly asks. Analysis output belongs in sidecars or analysis directories. - Do not silently downgrade media analysis. Source-safe does not mean no visuals, no transcription, no persistence, no metadata, or no markers. For Resolve-target media analysis, keep visual analysis, transcription, persisted artifacts, metadata writeback, and Media Pool marker writeback enabled unless the user explicitly opts out. Vision uses host_chat_paths by default: analyze actions return absolute frame_paths in a deferred payload; you must read those frames as images and call media_analysis(action="commit_vision", ...) to finalize. Not completing commit_vision leaves the analysis in pending_host_vision_analysis — that is a failure mode, not a success. Visual feedback: - For the current Color-page frame, use timeline_markers(action="get_thumbnail_image") when the client can display MCP images. - Use timeline_markers(action="get_thumbnail") when raw Resolve thumbnail data is needed for tooling. - Use project_settings(action="export_frame_as_still") only when a file export is explicitly useful, and write to a temp/stills location rather than near source media. High-value workflows: - Media analysis: use the analyze_media prompt or media_analysis.capabilities/install_guidance, then analyze file/clip/bin/project targets directly with persisted artifacts, host_chat_paths visual analysis (finish each clip with media_analysis(action="commit_vision", ...)), transcription, metadata writeback, and Media Pool marker writeback enabled by default unless the user opts out. - Timeline editing: use timeline.probe_edit_kernel_item, timeline.title_property_scan / timeline.set_title_text for Edit-page Text+ keys, duplicate_clips/copy_clips/move_clips, copy_range/overwrite_range/lift_range, and detect_gaps_overlaps. - Media ingest: use media_pool.ingest_capabilities, safe_import_media/safe_import_sequence, organize_clips, normalize_metadata, and relink planning actions. - Color: use timeline_item_color.grade_boundary_report, probe_node_graph, safe_set_cdl, safe_apply_drx, grade_version_snapshot/restore, and gallery/color-group capability actions. - Fusion: use fusion_comp.fusion_boundary_report, probe_fusion_comp, safe_add_tool, safe_set_inputs, and safe_connect_tools. - Audio/Fairlight: use timeline.fairlight_boundary_report, probe_audio_track/item, voice_isolation_capabilities, safe_auto_sync_audio, and subtitle_generation_probe. - Render/deliver: use render.export_render_boundary_report, validate_render_settings, safe_set_render_settings, prepare_render_job, and safe_quick_export. - Project lifecycle: use project_manager.project_boundary_report and safe project/database/archive actions. Keep destructive work scoped to disposable _mcp_ projects unless the user explicitly approves otherwise. - Extension authoring: use script_plugin.extension_boundary_report and safe_install_extension/safe_remove_extension. Respect refresh/restart requirements. Editorial improvements + versioning (C6 — always on for destructive timeline ops): - Every destructive timeline op (compound, captions, ripple delete, gap close, retime, marker batch, take swap, color grade, etc.) auto-archives the working timeline to the `Archive` bin BEFORE the mutation runs. You don't need to call archive yourself. - For multi-step editorial operations, call `timeline_versioning(action="begin_run", label="", initiator="brain.chat")` first. Every subsequent destructive call within that run will reuse the same `analysis_run_id` and produce ONE archived predecessor, not N. Pair with `timeline_versioning(action="end_run")` to write a cumulative per-metric summary. - When you're making a deliberate edit you can measure, pass `metric`, `direction`, and `rationale` in the action params. The hook captures the live `before_value` and `after_value` from the timeline and writes a `brain_edits` row with the delta — that's the measurement substrate for tuning. Supported metrics: `duration_seconds`, `avg_performance_score`, `clip_count`, `gap_count`, `total_gap_seconds`, `redundancy_score`. `direction` is `increase` | `decrease` | `target_value`. - For catastrophic ops (`timeline.delete_timelines`, `timeline.delete_track`, `timeline.delete_clips(ripple=True)`), strict mode is on by default — the call REFUSES to run if the pre-mutation archive can't be created. Pass `strict=true` on any destructive op to opt in to the same protection. - Read-only inspection (list, get_current, get_property, etc.) bypasses versioning entirely — no setup needed. - Inspect history via `timeline_versioning(action="get_history", timeline_name=…)`, `list_versions`, `diff_versions(from_version, to_version)`, or `list_runs`. Roll back via `timeline_versioning(action="rollback", timeline_name=…, version=…)`. For one-off scripting: - Prefer script_plugin(action="run_inline") over arbitrary persistent code changes. Use it to inspect Resolve state, then move durable behavior into guarded compound actions when it proves valuable. """ @mcp.prompt( name="analyze_media", title="Analyze Media", description="Run a source-safe DaVinci Resolve media-analysis workflow for a file, selected clip, bin, sequence/timeline, or whole project.", ) def analyze_media( target: str = "project", depth: str = "standard", finished_video: bool = False, include_visuals: bool = True, include_transcription: bool = True, persist: bool = True, ) -> str: """Slash-command style prompt for guided media analysis.""" return f"""Analyze Resolve media with the DaVinci Resolve MCP attached. Requested shape: - target: {target} - depth: {depth} - finished_video: {finished_video} - include_visuals: {include_visuals} - include_transcription: {include_transcription} - persist: {persist} Workflow: 1. Confirm the MCP is live with resolve_control(action="get_version") and project_manager(action="get_current"). 2. Call media_analysis(action="capabilities") and media_analysis(action="install_guidance"). Do not install anything automatically. 3. Resolve the target: - "project": use media_analysis(action="analyze_project"). - "selected" or "selected clip": use media_analysis(action="analyze_clip", params={{"selected": true}}). - "timeline" or "sequence": use media_analysis(action="analyze_sequence", params={{"track_types": ["video", "audio"]}}). - "bin:": use media_analysis(action="analyze_bin", params={{"path": "", "recursive": true}}). - An absolute file path: use media_analysis(action="analyze_file", params={{"path": ""}}). 4. Do not silently downgrade media analysis. Do not add include_visuals=false, include_transcription=false, publish_metadata=false, timed_markers="no", session_only=true, or dry_run=true unless the user explicitly asks for that opt-out or the target is a raw file path that cannot receive Resolve project writeback. 5. Do a quick memory check, then keep moving: - media_analysis(action="summarize") to find existing reports for the active project. - media_analysis(action="get_report") when a manifest/report already exists. - timeline(action="list"), timeline(action="get_current"), timeline(action="probe_timeline_structure"), and timeline_markers(action="get_all") when an edit already exists. The planner also reuses Resolve-published `davinci_resolve_mcp.analysis_report_path` provenance, the global analysis registry, and bounded related project-version roots by default; do not disable that unless the user asks for an isolated fresh run. If execution returns status="reuse_blocked", do not rerun analysis silently; restore the referenced report or use force_refresh=true only when the user explicitly wants a fresh read. Reuse existing evidence only when it already satisfies the requested technical, visual, transcription, and marker/writeback needs; otherwise run fresh analysis. 6. Execute analysis by default. Use dry_run=true only when the user asks for a preview or when you are intentionally staging a very large batch before a slice-based job. 7. Persist inspectable reports and artifacts by default under davinci-resolve-mcp-analysis. Use session_only=true only when the user explicitly asks for scratch results. 8. Visual analysis and transcription default on. If include_visuals is true, request vision={{"enabled": true, "provider": "host_chat_paths"}}. The analyze tool will respond with a deferred payload containing absolute frame_paths and a JSON schema; read each frame as a local image, produce JSON per the schema, then call media_analysis(action="commit_vision", params={{"clip_id": "...", "visual": , "vision_token": "..."}}) per clip. Metadata writeback and Media Pool clip markers publish automatically when commit_vision finalizes each clip. If include_transcription is true, request transcription={{"enabled": true, "allow_model_download": true}} so the configured local transcription backend can run. 9. If you cannot read the frame_paths as images, the visual layer remains pending_host_vision_analysis — surface that to the user; do not call the analysis complete unless they explicitly opt out with include_visuals=false. 10. Executed Resolve-target analysis writes metadata and source-time Media Pool clip markers by default after commit_vision finalizes. Pass publish_metadata=false, timed_markers="no", or dry_run=true only when the user asks to avoid Resolve project writeback. 11. If the task is about an existing edit, markers, or a finished video, call media_analysis(action="review_timeline_markers", params={{"vision": {{"enabled": {str(include_visuals).lower()}, "provider": "host_chat_paths"}}}}) when marker/frame alignment affects the decision. The response will include image_path; read it and answer inline (no commit step). 12. Timeline creation supports if_exists: use "reuse" for idempotent reruns, "version" for alternate cuts, and "fail" when duplicates indicate a workflow problem. Recommended execution params: {{ "dry_run": false, "depth": "{depth}", "session_only": false, "persist": {str(persist).lower()}, "publish_metadata": true, "timed_markers": "yes", "reuse_existing": true, "force_refresh": false, "reuse_policy": "compatible", "search_related_project_roots": true, "max_analysis_frames": 8, "vision": {{"enabled": {str(include_visuals).lower()}, "provider": "host_chat_paths"}}, "transcription": {{"enabled": {str(include_transcription).lower()}, "allow_model_download": true}} }} Interpretation rules learned from live Resolve sessions: - Preserve source media integrity. Do not modify, transcode, proxy, rename, move, or write beside source media. - Users can opt out of in-chat visual analysis by setting include_visuals=false. When opted out, do not read frame_paths and do not call commit_vision. - Users can opt out of transcription with include_transcription=false and can opt out of Resolve project writeback with publish_metadata=false or timed_markers="no". - Use the project-owned editorial craft reference in docs/guides/editorial-decision-guide.md; do not rely on personal or external editor skills. - When the user asks for cutting, pacing, story structure, suspense, comedy, or tonal reframing, use the editor craft lens: emotion and story outrank coverage; sound leads picture; find blink points and decisive frames; cut on reaction when meaning matters. - Treat scene/cut detection as guardrails, not story. If the source is a finished video, use black/flash ranges and likely cut points to avoid bad edit regions, but let transcript, sound, and complete thoughts drive editorial decisions. - For short-form edit recommendations, build an audio-first spine: premise, setup, turn, and button. Sacrifice visual variety when clarity or the joke needs it. - After a rough variant is assembled, verify it frame-by-frame: probe gaps/overlaps, inspect thumbnails at markers and cut points, compare marker intent against what Resolve actually shows, then revise marker names/source ranges if the image contradicts the plan. - Watch for Resolve timeline start-frame offsets. Positioned appends should anchor record_frame to the timeline start frame, often 108000 for 01:00:00:00. - Summarize results as editor-usable intelligence: technical state, warnings, motion/variance, visual content, transcript/sound notes, avoid ranges, best moments, and concrete next actions. When finished, report exactly which media_analysis call was made, whether artifacts were persisted, whether commit_vision was called for each clip with pending vision, whether metadata/markers were written back, and whether transcription succeeded.""" # ─── F2 — MCP Prompts for common multi-step workflows ────────────────────────── # See local/design/agentic-flow-improvements-gameplan-2.md §3 task F2. # These surface as slash commands in the host UI (e.g. /davinci-resolve:match-bin-to-hero). @mcp.prompt( name="analyze_and_propose_grade", title="Analyze + Propose Grade", description="Analyze the hero clip, build a grade_evidence_base, and stage a propose_grade artifact.", ) def analyze_and_propose_grade(hero_clip_id: str) -> str: return f"""Run the hero-clip color pipeline end-to-end. 1. Call media_analysis(action="analyze_clip", params={{"clip_id": "{hero_clip_id}"}}). 2. Read the resulting `analysis_signature` and confirm the clip has a current vision report. 3. Call timeline_item_color(action="grade_evidence_base", params={{"min_source_trust": "high"}}). 4. Lead your reply with the returned `evidence_base` line. 5. Call timeline_item_color(action="propose_grade", params={{ "target_id": "", "evidence_base": "", "frame_paths": [], "operation_class": "direct", "cdl_delta_or_artifact": {{"cdl": {{...}}}}, "execute": false }}). 6. Show the user the returned plan_id + preview_path. Wait for explicit confirmation before re-calling with execute=true. Never auto-execute the proposal.""" @mcp.prompt( name="match_bin_to_hero", title="Match Bin to Hero", description="Use bulk_match_to_hero to stage a per-target grade across a bin, dry-run first.", ) def match_bin_to_hero(hero_clip_id: str, method: str = "copy_grade") -> str: return f"""Match a bin's clips to a hero shot using bulk_match_to_hero. 1. Run media_analysis(action="analyze_bin", params={{"recursive": true}}) on the current bin so each target has a current vision report. 2. Call timeline_item_color(action="grade_evidence_base", params={{ "target": {{"target_id": "{hero_clip_id}"}}, "min_source_trust": "high" }}). 3. Lead your reply with the returned `evidence_base` line. 4. Call timeline_item_color(action="bulk_match_to_hero", params={{ "hero_id": "{hero_clip_id}", "target_ids": [], "method": "{method}", "min_source_trust": "high", "dry_run": true }}). 5. Show the user the per-target proposals and any `blocked` entries. 6. On confirmation, re-call with dry_run=false and the issued confirm_token.""" @mcp.prompt( name="verify_timeline_coverage", title="Verify Timeline Coverage", description="Run analyze_sequence on the current timeline and summarize coverage gaps.", ) def verify_timeline_coverage() -> str: return """Verify the current timeline has full analysis coverage. 1. Confirm with timeline(action="get_current") that a timeline is open. 2. Call media_analysis(action="analyze_sequence", params={"track_types": ["video"]}). 3. Inspect the returned manifest: clip_count vs successful_clip_count vs failed_clip_count. 4. If partial_success is true, surface failed_clip_ids and recommend retry-only-failed. 5. Call media_analysis(action="summarize") and read the `provenance.source_reports` list to verify every contributing clip has a current analysis_signature. 6. Report any clips in `provenance.missing_reports` as coverage gaps.""" @mcp.prompt( name="open_and_analyze_selection", title="Open Panel + Analyze Selection", description="Launch the control panel and analyze the current Resolve clip selection.", ) def open_and_analyze_selection() -> str: return """Open the analysis control panel and analyze the selected clips. 1. Call resolve_control(action="open_control_panel"). Surface the returned URL. 2. Call media_analysis(action="analyze_clip", params={"selected": true}). 3. Report manifest.successful_clip_count and any vision_pending count. 4. If vision_pending > 0, walk each pending clip's frame_paths and call media_analysis(action="commit_vision") per the host_chat_paths protocol. 5. Direct the user to the control panel URL for inline review of results.""" @mcp.prompt( name="prep_color_handoff", title="Prep Color Handoff", description="Generate a coverage + provenance + render-presets handoff packet for online/color.", ) def prep_color_handoff(output_dir: str = "") -> str: target_dir = output_dir or "~/Documents/davinci-resolve-mcp-analysis/handoff" return f"""Prepare a color/online handoff packet. 1. Call media_analysis(action="summarize") and capture provenance.source_reports. 2. Call render(action="list_render_presets") and timeline(action="probe_timeline_structure"). 3. Call timeline_versioning(action="list_versions") for the current timeline. 4. Write a handoff manifest to: {target_dir}/handoff_.json containing: - provenance source_reports list (clip signatures + paths) - render preset names - timeline version list - any caps usage at the time of handoff (media_analysis.get_caps) 5. Surface the manifest path back to the user. Do not write beside source media.""" # ─── Python Version Check ──────────────────────────────────────────────────── _py_ver = sys.version_info[:2] if _py_ver >= (3, 13): logger.warning( f"Python {_py_ver[0]}.{_py_ver[1]} detected. This is verified working on recent " f"Resolve builds (Studio 20.3.2), but older builds may not load the scripting " f"bridge on 3.13+. If scriptapp('Resolve') returns None, recreate the venv with " f"Python 3.10-3.12." ) # ─── Resolve Connection (lazy) ─────────────────────────────────────────────── sys.path.insert(0, RESOLVE_MODULES_PATH) resolve = None dvr_script = None _resolve_lock = threading.RLock() try: import DaVinciResolveScript as dvr_script logger.info("DaVinciResolveScript module loaded") except ImportError as e: logger.error(f"Cannot import DaVinciResolveScript: {e}") def _is_resolve_handle_live(candidate) -> bool: """Return True when a cached Resolve handle still answers root API calls.""" try: get_version = getattr(candidate, "GetVersion", None) if not callable(get_version): return False return bool(get_version()) except Exception as exc: logger.warning(f"Cached Resolve handle is stale: {exc}") return False def _try_connect(): """Attempt to connect to Resolve once. Returns resolve object or None.""" global resolve with _resolve_lock: if dvr_script is None: return None try: candidate = dvr_script.scriptapp("Resolve") if candidate and _is_resolve_handle_live(candidate): resolve = candidate logger.info(f"Connected: {resolve.GetProductName()} {resolve.GetVersionString()}") else: resolve = None return resolve except Exception as e: logger.error(f"Connection error: {e}") resolve = None return None def _launch_resolve(): """Launch DaVinci Resolve and wait for it to become available.""" sys_name = platform.system().lower() if sys_name == "darwin": app_path = "/Applications/DaVinci Resolve/DaVinci Resolve.app" if not os.path.exists(app_path): logger.error(f"DaVinci Resolve not found at {app_path}") return False subprocess.Popen(["open", app_path], stdin=subprocess.DEVNULL) elif sys_name == "windows": app_path = r"C:\Program Files\Blackmagic Design\DaVinci Resolve\Resolve.exe" if not os.path.exists(app_path): logger.error(f"DaVinci Resolve not found at {app_path}") return False subprocess.Popen([app_path], stdin=subprocess.DEVNULL) elif sys_name == "linux": app_path = "/opt/resolve/bin/resolve" if not os.path.exists(app_path): logger.error(f"DaVinci Resolve not found at {app_path}") return False subprocess.Popen([app_path], stdin=subprocess.DEVNULL) else: return False logger.info("Launched DaVinci Resolve, waiting for it to respond...") for i in range(30): time.sleep(2) if _try_connect(): logger.info(f"Resolve responded after {(i+1)*2}s") return True logger.warning("Resolve did not respond within 60s after launch") return False def get_resolve(): """Lazy connection to Resolve — connects on first tool call, auto-launches if needed.""" global resolve with _resolve_lock: if resolve is not None and _is_resolve_handle_live(resolve): return resolve resolve = None # Try to connect to an already-running Resolve. if _try_connect(): return resolve # Not running — launch it automatically. logger.info("Resolve not running, attempting to launch automatically...") _launch_resolve() return resolve def _destructive_versioning_provider() -> Optional[Tuple[Any, Any, str, Optional[str]]]: """Provider used by the C6 version-on-mutate hook. Returns (resolve, project, project_root, project_name) or None if any piece can't be resolved. The hook degrades silently when this returns None. """ try: r = get_resolve() if r is None: return None pm = r.GetProjectManager() if pm is None: return None proj = pm.GetCurrentProject() if proj is None: return None try: project_name = proj.GetName() except Exception: project_name = None try: project_id = proj.GetUniqueId() if hasattr(proj, "GetUniqueId") else None except Exception: project_id = None root = resolve_media_analysis_output_root( project_name=project_name, project_id=project_id, create=True, ) if not root or not root.get("success"): return None return (r, proj, root["project_root"], project_name) except Exception as exc: logger.debug("destructive versioning provider failed: %s", exc) return None _destructive_hook.register_project_root_provider(_destructive_versioning_provider) # ─── Resolve 21 AI-ops ledger plumbing ──────────────────────────────────────── import uuid as _ledger_uuid from src.utils import resolve_ai_ledger as _resolve_ai_ledger # One id per server process so the ledger / dashboard can scope "this session". _AI_LEDGER_SESSION_ID = _ledger_uuid.uuid4().hex def _ai_ledger_root() -> Optional[str]: """Best-effort project_root for the AI-ops ledger. None disables recording.""" try: provider = _destructive_versioning_provider() return provider[2] if provider else None except Exception: return None def _clip_file_size(item: Any) -> Tuple[Optional[str], Optional[int]]: """(file_path, size_bytes) for a MediaPoolItem, or (path|None, None).""" try: path = item.GetClipProperty("File Path") if isinstance(path, str) and path and os.path.exists(path): return path, os.path.getsize(path) return (path if isinstance(path, str) and path else None), None except Exception: return None, None def _ai_ledger_timed(op: str, *, clip_id: Optional[str] = None): """Ledger context manager bound to the current project_root + session.""" return _resolve_ai_ledger.timed( _ai_ledger_root(), op, clip_id=clip_id, session_id=_AI_LEDGER_SESSION_ID ) # ─── Resolve 21 AI-ops governance (soft tiers over the ledger) ──────────────── from src.utils import resolve_ai_governance as _resolve_ai_governance def _ai_governance_preset() -> Optional[str]: try: return _read_media_analysis_preferences().get("resolve_ai_governance_preset") except Exception: return None def _ai_governance_overrides() -> Optional[Dict[str, Any]]: try: raw = _read_media_analysis_preferences().get("resolve_ai_governance_overrides") or {} return raw if isinstance(raw, dict) else None except Exception: return None def _ai_governance_check(op: str) -> Dict[str, Any]: """Advisory governance status for the next run of `op` (best-effort).""" try: return _resolve_ai_governance.check( project_root=_ai_ledger_root(), session_id=_AI_LEDGER_SESSION_ID, op=op, preset=_ai_governance_preset(), overrides=_ai_governance_overrides(), ) except Exception: return {"applies": False} def _destructive_preference_provider(key: str) -> Any: """Reader for C6 preferences out of the existing media-analysis prefs file.""" try: return _read_media_analysis_preferences().get(key) except Exception: return None _destructive_hook.register_preference_provider(_destructive_preference_provider) # Gated (tool, action) pairs routed through the destructive_hook wrapper that # also live behind the confirm_token gate. The wrapper consults this set BEFORE # archiving so a token-issuance call (preview-only, no mutation) doesn't waste # a version. Keep in sync with the _issue_confirm_token call sites. _TOKEN_GATED_DESTRUCTIVE_ACTIONS = frozenset({ ("timeline", "delete_track"), ("timeline", "apply_cuts"), ("graph", "apply_grade_from_drx"), ("graph", "reset_all_grades"), # 21.0 AI ops that render/generate NEW media files (additive, but expensive # and irreversible without manual cleanup) — gated so they never run by # surprise. They never modify source media. ("folder", "remove_motion_blur"), ("media_pool_item", "remove_motion_blur"), ("project_settings", "generate_speech"), }) def _action_will_gate_pending_confirm( tool_name: str, action: str, params: Optional[Dict[str, Any]] ) -> bool: """True iff the next call to (tool_name, action, params) will short-circuit to issue a confirm_token (no mutation, nothing to archive yet).""" if not _confirm_token_required(): return False if isinstance(params, dict) and ("confirm_token" in params or "confirmToken" in params): return False if (tool_name, action) in _TOKEN_GATED_DESTRUCTIVE_ACTIONS: return True # delete_clips is gated only when ripple=True. if ( tool_name == "timeline" and action == "delete_clips" and isinstance(params, dict) and bool(params.get("ripple")) ): return True return False _destructive_hook.register_pending_confirm_check(_action_will_gate_pending_confirm) # ─── Analysis caps preference plumbing ──────────────────────────────────────── def _caps_preset_provider() -> Optional[str]: try: prefs = _read_media_analysis_preferences() return prefs.get("analysis_caps_preset") except Exception: return None def _caps_overrides_provider() -> Optional[Dict[str, Any]]: try: prefs = _read_media_analysis_preferences() raw = prefs.get("analysis_caps_overrides") or {} return raw if isinstance(raw, dict) else None except Exception: return None # Lazy import to avoid touching media_analysis at module-init time (it imports # our destructive-hook + analysis_caps modules already, but the providers we # register here read media-analysis preferences which the server owns). from src.utils import media_analysis as _media_analysis_module _media_analysis_module.register_caps_preset_provider(_caps_preset_provider) _media_analysis_module.register_caps_overrides_provider(_caps_overrides_provider) # ─── Helpers ────────────────────────────────────────────────────────────────── def _resolve_safe_dir(path): """Redirect sandbox/temp paths that Resolve can't access to ~/Desktop/resolve-stills. Covers macOS (/var/folders, /private/var), Linux (/tmp, /var/tmp), and Windows (AppData\\Local\\Temp) sandbox temp directories. """ system_temp = tempfile.gettempdir() _is_sandbox = False if platform.system() == "Darwin": _is_sandbox = path.startswith("/var/") or path.startswith("/private/var/") elif platform.system() == "Linux": _is_sandbox = path.startswith("/tmp") or path.startswith("/var/tmp") elif platform.system() == "Windows": # Check if path is under the system temp directory (e.g. AppData\Local\Temp) try: _is_sandbox = os.path.commonpath([os.path.abspath(path), os.path.abspath(system_temp)]) == os.path.abspath(system_temp) except ValueError: # Different drives on Windows _is_sandbox = False if _is_sandbox: return os.path.join(os.path.expanduser("~"), "Documents", "resolve-stills") return path # Error envelope categories — see local/design/agentic-flow-improvements-gameplan.md A1 # and local/design/agentic-flow-improvements-gameplan-2.md D1 for the retryable # default policy. Lock these names; downstream agents and tests route on them. ERROR_CATEGORIES = ( "precondition", # missing current timeline/clip/project; tell user what to do "not_connected", # Resolve not running or auto-launch failed "wrong_page", # action requires a specific page (Color, Edit, Fairlight…) "invalid_input", # caller-side fixable (bad param shape, unknown enum) "resolve_api_failed", # Resolve returned None/False for unclear reasons "destructive_blocked", # strict-mode/confirm-token refusal "pending_user_decision", # confirm_token required "unsupported", # feature/version/method not available "budget_exhausted", # caps refusal (vision/transcription/day budget) "timeout", # long-running op exceeded its cap "batch_partial", # mixed success in a batch op (some clips succeeded, some failed) ) # D1 — per-category retryable defaults. Used when `_err(...)` is called without # an explicit `retryable=` arg. Explicit overrides still win at the callsite. _CATEGORY_RETRYABLE_DEFAULT: Dict[str, bool] = { "precondition": False, # caller must change state first "not_connected": True, # auto-launch may succeed on next attempt "wrong_page": True, # trivial caller fix; retry after page switch "invalid_input": False, # caller fix required "resolve_api_failed": True, # often transient; retry once "destructive_blocked": False, # user decision required "pending_user_decision": False, # confirm_token required "unsupported": False, # API/version mismatch "budget_exhausted": False, # cap raise or day rollover needed "timeout": True, # may succeed if retried with more headroom "batch_partial": False, # caller must re-run only the failed subset } # Sentinel so `retryable=None` from a caller is distinguishable from "unspecified". _RETRYABLE_UNSET = object() def _err(message, *, code=None, category=None, retryable=_RETRYABLE_UNSET, remediation=None, reason=None, state=None): """Return a structured error envelope. Callers may pass just a message string for back-compat with the legacy shape; the envelope always populates code/category/retryable so the agent can route deterministically. Prefer naming a specific code+category at the callsite when the failure mode is known. `retryable`: - Omit to use the per-category default (see _CATEGORY_RETRYABLE_DEFAULT). - Pass True/False explicitly to override the default (rare; usually the default is correct). `state`: - Optional dict snapshot of the relevant values at failure time (e.g. {"queue_size": 0, "format": "mov"}). Machine-readable context so the agent doesn't have to parse `reason` prose. Omitted when empty. Shape: {"error": {"message": str, "code": str, "category": str, "retryable": bool, "reason": str?, "remediation": str?, "state": dict?}} """ cat = category if category in ERROR_CATEGORIES else "resolve_api_failed" if retryable is _RETRYABLE_UNSET: retryable_val = _CATEGORY_RETRYABLE_DEFAULT.get(cat, False) else: retryable_val = bool(retryable) body = { "message": str(message), "code": code or "UNSPECIFIED", "category": cat, "retryable": retryable_val, } if reason: body["reason"] = str(reason) if remediation: body["remediation"] = str(remediation) if state: body["state"] = state return {"error": body} def _ok(**kw): return {"success": True, **kw} def _record_action_outcome(scope_key: Optional[str], action_name: str, response: Dict[str, Any]) -> Dict[str, Any]: """E2 — record the (scope, action) outcome with the failure tracker and attach an `escalation` block to the response if the tracker says so. Callers should pass the already-built response (the `_err(...)` or `_ok(...)` return value); this helper mutates and returns it. A response with no `error` key is treated as a success and clears any prior failures. Wiring strategy: opt-in. Callers in high-blast-radius paths (analyze_clip, commit_vision, safe_apply_drx, etc.) should wrap their final return with this helper. Leaving it off for low-stakes reads (probe_*, *_capabilities) keeps the tracker focused on signals that matter. """ try: from src.utils import failure_tracker as _ft except Exception: return response err = response.get("error") if isinstance(response, dict) else None if err and isinstance(err, dict): category = err.get("category") or "resolve_api_failed" _ft.record_failure(scope_key, action_name) block = _ft.build_escalation_block(scope_key, action_name, category) if block: response["escalation"] = block else: _ft.record_success(scope_key, action_name) return response # ─────────────────────────────────────────────────────────────────────────── # B2 — Confirm-token gate on whole-grade-replacement and catastrophic ops. # In-process, session-scoped store. Tokens are one-time-use, expire after 5 min, # and are bound to (action, params_fingerprint) so a token for one mutation # cannot be reused for a different one. # ─────────────────────────────────────────────────────────────────────────── import hashlib as _hashlib import time as _time import uuid as _uuid _CONFIRM_TOKENS: Dict[str, Dict[str, Any]] = {} _CONFIRM_TTL_SECONDS = 300 def _confirm_token_fingerprint(action: str, params: Optional[Dict[str, Any]]) -> str: """Stable hash of (action, params) that identifies one specific mutation request.""" payload = {"action": action, "params": params or {}} # Strip the confirm_token itself if the caller is echoing it back to us. if isinstance(payload["params"], dict) and "confirm_token" in payload["params"]: payload["params"] = {k: v for k, v in payload["params"].items() if k != "confirm_token"} try: blob = json.dumps(payload, sort_keys=True, default=str) except Exception: blob = repr(payload) return _hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16] def _confirm_token_gc(): """Drop expired tokens; called on every issue/validate.""" now = _time.time() expired = [t for t, rec in _CONFIRM_TOKENS.items() if rec.get("expires_at", 0) < now] for t in expired: _CONFIRM_TOKENS.pop(t, None) def _confirm_token_required() -> bool: """Honor setup default destructive.require_confirm_token (default True).""" try: prefs = _media_analysis_preferences_read() if "_media_analysis_preferences_read" in globals() else {} except Exception: prefs = {} destructive = prefs.get("destructive") if isinstance(prefs.get("destructive"), dict) else {} val = destructive.get("require_confirm_token", True) if isinstance(val, str): return val.strip().lower() not in {"0", "false", "no", "off"} return bool(val) def _issue_confirm_token(*, action: str, params: Optional[Dict[str, Any]], preview: Dict[str, Any]) -> Dict[str, Any]: """Mint a token. Returns the pending_user_decision response shape.""" _confirm_token_gc() token = _uuid.uuid4().hex fp = _confirm_token_fingerprint(action, params) expires_at = _time.time() + _CONFIRM_TTL_SECONDS _CONFIRM_TOKENS[token] = { "action": action, "fingerprint": fp, "expires_at": expires_at, "issued_at": _time.time(), } body = _err( f"This action is destructive. Re-call with confirm_token to proceed.", code="CONFIRMATION_REQUIRED", category="pending_user_decision", retryable=False, remediation=f"Re-call {action} with params.confirm_token={token!r}; token expires in {_CONFIRM_TTL_SECONDS}s.", ) body.update({ "status": "confirmation_required", "confirm_token": token, "preview": preview, "expires_at_epoch": expires_at, "ttl_seconds": _CONFIRM_TTL_SECONDS, }) return body def _consume_confirm_token(*, action: str, params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: """If a valid token is present, consume it and return None (proceed). If missing/expired/mismatched, return a destructive_blocked error. If gating is disabled, return None (proceed). """ if not _confirm_token_required(): return None token = (params or {}).get("confirm_token") or (params or {}).get("confirmToken") if not token: return None # Caller is expected to call _issue_confirm_token in this case. _confirm_token_gc() rec = _CONFIRM_TOKENS.pop(token, None) # one-time use if rec is None: return _err( "confirm_token is invalid or expired", code="CONFIRM_TOKEN_INVALID", category="destructive_blocked", retryable=False, remediation=f"Re-call {action} without confirm_token to receive a fresh token.", ) if rec.get("action") != action: return _err( f"confirm_token issued for {rec.get('action')!r}, not {action!r}", code="CONFIRM_TOKEN_ACTION_MISMATCH", category="destructive_blocked", retryable=False, ) if rec.get("fingerprint") != _confirm_token_fingerprint(action, params): return _err( "confirm_token does not match the current params", code="CONFIRM_TOKEN_FINGERPRINT_MISMATCH", category="destructive_blocked", retryable=False, remediation="Either re-issue the token with current params or roll back the params change.", ) return None # OK to proceed def _activate_resolve_window() -> Dict[str, Any]: """Bring DaVinci Resolve to the foreground. macOS: AppleScript `tell application "DaVinci Resolve" to activate`. Windows: PowerShell `(New-Object -ComObject WScript.Shell).AppActivate(...)`. Linux: wmctrl/xdotool if present. Best-effort; returns {activated: bool, platform, error?}. Never raises. """ try: if sys.platform == "darwin": import subprocess proc = subprocess.run( ["osascript", "-e", 'tell application "DaVinci Resolve" to activate'], capture_output=True, text=True, timeout=5, stdin=subprocess.DEVNULL, ) return { "activated": proc.returncode == 0, "platform": "darwin", "error": proc.stderr.strip() if proc.returncode != 0 else None, } if sys.platform.startswith("win"): import subprocess proc = subprocess.run( ["powershell", "-NoProfile", "-Command", "$s = New-Object -ComObject WScript.Shell; " "$null = $s.AppActivate('DaVinci Resolve')"], capture_output=True, text=True, timeout=5, stdin=subprocess.DEVNULL, ) return { "activated": proc.returncode == 0, "platform": "win32", "error": proc.stderr.strip() if proc.returncode != 0 else None, } # Linux: try wmctrl, then xdotool. Quietly no-op if neither present. import subprocess, shutil if shutil.which("wmctrl"): proc = subprocess.run( ["wmctrl", "-a", "DaVinci Resolve"], capture_output=True, text=True, timeout=5, stdin=subprocess.DEVNULL, ) return { "activated": proc.returncode == 0, "platform": "linux", "tool": "wmctrl", } if shutil.which("xdotool"): proc = subprocess.run( ["xdotool", "search", "--name", "DaVinci Resolve", "windowactivate"], capture_output=True, text=True, timeout=5, stdin=subprocess.DEVNULL, ) return { "activated": proc.returncode == 0, "platform": "linux", "tool": "xdotool", } return {"activated": False, "platform": sys.platform, "note": "no window-manager tool found"} except Exception as exc: return {"activated": False, "error": f"{type(exc).__name__}: {exc}"} def _send_resolve_keystroke_go_to_mark_in() -> Dict[str, Any]: """Send the Resolve keyboard shortcut for "Go to In" (Shift+I). Resolve's scripting API has no direct call to move the source-viewer playhead, so we use the OS-level keystroke once the app is focused. Best-effort; returns {sent: bool, platform, error?}. """ try: if sys.platform == "darwin": import subprocess # Tiny pause lets the just-activated app settle before receiving keys. script = ( 'delay 0.15\n' 'tell application "System Events" to tell process "DaVinci Resolve"\n' ' key code 34 using {shift down}\n' # 34 = 'i' 'end tell' ) proc = subprocess.run( ["osascript", "-e", script], capture_output=True, text=True, timeout=5, stdin=subprocess.DEVNULL, ) return { "sent": proc.returncode == 0, "platform": "darwin", "shortcut": "Shift+I", "error": proc.stderr.strip() if proc.returncode != 0 else None, } if sys.platform.startswith("win"): import subprocess proc = subprocess.run( ["powershell", "-NoProfile", "-Command", "Add-Type -AssemblyName System.Windows.Forms; " "Start-Sleep -Milliseconds 150; " "[System.Windows.Forms.SendKeys]::SendWait('+i')"], capture_output=True, text=True, timeout=5, stdin=subprocess.DEVNULL, ) return { "sent": proc.returncode == 0, "platform": "win32", "shortcut": "Shift+I", "error": proc.stderr.strip() if proc.returncode != 0 else None, } # Linux: try xdotool only (wmctrl can't send keys). import subprocess, shutil if shutil.which("xdotool"): proc = subprocess.run( ["xdotool", "search", "--name", "DaVinci Resolve", "key", "--window", "%@", "shift+i"], capture_output=True, text=True, timeout=5, stdin=subprocess.DEVNULL, ) return {"sent": proc.returncode == 0, "platform": "linux", "tool": "xdotool", "shortcut": "Shift+I"} return {"sent": False, "platform": sys.platform, "note": "no key-send tool found"} except Exception as exc: return {"sent": False, "error": f"{type(exc).__name__}: {exc}"} def _has_method(obj, method_name): return callable(getattr(obj, method_name, None)) def _requires_method(obj, method_name, min_version): if _has_method(obj, method_name): return None return _err(f"{method_name} requires DaVinci Resolve {min_version}+") def _is_truncated(text): """True if a transcription preview was cut off. Resolve's `Transcription` clip property is a preview that ends with an ellipsis (… or ...) when the full transcript is longer than the property exposes, so the caller knows the returned text is partial. """ if not isinstance(text, str): return False t = text.rstrip() return t.endswith("…") or t.endswith("...") _MARKER_COLORS = [ "Blue", "Cyan", "Green", "Yellow", "Red", "Pink", "Purple", "Fuchsia", "Rose", "Lavender", "Sky", "Mint", "Lemon", "Sand", "Cocoa", "Cream", ] def _first_param(p: Dict[str, Any], *keys: str, default=None): for key in keys: if key in p and p[key] is not None: return p[key] return default def _normalize_marker_color(value): raw = str(value if value is not None else "Blue").strip() if not raw: raw = "Blue" for color in _MARKER_COLORS: if raw.lower() == color.lower(): return color, None return None, _err(f"Invalid marker color '{raw}'. Must be one of: {', '.join(_MARKER_COLORS)}") def _coerce_marker_number(value, field_name): if isinstance(value, bool): return None, _err(f"{field_name} must be a frame number, not a boolean") if isinstance(value, int): return value, None if isinstance(value, float): return int(value) if value.is_integer() else value, None if isinstance(value, str): raw = value.strip() if not raw: return None, _err(f"{field_name} cannot be empty") try: if "." in raw: number = float(raw) return int(number) if number.is_integer() else number, None return int(raw), None except ValueError: return None, _err(f"{field_name} must be a frame number") return None, _err(f"{field_name} must be a frame number") def _timeline_fps(tl): try: setting = tl.GetSetting("timelineFrameRate") except Exception as exc: return None, _err(f"Failed to read timelineFrameRate: {exc}") match = re.search(r"\d+(?:\.\d+)?", str(setting or "")) if not match: return None, _err("Could not determine timeline frame rate") return float(match.group(0)), None def _timecode_to_frame_id(timecode, fps): if not isinstance(timecode, str): return None, _err("timecode must be a string like '01:00:00:00'") tc = timecode.strip() drop_frame = ";" in tc parts = tc.replace(";", ":").replace(".", ":").split(":") if len(parts) != 4: return None, _err("timecode must use HH:MM:SS:FF format") try: hours, minutes, seconds, frames = [int(part) for part in parts] except ValueError: return None, _err("timecode fields must be numeric") nominal_fps = int(round(float(fps))) if nominal_fps <= 0: return None, _err("timeline frame rate must be greater than zero") if hours < 0 or minutes < 0 or minutes > 59 or seconds < 0 or seconds > 59: return None, _err("timecode hours must be non-negative, and minutes/seconds must be between 0 and 59") if frames < 0 or frames >= nominal_fps: return None, _err(f"timecode frame component must be between 0 and {nominal_fps - 1}") total_frames = ((hours * 3600 + minutes * 60 + seconds) * nominal_fps) + frames if drop_frame: drop_frames = int(round(nominal_fps * 0.0666666667)) total_minutes = hours * 60 + minutes total_frames -= drop_frames * (total_minutes - total_minutes // 10) return total_frames, None def _timeline_timecode_to_frame_id(tl, timecode): if tl is None: return None, _err("timecode markers require a timeline") fps, err = _timeline_fps(tl) if err: return None, err return _timecode_to_frame_id(timecode, fps) def _frame_id_to_timecode(frame: int, fps: float, separator: str = ":") -> str: nominal_fps = max(1, int(round(float(fps)))) frame = max(0, int(frame)) total_seconds, frames = divmod(frame, nominal_fps) hours, rem = divmod(total_seconds, 3600) minutes, seconds = divmod(rem, 60) return f"{hours:02d}:{minutes:02d}:{seconds:02d}{separator}{frames:02d}" def _timeline_frame_id_to_timecode(tl, frame: int) -> Tuple[Optional[str], Optional[Dict[str, Any]]]: fps, err = _timeline_fps(tl) if err: return None, err return _frame_id_to_timecode(int(frame), fps), None def _current_timeline_frame_id(tl): if tl is None: return None, _err("current playhead marker requires a timeline") try: timecode = tl.GetCurrentTimecode() except Exception as exc: return None, _err(f"Failed to read current timeline timecode: {exc}") if not timecode: return None, _err("Current timeline timecode is unavailable") return _timeline_timecode_to_frame_id(tl, timecode) def _marker_frame_from_params(p: Dict[str, Any], tl=None, default_to_current=False): raw_timecode = _first_param(p, "timecode", "time_code", "tc") if raw_timecode is not None: return _timeline_timecode_to_frame_id(tl, raw_timecode) raw_frame = _first_param(p, "frame", "frame_id", "frameId", "frame_num", "frameNum") if raw_frame is not None: if isinstance(raw_frame, str): lowered = raw_frame.strip().lower() if lowered in {"current", "playhead", "current_playhead", "now"}: return _current_timeline_frame_id(tl) if ":" in raw_frame or ";" in raw_frame: return _timeline_timecode_to_frame_id(tl, raw_frame) return _coerce_marker_number(raw_frame, "frame") if default_to_current: return _current_timeline_frame_id(tl) return None, _err("Missing marker frame. Provide frame, frame_id/frameId, or timecode.") def _marker_add_payload(p: Dict[str, Any], tl=None, default_to_current=False): frame, err = _marker_frame_from_params(p, tl=tl, default_to_current=default_to_current) if err: return None, err color, err = _normalize_marker_color(_first_param(p, "color", default="Blue")) if err: return None, err note = str(_first_param(p, "note", "comment", "description", default="") or "") name = str(_first_param(p, "name", "label", default=(note or "Marker")) or "Marker") duration, err = _coerce_marker_number( _first_param(p, "duration", "duration_frames", "durationFrames", default=1), "duration", ) if err: return None, err if duration <= 0: return None, _err("duration must be greater than zero") return { "frame": frame, "color": color, "name": name, "note": note, "duration": duration, "custom_data": str(_first_param(p, "custom_data", "customData", default="") or ""), }, None def _add_marker(target, marker: Dict[str, Any]): try: result = target.AddMarker( marker["frame"], marker["color"], marker["name"], marker["note"], marker["duration"], marker["custom_data"], ) except TypeError as exc: if marker["custom_data"]: return _err(f"AddMarker failed: {exc}") try: result = target.AddMarker( marker["frame"], marker["color"], marker["name"], marker["note"], marker["duration"], ) except Exception as fallback_exc: return _err(f"AddMarker failed: {fallback_exc}") except Exception as exc: return _err(f"AddMarker failed: {exc}") out = {"success": bool(result), "frame": marker["frame"]} if not result: try: markers = target.GetMarkers() or {} frame_keys = {marker["frame"]} if isinstance(marker["frame"], int): frame_keys.add(float(marker["frame"])) if any(frame_key in markers for frame_key in frame_keys): out["reason"] = f"A marker already exists at frame {marker['frame']}" except Exception: pass return out _ANNOTATION_KERNEL_ACTIONS = [ "annotation_capabilities", "probe_annotations", "normalize_marker_payload", "copy_annotations", "move_annotations", "sync_marker_custom_data", "clear_annotations_by_scope", "export_review_report", "annotation_boundary_report", ] def _annotation_capabilities(): return { "scopes": { "timeline": { "markers": True, "custom_data": True, "flags": False, "clip_color": False, "frame_space": "timeline frames or timeline timecode", }, "timeline_item": { "markers": True, "custom_data": True, "flags": True, "clip_color": True, "frame_space": "timeline item local/source-facing marker frames", }, "media_pool_item": { "markers": True, "custom_data": True, "flags": True, "clip_color": True, "frame_space": "media pool item source frames", }, }, "marker_colors": list(_MARKER_COLORS), "frame_aliases": ["frame", "frame_id", "frameId", "frame_num", "frameNum", "timecode", "tc"], "supported": [ "marker payload normalization", "marker add/get/update/delete by scope", "custom_data round-trip by scope", "timeline item and media pool item flags", "timeline item and media pool item clip color", "read-only review reports", "direct-frame annotation copy between scopes", ], "version_or_page_dependent": [ "current playhead marker insertion requires a current timeline and readable current timecode", "timeline current video item depends on playhead/page state", ], "boundaries": [ "timeline, timeline item, and media pool item frame spaces are not equivalent", "copy_annotations uses direct frame numbers unless the caller maps frames explicitly", "clip color and flags are related review metadata but not marker records", ], } def _marker_from_existing(frame, data: Dict[str, Any]): return { "frame": int(frame), "color": data.get("color", "Blue"), "name": data.get("name", ""), "note": data.get("note", ""), "duration": data.get("duration", 1), "custom_data": data.get("customData") or data.get("custom_data") or "", } def _annotation_target(scope: str, p: Dict[str, Any], tl=None): scope = scope or "timeline" if scope == "timeline": if tl is None: _, tl, err = _get_tl() if err: return None, err return tl, None if scope == "timeline_item": if p.get("current"): if tl is None: _, tl, err = _get_tl() if err: return None, err item = tl.GetCurrentVideoItem() if not item: return None, _err("No current video item") return item, None _, item, err = _get_item(p) if err: return None, err return item, None if scope == "media_pool_item": _, _, mp, err = _get_mp() if err: return None, err clip_id = p.get("clip_id") if clip_id: clip = _find_clip(mp.GetRootFolder(), clip_id) if not clip: return None, _err(f"Clip not found: {clip_id}") return clip, None if tl is None: _, tl, tl_err = _get_tl() if tl_err: return None, tl_err item = tl.GetCurrentVideoItem() clip = item.GetMediaPoolItem() if item else None if not clip: return None, _err("No media pool item could be resolved") return clip, None return None, _err(f"Unknown annotation scope: {scope}") def _annotation_snapshot(scope: str, target): snapshot = {"scope": scope, "markers": {}, "flags": None, "clip_color": None} if _has_method(target, "GetMarkers"): snapshot["markers"] = _ser(target.GetMarkers() or {}) if _has_method(target, "GetFlagList"): snapshot["flags"] = _ser(target.GetFlagList() or []) if _has_method(target, "GetClipColor"): snapshot["clip_color"] = _ser(target.GetClipColor()) if _has_method(target, "GetUniqueId"): try: snapshot["id"] = target.GetUniqueId() except Exception: pass if _has_method(target, "GetName"): try: snapshot["name"] = target.GetName() except Exception: pass return snapshot def _probe_annotations(tl, p: Dict[str, Any]): scope = p.get("scope") if scope: target, err = _annotation_target(scope, p, tl=tl) if err: return err return {"scopes": [_annotation_snapshot(scope, target)]} scopes = [] scopes.append(_annotation_snapshot("timeline", tl)) try: item = tl.GetCurrentVideoItem() except Exception: item = None if item: scopes.append(_annotation_snapshot("timeline_item", item)) try: clip = item.GetMediaPoolItem() except Exception: clip = None if clip: scopes.append(_annotation_snapshot("media_pool_item", clip)) return {"scopes": scopes, "count": len(scopes)} def _normalize_marker_payload_action(tl, p: Dict[str, Any]): marker, err = _marker_add_payload(p, tl=tl, default_to_current=bool(p.get("default_to_current", False))) if err: return err return {"marker": marker} def _copy_annotations(tl, p: Dict[str, Any], *, move: bool = False): source_p = dict(p.get("source") or {}) target_p = dict(p.get("target") or {}) source_scope = source_p.get("scope") or p.get("source_scope") or "timeline" target_scope = target_p.get("scope") or p.get("target_scope") or "timeline_item" source, err = _annotation_target(source_scope, source_p, tl=tl) if err: return err target, err = _annotation_target(target_scope, target_p, tl=tl) if err: return err markers = source.GetMarkers() if _has_method(source, "GetMarkers") else {} if not markers: return {"success": True, "copied": 0, "warnings": ["Source has no markers"]} warnings = [] copied = 0 for frame, data in (markers or {}).items(): marker = _marker_from_existing(frame, data) result = _add_marker(target, marker) if result.get("success"): copied += 1 else: warnings.append({"frame": frame, "result": result}) if p.get("include_flags", True) and _has_method(source, "GetFlagList") and _has_method(target, "AddFlag"): for flag in source.GetFlagList() or []: if not target.AddFlag(flag): warnings.append({"flag": flag, "result": "AddFlag returned false"}) if p.get("include_clip_color", True) and _has_method(source, "GetClipColor") and _has_method(target, "SetClipColor"): color = source.GetClipColor() if color and not target.SetClipColor(color): warnings.append({"clip_color": color, "result": "SetClipColor returned false"}) cleared = None if move and copied: clear_color = p.get("clear_color", "All") cleared = bool(source.DeleteMarkersByColor(clear_color)) if _has_method(source, "DeleteMarkersByColor") else False return { "success": copied == len(markers), "copied": copied, "source_scope": source_scope, "target_scope": target_scope, "frame_mapping": "direct", "warnings": warnings, "cleared_source": cleared, } def _sync_marker_custom_data(tl, p: Dict[str, Any]): scope = p.get("scope", "timeline") target, err = _annotation_target(scope, p, tl=tl) if err: return err frame, frame_err = _marker_frame_from_params(p, tl=tl if scope == "timeline" else None) if frame_err: return frame_err custom = _first_param(p, "custom_data", "customData", default="") if not _has_method(target, "UpdateMarkerCustomData"): return _err(f"{scope} does not expose UpdateMarkerCustomData") return {"success": bool(target.UpdateMarkerCustomData(frame, custom)), "frame": frame} def _clear_annotations_by_scope(tl, p: Dict[str, Any]): scope = p.get("scope", "timeline") target, err = _annotation_target(scope, p, tl=tl) if err: return err if p.get("custom_data") or p.get("customData"): if not _has_method(target, "DeleteMarkerByCustomData"): return _err(f"{scope} does not expose DeleteMarkerByCustomData") return {"success": bool(target.DeleteMarkerByCustomData(_first_param(p, "custom_data", "customData", default="")))} color = p.get("color", "All" if p.get("all", True) else "Blue") if not _has_method(target, "DeleteMarkersByColor"): return _err(f"{scope} does not expose DeleteMarkersByColor") result = {"success": bool(target.DeleteMarkersByColor(color)), "color": color} if p.get("clear_flags") and _has_method(target, "ClearFlags"): result["flags_cleared"] = bool(target.ClearFlags(p.get("flag_color", "All"))) if p.get("clear_clip_color") and _has_method(target, "ClearClipColor"): result["clip_color_cleared"] = bool(target.ClearClipColor()) return result def _export_review_report(tl, p: Dict[str, Any]): report = { "title": p.get("title", "Review Annotation Report"), "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "annotations": _probe_annotations(tl, p), } if p.get("include_capabilities", True): report["capabilities"] = _annotation_capabilities() return report def _annotation_boundary_report(tl, p: Dict[str, Any]): return { "capabilities": _annotation_capabilities(), "annotations": _probe_annotations(tl, p), } def _check(): resolve = get_resolve() if resolve is None: return None, None, _err( "Not connected to DaVinci Resolve. Is Resolve running?", code="NOT_CONNECTED", category="not_connected", retryable=True, remediation="Open DaVinci Resolve Studio and set Preferences > General > 'External scripting using' to Local.", ) pm = resolve.GetProjectManager() if pm is None: return None, None, _err( "Could not get ProjectManager from Resolve", code="NO_PROJECT_MANAGER", category="resolve_api_failed", retryable=True, ) proj = pm.GetCurrentProject() if not proj: return pm, None, _err( "No project open", code="NO_PROJECT", category="precondition", remediation="Open a project via project_manager(action='load', params={'name': ...}) or in the Resolve UI.", ) return pm, proj, None def _get_mp(): pm, proj, err = _check() if err: return None, None, None, err mp = proj.GetMediaPool() if not mp: return pm, proj, None, _err( "Failed to get MediaPool", code="NO_MEDIA_POOL", category="resolve_api_failed", ) return pm, proj, mp, None def _get_tl(): pm, proj, err = _check() if err: return None, None, err tl = proj.GetCurrentTimeline() if not tl: return proj, None, _err( "No current timeline", code="NO_CURRENT_TIMELINE", category="precondition", remediation="Open a timeline via timeline(action='set_current', params={'index': N}) or in the Resolve UI.", ) return proj, tl, None def _get_item(p): proj, tl, err = _get_tl() if err: return None, None, err track_type = p.get("track_type", "video") track_index = p.get("track_index", 1) item_index = p.get("item_index", 0) items = tl.GetItemListInTrack(track_type, track_index) if not items or item_index >= len(items): return tl, None, _err( f"No item at index {item_index} on {track_type} track {track_index}", code="ITEM_INDEX_OUT_OF_RANGE", category="invalid_input", remediation="Call timeline(action='get_items', params={'track_type': ..., 'index': ...}) to list valid items.", ) return tl, items[item_index], None def _has_fusion_timeline_scope(p: Dict[str, Any]) -> bool: return bool(p.get("clip_id") or p.get("timeline_item_id") or "timeline_item" in p) def _find_timeline_item_by_id(tl, timeline_item_id) -> Optional[Any]: """Find a timeline item by GetUniqueId() across timeline tracks.""" if not timeline_item_id: return None want = str(timeline_item_id) for track_type in ("video", "audio", "subtitle"): try: track_count = int(tl.GetTrackCount(track_type) or 0) except Exception: continue for track_index in range(1, track_count + 1): for item in (tl.GetItemListInTrack(track_type, track_index) or []): try: if str(item.GetUniqueId()) == want: return item except Exception: continue return None def _get_timeline_item_for_fusion(p: Dict[str, Any]): """Resolve optional timeline scope for fusion_comp.""" if not _has_fusion_timeline_scope(p): return None, None timeline_item = p.get("timeline_item") if "timeline_item" in p and not isinstance(timeline_item, dict): return None, _err("timeline_item must be an object with track_type, track_index, and item_index") _, tl, err = _get_tl() if err: return None, err timeline_item_id = p.get("clip_id") or p.get("timeline_item_id") if timeline_item_id: item = _find_timeline_item_by_id(tl, timeline_item_id) if not item: return None, _err(f"No timeline item with clip_id/timeline_item_id={timeline_item_id!r}") return item, None query = dict(timeline_item) query.setdefault("track_type", "video") _, item, item_err = _get_item(query) if item_err: return None, item_err return item, None def _get_fusion_comp_on_timeline_item(item, p: Dict[str, Any]): """Get a Fusion composition on a TimelineItem.""" try: comp_count = int(item.GetFusionCompCount() or 0) except Exception as exc: return None, _err(f"GetFusionCompCount failed: {exc}") if comp_count < 1: return None, _err("Timeline item has no Fusion compositions") comp_name = p.get("comp_name") if comp_name: comp = item.GetFusionCompByName(str(comp_name)) if not comp: return None, _err(f"No Fusion comp named {comp_name!r} on this timeline item") return comp, None try: comp_index = int(p.get("comp_index", 1)) except (TypeError, ValueError): return None, _err("comp_index must be a 1-based integer") if comp_index < 1 or comp_index > comp_count: return None, _err(f"No Fusion comp at comp_index={comp_index}; item has {comp_count} comp(s)") comp = item.GetFusionCompByIndex(comp_index) if not comp: return None, _err(f"GetFusionCompByIndex({comp_index}) returned no composition") return comp, None def _resolve_fusion_comp(p: Dict[str, Any], require_timeline_scope: bool = False): """Resolve a Fusion comp from timeline scope or the active Fusion page comp.""" if require_timeline_scope and not _has_fusion_timeline_scope(p): return None, _err( "Timeline scope is required: pass clip_id, timeline_item_id, " "or timeline_item={track_type, track_index, item_index}" ) r = get_resolve() if not r: return None, _err("Not connected to DaVinci Resolve. Is Resolve running?") item, item_err = _get_timeline_item_for_fusion(p) if item_err: return None, item_err if item is not None: return _get_fusion_comp_on_timeline_item(item, p) fusion = r.Fusion() if not fusion: return None, _err("Fusion not available — switch to the Fusion page first") comp = fusion.GetCurrentComp() if not comp: return None, _err( "No active Fusion composition. Open a clip in the Fusion page first, " "or pass clip_id, timeline_item_id, or timeline_item={track_type, track_index, item_index} " "with optional comp_name or comp_index." ) return comp, None def _find_clip(folder, clip_id): for clip in (folder.GetClipList() or []): if clip.GetUniqueId() == clip_id: return clip for sub in (folder.GetSubFolderList() or []): found = _find_clip(sub, clip_id) if found: return found return None def _find_clip_with_parent(folder, clip_id, _parent=None): """Return (clip, parent_folder) for clip_id, searching recursively. `parent_folder` is the immediate folder containing the clip, which is what MediaPool.SetCurrentFolder() needs in order to reveal the clip in the bin. Returns (None, None) if not found. """ for clip in (folder.GetClipList() or []): if clip.GetUniqueId() == clip_id: return clip, folder for sub in (folder.GetSubFolderList() or []): found_clip, found_parent = _find_clip_with_parent(sub, clip_id, folder) if found_clip: return found_clip, found_parent return None, None def _navigate_folder(mp, path): root = mp.GetRootFolder() if not path or path in ("Master", "/", ""): return root parts = path.strip("/").split("/") if parts[0] == "Master": parts = parts[1:] current = root for part in parts: found = False for sub in (current.GetSubFolderList() or []): if sub.GetName() == part: current = sub found = True break if not found: return None return current def _project_summary(proj, *, include_clips=False, clip_limit=50): """Live structural readout of a project: page, timelines, media-pool inventory. Distinct from the analysis-derived bin summary — this is a cheap "what's in this project right now" snapshot that needs no prior analysis. """ by_type: Dict[str, int] = {} sample_clips: List[Dict[str, Any]] = [] folder_count = 0 clip_count = 0 mp = proj.GetMediaPool() root = mp.GetRootFolder() if mp else None def walk(folder): nonlocal folder_count, clip_count folder_count += 1 for clip in (folder.GetClipList() or []): clip_count += 1 try: ctype = clip.GetClipProperty("Type") or "Unknown" except Exception: ctype = "Unknown" by_type[ctype] = by_type.get(ctype, 0) + 1 if include_clips and len(sample_clips) < clip_limit: sample_clips.append({ "name": _clip_name(clip), "type": ctype, "id": clip.GetUniqueId(), }) for sub in (folder.GetSubFolderList() or []): walk(sub) if root: walk(root) cur_tl = proj.GetCurrentTimeline() r = get_resolve() return { "project": proj.GetName(), "current_page": r.GetCurrentPage() if r else None, "timeline_count": proj.GetTimelineCount(), "current_timeline": cur_tl.GetName() if cur_tl else None, "media_pool": { "folder_count": folder_count, "clip_count": clip_count, "by_type": by_type, }, "clips": sample_clips if include_clips else None, } def _timeline_transcript(tl, *, with_timecodes=False): """Read a timeline's subtitle track(s) as transcript text.""" try: track_count = tl.GetTrackCount("subtitle") except Exception: track_count = 0 cues: List[Dict[str, Any]] = [] for ti in range(1, (track_count or 0) + 1): for item in (tl.GetItemListInTrack("subtitle", ti) or []): text = "" try: text = item.GetName() or "" except Exception: text = "" cue = {"text": text} if with_timecodes: try: cue["start"] = item.GetStart() cue["end"] = item.GetEnd() except Exception: pass cues.append(cue) full = " ".join(c["text"] for c in cues if c.get("text")) return { "text": full, "cue_count": len(cues), "has_subtitles": bool(cues), "cues": cues, } def _normalize_record_frame( ci: Dict[str, Any], index: int, timeline_start_frame: Optional[int] = None, ): """Translate wrapper record_frame offsets into Resolve absolute frames.""" rf = _frame_int(ci.get("recordFrame", ci.get("record_frame"))) if rf is None: return None, _err(f"clip_infos[{index}] record_frame/recordFrame must be numeric") mode_raw = ci.get("recordFrameMode", ci.get("record_frame_mode", "relative")) mode = str(mode_raw or "relative").strip().lower() mode_aliases = { "relative": "relative", "timeline_relative": "relative", "offset": "relative", "absolute": "absolute", "timeline_absolute": "absolute", "auto": "auto", } mode = mode_aliases.get(mode) if not mode: return None, _err( f"clip_infos[{index}] record_frame_mode must be 'relative', 'absolute', or 'auto'" ) start = _frame_int(timeline_start_frame) if start in (None, 0) or mode == "absolute": return rf, None if mode == "auto": return (start + rf) if rf < start else rf, None return start + rf, None def _timeline_start_frame(tl) -> Optional[int]: if not tl: return None try: return _frame_int(tl.GetStartFrame()) except Exception: return None def _build_append_clip_info_dict( root, ci: Dict[str, Any], index: int, timeline_start_frame: Optional[int] = None, ): """Build one MediaPool.AppendToTimeline clipInfo map (Python API uses camelCase keys). See docs/reference/resolve_scripting_api.txt: mediaPoolItem, startFrame, endFrame, optional mediaType, trackIndex, recordFrame. """ if not isinstance(ci, dict): return None, _err(f"clip_infos[{index}] must be an object") cid = ci.get("clip_id") or ci.get("media_pool_item_id") if not cid: return None, _err(f"clip_infos[{index}] requires clip_id or media_pool_item_id") mp_item = _find_clip(root, cid) if not mp_item: return None, _err(f"clip_infos[{index}]: media pool clip not found: {cid}") sf = ci.get("startFrame", ci.get("start_frame")) ef = ci.get("endFrame", ci.get("end_frame")) if sf is None or ef is None: return None, _err( f"clip_infos[{index}] requires start_frame/startFrame and end_frame/endFrame " "(source range on the MediaPoolItem)" ) rf = ci.get("recordFrame", ci.get("record_frame")) if rf is None: return None, _err( f"clip_infos[{index}] requires record_frame/recordFrame (timeline record frame)" ) rf, rf_err = _normalize_record_frame(ci, index, timeline_start_frame) if rf_err: return None, rf_err ti = ci.get("trackIndex", ci.get("track_index")) if ti is None: return None, _err( f"clip_infos[{index}] requires track_index/trackIndex (1-based track index)" ) out: Dict[str, Any] = { "mediaPoolItem": mp_item, "startFrame": sf, "endFrame": ef, "recordFrame": rf, "trackIndex": ti, } mt = ci.get("mediaType", ci.get("media_type")) if mt is not None: out["mediaType"] = mt return out, None def _build_create_clip_info_dict( root, ci: Dict[str, Any], index: int, timeline_start_frame: Optional[int] = None, ): """Build one MediaPool.CreateTimelineFromClips clipInfo map. See docs/reference/resolve_scripting_api.txt line 224: 4 keys only — mediaPoolItem, startFrame, endFrame, recordFrame. No trackIndex, no mediaType. """ if not isinstance(ci, dict): return None, _err(f"clip_infos[{index}] must be an object") cid = ci.get("clip_id") or ci.get("media_pool_item_id") if not cid: return None, _err(f"clip_infos[{index}] requires clip_id or media_pool_item_id") mp_item = _find_clip(root, cid) if not mp_item: return None, _err(f"clip_infos[{index}]: media pool clip not found: {cid}") sf = ci.get("startFrame", ci.get("start_frame")) ef = ci.get("endFrame", ci.get("end_frame")) if sf is None or ef is None: return None, _err( f"clip_infos[{index}] requires start_frame/startFrame and end_frame/endFrame " "(source range on the MediaPoolItem)" ) rf = ci.get("recordFrame", ci.get("record_frame")) if rf is None: return None, _err( f"clip_infos[{index}] requires record_frame/recordFrame (timeline record frame)" ) rf, rf_err = _normalize_record_frame(ci, index, timeline_start_frame) if rf_err: return None, rf_err return { "mediaPoolItem": mp_item, "startFrame": sf, "endFrame": ef, "recordFrame": rf, }, None def _frame_int(v): if v is None: return None try: return int(round(float(v))) except (TypeError, ValueError): return None def _safe_timeline_item_id(item): try: get_unique_id = getattr(item, "GetUniqueId", None) if callable(get_unique_id): item_id = get_unique_id() return None if item_id in (None, "") else str(item_id) except Exception: return None return None def _safe_timeline_item_name(item): try: get_name = getattr(item, "GetName", None) if callable(get_name): name = get_name() return None if name is None else str(name) except Exception: return None return None def _safe_media_pool_item_id(mpi): try: get_unique_id = getattr(mpi, "GetUniqueId", None) if callable(get_unique_id): media_id = get_unique_id() return None if media_id in (None, "") else str(media_id) except Exception: return None return None def _safe_media_pool_item_name(mpi): try: get_name = getattr(mpi, "GetName", None) if callable(get_name): name = get_name() return None if name is None else str(name) except Exception: return None return None def _timeline_item_source_start(item): if _has_method(item, "GetSourceStartFrame"): try: source_start = _frame_int(item.GetSourceStartFrame()) if source_start is not None: return source_start except Exception: pass try: return _frame_int(item.GetLeftOffset()) except Exception: return None def _timeline_item_media_pool_item(item): try: return item.GetMediaPoolItem() except Exception: return None def _timeline_item_duration(item, start: Optional[int] = None, end: Optional[int] = None): if _has_method(item, "GetDuration"): try: duration = _frame_int(item.GetDuration()) if duration is not None: return duration except Exception: pass if start is not None and end is not None: return end - start return None def _timeline_item_track_info(item): try: track_info = item.GetTrackTypeAndIndex() except Exception as exc: return None, _err(f"GetTrackTypeAndIndex: {exc}") if not track_info or len(track_info) < 2: return None, _err("GetTrackTypeAndIndex returned empty") try: return (str(track_info[0]).lower(), int(track_info[1])), None except (TypeError, ValueError): return None, _err("invalid source track index") def _timeline_item_summary(item, track_info=None): if not item: return None start = end = duration = source_start = source_end = None try: start = _frame_int(item.GetStart()) end = _frame_int(item.GetEnd()) except Exception: pass duration = _timeline_item_duration(item, start, end) source_start = _timeline_item_source_start(item) if source_start is not None and duration is not None: source_end = source_start + duration if track_info is None: track_info, _ = _timeline_item_track_info(item) media_pool_item = _timeline_item_media_pool_item(item) summary = { "timeline_item_id": _safe_timeline_item_id(item), "name": _safe_timeline_item_name(item), "track_type": track_info[0] if track_info else None, "track_index": track_info[1] if track_info else None, "start": start, "end": end, "duration": duration, "source_start": source_start, "source_end": source_end, "media_pool_item_id": _safe_media_pool_item_id(media_pool_item), "media_pool_item_name": _safe_media_pool_item_name(media_pool_item), } return summary # Filters the live timeline adapter can populate from a timeline-item summary. # Analysis-aware filters in clip_query (analyzed/has_transcription/shot_type/ # marker_color) require an analysis-DB join not yet wired here; reject them at # the boundary rather than return silently-wrong (empty) matches. _LIVE_CLIP_WHERE_FILTERS = { "track_type", "track_index", "name_contains", "duration_lt", "duration_gt", } def _timeline_clip_where(tl, p: Dict[str, Any]) -> Dict[str, Any]: """clip_where action: return timeline clips matching named filters (AND). Filters may be passed as a `filters` dict or as top-level params. Only the structural filters in `_LIVE_CLIP_WHERE_FILTERS` are supported live. """ filters = p.get("filters") if not isinstance(filters, dict): # Top-level convenience: treat every param as a filter and validate it, # so a typo'd key is rejected rather than silently matching everything. filters = {k: v for k, v in p.items() if k != "filters"} ok, unknown = _clip_query.validate_filters(filters) if not ok: return _err( f"Unknown clip_where filter(s): {unknown}", code="UNKNOWN_FILTER", category="invalid_input", state={"unknown": unknown, "supported": sorted(_clip_query.SUPPORTED_FILTERS)}, ) not_live = [k for k in filters if k not in _LIVE_CLIP_WHERE_FILTERS] if not_live: return _err( f"Filter(s) not yet supported on a live timeline: {not_live}", code="FILTER_NOT_LIVE", category="unsupported", remediation="Use structural filters (track_type, track_index, name_contains, duration_lt, duration_gt).", state={"not_live": not_live, "live_supported": sorted(_LIVE_CLIP_WHERE_FILTERS)}, ) clips: List[Dict[str, Any]] = [] for tt in ("video", "audio", "subtitle"): try: count = int(tl.GetTrackCount(tt) or 0) except Exception: continue for ti in range(1, count + 1): for item in (tl.GetItemListInTrack(tt, ti) or []): summary = _timeline_item_summary(item, (tt, ti)) if summary: clips.append(summary) matches = _clip_query.filter_clips(clips, filters) return _ok(filters=filters, match_count=len(matches), total_clips=len(clips), clips=matches) def _serialize_appended_timeline_item(item, index: int, *, allow_empty_timeline_item_id: bool = False): if not item: return None, _err(f"Failed to append clip_infos to timeline: missing timeline item at index {index}") item_id = None name = None try: item_id = item.GetUniqueId() except Exception as exc: if not allow_empty_timeline_item_id: logger.warning(f"Invalid timeline item returned for clip_infos[{index}]: {exc}") return None, _err(f"Failed to append clip_infos to timeline: invalid timeline item at index {index}") logger.warning(f"Appended timeline item has no readable id for clip_infos[{index}]: {exc}") try: name = item.GetName() except Exception as exc: if not allow_empty_timeline_item_id: logger.warning(f"Invalid timeline item returned for clip_infos[{index}]: {exc}") return None, _err(f"Failed to append clip_infos to timeline: invalid timeline item at index {index}") logger.warning(f"Appended timeline item has no readable name for clip_infos[{index}]: {exc}") if not item_id: if allow_empty_timeline_item_id: return {"timeline_item_id": None, "name": None if name is None else str(name)}, None return None, _err(f"Failed to append clip_infos to timeline: missing timeline item id at index {index}") return {"timeline_item_id": str(item_id), "name": None if name is None else str(name)}, None def _append_clip_info_from_timeline_item( item, target_track_index: int, record_frame_offset: int = 0, record_frame: Optional[int] = None, media_type: int = 1, source_start: Optional[int] = None, source_end: Optional[int] = None, ): """Build one MediaPool.AppendToTimeline clipInfo dict from a timeline item. Same pool media and source trim as the item; record at GetStart()+record_frame_offset on target_track_index. GetDuration() is preferred because Resolve's timeline end position can be inclusive for clips created via positioned append. """ try: mpi = item.GetMediaPoolItem() except Exception as exc: return None, _err(f"GetMediaPoolItem failed: {exc}") if not mpi: return None, _err( "Timeline item has no MediaPoolItem (generators / titles without pool media cannot use this)" ) try: t_start = _frame_int(item.GetStart()) t_end_excl = _frame_int(item.GetEnd()) except Exception as exc: return None, _err(f"GetStart/GetEnd failed: {exc}") if t_start is None or t_end_excl is None: return None, _err("GetStart/GetEnd returned unset values") duration_tl = None if _has_method(item, "GetDuration"): try: duration_tl = _frame_int(item.GetDuration()) except Exception: duration_tl = None if duration_tl is None: duration_tl = t_end_excl - t_start if duration_tl <= 0: return None, _err("invalid timeline duration") src_start = _timeline_item_source_start(item) if source_start is None else _frame_int(source_start) if src_start is None: return None, _err("could not read source trim (LeftOffset / GetSourceStartFrame)") src_end_excl = _frame_int(source_end) if source_end is not None else src_start + duration_tl if src_end_excl is None or src_end_excl <= src_start: return None, _err("invalid source range") record = int(record_frame) if record_frame is not None else t_start + int(record_frame_offset) return { "mediaPoolItem": mpi, "startFrame": src_start, "endFrame": src_end_excl, "recordFrame": record, "trackIndex": int(target_track_index), "mediaType": int(media_type), }, None def _find_appended_timeline_item_summary( tl, *, track_type: str = "video", target_track_index: int, record_frame: int, duration: int, source_media_pool_item, source_timeline_item_id: Optional[str] = None, ): """Recover an appended item's id by scanning the target track after append. Resolve can occasionally return a thin object from AppendToTimeline that lacks GetUniqueId/GetName even though the edit succeeded. The timeline track itself usually contains the real item handle immediately after the append. """ source_media_id = _safe_media_pool_item_id(source_media_pool_item) try: items = tl.GetItemListInTrack(track_type, target_track_index) or [] except Exception: return None matches = [] for item in items: item_id = _safe_timeline_item_id(item) if source_timeline_item_id and item_id == source_timeline_item_id: continue try: start = _frame_int(item.GetStart()) end = _frame_int(item.GetEnd()) except Exception: continue if start != record_frame or start is None or end is None: continue item_duration = None if _has_method(item, "GetDuration"): try: item_duration = _frame_int(item.GetDuration()) except Exception: item_duration = None if item_duration is None: item_duration = end - start if item_duration != duration: continue item_mpi = _timeline_item_media_pool_item(item) if source_media_id: if _safe_media_pool_item_id(item_mpi) != source_media_id: continue elif item_mpi is not source_media_pool_item: continue matches.append(item) if not matches: return None item = matches[-1] return { "timeline_item_id": _safe_timeline_item_id(item), "name": _safe_timeline_item_name(item), } _DUPLICATE_PLACEMENTS = { "same_time", "offset", "at_playhead", "track_above", "after_source", "next_gap", } _DUPLICATE_COPY_GROUP_ALIASES = { "all": "all", "all_supported": "all", "audio": "audio", "audio_properties": "audio", "cache": "cache", "color": "clip_color", "color_grade": "grades", "clipcolor": "clip_color", "clip_color": "clip_color", "enabled": "enabled", "enabled_state": "enabled", "flags": "flags", "fusion": "fusion", "fusion_comps": "fusion", "dynamic_zoom": "dynamic_zoom", "dynamiczoom": "dynamic_zoom", "grade": "grades", "grades": "grades", "keyframe": "keyframes", "keyframes": "keyframes", "markers": "markers", "marker": "markers", "retime": "retime", "retime_settings": "retime", "scaling": "scaling", "resize": "scaling", "sizing": "scaling", "stabilization": "stabilization", "stabilisation": "stabilization", "takes": "takes", "take_selectors": "takes", "transitions": "transitions", "transition": "transitions", "transform": "transform", "crop": "crop", "composite": "composite", "voice_isolation": "voice_isolation", } _DUPLICATE_COPY_PROPERTY_KEYS = { "transform": [ "Pan", "Tilt", "ZoomX", "ZoomY", "ZoomGang", "RotationAngle", "AnchorPointX", "AnchorPointY", "Pitch", "Yaw", "FlipX", "FlipY", ], "crop": [ "CropLeft", "CropRight", "CropTop", "CropBottom", "CropSoftness", "CropRetain", ], "composite": ["Opacity", "CompositeMode"], "audio": [ "Volume", "Pan", "AudioSyncOffsetIsManual", "AudioSyncOffset", "EQEnable", "NormalizeEnable", "NormalizeLevel", ], "retime": ["Speed", "RetimeProcess", "MotionEstimation"], "dynamic_zoom": ["DynamicZoomEnable", "DynamicZoomMode", "DynamicZoomEase"], "scaling": ["Distortion", "Scaling", "ResizeFilter"], "stabilization": ["StabilizationEnable", "StabilizationMethod", "StabilizationStrength"], } _DUPLICATE_KEYFRAME_PROPERTIES = [] for _group_keys in _DUPLICATE_COPY_PROPERTY_KEYS.values(): for _key in _group_keys: if _key not in _DUPLICATE_KEYFRAME_PROPERTIES: _DUPLICATE_KEYFRAME_PROPERTIES.append(_key) _DUPLICATE_COPY_ALL = [ "transform", "crop", "composite", "audio", "retime", "dynamic_zoom", "scaling", "stabilization", "clip_color", "markers", "flags", "enabled", "cache", "voice_isolation", "fusion", "grades", "takes", "keyframes", ] def _normalize_duplicate_placement(raw, has_offset: bool): placement = str(raw or ("offset" if has_offset else "same_time")).strip().lower() if placement not in _DUPLICATE_PLACEMENTS: return None, _err(f"placement must be one of: {', '.join(sorted(_DUPLICATE_PLACEMENTS))}") return placement, None def _normalize_copy_properties(raw): if raw in (None, False, ""): return [], None if raw is True: return list(_DUPLICATE_COPY_ALL), None if isinstance(raw, str): if raw.strip().lower() in {"basic", "all", "all_supported", "supported"}: return list(_DUPLICATE_COPY_ALL), None raw_items = [part.strip() for part in raw.split(",") if part.strip()] elif isinstance(raw, list): raw_items = raw else: return None, _err("copy_properties must be a list, comma-separated string, boolean, or omitted") groups = [] for item in raw_items: key = str(item).strip().lower() normalized = _DUPLICATE_COPY_GROUP_ALIASES.get(key) if not normalized: return None, _err( "copy_properties entries must be one of: " f"{', '.join(sorted(_DUPLICATE_COPY_GROUP_ALIASES))}" ) if normalized == "all": for group in _DUPLICATE_COPY_ALL: if group not in groups: groups.append(group) continue if normalized not in groups: groups.append(normalized) return groups, None def _coerce_duplicate_int(raw, name: str): try: return int(raw), None except (TypeError, ValueError): return None, _err(f"{name} must be an integer") def _resolve_duplicate_track_index(src_track: int, placement: str, p: Dict[str, Any], track_type: str = "video"): if track_type == "audio": target_raw = p.get("target_audio_track_index", p.get("targetAudioTrackIndex")) if target_raw is not None: return _coerce_duplicate_int(target_raw, "target_audio_track_index") track_offset_raw = p.get("audio_track_offset", p.get("audioTrackOffset", 0)) track_offset, offset_err = _coerce_duplicate_int(track_offset_raw, "audio_track_offset") if offset_err: return None, offset_err return src_track + track_offset, None target_raw = p.get("target_track_index", p.get("targetTrackIndex")) if target_raw is not None: return _coerce_duplicate_int(target_raw, "target_track_index") track_offset_raw = p.get("track_offset", p.get("trackOffset")) if track_offset_raw is None: track_offset = 1 if placement == "track_above" else 0 else: track_offset, offset_err = _coerce_duplicate_int(track_offset_raw, "track_offset") if offset_err: return None, offset_err return src_track + track_offset, None def _track_items_sorted(tl, track_type: str, track_index: int): try: items = tl.GetItemListInTrack(track_type, track_index) or [] except Exception: return [] sortable = [] for item in items: try: start = _frame_int(item.GetStart()) end = _frame_int(item.GetEnd()) except Exception: continue if start is None or end is None: continue sortable.append((start, end, item)) sortable.sort(key=lambda row: (row[0], row[1])) return sortable def _find_next_gap_record_frame( tl, *, track_type: str, track_index: int, duration: int, search_start: int, exclude_item_id: Optional[str] = None, ): cursor = int(search_start) for start, end, item in _track_items_sorted(tl, track_type, track_index): item_id = _safe_timeline_item_id(item) if exclude_item_id and item_id == exclude_item_id: continue if end <= cursor: continue if start >= cursor + duration: return cursor cursor = max(cursor, end) return cursor def _resolve_duplicate_record_frame( tl, item, placement: str, offset: int, p: Dict[str, Any], dest_track: int, track_type: str = "video", ): source = _timeline_item_summary(item) if not source or source["start"] is None or source["duration"] is None: return None, _err("could not resolve source timeline position") explicit_record = p.get("record_frame", p.get("recordFrame")) if explicit_record is not None: return _coerce_duplicate_int(explicit_record, "record_frame") if placement == "at_playhead": frame, frame_err = _current_timeline_frame_id(tl) if frame_err: return None, frame_err return frame + offset, None if placement == "after_source": return int(source["start"]) + int(source["duration"]) + offset, None if placement == "next_gap": search_start = int(source["start"]) + int(source["duration"]) + offset return _find_next_gap_record_frame( tl, track_type=track_type, track_index=dest_track, duration=int(source["duration"]), search_start=search_start, exclude_item_id=source["timeline_item_id"], ), None return int(source["start"]) + offset, None def _coerce_item_list(value): if value is None: return [] if isinstance(value, dict): iterable = value.values() elif isinstance(value, (list, tuple, set)): iterable = value else: iterable = [value] return [item for item in iterable if item] def _get_selected_timeline_items(tl): warnings = [] for method_name in ("GetSelectedTimelineItems", "GetSelectedItems", "GetSelectedClips"): method = getattr(tl, method_name, None) if not callable(method): continue try: items = _coerce_item_list(method()) except Exception as exc: warnings.append(f"{method_name} failed: {exc}") continue if items: return items, warnings current_video = getattr(tl, "GetCurrentVideoItem", None) if callable(current_video): try: item = current_video() except Exception as exc: return [], warnings + [f"GetCurrentVideoItem failed: {exc}"] if item: return [item], warnings + [ "Timeline selection API is unavailable; used current video item as selected source" ] return [], warnings def _copy_property_group(source_item, duplicate_item, keys: List[str]): details = {} for key in keys: try: value = source_item.GetProperty(key) except Exception as exc: details[key] = {"success": False, "error": f"GetProperty failed: {exc}"} continue if value is None: details[key] = {"success": True, "copied": False, "reason": "source value is unavailable"} continue try: details[key] = bool(duplicate_item.SetProperty(key, value)) except Exception as exc: details[key] = {"success": False, "error": f"SetProperty failed: {exc}"} success = all(value is True or (isinstance(value, dict) and value.get("success")) for value in details.values()) return {"success": success, "details": details} def _copy_clip_color(source_item, duplicate_item): try: color = source_item.GetClipColor() except Exception as exc: return {"success": False, "error": f"GetClipColor failed: {exc}"} if not color: return {"success": True, "color": None} try: set_ok = bool(duplicate_item.SetClipColor(color)) except Exception as exc: return {"success": False, "color": color, "error": f"SetClipColor failed: {exc}"} if not set_ok: return {"success": False, "color": color, "error": "SetClipColor returned false"} try: actual = duplicate_item.GetClipColor() except Exception: actual = color if actual != color: return { "success": False, "color": color, "actual": actual, "error": "SetClipColor did not persist the requested color", } return {"success": True, "color": color} def _copy_enabled_state(source_item, duplicate_item): try: enabled = bool(source_item.GetClipEnabled()) except Exception as exc: return {"success": False, "error": f"GetClipEnabled failed: {exc}"} try: return {"success": bool(duplicate_item.SetClipEnabled(enabled)), "enabled": enabled} except Exception as exc: return {"success": False, "enabled": enabled, "error": f"SetClipEnabled failed: {exc}"} def _marker_value(marker: Dict[str, Any], *keys, default=None): for key in keys: if key in marker: return marker[key] return default def _copy_timeline_item_markers(source_item, duplicate_item): try: markers = source_item.GetMarkers() or {} except Exception as exc: return {"success": False, "error": f"GetMarkers failed: {exc}"} copied = 0 failed = [] for frame, marker in markers.items(): if not isinstance(marker, dict): failed.append({"frame": frame, "error": "marker payload is not an object"}) continue frame_id = _frame_int(frame) if frame_id is None: failed.append({"frame": frame, "error": "marker frame is not numeric"}) continue result = _add_marker( duplicate_item, { "frame": frame_id, "color": _marker_value(marker, "color", "Color", default="Blue"), "name": _marker_value(marker, "name", "Name", default="Marker"), "note": _marker_value(marker, "note", "Note", default=""), "duration": _frame_int(_marker_value(marker, "duration", "Duration", default=1)) or 1, "custom_data": str(_marker_value(marker, "customData", "custom_data", "CustomData", default="") or ""), }, ) if result.get("success"): copied += 1 else: failed.append({"frame": frame_id, "error": result.get("error") or result.get("reason") or "AddMarker failed"}) return {"success": not failed, "copied": copied, "failed": failed} def _copy_flags(source_item, duplicate_item): try: flags = source_item.GetFlagList() or [] except Exception as exc: return {"success": False, "error": f"GetFlagList failed: {exc}"} results = {} for color in flags: try: results[str(color)] = bool(duplicate_item.AddFlag(color)) except Exception as exc: results[str(color)] = {"success": False, "error": f"AddFlag failed: {exc}"} return { "success": all(value is True for value in results.values()), "flags": list(flags), "details": results, } def _copy_cache_state(source_item, duplicate_item): results = {} pairs = [ ("color", "GetIsColorOutputCacheEnabled", "SetColorOutputCache"), ("fusion", "GetIsFusionOutputCacheEnabled", "SetFusionOutputCache"), ] for label, getter_name, setter_name in pairs: getter = getattr(source_item, getter_name, None) setter = getattr(duplicate_item, setter_name, None) if not callable(getter) or not callable(setter): results[label] = {"success": True, "copied": False, "reason": "cache API unavailable"} continue try: value = getter() except Exception as exc: results[label] = {"success": False, "error": f"{getter_name} failed: {exc}"} continue try: results[label] = {"success": bool(setter(value)), "value": value} except Exception as exc: results[label] = {"success": False, "value": value, "error": f"{setter_name} failed: {exc}"} return {"success": all(v.get("success") for v in results.values()), "details": results} def _copy_voice_isolation(source_item, duplicate_item): getter = getattr(source_item, "GetVoiceIsolationState", None) setter = getattr(duplicate_item, "SetVoiceIsolationState", None) if not callable(getter) or not callable(setter): return {"success": True, "copied": False, "reason": "voice isolation API unavailable"} try: state = getter() except Exception as exc: return {"success": False, "error": f"GetVoiceIsolationState failed: {exc}"} if not state: state = {"isEnabled": False, "amount": 0} try: return {"success": bool(setter(state)), "state": _ser(state)} except Exception as exc: return {"success": False, "state": _ser(state), "error": f"SetVoiceIsolationState failed: {exc}"} def _copy_fusion_comps(source_item, duplicate_item): try: count = int(source_item.GetFusionCompCount() or 0) except Exception as exc: return {"success": False, "error": f"GetFusionCompCount failed: {exc}"} if count <= 0: return {"success": True, "copied": 0} copied = 0 failed = [] with tempfile.TemporaryDirectory(prefix="mcp_fusion_copy_") as tmp_dir: for index in range(1, count + 1): path = os.path.join(tmp_dir, f"fusion_comp_{index}.setting") try: exported = bool(source_item.ExportFusionComp(path, index)) except Exception as exc: failed.append({"index": index, "error": f"ExportFusionComp failed: {exc}"}) continue if not exported: failed.append({"index": index, "error": "ExportFusionComp returned false"}) continue try: imported = duplicate_item.ImportFusionComp(path) except Exception as exc: failed.append({"index": index, "error": f"ImportFusionComp failed: {exc}"}) continue if imported: copied += 1 else: failed.append({"index": index, "error": "ImportFusionComp returned no composition"}) return {"success": not failed, "copied": copied, "failed": failed} def _copy_grades(source_item, duplicate_item): try: return {"success": bool(source_item.CopyGrades([duplicate_item]))} except Exception as exc: return {"success": False, "error": f"CopyGrades failed: {exc}"} def _copy_takes(source_item, duplicate_item): try: count = int(source_item.GetTakesCount() or 0) except Exception as exc: return {"success": False, "error": f"GetTakesCount failed: {exc}"} if count <= 0: return {"success": True, "copied": 0} copied = 0 failed = [] for index in range(1, count + 1): try: take = source_item.GetTakeByIndex(index) except Exception as exc: failed.append({"index": index, "error": f"GetTakeByIndex failed: {exc}"}) continue if not isinstance(take, dict): failed.append({"index": index, "error": "take payload is not an object"}) continue clip = take.get("mediaPoolItem") if not clip: failed.append({"index": index, "error": "take has no mediaPoolItem"}) continue try: added = bool(duplicate_item.AddTake(clip, take.get("startFrame", 0), take.get("endFrame", 0))) except Exception as exc: failed.append({"index": index, "error": f"AddTake failed: {exc}"}) continue if added: copied += 1 else: failed.append({"index": index, "error": "AddTake returned false"}) try: selected = int(source_item.GetSelectedTakeIndex() or 0) if selected > 0: duplicate_item.SelectTakeByIndex(selected) except Exception: pass return {"success": not failed, "copied": copied, "failed": failed} def _copy_keyframes(source_item, duplicate_item, properties: Optional[List[str]] = None): properties = properties or list(_DUPLICATE_KEYFRAME_PROPERTIES) copied = 0 failed = [] unavailable = [] for prop in properties: try: count = int(source_item.GetKeyframeCount(prop) or 0) except Exception as exc: unavailable.append({"property": prop, "error": f"GetKeyframeCount failed: {exc}"}) continue for index in range(count): try: keyframe = source_item.GetKeyframeAtIndex(prop, index) frame = keyframe.get("frame") if isinstance(keyframe, dict) else keyframe value = source_item.GetPropertyAtKeyframeIndex(prop, index) except Exception as exc: failed.append({"property": prop, "index": index, "error": f"read keyframe failed: {exc}"}) continue try: added = bool(duplicate_item.AddKeyframe(prop, frame, value)) except Exception as exc: failed.append({"property": prop, "frame": frame, "error": f"AddKeyframe failed: {exc}"}) continue if added: copied += 1 else: failed.append({"property": prop, "frame": frame, "error": "AddKeyframe returned false"}) return {"success": not failed, "copied": copied, "failed": failed, "unavailable": unavailable} def _copy_duplicate_item_state(source_item, duplicate_item, groups: List[str]): results = {} copy_order = [ "transform", "crop", "composite", "audio", "retime", "dynamic_zoom", "scaling", "stabilization", "cache", "voice_isolation", "fusion", "grades", "takes", "keyframes", "transitions", "clip_color", "markers", "flags", "enabled", ] ordered_groups = [group for group in copy_order if group in groups] ordered_groups.extend(group for group in groups if group not in ordered_groups) for group in ordered_groups: if group in _DUPLICATE_COPY_PROPERTY_KEYS: results[group] = _copy_property_group(source_item, duplicate_item, _DUPLICATE_COPY_PROPERTY_KEYS[group]) elif group == "clip_color": results[group] = _copy_clip_color(source_item, duplicate_item) elif group == "enabled": results[group] = _copy_enabled_state(source_item, duplicate_item) elif group == "markers": results[group] = _copy_timeline_item_markers(source_item, duplicate_item) elif group == "flags": results[group] = _copy_flags(source_item, duplicate_item) elif group == "cache": results[group] = _copy_cache_state(source_item, duplicate_item) elif group == "voice_isolation": results[group] = _copy_voice_isolation(source_item, duplicate_item) elif group == "fusion": results[group] = _copy_fusion_comps(source_item, duplicate_item) elif group == "grades": results[group] = _copy_grades(source_item, duplicate_item) elif group == "takes": results[group] = _copy_takes(source_item, duplicate_item) elif group == "keyframes": results[group] = _copy_keyframes(source_item, duplicate_item) elif group == "transitions": results[group] = { "success": True, "copied": False, "reason": "Resolve's public scripting API does not expose timeline item transition cloning", } return results def _timeline_media_type(track_type: str): if track_type == "video": return 1 if track_type == "audio": return 2 return None def _timeline_track_count(tl, track_type: str): try: return int(tl.GetTrackCount(track_type) or 0) except Exception: return 0 def _timeline_item_ids(items): ids = [] for item in items: item_id = _safe_timeline_item_id(item) if item_id: ids.append(item_id) return ids def _timeline_items_by_ids(tl, ids, track_types=("video", "audio", "subtitle")): ids_set = {str(item_id) for item_id in ids if item_id is not None} found = [] if not ids_set: return found for track_type in track_types: for track_index in range(1, _timeline_track_count(tl, track_type) + 1): for item in (tl.GetItemListInTrack(track_type, track_index) or []): if _safe_timeline_item_id(item) in ids_set: found.append(item) return found def _normalize_include_linked(raw): if raw in (None, False, "", []): return set() if raw is True: return {"audio"} if isinstance(raw, str): lowered = raw.strip().lower() if lowered in {"all", "true", "yes"}: return {"video", "audio"} return {part.strip().lower() for part in lowered.split(",") if part.strip()} if isinstance(raw, list): return {str(part).strip().lower() for part in raw if str(part).strip()} return {"audio"} def _linked_items_for_duplicate(item, include_types): if not include_types: return [], [] linked_method = getattr(item, "GetLinkedItems", None) if not callable(linked_method): return [], ["GetLinkedItems API unavailable; linked duplication skipped"] try: linked = linked_method() or [] except Exception as exc: return [], [f"GetLinkedItems failed: {exc}"] source_id = _safe_timeline_item_id(item) out = [] warnings = [] seen = set() for linked_item in linked: linked_id = _safe_timeline_item_id(linked_item) if linked_id and linked_id == source_id: continue if linked_id and linked_id in seen: continue track_info, track_err = _timeline_item_track_info(linked_item) if track_err: warnings.append(f"Linked item {linked_id or ''}: {track_err.get('error', track_err)}") continue track_type, _ = track_info if track_type not in include_types: continue if _timeline_media_type(track_type) is None: warnings.append(f"Linked item {linked_id or ''}: unsupported track type {track_type!r}") continue out.append(linked_item) if linked_id: seen.add(linked_id) return out, warnings def _append_and_recover_timeline_item( mp, tl, source_item, *, track_type: str, dest_track: int, record_frame: int, copy_properties: List[str], source_timeline_item_id: Optional[str] = None, source_start: Optional[int] = None, source_end: Optional[int] = None, ): media_type = _timeline_media_type(track_type) if media_type is None: return None, None, _err(f"Cannot append unsupported track type {track_type!r}") source_track_info, _ = _timeline_item_track_info(source_item) source_summary = _timeline_item_summary(source_item, track_info=source_track_info) info, ierr = _append_clip_info_from_timeline_item( source_item, dest_track, record_frame=record_frame, media_type=media_type, source_start=source_start, source_end=source_end, ) if ierr: return None, None, ierr try: out = mp.AppendToTimeline([info]) except Exception as exc: return None, None, _err(str(exc)) if not out or len(out) < 1: return None, None, _err("AppendToTimeline returned no item") ser, serr = _serialize_appended_timeline_item(out[0], 0, allow_empty_timeline_item_id=True) if serr: return None, None, serr if not ser.get("timeline_item_id"): recovered = _find_appended_timeline_item_summary( tl, track_type=track_type, target_track_index=dest_track, record_frame=int(info["recordFrame"]), duration=int(info["endFrame"]) - int(info["startFrame"]), source_media_pool_item=info["mediaPoolItem"], source_timeline_item_id=source_timeline_item_id, ) if recovered and recovered.get("timeline_item_id"): ser = recovered duplicate_item = None if ser.get("timeline_item_id"): duplicate_item = _find_timeline_item_by_id(tl, ser["timeline_item_id"]) if duplicate_item is None: out_item_id = _safe_timeline_item_id(out[0]) if out_item_id: duplicate_item = out[0] duplicate_summary = _timeline_item_summary(duplicate_item, track_info=(track_type, dest_track)) if duplicate_item else { "timeline_item_id": ser.get("timeline_item_id"), "name": ser.get("name"), "track_type": track_type, "track_index": dest_track, "start": int(info["recordFrame"]), "end": int(info["recordFrame"]) + int(info["endFrame"]) - int(info["startFrame"]), "duration": int(info["endFrame"]) - int(info["startFrame"]), "source_start": int(info["startFrame"]), "source_end": int(info["endFrame"]), "media_pool_item_id": _safe_media_pool_item_id(info["mediaPoolItem"]), "media_pool_item_name": _safe_media_pool_item_name(info["mediaPoolItem"]), } warnings = [] copied_properties = {} if copy_properties: if duplicate_item is None: warnings.append("Could not reacquire duplicate item; copy_properties were skipped") else: copied_properties = _copy_duplicate_item_state(source_item, duplicate_item, copy_properties) result = { "clip_id": source_timeline_item_id, "source_clip_id": source_timeline_item_id, "success": True, **ser, "source": source_summary, "duplicate": duplicate_summary, } if copied_properties: result["copied_properties"] = copied_properties if warnings: result["warnings"] = warnings return result, duplicate_item, None def _timeline_duplicate_clips_impl(proj, tl, p: Dict[str, Any], *, delete_sources: bool = False): ids = p.get("clip_ids") or p.get("ids") selected = bool(p.get("selected", False)) if ids is not None and not isinstance(ids, list): return _err("duplicate_clips requires clip_ids (list of timeline item unique IDs)") if not ids and not selected: return _err("duplicate_clips requires clip_ids or selected=True") has_offset = "record_frame_offset" in p or "recordFrameOffset" in p placement, placement_err = _normalize_duplicate_placement(p.get("placement"), has_offset) if placement_err: return placement_err copy_properties, copy_err = _normalize_copy_properties(p.get("copy_properties", p.get("copyProperties"))) if copy_err: return copy_err if p.get("copy_keyframes", p.get("copyKeyframes", False)) and "keyframes" not in copy_properties: copy_properties.append("keyframes") try: offset = int(p.get("record_frame_offset", p.get("recordFrameOffset", 0))) except (TypeError, ValueError): return _err("record_frame_offset must be an integer") mp = proj.GetMediaPool() if not mp: return _err("Failed to get MediaPool") source_entries: List[Dict[str, Any]] = [] seen_ids = set() if ids: for cid in ids: sid = str(cid) source_entries.append({"clip_id": sid, "item": _find_timeline_item_by_id(tl, sid)}) seen_ids.add(sid) selection_warnings: List[str] = [] if selected: selected_items, selection_warnings = _get_selected_timeline_items(tl) if not selected_items and not source_entries: return _err("selected=True did not resolve any timeline items") for item in selected_items: sid = _safe_timeline_item_id(item) if not sid: source_entries.append({"clip_id": None, "item": item}) continue if sid in seen_ids: continue source_entries.append({"clip_id": sid, "item": item}) seen_ids.add(sid) include_types = _normalize_include_linked(p.get("include_linked", p.get("includeLinked"))) relink = bool(p.get("relink", p.get("restore_linked", p.get("restoreLinked", bool(include_types))))) results: List[Dict[str, Any]] = [] source_delete_items = [] for entry in source_entries: item = entry.get("item") sid = entry.get("clip_id") or _safe_timeline_item_id(item) if not sid: results.append({"clip_id": None, "success": False, "error": "timeline item has no readable id"}) continue if not item: results.append({"clip_id": sid, "success": False, "error": "timeline item not found"}) continue normalized_track_info, track_err = _timeline_item_track_info(item) if track_err: results.append({"clip_id": sid, "success": False, "error": track_err.get("error", str(track_err))}) continue tt, src_track = normalized_track_info if tt != "video": results.append({ "clip_id": sid, "success": False, "error": f"primary duplicate item must be video (got {tt!r}); use include_linked from a linked video item for audio", }) continue dest_track, dest_err = _resolve_duplicate_track_index(src_track, placement, p, track_type="video") if dest_err: results.append({"clip_id": sid, "success": False, "error": dest_err.get("error", str(dest_err))}) continue if dest_track < 1: results.append({"clip_id": sid, "success": False, "error": "target_track_index must be >= 1"}) continue video_track_count = _timeline_track_count(tl, "video") if video_track_count and dest_track > video_track_count: results.append({ "clip_id": sid, "success": False, "error": f"target video track {dest_track} does not exist", }) continue record_frame, record_err = _resolve_duplicate_record_frame(tl, item, placement, offset, p, dest_track, track_type="video") if record_err: results.append({"clip_id": sid, "success": False, "error": record_err.get("error", str(record_err))}) continue primary_result, primary_duplicate, primary_err = _append_and_recover_timeline_item( mp, tl, item, track_type="video", dest_track=dest_track, record_frame=record_frame, copy_properties=copy_properties, source_timeline_item_id=sid, ) if primary_err: results.append({"clip_id": sid, "success": False, "error": primary_err.get("error", str(primary_err))}) continue primary_result["placement"] = placement source_start = _frame_int(item.GetStart()) base_delta = record_frame - source_start if source_start is not None else offset linked_items, linked_warnings = _linked_items_for_duplicate(item, include_types) linked_results = [] duplicate_link_items = [primary_duplicate] if primary_duplicate else [] original_link_items = [item] for linked_item in linked_items: linked_id = _safe_timeline_item_id(linked_item) linked_track_info, linked_track_err = _timeline_item_track_info(linked_item) if linked_track_err: linked_results.append({ "clip_id": linked_id, "success": False, "error": linked_track_err.get("error", str(linked_track_err)), }) continue linked_track_type, linked_src_track = linked_track_info linked_dest_track, linked_dest_err = _resolve_duplicate_track_index( linked_src_track, placement, p, track_type=linked_track_type, ) if linked_dest_err: linked_results.append({ "clip_id": linked_id, "success": False, "error": linked_dest_err.get("error", str(linked_dest_err)), }) continue track_count = _timeline_track_count(tl, linked_track_type) if linked_dest_track < 1 or (track_count and linked_dest_track > track_count): linked_results.append({ "clip_id": linked_id, "success": False, "error": f"target {linked_track_type} track {linked_dest_track} does not exist", }) continue linked_start = _frame_int(linked_item.GetStart()) if linked_start is None: linked_results.append({"clip_id": linked_id, "success": False, "error": "linked item start is unavailable"}) continue linked_record_frame = linked_start + base_delta linked_result, linked_duplicate, linked_err = _append_and_recover_timeline_item( mp, tl, linked_item, track_type=linked_track_type, dest_track=linked_dest_track, record_frame=linked_record_frame, copy_properties=copy_properties, source_timeline_item_id=linked_id, ) if linked_err: linked_results.append({"clip_id": linked_id, "success": False, "error": linked_err.get("error", str(linked_err))}) continue linked_result["placement"] = placement linked_results.append(linked_result) original_link_items.append(linked_item) if linked_duplicate: duplicate_link_items.append(linked_duplicate) if linked_results: primary_result["linked_results"] = linked_results if linked_warnings: primary_result.setdefault("warnings", []).extend(linked_warnings) if relink and len(duplicate_link_items) > 1: try: primary_result["linked"] = bool(tl.SetClipsLinked(duplicate_link_items, True)) except Exception as exc: primary_result.setdefault("warnings", []).append(f"SetClipsLinked failed: {exc}") if delete_sources: source_delete_items.extend(original_link_items if include_types else [item]) results.append(primary_result) out = {"results": results, "count": len(results), "placement": placement} if selection_warnings: out["warnings"] = selection_warnings if delete_sources: successful_source_ids = { result.get("source_clip_id") for result in results if result.get("success") and result.get("source_clip_id") } delete_items = [] seen_delete_ids = set() for item in source_delete_items: item_id = _safe_timeline_item_id(item) if not item_id or item_id in seen_delete_ids: continue if item_id in successful_source_ids or include_types: delete_items.append(item) seen_delete_ids.add(item_id) if delete_items: try: out["deleted_sources"] = bool(tl.DeleteClips(delete_items, bool(p.get("ripple", False)))) out["deleted_source_ids"] = _timeline_item_ids(delete_items) except Exception as exc: out["deleted_sources"] = False out["delete_error"] = str(exc) else: out["deleted_sources"] = False out["delete_error"] = "No successfully duplicated source items to delete" return out def _range_frames_from_params(tl, p: Dict[str, Any]): if p.get("use_mark_in_out", p.get("useMarkInOut", False)): mark = tl.GetMarkInOut() or {} mark_type = p.get("mark_type", p.get("markType", "video")) if mark_type not in mark: mark_type = "video" if "video" in mark else "audio" if "audio" in mark else None if not mark_type: return None, None, _err("No timeline mark in/out is set") return _frame_int(mark[mark_type].get("in")), _frame_int(mark[mark_type].get("out")), None start = p.get("start_frame", p.get("startFrame")) end = p.get("end_frame", p.get("endFrame")) if start is None or end is None: return None, None, _err("Range actions require start_frame/end_frame or use_mark_in_out=True") start = _frame_int(start) end = _frame_int(end) if start is None or end is None: return None, None, _err("start_frame and end_frame must be numeric") if end <= start: return None, None, _err("end_frame must be greater than start_frame") return start, end, None def _range_track_types(p: Dict[str, Any]): raw = p.get("track_types", p.get("trackTypes", p.get("track_type", p.get("trackType", "video")))) if raw == "all": return ["video", "audio"] if isinstance(raw, str): return [part.strip().lower() for part in raw.split(",") if part.strip()] if isinstance(raw, list): return [str(part).strip().lower() for part in raw if str(part).strip()] return ["video"] def _range_track_indices(p: Dict[str, Any], track_type: str): key = f"{track_type}_track_indices" raw = p.get(key, p.get(f"{track_type}TrackIndices", p.get("track_indices", p.get("trackIndices")))) if raw is None: return None if isinstance(raw, int): return [raw] if isinstance(raw, str): return [int(part.strip()) for part in raw.split(",") if part.strip()] return [int(part) for part in raw] def _collect_timeline_items_in_range(tl, p: Dict[str, Any]): start, end, err = _range_frames_from_params(tl, p) if err: return None, None, None, err items = [] for track_type in _range_track_types(p): if track_type not in {"video", "audio"}: return None, None, None, _err(f"Range actions support video/audio tracks, got {track_type!r}") indices = _range_track_indices(p, track_type) if indices is None: indices = list(range(1, _timeline_track_count(tl, track_type) + 1)) for track_index in indices: for item in (tl.GetItemListInTrack(track_type, track_index) or []): item_start = _frame_int(item.GetStart()) item_end = _frame_int(item.GetEnd()) if item_start is None or item_end is None: continue if item_start < end and item_end > start: items.append((track_type, track_index, item, max(item_start, start), min(item_end, end))) return start, end, items, None def _timeline_copy_range_impl(proj, tl, p: Dict[str, Any], *, overwrite: bool = False): start, end, items, err = _collect_timeline_items_in_range(tl, p) if err: return err if not items: return {"results": [], "count": 0, "range": {"start": start, "end": end}} record_raw = p.get("record_frame", p.get("recordFrame")) if record_raw is None: return _err("copy_range/duplicate_range require record_frame for destination range start") dest_start, dest_err = _coerce_duplicate_int(record_raw, "record_frame") if dest_err: return dest_err copy_properties, copy_err = _normalize_copy_properties(p.get("copy_properties", p.get("copyProperties"))) if copy_err: return copy_err if p.get("copy_keyframes", p.get("copyKeyframes", False)) and "keyframes" not in copy_properties: copy_properties.append("keyframes") mp = proj.GetMediaPool() if not mp: return _err("Failed to get MediaPool") duration = end - start deleted = None if overwrite: dest_end = dest_start + duration delete_targets = [] for track_type, _, _, _, _ in items: for track_index in range(1, _timeline_track_count(tl, track_type) + 1): for existing in (tl.GetItemListInTrack(track_type, track_index) or []): existing_start = _frame_int(existing.GetStart()) existing_end = _frame_int(existing.GetEnd()) if existing_start is None or existing_end is None: continue if existing_start < dest_end and existing_end > dest_start: delete_targets.append(existing) if delete_targets: deleted = bool(tl.DeleteClips(delete_targets, False)) results = [] for track_type, source_track, item, overlap_start, overlap_end in items: media_type = _timeline_media_type(track_type) if media_type is None: results.append({"clip_id": _safe_timeline_item_id(item), "success": False, "error": f"unsupported track type {track_type!r}"}) continue dest_track, track_err = _resolve_duplicate_track_index(source_track, "same_time", p, track_type=track_type) if track_err: results.append({"clip_id": _safe_timeline_item_id(item), "success": False, "error": track_err.get("error", str(track_err))}) continue item_source_start = _timeline_item_source_start(item) item_start = _frame_int(item.GetStart()) if item_source_start is None or item_start is None: results.append({"clip_id": _safe_timeline_item_id(item), "success": False, "error": "could not resolve source trim"}) continue source_start = item_source_start + (overlap_start - item_start) source_end = source_start + (overlap_end - overlap_start) record_frame = dest_start + (overlap_start - start) result, _, append_err = _append_and_recover_timeline_item( mp, tl, item, track_type=track_type, dest_track=dest_track, record_frame=record_frame, copy_properties=copy_properties, source_timeline_item_id=_safe_timeline_item_id(item), source_start=source_start, source_end=source_end, ) if append_err: results.append({"clip_id": _safe_timeline_item_id(item), "success": False, "error": append_err.get("error", str(append_err))}) continue result["range_source"] = {"start": overlap_start, "end": overlap_end} result["range_destination"] = {"start": record_frame, "end": record_frame + (overlap_end - overlap_start)} results.append(result) out = { "results": results, "count": len(results), "range": {"start": start, "end": end}, "destination_range": {"start": dest_start, "end": dest_start + duration}, } if overwrite: out["deleted_destination_overlaps"] = bool(deleted) return out def _timeline_lift_range_impl(tl, p: Dict[str, Any]): start, end, items, err = _collect_timeline_items_in_range(tl, p) if err: return err allow_partial = bool(p.get("allow_partial_item_delete", p.get("allowPartialItemDelete", False))) delete_items = [] blocked = [] for _, _, item, overlap_start, overlap_end in items: item_start = _frame_int(item.GetStart()) item_end = _frame_int(item.GetEnd()) if not allow_partial and (overlap_start != item_start or overlap_end != item_end): blocked.append({ "timeline_item_id": _safe_timeline_item_id(item), "name": _safe_timeline_item_name(item), "item_start": item_start, "item_end": item_end, "overlap_start": overlap_start, "overlap_end": overlap_end, }) continue delete_items.append(item) if blocked: return { "error": "Range partially overlaps timeline items; pass allow_partial_item_delete=True to delete whole overlapping items", "blocked": blocked, } if not delete_items: return {"success": True, "deleted": 0, "range": {"start": start, "end": end}} deleted_ids = _timeline_item_ids(delete_items) return { "success": bool(tl.DeleteClips(delete_items, bool(p.get("ripple", False)))), "deleted": len(delete_items), "deleted_ids": deleted_ids, "range": {"start": start, "end": end}, } def _timeline_edit_kernel_capabilities(): return { "supported": { "clip_duplication": [ "video timeline items with MediaPoolItem", "linked audio duplication from a linked video source", "selected/current video item fallback", "same_time", "offset", "at_playhead", "track_above", "after_source", "next_gap", ], "clip_operations": ["copy_clips", "move_clips"], "range_operations": [ "copy_range", "duplicate_range", "overwrite_range by deleting whole destination overlaps", "lift_range by deleting whole matching items", ], "copy_properties": list(_DUPLICATE_COPY_ALL) + ["transitions"], "read_only_probe": [ "timeline item method availability", "all GetProperty() values exposed by Resolve", "known property-key values", "keyframe counts for known properties", "linked item summaries", ], "source_media_integrity": [ "references original MediaPoolItems", "does not transcode, render, proxy, or create source derivatives", ], }, "partially_supported": { "audio_properties": "Resolve may reject SetProperty on some timeline audio items/builds; failures are reported per property.", "cache": "Color/Fusion cache state is copied only when Resolve exposes readable/writable cache APIs for the item.", "voice_isolation": "Copied only when Resolve exposes item-level voice isolation APIs.", "keyframes": "Copies keyframes for supported properties; interpolation readback is not exposed for full fidelity verification.", "dynamic_zoom_scaling_stabilization": "Copied through exposed TimelineItem.GetProperty/SetProperty keys when a Resolve build returns writable values.", }, "unsupported": { "transition_cloning": "Resolve's public scripting API does not expose timeline item transition cloning.", "razor_or_partial_lift": "Resolve's public scripting API does not expose a direct timeline split/razor primitive; partial range edits are represented by append-based copies or whole-item deletes.", "source_less_items": "Titles, generators, Fusion compositions, and subtitles without a MediaPoolItem cannot be cloned through AppendToTimeline clipInfo.", "deep_speed_ramp_semantics": "Only exposed Speed/RetimeProcess/MotionEstimation properties and supported keyframes are copied; opaque retime curves are not independently inspectable.", }, } def _callable_method_names(obj, names: List[str]): out = {} for name in names: out[name] = callable(getattr(obj, name, None)) return out def _safe_get_property(item, key: Optional[str] = None): try: if key is None: return _ser(item.GetProperty()), None return _ser(item.GetProperty(key)), None except Exception as exc: return None, str(exc) def _probe_keyframes(item, properties: List[str]): out = {} for prop in properties: try: count = int(item.GetKeyframeCount(prop) or 0) except Exception as exc: out[prop] = {"available": False, "error": str(exc)} continue frames = [] for index in range(count): try: keyframe = item.GetKeyframeAtIndex(prop, index) value = item.GetPropertyAtKeyframeIndex(prop, index) frames.append({"keyframe": _ser(keyframe), "value": _ser(value)}) except Exception as exc: frames.append({"error": str(exc)}) out[prop] = {"available": True, "count": count, "frames": frames} return out def _timeline_item_probe(item): known_property_keys = [] for keys in _DUPLICATE_COPY_PROPERTY_KEYS.values(): for key in keys: if key not in known_property_keys: known_property_keys.append(key) all_properties, all_properties_error = _safe_get_property(item) known_properties = {} for key in known_property_keys: value, err = _safe_get_property(item, key) known_properties[key] = {"value": value, "error": err} if err else {"value": value} linked = [] get_linked = getattr(item, "GetLinkedItems", None) if callable(get_linked): try: linked = [_timeline_item_summary(linked_item) for linked_item in (get_linked() or [])] except Exception as exc: linked = [{"error": str(exc)}] method_names = [ "GetMediaPoolItem", "GetLinkedItems", "GetProperty", "SetProperty", "GetKeyframeCount", "GetKeyframeAtIndex", "GetPropertyAtKeyframeIndex", "AddKeyframe", "SetKeyframeInterpolation", "GetFusionCompCount", "ExportFusionComp", "ImportFusionComp", "CopyGrades", "GetTakesCount", "AddTake", "GetVoiceIsolationState", "SetVoiceIsolationState", "GetClipColor", "SetClipColor", ] return { "summary": _timeline_item_summary(item), "methods": _callable_method_names(item, method_names), "all_properties": all_properties, "all_properties_error": all_properties_error, "known_properties": known_properties, "keyframes": _probe_keyframes(item, known_property_keys), "linked_items": linked, } def _timeline_probe_edit_kernel_item(tl, p: Dict[str, Any]): ids = p.get("clip_ids") or p.get("ids") selected = bool(p.get("selected", False)) items = [] if ids: if not isinstance(ids, list): return _err("probe_edit_kernel_item requires clip_ids as a list") for item_id in ids: item = _find_timeline_item_by_id(tl, item_id) if item: items.append(item) if selected: selected_items, warnings = _get_selected_timeline_items(tl) items.extend(selected_items) else: warnings = [] if not items and p.get("timeline_item"): _, item, item_err = _get_item(p["timeline_item"]) if item_err: return item_err items.append(item) if not items: return _err("probe_edit_kernel_item requires clip_ids, selected=True, or timeline_item scope") probes = [_timeline_item_probe(item) for item in items] out = {"items": probes, "count": len(probes)} if warnings: out["warnings"] = warnings return out def _timeline_resolve_item_optional(tl, p: Dict[str, Any]): """Resolve a single timeline item from clip_id, timeline_item_id, or timeline_item scope.""" item_id = p.get("clip_id") or p.get("timeline_item_id") if item_id: item = _find_timeline_item_by_id(tl, item_id) if not item: return None, _err(f"No timeline item with clip_id/timeline_item_id={item_id!r}") return item, None if p.get("timeline_item"): _, item, item_err = _get_item(p["timeline_item"]) if item_err: return None, item_err return item, None return None, _err("requires clip_id, timeline_item_id, or timeline_item={track_type, track_index, item_index}") def _timeline_title_property_scan(tl, p: Dict[str, Any]): item, err = _timeline_resolve_item_optional(tl, p) if err: return err flat, exc_text = _timeline_item_get_property_map(item, _ser) if exc_text and not flat: return _err(f"GetProperty failed: {exc_text}") fusion_count = None try: fusion_count = int(item.GetFusionCompCount() or 0) except Exception: pass return { "summary": _timeline_item_summary(item), "fusion_comp_count": fusion_count, "properties": flat, "text_key_candidates": _candidate_title_property_keys(flat), "note": ( "Generator / Text+ fields are undocumented in the public Scripting API; " "run this scan, inspect `text_key_candidates`, then call set_title_text with `property_key`." ), } def _timeline_set_title_text(tl, p: Dict[str, Any]) -> Dict[str, Any]: item, err = _timeline_resolve_item_optional(tl, p) if err: return err text = p.get("text") if text is None or not isinstance(text, str): return _err("set_title_text requires params.text (string)") property_key = p.get("property_key") or p.get("key") as_styled_xml = bool(p.get("as_styled_xml", p.get("styled", False))) try_plain_first = bool(p.get("try_plain_first", True)) readback = bool(p.get("readback", False)) try_heuristic_keys = bool(p.get("try_heuristic_keys", not bool(property_key))) if as_styled_xml: payload_modes = [(text, "as_given")] elif try_plain_first: payload_modes = [(text, "plain"), (_plain_to_minimal_styled_xml(text), "minimal_xml")] else: payload_modes = [(_plain_to_minimal_styled_xml(text), "minimal_xml"), (text, "plain")] keys: List[str] = [] if property_key: keys.append(str(property_key)) if try_heuristic_keys: flat, exc_text = _timeline_item_get_property_map(item, _ser) if exc_text and not flat: return _err(f"GetProperty failed: {exc_text}") for row in _candidate_title_property_keys(flat): k = row["key"] if k not in keys: keys.append(k) if not keys: keys = ["Styled Text", "StyledText", "Text", "Rich Text"] attempts: List[Dict[str, Any]] = [] for key in keys: for payload, mode in payload_modes: rec: Dict[str, Any] = {"property_key": key, "mode": mode} try: ok = bool(item.SetProperty(key, payload)) except Exception as exc: rec["success"] = False rec["error"] = str(exc) attempts.append(rec) continue rec["success"] = ok if readback and ok: try: rec["readback"] = item.GetProperty(key) except Exception as exc: rec["readback_error"] = str(exc) attempts.append(rec) if ok: return { "success": True, "timeline_item_id": _safe_timeline_item_id(item), "property_key": key, "mode": mode, "attempts": attempts, } return { "success": False, "error": "SetProperty did not succeed; run title_property_scan, copy a real key from `properties`, " "and pass `property_key` (see `attempts` for diagnostics).", "attempts": attempts, } def _timeline_bulk_set_title_text(tl, p: Dict[str, Any]) -> Dict[str, Any]: ops = p.get("ops") if not isinstance(ops, list) or not ops: return _err("bulk_set_title_text requires params.ops: non-empty list") results: List[Dict[str, Any]] = [] for index, op in enumerate(ops): if not isinstance(op, dict): results.append({"index": index, "success": False, "error": "op must be an object"}) continue merged = {**p, **op} merged.pop("ops", None) out = _timeline_set_title_text(tl, merged) out["index"] = index results.append(out) return {"results": results, "op_count": len(ops)} _TIMELINE_CONFORM_KERNEL_ACTIONS = [ "conform_capabilities", "probe_timeline_structure", "detect_gaps_overlaps", "source_range_report", "export_timeline_checked", "import_timeline_checked", "compare_timelines", "probe_interchange_roundtrip", "detect_missing_media", "build_relink_plan", "conform_boundary_report", ] _TIMELINE_EXPORT_ALIASES = { "aaf": ("EXPORT_AAF", "EXPORT_AAF_NEW", ".aaf"), "drt": ("EXPORT_DRT", "EXPORT_NONE", ".drt"), "edl": ("EXPORT_EDL", "EXPORT_NONE", ".edl"), "edl_cdl": ("EXPORT_EDL", "EXPORT_CDL", ".edl"), "edl_sdl": ("EXPORT_EDL", "EXPORT_SDL", ".edl"), "edl_missing_clips": ("EXPORT_EDL", "EXPORT_MISSING_CLIPS", ".edl"), "fcp7xml": ("EXPORT_FCP_7_XML", "EXPORT_NONE", ".xml"), "fcpxml": ("EXPORT_FCPXML_1_10", "EXPORT_NONE", ".fcpxml"), "fcpxml_1_8": ("EXPORT_FCPXML_1_8", "EXPORT_NONE", ".fcpxml"), "fcpxml_1_9": ("EXPORT_FCPXML_1_9", "EXPORT_NONE", ".fcpxml"), "fcpxml_1_10": ("EXPORT_FCPXML_1_10", "EXPORT_NONE", ".fcpxml"), "otio": ("EXPORT_OTIO", "EXPORT_NONE", ".otio"), } def _conform_capabilities(): return { "supported": { "timeline_structure": [ "timeline identity, frame bounds, start timecode, and track counts", "per-track item summaries across video, audio, and subtitle tracks", "timeline marker snapshot", "source MediaPoolItem identity and file path when Resolve exposes it", ], "analysis": [ "same-track gap detection", "same-track overlap detection", "source range summaries grouped by MediaPoolItem", "missing-media detection from file path existence and status metadata", "timeline snapshot comparison by track and item order", ], "interchange": [ "guarded timeline export to temp paths", "guarded timeline import from temp paths", "round-trip export/import/compare probe", "FCPXML, DRT, EDL, AAF, OTIO, and FCP7 XML aliases when Resolve exposes the constants", ], "relink_planning": [ "read-only search-root scan by missing file basename", "plan output that can be reviewed before media_pool.safe_relink executes", ], "source_media_integrity": [ "export/import probes write interchange files only under temp paths by default", "missing-media and relink-plan helpers never transcode, proxy, or alter source media", ], }, "partially_supported": { "interchange_roundtrip": "Export/import survival varies by format, Resolve build, timeline contents, and installed codecs.", "timeline_item_semantics": "Generators, titles, compound clips, transitions, effects, Fusion comps, and grades may not survive every interchange format.", "missing_media_status": "Resolve status fields vary; the kernel combines status text with local file existence when a file path is readable.", }, "unsupported": { "semantic_conform_decisions": "The public API does not decide creative conform intent; it can expose differences and relink candidates.", "transition_roundtrip_guarantee": "Transition internals are not fully inspectable through the public timeline item API.", "automatic_user_media_relink": "Relinking user media is not automatic. Plans are read-only unless the caller explicitly uses the existing safe relink API.", }, "export_aliases": { name: {"type": values[0], "subtype": values[1], "extension": values[2]} for name, values in _TIMELINE_EXPORT_ALIASES.items() }, } def _timeline_item_conform_summary(item, track_type: str, track_index: int, item_index: int): summary = _timeline_item_summary(item, (track_type, track_index)) or {} summary["item_index"] = item_index media_pool_item = _timeline_item_media_pool_item(item) file_path = None clip_properties = None media_status = None if media_pool_item: try: clip_properties = _ser(media_pool_item.GetClipProperty("")) except Exception: clip_properties = None if isinstance(clip_properties, dict): file_path = clip_properties.get("File Path") or clip_properties.get("FilePath") for key in ("Status", "Media Status", "Offline", "Online Status"): if key in clip_properties: media_status = clip_properties.get(key) break summary["file_path"] = file_path summary["file_exists"] = bool(file_path and os.path.exists(str(file_path))) summary["media_status"] = media_status if clip_properties is not None: summary["clip_properties"] = clip_properties return summary def _timeline_conform_snapshot(tl, p: Optional[Dict[str, Any]] = None): p = p or {} include_markers = bool(p.get("include_markers", True)) include_clip_properties = bool(p.get("include_clip_properties", False)) track_types = p.get("track_types") or ["video", "audio", "subtitle"] if not isinstance(track_types, list): return _err("track_types must be a list") tracks = {} item_count = 0 for track_type in track_types: try: track_count = int(tl.GetTrackCount(track_type) or 0) except Exception: track_count = 0 track_rows = [] for track_index in range(1, track_count + 1): items = [] for item_index, item in enumerate(tl.GetItemListInTrack(track_type, track_index) or []): summary = _timeline_item_conform_summary(item, track_type, track_index, item_index) if not include_clip_properties: summary.pop("clip_properties", None) items.append(summary) item_count += len(items) track_rows.append({ "track_index": track_index, "item_count": len(items), "items": items, }) tracks[track_type] = {"track_count": track_count, "tracks": track_rows} markers = {} if include_markers and _has_method(tl, "GetMarkers"): try: markers = _ser(tl.GetMarkers() or {}) except Exception as exc: markers = {"error": str(exc)} return { "name": tl.GetName() or "", "id": tl.GetUniqueId(), "start_frame": tl.GetStartFrame(), "end_frame": tl.GetEndFrame(), "start_timecode": tl.GetStartTimecode(), "item_count": item_count, "tracks": tracks, "markers": markers, } def _detect_gaps_overlaps_from_snapshot(snapshot: Dict[str, Any], p: Optional[Dict[str, Any]] = None): p = p or {} track_types = p.get("track_types") or ["video", "audio"] min_gap = int(p.get("min_gap", 1)) gaps = [] overlaps = [] tracks = snapshot.get("tracks", {}) for track_type in track_types: for track in (tracks.get(track_type, {}) or {}).get("tracks", []): items = sorted( [item for item in track.get("items", []) if item.get("start") is not None and item.get("end") is not None], key=lambda row: (row.get("start"), row.get("end")), ) for prev, curr in zip(items, items[1:]): prev_end = int(prev["end"]) curr_start = int(curr["start"]) if curr_start - prev_end >= min_gap: gaps.append({ "track_type": track_type, "track_index": track.get("track_index"), "start": prev_end, "end": curr_start, "duration": curr_start - prev_end, "after": prev.get("timeline_item_id"), "before": curr.get("timeline_item_id"), }) elif curr_start < prev_end: overlaps.append({ "track_type": track_type, "track_index": track.get("track_index"), "start": curr_start, "end": prev_end, "duration": prev_end - curr_start, "left": prev.get("timeline_item_id"), "right": curr.get("timeline_item_id"), }) return {"gaps": gaps, "overlaps": overlaps, "gap_count": len(gaps), "overlap_count": len(overlaps)} def _source_ranges_from_snapshot(snapshot: Dict[str, Any], p: Optional[Dict[str, Any]] = None): p = p or {} handles = int(p.get("handles", 0)) merge = bool(p.get("merge", True)) ranges: Dict[str, List[List[int]]] = {} occurrences = [] for track_type, type_payload in (snapshot.get("tracks") or {}).items(): if track_type == "subtitle": continue for track in type_payload.get("tracks", []): for item in track.get("items", []): source_start = item.get("source_start") source_end = item.get("source_end") if source_start is None or source_end is None: continue key = item.get("file_path") or item.get("media_pool_item_name") or item.get("name") or "unknown" start = max(0, int(source_start) - handles) end = int(source_end) + handles ranges.setdefault(key, []).append([start, end]) occurrences.append({ "key": key, "track_type": track_type, "track_index": track.get("track_index"), "timeline_item_id": item.get("timeline_item_id"), "timeline_range": [item.get("start"), item.get("end")], "source_range": [start, end], }) if merge: for key, key_ranges in list(ranges.items()): ordered = sorted(key_ranges) merged = [] for start, end in ordered: if not merged or start > merged[-1][1]: merged.append([start, end]) else: merged[-1][1] = max(merged[-1][1], end) ranges[key] = merged return {"ranges": ranges, "occurrences": occurrences, "handles": handles, "merged": merge} def _timeline_marker_rows_from_snapshot(snapshot: Dict[str, Any]) -> List[Dict[str, Any]]: markers = snapshot.get("markers") or {} rows = [] if not isinstance(markers, dict): return rows for frame, marker in markers.items(): if not isinstance(marker, dict): continue frame_id = _frame_int(frame) rows.append({ "frame": frame_id, "color": _marker_value(marker, "color", "Color"), "name": _marker_value(marker, "name", "Name", default="Marker"), "note": _marker_value(marker, "note", "Note", default=""), "duration": _marker_value(marker, "duration", "Duration", default=1), "custom_data": _marker_value(marker, "customData", "custom_data", "CustomData", default=""), }) rows.sort(key=lambda row: (row["frame"] is None, row["frame"] or 0)) return rows def _story_spine_from_snapshot(snapshot: Dict[str, Any]) -> Dict[str, Any]: markers = _timeline_marker_rows_from_snapshot(snapshot) tracks = snapshot.get("tracks") or {} track_summaries = [] for track_type in ("video", "audio", "subtitle"): for track in ((tracks.get(track_type) or {}).get("tracks") or []): items = track.get("items") or [] if not items: continue starts = [item.get("start") for item in items if item.get("start") is not None] ends = [item.get("end") for item in items if item.get("end") is not None] track_summaries.append({ "track_type": track_type, "track_index": track.get("track_index"), "item_count": len(items), "first_frame": min(starts) if starts else None, "last_frame": max(ends) if ends else None, "items": [ { "timeline_item_id": item.get("timeline_item_id"), "name": item.get("name"), "start": item.get("start"), "end": item.get("end"), "source_range": [item.get("source_start"), item.get("source_end")], "media_pool_item_name": item.get("media_pool_item_name"), } for item in items ], }) named_beats = [ { "frame": marker.get("frame"), "name": marker.get("name"), "note": marker.get("note"), "color": marker.get("color"), } for marker in markers ] audio_items = sum(row["item_count"] for row in track_summaries if row["track_type"] == "audio") video_items = sum(row["item_count"] for row in track_summaries if row["track_type"] == "video") return { "timeline": { "name": snapshot.get("name"), "id": snapshot.get("id"), "start_frame": snapshot.get("start_frame"), "end_frame": snapshot.get("end_frame"), "start_timecode": snapshot.get("start_timecode"), }, "marker_count": len(markers), "beats": named_beats, "track_summaries": track_summaries, "source_ranges": _source_ranges_from_snapshot(snapshot, {"handles": 0, "merge": True}), "audio_spine": { "present": audio_items > 0, "audio_item_count": audio_items, "video_item_count": video_items, "marker_guided": len(markers) > 0, }, "editorial_notes": [ "Use marker beats as intent, not proof; verify important beats with Resolve-rendered thumbnails.", "For short-form variants, preserve a clear audio spine before adding visual variety.", "Run detect_gaps_overlaps after creating or changing a variant.", ], } def _timeline_story_spine_report(tl, p: Dict[str, Any]) -> Dict[str, Any]: snapshot = _timeline_conform_snapshot(tl, { "track_types": p.get("track_types") or ["video", "audio", "subtitle"], "include_markers": True, "include_clip_properties": bool(p.get("include_clip_properties", False)), }) if isinstance(snapshot, dict) and snapshot.get("error"): return snapshot return _story_spine_from_snapshot(snapshot) def _timeline_items_by_ids_report(tl, ids: List[Any], track_types=("video", "audio")) -> Tuple[List[Any], List[str]]: found = _timeline_items_by_ids(tl, ids, track_types=track_types) found_ids = {_safe_timeline_item_id(item) for item in found} missing = [str(item_id) for item_id in ids if str(item_id) not in found_ids] return found, missing def _merge_property_groups(p: Dict[str, Any]) -> Dict[str, Any]: merged: Dict[str, Any] = {} for group in ("properties", "transform", "crop", "composite", "audio"): payload = p.get(group) if isinstance(payload, dict): merged.update(payload) for key in _DUPLICATE_KEYFRAME_PROPERTIES: if key in p: merged[key] = p[key] return merged def _timeline_bulk_set_item_properties(tl, p: Dict[str, Any]) -> Dict[str, Any]: ops = p.get("ops") if not isinstance(ops, list) or not ops: return _err("bulk_set_item_properties requires params.ops: non-empty list of objects") dry_run = bool(p.get("dry_run", False)) readback = bool(p.get("readback", False)) results = [] for index, op in enumerate(ops): if not isinstance(op, dict): results.append({"index": index, "success": False, "error": "op must be an object"}) continue item = None item_id = op.get("timeline_item_id") or op.get("clip_id") if item_id: item = _find_timeline_item_by_id(tl, item_id) elif "timeline_item" in op: _, item, item_err = _get_item(op["timeline_item"]) if item_err: results.append({"index": index, "success": False, "error": item_err.get("error")}) continue if item is None: results.append({"index": index, "success": False, "error": f"timeline item not found: {item_id}"}) continue properties = _merge_property_groups(op) if not properties: results.append({"index": index, "success": False, "error": "op requires properties, transform, crop, composite, audio, or direct property keys"}) continue item_result = { "index": index, "timeline_item_id": _safe_timeline_item_id(item), "name": _safe_timeline_item_name(item), "properties": {}, } if dry_run: item_result.update({"success": True, "would_set": properties}) results.append(item_result) continue for key, value in properties.items(): row = {"requested": value} try: row["success"] = bool(item.SetProperty(key, value)) except Exception as exc: row["success"] = False row["error"] = str(exc) if readback: try: row["readback"] = item.GetProperty(key) except Exception as exc: row["readback_error"] = str(exc) item_result["properties"][key] = row if "clip_color" in op: try: item_result["clip_color"] = bool(item.SetClipColor(op["clip_color"])) except Exception as exc: item_result["clip_color"] = {"success": False, "error": str(exc)} if "enabled" in op: try: item_result["enabled"] = bool(item.SetClipEnabled(bool(op["enabled"]))) except Exception as exc: item_result["enabled"] = {"success": False, "error": str(exc)} item_result["success"] = all(row.get("success") for row in item_result["properties"].values()) results.append(item_result) return {"success": all(row.get("success") for row in results), "results": results, "op_count": len(ops)} def _timeline_apply_look_to_items(tl, p: Dict[str, Any]) -> Dict[str, Any]: target_ids = p.get("target_ids") or p.get("timeline_item_ids") or [] if not isinstance(target_ids, list) or not target_ids: return _err("apply_look_to_items requires target_ids: non-empty list of video timeline item IDs") targets, missing = _timeline_items_by_ids_report(tl, target_ids, track_types=("video",)) target_summaries = [_timeline_item_summary(item) for item in targets] dry_run = bool(p.get("dry_run", False)) out: Dict[str, Any] = { "targets": target_summaries, "missing": missing, "dry_run": dry_run, } cdl = p.get("cdl") if cdl is not None: validation, err = _validate_cdl_payload(cdl) if err: return err if not validation["valid"]: return {"success": False, "validation": validation, "targets": target_summaries, "missing": missing} normalized = _normalize_cdl(validation["cdl"]) out["cdl"] = {"validation": validation, "normalized": normalized} source_item = None source_id = p.get("copy_from_item_id") or p.get("source_item_id") if source_id: source_item = _find_timeline_item_by_id(tl, source_id) out["copy_from_item_id"] = source_id if not source_item: out["source_error"] = f"source item not found: {source_id}" if dry_run: out["success"] = not missing and not out.get("source_error") out["would_apply_cdl"] = cdl is not None out["would_copy_grade"] = source_item is not None return out if missing or out.get("source_error"): out["success"] = False return out results = [] if cdl is not None: normalized = out["cdl"]["normalized"] for item in targets: try: results.append({ "timeline_item_id": _safe_timeline_item_id(item), "set_cdl": bool(item.SetCDL(normalized)), }) except Exception as exc: results.append({ "timeline_item_id": _safe_timeline_item_id(item), "set_cdl": False, "error": str(exc), }) out["cdl_results"] = results if source_item is not None: try: out["copy_grades"] = bool(source_item.CopyGrades(targets)) except Exception as exc: out["copy_grades"] = False out["copy_grades_error"] = str(exc) out["success"] = ( all(row.get("set_cdl", True) for row in results) and (source_item is None or bool(out.get("copy_grades"))) ) return out def _timeline_create_variant_from_ranges(proj, source_tl, p: Dict[str, Any]) -> Dict[str, Any]: ranges = p.get("ranges") or p.get("clip_infos") if not isinstance(ranges, list) or not ranges: return _err("create_variant_from_ranges requires ranges: non-empty list of source range objects") name = p.get("name") if not name: return _err("create_variant_from_ranges requires name") mp = proj.GetMediaPool() if not mp: return _err("Failed to get MediaPool") root = mp.GetRootFolder() start_frame = _frame_int(p.get("record_frame_start", p.get("recordFrameStart"))) if start_frame is None: try: start_frame = int(source_tl.GetStartFrame()) except Exception: start_frame = 0 built = [] cursor_by_track: Dict[Tuple[int, int], int] = {} max_tracks = {"video": 1, "audio": 1} for index, row in enumerate(ranges): if not isinstance(row, dict): return _err(f"ranges[{index}] must be an object") track_type = str(row.get("track_type", row.get("trackType", "video"))).lower() media_type = row.get("media_type", row.get("mediaType")) if media_type is None: media_type = _timeline_media_type(track_type) if media_type not in (1, 2): return _err(f"ranges[{index}] track_type/media_type must resolve to video or audio") track_index = int(row.get("track_index", row.get("trackIndex", 1))) if media_type == 1: max_tracks["video"] = max(max_tracks["video"], track_index) else: max_tracks["audio"] = max(max_tracks["audio"], track_index) start = _frame_int(row.get("start_frame", row.get("startFrame"))) end = _frame_int(row.get("end_frame", row.get("endFrame"))) if start is None or end is None or end <= start: return _err(f"ranges[{index}] requires valid start_frame/end_frame") record_frame = _frame_int(row.get("record_frame", row.get("recordFrame"))) key = (int(media_type), track_index) if record_frame is None: record_frame = cursor_by_track.get(key, start_frame) cursor_by_track[key] = record_frame + (end - start) built.append({ "clip_id": row.get("clip_id") or row.get("media_pool_item_id"), "start_frame": start, "end_frame": end, "record_frame": record_frame, "track_index": track_index, "media_type": int(media_type), "_source_row": row, "_index": index, }) if p.get("dry_run", False): return { "success": True, "dry_run": True, "name": name, "ranges": [ {key: value for key, value in row.items() if not key.startswith("_")} for row in built ], "markers": p.get("markers") or [], "would_create_timeline": True, } new_tl = mp.CreateEmptyTimeline(name) if not new_tl: return _err(f"Failed to create timeline: {name}") proj.SetCurrentTimeline(new_tl) if p.get("start_timecode"): try: new_tl.SetStartTimecode(p["start_timecode"]) except Exception: pass for track_type, needed in max_tracks.items(): while int(new_tl.GetTrackCount(track_type) or 0) < needed: if not new_tl.AddTrack(track_type): break append_infos = [] for row in built: clip_info, clip_err = _build_append_clip_info_dict(root, row, row["_index"]) if clip_err: return clip_err append_infos.append(clip_info) appended = mp.AppendToTimeline(append_infos) if not appended: return _err("AppendToTimeline returned no items for variant") items_out = [] for index, item in enumerate(appended): item_out, item_err = _serialize_appended_timeline_item(item, index, allow_empty_timeline_item_id=True) if item_err: return item_err item_out["range"] = {key: value for key, value in built[index].items() if not key.startswith("_")} transform = built[index]["_source_row"].get("transform") if isinstance(transform, dict): item_out["transform"] = {} for key, value in transform.items(): try: item_out["transform"][key] = bool(item.SetProperty(key, value)) except Exception as exc: item_out["transform"][key] = {"success": False, "error": str(exc)} items_out.append(item_out) marker_results = [] for marker in p.get("markers") or []: if not isinstance(marker, dict): marker_results.append({"success": False, "error": "marker must be an object"}) continue marker_payload, marker_err = _marker_add_payload(marker, tl=new_tl) if marker_err: marker_results.append(marker_err) continue marker_results.append(_add_marker(new_tl, marker_payload)) look_result = None if p.get("cdl"): target_ids = [row.get("timeline_item_id") for row in items_out if row.get("timeline_item_id") and row.get("range", {}).get("media_type") == 1] look_result = _timeline_apply_look_to_items(new_tl, {"target_ids": target_ids, "cdl": p.get("cdl")}) return { "success": True, "name": new_tl.GetName(), "id": new_tl.GetUniqueId(), "items": items_out, "markers": marker_results, "look": look_result, "gaps_overlaps": _detect_gaps_overlaps_from_snapshot(_timeline_conform_snapshot(new_tl, {}), {}), } def _thumbnail_raw_rgb(thumbnail_data: Dict[str, Any]) -> Tuple[int, int, bytes]: width = int(thumbnail_data.get("width") or 0) height = int(thumbnail_data.get("height") or 0) components = int(thumbnail_data.get("noOfComponents") or thumbnail_data.get("components") or thumbnail_data.get("channels") or 3) data = thumbnail_data.get("data") if isinstance(data, str): raw = base64.b64decode(data) elif isinstance(data, bytes): raw = data elif isinstance(data, bytearray): raw = bytes(data) elif isinstance(data, list): raw = bytes(data) else: raise ValueError(f"Unsupported thumbnail data type: {type(data).__name__}") if width <= 0 or height <= 0 or components not in (3, 4): raise ValueError("Unsupported thumbnail shape") expected = width * height * components if len(raw) < expected: raise ValueError("Thumbnail data is shorter than expected") raw = raw[:expected] if components == 3: return width, height, raw rgb = bytearray() for index in range(0, len(raw), 4): rgb.extend(raw[index:index + 3]) return width, height, bytes(rgb) def _rgb_to_png_bytes(width: int, height: int, raw_rgb: bytes) -> bytes: row_size = width * 3 filtered_rows = bytearray() for y in range(height): filtered_rows.append(0) start = y * row_size filtered_rows.extend(raw_rgb[start:start + row_size]) ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) return ( b"\x89PNG\r\n\x1a\n" + _png_chunk(b"IHDR", ihdr) + _png_chunk(b"IDAT", zlib.compress(bytes(filtered_rows))) + _png_chunk(b"IEND", b"") ) _TINY_FONT = { "A": ("111", "101", "111", "101", "101"), "B": ("110", "101", "110", "101", "110"), "C": ("111", "100", "100", "100", "111"), "D": ("110", "101", "101", "101", "110"), "E": ("111", "100", "110", "100", "111"), "F": ("111", "100", "110", "100", "100"), "G": ("111", "100", "101", "101", "111"), "H": ("101", "101", "111", "101", "101"), "I": ("111", "010", "010", "010", "111"), "J": ("001", "001", "001", "101", "111"), "K": ("101", "101", "110", "101", "101"), "L": ("100", "100", "100", "100", "111"), "M": ("101", "111", "111", "101", "101"), "N": ("101", "111", "111", "111", "101"), "O": ("111", "101", "101", "101", "111"), "P": ("111", "101", "111", "100", "100"), "Q": ("111", "101", "101", "111", "001"), "R": ("111", "101", "111", "110", "101"), "S": ("111", "100", "111", "001", "111"), "T": ("111", "010", "010", "010", "010"), "U": ("101", "101", "101", "101", "111"), "V": ("101", "101", "101", "101", "010"), "W": ("101", "101", "111", "111", "101"), "X": ("101", "101", "010", "101", "101"), "Y": ("101", "101", "010", "010", "010"), "Z": ("111", "001", "010", "100", "111"), "0": ("111", "101", "101", "101", "111"), "1": ("010", "110", "010", "010", "111"), "2": ("111", "001", "111", "100", "111"), "3": ("111", "001", "111", "001", "111"), "4": ("101", "101", "111", "001", "001"), "5": ("111", "100", "111", "001", "111"), "6": ("111", "100", "111", "101", "111"), "7": ("111", "001", "010", "010", "010"), "8": ("111", "101", "111", "101", "111"), "9": ("111", "101", "111", "001", "111"), ":": ("0", "1", "0", "1", "0"), ".": ("0", "0", "0", "0", "1"), "-": ("000", "000", "111", "000", "000"), "_": ("000", "000", "000", "000", "111"), "/": ("001", "001", "010", "100", "100"), "#": ("101", "111", "101", "111", "101"), " ": ("0", "0", "0", "0", "0"), } def _draw_rect_rgb(canvas: bytearray, canvas_w: int, canvas_h: int, x: int, y: int, w: int, h: int, color: Tuple[int, int, int]) -> None: x0 = max(0, x) y0 = max(0, y) x1 = min(canvas_w, x + w) y1 = min(canvas_h, y + h) for yy in range(y0, y1): for xx in range(x0, x1): idx = (yy * canvas_w + xx) * 3 canvas[idx:idx + 3] = bytes(color) def _draw_tiny_text_rgb(canvas: bytearray, canvas_w: int, canvas_h: int, x: int, y: int, text: str, *, scale: int = 2, color: Tuple[int, int, int] = (235, 235, 235)) -> None: cursor = x for char in str(text).upper(): glyph = _TINY_FONT.get(char, ("111", "101", "101", "101", "111")) glyph_w = max(len(row) for row in glyph) for gy, row in enumerate(glyph): for gx, bit in enumerate(row): if bit != "1": continue _draw_rect_rgb(canvas, canvas_w, canvas_h, cursor + gx * scale, y + gy * scale, scale, scale, color) cursor += (glyph_w + 1) * scale def _contact_sheet_sample_label(sample: Dict[str, Any], index: int) -> str: marker = sample.get("marker") or {} name = marker.get("name") or marker.get("note") or sample.get("source") or "frame" basis = sample.get("timecode") or f"f{sample.get('frame')}" label = f"{index:02d} {basis} {name}" label = re.sub(r"[^A-Za-z0-9:._/# -]+", " ", str(label)) return re.sub(r"\s+", " ", label).strip() def _contact_sheet_png_bytes(samples: List[Dict[str, Any]], columns: int = 4, padding: int = 8, label_height: int = 24) -> Tuple[int, int, bytes]: thumbs = [sample for sample in samples if sample.get("thumbnail_rgb")] if not thumbs: raise ValueError("No thumbnails available for contact sheet") thumb_w, thumb_h = thumbs[0]["thumbnail_rgb"][0], thumbs[0]["thumbnail_rgb"][1] columns = max(1, min(columns, len(thumbs))) rows = int(math.ceil(len(thumbs) / columns)) label_height = max(0, int(label_height or 0)) cell_h = thumb_h + label_height width = columns * thumb_w + (columns + 1) * padding height = rows * cell_h + (rows + 1) * padding canvas = bytearray([24, 24, 24] * width * height) for sample_index, sample in enumerate(thumbs): thumb_w, thumb_h, raw = sample["thumbnail_rgb"] col = sample_index % columns row = sample_index // columns x0 = padding + col * (thumb_w + padding) y0 = padding + row * (cell_h + padding) for y in range(thumb_h): src = y * thumb_w * 3 dst = ((y0 + y) * width + x0) * 3 canvas[dst:dst + thumb_w * 3] = raw[src:src + thumb_w * 3] if label_height: label = sample.get("label") or _contact_sheet_sample_label(sample, sample_index + 1) sample["label"] = label _draw_rect_rgb(canvas, width, height, x0, y0 + thumb_h, thumb_w, label_height, (12, 12, 12)) max_chars = max(4, (thumb_w - 8) // 8) _draw_tiny_text_rgb(canvas, width, height, x0 + 4, y0 + thumb_h + 5, label[:max_chars], scale=2) return width, height, _rgb_to_png_bytes(width, height, bytes(canvas)) def _timeline_contact_sheet_samples(tl, p: Dict[str, Any]) -> Tuple[Optional[List[Dict[str, Any]]], Optional[Dict[str, Any]]]: max_samples = max(1, int(p.get("max_samples", p.get("maxSamples", 12)))) frames = p.get("frames") samples = [] if frames is not None: if not isinstance(frames, list): return None, _err("frames must be a list") for frame in frames[:max_samples]: frame_id = _frame_int(frame) if frame_id is not None: samples.append({"frame": frame_id, "source": "frame"}) else: markers = _timeline_marker_rows_from_snapshot(_timeline_conform_snapshot(tl, {"track_types": [], "include_markers": True})) for marker in markers[:max_samples]: if marker.get("frame") is not None: samples.append({"frame": marker["frame"], "source": "marker", "marker": marker}) return samples, None def _timeline_thumbnail_contact_sheet(proj, tl, p: Dict[str, Any]) -> Dict[str, Any]: samples, sample_err = _timeline_contact_sheet_samples(tl, p) if sample_err: return sample_err if not samples: return _err("No frames or timeline markers available for thumbnail contact sheet") project_name, project_id = _project_name_and_id(proj) root = resolve_media_analysis_output_root( project_name=project_name, project_id=project_id, analysis_root=p.get("analysis_root"), source_paths=[], create=True, ) if not root.get("success"): return root original_timecode = None try: original_timecode = tl.GetCurrentTimecode() except Exception: pass sampled = [] try: for sample in samples: timecode, tc_err = _timeline_frame_id_to_timecode(tl, int(sample["frame"])) if tc_err: sample["error"] = tc_err.get("error") sampled.append(sample) continue try: tl.SetCurrentTimecode(timecode) thumbnail = tl.GetCurrentClipThumbnailImage() if not thumbnail: sample["error"] = "No thumbnail available at frame" else: sample["timecode"] = timecode sample["thumbnail_rgb"] = _thumbnail_raw_rgb(thumbnail) sample["thumbnail_available"] = True except Exception as exc: sample["error"] = str(exc) sampled.append(sample) finally: if original_timecode: try: tl.SetCurrentTimecode(original_timecode) except Exception: pass sheet_samples = [sample for sample in sampled if sample.get("thumbnail_rgb")] if not sheet_samples: return {"success": False, "samples": sampled, "error": "No thumbnails could be sampled"} for index, sample in enumerate(sheet_samples, 1): sample["label"] = _contact_sheet_sample_label(sample, index) width, height, png_bytes = _contact_sheet_png_bytes( sheet_samples, columns=int(p.get("columns", 4)), padding=int(p.get("padding", 8)), label_height=int(p.get("label_height", p.get("labelHeight", 24))), ) sheet_dir = os.path.join(root["project_root"], "timeline-contact-sheets") os.makedirs(sheet_dir, exist_ok=True) filename = f"{slugify(tl.GetName() or 'timeline')}-{int(time.time())}.png" path = os.path.join(sheet_dir, filename) with open(path, "wb") as handle: handle.write(png_bytes) for sample in sampled: sample.pop("thumbnail_rgb", None) metadata_path = os.path.splitext(path)[0] + ".json" metadata = { "success": True, "kind": "timeline_thumbnail_contact_sheet", "timeline_name": tl.GetName(), "image_path": path, "width": width, "height": height, "sample_count": len(sheet_samples), "samples": sampled, "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "review_guidance": [ "Use the labels as locators only; verify the visible frame against marker intent.", "Treat contact sheets as review evidence, not final visual analysis.", ], } with open(metadata_path, "w", encoding="utf-8") as handle: json.dump(metadata, handle, indent=2, ensure_ascii=False) handle.write("\n") return { "success": True, "path": path, "metadata_path": metadata_path, "width": width, "height": height, "sample_count": len(sheet_samples), "samples": sampled, "project_root": root["project_root"], } def _timeline_marker_thumbnail_review(proj, tl, p: Dict[str, Any]) -> Dict[str, Any]: review = _timeline_thumbnail_contact_sheet(proj, tl, {**p, "frames": None}) if not review.get("success"): return review review["review_guidance"] = [ "Compare each marker name/note with the sampled Resolve-rendered frame.", "If the image contradicts marker intent, update the marker before using it as edit evidence.", "This helper samples frames only; rich descriptions require the assistant viewing the generated sheet (host_chat_paths review payload).", ] review["review_prompt"] = { "task": "Review the marker contact sheet for editorial accuracy.", "schema": { "success": True, "timeline_summary": "What the marker frames suggest about the cut.", "marker_checks": [ { "label": "Contact-sheet label", "matches_marker_intent": "yes|no|unclear", "visible_evidence": "What the frame actually shows.", "recommended_action": "keep|rename_marker|move_marker|review_cut|ignore", } ], "editorial_risks": [], "next_actions": [], }, } return review def _audio_mix_capability_report(proj, mp, tl, p: Dict[str, Any]) -> Dict[str, Any]: report = _fairlight_boundary_report(proj, mp, tl, p) item = report.get("item") or {} audio_props = item.get("audio_properties") or {} unavailable = [key for key, value in audio_props.items() if value is None or isinstance(value, dict)] report["mix_recommendations"] = { "item_property_writes": "probe_with_safe_set_audio_properties_before relying on item-level Volume/Pan changes", "unavailable_or_readonly_probe_values": unavailable, "fallbacks": [ "Use track enable/lock/name and voice-isolation helpers where available.", "Use project_settings.apply_fairlight_preset when an approved Fairlight preset exists.", "Use manual Fairlight mixing for detailed levels, pans, automation curves, and plugin parameters.", "Add separate sound-design assets only when the user explicitly requests imports or media changes.", ], } return report def _timeline_export_value(value, resolve_obj=None): raw = str(value or "").strip() if not raw: return "", None const_name = raw if raw.startswith("EXPORT_") else None if const_name and resolve_obj is not None and hasattr(resolve_obj, const_name): return getattr(resolve_obj, const_name), const_name if const_name: return const_name, const_name return raw, None def _timeline_export_spec(p: Dict[str, Any], resolve_obj=None): requested = p.get("format") or p.get("type") or p.get("export_type") or "fcpxml" key = str(requested).strip().lower().replace("-", "_").replace(" ", "_") alias = _TIMELINE_EXPORT_ALIASES.get(key) if alias: type_name, subtype_name, ext = alias else: type_name = str(requested) subtype_name = p.get("subtype") or p.get("export_subtype") or "EXPORT_NONE" ext = p.get("extension") or ".timeline" if p.get("subtype") or p.get("export_subtype"): subtype_name = p.get("subtype") or p.get("export_subtype") export_type, export_type_name = _timeline_export_value(type_name, resolve_obj) export_subtype, export_subtype_name = _timeline_export_value(subtype_name, resolve_obj) return { "requested": requested, "export_type": export_type, "export_type_name": export_type_name or type_name, "export_subtype": export_subtype, "export_subtype_name": export_subtype_name or subtype_name, "extension": ext, } def _export_timeline_checked(tl, p: Dict[str, Any]): path = p.get("path") if not path: return _err("path is required") if p.get("require_temp_path", True) and not _render_temp_path_ok(path): return _err("path must be under the system temp directory unless require_temp_path=False") folder = os.path.dirname(os.path.abspath(path)) if folder: os.makedirs(folder, exist_ok=True) spec = _timeline_export_spec(p, resolve) if p.get("dry_run"): return _ok(path=path, would_export=True, spec={k: v for k, v in spec.items() if k != "export_type"}) success = bool(tl.Export(path, spec["export_type"], spec["export_subtype"])) files = [] primary_file = path if success and os.path.isdir(path): for dirpath, _, filenames in os.walk(path): for filename in filenames: file_path = os.path.join(dirpath, filename) files.append({"path": file_path, "size": os.path.getsize(file_path)}) preferred_exts = (".fcpxml", ".xml", ".edl", ".drt", ".aaf", ".otio") for ext in preferred_exts: match = next((row["path"] for row in files if row["path"].lower().endswith(ext)), None) if match: primary_file = match break size = 0 if success and os.path.exists(path): if os.path.isdir(path): size = sum(row["size"] for row in files) else: size = os.path.getsize(path) return { "success": success, "path": path, "primary_file": primary_file, "is_directory": bool(success and os.path.isdir(path)), "files": files, "size": size, "format": spec["requested"], "export_type": spec["export_type_name"], "export_subtype": spec["export_subtype_name"], } def _import_timeline_checked(proj, mp, p: Dict[str, Any]): path = p.get("path") if not path: return _err("path is required") if not os.path.exists(path): return _err(f"path does not exist: {path}") if p.get("require_temp_path", True) and not _render_temp_path_ok(path): return _err("path must be under the system temp directory unless require_temp_path=False") options = dict(p.get("options") or {}) if p.get("timeline_name") and "timelineName" not in options: options["timelineName"] = p["timeline_name"] if "import_source_clips" in p and "importSourceClips" not in options: options["importSourceClips"] = bool(p["import_source_clips"]) if p.get("dry_run"): return _ok(path=path, options=options, would_import=True) before_ids = set() for index in range(1, int(proj.GetTimelineCount() or 0) + 1): tl_existing = proj.GetTimelineByIndex(index) if tl_existing: before_ids.add(str(tl_existing.GetUniqueId())) imported = mp.ImportTimelineFromFile(path, options) if not imported: return _err("Failed to import timeline") imported_id = str(imported.GetUniqueId()) return _ok( name=imported.GetName(), id=imported_id, created_new=imported_id not in before_ids, timeline_count=proj.GetTimelineCount(), ) def _timeline_by_selector(proj, p: Dict[str, Any], *, prefix: str): timeline_id = p.get(f"{prefix}_timeline_id") or p.get(f"{prefix}_id") timeline_index = p.get(f"{prefix}_timeline_index") or p.get(f"{prefix}_index") if timeline_id: want = str(timeline_id) for index in range(1, int(proj.GetTimelineCount() or 0) + 1): tl = proj.GetTimelineByIndex(index) if tl and str(tl.GetUniqueId()) == want: return tl, None return None, _err(f"{prefix} timeline not found: {timeline_id}") if timeline_index is not None: try: index = int(timeline_index) except (TypeError, ValueError): return None, _err(f"{prefix}_timeline_index must be an integer") tl = proj.GetTimelineByIndex(index) return (tl, None) if tl else (None, _err(f"No {prefix} timeline at index {index}")) return proj.GetCurrentTimeline(), None def _timeline_identity(tl, index: Optional[int] = None) -> Dict[str, Any]: payload = { "name": tl.GetName() if tl else None, "id": tl.GetUniqueId() if tl else None, } if index is not None: payload["index"] = index return payload def _find_timeline_by_name(proj, name: Any): want = str(name or "") for index in range(1, int(proj.GetTimelineCount() or 0) + 1): tl = proj.GetTimelineByIndex(index) if tl and str(tl.GetName()) == want: return tl, index return None, None def _unique_timeline_name(proj, requested_name: Any) -> str: base = str(requested_name or "Untitled Timeline").strip() or "Untitled Timeline" existing_names = set() for index in range(1, int(proj.GetTimelineCount() or 0) + 1): tl = proj.GetTimelineByIndex(index) if tl: existing_names.add(str(tl.GetName())) if base not in existing_names: return base suffix = 2 while True: candidate = f"{base} v{suffix:02d}" if candidate not in existing_names: return candidate suffix += 1 def _resolve_timeline_create_policy(proj, p: Dict[str, Any]) -> Tuple[Optional[str], Optional[Any], Optional[Dict[str, Any]]]: requested_name = str(p.get("name") or "").strip() if not requested_name: return None, None, _err("name is required") policy = str( p.get("if_exists") or p.get("ifExists") or p.get("existing_policy") or p.get("existingPolicy") or "version" ).strip().lower() aliases = { "error": "fail", "existing": "reuse", "use_existing": "reuse", "unique": "version", "rename": "version", "new_version": "version", } policy = aliases.get(policy, policy) if policy not in {"version", "reuse", "fail"}: return None, None, _err("if_exists must be one of: version, reuse, fail") existing, existing_index = _find_timeline_by_name(proj, requested_name) if not existing: return requested_name, None, None if policy == "reuse": return requested_name, existing, _ok( name=existing.GetName(), id=existing.GetUniqueId(), reused_existing=True, if_exists=policy, existing_timeline=_timeline_identity(existing, existing_index), ) if policy == "fail": err = _err(f"Timeline already exists: {requested_name}") err.update({ "if_exists": policy, "existing_timeline": _timeline_identity(existing, existing_index), }) return None, existing, err return _unique_timeline_name(proj, requested_name), existing, None def _compare_timeline_snapshots(left: Dict[str, Any], right: Dict[str, Any]): differences = [] left_tracks = left.get("tracks", {}) right_tracks = right.get("tracks", {}) for track_type in sorted(set(left_tracks) | set(right_tracks)): left_type = left_tracks.get(track_type, {}) right_type = right_tracks.get(track_type, {}) if left_type.get("track_count", 0) != right_type.get("track_count", 0): differences.append({ "kind": "track_count", "track_type": track_type, "left": left_type.get("track_count", 0), "right": right_type.get("track_count", 0), }) left_track_map = {row.get("track_index"): row for row in left_type.get("tracks", [])} right_track_map = {row.get("track_index"): row for row in right_type.get("tracks", [])} for track_index in sorted(set(left_track_map) | set(right_track_map)): left_items = left_track_map.get(track_index, {}).get("items", []) right_items = right_track_map.get(track_index, {}).get("items", []) if len(left_items) != len(right_items): differences.append({ "kind": "item_count", "track_type": track_type, "track_index": track_index, "left": len(left_items), "right": len(right_items), }) for item_index, (left_item, right_item) in enumerate(zip(left_items, right_items)): fields = ["name", "start", "end", "source_start", "source_end", "media_pool_item_name"] changed = { field: {"left": left_item.get(field), "right": right_item.get(field)} for field in fields if left_item.get(field) != right_item.get(field) } if changed: differences.append({ "kind": "item_mismatch", "track_type": track_type, "track_index": track_index, "item_index": item_index, "changes": changed, }) return {"match": len(differences) == 0, "difference_count": len(differences), "differences": differences} def _compare_timelines(proj, tl, p: Dict[str, Any]): if isinstance(p.get("left_snapshot"), dict) and isinstance(p.get("right_snapshot"), dict): left = p["left_snapshot"] right = p["right_snapshot"] else: right_tl, err = _timeline_by_selector(proj, p, prefix="right") if err: return err left = _timeline_conform_snapshot(tl, p) right = _timeline_conform_snapshot(right_tl, p) return {"left": {"name": left.get("name"), "id": left.get("id")}, "right": {"name": right.get("name"), "id": right.get("id")}, **_compare_timeline_snapshots(left, right)} def _probe_interchange_roundtrip(proj, mp, tl, p: Dict[str, Any]): output_dir = p.get("output_dir") or tempfile.mkdtemp(prefix="mcp_conform_roundtrip_") if p.get("require_temp_path", True) and not _render_temp_path_ok(output_dir): return _err("output_dir must be under the system temp directory unless require_temp_path=False") os.makedirs(output_dir, exist_ok=True) spec = _timeline_export_spec(p, resolve) base_name = p.get("name") or f"roundtrip_{str(spec['requested']).lower()}" path = p.get("path") or os.path.join(output_dir, base_name + spec["extension"]) export_result = _export_timeline_checked(tl, {**p, "path": path, "require_temp_path": p.get("require_temp_path", True)}) if export_result.get("error") or not export_result.get("success"): return {"success": False, "stage": "export", "export": export_result} import_path = export_result.get("primary_file") or export_result.get("path") or path import_options = dict(p.get("import_options") or {}) requested_key = str(spec["requested"]).lower() if "drt" not in requested_key: import_options.setdefault("timelineName", p.get("imported_timeline_name", f"{tl.GetName()} {spec['requested']} Roundtrip")) import_options.setdefault("importSourceClips", bool(p.get("import_source_clips", False))) import_result = _import_timeline_checked( proj, mp, { "path": import_path, "options": import_options, "require_temp_path": p.get("require_temp_path", True), }, ) if import_result.get("error") or not import_result.get("success"): return {"success": False, "stage": "import", "export": export_result, "import": import_result} imported_tl = None if import_result.get("id"): imported_tl, _ = _timeline_by_selector(proj, {"right_timeline_id": import_result["id"]}, prefix="right") comparison = None if imported_tl: comparison = _compare_timeline_snapshots(_timeline_conform_snapshot(tl, p), _timeline_conform_snapshot(imported_tl, p)) cleanup_result = None if p.get("cleanup_imported", True) and imported_tl: cleanup_result = {"success": bool(mp.DeleteTimelines([imported_tl]))} return { "success": True, "format": spec["requested"], "path": path, "export": export_result, "import": import_result, "comparison": comparison, "cleanup": cleanup_result, } def _detect_missing_media_from_snapshot(snapshot: Dict[str, Any]): missing = [] present = [] for track_type, type_payload in (snapshot.get("tracks") or {}).items(): for track in type_payload.get("tracks", []): for item in track.get("items", []): file_path = item.get("file_path") status_text = str(item.get("media_status") or "").lower() exists = bool(file_path and os.path.exists(str(file_path))) is_missing = bool(file_path and not exists) or any(token in status_text for token in ("offline", "missing")) row = { "track_type": track_type, "track_index": track.get("track_index"), "timeline_item_id": item.get("timeline_item_id"), "media_pool_item_id": item.get("media_pool_item_id"), "name": item.get("name"), "media_pool_item_name": item.get("media_pool_item_name"), "file_path": file_path, "file_exists": exists, "media_status": item.get("media_status"), } if is_missing: missing.append(row) else: present.append(row) return {"missing": missing, "present_count": len(present), "missing_count": len(missing)} def _detect_missing_media(tl, p: Dict[str, Any]): snapshot = _timeline_conform_snapshot(tl, {**p, "include_clip_properties": True}) return _detect_missing_media_from_snapshot(snapshot) def _build_relink_plan(tl, p: Dict[str, Any]): search_roots = p.get("search_roots") or p.get("roots") or [] if not isinstance(search_roots, list) or not search_roots: return _err("search_roots must be a non-empty list") invalid = [root for root in search_roots if not isinstance(root, str) or not os.path.isdir(root)] if invalid: return _err(f"search_roots must be existing directories: {invalid}") missing_report = _detect_missing_media(tl, p) candidates = [] for row in missing_report.get("missing", []): wanted = os.path.basename(str(row.get("file_path") or row.get("media_pool_item_name") or row.get("name") or "")) matches = [] if wanted: for root in search_roots: for dirpath, _, filenames in os.walk(root): if wanted in filenames: matches.append(os.path.join(dirpath, wanted)) if not p.get("all_matches", False): break if matches and not p.get("all_matches", False): break candidates.append({**row, "wanted_basename": wanted, "candidate_paths": matches, "candidate_count": len(matches)}) return { "success": True, "dry_run": True, "search_roots": search_roots, "candidate_count": sum(1 for row in candidates if row["candidate_count"]), "missing_count": missing_report.get("missing_count", 0), "candidates": candidates, "execution_note": "Review this plan, then use media_pool.safe_relink with explicit synthetic or approved paths if desired.", } def _conform_boundary_report(tl, p: Dict[str, Any]): snapshot = _timeline_conform_snapshot(tl, p) return { "capabilities": _conform_capabilities(), "timeline": snapshot, "gaps_overlaps": _detect_gaps_overlaps_from_snapshot(snapshot, p), "source_ranges": _source_ranges_from_snapshot(snapshot, p), "missing_media": _detect_missing_media_from_snapshot(snapshot), } _TIMELINE_AUDIO_KERNEL_ACTIONS = [ "audio_capabilities", "probe_audio_item", "probe_audio_track", "safe_set_audio_properties", "voice_isolation_capabilities", "audio_mapping_report", "safe_auto_sync_audio", "transcription_capabilities", "subtitle_generation_probe", "fairlight_boundary_report", ] _AUDIO_PROPERTY_KEYS = ["Volume", "Pan", "AudioSyncOffsetIsManual", "AudioSyncOffset"] def _audio_capabilities(): return { "supported": { "track_state": [ "audio track count, subtype, name, lock, and enable state", "track add/delete through raw timeline actions", "track-level voice isolation when Resolve exposes 20.1+ APIs", ], "item_state": [ "timeline item audio property readback", "guarded audio property writes with restore", "timeline item source audio channel mapping when exposed", "item-level voice isolation when Resolve exposes 20.1+ APIs", ], "media_pool_audio": [ "MediaPoolItem audio mapping readback", "AutoSyncAudio through a guarded wrapper", "clip and folder transcription capability reporting", ], "timeline_ai": [ "subtitle generation dry-run planning by default", "explicit subtitle generation when allow_generate=True", ], "fairlight": [ "Fairlight preset list when Resolve exposes 20.2.2+ APIs", "ApplyFairlightPresetToCurrentTimeline through existing project_settings action", "InsertAudioToCurrentTrackAtPlayhead through existing project_settings action", ], }, "partially_supported": { "voice_isolation": "Track/item voice isolation depends on Resolve version, license, page state, and audio content.", "transcription_subtitles": "Transcription and subtitle generation can be asynchronous and may require installed AI components.", "audio_property_writes": "Some item types expose audio properties as read-only or reject writes despite returning readable values.", "auto_sync": "AutoSyncAudio depends on media content, channel layout, and selected sync settings.", }, "unsupported": { "destructive_audio_media_processing": "The kernel does not transcode, render, proxy, or alter source audio files.", "mix_automation_curves": "Resolve's public API does not expose full Fairlight mix automation curve editing.", "plugin_parameter_graphs": "Fairlight plugin internals are not fully inspectable through the public scripting API.", }, } def _audio_track_probe(tl, p: Dict[str, Any]): track_index = int(p.get("track_index", 1)) track_count = int(tl.GetTrackCount("audio") or 0) out = {"track_index": track_index, "track_count": track_count, "available": track_index <= track_count} if track_index > track_count: return out for key, getter, args in ( ("sub_type", "GetTrackSubType", ("audio", track_index)), ("name", "GetTrackName", ("audio", track_index)), ("enabled", "GetIsTrackEnabled", ("audio", track_index)), ("locked", "GetIsTrackLocked", ("audio", track_index)), ): method = getattr(tl, getter, None) if callable(method): try: out[key] = _ser(method(*args)) except Exception as exc: out[f"{key}_error"] = str(exc) if _has_method(tl, "GetVoiceIsolationState"): try: out["voice_isolation"] = _ser(tl.GetVoiceIsolationState(track_index) or {"isEnabled": False, "amount": 0}) except Exception as exc: out["voice_isolation_error"] = str(exc) else: out["voice_isolation_available"] = False return out def _audio_item_from_params(tl, p: Dict[str, Any]): track_type = p.get("track_type", "audio") track_index = int(p.get("track_index", 1)) item_index = int(p.get("item_index", 0)) items = tl.GetItemListInTrack(track_type, track_index) or [] if item_index >= len(items): return None, _err(f"No item at index {item_index} on {track_type} track {track_index}") return items[item_index], None def _timeline_item_audio_snapshot(item): props = {} for key in _AUDIO_PROPERTY_KEYS: try: props[key] = item.GetProperty(key) except Exception as exc: props[key] = {"error": str(exc)} source_mapping = None source_mapping_error = None if _has_method(item, "GetSourceAudioChannelMapping"): try: source_mapping = _ser(item.GetSourceAudioChannelMapping()) except Exception as exc: source_mapping_error = str(exc) voice = None voice_error = None if _has_method(item, "GetVoiceIsolationState"): try: voice = _ser(item.GetVoiceIsolationState() or {"isEnabled": False, "amount": 0}) except Exception as exc: voice_error = str(exc) return { "summary": _timeline_item_summary(item), "audio_properties": props, "source_audio_mapping": {"value": source_mapping, "error": source_mapping_error} if source_mapping_error else source_mapping, "voice_isolation": {"value": voice, "error": voice_error} if voice_error else voice, "methods": _callable_method_names( item, ["GetProperty", "SetProperty", "GetSourceAudioChannelMapping", "GetVoiceIsolationState", "SetVoiceIsolationState"], ), } def _probe_audio_item(tl, p: Dict[str, Any]): item, err = _audio_item_from_params(tl, p) if err: return err return _timeline_item_audio_snapshot(item) def _safe_set_audio_properties(tl, p: Dict[str, Any]): item, err = _audio_item_from_params(tl, p) if err: return err properties = p.get("properties") if properties is None: properties = {key: p[key] for key in _AUDIO_PROPERTY_KEYS if key in p} if not isinstance(properties, dict) or not properties: return _err("properties must be a non-empty object or pass one of Volume, Pan, AudioSyncOffsetIsManual, AudioSyncOffset") invalid = [key for key in properties if key not in _AUDIO_PROPERTY_KEYS] if invalid: return _err(f"Unsupported audio propertie(s): {', '.join(invalid)}") original = {} for key in properties: try: original[key] = item.GetProperty(key) except Exception as exc: original[key] = {"error": str(exc)} if p.get("dry_run"): return _ok(would_set=properties, original=original) results = {} for key, value in properties.items(): row = {"requested": value, "original": original.get(key)} try: row["write"] = bool(item.SetProperty(key, value)) except Exception as exc: row["write"] = False row["error"] = str(exc) try: row["readback"] = item.GetProperty(key) except Exception as exc: row["readback_error"] = str(exc) if p.get("restore", True) and not isinstance(original.get(key), dict): try: row["restore"] = bool(item.SetProperty(key, original[key])) except Exception as exc: row["restore"] = False row["restore_error"] = str(exc) results[key] = row return {"success": all(row.get("write") for row in results.values()), "results": results} def _voice_isolation_capabilities(tl, p: Dict[str, Any]): out = { "timeline_track": { "get_available": _has_method(tl, "GetVoiceIsolationState"), "set_available": _has_method(tl, "SetVoiceIsolationState"), }, "item": None, } if out["timeline_track"]["get_available"]: try: out["timeline_track"]["state"] = _ser(tl.GetVoiceIsolationState(int(p.get("track_index", 1))) or {"isEnabled": False, "amount": 0}) except Exception as exc: out["timeline_track"]["error"] = str(exc) item, item_err = _audio_item_from_params(tl, p) if item_err: out["item"] = {"available": False, "error": item_err.get("error")} else: out["item"] = { "get_available": _has_method(item, "GetVoiceIsolationState"), "set_available": _has_method(item, "SetVoiceIsolationState"), } if out["item"]["get_available"]: try: out["item"]["state"] = _ser(item.GetVoiceIsolationState() or {"isEnabled": False, "amount": 0}) except Exception as exc: out["item"]["error"] = str(exc) return out def _audio_mapping_report(mp, tl, p: Dict[str, Any]): root = mp.GetRootFolder() timeline_items = [] for track_type in ("video", "audio"): for track_index in range(1, int(tl.GetTrackCount(track_type) or 0) + 1): for item_index, item in enumerate(tl.GetItemListInTrack(track_type, track_index) or []): row = _timeline_item_summary(item, (track_type, track_index)) or {} row["item_index"] = item_index if _has_method(item, "GetSourceAudioChannelMapping"): try: row["source_audio_mapping"] = _ser(item.GetSourceAudioChannelMapping()) except Exception as exc: row["source_audio_mapping_error"] = str(exc) timeline_items.append(row) clip_rows = [] ids = p.get("clip_ids") clips = [] if ids: if not isinstance(ids, list): return _err("clip_ids must be a list") clips = [_find_clip(root, str(clip_id)) for clip_id in ids] clips = [clip for clip in clips if clip] else: seen = set() for row in timeline_items: clip_id = row.get("media_pool_item_id") if clip_id and clip_id not in seen: clip = _find_clip(root, clip_id) if clip: clips.append(clip) seen.add(clip_id) for clip in clips: mapping, mapping_error = _safe_clip_call(clip, "GetAudioMapping") clip_rows.append({ "summary": _media_pool_item_summary(clip), "audio_mapping": {"value": mapping, "error": mapping_error} if mapping_error else mapping, }) return {"timeline_items": timeline_items, "media_pool_items": clip_rows} def _extract_clip_frames(clip, p: Dict[str, Any]) -> Dict[str, Any]: """Extract still frames from a clip's source media at given timestamps. Source-safe: reads the source file with ffmpeg and writes JPEGs to a scratch output directory only — it never modifies, transcodes, or proxies the source. `timestamps` is a list of seconds. Returns the written frame paths. """ try: src = clip.GetClipProperty("File Path") except Exception: src = None if not src or not os.path.isfile(src): return _err("Clip has no readable source file (File Path)") timestamps = p.get("timestamps") if not isinstance(timestamps, list) or not timestamps: return _err("extract_frames requires 'timestamps' (a non-empty list of seconds)") if not shutil.which("ffmpeg"): return _err("ffmpeg not found on PATH") out_dir = p.get("output_dir") or tempfile.mkdtemp(prefix="mcp_frames_") try: os.makedirs(out_dir, exist_ok=True) except OSError as exc: return _err(f"Could not create output_dir: {exc}") paths, errors = [], [] for i, ts in enumerate(timestamps): out_path = os.path.join(out_dir, f"frame_{i:04d}.jpg") try: safe_run( ["ffmpeg", "-y", "-ss", str(float(ts)), "-i", src, "-frames:v", "1", "-q:v", "2", out_path], capture_output=True, timeout=60, ) except (OSError, subprocess.SubprocessError, ValueError) as exc: errors.append({"timestamp": ts, "error": str(exc)}) continue if os.path.isfile(out_path): paths.append(out_path) else: errors.append({"timestamp": ts, "error": "no frame written (timestamp out of range?)"}) return { "success": bool(paths), "source": src, "output_dir": out_dir, "frame_paths": paths, "count": len(paths), "errors": errors, } def _clip_name(clip): try: return clip.GetName() except Exception: return None def _synced_audio(clip): """Best-effort read of whether a clip currently has synced audio linked.""" try: v = clip.GetClipProperty("Synced Audio") except Exception: return False return bool(v) and str(v).strip() not in ("", "None", "0", "Off", "No") def _safe_auto_sync_audio(mp, p: Dict[str, Any]): root = mp.GetRootFolder() resolved, err = _clips_from_params(root, mp, p) if err: return err clips, missing = resolved # Use get_resolve() rather than the module global `resolve`, which is None # until populated and can be reset to None by a mid-call reconnect. A None # here makes _normalize_auto_sync_settings fall back to string enum keys, # which AutoSyncAudio silently rejects (returns False). settings = _normalize_auto_sync_settings(dict(p.get("settings") or {}), get_resolve()) if p.get("dry_run", True): return _ok(would_auto_sync=True, clips=_clip_summaries(clips), missing=missing, settings=settings) # Read-back verification: AutoSyncAudio's boolean return is unreliable, so # capture each clip's "Synced Audio" linkage before and after and report the # delta. Trust `linked`/`newly_linked`, not `success`. def _categorize(before, observed): linked, newly, already = [], [], [] for c, was, now in zip(clips, before, observed): if now: name = _clip_name(c) linked.append(name) (already if was else newly).append(name) return { "linked": linked, "newly_linked": newly, "already_linked": already, "verified": bool(linked), } res = verify_by_readback( mutate=lambda: mp.AutoSyncAudio(clips, settings), observe=lambda: [_synced_audio(c) for c in clips], snapshot=lambda: [_synced_audio(c) for c in clips], compare=_categorize, label="auto_sync_audio", intent={"clip_count": len(clips)}, ) return { "success": res["success_raw"], "verified": res["verified"], "linked": res["linked"], "newly_linked": res["newly_linked"], "already_linked": res["already_linked"], "count": len(clips), "missing": missing, "settings": settings, } def _resolve_audio_constant(resolve_obj, name: str, fallback): if resolve_obj is not None and hasattr(resolve_obj, name): return getattr(resolve_obj, name) return fallback def _normalize_auto_sync_settings(settings: Dict[str, Any], resolve_obj=None): if not settings: return settings normalized = {} mode_key = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_MODE", "syncMode") channel_key = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_CHANNEL_NUMBER", "channelNumber") retain_embedded_key = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_RETAIN_EMBEDDED_AUDIO", "retainEmbeddedAudio") retain_metadata_key = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_RETAIN_VIDEO_METADATA", "retainVideoMetadata") mode = settings.get("syncBy", settings.get("sync_by", settings.get("mode", settings.get(mode_key)))) if isinstance(mode, str): mode_norm = mode.strip().lower() if mode_norm in {"waveform", "audio_waveform", "audio_sync_waveform"}: mode = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_WAVEFORM", mode) elif mode_norm in {"timecode", "audio_sync_timecode"}: mode = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_TIMECODE", mode) if mode is not None: normalized[mode_key] = mode channel = settings.get("channelNumber", settings.get("channel_number", settings.get("channel", settings.get(channel_key)))) if isinstance(channel, str): channel_norm = channel.strip().lower() if channel_norm in {"auto", "automatic"}: channel = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_CHANNEL_AUTOMATIC", -1) elif channel_norm == "mix": channel = _resolve_audio_constant(resolve_obj, "AUDIO_SYNC_CHANNEL_MIX", -2) if channel is not None: normalized[channel_key] = channel for source_key, target_key in ( ("retainEmbeddedAudio", retain_embedded_key), ("retain_embedded_audio", retain_embedded_key), ("retainVideoMetadata", retain_metadata_key), ("retain_video_metadata", retain_metadata_key), ): if source_key in settings: normalized[target_key] = bool(settings[source_key]) for key, value in settings.items(): if key not in {"syncBy", "sync_by", "mode", "channelNumber", "channel_number", "channel", "retainEmbeddedAudio", "retain_embedded_audio", "retainVideoMetadata", "retain_video_metadata"}: normalized.setdefault(key, value) return normalized def _transcription_capabilities(mp, p: Dict[str, Any]): root = mp.GetRootFolder() clips = [] ids = p.get("clip_ids") if ids: if not isinstance(ids, list): return _err("clip_ids must be a list") clips = [_find_clip(root, str(clip_id)) for clip_id in ids] clips = [clip for clip in clips if clip] elif p.get("selected"): clips = mp.GetSelectedClips() or [] current_folder = mp.GetCurrentFolder() return { "clip_methods": [ { "summary": _media_pool_item_summary(clip), "transcribe_audio": _has_method(clip, "TranscribeAudio"), "clear_transcription": _has_method(clip, "ClearTranscription"), "perform_audio_classification": _has_method(clip, "PerformAudioClassification"), "clear_audio_classification": _has_method(clip, "ClearAudioClassification"), "analyze_for_intellisearch": _has_method(clip, "AnalyzeForIntellisearch"), "analyze_for_slate": _has_method(clip, "AnalyzeForSlate"), "remove_motion_blur": _has_method(clip, "RemoveMotionBlur"), } for clip in clips ], "folder": { "name": current_folder.GetName() if current_folder else None, "transcribe_audio": _has_method(current_folder, "TranscribeAudio") if current_folder else False, "clear_transcription": _has_method(current_folder, "ClearTranscription") if current_folder else False, "perform_audio_classification": _has_method(current_folder, "PerformAudioClassification") if current_folder else False, "clear_audio_classification": _has_method(current_folder, "ClearAudioClassification") if current_folder else False, "analyze_for_intellisearch": _has_method(current_folder, "AnalyzeForIntellisearch") if current_folder else False, "analyze_for_slate": _has_method(current_folder, "AnalyzeForSlate") if current_folder else False, "remove_motion_blur": _has_method(current_folder, "RemoveMotionBlur") if current_folder else False, }, "notes": [ "This action reports capability only; use media_pool_item/folder actions to mutate disposable or approved clips.", "Transcription/audio-classification may require Resolve Studio AI components and can run asynchronously.", "analyze_for_intellisearch requires the 'AI IntelliSearch' Extra; analyze_for_slate requires 'AI Slate ID'; remove_motion_blur creates new media and is confirm-gated (Resolve 21+).", ], } def _subtitle_generation_probe(tl, p: Dict[str, Any]): settings = dict(p.get("settings") or {}) if not p.get("allow_generate", False): return _ok(would_generate=True, settings=settings, note="Pass allow_generate=True to call CreateSubtitlesFromAudio.") if not _has_method(tl, "CreateSubtitlesFromAudio"): return _err("CreateSubtitlesFromAudio unavailable") return {"success": bool(tl.CreateSubtitlesFromAudio(settings)), "settings": settings} def _fairlight_boundary_report(proj, mp, tl, p: Dict[str, Any]): fairlight_presets = None preset_error = None resolve_obj = resolve if resolve_obj is not None and _has_method(resolve_obj, "GetFairlightPresets"): try: fairlight_presets = _ser(resolve_obj.GetFairlightPresets() or []) except Exception as exc: preset_error = str(exc) return { "capabilities": _audio_capabilities(), "track": _audio_track_probe(tl, p), "item": _probe_audio_item(tl, p) if int(tl.GetTrackCount(p.get("track_type", "audio")) or 0) else None, "voice_isolation": _voice_isolation_capabilities(tl, p), "audio_mapping": _audio_mapping_report(mp, tl, p), "transcription": _transcription_capabilities(mp, p), "fairlight_presets": {"value": fairlight_presets, "error": preset_error} if preset_error else fairlight_presets, "project_methods": _callable_method_names(proj, ["InsertAudioToCurrentTrackAtPlayhead", "ApplyFairlightPresetToCurrentTimeline"]), } _MEDIA_POOL_ITEM_METHODS = [ "GetName", "SetName", "GetMetadata", "SetMetadata", "GetThirdPartyMetadata", "SetThirdPartyMetadata", "GetMediaId", "GetClipProperty", "SetClipProperty", "GetMarkers", "AddMarker", "GetMarkerByCustomData", "UpdateMarkerCustomData", "GetMarkerCustomData", "DeleteMarkersByColor", "DeleteMarkerAtFrame", "DeleteMarkerByCustomData", "AddFlag", "GetFlagList", "ClearFlags", "GetClipColor", "SetClipColor", "ClearClipColor", "LinkProxyMedia", "UnlinkProxyMedia", "ReplaceClip", "LinkFullResolutionMedia", "MonitorGrowingFile", "ReplaceClipPreserveSubClip", "TranscribeAudio", "ClearTranscription", "GetAudioMapping", "GetMarkInOut", "SetMarkInOut", "ClearMarkInOut", ] _MEDIA_POOL_METHODS = [ "GetRootFolder", "AddSubFolder", "CreateEmptyTimeline", "CreateTimelineFromClips", "ImportTimelineFromFile", "DeleteTimelines", "AppendToTimeline", "GetCurrentFolder", "SetCurrentFolder", "DeleteFolders", "DeleteClips", "MoveClips", "MoveFolders", "RefreshFolders", "RelinkClips", "UnlinkClips", "ImportMedia", "ExportMetadata", "GetUniqueId", "CreateStereoClip", "AutoSyncAudio", "GetSelectedClips", "SetSelectedClip", "GetClipMatteList", "GetTimelineMatteList", "DeleteClipMattes", "ImportFolderFromFile", ] _MEDIA_POOL_KNOWN_CLIP_PROPERTIES = [ "File Path", "Type", "Format", "FPS", "Frames", "Duration", "Start TC", "End TC", "Resolution", "Codec", "Bit Depth", "Audio Ch", "Sample Rate", "Data Level", "PAR", "Alpha mode", ] _MEDIA_POOL_KERNEL_ACTIONS = [ "ingest_capabilities", "setup_multicam_timeline", "probe_ingest_item", "probe_media_pool", "safe_import_media", "safe_import_sequence", "safe_import_folder", "organize_clips", "copy_metadata", "normalize_metadata", "probe_clip_properties", "metadata_field_inventory", "safe_relink", "safe_unlink", "link_proxy_checked", "link_full_resolution_checked", "set_clip_marks", "clear_clip_marks", "copy_clip_annotations", "media_pool_boundary_report", ] def _ensure_timeline_tracks(tl, track_type: str, needed: int, *, audio_type: str = "stereo"): """Ensure a timeline exposes at least `needed` tracks of the requested type.""" needed = max(0, int(needed or 0)) added = 0 try: current = int(tl.GetTrackCount(track_type) or 0) except Exception as exc: return {"success": False, "error": f"GetTrackCount({track_type}) failed: {exc}"} while current < needed: try: if track_type == "audio": ok = tl.AddTrack(track_type, {"audioType": audio_type}) else: ok = tl.AddTrack(track_type) except TypeError: ok = tl.AddTrack(track_type) except Exception as exc: return {"success": False, "error": f"AddTrack({track_type}) failed: {exc}"} if not ok: return {"success": False, "error": f"AddTrack({track_type}) returned false at track {current + 1}"} added += 1 current += 1 return {"success": True, "existing": current - added, "added": added, "count": current} def _set_multicam_track_names(tl, plan: Dict[str, Any]): results = [] for angle in plan.get("angles") or []: angle_name = angle.get("angle_name") or f"Angle {angle.get('angle_index')}" v_index = angle.get("video_track_index") if v_index: try: results.append({ "track_type": "video", "track_index": v_index, "name": angle_name, "success": bool(tl.SetTrackName("video", int(v_index), str(angle_name))), }) except Exception as exc: results.append({"track_type": "video", "track_index": v_index, "success": False, "error": str(exc)}) a_index = angle.get("audio_track_index") if a_index: try: results.append({ "track_type": "audio", "track_index": a_index, "name": angle_name, "success": bool(tl.SetTrackName("audio", int(a_index), str(angle_name))), }) except Exception as exc: results.append({"track_type": "audio", "track_index": a_index, "success": False, "error": str(exc)}) return results def _setup_multicam_timeline(proj, mp, p: Dict[str, Any]): root = mp.GetRootFolder() plan, plan_err = build_multicam_setup_plan(root, p, _find_clip) if plan_err: return plan_err if p.get("dry_run", False): return { **plan, "dry_run": True, "would_create_timeline": True, "would_append": len(plan.get("append_rows") or []), } new_tl = mp.CreateEmptyTimeline(plan["name"]) if not new_tl: return _err(f"Failed to create multicam setup timeline: {plan['name']}") try: proj.SetCurrentTimeline(new_tl) except Exception: pass if plan.get("start_timecode"): try: new_tl.SetStartTimecode(plan["start_timecode"]) except Exception: pass audio_type = str(p.get("audio_type", p.get("audioType", "stereo")) or "stereo") video_tracks = _ensure_timeline_tracks(new_tl, "video", plan.get("max_video_track", 0)) if not video_tracks.get("success"): return video_tracks audio_tracks = _ensure_timeline_tracks(new_tl, "audio", plan.get("max_audio_track", 0), audio_type=audio_type) if not audio_tracks.get("success"): return audio_tracks track_names = _set_multicam_track_names(new_tl, plan) timeline_start = _timeline_start_frame(new_tl) append_infos = [] append_rows = plan.get("append_rows") or [] for index, row in enumerate(append_rows): clip_info, clip_err = _build_append_clip_info_dict(root, row, index, timeline_start) if clip_err: return clip_err append_infos.append(clip_info) appended = mp.AppendToTimeline(append_infos) if not appended: return _err("AppendToTimeline returned no items for multicam setup") items_out = [] for index, item in enumerate(appended): item_out, item_err = _serialize_appended_timeline_item(item, index, allow_empty_timeline_item_id=True) if item_err: return item_err item_out["setup_row"] = append_rows[index] items_out.append(item_out) return { **plan, "dry_run": False, "timeline_name": new_tl.GetName(), "timeline_id": new_tl.GetUniqueId(), "items": items_out, "track_setup": {"video": video_tracks, "audio": audio_tracks, "names": track_names}, } def _safe_clip_call(clip, method_name: str, *args): method = getattr(clip, method_name, None) if not callable(method): return None, f"{method_name} unavailable" try: return _ser(method(*args)), None except Exception as exc: return None, str(exc) def _media_pool_item_summary(clip): if not clip: return None summary = { "name": _safe_media_pool_item_name(clip), "id": _safe_media_pool_item_id(clip), "media_id": None, "file_path": None, "type": None, "duration": None, } media_id, _ = _safe_clip_call(clip, "GetMediaId") if media_id is not None: summary["media_id"] = media_id properties, _ = _safe_clip_call(clip, "GetClipProperty", "") if isinstance(properties, dict): summary["file_path"] = properties.get("File Path") or properties.get("FilePath") summary["type"] = properties.get("Type") summary["duration"] = properties.get("Duration") return summary def _project_name_and_id(project) -> Tuple[Optional[str], Optional[str]]: name = project_id = None if project and _has_method(project, "GetName"): try: name = project.GetName() except Exception: name = None if project and _has_method(project, "GetUniqueId"): try: project_id = project.GetUniqueId() except Exception: project_id = None return name, project_id def _media_analysis_clip_record(clip, bin_path: Optional[str] = None) -> Optional[Dict[str, Any]]: if not clip: return None props, props_error = _safe_clip_call(clip, "GetClipProperty", "") props = props if isinstance(props, dict) else {} file_path = props.get("File Path") or props.get("FilePath") record = { "clip_id": _safe_media_pool_item_id(clip), "clip_name": _safe_media_pool_item_name(clip), "bin_path": bin_path, "file_path": file_path, "media_id": _safe_clip_call(clip, "GetMediaId")[0], "duration": props.get("Duration"), "fps": props.get("FPS"), "resolution": props.get("Resolution"), "media_type": props.get("Type"), "clip_properties_error": props_error, } third_party, _ = _safe_clip_call(clip, "GetThirdPartyMetadata", "") if isinstance(third_party, dict): mcp_third_party = { key: value for key, value in third_party.items() if str(key).startswith("davinci_resolve_mcp.") } if mcp_third_party: record["third_party_metadata"] = mcp_third_party report_path = third_party.get("davinci_resolve_mcp.analysis_report_path") if report_path: record["analysis_report_path"] = report_path signature = third_party.get("davinci_resolve_mcp.analysis_signature") if signature: record["published_analysis_signature"] = signature published_at = third_party.get("davinci_resolve_mcp.published_at") if published_at: record["published_analysis_at"] = published_at metadata, _ = _safe_clip_call(clip, "GetMetadata", "") if isinstance(metadata, dict): fields_with_analysis = [] for field in ("Description", "Comments", "Keywords", "Keyword", "People"): value = metadata.get(field) if isinstance(value, (list, tuple, set)): text = "\n".join(str(item) for item in value) else: text = str(value or "") if "[DaVinci Resolve MCP Analysis]" in text or "davinci_resolve_mcp." in text: fields_with_analysis.append(field) if fields_with_analysis: record["analysis_metadata_present"] = True record["analysis_metadata_fields"] = fields_with_analysis provenance = {} if record.get("analysis_report_path"): provenance["analysis_report_path"] = record["analysis_report_path"] if record.get("published_analysis_signature"): provenance["analysis_signature"] = record["published_analysis_signature"] if record.get("published_analysis_at"): provenance["published_at"] = record["published_analysis_at"] if record.get("analysis_metadata_present"): provenance["standard_metadata_fields"] = record.get("analysis_metadata_fields", []) if record.get("third_party_metadata"): provenance["third_party_keys"] = sorted(record["third_party_metadata"].keys()) if provenance: record["analysis_provenance"] = provenance return record _MEDIA_ANALYSIS_CONTAINER_TYPE_PARTS = ( "adjustment", "compound", "fusion", "generator", "multicam", "multi cam", "sequence", "subclip", "sub clip", "timeline", "title", ) def _media_analysis_record_analyzable(record: Dict[str, Any]) -> Tuple[bool, str]: media_type = str(record.get("media_type") or "").strip().lower() for marker in _MEDIA_ANALYSIS_CONTAINER_TYPE_PARTS: if marker in media_type: return False, f"Resolve {record.get('media_type') or 'container'} item" file_path = record.get("file_path") if not file_path: return False, "No source file path exposed" extension = os.path.splitext(str(file_path))[1].lower() if extension not in MEDIA_EXTENSIONS: return False, f"Unsupported extension: {extension or 'none'}" if not os.path.isfile(str(file_path)): return False, "Source file missing" return True, "Online source media" def _media_analysis_folder_records(folder, bin_path: str = "Master", recursive: bool = True) -> Tuple[List[Dict[str, Any]], List[str]]: records: List[Dict[str, Any]] = [] warnings: List[str] = [] if not folder: return records, ["Folder unavailable"] try: clips = folder.GetClipList() or [] except Exception as exc: return records, [f"GetClipList failed for {bin_path}: {exc}"] for clip in clips: record = _media_analysis_clip_record(clip, bin_path) if not record: continue if not record.get("file_path"): warnings.append(f"Skipping clip without file path: {record.get('clip_name')}") continue records.append(record) if recursive: try: subfolders = folder.GetSubFolderList() or [] except Exception as exc: warnings.append(f"GetSubFolderList failed for {bin_path}: {exc}") subfolders = [] for sub in subfolders: try: sub_name = sub.GetName() except Exception: sub_name = "Unnamed" child_records, child_warnings = _media_analysis_folder_records( sub, f"{bin_path}/{sub_name}", recursive=True, ) records.extend(child_records) warnings.extend(child_warnings) return records, warnings def _media_analysis_dedupe_records(records: List[Dict[str, Any]], include_duplicates: bool = False) -> Tuple[List[Dict[str, Any]], int]: if include_duplicates: return records, 0 seen = set() deduped = [] duplicate_count = 0 for record in records: key = record.get("file_path") or record.get("media_id") or record.get("clip_id") if key in seen: duplicate_count += 1 continue seen.add(key) deduped.append(record) return deduped, duplicate_count def _media_analysis_sequence_track_types(raw: Any) -> List[str]: if raw in (None, "", []): return ["video", "audio"] if isinstance(raw, str): values = re.split(r"[\s,]+", raw.strip()) elif isinstance(raw, list): values = [str(value).strip() for value in raw] else: values = [str(raw).strip()] out: List[str] = [] for value in values: lower = value.lower() if lower in {"all", "*"}: return ["video", "audio", "subtitle"] if lower in {"video", "audio", "subtitle"} and lower not in out: out.append(lower) return out or ["video", "audio"] def _media_analysis_timeline_records(project, target: Dict[str, Any], p: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], Dict[str, Any], List[str], Optional[Dict[str, Any]]]: warnings: List[str] = [] if project is None: return [], {}, warnings, _err("Resolve project is required for sequence/timeline targets") timeline_index = target.get("timeline_index") or target.get("timelineIndex") or p.get("timeline_index") or p.get("timelineIndex") if timeline_index not in (None, ""): try: tl = project.GetTimelineByIndex(int(timeline_index)) except Exception as exc: return [], {}, warnings, _err(f"GetTimelineByIndex failed: {exc}") else: try: tl = project.GetCurrentTimeline() except Exception as exc: return [], {}, warnings, _err(f"GetCurrentTimeline failed: {exc}") if not tl: return [], {}, warnings, _err("No current timeline for sequence analysis") track_types = _media_analysis_sequence_track_types( target.get("track_types") or target.get("trackTypes") or p.get("track_types") or p.get("trackTypes") ) records_by_key: Dict[str, Dict[str, Any]] = {} occurrence_count = 0 for track_type in track_types: try: track_count = int(tl.GetTrackCount(track_type) or 0) except Exception as exc: warnings.append(f"GetTrackCount failed for {track_type}: {exc}") continue for track_index in range(1, track_count + 1): try: items = tl.GetItemListInTrack(track_type, track_index) or [] except Exception as exc: warnings.append(f"GetItemListInTrack failed for {track_type} {track_index}: {exc}") continue for item_index, item in enumerate(items): occurrence_count += 1 media_pool_item = _timeline_item_media_pool_item(item) summary = _timeline_item_summary(item, track_info=(track_type, track_index)) or {} summary["item_index"] = item_index if not media_pool_item: warnings.append(f"Skipping timeline item without Media Pool source: {summary.get('name') or summary.get('timeline_item_id')}") continue record = _media_analysis_clip_record(media_pool_item) if not record: continue if not record.get("file_path"): warnings.append(f"Skipping timeline source without file path: {record.get('clip_name')}") continue key = str(record.get("file_path") or record.get("media_id") or record.get("clip_id")) if key not in records_by_key: record["timeline_occurrences"] = [] records_by_key[key] = record records_by_key[key].setdefault("timeline_occurrences", []).append(summary) timeline_info = { "name": None, "id": None, "track_types": track_types, "occurrence_count": occurrence_count, "asset_count": len(records_by_key), } try: timeline_info["name"] = tl.GetName() except Exception: pass try: timeline_info["id"] = tl.GetUniqueId() except Exception: pass return list(records_by_key.values()), timeline_info, warnings, None def _media_analysis_bool(value: Any, default: bool = False) -> bool: if value is None: return default if isinstance(value, bool): return value if isinstance(value, str): return value.strip().lower() in {"1", "true", "yes", "y", "on"} return bool(value) def _media_analysis_target_dict(raw_target: Any, p: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: p = p or {} if raw_target is None: return {} if isinstance(raw_target, dict): return dict(raw_target) if isinstance(raw_target, str): value = raw_target.strip() if not value: return {} lower = value.lower() if lower in {"project", "all", "all_media", "all media"}: return {"type": "project", "recursive": True} if lower in {"timeline", "sequence", "current_timeline", "current timeline", "current_sequence", "current sequence"}: return {"type": "sequence"} if lower in {"selected", "selected_clip", "selected clip", "current", "current clip"}: return {"type": "clip", "selected": True} if lower.startswith("bin:"): path = value.split(":", 1)[1].strip() or "Master" return {"type": "bin", "path": path, "recursive": True} if lower in {"bin", "folder"}: return { "type": "bin", "path": p.get("bin_path") or p.get("path") or "Master", "recursive": True, } if lower in {"clip", "clips", "file"}: return {"type": lower} expanded = os.path.expanduser(value) if os.path.isabs(expanded): return {"type": "file", "path": expanded} return { "_invalid_target": ( "Unsupported media_analysis target string. Use 'project', " "'selected', 'timeline', 'sequence', 'bin:', or an absolute file path." ) } return {"_invalid_target": f"target must be an object or string, got {type(raw_target).__name__}"} def _media_analysis_extract_json_text(text: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: raw = (text or "").strip() if raw.startswith("```"): raw = re.sub(r"^```(?:json)?\s*", "", raw, flags=re.IGNORECASE) raw = re.sub(r"\s*```$", "", raw) try: payload = json.loads(raw) except json.JSONDecodeError: start = raw.find("{") end = raw.rfind("}") if start < 0 or end <= start: return None, "Sampling response did not contain a JSON object" try: payload = json.loads(raw[start:end + 1]) except json.JSONDecodeError as exc: return None, f"Sampling response JSON parse failed: {exc}" if not isinstance(payload, dict): return None, "Sampling response JSON must be an object" return payload, None def _media_analysis_capabilities_for_request(ctx: Optional[Context]) -> Dict[str, Any]: """Capabilities report shaped for the active MCP request. Vision uses host_chat_paths: the server emits a deferred payload with absolute frame paths and the host chat completes the analysis via media_analysis(action="commit_vision"). No sampling/createMessage required. """ caps = detect_media_analysis_capabilities() vision = caps.setdefault("vision", {}) prefs = _media_analysis_effective_preferences() default_on = prefs.get("vision_default") == "on" configured_provider = vision.get("provider") or HOST_CHAT_PATHS_PROVIDER vision["enabled_by_default"] = default_on vision["default_provider"] = HOST_CHAT_PATHS_PROVIDER vision["provider"] = configured_provider vision["available"] = True vision["availability"] = "ready" if configured_provider in HOST_CHAT_VISION_PROVIDERS or configured_provider in {"mock", "local_mock"} else "configured_provider" vision["host_chat_paths"] = { "available": True, "provider": HOST_CHAT_PATHS_PROVIDER, "requires": "MCP host chat able to read local image files (e.g. Claude Code Read tool, Claude Desktop image input)", "commit_action": {"tool": "media_analysis", "action": "commit_vision"}, "schema_reference": VISION_SCHEMA_REFERENCE, } vision["note"] = ( "Vision is enabled by default through host_chat_paths. Analyze actions " "return absolute frame paths in a deferred payload; the host chat reads " "them as images, produces JSON per the included schema, and calls " "media_analysis(action='commit_vision', ...) to merge and publish. " "Works with any MCP client whose chat model is vision-capable." ) return caps DEFAULT_TIMELINE_MARKER_REVIEW_PROMPT = """Return only strict JSON for Resolve marker/contact-sheet review. Read the contact-sheet image at image_path as a local image, then check whether marker names/notes match what the rendered Resolve frames actually show. Be precise, editorial, and source-safe. Use this schema: { "success": true, "provider": "host_chat_paths", "timeline_summary": "Concise editorial read of the marker frames.", "marker_checks": [ { "label": "Contact-sheet label or timecode.", "matches_marker_intent": "yes|no|unclear", "visible_evidence": "What the frame shows.", "recommended_action": "keep|rename_marker|move_marker|review_cut|ignore" } ], "editorial_risks": [], "next_actions": [], "confidence": "low|medium|high" } Do not include markdown fences, prose outside JSON, or keys outside this schema.""" def _media_analysis_host_chat_image_review_payload( image_path: str, metadata: Dict[str, Any], vision: Dict[str, Any], ) -> Dict[str, Any]: """Return a deferred host_chat_paths payload pointing at an image to review. Unlike clip analysis, marker review writes nothing back to Resolve, so there is no commit step — the host chat can read the image and answer inline. """ if not image_path or not os.path.isfile(image_path): return { "success": False, "status": "skipped", "provider": HOST_CHAT_PATHS_PROVIDER, "reason": f"Review image not found: {image_path}", } return { "success": True, "status": "pending_host_analysis", "provider": HOST_CHAT_PATHS_PROVIDER, "image_path": os.path.realpath(os.path.abspath(image_path)), "metadata": metadata, "prompt": str(vision.get("prompt") or DEFAULT_TIMELINE_MARKER_REVIEW_PROMPT), "instructions": ( "Read image_path as a local image using your client's image-reading " "capability. Produce a single JSON object matching the structure in " "prompt and return it to the user inline. No follow-up tool call is " "required — marker review does not persist to Resolve." ), } def _media_analysis_records_from_target(mp, p: Dict[str, Any], project=None) -> Tuple[Optional[List[Dict[str, Any]]], Dict[str, Any], List[str], Optional[Dict[str, Any]]]: target = _media_analysis_target_dict(p.get("target"), p) if target.get("_invalid_target"): return None, target, [], _err(target["_invalid_target"]) target_type = str(target.get("type") or p.get("target_type") or "clip").strip().lower() warnings: List[str] = [] if target_type == "file": file_path = target.get("path") or p.get("path") or p.get("file_path") or p.get("filePath") if not file_path: return None, target, warnings, _err("file target requires path or file_path") file_path = os.path.realpath(os.path.abspath(os.path.expanduser(str(file_path)))) if not os.path.isfile(file_path): return None, target, warnings, _err(f"Media file not found: {file_path}") target.update({"type": "file", "path": file_path}) return ([{ "clip_id": None, "clip_name": os.path.basename(file_path), "bin_path": None, "file_path": file_path, "media_id": None, "duration": None, "fps": None, "resolution": None, "media_type": "file", }], target, warnings, None) if target_type in {"sequence", "timeline", "current_timeline", "current_sequence"}: records, timeline_info, timeline_warnings, timeline_err = _media_analysis_timeline_records(project, target, p) warnings.extend(timeline_warnings) if timeline_err: return None, target, warnings, timeline_err target.update({"type": "sequence", "timeline": timeline_info}) elif mp is None: return None, target, warnings, _err("Resolve Media Pool is required for clip, bin, project, and sequence targets") elif target_type == "clip": clip_id = target.get("clip_id") or p.get("clip_id") selected = bool(target.get("selected") or p.get("selected")) clips = [] if selected: try: clips = mp.GetSelectedClips() or [] except Exception as exc: return None, target, warnings, _err(f"GetSelectedClips failed: {exc}") if not clips: return None, target, warnings, _err("No Media Pool clips are selected") else: if not clip_id: return None, target, warnings, _err("clip target requires clip_id or selected=true") clip = _find_clip(mp.GetRootFolder(), clip_id) if not clip: return None, target, warnings, _err(f"Clip not found: {clip_id}") clips = [clip] records = [] for clip in clips: record = _media_analysis_clip_record(clip) if record and record.get("file_path"): records.append(record) elif record: warnings.append(f"Skipping clip without file path: {record.get('clip_name')}") target.update({"type": "clip", "selected": selected, "clip_id": clip_id}) elif target_type == "clips": raw_ids = target.get("clip_ids") or target.get("clipIds") or p.get("clip_ids") or p.get("clipIds") or p.get("ids") if isinstance(raw_ids, str): clip_ids = [item.strip() for item in raw_ids.split(",") if item.strip()] elif isinstance(raw_ids, list): clip_ids = [str(item).strip() for item in raw_ids if str(item).strip()] else: clip_ids = [] if not clip_ids: return None, target, warnings, _err("clips target requires clip_ids") root_folder = mp.GetRootFolder() records = [] missing_ids = [] skipped = [] for clip_id in clip_ids: clip = _find_clip(root_folder, clip_id) if not clip: missing_ids.append(clip_id) continue record = _media_analysis_clip_record(clip) if not record: skipped.append({"clip_id": clip_id, "reason": "clip record unavailable"}) continue analyzable, reason = _media_analysis_record_analyzable(record) if not analyzable: skipped.append({"clip_id": clip_id, "clip_name": record.get("clip_name"), "reason": reason}) continue records.append(record) if missing_ids: return None, target, warnings, _err(f"Clip(s) not found: {', '.join(missing_ids)}") if skipped: warnings.extend([ f"Skipping non-analyzable clip {item.get('clip_name') or item.get('clip_id')}: {item.get('reason')}" for item in skipped ]) target.update({"type": "clips", "clip_ids": clip_ids, "skipped": skipped}) elif target_type == "bin": path = target.get("path") or p.get("bin_path") or p.get("path") or "Master" recursive = bool(target.get("recursive", p.get("recursive", True))) folder = _navigate_folder(mp, path) if not folder: return None, target, warnings, _err(f"Folder not found: {path}") records, folder_warnings = _media_analysis_folder_records(folder, path if path else "Master", recursive) warnings.extend(folder_warnings) target.update({"type": "bin", "path": path, "recursive": recursive}) elif target_type == "project": recursive = bool(target.get("recursive", True)) records, folder_warnings = _media_analysis_folder_records(mp.GetRootFolder(), "Master", recursive=recursive) warnings.extend(folder_warnings) target.update({"type": "project", "recursive": recursive}) else: return None, target, warnings, _err("target.type must be one of file, clip, clips, bin, project, sequence, timeline") records, duplicate_count = _media_analysis_dedupe_records(records, bool(p.get("include_duplicates", target.get("include_duplicates", False)))) if duplicate_count: warnings.append(f"Deduped {duplicate_count} repeated source media reference(s)") if not records: return None, target, warnings, _err("No analyzable media with file paths found for target") return records, target, warnings, None def _media_analysis_sync_event_records(proj, p: Dict[str, Any]): paths = p.get("paths") or p.get("file_paths") or p.get("filePaths") warnings: List[str] = [] normalized_target: Dict[str, Any] = {"type": "paths"} if paths else _media_analysis_target_dict(p.get("target"), p) if paths: if isinstance(paths, str): paths = [paths] if not isinstance(paths, list) or not paths: return None, normalized_target, warnings, _err("paths must be a non-empty string or list") records = [] for index, path in enumerate(paths): expanded = os.path.realpath(os.path.abspath(os.path.expanduser(str(path)))) if not os.path.isfile(expanded): return None, normalized_target, warnings, _err(f"Media file not found: {expanded}") records.append({ "clip_id": None, "clip_name": os.path.basename(expanded), "bin_path": None, "file_path": expanded, "media_id": None, "duration": None, "fps": p.get("fps"), "resolution": None, "media_type": "file", "source_index": index, }) normalized_target["paths"] = [record["file_path"] for record in records] return records, normalized_target, warnings, None if not p.get("target") and (p.get("path") or p.get("file_path") or p.get("filePath")): p["target"] = { "type": "file", "path": p.get("path") or p.get("file_path") or p.get("filePath"), } mp = proj.GetMediaPool() if not mp: return None, normalized_target, warnings, _err("Failed to get MediaPool") records, normalized_target, warnings, target_err = _media_analysis_records_from_target(mp, p, project=proj) return records, normalized_target, warnings, target_err def _media_analysis_confirmed(p: Dict[str, Any]) -> bool: return _media_analysis_bool( p.get("confirm", p.get("confirmed", p.get("apply", p.get("write_markers")))), False, ) def _media_analysis_publish_confirmed(p: Dict[str, Any]) -> bool: explicit = _first_param(p, "confirm", "confirmed", "apply") if explicit is not None: return _media_analysis_bool(explicit, False) return not _media_analysis_effective_preferences().get("ask_before_metadata_publish") _MEDIA_ANALYSIS_PREFS_ENV = "DAVINCI_RESOLVE_MCP_MEDIA_ANALYSIS_PREFS" _SETUP_CHOICE_CLEAR_VALUES = {"", "ask", "prompt", "clear", "default", "none", "null", "unset"} _SETUP_YES_NO_VALUES = {"yes", "no"} _MEDIA_ANALYSIS_DEFAULT_MARKER_TYPES = ["shots", "slate_clap", "sync_events", "best_moments", "qc_warnings"] _MEDIA_ANALYSIS_DEFAULT_MARKER_COLORS = { "shots": "Blue", "slate_clap": "Cyan", "sync_events": "Cyan", "best_moments": "Green", "qc_warnings": "Red", } _MEDIA_ANALYSIS_MARKER_TYPE_ALIASES = { "slate": "slate_clap", "slate_claps": "slate_clap", "slate_clap": "slate_clap", "slate-clap": "slate_clap", "shot": "shots", "shots": "shots", "shot_range": "shots", "shot_ranges": "shots", "sync": "sync_events", "sync_event": "sync_events", "sync_events": "sync_events", "best": "best_moments", "best_moment": "best_moments", "best_moments": "best_moments", "qc": "qc_warnings", "qc_flag": "qc_warnings", "qc_flags": "qc_warnings", "qc_warning": "qc_warnings", "qc_warnings": "qc_warnings", } _MEDIA_ANALYSIS_DEFAULT_PREFS = { "timed_markers_default": "ask", "slate_detection_default": "yes", "vision_default": "on", "transcription_default": "yes", "analysis_persistence": "keep_artifacts", "metadata_publish_fields": [ "Description", "Comments", "Keywords", "People", "Scene", "Shot", "Take", "Camera #", "Roll Card #", ], "metadata_overwrite_policy": "preserve_human", "timed_marker_types": list(_MEDIA_ANALYSIS_DEFAULT_MARKER_TYPES), "timed_marker_colors": dict(_MEDIA_ANALYSIS_DEFAULT_MARKER_COLORS), "max_timed_markers_per_clip": 0, "include_confidence_scores": True, "include_source_time_notes": True, "analysis_summary_style": "concise", "report_format": "compact", "source_trust": "auto", "default_depth": "standard", "default_sample_frames": 8, # Frame-sampling mode — how many frames a clip gets for visual analysis. # "ask" prompts the user to choose a standing default the first time they # analyze; the choice is then saved here. Canonical modes (see # media_analysis.SAMPLING_MODES): fixed (Economy), per_minute (Balanced), # adaptive_capped (Thorough, recommended), adaptive (Thorough uncapped). "sampling_mode_default": "ask", # Tunables shared by Balanced + Thorough modes. frames_per_minute drives the # Balanced target; frame_floor/frame_ceiling bound every duration/content # scaled mode (the ceiling is also the Thorough per-clip cap). "sampling_frames_per_minute": 4.0, "sampling_frame_floor": 3, "sampling_frame_ceiling": 80, "preferred_analysis_root": None, "preferred_generated_media_folder": None, "default_post_operation_page": "stay_put", "marker_custom_data": "namespaced", "metadata_writeback_default": True, "ask_before_metadata_publish": False, "dry_run_first_default": False, # C6: when true, archive_current_timeline calls project.SaveProject() after # recording the version so a Resolve crash can't lose the archive. Default # off because automatic saves can disrupt interactive editing sessions. "timeline_versioning_auto_save_after_archive": False, # Analysis caps preset (minimal | standard | generous | unlimited). Controls # per-clip frame budget, response payload trim, vision token budgets # per-clip / per-job / per-day, wall-clock timeout, and frame image-size cap. "analysis_caps_preset": "standard", # Per-field overrides; merged into the preset at resolve time. Keys must # match Caps fields (response_chars, vision_tokens_per_clip, frames_per_clip, # vision_tokens_per_job, vision_tokens_per_day, wall_clock_seconds_per_call, # max_frame_dim_pixels). Pass an integer or "unlimited" to lift that cap. "analysis_caps_overrides": {}, # Soft governance tier for the media-creating Resolve 21 AI ops (deblur / # speech): off | lenient | standard | strict. Advisory only — surfaced in the # confirm preview + panel; never hard-blocks (the ops are confirm-gated). "resolve_ai_governance_preset": "standard", "resolve_ai_governance_overrides": {}, } def _media_analysis_preferences_path() -> str: override = os.environ.get(_MEDIA_ANALYSIS_PREFS_ENV) if override: return os.path.realpath(os.path.abspath(os.path.expanduser(override))) return os.path.join(project_dir, "logs", "media-analysis-preferences.json") def _read_media_analysis_preferences() -> Dict[str, Any]: path = _media_analysis_preferences_path() try: with open(path, "r", encoding="utf-8") as handle: payload = json.load(handle) return payload if isinstance(payload, dict) else {} except (OSError, json.JSONDecodeError): return {} def _write_media_analysis_preferences(preferences: Dict[str, Any]) -> None: path = _media_analysis_preferences_path() os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as handle: json.dump(preferences, handle, indent=2, sort_keys=True) handle.write("\n") def _setup_text_key(value: Any) -> str: return str(value or "").strip().lower().replace("-", "_").replace(" ", "_") def _normalize_yes_no_ask(value: Any) -> Optional[str]: if value is None: return None if isinstance(value, bool): return "yes" if value else "no" raw = _setup_text_key(value) if raw in {"1", "true", "y", "yes", "on", "enable", "enabled"}: return "yes" if raw in {"0", "false", "n", "no", "off", "disable", "disabled"}: return "no" if raw in {"ask", "prompt", "ask_me", "ask_user"}: return "ask" if raw in _SETUP_CHOICE_CLEAR_VALUES: return "ask" return None def _normalize_vision_default(value: Any) -> Optional[str]: if value is None: return None if isinstance(value, bool): return "on" if value else "off" raw = _setup_text_key(value) aliases = { "1": "on", "true": "on", "yes": "on", "on": "on", "enable": "on", "enabled": "on", "chat": "on", "chat_context": "on", "host_chat": "on", "host_chat_paths": "on", "0": "off", "false": "off", "no": "off", "off": "off", "disable": "off", "disabled": "off", "technical": "technical_only", "technical_only": "technical_only", "technicalonly": "technical_only", "ask": "ask", "prompt": "ask", } return aliases.get(raw) def _normalize_analysis_persistence(value: Any) -> Optional[str]: raw = _setup_text_key(value) aliases = { "": "session_only", "session": "session_only", "session_only": "session_only", "scratch": "session_only", "temporary": "session_only", "keep": "keep_reports", "persist": "keep_reports", "persistent": "keep_reports", "reports": "keep_reports", "keep_reports": "keep_reports", "keep_report": "keep_reports", "artifacts": "keep_artifacts", "keep_artifacts": "keep_artifacts", "keep_frames": "keep_artifacts", "frames": "keep_artifacts", } return aliases.get(raw) def _normalize_metadata_overwrite_policy(value: Any) -> Optional[str]: raw = _setup_text_key(value) aliases = { "preserve": "preserve_human", "preserve_human": "preserve_human", "preserve_existing": "preserve_human", "safe": "preserve_human", "fill": "fill_empty", "fill_empty": "fill_empty", "empty_only": "fill_empty", "owned": "overwrite_owned_blocks", "owned_blocks": "overwrite_owned_blocks", "overwrite_owned": "overwrite_owned_blocks", "overwrite_owned_blocks": "overwrite_owned_blocks", "overwrite": "overwrite_all", "overwrite_all": "overwrite_all", "replace": "overwrite_all", } return aliases.get(raw) def _normalize_setup_choice(value: Any, allowed: List[str], aliases: Optional[Dict[str, str]] = None) -> Optional[str]: raw = _setup_text_key(value) if aliases and raw in aliases: raw = aliases[raw] return raw if raw in set(allowed) else None def _normalize_setup_list(value: Any, aliases: Optional[Dict[str, str]] = None, allowed: Optional[List[str]] = None) -> List[str]: if value is None: return [] if isinstance(value, str): parts = [part.strip() for part in re.split(r"[,;]", value) if part.strip()] elif isinstance(value, (list, tuple, set)): parts = [str(part).strip() for part in value if str(part).strip()] else: parts = [str(value).strip()] normalized = [] allowed_set = set(allowed or []) for part in parts: key = _setup_text_key(part) if aliases and key in aliases: key = aliases[key] if allowed_set and key not in allowed_set: continue if key and key not in normalized: normalized.append(key) return normalized def _setup_positive_int(value: Any, default: int, min_value: int = 0, max_value: int = 9999) -> int: try: parsed = int(value) except (TypeError, ValueError): return default return max(min_value, min(parsed, max_value)) def _setup_marker_limit(value: Any, default: int = 12) -> int: if isinstance(value, str) and _setup_text_key(value) in {"unlimited", "no_limit", "nolimit", "all"}: return 0 return _setup_positive_int(value, default, 0, 250) def _setup_positive_float(value: Any, default: float, min_value: float = 0.1, max_value: float = 8760.0) -> float: try: parsed = float(value) except (TypeError, ValueError): return default return max(min_value, min(parsed, max_value)) def _normalize_marker_colors(value: Any) -> Dict[str, str]: if not isinstance(value, dict): return {} colors: Dict[str, str] = {} for raw_key, raw_color in value.items(): marker_type = _MEDIA_ANALYSIS_MARKER_TYPE_ALIASES.get(_setup_text_key(raw_key), _setup_text_key(raw_key)) color, color_err = _normalize_marker_color(raw_color) if color_err: continue colors[marker_type] = color return colors def _media_analysis_effective_preferences() -> Dict[str, Any]: preferences = _read_media_analysis_preferences() effective = dict(_MEDIA_ANALYSIS_DEFAULT_PREFS) for key, value in preferences.items(): if key in effective: effective[key] = value timed_default = _normalize_timed_marker_choice(effective.get("timed_markers_default")) effective["timed_markers_default"] = timed_default if timed_default in {"yes", "no"} else None effective["slate_detection_default"] = _normalize_yes_no_ask(effective.get("slate_detection_default")) or "ask" effective["vision_default"] = _normalize_vision_default(effective.get("vision_default")) or "on" effective["transcription_default"] = _normalize_yes_no_ask(effective.get("transcription_default")) or "yes" effective["analysis_persistence"] = _normalize_analysis_persistence(effective.get("analysis_persistence")) or "session_only" effective["metadata_overwrite_policy"] = _normalize_metadata_overwrite_policy(effective.get("metadata_overwrite_policy")) or "preserve_human" effective["timed_marker_types"] = _normalize_setup_list( effective.get("timed_marker_types"), aliases=_MEDIA_ANALYSIS_MARKER_TYPE_ALIASES, allowed=["shots", "slate_clap", "sync_events", "best_moments", "qc_warnings"], ) or list(_MEDIA_ANALYSIS_DEFAULT_MARKER_TYPES) effective["timed_marker_colors"] = { **_MEDIA_ANALYSIS_DEFAULT_MARKER_COLORS, **_normalize_marker_colors(effective.get("timed_marker_colors")), } effective["max_timed_markers_per_clip"] = _setup_marker_limit(effective.get("max_timed_markers_per_clip"), 12) effective["metadata_publish_fields"] = [ str(field).strip() for field in _media_analysis_as_list(effective.get("metadata_publish_fields")) if str(field).strip() ] or list(_MEDIA_ANALYSIS_DEFAULT_PUBLISH_FIELDS) effective["include_confidence_scores"] = _media_analysis_bool(effective.get("include_confidence_scores"), True) effective["include_source_time_notes"] = _media_analysis_bool(effective.get("include_source_time_notes"), True) effective["analysis_summary_style"] = _normalize_setup_choice( effective.get("analysis_summary_style"), ["full", "concise", "creative", "technical"], aliases={ "assistant_editor": "creative", "assistant": "creative", "editor": "creative", "producer": "creative", "qc": "technical", "qc_focus": "technical", "qc_focused": "technical", }, ) or "concise" effective["report_format"] = _normalize_setup_choice(effective.get("report_format"), ["compact", "full", "machine_readable"]) or "compact" effective["source_trust"] = _normalize_setup_choice( effective.get("source_trust"), ["auto", "filename", "low", "medium", "high"], aliases={"none": "auto", "default": "auto"}, ) or "auto" effective["default_depth"] = _normalize_setup_choice( effective.get("default_depth"), ["quick", "standard", "deep"], ) or "standard" try: sample_frames_raw = effective.get("default_sample_frames") sample_frames_int = int(sample_frames_raw) if sample_frames_raw not in (None, "") else 8 except (TypeError, ValueError): sample_frames_int = 8 effective["default_sample_frames"] = max(0, min(48, sample_frames_int)) # sampling_mode_default normalizes to a canonical mode, or None when unset / # "ask" (None means "not yet chosen" → first-run prompt fires). effective["sampling_mode_default"] = _normalize_sampling_mode_default(effective.get("sampling_mode_default")) def _pos_number(value: Any, fallback: float) -> float: try: f = float(value) except (TypeError, ValueError): return fallback return f if f > 0 else fallback effective["sampling_frames_per_minute"] = _pos_number( effective.get("sampling_frames_per_minute"), _media_analysis_module.DEFAULT_FRAMES_PER_MINUTE ) effective["sampling_frame_floor"] = int(_pos_number( effective.get("sampling_frame_floor"), _media_analysis_module.DEFAULT_FRAME_FLOOR )) ceiling = int(_pos_number( effective.get("sampling_frame_ceiling"), _media_analysis_module.DEFAULT_FRAME_CEILING )) if ceiling < effective["sampling_frame_floor"]: ceiling = effective["sampling_frame_floor"] effective["sampling_frame_ceiling"] = ceiling effective["default_post_operation_page"] = _normalize_setup_choice( effective.get("default_post_operation_page"), ["stay_put", "media", "cut", "edit", "fusion", "color", "fairlight", "deliver"], aliases={"media_pool": "media", "none": "stay_put"}, ) or "stay_put" effective["marker_custom_data"] = _normalize_setup_choice(effective.get("marker_custom_data"), ["namespaced", "minimal"]) or "namespaced" effective["metadata_writeback_default"] = _media_analysis_bool(effective.get("metadata_writeback_default"), True) effective["ask_before_metadata_publish"] = _media_analysis_bool(effective.get("ask_before_metadata_publish"), True) effective["dry_run_first_default"] = _media_analysis_bool(effective.get("dry_run_first_default"), True) return effective def _normalize_timed_marker_choice(value: Any) -> Optional[str]: if value is None: return None if isinstance(value, dict): value = _first_param(value, "choice", "timed_markers", "timedMarkers", "enabled", "write_markers", "writeMarkers") if isinstance(value, bool): return "yes" if value else "no" raw = str(value).strip().lower().replace("-", "_").replace(" ", "_") if raw in {"1", "true", "y", "yes", "on", "enable", "enabled"}: return "yes" if raw in {"0", "false", "n", "no", "off", "disable", "disabled"}: return "no" if raw in {"default_yes", "yes_default", "always_yes", "yes_always"}: return "default_yes" if raw in {"default_no", "no_default", "always_no", "no_always"}: return "default_no" if raw in {"ask", "prompt", "ask_me", "ask_user"}: return "ask" return None def _timed_marker_choice_from_params(p: Dict[str, Any]) -> Optional[str]: raw = _first_param( p, "timed_markers", "timedMarkers", "timed_marker_choice", "timedMarkerChoice", "marker_choice", "markerChoice", "write_markers", "writeMarkers", "marker_writeback", "markerWriteback", "add_markers", "addMarkers", ) if raw is None and isinstance(p.get("markers"), dict): raw = _first_param( p["markers"], "timed_markers", "timedMarkers", "choice", "enabled", "write_markers", "writeMarkers", ) return _normalize_timed_marker_choice(raw) def _media_analysis_timed_marker_prompt() -> Dict[str, Any]: return { "question": "Do you want source-time analysis notes written as Media Pool clip markers?", "default_behavior": "Timed markers are written by default; choose no/default_no to disable them.", "options": [ {"id": "yes", "label": "Yes", "description": "Write timed markers for this publish only.", "params": {"timed_markers": "yes"}}, {"id": "no", "label": "No", "description": "Skip timed markers for this publish only.", "params": {"timed_markers": "no"}}, {"id": "default_yes", "label": "Default Yes", "description": "Write timed markers now and save yes as the future default.", "params": {"timed_markers": "default_yes"}}, {"id": "default_no", "label": "Default No", "description": "Skip timed markers now and save no as the future default.", "params": {"timed_markers": "default_no"}}, ], } # V2 architecture decision: machine-generated per-shot / qc_warning / best_moment markers # are NOT written to Resolve. Clip-level metadata (Description, Keywords, Comments, # third_party namespace) still writes through for searchability in Resolve's bin. # Editor's own markers in Resolve are untouched by the machine. # # Rationale: bidirectional sync with Resolve markers was the largest source of # architectural pain (pull-only API with no event hooks, 362-char note truncation, # one-marker-per-frame collisions). The canonical store is the analysis DB; the # correction surface is the control panel + chat, not Resolve markers. # # See the V2 shot schema spec §9.1 (Decisions log) for details. V2_MACHINE_MARKER_WRITEBACK_ENABLED = False def _media_analysis_timed_marker_decision(p: Dict[str, Any]) -> Dict[str, Any]: """Decide whether to write machine-generated timed markers to Resolve. In V2 this always returns enabled=False; see V2_MACHINE_MARKER_WRITEBACK_ENABLED. The user preference / choice flow is retained for traceability and so the API surface is unchanged for callers, but the writeback path is gated off. """ if isinstance(p.get("_timed_marker_decision"), dict): decision = dict(p["_timed_marker_decision"]) if not V2_MACHINE_MARKER_WRITEBACK_ENABLED: decision["enabled"] = False decision.setdefault("source", "v2_disabled") decision["prompt_required"] = False decision.setdefault( "note", "V2 architecture disables machine marker writeback to Resolve; " "use clip-level metadata and the control panel instead.", ) return decision choice = _timed_marker_choice_from_params(p) preferences = _read_media_analysis_preferences() explicit_saved_default = "timed_markers_default" in preferences saved_default = _media_analysis_effective_preferences().get("timed_markers_default") source = "explicit" saved = None if choice in {"default_yes", "default_no"}: saved = "yes" if choice == "default_yes" else "no" preferences["timed_markers_default"] = saved preferences["timed_markers_default_updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) _write_media_analysis_preferences(preferences) enabled = saved == "yes" elif choice in {"yes", "no"}: enabled = choice == "yes" elif choice == "ask": enabled = False elif saved_default in {"yes", "no"}: source = "saved_default" if explicit_saved_default else "default" choice = saved_default enabled = saved_default == "yes" else: source = "prompt_required" choice = "ask" enabled = False if not V2_MACHINE_MARKER_WRITEBACK_ENABLED: # Override the preference-based decision; the V2 architecture has dropped # machine marker writeback entirely. Preserve the recorded choice for audit. return { "enabled": False, "choice": choice, "source": "v2_disabled", "prompt_required": False, "saved_default": saved or (saved_default if saved_default in {"yes", "no"} else None), "preferences_path": _media_analysis_preferences_path(), "note": ( "V2 architecture disables machine marker writeback to Resolve " "(per-shot, qc_warning, best_moment). Clip-level metadata " "(Description, Keywords, Comments) still writes. See the V2 " "shot schema spec §9.1 for rationale." ), } return { "enabled": enabled, "choice": choice, "source": source, "prompt_required": source == "prompt_required" or choice == "ask", "saved_default": saved or (saved_default if saved_default in {"yes", "no"} else None), "preferences_path": _media_analysis_preferences_path(), } def _normalize_sampling_mode_default(value: Any) -> Optional[str]: """Resolve a stored sampling_mode_default to a canonical mode, or None. None means "not chosen yet" — covers an unset value and the "ask" sentinel, both of which should trigger the first-run prompt. """ if value is None: return None raw = str(value).strip().lower() if raw in {"", "ask", "prompt", "ask_me", "ask_user", "none", "unset", "default"}: return None return _media_analysis_module.normalize_sampling_mode(value, default=None) def _sampling_mode_choice_from_params(p: Dict[str, Any]) -> Optional[str]: """Read an explicit sampling-mode choice from analysis params. Returns a canonical mode, the "ask" sentinel, or None when unspecified. """ raw = _first_param( p, "sampling_mode", "samplingMode", "frame_sampling_mode", "frameSamplingMode", "analysis_mode", "analysisMode", ) if raw is None and isinstance(p.get("sampling"), dict): raw = _first_param(p["sampling"], "mode", "sampling_mode", "samplingMode") if raw is None: return None if str(raw).strip().lower() in {"ask", "prompt", "ask_me", "ask_user"}: return "ask" return _media_analysis_module.normalize_sampling_mode(raw, default=None) def _media_analysis_sampling_mode_prompt() -> Dict[str, Any]: """First-run prompt offering the four sampling modes (Thorough recommended). Each option saves the chosen mode as the standing default (save_sampling_default) so the user is only asked once. Pass `sampling_mode` alone, without the save flag, for a one-off run that doesn't change the default. """ return { "question": ( "How should frames be sampled for visual analysis? This sets your " "default for future runs (pass sampling_mode per-call for a one-off)." ), "default_behavior": "Recommended: Thorough — content-aware coverage with a bounded, predictable cost.", "options": [ { "id": "fixed", "label": "Economy", "description": ( "Flat ~8 frames per clip regardless of length. Cheapest and most " "predictable; good for proxies, triage, or known-short clips." ), "params": {"sampling_mode": "fixed", "save_sampling_default": True}, }, { "id": "per_minute", "label": "Balanced", "description": ( "Frames scale with duration (~4/min, bounded 3–80). Cost is linear " "in footage length and easy to predict; content-blind." ), "params": {"sampling_mode": "per_minute", "save_sampling_default": True}, }, { "id": "adaptive_capped", "label": "Thorough (recommended)", "description": ( "Content-aware: samples shot boundaries, representatives, and flash " "frames, bounded 3–80 per clip. Best coverage with a bounded cost." ), "params": {"sampling_mode": "adaptive_capped", "save_sampling_default": True}, }, { "id": "adaptive", "label": "Thorough (uncapped)", "description": ( "Content-aware with no per-clip ceiling (up to 512 frames). Use only " "when clips are known to be short or few — cost can grow fast." ), "params": {"sampling_mode": "adaptive", "save_sampling_default": True}, }, ], "recommended": _media_analysis_module.RECOMMENDED_SAMPLING_MODE, } def _media_analysis_sampling_mode_decision(p: Dict[str, Any]) -> Dict[str, Any]: """Resolve the frame-sampling mode for an analysis run. Resolution order: 1. Explicit `sampling_mode` param → one-off (persisted only if save flag set). 2. Saved `sampling_mode_default` preference → used silently. 3. Otherwise → prompt_required (first run); falls back to the recommended mode so previews/automation still work, but the entry point surfaces the prompt and blocks real execution. """ choice = _sampling_mode_choice_from_params(p) save_default = _media_analysis_bool( _first_param(p, "save_sampling_default", "saveSamplingDefault", "set_sampling_default", "setSamplingDefault"), False, ) preferences = _read_media_analysis_preferences() explicit_saved = "sampling_mode_default" in preferences saved_default = _media_analysis_effective_preferences().get("sampling_mode_default") recommended = _media_analysis_module.RECOMMENDED_SAMPLING_MODE saved = None if choice and choice != "ask": mode = choice source = "explicit" if save_default: saved = mode preferences["sampling_mode_default"] = mode preferences["sampling_mode_default_updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) _write_media_analysis_preferences(preferences) source = "saved_default" elif choice == "ask": mode = recommended source = "prompt_required" elif saved_default: mode = saved_default source = "saved_default" if explicit_saved else "default" else: mode = recommended source = "prompt_required" return { "mode": mode, "source": source, "prompt_required": source == "prompt_required", "saved_default": saved or saved_default, "preferences_path": _media_analysis_preferences_path(), } def _media_analysis_sync_marker_suggestions(detection: Dict[str, Any]) -> List[Dict[str, Any]]: suggestions = [] for file_result in detection.get("files") or []: suggestions.extend(file_result.get("marker_suggestions") or []) return suggestions def _apply_sync_event_markers(proj, detection: Dict[str, Any], p: Dict[str, Any]) -> Dict[str, Any]: if not _media_analysis_confirmed(p): return { "success": False, "confirmation_required": True, "message": "Review marker_suggestions, ask the user, then call again with confirm=true to add markers.", "preview": detection, } mp = proj.GetMediaPool() if not mp: return _err("Failed to get MediaPool") root = mp.GetRootFolder() suggestions = _media_analysis_sync_marker_suggestions(detection) eligible = [suggestion for suggestion in suggestions if suggestion.get("eligible")] skipped = [] added = [] failed = [] replace_existing = _media_analysis_bool(p.get("replace_existing"), False) if not eligible: return { "success": True, "added": 0, "skipped": suggestions, "message": "No eligible marker suggestions. Analyze Media Pool clips, not raw file paths, to add Resolve clip markers.", "detection": detection, } for suggestion in eligible: clip_id = suggestion.get("clip_id") clip = _find_clip(root, clip_id) if not clip: failed.append({"clip_id": clip_id, "reason": "Clip not found"}) continue if suggestion.get("event_type") == "slate_clap" and not _media_analysis_slate_visual_confirmed( {"slate": suggestion.get("slate") if isinstance(suggestion.get("slate"), dict) else {}}, suggestion.get("slate_review") if isinstance(suggestion.get("slate_review"), dict) else None, ): skipped.append({ "clip_id": clip_id, "frame": suggestion.get("event_frame"), "reason": "slate_not_visually_confirmed", "custom_data": (suggestion.get("marker") or {}).get("custom_data"), }) continue marker = dict(suggestion.get("marker") or {}) color, color_err = _normalize_marker_color(marker.get("color")) if color_err: failed.append({"clip_id": clip_id, "marker": marker, "result": color_err}) continue marker["color"] = color marker["frame"] = int(marker["frame"]) marker["duration"] = max(1, int(marker.get("duration") or 1)) custom_data = marker.get("custom_data") or marker.get("customData") or "" if custom_data and _has_method(clip, "GetMarkerByCustomData"): try: existing = clip.GetMarkerByCustomData(custom_data) except Exception: existing = None if existing and not replace_existing: skipped.append({"clip_id": clip_id, "frame": marker["frame"], "reason": "Marker already exists", "custom_data": custom_data}) continue if existing and replace_existing and _has_method(clip, "DeleteMarkerByCustomData"): clip.DeleteMarkerByCustomData(custom_data) result = _add_marker(clip, marker) if result.get("success"): added.append({ "clip_id": clip_id, "clip_name": suggestion.get("clip_name"), "frame": result.get("frame"), "name": marker.get("name"), "custom_data": custom_data, }) else: failed.append({"clip_id": clip_id, "marker": marker, "result": result}) ineligible = [suggestion for suggestion in suggestions if not suggestion.get("eligible")] skipped.extend(ineligible) return { "success": not failed, "added": len(added), "markers": added, "skipped": skipped, "failed": failed, "detection": detection, } def _media_analysis_marker_writeback_enabled(p: Dict[str, Any]) -> bool: return bool(_media_analysis_timed_marker_decision(p).get("enabled")) def _media_analysis_marker_options(p: Dict[str, Any]) -> Dict[str, Any]: defaults = _media_analysis_effective_preferences() raw = p.get("markers") options = { "marker_types": defaults.get("timed_marker_types"), "marker_colors": defaults.get("timed_marker_colors"), "max_markers": defaults.get("max_timed_markers_per_clip"), "custom_data_mode": defaults.get("marker_custom_data"), } if isinstance(raw, dict): options.update(raw) for key in ( "write_markers", "writeMarkers", "marker_writeback", "markerWriteback", "add_markers", "addMarkers", "marker_color", "markerColor", "marker_duration", "markerDuration", "max_markers", "maxMarkers", "marker_types", "markerTypes", "marker_colors", "markerColors", "marker_custom_data", "markerCustomData", "custom_data_mode", "customDataMode", "replace_existing", "replaceExisting", ): if key in p: if key in {"marker_custom_data", "markerCustomData"}: options["custom_data_mode"] = p[key] else: options[key] = p[key] return options def _media_analysis_float(value: Any) -> Optional[float]: if isinstance(value, bool) or value is None: return None if isinstance(value, (int, float)): return float(value) match = re.search(r"-?\d+(?:\.\d+)?", str(value)) if not match: return None try: return float(match.group(0)) except ValueError: return None def _media_analysis_report_fps(record: Dict[str, Any], report: Dict[str, Any]) -> Optional[float]: candidates = [ record.get("fps"), _media_analysis_pick_nested(report, ("clip", "fps")), _media_analysis_pick_nested(report, ("technical", "video", 0, "frame_rate")), ] technical = report.get("technical") if isinstance(report.get("technical"), dict) else {} video = technical.get("video") if isinstance(technical.get("video"), list) else [] if video and isinstance(video[0], dict): candidates.extend([video[0].get("frame_rate"), video[0].get("avg_frame_rate"), video[0].get("r_frame_rate")]) for candidate in candidates: if isinstance(candidate, str) and "/" in candidate: num, _, den = candidate.partition("/") try: den_float = float(den) if den_float: return float(num) / den_float except ValueError: continue value = _media_analysis_float(candidate) if value and value > 0: return value return None def _media_analysis_time_to_frame(value: Any, fps: Optional[float]) -> Optional[int]: if value is None: return None if isinstance(value, dict): for key in ("frame", "frame_id", "frameId", "frame_num", "frameNum"): frame = _frame_int(value.get(key)) if frame is not None: return frame for key in ("timecode", "time_code", "tc", "time_seconds", "timeSeconds", "seconds"): frame = _media_analysis_time_to_frame(value.get(key), fps) if frame is not None: return frame return None if isinstance(value, (int, float)) and not isinstance(value, bool): return int(round(float(value) * fps)) if fps else int(round(float(value))) text = str(value or "").strip() if not text: return None tc_match = re.search(r"(? str: if isinstance(value, dict): for key in ("note", "description", "text", "label", "reason", "summary"): text = _media_analysis_metadata_text(value.get(key)) if text: return text return fallback text = _media_analysis_metadata_text(value) return text or fallback def _media_analysis_note_items(value: Any) -> List[Any]: if value is None: return [] if isinstance(value, list): return [item for item in value if item not in (None, "", [], {})] return _media_analysis_as_list(value) def _media_analysis_make_marker( *, frame: int, color: str, name: str, note: str, duration: int, source: str, report_path: Optional[str], extra: Optional[Dict[str, Any]] = None, custom_data_mode: str = "namespaced", ) -> Dict[str, Any]: payload = {"source": source} if custom_data_mode != "minimal": payload["namespace"] = "davinci_resolve_mcp.analysis" if report_path and custom_data_mode != "minimal": payload["analysis_report_path"] = report_path if extra: payload.update({key: value for key, value in extra.items() if value not in (None, "", [], {})}) return { "frame": int(frame), "color": color, "name": name, "note": note, "duration": max(1, int(duration or 1)), "custom_data": json.dumps(payload, sort_keys=True, ensure_ascii=False), } def _media_analysis_marker_candidates_from_report( report: Dict[str, Any], record: Dict[str, Any], sync_event: Optional[Dict[str, Any]], report_path: Optional[str], p: Dict[str, Any], slate_review: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: options = _media_analysis_marker_options(p) fps = _media_analysis_report_fps(record, report) max_markers = _setup_marker_limit(options.get("max_markers", options.get("maxMarkers", 12)), 12) duration = _setup_positive_int(options.get("marker_duration", options.get("markerDuration", 1)), 1, 1, 999) marker_types = _normalize_setup_list( options.get("marker_types", options.get("markerTypes")), aliases=_MEDIA_ANALYSIS_MARKER_TYPE_ALIASES, allowed=["shots", "slate_clap", "sync_events", "best_moments", "qc_warnings"], ) or list(_MEDIA_ANALYSIS_DEFAULT_MARKER_TYPES) marker_colors = { **_MEDIA_ANALYSIS_DEFAULT_MARKER_COLORS, **_normalize_marker_colors(options.get("marker_colors", options.get("markerColors"))), } custom_data_mode = _normalize_setup_choice( options.get("custom_data_mode", options.get("customDataMode")), ["namespaced", "minimal"], ) or "namespaced" default_color, color_err = _normalize_marker_color(options.get("marker_color", options.get("markerColor", "Blue"))) if color_err: default_color = "Blue" markers: List[Dict[str, Any]] = [] skipped: List[Dict[str, Any]] = [] marker_plan = report.get("clip_analysis_markers") if isinstance(report.get("clip_analysis_markers"), dict) else {} plan_markers = marker_plan.get("markers") if isinstance(marker_plan.get("markers"), list) else [] for item in plan_markers: if not isinstance(item, dict): continue item_type = _setup_text_key(item.get("type")) planned_type = _MEDIA_ANALYSIS_MARKER_TYPE_ALIASES.get(item_type, item_type) if planned_type not in marker_types: continue frame = item.get("start_frame") try: frame = int(frame) except (TypeError, ValueError): skipped.append({"source": "clip_marker_plan", "reason": "missing_frame", "item": item.get("id")}) continue planned_color, planned_color_err = _normalize_marker_color( item.get("color") or marker_colors.get(planned_type) or marker_colors.get(str(item.get("type") or "")) or default_color ) if planned_color_err: planned_color = marker_colors.get(planned_type) or default_color visual_note = _media_analysis_metadata_text(item.get("visual_description")) sound_note = _media_analysis_metadata_text(item.get("sound_note")) note_parts = [] if visual_note and "unavailable" not in visual_note.lower(): note_parts.append(visual_note) if sound_note and "no transcript excerpt" not in sound_note.lower(): note_parts.append(sound_note) note = " | ".join(note_parts) or _media_analysis_metadata_text(item.get("name")) or "Analysis marker" markers.append(_media_analysis_make_marker( frame=frame, color=planned_color, name=_media_analysis_metadata_text(item.get("name")) or "Analysis Marker", note=note, duration=_setup_positive_int(item.get("duration_frames"), duration, 1, 99999), source="clip_marker_plan", report_path=report_path, extra={ "marker_id": item.get("id"), "marker_type": item.get("type"), "marker_subtype": item.get("subtype"), "marker_source": item.get("source"), "confidence": item.get("confidence"), "start_seconds": item.get("start_seconds"), "end_seconds": item.get("end_seconds"), }, custom_data_mode=custom_data_mode, )) slate_visual_confirmed = _media_analysis_slate_visual_confirmed(report, slate_review) if sync_event and ("slate_clap" in marker_types or "sync_events" in marker_types): frame = _media_analysis_time_to_frame(sync_event, fps) if frame is None: skipped.append({"source": "sync_event", "reason": "missing_frame_or_fps", "item": sync_event}) elif not slate_visual_confirmed: skipped.append({ "source": "sync_event", "frame": frame, "reason": "slate_not_visually_confirmed", "item": sync_event, }) else: confidence = sync_event.get("confidence") note = "Visually confirmed slate clap" if sync_event.get("time_seconds") is not None: note += f" at {sync_event.get('time_seconds')}s" if confidence is not None: note += f", confidence {confidence}" markers.append(_media_analysis_make_marker( frame=frame, color=marker_colors.get("slate_clap") or marker_colors.get("sync_events") or "Cyan", name="Slate Clap", note=note, duration=duration, source="sync_event", report_path=report_path, extra={"confidence": confidence, "time_seconds": sync_event.get("time_seconds")}, custom_data_mode=custom_data_mode, )) visual = report.get("visual") if isinstance(report.get("visual"), dict) else {} editing_notes = visual.get("editing_notes") if isinstance(visual.get("editing_notes"), dict) else {} if "best_moments" in marker_types: for item in _media_analysis_note_items(editing_notes.get("best_moments")): frame = _media_analysis_time_to_frame(item, fps) if frame is None: skipped.append({"source": "best_moment", "reason": "missing_timecode_or_fps", "item": item}) continue markers.append(_media_analysis_make_marker( frame=frame, color=marker_colors.get("best_moments") or "Green", name="Best Moment", note=_media_analysis_marker_note(item, "Best moment"), duration=duration, source="best_moment", report_path=report_path, custom_data_mode=custom_data_mode, )) if "qc_warnings" in marker_types: timed_warnings = ( _media_analysis_note_items(report.get("technical_warnings")) + _media_analysis_note_items(editing_notes.get("qc_flags")) ) for item in timed_warnings: frame = _media_analysis_time_to_frame(item, fps) if frame is None: continue markers.append(_media_analysis_make_marker( frame=frame, color=marker_colors.get("qc_warnings") or "Yellow", name="QC Warning", note=_media_analysis_marker_note(item, "QC warning"), duration=duration, source="qc_warning", report_path=report_path, custom_data_mode=custom_data_mode, )) deduped: List[Dict[str, Any]] = [] seen = set() markers.sort( key=lambda marker: ( int(marker.get("frame") or 0), marker.get("name") or "", marker.get("note") or "", ) ) for marker in markers: key = (marker["frame"], marker["name"], marker["note"]) if key in seen: continue seen.add(key) if max_markers > 0 and len(deduped) >= max_markers: skipped.append({ "source": marker.get("name"), "frame": marker.get("frame"), "reason": "max_markers_reached", }) continue if marker.get("color") == "Blue": marker["color"] = default_color deduped.append(marker) return { "enabled": _media_analysis_marker_writeback_enabled(p), "fps": fps, "markers": deduped, "skipped": skipped, } def _apply_media_analysis_clip_markers(clip, markers: List[Dict[str, Any]], p: Dict[str, Any]) -> Dict[str, Any]: replace_existing = _media_analysis_bool(p.get("replace_existing", p.get("replaceExisting")), False) added = [] skipped = [] failed = [] for marker in markers: custom_data = marker.get("custom_data") or "" if custom_data and _has_method(clip, "GetMarkerByCustomData"): try: existing = clip.GetMarkerByCustomData(custom_data) except Exception: existing = None if existing and not replace_existing: skipped.append({"frame": marker.get("frame"), "name": marker.get("name"), "reason": "Marker already exists", "custom_data": custom_data}) continue if existing and replace_existing and _has_method(clip, "DeleteMarkerByCustomData"): clip.DeleteMarkerByCustomData(custom_data) result = _add_marker(clip, marker) if result.get("success"): added.append({"frame": result.get("frame"), "name": marker.get("name"), "custom_data": custom_data}) elif result.get("reason") and "already exists" in str(result.get("reason")).lower(): skipped.append({"frame": marker.get("frame"), "name": marker.get("name"), "reason": result.get("reason")}) else: failed.append({"marker": marker, "result": result}) return {"success": not failed, "added": added, "skipped": skipped, "failed": failed} _MCP_METADATA_BLOCK_START = "[DaVinci Resolve MCP Analysis]" _MCP_METADATA_BLOCK_END = "[/DaVinci Resolve MCP Analysis]" _MCP_METADATA_PROVENANCE_PREFIX = "davinci_resolve_mcp." _MEDIA_ANALYSIS_DEFAULT_PUBLISH_FIELDS = ["Description", "Comments", "Keywords", "People"] _MEDIA_ANALYSIS_LIST_FIELDS = {"Keywords", "Keyword", "People"} _MEDIA_ANALYSIS_FILL_EMPTY_FIELDS = {"Scene", "Shot", "Take", "Camera #", "Camera", "Roll/Card", "Roll Card #", "Reel"} _METADATA_PANEL_GROUP_ORDER = [ "All Groups", "Shot & Scene", "Clip Details", "Camera", "Tech Details", "Stereo3D & VFX", "Audio", "Audio Tracks", "Production", "Production Crew", "Reviewed By", "Immersive", "Custom", ] _METADATA_PANEL_GROUP_FIELD_HINTS = { "Shot & Scene": { "Description", "Comments", "Keyword", "Keywords", "People", "Clip Color", "Shot", "Scene", "Take", "Angle", "Move", "Day / Night", "Environment", "Shot Type", "Flags", "Good Take", "Shoot Day", "Date Recorded", "Camera #", "Roll/Card", "Roll Card #", "Reel Name", "Reel Number", "Clip Number", "Program Name", "Episode #", "Episode Name", "Shot During Ep", "Location", "Unit Name", "Setup", }, "Clip Details": { "Clip Name", "File Name", "File Path", "Clip Directory", "Type", "Format", "Duration", "Frames", "FPS", "Start", "End", "In", "Out", "Start TC", "End TC", "Slate TC", "Start KeyKode", "EDL Clip Name", "Date Added", "Date Created", "Date Modified", "Media Type", "Online Status", "Proxy", "Proxy Media Path", "Cloud Sync", }, "Camera": { "Camera #", "Camera ID", "Camera Type", "Camera Manufacturer", "Camera Model", "Camera Serial #", "Camera Firmware", "Camera Format", "Camera FPS", "Camera TC Type", "Camera Notes", "Camera Operator", "Camera Assistant", "Camera Position", "Camera Pan Angle", "Camera Tilt Angle", "Camera Roll Angle", "Camera Aperture", "Camera Aperture Type", "Sensor", "Sensor Area Captured", "ISO", "ND Filter", "Shutter Angle", "Shutter Speed", "Shutter Type", "White Balance Tint", "White Point (Kelvin)", "Focal Point (mm)", "Distance", "Focus Puller", "Lens Type", "Lens Number", "Lens Notes", "Lens Chart", "Framing Chart", "Safe Area", "Color Chart", "Grey Chart", }, "Tech Details": { "Alpha mode", "Aspect Ratio Notes", "Audio Bit Depth", "Bit Depth", "Bit Rate", "Codec", "Codec Bitrate", "Compression Ratio", "Data Level", "Drop frame", "Enable Deinterlacing", "Field Dominance", "Filter", "Format", "FPS", "Frames", "H-FLIP", "Input Color Space", "Input LUT", "Input Sizing Preset", "Mon Color Space", "Monitor LUT", "PAR", "PAR Notes", "RAW", "Resolution", "Shot Frame Rate", "Super Scale", "SuperScale Noise Reduction", "SuperScale Sharpness", "Time-lapse Interval", "Transcription Status", "V-FLIP", "Video Codec", "Color Space Notes", "Gamma Notes", "Data Level", "IDT", "LUT 1", "LUT 2", "LUT 3", "LUT Used", "LUT Used On Set", "CDL SAT", "CDL SOP", "Sharpness", "Noise Reduction", }, "Stereo3D & VFX": { "3D Rig ID #", "3D Rig Type", "BG (m)", "CV (m)", "Convergence Adj", "FG (m)", "IA (mm)", "ILPD", "Projection", "Rig Inverted", "S3D Eye", "S3D Notes", "S3D Shot", "S3D Sync", "VFX Grey Ball", "VFX Markers", "VFX Mirror Ball", "VFX Notes", "VFX Shot #", "VFX Svsr Reviewed", }, "Audio": { "Audio Bit Depth", "Audio Ch", "Audio Codec", "Audio Duration TC", "Audio End TC", "Audio File Type", "Audio FPS", "Audio Media", "Audio Notes", "Audio Offset", "Audio Recorder", "Audio Start TC", "Audio TC Type", "Dialog Duration", "Dialog Notes", "Dialog Starts As", "Embedded Audio", "End Dialog TC", "Sample Rate", "Sample Rate (KHz)", "Sound Mixer", "Sound Roll #", "Start Dialog TC", "Synced Audio", }, "Production": { "Assistant Director", "Assistant Producer", "Category", "Episode #", "Episode Name", "Genre", "Line Producer", "Location", "Post Producer", "Producer", "Production Company", "Production Name", "Program Name", "Series #", "Setup", "Shoot Day", "Shot During Ep", "Subcategory", "Unit Manager", "Unit Name", }, "Production Crew": { "2nd Asst", "2nd Continuity", "2nd DIT", "2nd DOP", "2nd Dir", "2nd Dir Asst", "Assistant Director", "Camera Assistant", "Camera Operator", "Colorist", "Colorist Assistant", "Continuity", "DOP", "Dailies Colorist", "Data Wrangler", "Digital Technician", "Director", "Editing Assistant", "Editor", "Focus Puller", "Key Grip", "Production Asst", "Script Supervisor", "Sound Mixer", }, "Reviewed By": { "2nd DOP Reviewed", "2nd Dir Reviewed", "Colorist Reviewed", "Continuity Reviewed", "DOP Reviewed", "Director Reviewed", "Focus Reviewed", "Sound Reviewed", "VFX Svsr Reviewed", "Wardrobe Reviewed", }, "Immersive": { "Immersive ID", }, "Custom": { "Aux 1", "Aux 2", "FSD", "Lab Roll #", "Offline Reference", "Reviewers Notes", "Send to", "Send to Studio", "Tone", "Uploaded From", "Usage", }, } _METADATA_FIELD_PROPERTY_ALIASES = { "Keywords": ("Keyword",), "Roll/Card": ("Roll Card #",), } _METADATA_FIELD_WRITE_ALIASES = { "Keyword": "Keywords", "Roll/Card": "Roll Card #", } def _metadata_clip_property_key_for_field(field: str, properties: Dict[str, Any]) -> Optional[str]: if field in properties: return field for alias in _METADATA_FIELD_PROPERTY_ALIASES.get(field, ()): if alias in properties: return alias return None def _metadata_write_field_for_field(field: str) -> str: return _METADATA_FIELD_WRITE_ALIASES.get(field, field) def _metadata_panel_group_for_field(field: str) -> str: for group, fields in _METADATA_PANEL_GROUP_FIELD_HINTS.items(): if field in fields: return group if re.fullmatch(r"Track\s+\d+", field or ""): return "Audio Tracks" if field.startswith("Audio "): return "Audio" if field.startswith("Camera "): return "Camera" if field.startswith("VFX ") or field.startswith("S3D ") or field.startswith("3D "): return "Stereo3D & VFX" if field.endswith(" Reviewed"): return "Reviewed By" return "Unclassified" def _metadata_panel_group_inventory(fields: List[str]) -> List[Dict[str, Any]]: grouped: Dict[str, List[str]] = {"All Groups": list(fields)} for field in fields: grouped.setdefault(_metadata_panel_group_for_field(field), []).append(field) ordered_names = list(_METADATA_PANEL_GROUP_ORDER) if grouped.get("Unclassified"): ordered_names.append("Unclassified") return [ {"name": name, "field_count": len(grouped.get(name, [])), "fields": grouped.get(name, [])} for name in ordered_names if name == "All Groups" or grouped.get(name) ] def _media_analysis_metadata_text(value: Any) -> str: if value is None: return "" if isinstance(value, (list, tuple, set)): return ", ".join(str(item).strip() for item in value if str(item).strip()) return str(value).strip() def _media_analysis_as_list(value: Any) -> List[str]: if value is None: return [] if isinstance(value, (list, tuple, set)): raw_items = value else: raw_items = re.split(r"[,;\n]+", str(value)) out: List[str] = [] seen = set() for item in raw_items: text = str(item).strip() if not text: continue key = text.casefold() if key in seen: continue seen.add(key) out.append(text) return out def _media_analysis_merge_lists(existing: Any, proposed: Any) -> Tuple[str, List[str]]: existing_items = _media_analysis_as_list(existing) merged = list(existing_items) seen = {item.casefold() for item in merged} added = [] for item in _media_analysis_as_list(proposed): key = item.casefold() if key in seen: continue seen.add(key) merged.append(item) added.append(item) return ", ".join(merged), added def _media_analysis_replace_owned_block(existing: Any, body: Any) -> str: existing_text = _media_analysis_metadata_text(existing) body_text = _media_analysis_metadata_text(body) block = f"{_MCP_METADATA_BLOCK_START}\n{body_text}\n{_MCP_METADATA_BLOCK_END}" pattern = ( re.escape(_MCP_METADATA_BLOCK_START) + r".*?" + re.escape(_MCP_METADATA_BLOCK_END) ) if re.search(pattern, existing_text, flags=re.DOTALL): return re.sub(pattern, block, existing_text, flags=re.DOTALL).strip() return f"{existing_text}\n\n{block}".strip() if existing_text else block def _media_analysis_confidence_rank(value: Any) -> int: raw = str(value or "").strip().lower() return {"none": 0, "low": 1, "medium": 2, "high": 3}.get(raw, 0) def _media_analysis_pick_nested(payload: Dict[str, Any], *paths: Tuple[str, ...]) -> Any: for path in paths: current: Any = payload for key in path: if not isinstance(current, dict) or key not in current: current = None break current = current[key] if current not in (None, "", [], {}): return current return None def _media_analysis_best_sync_event(detection: Optional[Dict[str, Any]], clip_id: Optional[str]) -> Optional[Dict[str, Any]]: if not isinstance(detection, dict): return None best = None best_score = -1.0 for file_result in detection.get("files") or []: if clip_id and file_result.get("clip_id") not in (None, clip_id): continue for event in file_result.get("events") or []: if event.get("type") != "slate_clap": continue try: score = float(event.get("confidence") or 0) except (TypeError, ValueError): score = 0.0 if score > best_score: best_score = score best = dict(event) best["clip_id"] = file_result.get("clip_id") best["clip_name"] = file_result.get("clip_name") return best def _media_analysis_visual_analysis_succeeded(report: Dict[str, Any]) -> bool: visual = report.get("visual") if isinstance(report.get("visual"), dict) else {} if str(visual.get("status") or "").strip().lower() in {"skipped", "disabled"}: return False has_visual_content = bool( visual.get("clip_summary") or visual.get("content") or visual.get("editing_notes") or visual.get("analysis_keyframes") or visual.get("slate") ) if visual.get("success") is False and not has_visual_content: return False return has_visual_content def _media_analysis_slate_visible(source: Any) -> bool: if not isinstance(source, dict): return False value = _first_param( source, "slate_visible", "slateVisible", "visible", "is_slate", "isSlate", "has_slate", "hasSlate", ) if value is None: return False return _media_analysis_bool(value, False) def _media_analysis_slate_visual_confirmed( report: Dict[str, Any], slate_review: Optional[Dict[str, Any]] = None, ) -> bool: sources: List[Any] = [] if isinstance(slate_review, dict): sources.extend([ slate_review, slate_review.get("slate") if isinstance(slate_review.get("slate"), dict) else {}, ]) visual = report.get("visual") if isinstance(report.get("visual"), dict) else {} sources.extend([ report.get("slate") if isinstance(report.get("slate"), dict) else {}, visual.get("slate") if isinstance(visual.get("slate"), dict) else {}, ]) return any(_media_analysis_slate_visible(source) for source in sources) def _media_analysis_confirmed_slate_sources( report: Dict[str, Any], slate_review: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: sources: List[Dict[str, Any]] = [] def add_confirmed(container: Any) -> None: if not isinstance(container, dict): return nested_slate = container.get("slate") if isinstance(container.get("slate"), dict) else {} if not (_media_analysis_slate_visible(container) or _media_analysis_slate_visible(nested_slate)): return sources.append(container) fields = container.get("fields") if isinstance(container.get("fields"), dict) else {} if fields: sources.append(fields) if nested_slate: sources.append(nested_slate) add_confirmed(slate_review) add_confirmed(report.get("slate") if isinstance(report.get("slate"), dict) else {}) visual = report.get("visual") if isinstance(report.get("visual"), dict) else {} add_confirmed(visual.get("slate") if isinstance(visual.get("slate"), dict) else {}) return sources def _media_analysis_collect_slate_fields(report: Dict[str, Any], slate_review: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: slate_sources = _media_analysis_confirmed_slate_sources(report, slate_review) field_aliases = { "Scene": ("scene", "scene_number", "sceneNumber"), "Shot": ("shot", "shot_number", "shotNumber"), "Take": ("take", "take_number", "takeNumber"), "Camera #": ("camera", "camera_id", "cameraId", "camera_number", "cameraNumber"), "Roll/Card": ("roll", "reel", "card", "roll_card", "rollCard"), } collected: Dict[str, Any] = {} confidence = None for source in slate_sources: confidence = _media_analysis_pick_nested(source, ("confidence", "overall"), ("confidence",)) if confidence: break collected["_confidence"] = confidence or "unknown" for resolve_field, aliases in field_aliases.items(): for source in slate_sources: if not isinstance(source, dict): continue for alias in aliases: if source.get(alias) not in (None, ""): collected[resolve_field] = source.get(alias) break if resolve_field in collected: break visible_text = None for source in slate_sources: visible_text = _media_analysis_pick_nested(source, ("visible_text",), ("visibleText",)) if visible_text: break if visible_text: collected["_visible_text"] = visible_text return collected def _media_analysis_report_metadata_candidates( report: Dict[str, Any], *, detection: Optional[Dict[str, Any]] = None, slate_review: Optional[Dict[str, Any]] = None, fields: Optional[List[str]] = None, require_visual_description: bool = False, require_visual_slate: bool = True, ) -> Dict[str, Any]: requested_fields = fields or list(_MEDIA_ANALYSIS_DEFAULT_PUBLISH_FIELDS) visual = report.get("visual") if isinstance(report.get("visual"), dict) else {} content = visual.get("content") if isinstance(visual.get("content"), dict) else {} classification = visual.get("editorial_classification") if isinstance(visual.get("editorial_classification"), dict) else {} editing_notes = visual.get("editing_notes") if isinstance(visual.get("editing_notes"), dict) else {} shot_style = visual.get("shot_and_style") if isinstance(visual.get("shot_and_style"), dict) else {} visual_succeeded = _media_analysis_visual_analysis_succeeded(report) summary = visual.get("clip_summary") if visual_succeeded else None if not summary and not require_visual_description: summary = ( report.get("summary") or (report.get("clip") or {}).get("clip_name") or "Analyzed media clip" ) comments = [f"Summary: {summary}"] if summary else [] primary_use = classification.get("primary_use") select_potential = classification.get("select_potential") if primary_use and primary_use != "unknown": use_line = f"Editorial use: {primary_use}" if select_potential and select_potential != "unknown": use_line += f" ({select_potential} select potential)" comments.append(use_line) if classification.get("reason"): comments.append(f"Reason: {classification['reason']}") best_moments = editing_notes.get("best_moments") or [] if best_moments: comments.append("Best moments: " + "; ".join(_media_analysis_as_list(best_moments)[:4])) qc_flags = list(report.get("technical_warnings") or []) + list(editing_notes.get("qc_flags") or []) if qc_flags: comments.append("Warnings: " + "; ".join(_media_analysis_as_list(qc_flags)[:6])) visible_text = content.get("visible_text") or [] if visible_text: comments.append("Visible text: " + "; ".join(_media_analysis_as_list(visible_text)[:5])) sync_event = _media_analysis_best_sync_event(detection, (report.get("clip") or {}).get("clip_id")) slate_visual_confirmed = _media_analysis_slate_visual_confirmed(report, slate_review) if sync_event and (slate_visual_confirmed or not require_visual_slate): slate_label = "Confirmed slate clap" if slate_visual_confirmed else "Likely slate clap" comments.append( f"{slate_label}: " f"{sync_event.get('time_seconds')}s" + (f", frame {sync_event.get('frame')}" if sync_event.get("frame") is not None else "") + (f", confidence {sync_event.get('confidence')}" if sync_event.get("confidence") is not None else "") ) slate = _media_analysis_collect_slate_fields(report, slate_review) if any(key in slate for key in ("Scene", "Shot", "Take", "Camera #")): slate_bits = [ f"{key}: {slate[key]}" for key in ("Scene", "Shot", "Take", "Camera #", "Roll/Card") if slate.get(key) not in (None, "") ] comments.append("Slate read: " + ", ".join(slate_bits)) if slate.get("_visible_text"): comments.append("Slate visible text: " + "; ".join(_media_analysis_as_list(slate["_visible_text"])[:5])) keywords: List[Any] = [] keywords.extend(editing_notes.get("search_tags") or []) if primary_use and primary_use != "unknown": keywords.append(primary_use) for key in ("actions", "objects", "locations", "notable_audio_context"): keywords.extend(_media_analysis_as_list(content.get(key))) keywords.extend(_media_analysis_as_list(shot_style.get("shot_sizes"))) keywords.extend(_media_analysis_as_list(shot_style.get("camera_motion"))) if sync_event and (slate_visual_confirmed or not require_visual_slate): keywords.append("slate clap") people: List[Any] = [] for key in ("people", "people_names", "named_people", "identified_people"): people.extend(_media_analysis_as_list(content.get(key))) people.extend(_media_analysis_as_list(visual.get("people"))) candidates: Dict[str, Any] = {} if "Description" in requested_fields and summary: candidates["Description"] = _media_analysis_metadata_text(summary) if "Comments" in requested_fields and comments: candidates["Comments"] = "\n".join(_media_analysis_metadata_text(line) for line in comments if _media_analysis_metadata_text(line)) keyword_field = "Keywords" if "Keywords" in requested_fields else ("Keyword" if "Keyword" in requested_fields else None) if keyword_field: candidates[keyword_field] = _media_analysis_as_list(keywords) if "People" in requested_fields: candidates["People"] = _media_analysis_as_list(people) slate_confidence = slate.get("_confidence") high_confidence_slate = _media_analysis_confidence_rank(slate_confidence) >= 3 for field in ("Scene", "Shot", "Take", "Camera #", "Roll/Card"): target_field = field if field == "Roll/Card" and field not in requested_fields and "Roll Card #" in requested_fields: target_field = "Roll Card #" if target_field in requested_fields and slate.get(field) not in (None, ""): candidates[target_field] = _media_analysis_metadata_text(slate[field]) if high_confidence_slate else "" return { "fields": candidates, "evidence": { "summary": summary, "sync_event": sync_event, "visual_analysis_required": bool(require_visual_description), "visual_analysis_succeeded": visual_succeeded, "slate_visual_required": bool(require_visual_slate), "slate_visual_confirmed": slate_visual_confirmed, "slate": slate, "slate_confidence": slate_confidence, }, } def _media_analysis_merge_metadata_field(field: str, existing: Any, proposed: Any, p: Dict[str, Any]) -> Dict[str, Any]: proposed_text = _media_analysis_metadata_text(proposed) existing_text = _media_analysis_metadata_text(existing) overwrite = _media_analysis_bool(p.get("overwrite"), False) merge_policy = str(p.get("merge_policy") or p.get("mergePolicy") or "append_relevant").strip().lower() if field in _MEDIA_ANALYSIS_LIST_FIELDS: merged, added = _media_analysis_merge_lists(existing, proposed) return { "field": field, "existing": existing_text, "proposed": proposed, "value": merged, "changed": bool(added), "reason": "deduped_append" if added else "no_new_values", "added": added, } if not proposed_text: return { "field": field, "existing": existing_text, "proposed": proposed_text, "value": existing_text, "changed": False, "reason": "no_confident_proposal", } if overwrite: return { "field": field, "existing": existing_text, "proposed": proposed_text, "value": proposed_text, "changed": proposed_text != existing_text, "reason": "overwrite", } if merge_policy in {"fill_empty", "empty_only"} and existing_text: return { "field": field, "existing": existing_text, "proposed": proposed_text, "value": existing_text, "changed": False, "reason": "preserved_existing_fill_empty_policy", } if field in _MEDIA_ANALYSIS_FILL_EMPTY_FIELDS: if existing_text: return { "field": field, "existing": existing_text, "proposed": proposed_text, "value": existing_text, "changed": False, "reason": "preserved_existing_fill_empty_field", } return { "field": field, "existing": existing_text, "proposed": proposed_text, "value": proposed_text, "changed": True, "reason": "filled_empty_field", } if field in {"Comments", "Description"} and (merge_policy in {"append_relevant", "append", "update_block"} or existing_text): merged = _media_analysis_replace_owned_block(existing_text, proposed_text) return { "field": field, "existing": existing_text, "proposed": proposed_text, "value": merged, "changed": merged != existing_text, "reason": "updated_mcp_block", } if existing_text: return { "field": field, "existing": existing_text, "proposed": proposed_text, "value": existing_text, "changed": False, "reason": "preserved_existing", } return { "field": field, "existing": existing_text, "proposed": proposed_text, "value": proposed_text, "changed": True, "reason": "filled_empty_field", } def _media_analysis_provenance_metadata(report: Dict[str, Any], report_path: Optional[str], changed_fields: List[str]) -> Dict[str, str]: signature = report.get("analysis_signature") if isinstance(report.get("analysis_signature"), dict) else {} payload = { "analysis_report_path": report_path or "", "analysis_signature": signature.get("signature_hash") or "", "analysis_version": str(report.get("analysis_version") or ""), "published_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "publisher_version": VERSION, "changed_fields": ",".join(changed_fields), } return { f"{_MCP_METADATA_PROVENANCE_PREFIX}{key}": value for key, value in payload.items() if value not in (None, "") } def _has_any_param(p: Dict[str, Any], *keys: str) -> bool: return any(key in p and p[key] is not None for key in keys) def _media_analysis_transcription_options(enabled: bool) -> Dict[str, Any]: options = {"enabled": bool(enabled)} if enabled: options["allow_model_download"] = True return options def _media_analysis_metadata_writeback_enabled(p: Dict[str, Any]) -> bool: raw = _first_param( p, "publish_metadata", "publishMetadata", "write_metadata", "writeMetadata", "metadata_writeback", "metadataWriteback", "write_to_resolve", "writeToResolve", ) if raw is None: return bool(_media_analysis_effective_preferences().get("metadata_writeback_default", True)) return _media_analysis_bool(raw, True) def _compact_clip_row_for_response(clip: Any) -> Any: """Build a compact per-clip row for tool-result responses (V2 P9). Heavy fields (visual.prompt ~9k chars, visual.frame_metadata, visual.shot_table, visual.frame_paths in full) are replaced with summaries. The complete payload remains on disk at analysis_json; consumers can read from there when needed. """ if not isinstance(clip, dict): return clip record = clip.get("record") if isinstance(clip.get("record"), dict) else {} visual = clip.get("visual") if isinstance(clip.get("visual"), dict) else None out: Dict[str, Any] = { "success": clip.get("success"), "analysis_json": clip.get("analysis_json"), "clip_dir": clip.get("clip_dir"), "marker_plan_json": clip.get("marker_plan_json"), "vision_status": clip.get("vision_status"), "reused": clip.get("reused"), "reused_from": clip.get("reused_from"), "reuse_source": clip.get("reuse_source"), "reuse_reason": clip.get("reuse_reason"), "cache_status": clip.get("cache_status"), "cache_warnings": clip.get("cache_warnings"), "record": { k: record.get(k) for k in ("clip_id", "clip_name", "file_path", "duration_seconds", "fps", "media_type", "resolution", "bin_path", "media_id") if record.get(k) is not None }, } if "error" in clip: out["error"] = clip.get("error") if "caps_refusal" in clip: out["caps_refusal"] = clip.get("caps_refusal") if isinstance(visual, dict): frame_paths = visual.get("frame_paths") or [] shot_table = visual.get("shot_table") or [] prompt = visual.get("prompt") out["visual"] = { "status": visual.get("status"), "provider": visual.get("provider"), "schema_reference": visual.get("schema_reference"), "vision_token": visual.get("vision_token"), "frame_count": len(frame_paths), "frame_paths_summary": { "directory": (frame_paths[0].rsplit("/", 1)[0] if frame_paths else None), "first": frame_paths[0] if frame_paths else None, "last": frame_paths[-1] if frame_paths else None, "count": len(frame_paths), }, "shot_count": len(shot_table), "commit_action": visual.get("commit_action"), "prompt_size_chars": (len(prompt) if isinstance(prompt, str) else None), "instructions": ( "Full vision payload (prompt, schema, frame_paths[], frame_metadata[], shot_table[]) " "is on disk at analysis_json. Read it for vision pass; call media_analysis" "(action='commit_vision', params={clip_id, visual, vision_token}) when done. " "Use verbose=true on analyze to inline the full payload instead." ), } return out def _compact_manifest_for_response(manifest: Any, *, verbose: bool = False) -> Any: """Compact manifest for tool-result return (V2 P9 — fixes 25k-token overflow). When verbose=False (default), heavy clip-level fields are replaced with summaries. The full manifest is always persisted to disk; this only affects what gets returned over the wire to the calling chat / agent. """ if verbose or not isinstance(manifest, dict): return manifest compact = { k: manifest.get(k) for k in ( "success", "analysis_version", "schema_version", "vision_pending", "vision_pending_clip_count", "clip_count", "successful_clip_count", "failed_clip_count", "caps_refusal_clip_count", "error", "partial_success", "completed_clip_ids", "failed_clip_ids", "started_at", "completed_at", "project_name", "project_id", "depth", "session_only", "session_token", "pending_action", "index", "analysis_registry", "reuse_summary", "memory_layer_warnings", "artifacts_cleaned_up", "artifact_cleanup_root", ) if k in manifest } compact["clips"] = [ _compact_clip_row_for_response(c) for c in (manifest.get("clips") or []) ] # Drop the heavy 'reports' field that session_only mode inlines. Consumers # who need it should call verbose=true or read analysis_json from disk. if isinstance(manifest.get("reports"), list): compact["reports_summary"] = { "count": len(manifest["reports"]), "note": "Full reports inlined only when verbose=true. Read analysis_json on disk per clip.", } return compact def _media_analysis_missing_capabilities_response(plan: Dict[str, Any], *, action_label: str = "analysis") -> Dict[str, Any]: gaps = plan.get("capability_gaps") or [] install = plan.get("install_guidance") or {} return { "success": False, "status": "missing_required_capabilities", "error": f"Cannot start {action_label} because required local analysis tools are missing.", "capability_gaps": gaps, "install_guidance": install, "next_step": ( "Install or configure the missing tools, then rerun the analysis. " "The MCP reports guidance only and does not install packages automatically." ), "plan": plan, } def _compact_metadata_publish_for_response(publish: Any, *, verbose: bool = False) -> Any: """Compact publish_clip_metadata response for wire return (V2 P9). The full response carries the analysis manifest, marker plans, evidence blocks, and per-field write attempts — easily 100k+ chars for a normal clip. The compact view keeps the success/error signal and the per-clip outcome summary, drops bulk. """ if verbose or not isinstance(publish, dict): return publish compact = { k: publish.get(k) for k in ( "success", "dry_run", "confirmation_required", "marker_writeback_enabled", "timed_marker_decision", "target", "clip_count", "changed_clip_count", "warnings", "setup_defaults_applied", ) if k in publish } if isinstance(publish.get("timed_marker_prompt"), str) or publish.get("timed_marker_prompt") is None: compact["timed_marker_prompt"] = publish.get("timed_marker_prompt") results = publish.get("results") if isinstance(results, list): compact["results"] = [ { "clip_id": (r.get("clip_id") if isinstance(r, dict) else None), "clip_name": (r.get("clip_name") if isinstance(r, dict) else None), "success": (r.get("success") if isinstance(r, dict) else None), "metadata_error": (r.get("metadata_error") if isinstance(r, dict) else None), "third_party_metadata_error": (r.get("third_party_metadata_error") if isinstance(r, dict) else None), "marker_writeback_enabled": (r.get("marker_writeback_enabled") if isinstance(r, dict) else None), "fields_written": ( list((r.get("metadata_writes") or {}).keys()) if isinstance(r, dict) and isinstance(r.get("metadata_writes"), dict) else [] ), "third_party_keys_written": ( list((r.get("third_party_metadata_writes") or {}).keys()) if isinstance(r, dict) and isinstance(r.get("third_party_metadata_writes"), dict) else [] ), "analysis_report": (r.get("analysis_report") if isinstance(r, dict) else None), "write_errors": (r.get("write_errors") if isinstance(r, dict) else None), } for r in results ] # Drop the heavy analysis_manifest from the publish response — caller has it # in the parent result['manifest'] already (also compacted). return compact def _media_analysis_apply_setup_defaults(action: str, p: Dict[str, Any]) -> Dict[str, Any]: prefs = _media_analysis_effective_preferences() applied: Dict[str, Any] = {} out = dict(p) preferred_root = prefs.get("preferred_analysis_root") if preferred_root and not _has_any_param(out, "analysis_root", "analysisRoot"): out["analysis_root"] = preferred_root applied["preferred_analysis_root"] = preferred_root if not _has_any_param(out, "include_confidence_scores", "includeConfidenceScores"): out["include_confidence_scores"] = prefs.get("include_confidence_scores") applied["include_confidence_scores"] = prefs.get("include_confidence_scores") if not _has_any_param(out, "include_source_time_notes", "includeSourceTimeNotes"): out["include_source_time_notes"] = prefs.get("include_source_time_notes") applied["include_source_time_notes"] = prefs.get("include_source_time_notes") if not _has_any_param(out, "analysis_summary_style", "analysisSummaryStyle"): out["analysis_summary_style"] = prefs.get("analysis_summary_style") applied["analysis_summary_style"] = prefs.get("analysis_summary_style") if not _has_any_param(out, "report_format", "reportFormat"): out["report_format"] = prefs.get("report_format") applied["report_format"] = prefs.get("report_format") if not _has_any_param(out, "source_trust", "sourceTrust"): pref_trust = prefs.get("source_trust") if pref_trust and pref_trust != "auto": out["source_trust"] = pref_trust applied["source_trust"] = pref_trust if action in {"plan", "analyze_file", "analyze_clip", "analyze_bin", "analyze_project", "analyze_timeline", "analyze_sequence", "start_batch_job", "review_timeline_markers", "publish_clip_metadata"}: include_visuals = _first_param(out, "include_visuals", "includeVisuals") if not _has_any_param(out, "vision"): if include_visuals is not None: visuals_enabled = _media_analysis_bool(include_visuals, True) out["vision"] = {"enabled": visuals_enabled} if visuals_enabled: out["vision"]["provider"] = HOST_CHAT_PATHS_PROVIDER applied["include_visuals"] = visuals_enabled else: vision_default = prefs.get("vision_default") if vision_default == "on": out["vision"] = {"enabled": True, "provider": HOST_CHAT_PATHS_PROVIDER} applied["vision_default"] = vision_default elif vision_default in {"off", "technical_only"}: out["vision"] = {"enabled": False} applied["vision_default"] = vision_default include_transcription = _first_param(out, "include_transcription", "includeTranscription") if not _has_any_param(out, "transcription"): if include_transcription is not None: out["transcription"] = _media_analysis_transcription_options( _media_analysis_bool(include_transcription, False) ) applied["include_transcription"] = out["transcription"]["enabled"] else: transcription_default = prefs.get("transcription_default") if transcription_default in {"yes", "no"}: out["transcription"] = _media_analysis_transcription_options( transcription_default == "yes" ) applied["transcription_default"] = transcription_default # Frame-sampling mode: resolve from explicit param > saved default > first-run # prompt (recommended fallback). Inject mode + tunables so the analysis engine # picks them up via _resolve_sampling_config; stash the decision so the entry # point can surface the first-run prompt. if action in {"plan", "analyze_file", "analyze_clip", "analyze_bin", "analyze_project", "analyze_timeline", "analyze_sequence", "start_batch_job"}: sampling_decision = _media_analysis_sampling_mode_decision(out) if not _has_any_param(out, "sampling_mode", "samplingMode", "frame_sampling_mode", "frameSamplingMode"): out["sampling_mode"] = sampling_decision["mode"] applied["sampling_mode"] = sampling_decision["mode"] if not _has_any_param(out, "frames_per_minute", "framesPerMinute"): out["frames_per_minute"] = prefs.get("sampling_frames_per_minute") if not _has_any_param(out, "frame_floor", "frameFloor"): out["frame_floor"] = prefs.get("sampling_frame_floor") if not _has_any_param(out, "frame_ceiling", "frameCeiling"): out["frame_ceiling"] = prefs.get("sampling_frame_ceiling") out["_sampling_mode_decision"] = sampling_decision if action in {"plan", "analyze_file", "analyze_clip", "analyze_bin", "analyze_project", "analyze_timeline", "analyze_sequence", "start_batch_job", "publish_clip_metadata"}: if not _has_any_param( out, "publish_metadata", "publishMetadata", "write_metadata", "writeMetadata", "metadata_writeback", "metadataWriteback", "write_to_resolve", "writeToResolve", ): out["publish_metadata"] = bool(prefs.get("metadata_writeback_default", True)) applied["metadata_writeback_default"] = out["publish_metadata"] persistence = prefs.get("analysis_persistence") if persistence == "keep_reports": if not _has_any_param(out, "persist"): out["persist"] = True if not _has_any_param(out, "session_only", "sessionOnly"): out["session_only"] = False if not _has_any_param(out, "cleanup_frames", "cleanupFrames"): out["cleanup_frames"] = True applied["analysis_persistence"] = persistence elif persistence == "keep_artifacts": if not _has_any_param(out, "persist"): out["persist"] = True if not _has_any_param(out, "session_only", "sessionOnly"): out["session_only"] = False if not _has_any_param(out, "keep_artifacts", "keepArtifacts"): out["keep_artifacts"] = True if not _has_any_param(out, "cleanup_frames", "cleanupFrames"): out["cleanup_frames"] = False applied["analysis_persistence"] = persistence if action == "publish_clip_metadata": if not _has_any_param(out, "fields"): out["fields"] = list(prefs.get("metadata_publish_fields") or _MEDIA_ANALYSIS_DEFAULT_PUBLISH_FIELDS) applied["metadata_publish_fields"] = out["fields"] policy = prefs.get("metadata_overwrite_policy") if policy == "overwrite_all" and not _has_any_param(out, "overwrite"): out["overwrite"] = True applied["metadata_overwrite_policy"] = policy elif policy == "fill_empty" and not _has_any_param(out, "merge_policy", "mergePolicy"): out["merge_policy"] = "fill_empty" applied["metadata_overwrite_policy"] = policy elif policy == "overwrite_owned_blocks" and not _has_any_param(out, "merge_policy", "mergePolicy"): out["merge_policy"] = "update_block" applied["metadata_overwrite_policy"] = policy elif policy == "preserve_human" and not _has_any_param(out, "merge_policy", "mergePolicy"): out["merge_policy"] = "append_relevant" applied["metadata_overwrite_policy"] = policy if not _has_any_param(out, "slate_detection", "slateDetection"): slate_default = prefs.get("slate_detection_default") if slate_default in {"yes", "no"}: out["slate_detection"] = { "enabled": slate_default == "yes", "use_vision": slate_default == "yes" and (prefs.get("vision_default") == "on"), } applied["slate_detection_default"] = slate_default if not _has_any_param(out, "confirm", "confirmed", "apply") and not prefs.get("ask_before_metadata_publish"): out["confirm"] = True applied["ask_before_metadata_publish"] = False if not _has_any_param(out, "dry_run", "dryRun") and not prefs.get("dry_run_first_default") and _media_analysis_publish_confirmed(out): out["dry_run"] = False applied["dry_run_first_default"] = False if applied: out["_setup_defaults_applied"] = applied return out async def _media_analysis_chat_context_slate_review( record: Dict[str, Any], sync_event: Optional[Dict[str, Any]], slate_detection: Dict[str, Any], ctx: Optional[Context], ) -> Dict[str, Any]: """Slate vision is now handled by the main commit_vision payload's `slate` block. The vision schema (see DEFAULT_VISION_ANALYSIS_PROMPT) already includes a slate block; the host chat fills it in during commit_vision when a slate or clapper is visible in the sampled keyframes. This stub keeps the publish flow happy when slate_detection.use_vision is set but takes no action. """ if not sync_event: return {"success": True, "status": "skipped", "reason": "No slate-clap event detected."} vision = slate_detection.get("vision") or {} if not _media_analysis_bool(slate_detection.get("use_vision", slate_detection.get("useVision", vision.get("enabled"))), False): return {"success": True, "status": "skipped", "reason": "Slate vision disabled."} return { "success": True, "status": "skipped", "provider": HOST_CHAT_PATHS_PROVIDER, "reason": ( "Slate vision is embedded in the host_chat_paths commit_vision payload; " "fill in visual.slate during commit_vision instead of running a separate slate pass." ), "sync_event": sync_event, } async def _publish_clip_metadata_from_analysis( proj, p: Dict[str, Any], ctx: Optional[Context], ) -> Dict[str, Any]: mp = proj.GetMediaPool() if not mp: return _err("Failed to get MediaPool") target = _media_analysis_target_dict(p.get("target"), p) if str(target.get("type") or p.get("target_type") or "clip").strip().lower() == "file": return _err("publish_clip_metadata requires Resolve Media Pool clips, not raw file targets") records, normalized_target, warnings, target_err = _media_analysis_records_from_target(mp, p, project=proj) if target_err: if warnings: target_err["warnings"] = warnings return target_err assert records is not None marker_decision = _media_analysis_timed_marker_decision(p) p = dict(p) p["_timed_marker_decision"] = marker_decision caller_dry_run = _media_analysis_bool(p.get("dry_run"), True) # what the caller asked for dry_run = caller_dry_run # V2 P10: track WHY dry_run might get auto-flipped, so we can report it # honestly. Previous behavior: auto-flip to dry_run=True and still return # success=True per-clip, producing a silent lie ("publish succeeded" when # nothing was written). Fix: surface the flip reason in the response so # callers can distinguish "no writes attempted" from "writes succeeded". auto_dry_run_flip = False auto_dry_run_flip_reason: Optional[str] = None if not _media_analysis_metadata_writeback_enabled(p): if not dry_run: auto_dry_run_flip = True auto_dry_run_flip_reason = "metadata_writeback_disabled" dry_run = True if not dry_run and not _media_analysis_publish_confirmed(p): dry_run = True auto_dry_run_flip = True auto_dry_run_flip_reason = auto_dry_run_flip_reason or "publish_not_confirmed" confirmation_required = True elif not dry_run and marker_decision.get("prompt_required"): dry_run = True auto_dry_run_flip = True auto_dry_run_flip_reason = auto_dry_run_flip_reason or "marker_prompt_required" confirmation_required = True else: confirmation_required = bool(not caller_dry_run and dry_run) project_name, project_id = _project_name_and_id(proj) analysis_params = dict(p) analysis_params["target"] = normalized_target analysis_params["dry_run"] = False if "persist" not in analysis_params and "session_only" not in analysis_params: analysis_params["session_only"] = dry_run analysis_params["persist"] = not dry_run if dry_run and "cleanup_frames" not in analysis_params: analysis_params["cleanup_frames"] = True caps = _media_analysis_capabilities_for_request(ctx) # Auto-publish-after-commit_vision fast path. When the caller has just # written a fresh analysis report (commit_vision did the merge), it passes # `_pre_resolved_report_paths={clip_id: analysis_json_path, ...}` so we # skip the re-analysis step entirely. Re-running execute_plan here is # fragile: any cache-reuse miss (path normalization, signature drift, # record-shape difference) causes a fresh analysis pass, which re-emits # `vision_status=pending_host_analysis` and silently lies in the response # ("status=pending_host_vision_analysis") even though the disk artifact # is fine. Reading the just-written report directly removes that class. pre_resolved_paths = p.get("_pre_resolved_report_paths") or {} plan: Dict[str, Any] = {} manifest: Dict[str, Any] = {} reports_by_clip_id: Dict[str, Tuple[Dict[str, Any], Optional[str]]] = {} if pre_resolved_paths and isinstance(pre_resolved_paths, dict): for clip_id_key, report_path in pre_resolved_paths.items(): if not clip_id_key or not report_path or not os.path.isfile(report_path): continue try: with open(report_path, "r", encoding="utf-8") as handle: reports_by_clip_id[str(clip_id_key)] = (json.load(handle), report_path) except (OSError, json.JSONDecodeError): continue manifest = { "success": True, "vision_pending": False, "pre_resolved": True, "clips": [ { "record": rec, "success": True, "reused": True, "analysis_json": reports_by_clip_id.get(str(rec.get("clip_id") or ""), (None, None))[1], } for rec in records ], } plan = {"success": True, "pre_resolved": True, "clip_count": len(records)} else: plan = build_media_analysis_plan( project_name=project_name, project_id=project_id, records=records, target=normalized_target, params=analysis_params, capabilities=caps, ) if not plan.get("success"): return plan if plan.get("capability_gaps"): return _media_analysis_missing_capabilities_response(plan, action_label="metadata publish analysis") manifest = await execute_media_analysis_plan_async( plan, params=analysis_params, capabilities=caps, ) if not manifest.get("success"): return {"success": False, "manifest": manifest, "plan": plan} if manifest.get("vision_pending"): return { "success": True, "status": "pending_host_vision_analysis", "manifest": manifest, "plan": plan, "pending_action": manifest.get("pending_action"), "reason": ( "Visual analysis is deferred to the host chat. Inspect manifest.clips[*].visual " "for each clip's frame_paths and call media_analysis(action='commit_vision', ...) " "per clip. Metadata writeback will run automatically when commit_vision finalizes." ), } for report in manifest.get("reports") or []: clip_id = ((report.get("clip") or {}).get("clip_id")) if clip_id: reports_by_clip_id[str(clip_id)] = (report, None) for row in manifest.get("clips") or []: report_path = row.get("analysis_json") if manifest.get("session_only") and manifest.get("artifacts_cleaned_up") and report_path and not os.path.isfile(report_path): report_path = None record = row.get("record") or {} clip_id = record.get("clip_id") if clip_id and str(clip_id) in reports_by_clip_id: existing_report, _ = reports_by_clip_id[str(clip_id)] reports_by_clip_id[str(clip_id)] = (existing_report, report_path) continue if clip_id and report_path and os.path.isfile(report_path): try: with open(report_path, "r", encoding="utf-8") as handle: reports_by_clip_id[str(clip_id)] = (json.load(handle), report_path) except (OSError, json.JSONDecodeError): pass detection = None slate_detection = dict(p.get("slate_detection") or p.get("slateDetection") or {}) if "vision" not in slate_detection and isinstance(p.get("vision"), dict): slate_detection["vision"] = p.get("vision") if _media_analysis_bool(slate_detection.get("enabled"), False): detection_params = dict(p) detection_params.update(slate_detection) detection_params["target"] = normalized_target detection_params.setdefault("prefer_event_type", "slate_clap") detection = detect_media_sync_events(records, detection_params) fields = p.get("fields") or list(_MEDIA_ANALYSIS_DEFAULT_PUBLISH_FIELDS) if not isinstance(fields, list): return _err("fields must be a list of Resolve metadata field names") fields = [str(field) for field in fields if str(field).strip()] include_provenance = _media_analysis_bool(p.get("include_provenance", p.get("includeProvenance")), True) marker_writeback_enabled = bool(marker_decision.get("enabled")) root = mp.GetRootFolder() results = [] write_failures = [] for record in records: clip_id = str(record.get("clip_id") or "") clip = _find_clip(root, clip_id) if not clip: results.append({"clip_id": clip_id, "success": False, "error": "Clip not found for metadata write"}) write_failures.append(clip_id) continue report, report_path = reports_by_clip_id.get(clip_id, ({}, None)) if not report: results.append({"clip_id": clip_id, "success": False, "error": "Analysis report not available"}) write_failures.append(clip_id) continue metadata, metadata_error = _safe_clip_call(clip, "GetMetadata", "") metadata = metadata if isinstance(metadata, dict) else {} third_party, third_party_error = _safe_clip_call(clip, "GetThirdPartyMetadata", "") third_party = third_party if isinstance(third_party, dict) else {} sync_event = _media_analysis_best_sync_event(detection, clip_id) slate_review = None if sync_event and _media_analysis_bool(slate_detection.get("enabled"), False): slate_review = await _media_analysis_chat_context_slate_review(record, sync_event, slate_detection, ctx) candidate_payload = _media_analysis_report_metadata_candidates( report, detection=detection, slate_review=slate_review, fields=fields, require_visual_description=_media_analysis_bool(p.get("require_visual_description", p.get("requireVisualDescription")), False), require_visual_slate=True, ) proposed_fields = candidate_payload["fields"] field_results = [] writes: Dict[str, str] = {} for field in fields: write_field = _metadata_write_field_for_field(field) existing_value = metadata.get(write_field, metadata.get(field, "")) merged = _media_analysis_merge_metadata_field(field, existing_value, proposed_fields.get(field), p) if write_field != field: merged["write_field"] = write_field field_results.append(merged) if merged.get("changed"): writes[write_field] = str(merged["value"]) provenance = _media_analysis_provenance_metadata(report, report_path, sorted(writes.keys())) if include_provenance else {} marker_writeback = _media_analysis_marker_candidates_from_report( report, record, sync_event, report_path, p, slate_review=slate_review, ) # V2 P10: row.success starts False — only becomes True if writes actually # happen and succeed. Previously defaulted to True, so dry_run cases # silently returned success=true with no writes. row = { "clip_id": clip_id, "clip_name": record.get("clip_name"), "success": False, "status": "dry_run" if dry_run else "writes_pending", "dry_run": dry_run, "auto_dry_run_flip": auto_dry_run_flip, "auto_dry_run_flip_reason": auto_dry_run_flip_reason, "timed_marker_decision": marker_decision, "marker_writeback_enabled": marker_writeback_enabled, "metadata_error": metadata_error, "third_party_metadata_error": third_party_error, "analysis_report": report_path, "fields": field_results, "metadata_writes": writes, "third_party_metadata_writes": provenance, "marker_writes": marker_writeback if marker_writeback_enabled else {"enabled": False}, "evidence": candidate_payload.get("evidence"), "slate_review": slate_review, } if not dry_run: metadata_ok = True third_party_ok = True markers_ok = True for field, value in writes.items(): try: metadata_ok = bool(clip.SetMetadata(field, value)) and metadata_ok except Exception as exc: metadata_ok = False row.setdefault("write_errors", []).append({"field": field, "error": str(exc)}) for key, value in provenance.items(): try: third_party_ok = bool(clip.SetThirdPartyMetadata(key, value)) and third_party_ok except Exception as exc: third_party_ok = False row.setdefault("write_errors", []).append({"third_party_key": key, "error": str(exc)}) if marker_writeback_enabled: marker_result = _apply_media_analysis_clip_markers(clip, marker_writeback.get("markers") or [], p) marker_writeback["result"] = marker_result row["marker_writes"] = marker_writeback markers_ok = marker_result.get("success") is True row["success"] = metadata_ok and third_party_ok and markers_ok row["status"] = "wrote" if row["success"] else "write_failed" if not row["success"]: write_failures.append(clip_id) elif auto_dry_run_flip: # Caller asked for writes; we silently downgraded. Treat as a failure # of intent (caller will surface this to the user). row["status"] = f"blocked:{auto_dry_run_flip_reason}" write_failures.append(clip_id) results.append(row) # Top-level success requires: no write failures AND writes weren't auto-blocked. # A dry_run requested explicitly by the caller returns success=true (preview ok); # a dry_run auto-flipped from a real write request returns success=false. top_success = (not write_failures) and (not auto_dry_run_flip) return { "success": top_success, "dry_run": dry_run, "caller_dry_run": caller_dry_run, "auto_dry_run_flip": auto_dry_run_flip, "auto_dry_run_flip_reason": auto_dry_run_flip_reason, "setup_defaults_applied": p.get("_setup_defaults_applied", {}), "marker_writeback_enabled": marker_writeback_enabled, "timed_marker_decision": marker_decision, "timed_marker_prompt": _media_analysis_timed_marker_prompt() if marker_decision.get("prompt_required") else None, "confirmation_required": confirmation_required, "target": normalized_target, "clip_count": len(records), "changed_clip_count": sum(1 for row in results if row.get("metadata_writes")), "results": results, "warnings": warnings, "analysis_manifest": manifest, "sync_detection": detection, } def _media_pool_item_probe(clip): metadata, metadata_error = _safe_clip_call(clip, "GetMetadata", "") third_party, third_party_error = _safe_clip_call(clip, "GetThirdPartyMetadata", "") properties, properties_error = _safe_clip_call(clip, "GetClipProperty", "") known_properties = {} for key in _MEDIA_POOL_KNOWN_CLIP_PROPERTIES: value, err = _safe_clip_call(clip, "GetClipProperty", key) known_properties[key] = {"value": value, "error": err} if err else {"value": value} color, color_error = _safe_clip_call(clip, "GetClipColor") markers, markers_error = _safe_clip_call(clip, "GetMarkers") flags, flags_error = _safe_clip_call(clip, "GetFlagList") audio_mapping, audio_mapping_error = _safe_clip_call(clip, "GetAudioMapping") mark, mark_error = _safe_clip_call(clip, "GetMarkInOut") return { "summary": _media_pool_item_summary(clip), "methods": _callable_method_names(clip, _MEDIA_POOL_ITEM_METHODS), "metadata": {"value": metadata, "error": metadata_error} if metadata_error else metadata, "third_party_metadata": ( {"value": third_party, "error": third_party_error} if third_party_error else third_party ), "clip_properties": {"value": properties, "error": properties_error} if properties_error else properties, "known_clip_properties": known_properties, "clip_color": {"value": color, "error": color_error} if color_error else color, "markers": {"value": markers, "error": markers_error} if markers_error else markers, "flags": {"value": flags, "error": flags_error} if flags_error else flags, "audio_mapping": ( {"value": audio_mapping, "error": audio_mapping_error} if audio_mapping_error else audio_mapping ), "mark_in_out": {"value": mark, "error": mark_error} if mark_error else mark, } def _folder_probe(folder, depth: int = 1): if not folder: return None clips = [] for clip in (folder.GetClipList() or []): clips.append(_media_pool_item_summary(clip)) subfolders = [] if depth > 0: for sub in (folder.GetSubFolderList() or []): subfolders.append(_folder_probe(sub, depth - 1)) stale = None try: stale = bool(folder.GetIsFolderStale()) except Exception: pass return { "name": folder.GetName(), "id": folder.GetUniqueId(), "stale": stale, "clip_count": len(clips), "clips": clips, "subfolder_count": len(subfolders), "subfolders": subfolders, } def _media_pool_ingest_capabilities(): return { "supported": { "storage_browsing": ["get_volumes", "get_subfolders", "get_files"], "imports": [ "media_storage.import_to_pool simple paths", "media_storage.import_to_pool item_infos", "media_pool.import_media simple paths", "media_pool.import_media image sequence clip_infos", "media_pool.import_folder", "media_pool.safe_import_media with path validation and optional target folder", "media_pool.safe_import_sequence with printf-pattern frame validation", "media_pool.safe_import_folder with directory validation", ], "organization": [ "folder add/delete/move", "clip move/delete", "current folder set/get", "selected clip get/set", "media_pool.organize_clips with optional folder creation", ], "metadata": [ "metadata get/set scalar", "metadata get/set dict", "third-party metadata get/set", "media_pool.copy_metadata across clips", "media_pool.normalize_metadata bulk writes", "clip property snapshot", "clip property set when Resolve accepts the key", "media_pool.probe_clip_properties read-only property snapshots", ], "annotations": [ "media pool item markers", "media pool item custom marker data", "flags", "clip color", "mark in/out", "media_pool.set_clip_marks and clear_clip_marks", "media_pool.copy_clip_annotations for markers, flags, and clip color", ], "media_links": [ "relink/unlink through Resolve MediaPool APIs", "media_pool.safe_relink and safe_unlink with path/clip validation", "proxy link/unlink through MediaPoolItem APIs", "media_pool.link_proxy_checked with file validation", "full-resolution media link where Resolve 20 exposes it", "media_pool.link_full_resolution_checked with version/path validation", ], "read_only_probe": [ "media pool method availability", "media pool folder summaries", "media pool item method availability", "metadata snapshots", "third-party metadata snapshots", "clip property snapshots", "metadata field inventory with inferred Resolve Metadata-panel group hints", "markers, flags, clip color, audio mapping, mark in/out", "media_pool.media_pool_boundary_report", ], "source_media_integrity": [ "live validation uses generated synthetic media only", "safe helpers must not transcode, render, proxy, or overwrite user source media", ], }, "partially_supported": { "clip_properties": "Resolve accepts only some GetClipProperty/SetClipProperty keys by media type and build.", "proxy_and_full_resolution_links": "Resolve may accept paths without deep compatibility validation; probes must use synthetic media.", "audio_transcription": "Transcription availability depends on Resolve Studio features, installed components, media type, and page/build state.", "image_sequences": "Sequence import behavior depends on FilePath pattern, frame range, and Resolve's still/sequence interpretation.", "audio_mapping": "Readback is available on supported media, but mapping shape varies by clip type.", }, "unsupported": { "source_media_mutation": "The MCP kernel never edits, transcodes, proxies, or overwrites original source media unless explicitly requested by the user.", "safe_destructive_replace": "ReplaceClip and ReplaceClipPreserveSubClip are exposed raw APIs; kernel probes must restrict them to disposable synthetic media.", "guaranteed_metadata_schema": "Resolve does not guarantee every metadata key is writable or stable across versions/locales.", }, } def _media_pool_probe(mp, p: Dict[str, Any]): depth = p.get("depth", 1) try: depth = max(0, min(int(depth), 4)) except (TypeError, ValueError): return _err("depth must be an integer") root = mp.GetRootFolder() current = mp.GetCurrentFolder() selected = [] try: selected = [_media_pool_item_summary(clip) for clip in (mp.GetSelectedClips() or [])] except Exception as exc: selected = [{"error": str(exc)}] return { "media_pool_id": mp.GetUniqueId(), "methods": _callable_method_names(mp, _MEDIA_POOL_METHODS), "root": _folder_probe(root, depth), "current_folder": _folder_probe(current, 0) if current else None, "selected_clips": selected, } def _media_pool_probe_ingest_items(mp, p: Dict[str, Any]): root = mp.GetRootFolder() ids = p.get("clip_ids") or p.get("ids") selected = bool(p.get("selected", False)) clips = [] warnings = [] if ids: if not isinstance(ids, list): return _err("probe_ingest_item requires clip_ids as a list") for clip_id in ids: clip = _find_clip(root, str(clip_id)) if clip: clips.append(clip) else: warnings.append(f"Clip not found: {clip_id}") if selected: try: clips.extend(mp.GetSelectedClips() or []) except Exception as exc: warnings.append(f"GetSelectedClips failed: {exc}") if not clips: return _err("probe_ingest_item requires clip_ids or selected=True") out = {"items": [_media_pool_item_probe(clip) for clip in clips], "count": len(clips)} if warnings: out["warnings"] = warnings return out def _path_error(path: str, *, must_be_dir: bool = False, must_be_file: bool = False): if not path or not isinstance(path, str): return "path must be a non-empty string" if not os.path.exists(path): return f"path does not exist: {path}" if must_be_dir and not os.path.isdir(path): return f"path is not a directory: {path}" if must_be_file and not os.path.isfile(path): return f"path is not a file: {path}" return None def _string_list_param(p: Dict[str, Any], key: str): value = p.get(key) if not isinstance(value, list) or not value: return None, _err(f"{key} must be a non-empty list") cleaned = [] for index, item in enumerate(value): if not isinstance(item, str) or not item: return None, _err(f"{key}[{index}] must be a non-empty string") cleaned.append(item) return cleaned, None def _clips_from_params(root, mp, p: Dict[str, Any], *, key: str = "clip_ids"): ids = p.get(key) or p.get("ids") selected = bool(p.get("selected", False)) clips = [] missing = [] if ids: if not isinstance(ids, list): return None, _err(f"{key} must be a list") for clip_id in ids: clip = _find_clip(root, str(clip_id)) if clip: clips.append(clip) else: missing.append(str(clip_id)) if selected: clips.extend(mp.GetSelectedClips() or []) deduped = [] seen = set() for clip in clips: clip_id = _safe_media_pool_item_id(clip) or id(clip) if clip_id in seen: continue seen.add(clip_id) deduped.append(clip) if not deduped: return None, _err(f"Provide {key} or selected=True") return (deduped, missing), None def _clip_summaries(clips): return [_media_pool_item_summary(clip) for clip in clips] def _imported_clip_summaries(items): return { "imported": len(items) if items else 0, "clips": _clip_summaries(items or []), } def _format_sequence_path(pattern: str, index: int): try: return pattern % index except (TypeError, ValueError): return None def _missing_sequence_frames(pattern: str, start: int, end: int): missing = [] unformattable = False for index in range(start, end + 1): path = _format_sequence_path(pattern, index) if not path: unformattable = True break if not os.path.exists(path): missing.append(path) return missing, unformattable def _set_current_folder_temporarily(mp, target_path: Optional[str]): if not target_path: return None, None target = _navigate_folder(mp, target_path) if not target: return None, _err(f"Target folder not found: {target_path}") previous = mp.GetCurrentFolder() if not mp.SetCurrentFolder(target): return None, _err(f"Failed to set current folder: {target_path}") return previous, None def _restore_current_folder(mp, previous): if previous: try: mp.SetCurrentFolder(previous) except Exception: pass def _ensure_folder_path(mp, path: str): if not path or path in ("Master", "/", ""): return mp.GetRootFolder(), None existing = _navigate_folder(mp, path) if existing: return existing, None parts = path.strip("/").split("/") if parts and parts[0] == "Master": parts = parts[1:] current = mp.GetRootFolder() built = ["Master"] for part in parts: built.append(part) found = None for sub in (current.GetSubFolderList() or []): if sub.GetName() == part: found = sub break if not found: found = mp.AddSubFolder(current, part) if not found: return None, _err(f"Failed to create folder: {'/'.join(built)}") current = found return current, None def _safe_import_media(mp, p: Dict[str, Any]): paths, err = _string_list_param(p, "paths") if err: return err errors = [] for path in paths: path_err = _path_error(path) if path_err: errors.append(path_err) if errors: return _err("; ".join(errors)) if p.get("dry_run"): return _ok(would_import=paths, target_folder=p.get("target_folder")) previous, folder_err = _set_current_folder_temporarily(mp, p.get("target_folder")) if folder_err: return folder_err try: return _ok(**_imported_clip_summaries(mp.ImportMedia(paths) or [])) finally: _restore_current_folder(mp, previous) def _safe_import_sequence(mp, p: Dict[str, Any]): pattern = p.get("FilePath") or p.get("file_path") or p.get("pattern") if not pattern: return _err("Provide FilePath, file_path, or pattern") try: start = int(p.get("StartIndex", p.get("start_index", 1))) end = int(p.get("EndIndex", p.get("end_index", start))) except (TypeError, ValueError): return _err("StartIndex/EndIndex must be integers") if end < start: return _err("EndIndex must be greater than or equal to StartIndex") missing, unformattable = _missing_sequence_frames(pattern, start, end) if unformattable: return _err("Sequence pattern must be printf-style, e.g. frame_%03d.png") if missing: sample = missing[:5] suffix = "" if len(missing) <= 5 else f" (+{len(missing) - 5} more)" return _err(f"Missing sequence frames: {sample}{suffix}") info = {"FilePath": pattern, "StartIndex": start, "EndIndex": end} if p.get("dry_run"): return _ok(would_import=[info], target_folder=p.get("target_folder")) previous, folder_err = _set_current_folder_temporarily(mp, p.get("target_folder")) if folder_err: return folder_err try: return _ok(**_imported_clip_summaries(mp.ImportMedia([info]) or [])) finally: _restore_current_folder(mp, previous) def _safe_import_folder(mp, p: Dict[str, Any]): path = p.get("path") path_err = _path_error(path, must_be_dir=True) if path_err: return _err(path_err) if p.get("dry_run"): return _ok(would_import_folder=path, source_clips_path=p.get("source_clips_path", "")) return {"success": bool(mp.ImportFolderFromFile(path, p.get("source_clips_path", "")))} def _organize_clips(mp, root, p: Dict[str, Any]): target_path = p.get("target_path") if not target_path: return _err("target_path is required") if p.get("create_missing"): target, target_err = _ensure_folder_path(mp, target_path) else: target = _navigate_folder(mp, target_path) target_err = None if target else _err(f"Target folder not found: {target_path}") if target_err: return target_err resolved, err = _clips_from_params(root, mp, p) if err: return err clips, missing = resolved if p.get("dry_run"): return _ok(target_path=target_path, clips=_clip_summaries(clips), missing=missing) return {"success": bool(mp.MoveClips(clips, target)), "moved": len(clips), "missing": missing} def _copy_metadata(root, p: Dict[str, Any]): source = _find_clip(root, p.get("source_clip_id", "")) if not source: return _err(f"Source clip not found: {p.get('source_clip_id')}") target_ids = p.get("target_clip_ids") if not isinstance(target_ids, list) or not target_ids: return _err("target_clip_ids must be a non-empty list") metadata = source.GetMetadata("") or {} if p.get("keys"): keys = set(p["keys"]) metadata = {key: value for key, value in metadata.items() if key in keys} third_party = {} if p.get("include_third_party", True): third_party = source.GetThirdPartyMetadata("") or {} results = [] for target_id in target_ids: target = _find_clip(root, str(target_id)) if not target: results.append({"clip_id": target_id, "success": False, "error": "Clip not found"}) continue if p.get("dry_run"): results.append({"clip_id": target_id, "success": True, "metadata_keys": sorted(metadata.keys()), "third_party_keys": sorted(third_party.keys())}) continue ok = bool(target.SetMetadata(metadata)) if metadata else True third_party_ok = True for key, value in third_party.items(): third_party_ok = bool(target.SetThirdPartyMetadata(key, value)) and third_party_ok results.append({"clip_id": target_id, "success": ok and third_party_ok}) return {"success": all(row.get("success") for row in results), "results": results} def _normalize_metadata(root, mp, p: Dict[str, Any]): resolved, err = _clips_from_params(root, mp, p) if err: return err clips, missing = resolved metadata = p.get("metadata") or {} third_party = p.get("third_party_metadata") or p.get("thirdPartyMetadata") or {} if not isinstance(metadata, dict) or not isinstance(third_party, dict): return _err("metadata and third_party_metadata must be objects") if not metadata and not third_party: return _err("Provide metadata or third_party_metadata") results = [] for clip in clips: clip_id = _safe_media_pool_item_id(clip) if p.get("dry_run"): results.append({"clip_id": clip_id, "success": True, "metadata_keys": sorted(metadata.keys()), "third_party_keys": sorted(third_party.keys())}) continue ok = bool(clip.SetMetadata(metadata)) if metadata else True third_party_ok = True for key, value in third_party.items(): third_party_ok = bool(clip.SetThirdPartyMetadata(key, value)) and third_party_ok results.append({"clip_id": clip_id, "success": ok and third_party_ok}) return {"success": all(row.get("success") for row in results), "count": len(results), "missing": missing, "results": results} def _probe_clip_properties(root, mp, p: Dict[str, Any]): resolved, err = _clips_from_params(root, mp, p) if err: return err clips, missing = resolved return { "count": len(clips), "missing": missing, "items": [ { "summary": _media_pool_item_summary(clip), "properties": _safe_clip_call(clip, "GetClipProperty", "")[0], "known_clip_properties": _media_pool_item_probe(clip)["known_clip_properties"], } for clip in clips ], } def _metadata_field_inventory(root, mp, p: Dict[str, Any]): resolved, err = _clips_from_params(root, mp, p) if err: return err clips, missing = resolved include_values = _media_analysis_bool(p.get("include_values", p.get("includeValues")), False) analysis_fields = list(p.get("analysis_fields") or p.get("analysisFields") or _MEDIA_ANALYSIS_DEFAULT_PUBLISH_FIELDS) slate_fields = ["Scene", "Shot", "Take", "Camera #", "Roll/Card"] optional_fields = list(p.get("optional_fields") or p.get("optionalFields") or slate_fields) items = [] for clip in clips: metadata, metadata_error = _safe_clip_call(clip, "GetMetadata", "") third_party, third_party_error = _safe_clip_call(clip, "GetThirdPartyMetadata", "") properties, properties_error = _safe_clip_call(clip, "GetClipProperty", "") metadata = metadata if isinstance(metadata, dict) else {} third_party = third_party if isinstance(third_party, dict) else {} properties = properties if isinstance(properties, dict) else {} property_fields = list(properties.keys()) metadata_fields = list(metadata.keys()) def field_status(field: str) -> Dict[str, Any]: clip_property_key = _metadata_clip_property_key_for_field(field, properties) metadata_write_key = _metadata_write_field_for_field(field) row = { "field": field, "in_get_metadata": field in metadata, "in_clip_properties": clip_property_key is not None, "inferred_ui_group": _metadata_panel_group_for_field(field), } if metadata_write_key != field: row["metadata_write_key"] = metadata_write_key if clip_property_key: row["clip_property_key"] = clip_property_key if include_values: if field in metadata: row["metadata_value"] = metadata.get(field) if clip_property_key: row["clip_property_value"] = properties.get(clip_property_key) return row item = { "summary": _media_pool_item_summary(clip), "metadata": { "field_count": len(metadata_fields), "fields": metadata_fields, }, "third_party_metadata": { "field_count": len(third_party), "fields": list(third_party.keys()), }, "clip_properties": { "field_count": len(property_fields), "fields": property_fields, }, "metadata_panel_groups": _metadata_panel_group_inventory(property_fields), "analysis_writeback_fields": { "default": [field_status(str(field)) for field in analysis_fields], "optional_slate": [field_status(str(field)) for field in optional_fields], }, } if metadata_error: item["metadata"]["error"] = metadata_error if third_party_error: item["third_party_metadata"]["error"] = third_party_error if properties_error: item["clip_properties"]["error"] = properties_error if include_values: item["metadata"]["values"] = metadata item["third_party_metadata"]["values"] = third_party item["clip_properties"]["values"] = properties items.append(item) return { "success": True, "count": len(items), "missing": missing, "group_source": "best_effort_from_GetClipProperty_keys_and_Resolve_20_metadata_panel_labels", "ui_group_names": list(_METADATA_PANEL_GROUP_ORDER), "notes": [ "Read-only probe: no SetMetadata or SetClipProperty calls were made.", "Resolve does not expose a guaranteed UI metadata-group schema through the public scripting API.", "Unfilled clips may return an empty GetMetadata() dict while GetClipProperty('') still exposes metadata-panel-style fields.", "metadata_panel_groups is an inferred grouping of the observed clip-property keys, not a Resolve-authored schema.", ], "items": items, } def _safe_relink(mp, root, p: Dict[str, Any]): folder_path = p.get("folder_path") path_err = _path_error(folder_path, must_be_dir=True) if path_err: return _err(path_err) resolved, err = _clips_from_params(root, mp, p) if err: return err clips, missing = resolved if p.get("dry_run"): return _ok(folder_path=folder_path, clips=_clip_summaries(clips), missing=missing) return {"success": bool(mp.RelinkClips(clips, folder_path)), "count": len(clips), "missing": missing} def _safe_unlink(mp, root, p: Dict[str, Any]): resolved, err = _clips_from_params(root, mp, p) if err: return err clips, missing = resolved if p.get("dry_run"): return _ok(clips=_clip_summaries(clips), missing=missing) return {"success": bool(mp.UnlinkClips(clips)), "count": len(clips), "missing": missing} def _link_proxy_checked(root, p: Dict[str, Any]): clip = _find_clip(root, p.get("clip_id", "")) if not clip: return _err(f"Clip not found: {p.get('clip_id')}") proxy_path = p.get("proxy_path") or p.get("path") path_err = _path_error(proxy_path, must_be_file=True) if path_err: return _err(path_err) if p.get("dry_run"): return _ok(clip=_media_pool_item_summary(clip), proxy_path=proxy_path) return {"success": bool(clip.LinkProxyMedia(proxy_path))} def _link_full_resolution_checked(root, p: Dict[str, Any]): clip = _find_clip(root, p.get("clip_id", "")) if not clip: return _err(f"Clip not found: {p.get('clip_id')}") missing = _requires_method(clip, "LinkFullResolutionMedia", "20.0") if missing: return missing path = p.get("path") or p.get("full_res_media_path") or p.get("fullResMediaPath") path_err = _path_error(path, must_be_file=True) if path_err: return _err(path_err) if p.get("dry_run"): return _ok(clip=_media_pool_item_summary(clip), path=path) return {"success": bool(clip.LinkFullResolutionMedia(path))} def _set_clip_marks(root, mp, p: Dict[str, Any]): resolved, err = _clips_from_params(root, mp, p) if err: return err clips, missing = resolved try: mark_in = int(p["mark_in"]) mark_out = int(p["mark_out"]) except (KeyError, TypeError, ValueError): return _err("mark_in and mark_out must be integers") results = [] for clip in clips: clip_id = _safe_media_pool_item_id(clip) if p.get("dry_run"): results.append({"clip_id": clip_id, "success": True, "mark_in": mark_in, "mark_out": mark_out}) continue results.append({"clip_id": clip_id, "success": bool(clip.SetMarkInOut(mark_in, mark_out, p.get("type", "all")))}) return {"success": all(row.get("success") for row in results), "count": len(results), "missing": missing, "results": results} def _clear_clip_marks(root, mp, p: Dict[str, Any]): resolved, err = _clips_from_params(root, mp, p) if err: return err clips, missing = resolved results = [] for clip in clips: clip_id = _safe_media_pool_item_id(clip) if p.get("dry_run"): results.append({"clip_id": clip_id, "success": True}) continue results.append({"clip_id": clip_id, "success": bool(clip.ClearMarkInOut(p.get("type", "all")))}) return {"success": all(row.get("success") for row in results), "count": len(results), "missing": missing, "results": results} def _copy_clip_annotations(root, p: Dict[str, Any]): source = _find_clip(root, p.get("source_clip_id", "")) if not source: return _err(f"Source clip not found: {p.get('source_clip_id')}") target_ids = p.get("target_clip_ids") if not isinstance(target_ids, list) or not target_ids: return _err("target_clip_ids must be a non-empty list") markers = source.GetMarkers() or {} flags = source.GetFlagList() or [] color = source.GetClipColor() include_markers = p.get("include_markers", True) include_flags = p.get("include_flags", True) include_color = p.get("include_clip_color", True) results = [] for target_id in target_ids: target = _find_clip(root, str(target_id)) if not target: results.append({"clip_id": target_id, "success": False, "error": "Clip not found"}) continue if p.get("dry_run"): results.append({"clip_id": target_id, "success": True, "markers": len(markers), "flags": len(flags), "clip_color": color}) continue ok = True if include_color and color: ok = bool(target.SetClipColor(color)) and ok if include_flags: for flag in flags: ok = bool(target.AddFlag(flag)) and ok if include_markers: for frame, marker in markers.items(): custom = marker.get("customData") or marker.get("custom_data") or "" ok = bool( target.AddMarker( int(frame), marker.get("color", "Blue"), marker.get("name", ""), marker.get("note", ""), marker.get("duration", 1), custom, ) ) and ok results.append({"clip_id": target_id, "success": ok}) return {"success": all(row.get("success") for row in results), "results": results} def _media_pool_boundary_report(mp, p: Dict[str, Any]): report = { "capabilities": _media_pool_ingest_capabilities(), "media_pool": _media_pool_probe(mp, {"depth": p.get("depth", 1)}), } if p.get("clip_ids") or p.get("selected"): report["items"] = _media_pool_probe_ingest_items(mp, p) return report def _ser(obj): """Serialize Resolve API objects to JSON-safe values.""" if obj is None: return None if isinstance(obj, (str, int, float, bool)): return obj if isinstance(obj, dict): return {k: _ser(v) for k, v in obj.items()} if isinstance(obj, (list, tuple)): return [_ser(v) for v in obj] # Resolve API object — return repr return str(obj) def _png_chunk(chunk_type: bytes, chunk_data: bytes) -> bytes: payload = chunk_type + chunk_data crc = struct.pack(">I", zlib.crc32(payload) & 0xFFFFFFFF) return struct.pack(">I", len(chunk_data)) + payload + crc def _thumbnail_data_to_png_bytes(thumbnail_data: Dict[str, Any]) -> bytes: """Convert Resolve's raw thumbnail dict into PNG bytes.""" if not isinstance(thumbnail_data, dict): raise ValueError("thumbnail_data must be a dict") width = int(thumbnail_data.get("width") or 0) height = int(thumbnail_data.get("height") or 0) components = int( thumbnail_data.get("noOfComponents") or thumbnail_data.get("components") or thumbnail_data.get("channels") or 3 ) depth = int(thumbnail_data.get("depth") or 8) data = thumbnail_data.get("data") if width <= 0 or height <= 0: raise ValueError(f"Invalid thumbnail dimensions: {width}x{height}") if components not in (3, 4): raise ValueError(f"Unsupported thumbnail component count: {components}") if depth != 8: raise ValueError(f"Unsupported thumbnail bit depth: {depth}") if not data: raise ValueError("Thumbnail data is empty") if isinstance(data, str): raw = base64.b64decode(data) elif isinstance(data, bytes): raw = data elif isinstance(data, bytearray): raw = bytes(data) elif isinstance(data, list): raw = bytes(data) else: raise ValueError(f"Unsupported thumbnail data type: {type(data).__name__}") row_size = width * components expected_size = row_size * height if len(raw) < expected_size: raise ValueError( f"Thumbnail data too short: got {len(raw)} bytes, expected " f"{expected_size} for {width}x{height}x{components}" ) raw = raw[:expected_size] filtered_rows = bytearray() for y in range(height): filtered_rows.append(0) start = y * row_size filtered_rows.extend(raw[start:start + row_size]) color_type = 2 if components == 3 else 6 ihdr = struct.pack(">IIBBBBB", width, height, 8, color_type, 0, 0, 0) return ( b"\x89PNG\r\n\x1a\n" + _png_chunk(b"IHDR", ihdr) + _png_chunk(b"IDAT", zlib.compress(bytes(filtered_rows))) + _png_chunk(b"IEND", b"") ) def _unknown(action, valid): return _err(f"Unknown action '{action}'. Valid actions: {', '.join(valid)}") def _normalize_cdl(cdl): """Normalize CDL payloads to the string format Resolve's SetCDL expects.""" return normalize_cdl_payload(cdl) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 1: resolve # ═══════════════════════════════════════════════════════════════════════════════ def _mcp_update_status_payload(force: bool = False, timeout: float = 3.0) -> Dict[str, Any]: update_env = _setup_update_env() if force: update = check_for_updates(VERSION, project_dir, env=update_env, timeout=timeout, force=True) else: update = get_cached_update_status(project_dir, VERSION, env=update_env) return { "version": VERSION, "update": update, "decision": update_prompt_decision(update, env=update_env), } _SETUP_UPDATE_MODES = {"prompt", "auto", "notify", "never"} _SETUP_CLEAR_VALUES = {"", "ask", "prompt", "clear", "default", "none", "null", "unset"} def _setup_update_state() -> Dict[str, Any]: try: with open(update_state_path(project_dir), "r", encoding="utf-8") as handle: payload = json.load(handle) return payload if isinstance(payload, dict) else {} except (OSError, json.JSONDecodeError): return {} def _setup_update_env() -> Dict[str, str]: env = dict(os.environ) state = _setup_update_state() if state.get("check_interval_hours") is not None and "DAVINCI_RESOLVE_MCP_UPDATE_INTERVAL_HOURS" not in env: env["DAVINCI_RESOLVE_MCP_UPDATE_INTERVAL_HOURS"] = str(state["check_interval_hours"]) if state.get("snooze_hours") is not None and "DAVINCI_RESOLVE_MCP_UPDATE_SNOOZE_HOURS" not in env: env["DAVINCI_RESOLVE_MCP_UPDATE_SNOOZE_HOURS"] = str(state["snooze_hours"]) return env def _setup_bool(value: Any, default: bool = False) -> bool: if value is None: return default if isinstance(value, bool): return value text = str(value).strip().lower() if text in {"1", "true", "yes", "on", "enabled"}: return True if text in {"0", "false", "no", "off", "disabled"}: return False return default def _setup_nested(params: Dict[str, Any], *keys: str) -> Dict[str, Any]: for key in keys: value = params.get(key) if isinstance(value, dict): return value return {} def _setup_normalize_timed_marker_default(value: Any) -> Tuple[Optional[str], Optional[str], bool]: if value is None: return None, None, False if not isinstance(value, bool) and str(value).strip().lower().replace("-", "_").replace(" ", "_") in _SETUP_CLEAR_VALUES: return None, None, True choice = _normalize_timed_marker_choice(value) if choice in {"default_yes", "default_no"}: return ("yes" if choice == "default_yes" else "no"), None, False if choice in {"yes", "no"}: return choice, None, False if choice == "ask": return None, None, True return None, f"Unsupported timed markers default: {value!r}. Use yes, no, ask, default_yes, or default_no.", False def _setup_media_analysis_defaults() -> Dict[str, Any]: effective = _media_analysis_effective_preferences() return { **effective, "preferences_path": _media_analysis_preferences_path(), "options": { "yes_no_ask": ["yes", "no", "ask"], "timed_markers": ["yes", "no", "ask", "default_yes", "default_no"], "vision_default": ["on", "off", "technical_only", "ask"], "analysis_persistence": ["session_only", "keep_reports", "keep_artifacts"], "metadata_writeback_default": [True, False], "metadata_overwrite_policy": ["preserve_human", "fill_empty", "overwrite_owned_blocks", "overwrite_all"], "timed_marker_types": ["shots", "slate_clap", "sync_events", "best_moments", "qc_warnings"], "analysis_summary_style": ["full", "concise", "creative", "technical"], "report_format": ["compact", "full", "machine_readable"], "source_trust": ["auto", "filename", "low", "medium", "high"], "default_depth": ["quick", "standard", "deep"], "default_post_operation_page": ["stay_put", "media", "cut", "edit", "fusion", "color", "fairlight", "deliver"], "sampling_mode_default": ["ask", "fixed", "per_minute", "adaptive_capped", "adaptive"], }, "sampling_mode_labels": dict(_media_analysis_module.SAMPLING_MODE_LABELS), } def _setup_updates_defaults() -> Dict[str, Any]: update_env = _setup_update_env() state = _setup_update_state() mode = get_update_mode(project_dir, update_env) update = get_cached_update_status(project_dir, VERSION, env=update_env) update["update_mode"] = mode return { "mode": mode, "check_interval_hours": _setup_positive_float(state.get("check_interval_hours"), 24.0, 0.1, 8760.0), "snooze_hours": _setup_positive_float(state.get("snooze_hours"), 24.0, 0.1, 8760.0), "state_path": str(update_state_path(project_dir)), "options": sorted(_SETUP_UPDATE_MODES), "update": update, "decision": update_prompt_decision(update, env=update_env), } def _setup_defaults_snapshot() -> Dict[str, Any]: return { "media_analysis": _setup_media_analysis_defaults(), "updates": _setup_updates_defaults(), } def _setup_set_media_analysis_defaults(media_defaults: Dict[str, Any], dry_run: bool) -> Dict[str, Any]: if not media_defaults: return {"changed": False, "recognized": False} alias_to_key = { "timed_markers_default": "timed_markers_default", "timedmarkersdefault": "timed_markers_default", "timed_markers": "timed_markers_default", "timedmarkers": "timed_markers_default", "write_markers": "timed_markers_default", "writemarkers": "timed_markers_default", "slate_detection_default": "slate_detection_default", "slatedetectiondefault": "slate_detection_default", "slate_detection": "slate_detection_default", "slatedetection": "slate_detection_default", "vision_default": "vision_default", "visiondefault": "vision_default", "vision": "vision_default", "transcription_default": "transcription_default", "transcriptiondefault": "transcription_default", "transcription": "transcription_default", "analysis_persistence": "analysis_persistence", "analysispersistence": "analysis_persistence", "persistence": "analysis_persistence", "metadata_publish_fields": "metadata_publish_fields", "metadatapublishfields": "metadata_publish_fields", "fields": "metadata_publish_fields", "metadata_fields": "metadata_publish_fields", "metadata_overwrite_policy": "metadata_overwrite_policy", "metadataoverwritepolicy": "metadata_overwrite_policy", "overwrite_policy": "metadata_overwrite_policy", "overwritepolicy": "metadata_overwrite_policy", "timed_marker_types": "timed_marker_types", "timedmarkertypes": "timed_marker_types", "marker_types": "timed_marker_types", "markertypes": "timed_marker_types", "timed_marker_colors": "timed_marker_colors", "timedmarkercolors": "timed_marker_colors", "marker_colors": "timed_marker_colors", "markercolors": "timed_marker_colors", "max_timed_markers_per_clip": "max_timed_markers_per_clip", "maxtimedmarkersperclip": "max_timed_markers_per_clip", "max_markers": "max_timed_markers_per_clip", "maxmarkers": "max_timed_markers_per_clip", "include_confidence_scores": "include_confidence_scores", "includeconfidencescores": "include_confidence_scores", "include_source_time_notes": "include_source_time_notes", "includesourcetimenotes": "include_source_time_notes", "analysis_summary_style": "analysis_summary_style", "analysissummarystyle": "analysis_summary_style", "summary_style": "analysis_summary_style", "summarystyle": "analysis_summary_style", "report_format": "report_format", "reportformat": "report_format", "preferred_analysis_root": "preferred_analysis_root", "preferredanalysisroot": "preferred_analysis_root", "analysis_root": "preferred_analysis_root", "analysisroot": "preferred_analysis_root", "preferred_generated_media_folder": "preferred_generated_media_folder", "preferredgeneratedmediafolder": "preferred_generated_media_folder", "generated_media_folder": "preferred_generated_media_folder", "generatedmediafolder": "preferred_generated_media_folder", "default_post_operation_page": "default_post_operation_page", "defaultpostoperationpage": "default_post_operation_page", "post_operation_page": "default_post_operation_page", "postoperationpage": "default_post_operation_page", "marker_custom_data": "marker_custom_data", "markercustomdata": "marker_custom_data", "metadata_writeback_default": "metadata_writeback_default", "metadatawritebackdefault": "metadata_writeback_default", "metadata_writeback": "metadata_writeback_default", "metadatawriteback": "metadata_writeback_default", "publish_metadata": "metadata_writeback_default", "publishmetadata": "metadata_writeback_default", "write_metadata": "metadata_writeback_default", "writemetadata": "metadata_writeback_default", "write_to_resolve": "metadata_writeback_default", "writetoresolve": "metadata_writeback_default", "ask_before_metadata_publish": "ask_before_metadata_publish", "askbeforemetadatapublish": "ask_before_metadata_publish", "dry_run_first_default": "dry_run_first_default", "dryrunfirstdefault": "dry_run_first_default", "dry_run_first": "dry_run_first_default", "dryrunfirst": "dry_run_first_default", "source_trust": "source_trust", "sourcetrust": "source_trust", "trust": "source_trust", "default_depth": "default_depth", "defaultdepth": "default_depth", "default_sample_frames": "default_sample_frames", "defaultsampleframes": "default_sample_frames", "sample_frames": "default_sample_frames", "sampleframes": "default_sample_frames", "sampling_mode_default": "sampling_mode_default", "samplingmodedefault": "sampling_mode_default", "sampling_mode": "sampling_mode_default", "samplingmode": "sampling_mode_default", "analysis_mode": "sampling_mode_default", "analysismode": "sampling_mode_default", "sampling_frames_per_minute": "sampling_frames_per_minute", "samplingframesperminute": "sampling_frames_per_minute", "frames_per_minute": "sampling_frames_per_minute", "framesperminute": "sampling_frames_per_minute", "sampling_frame_floor": "sampling_frame_floor", "samplingframefloor": "sampling_frame_floor", "frame_floor": "sampling_frame_floor", "framefloor": "sampling_frame_floor", "sampling_frame_ceiling": "sampling_frame_ceiling", "samplingframeceiling": "sampling_frame_ceiling", "frame_ceiling": "sampling_frame_ceiling", "frameceiling": "sampling_frame_ceiling", } requested: Dict[str, Any] = {} for key, value in media_defaults.items(): normalized_key = alias_to_key.get(_setup_text_key(key).replace("_", "")) if not normalized_key: normalized_key = alias_to_key.get(_setup_text_key(key)) if normalized_key: requested[normalized_key] = value if not requested: return {"changed": False, "recognized": False} preferences = _read_media_analysis_preferences() before = _media_analysis_effective_preferences() next_preferences = dict(preferences) updates: Dict[str, Dict[str, Any]] = {} def clear_requested(raw: Any) -> bool: return raw is None or (not isinstance(raw, bool) and _setup_text_key(raw) in _SETUP_CHOICE_CLEAR_VALUES) def set_or_clear(key: str, raw: Any, value: Any, *, allow_clear: bool = True) -> None: if allow_clear and clear_requested(raw): next_preferences.pop(key, None) next_preferences.pop(f"{key}_updated_at", None) updates[key] = {"before": before.get(key), "after": _MEDIA_ANALYSIS_DEFAULT_PREFS.get(key), "cleared": True} else: next_preferences[key] = value updates[key] = {"before": before.get(key), "after": value} for key, raw_value in requested.items(): if key == "timed_markers_default": normalized, error, clear = _setup_normalize_timed_marker_default(raw_value) if error: return _err(error) if clear: next_preferences.pop("timed_markers_default", None) next_preferences.pop("timed_markers_default_updated_at", None) updates[key] = {"before": before.get(key), "after": None, "cleared": True} else: next_preferences["timed_markers_default"] = normalized updates[key] = {"before": before.get(key), "after": normalized} elif clear_requested(raw_value): set_or_clear(key, raw_value, _MEDIA_ANALYSIS_DEFAULT_PREFS.get(key)) elif key in {"slate_detection_default", "transcription_default"}: normalized = _normalize_yes_no_ask(raw_value) if normalized is None: return _err(f"Unsupported {key}: {raw_value!r}. Use yes, no, or ask.") set_or_clear(key, raw_value, normalized) elif key == "vision_default": normalized = _normalize_vision_default(raw_value) if normalized is None: return _err("Unsupported vision_default. Use on, off, technical_only, or ask.") set_or_clear(key, raw_value, normalized) elif key == "analysis_persistence": normalized = _normalize_analysis_persistence(raw_value) if normalized is None: return _err("Unsupported analysis_persistence. Use session_only, keep_reports, or keep_artifacts.") set_or_clear(key, raw_value, normalized) elif key == "metadata_publish_fields": fields = [str(field).strip() for field in _media_analysis_as_list(raw_value) if str(field).strip()] if not fields and not clear_requested(raw_value): return _err("metadata_publish_fields must be a non-empty list or comma-separated string.") set_or_clear(key, raw_value, fields) elif key == "metadata_overwrite_policy": normalized = _normalize_metadata_overwrite_policy(raw_value) if normalized is None: return _err("Unsupported metadata_overwrite_policy. Use preserve_human, fill_empty, overwrite_owned_blocks, or overwrite_all.") set_or_clear(key, raw_value, normalized) elif key == "timed_marker_types": marker_types = _normalize_setup_list( raw_value, aliases=_MEDIA_ANALYSIS_MARKER_TYPE_ALIASES, allowed=["shots", "slate_clap", "sync_events", "best_moments", "qc_warnings"], ) if not marker_types and not clear_requested(raw_value): return _err("timed_marker_types must include shots, slate_clap, sync_events, best_moments, or qc_warnings.") set_or_clear(key, raw_value, marker_types) elif key == "timed_marker_colors": colors = _normalize_marker_colors(raw_value) if not colors and not clear_requested(raw_value): return _err("timed_marker_colors must be an object of marker type to Resolve marker color.") set_or_clear(key, raw_value, colors) elif key == "max_timed_markers_per_clip": set_or_clear(key, raw_value, _setup_marker_limit(raw_value, 12)) elif key in {"include_confidence_scores", "include_source_time_notes", "metadata_writeback_default", "ask_before_metadata_publish", "dry_run_first_default"}: set_or_clear(key, raw_value, _media_analysis_bool(raw_value, _MEDIA_ANALYSIS_DEFAULT_PREFS[key])) elif key == "analysis_summary_style": normalized = _normalize_setup_choice( raw_value, ["full", "concise", "creative", "technical"], aliases={ "assistant_editor": "creative", "assistant": "creative", "editor": "creative", "producer": "creative", "qc": "technical", "qc_focus": "technical", "qc_focused": "technical", }, ) if normalized is None: return _err("Unsupported analysis_summary_style. Use full, concise, creative, or technical.") set_or_clear(key, raw_value, normalized) elif key == "report_format": normalized = _normalize_setup_choice(raw_value, ["compact", "full", "machine_readable"]) if normalized is None: return _err("Unsupported report_format. Use compact, full, or machine_readable.") set_or_clear(key, raw_value, normalized) elif key == "source_trust": normalized = _normalize_setup_choice( raw_value, ["auto", "filename", "low", "medium", "high"], aliases={"none": "auto", "default": "auto"}, ) if normalized is None: return _err("Unsupported source_trust. Use auto, filename, low, medium, or high.") set_or_clear(key, raw_value, normalized) elif key == "default_depth": normalized = _normalize_setup_choice(raw_value, ["quick", "standard", "deep"]) if normalized is None: return _err("Unsupported default_depth. Use quick, standard, or deep.") set_or_clear(key, raw_value, normalized) elif key == "default_sample_frames": try: frames_int = int(raw_value) if not isinstance(raw_value, bool) else 8 except (TypeError, ValueError): return _err("default_sample_frames must be an integer between 0 and 48.") set_or_clear(key, raw_value, max(0, min(48, frames_int))) elif key == "sampling_mode_default": # "ask" clears the saved default so the first-run prompt fires again; # otherwise normalize a canonical key or friendly label. if _setup_text_key(raw_value) in {"ask", "prompt", "askme", "askuser"}: next_preferences.pop("sampling_mode_default", None) next_preferences.pop("sampling_mode_default_updated_at", None) updates[key] = {"before": before.get(key), "after": None, "cleared": True} else: normalized = _media_analysis_module.normalize_sampling_mode(raw_value, default=None) if normalized is None: return _err( "Unsupported sampling_mode_default. Use ask, fixed/economy, " "per_minute/balanced, adaptive_capped/thorough, or adaptive." ) set_or_clear(key, raw_value, normalized) elif key == "sampling_frames_per_minute": try: rate = float(raw_value) except (TypeError, ValueError): return _err("sampling_frames_per_minute must be a positive number.") if rate <= 0: return _err("sampling_frames_per_minute must be greater than 0.") set_or_clear(key, raw_value, rate) elif key in {"sampling_frame_floor", "sampling_frame_ceiling"}: try: n = int(raw_value) if not isinstance(raw_value, bool) else 0 except (TypeError, ValueError): return _err(f"{key} must be a positive integer.") if n <= 0: return _err(f"{key} must be a positive integer.") set_or_clear(key, raw_value, n) elif key == "default_post_operation_page": normalized = _normalize_setup_choice( raw_value, ["stay_put", "media", "cut", "edit", "fusion", "color", "fairlight", "deliver"], aliases={"media_pool": "media", "none": "stay_put"}, ) if normalized is None: return _err("Unsupported default_post_operation_page.") set_or_clear(key, raw_value, normalized) elif key == "marker_custom_data": normalized = _normalize_setup_choice(raw_value, ["namespaced", "minimal"]) if normalized is None: return _err("Unsupported marker_custom_data. Use namespaced or minimal.") set_or_clear(key, raw_value, normalized) elif key in {"preferred_analysis_root", "preferred_generated_media_folder"}: path = None if clear_requested(raw_value) else os.path.realpath(os.path.abspath(os.path.expanduser(str(raw_value)))) set_or_clear(key, raw_value, path) if dry_run: return { "changed": True, "recognized": True, "updates": updates, "before": before, "after": {**before, **{key: row.get("after") for key, row in updates.items()}}, "dry_run": True, } updated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) for key, row in updates.items(): if row.get("cleared"): next_preferences.pop(f"{key}_updated_at", None) else: next_preferences[f"{key}_updated_at"] = updated_at _write_media_analysis_preferences(next_preferences) after = _media_analysis_effective_preferences() return { "changed": before != after, "recognized": True, "updates": updates, "before": before, "after": after, "updated_at": updated_at, "preferences_path": _media_analysis_preferences_path(), } def _setup_set_updates_defaults(update_defaults: Dict[str, Any], dry_run: bool) -> Dict[str, Any]: mode = _first_param( update_defaults, "mode", "update_mode", "updateMode", "policy", "update_policy", "updatePolicy", default=None, ) interval = _first_param( update_defaults, "check_interval_hours", "checkIntervalHours", "interval_hours", "intervalHours", "update_interval_hours", "updateIntervalHours", default=None, ) snooze = _first_param( update_defaults, "snooze_hours", "snoozeHours", "update_snooze_hours", "updateSnoozeHours", default=None, ) if mode is None and interval is None and snooze is None: return {"changed": False, "recognized": False} before = _setup_updates_defaults() state = _setup_update_state() updates: Dict[str, Dict[str, Any]] = {} if mode is not None: normalized = str(mode).strip().lower().replace("-", "_") if normalized == "manual": normalized = "prompt" if normalized not in _SETUP_UPDATE_MODES: return _err("Unsupported update mode. Use prompt, auto, notify, or never.") updates["mode"] = {"before": before.get("mode"), "after": normalized} if interval is not None: normalized_interval = _setup_positive_float(interval, 24.0, 0.1, 8760.0) updates["check_interval_hours"] = {"before": before.get("check_interval_hours"), "after": normalized_interval} if snooze is not None: normalized_snooze = _setup_positive_float(snooze, 24.0, 0.1, 8760.0) updates["snooze_hours"] = {"before": before.get("snooze_hours"), "after": normalized_snooze} if dry_run: return {"changed": True, "recognized": True, "updates": updates, "before": before, "dry_run": True} if "mode" in updates: set_update_mode(project_dir, updates["mode"]["after"], env=_setup_update_env()) state = _setup_update_state() updated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) if "check_interval_hours" in updates: state["check_interval_hours"] = updates["check_interval_hours"]["after"] if "snooze_hours" in updates: state["snooze_hours"] = updates["snooze_hours"]["after"] if updates: state["setup_defaults_updated_at"] = updated_at with open(update_state_path(project_dir), "w", encoding="utf-8") as handle: json.dump(state, handle, indent=2, sort_keys=True) handle.write("\n") return { "changed": bool(updates), "recognized": True, "updates": updates, "before": before, "after": _setup_updates_defaults(), "state_path": str(update_state_path(project_dir)), } def _setup_clear_defaults(keys: Any, dry_run: bool) -> Dict[str, Any]: if keys is None: normalized_keys = {"all"} elif isinstance(keys, str): normalized_keys = {part.strip().lower() for part in keys.split(",") if part.strip()} elif isinstance(keys, list): normalized_keys = {str(part).strip().lower() for part in keys if str(part).strip()} else: return _err("keys must be a string, list, or omitted") clear_all = not normalized_keys or "all" in normalized_keys result: Dict[str, Any] = {"dry_run": dry_run, "cleared": []} media_clear_keys = { "timed_markers_default": "media_analysis.timed_markers_default", "slate_detection_default": "media_analysis.slate_detection_default", "vision_default": "media_analysis.vision_default", "transcription_default": "media_analysis.transcription_default", "analysis_persistence": "media_analysis.analysis_persistence", "metadata_publish_fields": "media_analysis.metadata_publish_fields", "metadata_overwrite_policy": "media_analysis.metadata_overwrite_policy", "timed_marker_types": "media_analysis.timed_marker_types", "timed_marker_colors": "media_analysis.timed_marker_colors", "max_timed_markers_per_clip": "media_analysis.max_timed_markers_per_clip", "include_confidence_scores": "media_analysis.include_confidence_scores", "include_source_time_notes": "media_analysis.include_source_time_notes", "analysis_summary_style": "media_analysis.analysis_summary_style", "report_format": "media_analysis.report_format", "preferred_analysis_root": "media_analysis.preferred_analysis_root", "preferred_generated_media_folder": "media_analysis.preferred_generated_media_folder", "default_post_operation_page": "media_analysis.default_post_operation_page", "marker_custom_data": "media_analysis.marker_custom_data", "metadata_writeback_default": "media_analysis.metadata_writeback_default", "ask_before_metadata_publish": "media_analysis.ask_before_metadata_publish", "dry_run_first_default": "media_analysis.dry_run_first_default", "sampling_mode_default": "media_analysis.sampling_mode_default", "sampling_frames_per_minute": "media_analysis.sampling_frames_per_minute", "sampling_frame_floor": "media_analysis.sampling_frame_floor", "sampling_frame_ceiling": "media_analysis.sampling_frame_ceiling", } media_payload: Dict[str, Any] = {} if clear_all or "media_analysis" in normalized_keys: media_payload = {key: "clear" for key in media_clear_keys} else: for key, label in media_clear_keys.items(): if key in normalized_keys or label in normalized_keys: media_payload[key] = "clear" if media_payload: result["media_analysis"] = _setup_set_media_analysis_defaults(media_payload, dry_run) if result["media_analysis"].get("error"): return result["media_analysis"] result["cleared"].extend(media_clear_keys[key] for key in media_payload) if clear_all or normalized_keys & {"updates", "updates.mode", "update_mode", "mcp_update_policy"}: result["updates"] = _setup_set_updates_defaults({"mode": "prompt"}, dry_run) if result["updates"].get("error"): return result["updates"] result["cleared"].append("updates.mode") if clear_all or normalized_keys & {"updates.check_interval_hours", "check_interval_hours", "update_interval_hours"}: state = _setup_update_state() if dry_run: result["update_check_interval_hours"] = {"changed": True, "dry_run": True} else: state.pop("check_interval_hours", None) with open(update_state_path(project_dir), "w", encoding="utf-8") as handle: json.dump(state, handle, indent=2, sort_keys=True) handle.write("\n") result["update_check_interval_hours"] = {"changed": True, "state_path": str(update_state_path(project_dir))} result["cleared"].append("updates.check_interval_hours") if clear_all or normalized_keys & {"updates.snooze_hours", "snooze_hours", "update_snooze_hours"}: state = _setup_update_state() if dry_run: result["update_snooze_hours"] = {"changed": True, "dry_run": True} else: state.pop("snooze_hours", None) with open(update_state_path(project_dir), "w", encoding="utf-8") as handle: json.dump(state, handle, indent=2, sort_keys=True) handle.write("\n") result["update_snooze_hours"] = {"changed": True, "state_path": str(update_state_path(project_dir))} result["cleared"].append("updates.snooze_hours") if clear_all or normalized_keys & {"updates.prompt_preferences", "updates.snooze", "updates.ignore", "update_prompt_preferences"}: if dry_run: result["update_prompt_preferences"] = {"changed": True, "dry_run": True} else: clear_update_prompt_preferences(project_dir) result["update_prompt_preferences"] = {"changed": True, "state_path": str(update_state_path(project_dir))} result["cleared"].append("updates.prompt_preferences") if not result["cleared"]: return _err("No known setup defaults matched keys") result["defaults"] = _setup_defaults_snapshot() return _ok(**result) @mcp.tool() def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Configure MCP conversational defaults and setup preferences. Actions: schema() -> {defaults, actions} get_defaults() -> {defaults} set_defaults(defaults?|media_analysis?|updates?|dry_run?) -> {defaults, changes} clear_defaults(keys?, dry_run?) -> {defaults, cleared} Current defaults: media_analysis.*: analysis, metadata, marker, reporting, and workflow defaults updates.*: MCP update policy, interval, and snooze defaults """ p = params or {} if action in {"schema", "capabilities", "options"}: return { "actions": ["schema", "get_defaults", "set_defaults", "clear_defaults"], "defaults": { "media_analysis.timed_markers_default": { "description": "Default answer for writing source-time analysis notes as Media Pool clip markers.", "values": ["yes", "no", "ask", "default_yes", "default_no"], "storage": _media_analysis_preferences_path(), }, "media_analysis.slate_detection_default": {"values": ["yes", "no", "ask"], "storage": _media_analysis_preferences_path()}, "media_analysis.vision_default": {"values": ["on", "off", "technical_only", "ask"], "storage": _media_analysis_preferences_path()}, "media_analysis.transcription_default": {"values": ["yes", "no", "ask"], "storage": _media_analysis_preferences_path()}, "media_analysis.analysis_persistence": {"values": ["session_only", "keep_reports", "keep_artifacts"], "storage": _media_analysis_preferences_path()}, "media_analysis.metadata_publish_fields": {"values": "list of Resolve metadata field names", "storage": _media_analysis_preferences_path()}, "media_analysis.metadata_overwrite_policy": {"values": ["preserve_human", "fill_empty", "overwrite_owned_blocks", "overwrite_all"], "storage": _media_analysis_preferences_path()}, "media_analysis.timed_marker_types": {"values": ["shots", "slate_clap", "sync_events", "best_moments", "qc_warnings"], "storage": _media_analysis_preferences_path()}, "media_analysis.timed_marker_colors": {"values": "object mapping marker type to Resolve marker color", "storage": _media_analysis_preferences_path()}, "media_analysis.max_timed_markers_per_clip": {"values": "0 for unlimited, or integer 1-250", "storage": _media_analysis_preferences_path()}, "media_analysis.include_confidence_scores": {"values": [True, False], "storage": _media_analysis_preferences_path()}, "media_analysis.include_source_time_notes": {"values": [True, False], "storage": _media_analysis_preferences_path()}, "media_analysis.analysis_summary_style": {"values": ["concise", "assistant_editor", "qc", "producer", "full"], "storage": _media_analysis_preferences_path()}, "media_analysis.report_format": {"values": ["compact", "full", "machine_readable"], "storage": _media_analysis_preferences_path()}, "media_analysis.preferred_analysis_root": {"values": "absolute or expandable path", "storage": _media_analysis_preferences_path()}, "media_analysis.preferred_generated_media_folder": {"values": "absolute or expandable path", "storage": _media_analysis_preferences_path()}, "media_analysis.default_post_operation_page": {"values": ["stay_put", "media", "cut", "edit", "fusion", "color", "fairlight", "deliver"], "storage": _media_analysis_preferences_path()}, "media_analysis.marker_custom_data": {"values": ["namespaced", "minimal"], "storage": _media_analysis_preferences_path()}, "media_analysis.metadata_writeback_default": {"values": [True, False], "storage": _media_analysis_preferences_path()}, "media_analysis.ask_before_metadata_publish": {"values": [True, False], "storage": _media_analysis_preferences_path()}, "media_analysis.dry_run_first_default": {"values": [True, False], "storage": _media_analysis_preferences_path()}, "media_analysis.sampling_mode_default": { "description": "Frame-sampling mode for visual analysis. 'ask' prompts on first analysis to set a standing default. fixed=Economy (flat frames), per_minute=Balanced (duration-scaled), adaptive_capped=Thorough (content-aware, bounded — recommended), adaptive=Thorough uncapped.", "values": ["ask", "fixed", "per_minute", "adaptive_capped", "adaptive"], "storage": _media_analysis_preferences_path(), }, "media_analysis.sampling_frames_per_minute": {"description": "Frames per minute for Balanced mode (also seeds Thorough on short clips).", "values": "number > 0 (default 4)", "storage": _media_analysis_preferences_path()}, "media_analysis.sampling_frame_floor": {"description": "Minimum frames per clip for duration/content-scaled modes.", "values": "integer > 0 (default 3)", "storage": _media_analysis_preferences_path()}, "media_analysis.sampling_frame_ceiling": {"description": "Maximum frames per clip for Balanced + Thorough modes (the Thorough per-clip cap).", "values": "integer > 0 (default 80)", "storage": _media_analysis_preferences_path()}, "updates.mode": { "description": "Local MCP update policy.", "values": sorted(_SETUP_UPDATE_MODES), "storage": str(update_state_path(project_dir)), }, "updates.check_interval_hours": {"values": "number >= 0.1", "storage": str(update_state_path(project_dir))}, "updates.snooze_hours": {"values": "number >= 0.1", "storage": str(update_state_path(project_dir))}, }, } if action in {"get_defaults", "get", "status"}: return _ok(defaults=_setup_defaults_snapshot()) dry_run = _setup_bool(p.get("dry_run", p.get("dryRun")), False) if action in {"set_defaults", "set", "configure"}: defaults = p.get("defaults") if isinstance(p.get("defaults"), dict) else {} merged = {**defaults, **{k: v for k, v in p.items() if k != "defaults"}} media_defaults = { **_setup_nested(merged, "media_analysis", "mediaAnalysis"), **{ key: value for key, value in merged.items() if key not in {"updates", "mcp_updates", "mcpUpdates", "dry_run", "dryRun"} }, **({ "timed_markers_default": _first_param( merged, "timed_markers_default", "timedMarkersDefault", "timed_markers", "timedMarkers", "write_markers", "writeMarkers", default=None, ) } if any(key in merged for key in ("timed_markers_default", "timedMarkersDefault", "timed_markers", "timedMarkers", "write_markers", "writeMarkers")) else {}), } update_defaults = { **_setup_nested(merged, "updates", "mcp_updates", "mcpUpdates"), **({ "mode": _first_param( merged, "update_mode", "updateMode", "update_policy", "updatePolicy", "mcp_update_policy", "mcpUpdatePolicy", default=None, ) } if any(key in merged for key in ("update_mode", "updateMode", "update_policy", "updatePolicy", "mcp_update_policy", "mcpUpdatePolicy")) else {}), **({ "check_interval_hours": _first_param( merged, "check_interval_hours", "checkIntervalHours", "update_interval_hours", "updateIntervalHours", default=None, ) } if any(key in merged for key in ("check_interval_hours", "checkIntervalHours", "update_interval_hours", "updateIntervalHours")) else {}), **({ "snooze_hours": _first_param( merged, "snooze_hours", "snoozeHours", "update_snooze_hours", "updateSnoozeHours", default=None, ) } if any(key in merged for key in ("snooze_hours", "snoozeHours", "update_snooze_hours", "updateSnoozeHours")) else {}), } media_result = _setup_set_media_analysis_defaults(media_defaults, dry_run) if media_result.get("error"): return media_result update_result = _setup_set_updates_defaults(update_defaults, dry_run) if update_result.get("error"): return update_result recognized = bool(media_result.get("recognized")) or bool(update_result.get("recognized")) if not recognized: return _err("set_defaults did not receive a recognized default to set") return _ok( dry_run=dry_run, changes={ "media_analysis": media_result, "updates": update_result, }, defaults=_setup_defaults_snapshot(), ) if action in {"clear_defaults", "clear", "reset"}: return _setup_clear_defaults(p.get("keys"), dry_run) return _unknown(action, ["schema", "get_defaults", "set_defaults", "clear_defaults"]) @mcp.tool() def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """App-level DaVinci Resolve operations. Actions: launch() -> {success, message} — Launch DaVinci Resolve if not running. Call this FIRST if any tool returns a 'Not connected' error. get_version() -> {product, version, version_string} mcp_update_status(force_check?) -> {version, update, decision} set_mcp_update_policy(mode) -> {success, version, update, decision} ignore_mcp_update() -> {success, version, update, decision} snooze_mcp_update(hours?) -> {success, version, update, decision} clear_mcp_update_preferences() -> {success, version, update, decision} api_truth(query?) -> {verified_on, count, facts} — look up behaviorally-verified facts about quirky/unreliable Resolve API behavior (no connection needed). verification_stats() -> {stats} — readback-verification tally (verified/contradicted/unverified) since server start (no connection needed). get_page() -> {page} open_page(page) -> {success} — page: edit, cut, color, fusion, fairlight, deliver get_keyframe_mode() -> {mode} set_keyframe_mode(mode) -> {success} quit() -> {success} get_fairlight_presets() -> {presets} set_high_priority() -> {success} disable_background_tasks_for_current_session() -> {success} — Resolve 21+ open_control_panel(port?, host?, open_browser?) -> {success, url, pid, port, status} — Launches the analysis control panel (src/analysis_dashboard.py) as a background process. Idempotent: returns the existing URL if already running. control_panel_status() -> {running, pid, port, url} close_control_panel() -> {success, was_running} save_state() -> {state_token, page, current_timeline_id, current_timecode, selected_clip_ids} — Captures the current Resolve UI state so it can be restored after a preview. restore_state(state_token) -> {success, restored: {...}} — Returns Resolve to a previously-saved state. """ p = params or {} # api_truth is a static knowledge lookup — no Resolve connection needed. if action == "api_truth": facts = lookup_api_truth(p.get("query")) return {"verified_on": _API_TRUTH_VERIFIED_ON, "count": len(facts), "facts": facts} if action == "verification_stats": # Process-level readback-verification tally — no connection needed. stats = _verification_stats() return {"stats": stats, "note": "Counts since server start. A rising " "'contradicted' count means the API reported success but a readback disagreed."} # Control-panel actions don't require Resolve to be running. if action == "open_control_panel": return _open_control_panel(p) elif action == "control_panel_status": return _control_panel_status() elif action == "close_control_panel": return _close_control_panel() elif action == "save_state": return _resolve_save_state() elif action == "restore_state": return _resolve_restore_state(p) if action == "mcp_update_status": return _mcp_update_status_payload( force=_media_analysis_bool(p.get("force_check", p.get("forceCheck")), False), timeout=float(p.get("timeout", 3.0)), ) elif action == "set_mcp_update_policy": mode = str(p.get("mode") or "").strip().lower() if mode not in {"prompt", "auto", "notify", "never"}: return _err("set_mcp_update_policy requires mode: prompt, auto, notify, or never") set_update_mode(project_dir, mode, env=_setup_update_env()) return _ok(**_mcp_update_status_payload()) elif action == "ignore_mcp_update": update = get_cached_update_status(project_dir, VERSION, env=_setup_update_env()) if update.get("status") != "update_available": return _err("No available MCP update is cached to ignore.") ignore_update_version(project_dir, update, env=_setup_update_env()) return _ok(**_mcp_update_status_payload()) elif action == "snooze_mcp_update": snooze_update_prompt(project_dir, hours=p.get("hours"), env=_setup_update_env()) return _ok(**_mcp_update_status_payload()) elif action == "clear_mcp_update_preferences": clear_update_prompt_preferences(project_dir, env=_setup_update_env()) return _ok(**_mcp_update_status_payload()) # launch works even when Resolve is not connected if action == "launch": r = get_resolve() # auto-launches if not running if r is not None: return _ok(message="DaVinci Resolve is running and connected.") return _err("Could not connect to DaVinci Resolve. Check that Resolve Studio is installed and 'External scripting using' is set to Local in Preferences.") r = get_resolve() # auto-launches if not running if r is None: return _err("Could not connect to DaVinci Resolve after auto-launch attempt. Check that Resolve Studio is installed.") if action == "get_version": update_env = _setup_update_env() mcp_update = get_cached_update_status(project_dir, VERSION, env=update_env) return { "product": r.GetProductName(), "version": r.GetVersion(), "version_string": r.GetVersionString(), "mcp": { "version": VERSION, "update": mcp_update, "update_decision": update_prompt_decision(mcp_update, env=update_env), }, } elif action == "get_page": return {"page": r.GetCurrentPage()} elif action == "open_page": err, clean = _validate_params(p, { "page": {"enum": ["media", "cut", "edit", "color", "fusion", "fairlight", "deliver"], "required": True}, }) if err: return _err(err) # Serialize page switches so concurrent agents can't flip the single # globally-active page underneath each other. return {"success": bool(_open_page_serialized(r, clean["page"]))} elif action == "get_keyframe_mode": return {"mode": r.GetKeyframeMode()} elif action == "set_keyframe_mode": return {"success": bool(r.SetKeyframeMode(p["mode"]))} elif action == "quit": r.Quit() return _ok() elif action == "get_fairlight_presets": missing = _requires_method(r, "GetFairlightPresets", "20.2.2") if missing: return missing return {"presets": _ser(r.GetFairlightPresets())} elif action == "set_high_priority": return {"success": bool(r.SetHighPriority())} elif action == "disable_background_tasks_for_current_session": missing = _requires_method(r, "DisableBackgroundTasksForCurrentResolveSession", "21.0") if missing: return missing r.DisableBackgroundTasksForCurrentResolveSession() return _ok() return _unknown(action, ["launch","get_version","api_truth","verification_stats","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"]) # ─── V2 C4: Per-field corrections with provenance + changelog ──────────────── # # Until the SQLite source-of-truth migration (C1) lands, corrections live in a # per-clip sidecar JSON at {clip_dir}/corrections.json. Schema mirrors the V2 # DB design (V2 schema — subjective_fields + field_changelog tables): # # { # "schema_version": "2.0", # "clip_uuid": "...", # "current": { # "::": { # "value": , # "confidence": "low|medium|high", # "source": "human" | "vision_v0.2", # "author": "sam@bradfordoperations.com", # "timestamp": "2026-05-19T...Z" # } # }, # "changelog": [ # {previous_value, new_value, source, author, change_reason, timestamp} # ] # } # # Subsequent commit_vision / analyze runs must read this file and PRESERVE any # field whose `source == "human"` (V2 trust-but-fix-optionally contract). # Migration target: once C1 lands, ingest corrections.json into the DB tables. def _v2_corrections_path_for_clip(project_root: str, clip_dir: Optional[str], clip_id: Optional[str]) -> Optional[str]: """Find the corrections.json path for a clip. Returns None if clip_dir can't be resolved.""" if clip_dir and os.path.isabs(clip_dir): return os.path.join(clip_dir, "corrections.json") # Walk clips/ to find a directory containing analysis.json with this clip_id if not clip_id: return None clips_root = os.path.join(project_root, "clips") if not os.path.isdir(clips_root): return None for entry in os.listdir(clips_root): candidate_dir = os.path.join(clips_root, entry) if not os.path.isdir(candidate_dir): continue analysis = os.path.join(candidate_dir, "analysis.json") if not os.path.isfile(analysis): continue try: with open(analysis, "r", encoding="utf-8") as handle: report = json.load(handle) if (report.get("clip") or {}).get("clip_id") == clip_id: return os.path.join(candidate_dir, "corrections.json") except (OSError, json.JSONDecodeError): continue return None def _v2_read_corrections(path: str) -> Dict[str, Any]: if not os.path.isfile(path): return {"schema_version": "2.0", "current": {}, "changelog": []} try: with open(path, "r", encoding="utf-8") as handle: data = json.load(handle) # Ensure shape if not isinstance(data, dict): data = {} data.setdefault("schema_version", "2.0") data.setdefault("current", {}) data.setdefault("changelog", []) return data except (OSError, json.JSONDecodeError): return {"schema_version": "2.0", "current": {}, "changelog": []} def _v2_write_corrections(path: str, data: Dict[str, Any]) -> Dict[str, Any]: try: os.makedirs(os.path.dirname(path), exist_ok=True) tmp_path = path + ".tmp" with open(tmp_path, "w", encoding="utf-8") as handle: json.dump(data, handle, indent=2, sort_keys=True, default=str) os.replace(tmp_path, path) return {"success": True, "path": path} except OSError as exc: return {"success": False, "error": f"{type(exc).__name__}: {exc}"} def _v2_update_field(project_root: str, p: Dict[str, Any], *, entity_type: str) -> Dict[str, Any]: """Apply a correction to a subjective field with provenance + changelog.""" clip_id = p.get("clip_id") or p.get("clipId") clip_dir = p.get("clip_dir") or p.get("clipDir") if not clip_id and not clip_dir: return _err("update_*_field requires clip_id or clip_dir") entity_uuid = ( p.get("entity_uuid") or p.get("entityUuid") or (p.get("shot_uuid") if entity_type == "shot" else None) or (p.get("shot_index") if entity_type == "shot" else None) or clip_id # for clip entity, the entity_uuid is the clip_id ) if entity_uuid is None: return _err(f"update_{entity_type}_field requires entity_uuid (or shot_index for shots)") field_path = p.get("field_path") or p.get("fieldPath") if not field_path: return _err("update_*_field requires field_path (e.g. 'visual.shot_size')") if "new_value" not in p and "newValue" not in p and "value" not in p: return _err("update_*_field requires new_value") new_value = p.get("new_value") if "new_value" in p else (p.get("newValue") if "newValue" in p else p.get("value")) author = p.get("author") or "unknown" reason = p.get("reason") or p.get("change_reason") confidence = p.get("confidence") path = _v2_corrections_path_for_clip(project_root, clip_dir, clip_id) if not path: return _err(f"Could not locate clip directory for clip_id={clip_id} under {project_root}/clips/. Pass clip_dir explicitly if the clip directory is non-standard.") data = _v2_read_corrections(path) if clip_id and "clip_id" not in data: data["clip_id"] = str(clip_id) key = f"{entity_type}:{entity_uuid}:{field_path}" previous = data["current"].get(key) now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) new_entry = { "value": new_value, "source": "human", "author": author, "timestamp": now, } if confidence: new_entry["confidence"] = str(confidence) data["current"][key] = new_entry data["changelog"].append({ "entity_type": entity_type, "entity_uuid": str(entity_uuid), "field_path": field_path, "previous_value": (previous or {}).get("value"), "new_value": new_value, "previous_source": (previous or {}).get("source"), "new_source": "human", "previous_author": (previous or {}).get("author"), "new_author": author, "change_reason": reason, "timestamp": now, }) write_result = _v2_write_corrections(path, data) if not write_result.get("success"): return write_result return { "success": True, "entity_type": entity_type, "entity_uuid": str(entity_uuid), "field_path": field_path, "new_value": new_value, "previous_value": (previous or {}).get("value"), "author": author, "timestamp": now, "corrections_path": path, } def _v2_get_field_history(project_root: str, p: Dict[str, Any]) -> Dict[str, Any]: clip_id = p.get("clip_id") or p.get("clipId") clip_dir = p.get("clip_dir") or p.get("clipDir") path = _v2_corrections_path_for_clip(project_root, clip_dir, clip_id) if not path or not os.path.isfile(path): return {"success": True, "history": [], "note": "No corrections recorded for this clip."} data = _v2_read_corrections(path) entity_type = p.get("entity_type") or p.get("entityType") entity_uuid = p.get("entity_uuid") or p.get("entityUuid") field_path = p.get("field_path") or p.get("fieldPath") rows = data.get("changelog") or [] if entity_type: rows = [r for r in rows if r.get("entity_type") == entity_type] if entity_uuid is not None: rows = [r for r in rows if str(r.get("entity_uuid")) == str(entity_uuid)] if field_path: rows = [r for r in rows if r.get("field_path") == field_path] return {"success": True, "history": rows, "corrections_path": path} def _v2_revert_field(project_root: str, p: Dict[str, Any]) -> Dict[str, Any]: clip_id = p.get("clip_id") or p.get("clipId") clip_dir = p.get("clip_dir") or p.get("clipDir") entity_type = p.get("entity_type") or p.get("entityType") entity_uuid = p.get("entity_uuid") or p.get("entityUuid") field_path = p.get("field_path") or p.get("fieldPath") if not (entity_type and entity_uuid is not None and field_path): return _err("revert_field requires entity_type, entity_uuid, field_path") author = p.get("author") or "unknown" path = _v2_corrections_path_for_clip(project_root, clip_dir, clip_id) if not path or not os.path.isfile(path): return _err("No corrections file exists for this clip; nothing to revert.") data = _v2_read_corrections(path) key = f"{entity_type}:{entity_uuid}:{field_path}" if key not in data.get("current", {}): return _err(f"No current correction for {key}; nothing to revert.") # Walk changelog backwards to find the value BEFORE the most-recent change for this key target_changelog = [r for r in (data.get("changelog") or []) if r.get("entity_type") == entity_type and str(r.get("entity_uuid")) == str(entity_uuid) and r.get("field_path") == field_path] if not target_changelog: return _err("No changelog entries found for this field; cannot revert.") last_change = target_changelog[-1] revert_to = last_change.get("previous_value") revert_source = last_change.get("previous_source") or "vision_v0.2" now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) if revert_to is None and not last_change.get("previous_source"): # The reverted-to state is "no human correction" — remove the current entry previous_entry = data["current"].pop(key, None) action_taken = "removed (back to machine-derived)" else: previous_entry = data["current"].get(key) data["current"][key] = { "value": revert_to, "source": revert_source, "author": last_change.get("previous_author") or "system", "timestamp": now, } action_taken = "reverted to previous value" data["changelog"].append({ "entity_type": entity_type, "entity_uuid": str(entity_uuid), "field_path": field_path, "previous_value": (previous_entry or {}).get("value"), "new_value": revert_to, "previous_source": (previous_entry or {}).get("source"), "new_source": revert_source, "previous_author": (previous_entry or {}).get("author"), "new_author": author, "change_reason": f"revert by {author}", "timestamp": now, }) write_result = _v2_write_corrections(path, data) if not write_result.get("success"): return write_result return { "success": True, "action": action_taken, "field_path": field_path, "reverted_value": revert_to, "timestamp": now, "corrections_path": path, } def _v2_list_corrections(project_root: str, p: Dict[str, Any]) -> Dict[str, Any]: """List all corrections across the project (or for a specific clip).""" clip_id = p.get("clip_id") or p.get("clipId") if clip_id: clip_dir = p.get("clip_dir") or p.get("clipDir") path = _v2_corrections_path_for_clip(project_root, clip_dir, clip_id) if not path or not os.path.isfile(path): return {"success": True, "corrections": [], "note": "No corrections for this clip."} data = _v2_read_corrections(path) return { "success": True, "clip_id": clip_id, "current_field_count": len(data.get("current", {})), "changelog_count": len(data.get("changelog", [])), "current": data.get("current", {}), "changelog": data.get("changelog", []), "corrections_path": path, } # Whole project — walk clips/ clips_root = os.path.join(project_root, "clips") if not os.path.isdir(clips_root): return {"success": True, "corrections": [], "note": "No clips directory."} summary = [] for entry in os.listdir(clips_root): candidate = os.path.join(clips_root, entry, "corrections.json") if not os.path.isfile(candidate): continue data = _v2_read_corrections(candidate) summary.append({ "clip_dir": entry, "clip_id": data.get("clip_id"), "current_field_count": len(data.get("current", {})), "changelog_count": len(data.get("changelog", [])), "corrections_path": candidate, }) return {"success": True, "project_root": project_root, "corrections": summary} # ─── V2 P12: Control panel lifecycle ────────────────────────────────────────── def _control_panel_pidfile() -> str: return os.path.expanduser("~/Documents/davinci-resolve-mcp-analysis/.control_panel.pid") def _control_panel_read_state() -> Optional[Dict[str, Any]]: """Read the saved PID/port from the pidfile if present. Returns None if absent or unreadable.""" path = _control_panel_pidfile() if not os.path.isfile(path): return None try: with open(path, "r", encoding="utf-8") as handle: return json.load(handle) except (OSError, json.JSONDecodeError): return None def _control_panel_pid_alive(pid: int) -> bool: """Check whether a PID is alive on this OS without killing it.""" if not pid or pid <= 0: return False try: os.kill(pid, 0) # signal 0 = existence check except (ProcessLookupError, PermissionError): return False except OSError: return False return True def _control_panel_status() -> Dict[str, Any]: state = _control_panel_read_state() or {} pid = int(state.get("pid") or 0) running = _control_panel_pid_alive(pid) if not running: # Stale pidfile — clean up if state: try: os.remove(_control_panel_pidfile()) except OSError: pass return {"running": False, "pid": None, "port": None, "url": None} return { "running": True, "pid": pid, "port": state.get("port"), "host": state.get("host"), "url": state.get("url"), "started_at": state.get("started_at"), } def _pick_dashboard_python(repo_root: str) -> Tuple[str, Optional[str]]: """Pick the interpreter to launch the dashboard with. Prefer a repo-local venv whose interpreter can import ``mcp`` — that survives the MCP server itself being started under system Python. Returns ``(executable, source)`` where ``source`` is "venv:" when a venv was picked, ``"sys.executable"`` otherwise. """ import subprocess import sys as _sys candidates = [ os.path.join(repo_root, "venv", "bin", "python"), os.path.join(repo_root, ".venv", "bin", "python"), os.path.join(repo_root, "venv", "Scripts", "python.exe"), os.path.join(repo_root, ".venv", "Scripts", "python.exe"), ] for candidate in candidates: if not os.path.isfile(candidate) or not os.access(candidate, os.X_OK): continue try: result = subprocess.run( [candidate, "-c", "import mcp"], capture_output=True, timeout=5, check=False, stdin=subprocess.DEVNULL, ) except (OSError, subprocess.TimeoutExpired): continue if result.returncode == 0: return candidate, f"venv:{candidate}" return _sys.executable, "sys.executable" def _control_panel_probe(host: str, port: int, timeout: float = 1.5) -> Dict[str, Any]: """Probe a port to see whether a dashboard is listening and what version. Returns ``{"is_dashboard": bool, "version": Optional[str]}``. - ``is_dashboard`` is True when /api/boot responds with a recognizable dashboard payload (``success: true`` plus a project field). This lets callers distinguish an older dashboard that predates the ``mcp_version`` surface from a non-dashboard process squatting on the port. - ``version`` is the reported MCP version, or None if the dashboard predates the field. """ import urllib.request url = f"http://{host}:{port}/api/boot" try: with urllib.request.urlopen(url, timeout=timeout) as resp: payload = json.loads(resp.read().decode("utf-8") or "{}") except Exception: return {"is_dashboard": False, "version": None} if not isinstance(payload, dict): return {"is_dashboard": False, "version": None} is_dashboard = bool(payload.get("success")) and any( k in payload for k in ("project_name", "project_id", "project_root") ) cap = payload.get("capabilities") version: Optional[str] = None if isinstance(cap, dict) and cap.get("mcp_version"): version = str(cap["mcp_version"]) elif payload.get("mcp_version"): version = str(payload["mcp_version"]) return {"is_dashboard": is_dashboard, "version": version} def _control_panel_remote_version(host: str, port: int, timeout: float = 1.5) -> Optional[str]: """Backwards-compat wrapper around :func:`_control_panel_probe`. None on failure.""" return _control_panel_probe(host, port, timeout).get("version") def _port_owner_pid(host: str, port: int) -> Optional[int]: """Return PID of the process LISTENing on `port`, or None if free/unknown. Uses lsof with `-iTCP: -sTCP:LISTEN -t`: one PID per line, no header. Host is informational only — lsof matches any local LISTEN socket on that port (which is what we care about for port-collision detection). """ import subprocess try: result = subprocess.run( ["lsof", "-nP", "-iTCP:" + str(port), "-sTCP:LISTEN", "-t"], capture_output=True, timeout=3, text=True, check=False, stdin=subprocess.DEVNULL, ) except (OSError, subprocess.TimeoutExpired): return None for line in (result.stdout or "").splitlines(): line = line.strip() if line.isdigit(): return int(line) return None def _open_control_panel(p: Dict[str, Any]) -> Dict[str, Any]: import subprocess import socket import time as _t host = p.get("host") or "127.0.0.1" port = int(p.get("port") or 8765) force_restart = _media_analysis_bool(p.get("force_restart", p.get("forceRestart")), False) # Freshness check: probe the port directly (works whether or not THIS MCP # tracks the listener in its pidfile). This unifies two scenarios: # 1. Tracked stale: pidfile present, our prior MCP spawn still alive, # now running an older VERSION. # 2. Untracked stale: process survived an MCP restart with # start_new_session=True; pidfile was removed but the dashboard is # still listening on the port. # Both surface as `status: "stale_running"` so the caller knows to # force_restart. A non-dashboard squatter on the port still falls through # to a port-collision error. existing = _control_panel_status() port_pid = _port_owner_pid(host, port) live_version = VERSION if port_pid is not None and not force_restart: probe = _control_panel_probe(host, port) tracked_url = (existing or {}).get("url") if existing.get("running") else None url = tracked_url or f"http://{host}:{port}" if probe["is_dashboard"]: remote_version = probe["version"] # Compare with explicit None handling: an older dashboard that # predates the mcp_version field is also stale — it can't honor # newer surfaces and the caller needs to know. if remote_version != live_version: reported = remote_version or "unknown (predates the mcp_version field)" return { "success": True, "status": "stale_running", "url": url, "pid": port_pid, "port": port, "running_version": remote_version, "live_version": live_version, "remediation": ( f"The running control panel reports version {reported} but the " f"MCP server is at {live_version}. Re-call open_control_panel with " "force_restart=true to terminate the stale process and relaunch." ), } return { "success": True, "status": "already_running", "url": url, "pid": port_pid, "port": port, "running_version": remote_version, "note": "Control panel already running; returning existing URL.", } # Port held by a non-dashboard process — surface as collision. return _err( f"Port {port} is already in use by PID {port_pid} (not a control panel). " "Re-call with force_restart=true to terminate it, or pass a different port.", ) # Force-restart path: kill whatever owns the port (could be the tracked PID # or an untracked stale process) before re-spawning. if force_restart: tracked_pid = int((existing or {}).get("pid") or 0) for victim in {tracked_pid, port_pid or 0} - {0}: try: os.kill(victim, 15) # SIGTERM except (ProcessLookupError, PermissionError, OSError): pass for _ in range(20): if _port_owner_pid(host, port) is None: break _t.sleep(0.1) try: os.remove(_control_panel_pidfile()) except OSError: pass # Safety net: if force_restart couldn't free the port (e.g. PID owned by # another user, SIGTERM ignored), bail rather than spawning a child that # will crash silently with "Address already in use". port_pid = _port_owner_pid(host, port) if port_pid is not None: return _err( f"Port {port} is still in use by PID {port_pid} after force_restart. " "Pass a different port or kill the process manually.", ) project_name = p.get("project_name") or "Dashboard Analysis" project_id = p.get("project_id") or "dashboard" analysis_root = p.get("analysis_root") or os.path.expanduser("~/Documents/davinci-resolve-mcp-analysis") open_browser = _media_analysis_bool(p.get("open_browser", p.get("openBrowser")), False) # Locate the repo root so we can run the dashboard module repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) python_exe, python_source = _pick_dashboard_python(repo_root) cmd = [ python_exe, "-m", "src.analysis_dashboard", "--host", str(host), "--port", str(port), "--project-name", str(project_name), "--project-id", str(project_id), "--analysis-root", str(analysis_root), ] if open_browser: cmd.append("--open") else: cmd.append("--no-open") # Detach so the dashboard outlives this MCP call. log_path = os.path.join(os.path.expanduser("~/Documents/davinci-resolve-mcp-analysis"), ".control_panel.log") try: os.makedirs(os.path.dirname(log_path), exist_ok=True) log_handle = open(log_path, "a", encoding="utf-8") except OSError: log_handle = subprocess.DEVNULL try: proc = subprocess.Popen( cmd, cwd=repo_root, stdout=log_handle, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True, ) except (OSError, FileNotFoundError) as exc: return _err(f"Failed to launch control panel: {type(exc).__name__}: {exc}") # Verify the child actually came up. Bind errors (port in use), import # failures, etc. would otherwise leave us reporting "launched" while the # child has already died. Poll until the child is serving or we time out. serving = False for _ in range(40): # ~4 seconds total rc = proc.poll() if rc is not None: # Child already exited — capture the tail of the log for diagnostics. tail = "" try: with open(log_path, "r", encoding="utf-8") as handle: tail = handle.read()[-800:] except OSError: pass return _err( f"Control panel child exited (rc={rc}) before serving. " f"Log tail: {tail.strip()!r}", ) try: with socket.create_connection((host, port), timeout=0.25): serving = True break except OSError: pass _t.sleep(0.1) if not serving: try: proc.terminate() except OSError: pass return _err( f"Control panel did not start accepting connections within 4s " f"(pid {proc.pid}). Check {log_path} for details.", ) # Write the pidfile so subsequent calls find it url = f"http://{host}:{port}" state = { "pid": proc.pid, "port": port, "host": host, "url": url, "started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "project_name": project_name, "project_id": project_id, "analysis_root": analysis_root, "log_path": log_path, "python_executable": python_exe, "python_source": python_source, } try: os.makedirs(os.path.dirname(_control_panel_pidfile()), exist_ok=True) with open(_control_panel_pidfile(), "w", encoding="utf-8") as handle: json.dump(state, handle, indent=2) except OSError: pass # non-fatal; status-check will just spawn a new one next time return { "success": True, "status": "launched", "url": url, "pid": proc.pid, "port": port, "host": host, "log_path": log_path, "python_executable": python_exe, "python_source": python_source, "note": ( "Control panel launched in background. Open the URL in a browser, or " "call again with open_browser=true to auto-open. Use close_control_panel " "to terminate." ), } # ─── V2 B4: Save / restore Resolve UI state for preview workflows ───────────── # # The control panel's "Open in Resolve at this timecode" flow wants to: # 1. Save where the editor was (page, current timeline, timecode) # 2. Preview a different clip in source viewer (via media_pool_item.open_in_viewer) # 3. Restore the editor to their prior context once they close the preview # # State is held in-memory in a small token-keyed dict (single-user model). _RESOLVE_STATE_SNAPSHOTS: Dict[str, Dict[str, Any]] = {} def _resolve_save_state() -> Dict[str, Any]: r = get_resolve() if r is None: return _err("Not connected to DaVinci Resolve.") pm = r.GetProjectManager() proj = pm.GetCurrentProject() if pm else None state: Dict[str, Any] = { "saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "page": r.GetCurrentPage(), } if proj is not None: try: tl = proj.GetCurrentTimeline() except Exception: tl = None if tl is not None: try: state["current_timeline_id"] = tl.GetUniqueId() state["current_timeline_name"] = tl.GetName() state["current_timecode"] = tl.GetCurrentTimecode() except Exception: pass try: mp = proj.GetMediaPool() if mp: selected = mp.GetSelectedClips() or [] state["selected_clip_ids"] = [c.GetUniqueId() for c in selected if c] current_folder = mp.GetCurrentFolder() if current_folder is not None: state["current_folder_name"] = current_folder.GetName() except Exception: pass token = short_hash(json.dumps(state, sort_keys=True, default=str), length=12) if "short_hash" in globals() else str(int(time.time() * 1000)) _RESOLVE_STATE_SNAPSHOTS[token] = state # Prune old snapshots (keep last 20) if len(_RESOLVE_STATE_SNAPSHOTS) > 20: oldest = sorted(_RESOLVE_STATE_SNAPSHOTS.items(), key=lambda kv: kv[1].get("saved_at") or "") for k, _ in oldest[:-20]: _RESOLVE_STATE_SNAPSHOTS.pop(k, None) return {"state_token": token, **state} def _resolve_restore_state(p: Dict[str, Any]) -> Dict[str, Any]: token = p.get("state_token") or p.get("stateToken") if not token: return _err("restore_state requires state_token") state = _RESOLVE_STATE_SNAPSHOTS.get(token) if not state: return _err(f"Unknown state_token: {token}") r = get_resolve() if r is None: return _err("Not connected to DaVinci Resolve.") restored: Dict[str, Any] = {} # Restore page first so subsequent ops land in the right context if state.get("page"): try: r.OpenPage(state["page"]) restored["page"] = state["page"] except Exception as exc: restored["page_error"] = str(exc) pm = r.GetProjectManager() proj = pm.GetCurrentProject() if pm else None if proj is not None and state.get("current_timeline_id"): try: count = proj.GetTimelineCount() or 0 for i in range(1, count + 1): tl = proj.GetTimelineByIndex(i) if tl and tl.GetUniqueId() == state["current_timeline_id"]: proj.SetCurrentTimeline(tl) restored["current_timeline_id"] = state["current_timeline_id"] if state.get("current_timecode"): try: tl.SetCurrentTimecode(state["current_timecode"]) restored["current_timecode"] = state["current_timecode"] except Exception: pass break except Exception as exc: restored["timeline_error"] = str(exc) # Restore media pool selection if proj is not None and state.get("selected_clip_ids"): try: mp = proj.GetMediaPool() if mp: root = mp.GetRootFolder() for cid in state["selected_clip_ids"]: found, parent = _find_clip_with_parent(root, cid) if found and parent is not None: mp.SetCurrentFolder(parent) mp.SetSelectedClip(found) restored["selected_clip_id"] = cid break # SetSelectedClip is singular; pick the first except Exception as exc: restored["selection_error"] = str(exc) return {"success": True, "state_token": token, "restored": restored} def _close_control_panel() -> Dict[str, Any]: state = _control_panel_read_state() if not state: return {"success": True, "was_running": False, "note": "No control panel was running."} pid = int(state.get("pid") or 0) if not _control_panel_pid_alive(pid): try: os.remove(_control_panel_pidfile()) except OSError: pass return {"success": True, "was_running": False, "note": "Stale pidfile cleaned up."} try: os.kill(pid, 15) # SIGTERM except (ProcessLookupError, PermissionError, OSError) as exc: return _err(f"Failed to terminate control panel (pid {pid}): {exc}") # Best-effort: give it a moment to die, then remove pidfile import time as _t for _ in range(10): if not _control_panel_pid_alive(pid): break _t.sleep(0.1) try: os.remove(_control_panel_pidfile()) except OSError: pass return {"success": True, "was_running": True, "pid": pid} # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 2: layout_presets # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() def layout_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Manage DaVinci Resolve UI layout presets. Actions: save(name) -> {success} load(name) -> {success} update(name) -> {success} export(name, path) -> {success} import_preset(path, name?) -> {success} delete(name) -> {success} """ p = params or {} r = get_resolve() if r is None: return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.") if action == "save": return {"success": bool(r.SaveLayoutPreset(p["name"]))} elif action == "load": return {"success": bool(r.LoadLayoutPreset(p["name"]))} elif action == "update": return {"success": bool(r.UpdateLayoutPreset(p["name"]))} elif action == "export": return {"success": bool(r.ExportLayoutPreset(p["name"], p["path"]))} elif action == "import_preset": if "name" in p: return {"success": bool(r.ImportLayoutPreset(p["path"], p["name"]))} return {"success": bool(r.ImportLayoutPreset(p["path"]))} elif action == "delete": return {"success": bool(r.DeleteLayoutPreset(p["name"]))} return _unknown(action, ["save","load","update","export","import_preset","delete"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 3: render_presets # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() def render_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Import/export render and burn-in presets. Actions: import_render(path) -> {success} export_render(name, path) -> {success} import_burnin(path) -> {success} export_burnin(name, path) -> {success} """ p = params or {} r = get_resolve() if r is None: return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.") if action == "import_render": return {"success": bool(r.ImportRenderPreset(p["path"]))} elif action == "export_render": return {"success": bool(r.ExportRenderPreset(p["name"], p["path"]))} elif action == "import_burnin": return {"success": bool(r.ImportBurnInPreset(p["path"]))} elif action == "export_burnin": return {"success": bool(r.ExportBurnInPreset(p["name"], p["path"]))} return _unknown(action, ["import_render","export_render","import_burnin","export_burnin"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 4: project_manager # ═══════════════════════════════════════════════════════════════════════════════ _PROJECT_KERNEL_ACTIONS = [ "project_capabilities", "probe_project_lifecycle", "probe_project_settings", "safe_project_create", "safe_project_export", "safe_project_import", "safe_project_archive", "safe_project_restore", "safe_project_delete", "safe_set_project_settings", "project_settings_snapshot", "database_capabilities", "safe_set_current_database", "preset_lifecycle_probe", "project_boundary_report", ] _PROJECT_MANAGER_METHODS = [ "ArchiveProject", "CreateProject", "DeleteProject", "LoadProject", "GetCurrentProject", "SaveProject", "CloseProject", "CreateFolder", "DeleteFolder", "GetProjectListInCurrentFolder", "GetFolderListInCurrentFolder", "GotoRootFolder", "GotoParentFolder", "GetCurrentFolder", "OpenFolder", "ImportProject", "ExportProject", "RestoreProject", "GetCurrentDatabase", "GetDatabaseList", "SetCurrentDatabase", "CreateCloudProject", "LoadCloudProject", "ImportCloudProject", "RestoreCloudProject", ] _PROJECT_METHODS = [ "GetName", "SetName", "GetUniqueId", "GetSetting", "SetSetting", "GetPresetList", "SetPreset", "GetTimelineCount", "GetTimelineByIndex", "GetCurrentTimeline", "SetCurrentTimeline", "GetMediaPool", "GetGallery", "GetRenderPresetList", "GetQuickExportRenderPresets", "GetRenderJobList", "GetRenderSettings", "GetRenderFormats", "RefreshLUTList", "LoadBurnInPreset", "ExportCurrentFrameAsStill", "GetColorGroupsList", ] _PROJECT_SETTING_PROBE_KEYS = [ "timelineFrameRate", "timelinePlaybackFrameRate", "timelineResolutionWidth", "timelineResolutionHeight", "videoMonitorFormat", "superScale", "colorScienceMode", "timelineWorkingLuminance", "timelineOutputResMismatchBehavior", ] def _is_disposable_project_name(name: Any) -> bool: return isinstance(name, str) and name.startswith("_mcp_") and len(name) > len("_mcp_") def _require_disposable_project_name( name: Any, *, field: str = "name", allow_non_mcp_name: bool = False, ) -> Optional[Dict[str, Any]]: if allow_non_mcp_name: if isinstance(name, str) and name: return None return _err(f"{field} must be a non-empty string") if not _is_disposable_project_name(name): return _err(f"{field} must start with '_mcp_' unless allow_non_mcp_name=True") return None def _project_path_guard(path: Any, *, field: str = "path", require_temp_path: bool = True) -> Optional[Dict[str, Any]]: if not isinstance(path, str) or not path: return _err(f"{field} is required") if require_temp_path and not _render_temp_path_ok(path): return _err(f"{field} must be under the system temp directory unless require_temp_path=False") return None def _project_path_parent(path: str) -> str: parent = os.path.dirname(os.path.abspath(path)) return parent or os.getcwd() def _project_object_summary(project) -> Optional[Dict[str, Any]]: if not project: return None out: Dict[str, Any] = {} for key, method_name in (("name", "GetName"), ("id", "GetUniqueId")): if _has_method(project, method_name): try: out[key] = _ser(getattr(project, method_name)()) except Exception as exc: out[f"{key}_error"] = str(exc) return out def _project_folder_summary(folder) -> Optional[Dict[str, Any]]: if folder is None: return None if isinstance(folder, str): return {"name": folder} out: Dict[str, Any] = {} for key, method_name in (("name", "GetName"), ("id", "GetUniqueId")): if _has_method(folder, method_name): try: out[key] = _ser(getattr(folder, method_name)()) except Exception as exc: out[f"{key}_error"] = str(exc) return out or {"repr": str(folder)} def _project_manager_snapshot(pm) -> Dict[str, Any]: out: Dict[str, Any] = { "methods": {method: _has_method(pm, method) for method in _PROJECT_MANAGER_METHODS}, } try: out["projects"] = _ser(pm.GetProjectListInCurrentFolder() or []) except Exception as exc: out["projects_error"] = str(exc) try: folders = pm.GetFolderListInCurrentFolder() or [] out["folders"] = [_project_folder_summary(folder) for folder in folders] except Exception as exc: out["folders_error"] = str(exc) try: out["current_folder"] = _project_folder_summary(pm.GetCurrentFolder()) except Exception as exc: out["current_folder_error"] = str(exc) try: out["current_project"] = _project_object_summary(pm.GetCurrentProject()) except Exception as exc: out["current_project_error"] = str(exc) return out def _project_capabilities(pm=None, project=None, resolve_obj=None) -> Dict[str, Any]: return { "project_manager_methods": {method: (_has_method(pm, method) if pm else True) for method in _PROJECT_MANAGER_METHODS}, "project_methods": {method: (_has_method(project, method) if project else True) for method in _PROJECT_METHODS}, "safe_guards": { "disposable_project_prefix": "_mcp_", "temp_paths_required_by_default": True, "archive_source_media_default": False, "archive_render_cache_default": False, "archive_proxy_media_default": False, "database_switch_dry_run_default": True, "cloud_project_mutation_default": "not_run_by_default", }, "kernel_actions": list(_PROJECT_KERNEL_ACTIONS), "resolve": { "layout_presets": { "save": _has_method(resolve_obj, "SaveLayoutPreset") if resolve_obj else True, "load": _has_method(resolve_obj, "LoadLayoutPreset") if resolve_obj else True, "update": _has_method(resolve_obj, "UpdateLayoutPreset") if resolve_obj else True, "export": _has_method(resolve_obj, "ExportLayoutPreset") if resolve_obj else True, "import": _has_method(resolve_obj, "ImportLayoutPreset") if resolve_obj else True, "delete": _has_method(resolve_obj, "DeleteLayoutPreset") if resolve_obj else True, }, "render_presets": { "import_render": _has_method(resolve_obj, "ImportRenderPreset") if resolve_obj else True, "export_render": _has_method(resolve_obj, "ExportRenderPreset") if resolve_obj else True, "import_burnin": _has_method(resolve_obj, "ImportBurnInPreset") if resolve_obj else True, "export_burnin": _has_method(resolve_obj, "ExportBurnInPreset") if resolve_obj else True, }, }, } def _project_settings_snapshot(project, p: Dict[str, Any]) -> Dict[str, Any]: settings_key = p.get("name", p.get("key", "")) out: Dict[str, Any] = { "project": _project_object_summary(project), "methods": {method: _has_method(project, method) for method in _PROJECT_METHODS}, } if _has_method(project, "GetSetting"): try: out["settings"] = _ser(project.GetSetting(settings_key)) except Exception as exc: out["settings_error"] = str(exc) if _has_method(project, "GetPresetList"): try: out["presets"] = _ser(project.GetPresetList() or []) except Exception as exc: out["presets_error"] = str(exc) if _has_method(project, "GetTimelineCount"): try: out["timeline_count"] = _ser(project.GetTimelineCount()) except Exception as exc: out["timeline_count_error"] = str(exc) if _has_method(project, "GetCurrentTimeline"): try: tl = project.GetCurrentTimeline() out["current_timeline"] = { "name": _ser(tl.GetName()) if tl and _has_method(tl, "GetName") else None, "id": _ser(tl.GetUniqueId()) if tl and _has_method(tl, "GetUniqueId") else None, } except Exception as exc: out["current_timeline_error"] = str(exc) if _has_method(project, "GetRenderPresetList"): try: out["render_presets"] = _ser(project.GetRenderPresetList() or []) except Exception as exc: out["render_presets_error"] = str(exc) if _has_method(project, "GetQuickExportRenderPresets"): try: out["quick_export_presets"] = _ser(project.GetQuickExportRenderPresets() or []) except Exception as exc: out["quick_export_presets_error"] = str(exc) if _has_method(project, "GetColorGroupsList"): try: out["color_groups"] = [{"name": _ser(group.GetName())} for group in (project.GetColorGroupsList() or [])] except Exception as exc: out["color_groups_error"] = str(exc) return out def _probe_project_settings(project, p: Dict[str, Any]) -> Dict[str, Any]: keys = p.get("keys", p.get("candidate_keys", _PROJECT_SETTING_PROBE_KEYS)) if not isinstance(keys, list): return _err("keys must be a list") out = { "snapshot": _project_settings_snapshot(project, p), "candidate_settings": {}, } for key in keys: if not isinstance(key, str) or not key: continue row: Dict[str, Any] = {} try: row["value"] = _ser(project.GetSetting(key)) except Exception as exc: row["error"] = str(exc) out["candidate_settings"][key] = row if p.get("try_write"): settings = { key: row["value"] for key, row in out["candidate_settings"].items() if "value" in row and row["value"] not in (None, "") } if settings: out["write_restore_probe"] = _safe_set_project_settings(project, { "settings": settings, "restore": True, "dry_run": bool(p.get("dry_run", False)), }) return out def _safe_set_project_settings(project, p: Dict[str, Any]) -> Dict[str, Any]: settings = p.get("settings") if settings is None: settings = {key: p[key] for key in _PROJECT_SETTING_PROBE_KEYS if key in p} if not isinstance(settings, dict) or not settings: return _err("settings must be a non-empty object") original: Dict[str, Any] = {} for key in settings: try: original[key] = _ser(project.GetSetting(key)) except Exception as exc: original[key] = {"error": str(exc)} if p.get("dry_run"): return _ok(would_set=settings, original=original, restore=p.get("restore", True)) results: Dict[str, Any] = {} for key, value in settings.items(): row: Dict[str, Any] = {"requested": value, "original": original.get(key)} try: row["write"] = bool(project.SetSetting(key, value)) except Exception as exc: row["write"] = False row["error"] = str(exc) try: row["readback"] = _ser(project.GetSetting(key)) except Exception as exc: row["readback_error"] = str(exc) if p.get("restore", True) and not isinstance(original.get(key), dict): try: row["restore"] = bool(project.SetSetting(key, original[key])) row["restored_value"] = _ser(project.GetSetting(key)) except Exception as exc: row["restore"] = False row["restore_error"] = str(exc) results[key] = row return {"success": all(row.get("write") for row in results.values()), "results": results} def _safe_project_create(pm, resolve_obj, p: Dict[str, Any]) -> Dict[str, Any]: name = p.get("name") invalid = _require_disposable_project_name(name, allow_non_mcp_name=p.get("allow_non_mcp_name", False)) if invalid: return invalid media_location_path = p.get("media_location_path") or p.get("mediaLocationPath") if media_location_path: path_err = _project_path_guard( media_location_path, field="media_location_path", require_temp_path=p.get("require_temp_media_location", True), ) if path_err: return path_err version = resolve_obj.GetVersion() or [0] if version[0] < 20 or (version[0] == 20 and len(version) > 2 and (version[1], version[2]) < (2, 2)): return _err("ProjectManager.CreateProject media_location_path requires DaVinci Resolve 20.2.2+") if p.get("dry_run"): return _ok(would_create=True, name=name, media_location_path=media_location_path) project = pm.CreateProject(name, media_location_path) if media_location_path else pm.CreateProject(name) return _ok(project=_project_object_summary(project), name=project.GetName()) if project else _err(f"Failed to create '{name}'") def _safe_project_export(pm, p: Dict[str, Any]) -> Dict[str, Any]: name = p.get("name") invalid = _require_disposable_project_name(name, allow_non_mcp_name=p.get("allow_non_mcp_name", False)) if invalid: return invalid path = p.get("path") path_err = _project_path_guard(path, require_temp_path=p.get("require_temp_path", True)) if path_err: return path_err os.makedirs(_project_path_parent(path), exist_ok=True) with_stills = bool(p.get("with_stills_and_luts", False)) if p.get("dry_run"): return _ok(would_export=True, name=name, path=path, with_stills_and_luts=with_stills) return {"success": bool(pm.ExportProject(name, path, with_stills))} def _safe_project_import(pm, p: Dict[str, Any]) -> Dict[str, Any]: path = p.get("path") path_err = _project_path_guard(path, require_temp_path=p.get("require_temp_path", True)) if path_err: return path_err if not os.path.exists(path): return _err(f"path does not exist: {path}") name = p.get("name") invalid = _require_disposable_project_name(name, allow_non_mcp_name=p.get("allow_non_mcp_name", False)) if invalid: return invalid if p.get("dry_run"): return _ok(would_import=True, path=path, name=name) return {"success": bool(pm.ImportProject(path, name))} def _safe_project_archive(pm, p: Dict[str, Any]) -> Dict[str, Any]: name = p.get("name") invalid = _require_disposable_project_name(name, allow_non_mcp_name=p.get("allow_non_mcp_name", False)) if invalid: return invalid path = p.get("path") path_err = _project_path_guard(path, require_temp_path=p.get("require_temp_path", True)) if path_err: return path_err src_media = bool(p.get("src_media", False)) render_cache = bool(p.get("render_cache", False)) proxy_media = bool(p.get("proxy_media", False)) if (src_media or render_cache or proxy_media) and not p.get("allow_media_archive", False): return _err("Archive media/cache/proxy flags must stay false unless allow_media_archive=True") os.makedirs(_project_path_parent(path), exist_ok=True) if p.get("dry_run"): return _ok( would_archive=True, name=name, path=path, src_media=src_media, render_cache=render_cache, proxy_media=proxy_media, ) return {"success": bool(pm.ArchiveProject(name, path, src_media, render_cache, proxy_media))} def _safe_project_restore(pm, p: Dict[str, Any]) -> Dict[str, Any]: path = p.get("path") path_err = _project_path_guard(path, require_temp_path=p.get("require_temp_path", True)) if path_err: return path_err if not os.path.exists(path): return _err(f"path does not exist: {path}") name = p.get("name") invalid = _require_disposable_project_name(name, allow_non_mcp_name=p.get("allow_non_mcp_name", False)) if invalid: return invalid if p.get("dry_run"): return _ok(would_restore=True, path=path, name=name) return {"success": bool(pm.RestoreProject(path, name))} def _safe_project_delete(pm, p: Dict[str, Any]) -> Dict[str, Any]: name = p.get("name") invalid = _require_disposable_project_name(name, allow_non_mcp_name=p.get("allow_non_mcp_name", False)) if invalid: return invalid if p.get("dry_run"): return _ok(would_delete=True, name=name) current = pm.GetCurrentProject() current_name = current.GetName() if current and _has_method(current, "GetName") else None if current_name == name: if not p.get("close_current", False): return _err("Refusing to delete the currently open project; pass close_current=True") if p.get("save_current", True): pm.SaveProject() closed = bool(pm.CloseProject(current)) if not closed: return _err(f"Failed to close current project '{name}' before delete") return {"success": bool(pm.DeleteProject(name))} def _database_capabilities(pm) -> Dict[str, Any]: out: Dict[str, Any] = { "methods": { "get_current": _has_method(pm, "GetCurrentDatabase"), "list": _has_method(pm, "GetDatabaseList"), "set_current": _has_method(pm, "SetCurrentDatabase"), }, "guards": { "set_current_default": "dry_run", "set_current_requires_allow_switch": True, "reason": "SetCurrentDatabase closes any open project and can disrupt the user's Resolve state.", }, } if _has_method(pm, "GetCurrentDatabase"): try: out["current"] = _ser(pm.GetCurrentDatabase()) except Exception as exc: out["current_error"] = str(exc) if _has_method(pm, "GetDatabaseList"): try: out["databases"] = _ser(pm.GetDatabaseList() or []) except Exception as exc: out["databases_error"] = str(exc) return out def _safe_set_current_database(pm, p: Dict[str, Any]) -> Dict[str, Any]: db_info = p.get("db_info") if not isinstance(db_info, dict) or not db_info.get("DbType") or not db_info.get("DbName"): return _err("db_info must include DbType and DbName") current = _ser(pm.GetCurrentDatabase()) if _has_method(pm, "GetCurrentDatabase") else None dry_run = p.get("dry_run", True) or not p.get("allow_switch", False) if dry_run: return _ok( would_switch=True, current=current, target=_ser(db_info), requires_allow_switch=True, note="SetCurrentDatabase closes any open project; pass allow_switch=True and dry_run=False to execute.", ) return {"success": bool(pm.SetCurrentDatabase(db_info))} def _preset_lifecycle_probe(resolve_obj, project, p: Dict[str, Any]) -> Dict[str, Any]: out: Dict[str, Any] = { "project_presets": {"available": _has_method(project, "GetPresetList")}, "render_presets": {"available": _has_method(project, "GetRenderPresetList")}, "quick_export_presets": {"available": _has_method(project, "GetQuickExportRenderPresets")}, "fairlight_presets": {"available": _has_method(resolve_obj, "GetFairlightPresets")}, "layout_presets": { "save": _has_method(resolve_obj, "SaveLayoutPreset"), "load": _has_method(resolve_obj, "LoadLayoutPreset"), "update": _has_method(resolve_obj, "UpdateLayoutPreset"), "export": _has_method(resolve_obj, "ExportLayoutPreset"), "import": _has_method(resolve_obj, "ImportLayoutPreset"), "delete": _has_method(resolve_obj, "DeleteLayoutPreset"), }, "render_preset_files": { "import_render": _has_method(resolve_obj, "ImportRenderPreset"), "export_render": _has_method(resolve_obj, "ExportRenderPreset"), "import_burnin": _has_method(resolve_obj, "ImportBurnInPreset"), "export_burnin": _has_method(resolve_obj, "ExportBurnInPreset"), }, } try: out["project_presets"]["items"] = _ser(project.GetPresetList() or []) except Exception as exc: out["project_presets"]["error"] = str(exc) try: out["render_presets"]["items"] = _ser(project.GetRenderPresetList() or []) except Exception as exc: out["render_presets"]["error"] = str(exc) try: out["quick_export_presets"]["items"] = _ser(project.GetQuickExportRenderPresets() or []) except Exception as exc: out["quick_export_presets"]["error"] = str(exc) try: out["fairlight_presets"]["items"] = _ser(resolve_obj.GetFairlightPresets() or []) except Exception as exc: out["fairlight_presets"]["error"] = str(exc) return out def _project_boundary_report(resolve_obj, pm, project, p: Dict[str, Any]) -> Dict[str, Any]: return { "capabilities": _project_capabilities(pm, project, resolve_obj), "project_manager": _project_manager_snapshot(pm), "settings": _probe_project_settings(project, p) if project else {"available": False}, "database": _database_capabilities(pm), "presets": _preset_lifecycle_probe(resolve_obj, project, p) if project else {"available": False}, "cloud": { "methods": { "create": _has_method(pm, "CreateCloudProject"), "load": _has_method(pm, "LoadCloudProject"), "import": _has_method(pm, "ImportCloudProject"), "restore": _has_method(pm, "RestoreCloudProject"), }, "default_probe_mode": "shape_only", "requires_external_infrastructure": True, }, } def _find_project_timeline(project, name: str): """Return the timeline named `name` in `project`, or None.""" try: count = int(project.GetTimelineCount() or 0) except Exception: return None for i in range(1, count + 1): tl = project.GetTimelineByIndex(i) try: if tl and tl.GetName() == name: return tl except Exception: continue return None class _SpecLiveExecutor: """Live executor for project_spec.apply_spec — adapts a Resolve project to the duck-typed executor contract. Spec-aware so live_state() only reads the setting keys the spec cares about.""" def __init__(self, r, pm, spec): self._r = r self._pm = pm self._spec = spec self._proj = pm.GetCurrentProject() def live_state(self) -> Dict[str, Any]: proj = self._proj projects = list(self._pm.GetProjectListInCurrentFolder() or []) settings: Dict[str, Any] = {} if proj: for k in _project_spec.effective_settings(self._spec): try: v = proj.GetSetting(k) except Exception: v = None if v is not None: settings[k] = v spec_names = {t.name for t in self._spec.timelines} timelines: List[Dict[str, Any]] = [] if proj: count = int(proj.GetTimelineCount() or 0) for i in range(1, count + 1): tl = proj.GetTimelineByIndex(i) if not tl: continue name = tl.GetName() if name not in spec_names: timelines.append({"name": name}) continue tspec = next((t for t in self._spec.timelines if t.name == name), None) keys = set((tspec.settings if tspec else {})) | {"timelineFrameRate"} tl_settings: Dict[str, Any] = {} for k in keys: try: v = tl.GetSetting(k) except Exception: v = None if v is not None: tl_settings[k] = v markers: List[Dict[str, Any]] = [] try: for frame, m in (tl.GetMarkers() or {}).items(): entry = {"frame": int(frame)} if isinstance(m, dict): entry.update({kk: m.get(kk) for kk in ("color", "name", "note", "duration", "customData")}) markers.append(entry) except Exception: pass timelines.append({"name": name, "settings": tl_settings, "markers": markers}) return { "project": proj.GetName() if proj else None, "projects": projects, "settings": settings, "timelines": timelines, } def ensure_project(self, name: str) -> bool: if self._proj and self._proj.GetName() == name: return True projects = list(self._pm.GetProjectListInCurrentFolder() or []) proj = self._pm.LoadProject(name) if name in projects else self._pm.CreateProject(name) if proj: self._proj = proj return True return False def set_project_setting(self, key: str, value: Any) -> bool: if not self._proj: return False try: return bool(self._proj.SetSetting(key, str(value))) except Exception: return False def ensure_timeline(self, name: str, fps: Optional[float]) -> bool: if not self._proj: return False tl = _find_project_timeline(self._proj, name) if tl is None: mp = self._proj.GetMediaPool() if mp is None: return False tl = mp.CreateEmptyTimeline(name) if tl is None: return False if fps is not None: try: tl.SetSetting("timelineFrameRate", str(fps)) except Exception: pass return True def set_timeline_setting(self, tl_name: str, key: str, value: Any) -> bool: tl = _find_project_timeline(self._proj, tl_name) if self._proj else None if tl is None: return False try: return bool(tl.SetSetting(key, str(value))) except Exception: return False def add_marker(self, tl_name: str, marker: Dict[str, Any]) -> bool: tl = _find_project_timeline(self._proj, tl_name) if self._proj else None if tl is None: return False try: return bool(tl.AddMarker( int(marker.get("frame", 0)), marker.get("color", "Blue"), marker.get("name", ""), marker.get("note", ""), int(marker.get("duration", 1)), marker.get("customData", marker.get("custom_data", "")), )) except Exception: return False def _make_spec_hook_runner(timeout: float = 120.0): """Return a callable that runs a Hook's shell command (opt-in only).""" import subprocess def _run(hook) -> bool: try: proc = subprocess.run( hook.command, shell=True, timeout=timeout, stdin=subprocess.DEVNULL, capture_output=True, text=True, ) return proc.returncode == 0 except Exception as exc: logger.warning("spec hook '%s' failed: %s", hook.name or hook.command, exc) return False return _run def _spec_action(r, pm, action: str, p: Dict[str, Any]) -> Dict[str, Any]: """plan_spec / apply_spec / diff_to_spec — declarative project reconcile.""" spec_path = p.get("spec_path") or p.get("path") try: if spec_path: spec = _project_spec.load_spec(str(spec_path)) elif isinstance(p.get("spec"), dict): spec = _project_spec.spec_from_dict(p["spec"]) else: return _err("Provide spec_path (file) or an inline spec dict.", code="NO_SPEC", category="invalid_input") except _project_spec.SpecError as exc: return _err(str(exc), code="SPEC_INVALID", category="invalid_input", state=exc.state) executor = _SpecLiveExecutor(r, pm, spec) if action == "diff_to_spec": return _ok(project=spec.project, **_project_spec.plan_spec(spec, executor.live_state())) if action == "plan_spec": return _ok(project=spec.project, **_project_spec.apply_spec(spec, executor, dry_run=True)) # apply_spec run_hooks = bool(p.get("run_hooks", False)) try: result = _project_spec.apply_spec( spec, executor, dry_run=bool(p.get("dry_run", False)), run_hooks=run_hooks, continue_on_error=bool(p.get("continue_on_error", False)), run_hook=_make_spec_hook_runner() if run_hooks else None, ) except _project_spec.SpecError as exc: return _err(str(exc), code="SPEC_APPLY_FAILED", category="batch_partial", state=exc.state) return _ok(**result) if result.get("success") else {"success": False, **result} def _project_lint_live(r, pm) -> Dict[str, Any]: """Gather live project state and run the lint health-check.""" proj = pm.GetCurrentProject() if not proj: return _ok(**_project_lint.lint_report({"project": None})) state: Dict[str, Any] = {"project": proj.GetName()} try: cur = proj.GetCurrentTimeline() state["current_timeline"] = cur.GetName() if cur else None except Exception: state["current_timeline"] = None timelines: List[Dict[str, Any]] = [] try: count = int(proj.GetTimelineCount() or 0) except Exception: count = 0 for i in range(1, count + 1): tl = proj.GetTimelineByIndex(i) if not tl: continue fps = None try: fps = float(tl.GetSetting("timelineFrameRate")) except Exception: pass item_count = 0 try: vc = int(tl.GetTrackCount("video") or 0) for ti in range(1, vc + 1): item_count += len(tl.GetItemListInTrack("video", ti) or []) except Exception: pass timelines.append({"name": tl.GetName(), "fps": fps, "item_count": item_count}) state["timelines"] = timelines settings: Dict[str, Any] = {} try: csm = proj.GetSetting("colorScienceMode") if csm is not None: settings["colorScienceMode"] = csm except Exception: pass state["settings"] = settings render: Dict[str, Any] = {} try: rf = proj.GetCurrentRenderFormatAndCodec() or {} if rf.get("format"): render["format"] = rf.get("format") render["codec"] = rf.get("codec") except Exception: pass state["render"] = render return _ok(**_project_lint.lint_report(state)) @mcp.tool() def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Manage DaVinci Resolve projects. Actions: list() -> {projects} get_current() -> {name, id} create(name, media_location_path?) -> {success, name} load(name) -> {success} save() -> {success} close() -> {success} delete(name) -> {success} import_project(path, name?) -> {success} export_project(name, path, with_stills_and_luts?) -> {success} archive(name, path, src_media?, render_cache?, proxy_media?) -> {success} restore(path, name?) -> {success} project_capabilities() -> {capabilities} probe_project_lifecycle() -> {project_manager, ...} probe_project_settings(keys?, try_write?) -> {snapshot, candidate_settings} safe_project_create(name, media_location_path?, dry_run?) -> {success} safe_project_export(name, path, with_stills_and_luts?, dry_run?) -> {success} safe_project_import(path, name, dry_run?) -> {success} safe_project_archive(name, path, src_media=false, render_cache=false, proxy_media=false, dry_run?) -> {success} safe_project_restore(path, name, dry_run?) -> {success} safe_project_delete(name, close_current?, dry_run?) -> {success} safe_set_project_settings(settings, restore?, dry_run?) -> {success} project_settings_snapshot(name?) -> {project, settings, presets, ...} database_capabilities() -> {methods, current, databases} safe_set_current_database(db_info, dry_run?, allow_switch?) -> {success} preset_lifecycle_probe() -> {project_presets, render_presets, layout_presets, ...} project_boundary_report() -> {capabilities, project_manager, settings, database, presets, cloud} lint() -> {ok, counts, issues} — graded project health pre-flight (no project, no current timeline, mixed fps, empty timeline, render/color-science unset, offline media). diff_to_spec(spec_path|spec) -> {actions, diff, change_count} — preview drift vs a declarative spec WITHOUT mutating. Spec is YAML/JSON: {project, color_preset?, settings?, timelines:[{name,fps?,settings?,markers?}], hooks?}. plan_spec(spec_path|spec) -> {dry_run, actions, diff, change_count} — same as apply with dry_run. apply_spec(spec_path|spec, dry_run?, run_hooks?, continue_on_error?) -> {success, applied, failures} Reconcile the project toward the spec (idempotent: re-runs are no-ops). Color/HDR settings are applied in dependency order; markers only added if absent. Hooks run only when run_hooks=true (executes shell from the spec — opt-in). """ p = params or {} r = get_resolve() if r is None: return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.") pm = r.GetProjectManager() if action == "lint": return _project_lint_live(r, pm) if action in {"diff_to_spec", "plan_spec", "apply_spec"}: return _spec_action(r, pm, action, p) if action == "project_capabilities": return _project_capabilities(pm, pm.GetCurrentProject(), r) elif action == "probe_project_lifecycle": return _project_manager_snapshot(pm) elif action == "database_capabilities": return _database_capabilities(pm) elif action == "safe_set_current_database": return _safe_set_current_database(pm, p) elif action == "safe_project_create": return _safe_project_create(pm, r, p) elif action == "safe_project_export": return _safe_project_export(pm, p) elif action == "safe_project_import": return _safe_project_import(pm, p) elif action == "safe_project_archive": return _safe_project_archive(pm, p) elif action == "safe_project_restore": return _safe_project_restore(pm, p) elif action == "safe_project_delete": return _safe_project_delete(pm, p) elif action in {"probe_project_settings", "safe_set_project_settings", "project_settings_snapshot", "preset_lifecycle_probe", "project_boundary_report"}: proj = pm.GetCurrentProject() if not proj: return _err("No project open") if action == "probe_project_settings": return _probe_project_settings(proj, p) if action == "safe_set_project_settings": return _safe_set_project_settings(proj, p) if action == "project_settings_snapshot": return _project_settings_snapshot(proj, p) if action == "preset_lifecycle_probe": return _preset_lifecycle_probe(r, proj, p) return _project_boundary_report(r, pm, proj, p) elif action == "list": return {"projects": pm.GetProjectListInCurrentFolder()} elif action == "get_current": proj = pm.GetCurrentProject() return {"name": proj.GetName(), "id": proj.GetUniqueId()} if proj else _err("No project open") elif action == "create": media_location_path = p.get("media_location_path") or p.get("mediaLocationPath") if media_location_path: version = r.GetVersion() or [0] if version[0] < 20 or (version[0] == 20 and len(version) > 2 and (version[1], version[2]) < (2, 2)): return _err("ProjectManager.CreateProject media_location_path requires DaVinci Resolve 20.2.2+") proj = pm.CreateProject(p["name"], media_location_path) if media_location_path else pm.CreateProject(p["name"]) return _ok(name=proj.GetName()) if proj else _err(f"Failed to create '{p['name']}'") elif action == "load": proj = pm.LoadProject(p["name"]) return _ok() if proj else _err(f"Failed to load '{p['name']}'") elif action == "save": return {"success": bool(pm.SaveProject())} elif action == "close": proj = pm.GetCurrentProject() return {"success": bool(pm.CloseProject(proj))} if proj else _err("No project open") elif action == "delete": return {"success": bool(pm.DeleteProject(p["name"]))} elif action == "import_project": return {"success": bool(pm.ImportProject(p["path"], p.get("name")))} elif action == "export_project": return {"success": bool(pm.ExportProject(p["name"], p["path"], p.get("with_stills_and_luts", True)))} elif action == "archive": return {"success": bool(pm.ArchiveProject(p["name"], p["path"], p.get("src_media", True), p.get("render_cache", True), p.get("proxy_media", False)))} elif action == "restore": return {"success": bool(pm.RestoreProject(p["path"], p.get("name")))} return _unknown(action, ["list","get_current","create","load","save","close","delete","import_project","export_project","archive","restore","lint","diff_to_spec","plan_spec","apply_spec", *_PROJECT_KERNEL_ACTIONS]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 5: project_manager_folders # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() def project_manager_folders(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Navigate and manage project folders in the Project Manager. Actions: list() -> {folders} get_current() -> {folder} create(name) -> {success} delete(name) -> {success} open(name) -> {success} goto_root() -> {success} goto_parent() -> {success} """ p = params or {} r = get_resolve() if r is None: return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.") pm = r.GetProjectManager() if action == "list": folders = pm.GetFolderListInCurrentFolder() or [] return {"folders": [_project_folder_summary(f) for f in folders]} elif action == "get_current": folder = pm.GetCurrentFolder() if not folder: return _err("No current folder") return {"folder": _project_folder_summary(folder)} elif action == "create": return {"success": bool(pm.CreateFolder(p["name"]))} elif action == "delete": return {"success": bool(pm.DeleteFolder(p["name"]))} elif action == "open": return {"success": bool(pm.OpenFolder(p["name"]))} elif action == "goto_root": return {"success": bool(pm.GotoRootFolder())} elif action == "goto_parent": return {"success": bool(pm.GotoParentFolder())} return _unknown(action, ["list","get_current","create","delete","open","goto_root","goto_parent"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 6: project_manager_cloud # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() def project_manager_cloud(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Cloud project operations (requires DaVinci Resolve cloud infrastructure). Actions: create(settings) -> {success} — settings: {CLOUD_SETTING_PROJECT_NAME, ...} load(settings) -> {success} import_project(path, settings) -> {success} restore(folder_path, settings) -> {success} """ p = params or {} r = get_resolve() if r is None: return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.") pm = r.GetProjectManager() if action == "create": proj = pm.CreateCloudProject(p["settings"]) return _ok(name=proj.GetName()) if proj else _err("Failed to create cloud project") elif action == "load": proj = pm.LoadCloudProject(p["settings"]) return _ok(name=proj.GetName()) if proj else _err("Failed to load cloud project") elif action == "import_project": return {"success": bool(pm.ImportCloudProject(p["path"], p["settings"]))} elif action == "restore": return {"success": bool(pm.RestoreCloudProject(p["folder_path"], p["settings"]))} return _unknown(action, ["create","load","import_project","restore"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 7: project_manager_database # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() def project_manager_database(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Manage DaVinci Resolve project databases. Actions: get_current() -> {db_type, db_name} list() -> {databases} set_current(db_info) -> {success} — db_info: {DbType, DbName} """ p = params or {} r = get_resolve() if r is None: return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.") pm = r.GetProjectManager() if action == "get_current": db = pm.GetCurrentDatabase() if not db: return _err("Failed to get current database") return {"db_type": db.get("DbType"), "db_name": db.get("DbName")} elif action == "list": return {"databases": pm.GetDatabaseList()} elif action == "set_current": return {"success": bool(pm.SetCurrentDatabase(p["db_info"]))} return _unknown(action, ["get_current","list","set_current"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 8: project_settings # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Project metadata, settings, and color groups. Actions: get_name() -> {name} set_name(name) -> {success} get_setting(name?) -> {settings} — omit name for all settings set_setting(name, value) -> {success} get_unique_id() -> {id} get_presets() -> {presets} set_preset(name) -> {success} refresh_luts() -> {success} get_gallery() -> {available} export_frame_as_still(path) -> {success} project_summary(include_clips?, clip_limit?) -> {project, current_page, timeline_count, current_timeline, media_pool} Live structural readout — what's in this project right now (no analysis needed). load_burnin_preset(name) -> {success} insert_audio(media_path, start_offset?, duration?) -> {success} get_color_groups() -> {groups} add_color_group(name) -> {success, name} delete_color_group(name) -> {success} apply_fairlight_preset(preset_name) -> {success} generate_speech(speech_generation_settings, timecode?) -> {success, new, new_id} — Resolve 21+, AI Speech Generator; creates new audio media (confirm-gated) """ p = params or {} _, proj, err = _check() if err: return err if action == "get_name": return {"name": proj.GetName()} elif action == "set_name": return {"success": bool(proj.SetName(p["name"]))} elif action == "get_setting": return {"settings": _ser(proj.GetSetting(p.get("name", "")))} elif action == "set_setting": return {"success": bool(proj.SetSetting(p["name"], p["value"]))} elif action == "get_unique_id": return {"id": proj.GetUniqueId()} elif action == "get_presets": return {"presets": _ser(proj.GetPresetList())} elif action == "set_preset": return {"success": bool(proj.SetPreset(p["name"]))} elif action == "refresh_luts": return {"success": bool(proj.RefreshLUTList())} elif action == "get_gallery": g = proj.GetGallery() return {"available": g is not None} elif action == "export_frame_as_still": err, clean = _validate_params(p, { "path": {"type": str, "required": True, "non_empty": True, "parent_dir_exists": True}, }) if err: return _err(err) return {"success": bool(proj.ExportCurrentFrameAsStill(clean["path"]))} elif action == "project_summary": return _project_summary( proj, include_clips=bool(p.get("include_clips")), clip_limit=int(p.get("clip_limit", 50)), ) elif action == "load_burnin_preset": return {"success": bool(proj.LoadBurnInPreset(p["name"]))} elif action == "insert_audio": return {"success": bool(proj.InsertAudioToCurrentTrackAtPlayhead( p["media_path"], p.get("start_offset", 0), p.get("duration", 0)))} elif action == "get_color_groups": groups = proj.GetColorGroupsList() return {"groups": [{"name": g.GetName()} for g in (groups or [])]} elif action == "add_color_group": g = proj.AddColorGroup(p["name"]) return _ok(name=g.GetName()) if g else _err("Failed to add color group") elif action == "delete_color_group": groups = proj.GetColorGroupsList() or [] for g in groups: if g.GetName() == p["name"]: return {"success": bool(proj.DeleteColorGroup(g))} return _err(f"Color group '{p['name']}' not found") elif action == "apply_fairlight_preset": missing = _requires_method(proj, "ApplyFairlightPresetToCurrentTimeline", "20.2.2") if missing: return missing return {"success": bool(proj.ApplyFairlightPresetToCurrentTimeline(p["preset_name"]))} elif action == "generate_speech": missing = _requires_method(proj, "GenerateSpeech", "21.0") if missing: return missing settings = _first_param(p, "speech_generation_settings", "speechGenerationSettings", "settings", default=None) or {} if not isinstance(settings, dict) or not settings.get("TextInput"): return _err("generate_speech requires speech_generation_settings with a 'TextInput' string. " "Optional keys: VoiceModel, CustomVoiceFile, Speed, Variation, Pitch, GenerationID, Filename, AddToTimeline, AudioTrack.") timecode = _first_param(p, "timecode", default="") or "" if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required(): return _issue_confirm_token( action="project_settings.generate_speech", params=p, preview={ "operation": "project_settings.generate_speech", "warning": "Generates a NEW AI text-to-speech audio item" + (" and adds it to the timeline." if settings.get("AddToTimeline") else "."), "text_input": settings.get("TextInput"), "voice_model": settings.get("VoiceModel"), "add_to_timeline": bool(settings.get("AddToTimeline")), "timecode": timecode, "governance": _ai_governance_check("generate_speech"), }, ) blocked = _consume_confirm_token(action="project_settings.generate_speech", params=p) if blocked: return blocked with _ai_ledger_timed("generate_speech") as _rec: new_item = proj.GenerateSpeech(settings, timecode) _rec.success = bool(new_item) if new_item: path, nbytes = _clip_file_size(new_item) _rec.output_path = path _rec.output_bytes = nbytes if not new_item: return {"success": False} return {"success": True, "new": new_item.GetName(), "new_id": new_item.GetUniqueId(), "output_path": _rec.output_path, "output_bytes": _rec.output_bytes} return _unknown(action, ["get_name","set_name","get_setting","set_setting","get_unique_id","get_presets","set_preset","refresh_luts","get_gallery","export_frame_as_still","project_summary","load_burnin_preset","insert_audio","get_color_groups","add_color_group","delete_color_group","apply_fairlight_preset","generate_speech"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 9: render # ═══════════════════════════════════════════════════════════════════════════════ _RENDER_METHODS = [ "AddRenderJob", "DeleteRenderJob", "DeleteAllRenderJobs", "GetRenderJobList", "GetRenderJobStatus", "StartRendering", "StopRendering", "IsRenderingInProgress", "GetRenderFormats", "GetRenderCodecs", "GetCurrentRenderFormatAndCodec", "SetCurrentRenderFormatAndCodec", "GetCurrentRenderMode", "SetCurrentRenderMode", "GetRenderResolutions", "GetRenderSettings", "SetRenderSettings", "GetRenderPresetList", "LoadRenderPreset", "SaveAsNewRenderPreset", "DeleteRenderPreset", "GetQuickExportRenderPresets", "RenderWithQuickExport", ] _RENDER_SETTING_KEYS = [ "SelectAllFrames", "MarkIn", "MarkOut", "TargetDir", "CustomName", "UniqueFilenameStyle", "ExportVideo", "ExportAudio", "FormatWidth", "FormatHeight", "FrameRate", "PixelAspectRatio", "VideoQuality", "AudioCodec", "AudioBitDepth", "AudioSampleRate", "ColorSpaceTag", "GammaTag", "ExportAlpha", "EncodingProfile", "MultiPassEncode", "AlphaMode", "NetworkOptimization", "ClipStartFrame", "TimelineStartTimecode", "ReplaceExistingFilesInPlace", "ExportSubtitle", "SubtitleFormat", ] _RENDER_KERNEL_ACTIONS = [ "render_capabilities", "probe_render_matrix", "probe_render_settings", "validate_render_settings", "safe_set_render_settings", "prepare_render_job", "render_job_lifecycle_probe", "quick_export_capabilities", "safe_quick_export", "export_render_boundary_report", ] def _render_temp_path_ok(path: str) -> bool: if not path: return False try: target = os.path.abspath(path) temp_roots = [ os.path.abspath(tempfile.gettempdir()), os.path.abspath("/private/tmp"), os.path.abspath("/tmp"), ] return any(os.path.commonpath([target, root]) == root for root in temp_roots) except ValueError: return False def _render_formats(proj): formats = proj.GetRenderFormats() or {} return _ser(formats) def _render_codecs(proj, fmt: str): try: return _ser(proj.GetRenderCodecs(fmt) or {}) except Exception as exc: return {"error": str(exc)} def _render_capabilities(proj): formats = _render_formats(proj) presets = _ser(proj.GetRenderPresetList() or []) quick_presets = [] if _has_method(proj, "GetQuickExportRenderPresets"): try: quick_presets = _ser(proj.GetQuickExportRenderPresets() or []) except Exception: quick_presets = [] return { "methods": _callable_method_names(proj, _RENDER_METHODS), "formats": formats, "format_count": len(formats) if isinstance(formats, dict) else 0, "presets": presets, "quick_export_presets": quick_presets, "supported_settings": list(_RENDER_SETTING_KEYS), "guards": { "safe_quick_export_requires_allow_render": True, "temp_target_required_for_lifecycle_probe": True, "upload_disabled_for_safe_quick_export": True, }, } def _probe_render_matrix(proj, p: Dict[str, Any]): formats = _render_formats(proj) if not isinstance(formats, dict): return _err("GetRenderFormats did not return a format dictionary") requested = p.get("formats") if requested is not None and not isinstance(requested, list): return _err("formats must be a list when provided") max_pairs = p.get("max_pairs") try: max_pairs = int(max_pairs) if max_pairs is not None else None except (TypeError, ValueError): return _err("max_pairs must be an integer") matrix = [] pair_count = 0 errors = [] for fmt, extension in formats.items(): if requested and fmt not in requested: continue codecs = _render_codecs(proj, fmt) format_row = {"format": fmt, "extension": extension, "codecs": [], "codec_count": 0} if isinstance(codecs, dict) and codecs.get("error"): format_row["error"] = codecs["error"] errors.append({"format": fmt, "error": codecs["error"]}) matrix.append(format_row) continue if isinstance(codecs, dict): format_row["codec_count"] = len(codecs) for label, codec in codecs.items(): if max_pairs is not None and pair_count >= max_pairs: break row = {"label": label, "codec": codec} try: row["resolutions"] = _ser(proj.GetRenderResolutions(fmt, codec) or []) row["resolution_count"] = len(row["resolutions"]) except Exception as exc: row["error"] = str(exc) errors.append({"format": fmt, "codec": codec, "error": str(exc)}) format_row["codecs"].append(row) pair_count += 1 matrix.append(format_row) if max_pairs is not None and pair_count >= max_pairs: break return { "formats": len(matrix), "format_total": len(formats), "pairs_probed": pair_count, "errors": errors, "matrix": matrix, } def _render_settings_snapshot(proj): if _has_method(proj, "GetRenderSettings"): settings = _ser(proj.GetRenderSettings()) else: settings = {"error": "GetRenderSettings unavailable"} return { "format_and_codec": _ser(proj.GetCurrentRenderFormatAndCodec()), "mode": _ser(proj.GetCurrentRenderMode()), "settings": settings, "jobs": _ser(proj.GetRenderJobList() or []), "is_rendering": bool(proj.IsRenderingInProgress()), } def _validate_render_settings_payload(settings: Dict[str, Any], *, require_temp_target: bool = False): if not isinstance(settings, dict) or not settings: return None, _err("settings must be a non-empty object") unknown = sorted(key for key in settings if key not in _RENDER_SETTING_KEYS) errors = [] target_dir = settings.get("TargetDir") if target_dir is not None: if not isinstance(target_dir, str) or not target_dir: errors.append("TargetDir must be a non-empty string") elif not os.path.isdir(target_dir): errors.append(f"TargetDir does not exist: {target_dir}") elif require_temp_target and not _render_temp_path_ok(target_dir): errors.append("TargetDir must be under the system temp directory for this safe operation") elif require_temp_target: errors.append("TargetDir is required for this safe operation") for key in ("FormatWidth", "FormatHeight", "MarkIn", "MarkOut", "AudioBitDepth", "AudioSampleRate"): if key in settings and not isinstance(settings[key], int): errors.append(f"{key} must be an integer") for key in ("SelectAllFrames", "ExportVideo", "ExportAudio", "ExportAlpha", "MultiPassEncode", "NetworkOptimization", "ReplaceExistingFilesInPlace", "ExportSubtitle"): if key in settings and not isinstance(settings[key], bool): errors.append(f"{key} must be a boolean") if "MarkIn" in settings and "MarkOut" in settings and settings["MarkOut"] < settings["MarkIn"]: errors.append("MarkOut must be greater than or equal to MarkIn") result = {"valid": not errors, "unknown_keys": unknown, "errors": errors, "settings": dict(settings)} return result, None def _validate_render_settings_action(p: Dict[str, Any]): validation, err = _validate_render_settings_payload( p.get("settings"), require_temp_target=bool(p.get("require_temp_target", False)), ) if err: return err return validation def _settings_diff(requested: Dict[str, Any], applied: Dict[str, Any]): if not isinstance(applied, dict): return {"matched": [], "coerced_or_missing": sorted(requested.keys())} matched = [] coerced = {} for key, value in requested.items(): if applied.get(key) == value: matched.append(key) else: coerced[key] = {"requested": value, "applied": applied.get(key)} return {"matched": sorted(matched), "coerced_or_missing": coerced} def _safe_set_render_settings(proj, p: Dict[str, Any]): settings = p.get("settings") validation, err = _validate_render_settings_payload( settings, require_temp_target=bool(p.get("require_temp_target", False)), ) if err: return err if not validation["valid"]: return {"success": False, "validation": validation} if p.get("dry_run"): return _ok(validation=validation) before = _render_settings_snapshot(proj) success = bool(proj.SetRenderSettings(settings)) after_settings = _ser(proj.GetRenderSettings()) if _has_method(proj, "GetRenderSettings") else {} result = { "success": success, "validation": validation, "before": before, "after": after_settings, "diff": _settings_diff(settings, after_settings), } if p.get("restore") and isinstance(before.get("settings"), dict): result["restore_success"] = bool(proj.SetRenderSettings(before["settings"])) return result def _prepare_render_job(proj, p: Dict[str, Any]): target_dir = p.get("target_dir") or (p.get("settings") or {}).get("TargetDir") if not target_dir: return _err("target_dir or settings.TargetDir is required") if not os.path.isdir(target_dir): return _err(f"target_dir does not exist: {target_dir}") if p.get("require_temp_target", True) and not _render_temp_path_ok(target_dir): return _err("target_dir must be under the system temp directory unless require_temp_target=False") settings = dict(p.get("settings") or {}) settings.setdefault("TargetDir", target_dir) if p.get("custom_name"): settings["CustomName"] = p["custom_name"] validation, err = _validate_render_settings_payload(settings, require_temp_target=p.get("require_temp_target", True)) if err: return err if not validation["valid"]: return {"success": False, "validation": validation} if p.get("dry_run"): return _ok(validation=validation, format=p.get("format"), codec=p.get("codec")) before = _render_settings_snapshot(proj) format_success = None if p.get("format") and p.get("codec"): format_success = bool(proj.SetCurrentRenderFormatAndCodec(p["format"], p["codec"])) settings_success = bool(proj.SetRenderSettings(settings)) job_id = proj.AddRenderJob() if settings_success else None return { "success": bool(job_id), "job_id": job_id, "format_success": format_success, "settings_success": settings_success, "before": before, "settings": settings, } def _render_job_lifecycle_probe(proj, p: Dict[str, Any]): prepared = _prepare_render_job(proj, {**p, "dry_run": False, "require_temp_target": True}) if prepared.get("error") or not prepared.get("success"): return prepared job_id = prepared["job_id"] status_before = _ser(proj.GetRenderJobStatus(job_id)) delete_success = bool(proj.DeleteRenderJob(job_id)) return { "success": delete_success, "job_id": job_id, "status_before_delete": status_before, "delete_success": delete_success, "prepared": prepared, } def _quick_export_capabilities(proj): presets = [] if _has_method(proj, "GetQuickExportRenderPresets"): presets = _ser(proj.GetQuickExportRenderPresets() or []) return { "presets": presets, "preset_count": len(presets) if isinstance(presets, list) else 0, "safe_params": ["TargetDir", "CustomName", "VideoQuality", "EnableUpload"], "guards": { "EnableUpload_forced_false": True, "allow_render_required": True, "temp_target_required": True, }, } def _safe_quick_export(proj, p: Dict[str, Any]): preset = p.get("preset") if not preset: return _err("preset is required") params = dict(p.get("params") or {}) target_dir = params.get("TargetDir") or p.get("target_dir") if target_dir: params["TargetDir"] = target_dir if p.get("custom_name"): params["CustomName"] = p["custom_name"] params["EnableUpload"] = False validation, err = _validate_render_settings_payload( {key: value for key, value in params.items() if key in _RENDER_SETTING_KEYS}, require_temp_target=bool(p.get("require_temp_target", True)), ) if err: return err if not validation["valid"]: return {"success": False, "validation": validation} if p.get("dry_run") or not p.get("allow_render", False): return _ok(would_render=False, preset=preset, params=params, validation=validation) status = _ser(proj.RenderWithQuickExport(preset, params)) return {"success": not (isinstance(status, dict) and status.get("error")), "status": status, "params": params} def _export_render_boundary_report(proj, p: Dict[str, Any]): report = { "capabilities": _render_capabilities(proj), "settings": _render_settings_snapshot(proj), } if p.get("include_matrix", True): report["matrix"] = _probe_render_matrix(proj, {"max_pairs": p.get("max_pairs")}) if p.get("include_quick_export", True): report["quick_export"] = _quick_export_capabilities(proj) return report @mcp.tool() def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Render pipeline: jobs, presets, formats, codecs, and rendering. Actions: add_job() -> {job_id} delete_job(job_id) -> {success} delete_all_jobs() -> {success} list_jobs() -> {jobs} get_job_status(job_id) -> {status} start(job_ids?, interactive?) -> {success} stop() -> {success} is_rendering() -> {rendering} get_formats() -> {formats} get_codecs(format) -> {codecs} get_format_and_codec() -> {format, codec} set_format_and_codec(format, codec) -> {success} get_mode() -> {mode} set_mode(mode) -> {success} get_resolutions(format, codec) -> {resolutions} get_settings() -> {settings} (alias for set_render_settings with get) set_settings(settings) -> {success} list_presets() -> {presets} load_preset(name) -> {success} save_preset(name) -> {success} delete_preset(name) -> {success} quick_export_presets() -> {presets} quick_export(preset, params?) -> {status} render_capabilities() -> {methods, formats, presets, quick_export_presets} probe_render_matrix(formats?, max_pairs?) -> {matrix, errors} probe_render_settings() -> {format_and_codec, mode, settings, jobs, is_rendering} validate_render_settings(settings, require_temp_target?) -> {valid, errors, unknown_keys} safe_set_render_settings(settings, dry_run?, restore?, require_temp_target?) -> {success, diff} prepare_render_job(target_dir, settings?, format?, codec?, custom_name?, dry_run?) -> {success, job_id} render_job_lifecycle_probe(target_dir, settings?, format?, codec?, custom_name?) -> {success, job_id, status_before_delete} quick_export_capabilities() -> {presets, safe_params, guards} safe_quick_export(preset, target_dir?|params?, custom_name?, dry_run?, allow_render?) -> {success, status} export_render_boundary_report(include_matrix?, max_pairs?, include_quick_export?) -> {capabilities, settings, matrix?} """ p = params or {} _, proj, err = _check() if err: return err if action == "add_job": jid = proj.AddRenderJob() return {"job_id": jid} if jid else _err("Failed to add render job") elif action == "delete_job": return {"success": bool(proj.DeleteRenderJob(p["job_id"]))} elif action == "delete_all_jobs": return {"success": bool(proj.DeleteAllRenderJobs())} elif action == "list_jobs": return {"jobs": _ser(proj.GetRenderJobList())} elif action == "get_job_status": return _ser(proj.GetRenderJobStatus(p["job_id"])) elif action == "start": job_ids = p.get("job_ids") interactive = p.get("interactive", False) if job_ids: return {"success": bool(proj.StartRendering(job_ids, interactive))} return {"success": bool(proj.StartRendering(interactive))} elif action == "stop": proj.StopRendering() return _ok() elif action == "is_rendering": return {"rendering": bool(proj.IsRenderingInProgress())} elif action == "get_formats": return {"formats": _ser(proj.GetRenderFormats())} elif action == "get_codecs": return {"codecs": _ser(proj.GetRenderCodecs(p["format"]))} elif action == "get_format_and_codec": return _ser(proj.GetCurrentRenderFormatAndCodec()) elif action == "set_format_and_codec": return {"success": bool(proj.SetCurrentRenderFormatAndCodec(p["format"], p["codec"]))} elif action == "get_mode": return {"mode": proj.GetCurrentRenderMode()} elif action == "set_mode": return {"success": bool(proj.SetCurrentRenderMode(p["mode"]))} elif action == "get_resolutions": return {"resolutions": _ser(proj.GetRenderResolutions(p["format"], p["codec"]))} elif action == "get_settings": missing = _requires_method(proj, "GetRenderSettings", "unknown") if missing: return missing return {"settings": _ser(proj.GetRenderSettings())} elif action == "set_settings": return {"success": bool(proj.SetRenderSettings(p["settings"]))} elif action == "list_presets": return {"presets": proj.GetRenderPresetList()} elif action == "load_preset": return {"success": bool(proj.LoadRenderPreset(p["name"]))} elif action == "save_preset": return {"success": bool(proj.SaveAsNewRenderPreset(p["name"]))} elif action == "delete_preset": return {"success": bool(proj.DeleteRenderPreset(p["name"]))} elif action == "quick_export_presets": return {"presets": proj.GetQuickExportRenderPresets()} elif action == "quick_export": return _ser(proj.RenderWithQuickExport(p["preset"], p.get("params", {}))) elif action == "render_capabilities": return _render_capabilities(proj) elif action == "probe_render_matrix": return _probe_render_matrix(proj, p) elif action == "probe_render_settings": return _render_settings_snapshot(proj) elif action == "validate_render_settings": return _validate_render_settings_action(p) elif action == "safe_set_render_settings": return _safe_set_render_settings(proj, p) elif action == "prepare_render_job": return _prepare_render_job(proj, p) elif action == "render_job_lifecycle_probe": return _render_job_lifecycle_probe(proj, p) elif action == "quick_export_capabilities": return _quick_export_capabilities(proj) elif action == "safe_quick_export": return _safe_quick_export(proj, p) elif action == "export_render_boundary_report": return _export_render_boundary_report(proj, p) return _unknown(action, ["add_job","delete_job","delete_all_jobs","list_jobs","get_job_status","start","stop","is_rendering","get_formats","get_codecs","get_format_and_codec","set_format_and_codec","get_mode","set_mode","get_resolutions","get_settings","set_settings","list_presets","load_preset","save_preset","delete_preset","quick_export_presets","quick_export",*_RENDER_KERNEL_ACTIONS]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 10: media_storage # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() def media_storage(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Browse storage volumes and import media into the Media Pool. Actions: get_volumes() -> {volumes} get_subfolders(path) -> {subfolders} get_files(path) -> {files} reveal(path) -> {success} import_to_pool(items) -> {imported} — simple: params.items is a list of absolute file/folder paths import_to_pool(item_infos) -> {imported} — positioned: params.item_infos is a list of {media, startFrame, endFrame} dicts per docs line 210. Mirrors MediaStorage.AddItemListToMediaPool([{itemInfo}, ...]). add_clip_mattes(clip_id, paths, stereo_eye?) -> {success} add_timeline_mattes(paths) -> {items} """ p = params or {} r = get_resolve() if r is None: return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.") ms = r.GetMediaStorage() if action == "get_volumes": return {"volumes": ms.GetMountedVolumeList()} elif action == "get_subfolders": return {"subfolders": ms.GetSubFolderList(p["path"])} elif action == "get_files": return {"files": ms.GetFileList(p["path"])} elif action == "reveal": return {"success": bool(ms.RevealInStorage(p["path"]))} elif action == "import_to_pool": if p.get("item_infos") is not None: raw = p["item_infos"] if not isinstance(raw, list) or not raw: return _err("item_infos must be a non-empty list") for i, info in enumerate(raw): if not isinstance(info, dict): return _err(f"item_infos[{i}] must be an object") if not info.get("media"): return _err(f"item_infos[{i}] requires media (file path)") result = ms.AddItemListToMediaPool(raw) else: items = p.get("items") if not items: return _err("Provide items (simple) or item_infos (positioned)") result = ms.AddItemListToMediaPool(items) return {"imported": len(result) if result else 0} elif action == "add_clip_mattes": _, proj, mp, err = _get_mp() if err: return err clip = _find_clip(mp.GetRootFolder(), p["clip_id"]) if not clip: return _err(f"Clip not found: {p['clip_id']}") eye = p.get("stereo_eye", "") return {"success": bool(ms.AddClipMattesToMediaPool(clip, p["paths"], eye))} elif action == "add_timeline_mattes": result = ms.AddTimelineMattesToMediaPool(p["paths"]) return {"items": len(result) if result else 0} return _unknown(action, ["get_volumes","get_subfolders","get_files","reveal","import_to_pool","add_clip_mattes","add_timeline_mattes"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 11: media_pool # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() @_destructive_op("media_pool") def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Manage the Media Pool: folders, clips, timelines, import/export. PREFER safe_* / probe_* / *_capabilities / *_boundary_report / *_checked variants where they exist. Raw mutators below do not validate paths, support dry_run, or normalize errors. Actions: get_root_folder() -> {name, id} get_current_folder() -> {name, id} set_current_folder(path) -> {success} — path like "Master/SubFolder" add_subfolder(name, parent_path?) -> {success, name, id} delete_folders(folder_ids) -> {success} DESTRUCTIVE. Deletes folders + every clip they contain. move_folders(folder_ids, target_path) -> {success} refresh() -> {success} create_timeline(name, if_exists?) -> {success, name, id} — if_exists: version (default), reuse, or fail create_timeline_from_clips(name, clip_ids, if_exists?) -> {success, name, id} — simple: params.clip_ids appends clips end-to-end into a new timeline create_timeline_from_clips(name, clip_infos, if_exists?) -> {success, name, id} — positioned: params.clip_infos is a list of {clip_id or media_pool_item_id, start_frame & end_frame (or startFrame/endFrame), record_frame/recordFrame}. record_frame is relative to the created timeline start frame by default; pass record_frame_mode="absolute" for raw Resolve recordFrame values. setup_multicam_timeline(name, clip_ids|angles, sync_mode?, include_audio?, dry_run?) -> {success} — creates a stacked multicam prep timeline: one angle per video track, optional matching audio tracks. Native multicam clip conversion remains a Resolve UI step. import_timeline(path, options?) -> {success, name} UNSAFE. No path sandboxing. Prefer timeline.import_timeline_checked. delete_timelines(timeline_ids) -> {success} CATASTROPHIC. Deletes timelines outright. append_to_timeline(clip_ids) -> {success, count} — legacy: params.clip_ids only (appends at end / default placement) append_to_timeline(clip_infos) -> {success, count, items} — positioned: params.clip_infos is a list of {clip_id or media_pool_item_id, start_frame & end_frame (or startFrame/endFrame), record_frame/recordFrame, track_index/trackIndex (1-based), optional media_type/mediaType (1=video, 2=audio)}. record_frame is relative to the current timeline start frame by default; pass record_frame_mode="absolute" for raw Resolve recordFrame values. Returns timeline_item_id per item. import_media(paths) -> {imported} UNSAFE. No dry_run. Prefer safe_import_media. — simple: params.paths is a list of file/folder paths import_media(clip_infos) -> {imported} UNSAFE. No dry_run. Prefer safe_import_sequence. — image sequences: params.clip_infos is a list of {FilePath, StartIndex, EndIndex} dicts (PascalCase keys per Resolve docs). Example: [{"FilePath": "frame_%03d.dpx", "StartIndex": 1, "EndIndex": 100}] delete_clips(clip_ids) -> {success} DESTRUCTIVE. Removes clips from the Media Pool (does not touch source files). move_clips(clip_ids, target_path) -> {success} relink(clip_ids, folder_path) -> {success} UNSAFE. No dry_run. Prefer safe_relink. unlink(clip_ids) -> {success} UNSAFE. No dry_run. Prefer safe_unlink. export_metadata(path, clip_ids?) -> {success} get_unique_id() -> {id} create_stereo_clip(left_id, right_id) -> {success, name} auto_sync_audio(clip_ids, settings?) -> {success} get_selected() -> {clips} set_selected(clip_id) -> {success} get_clip_mattes(clip_id) -> {mattes} get_timeline_mattes(folder_path?) -> {mattes} delete_clip_mattes(clip_id, paths) -> {success} import_folder(path, source_clips_path?) -> {success} ingest_capabilities() -> {supported, partially_supported, unsupported} probe_media_pool(depth?) -> {media_pool_id, methods, root, current_folder, selected_clips} probe_ingest_item(clip_ids? selected?) -> {items, count} safe_import_media(paths, target_folder?, dry_run?) -> {success, imported, clips} safe_import_sequence(FilePath|file_path|pattern, StartIndex?, EndIndex?, target_folder?, dry_run?) -> {success, imported, clips} safe_import_folder(path, source_clips_path?, dry_run?) -> {success} organize_clips(clip_ids|selected, target_path, create_missing?, dry_run?) -> {success} copy_metadata(source_clip_id, target_clip_ids, keys?, include_third_party?, dry_run?) -> {success, results} normalize_metadata(clip_ids|selected, metadata?, third_party_metadata?, dry_run?) -> {success, results} probe_clip_properties(clip_ids|selected) -> {items, count} metadata_field_inventory(clip_ids|selected, include_values?) -> {items, ui_group_names} safe_relink(clip_ids|selected, folder_path, dry_run?) -> {success} safe_unlink(clip_ids|selected, dry_run?) -> {success} link_proxy_checked(clip_id, proxy_path|path, dry_run?) -> {success} link_full_resolution_checked(clip_id, path|full_res_media_path, dry_run?) -> {success} set_clip_marks(clip_ids|selected, mark_in, mark_out, type?, dry_run?) -> {success, results} clear_clip_marks(clip_ids|selected, type?, dry_run?) -> {success, results} copy_clip_annotations(source_clip_id, target_clip_ids, include_markers?, include_flags?, include_clip_color?, dry_run?) -> {success, results} media_pool_boundary_report(depth?, clip_ids?, selected?) -> {capabilities, media_pool, items?} """ p = params or {} _, proj, mp, err = _get_mp() if err: return err root = mp.GetRootFolder() if action == "get_root_folder": return {"name": root.GetName(), "id": root.GetUniqueId()} elif action == "get_current_folder": f = mp.GetCurrentFolder() return {"name": f.GetName(), "id": f.GetUniqueId()} if f else _err("No current folder") elif action == "set_current_folder": f = _navigate_folder(mp, p.get("path", "")) if not f: return _err(f"Folder not found: {p.get('path')}") return {"success": bool(mp.SetCurrentFolder(f))} elif action == "add_subfolder": parent = _navigate_folder(mp, p.get("parent_path", "")) or mp.GetCurrentFolder() f = mp.AddSubFolder(parent, p["name"]) return _ok(name=f.GetName(), id=f.GetUniqueId()) if f else _err("Failed to create subfolder") elif action == "delete_folders": folders = [] for fid in p["folder_ids"]: # Search for folder by ID (simplified - searches root subfolders) for sub in (root.GetSubFolderList() or []): if sub.GetUniqueId() == fid: folders.append(sub) return {"success": bool(mp.DeleteFolders(folders))} if folders else _err("No folders found") elif action == "move_folders": target = _navigate_folder(mp, p["target_path"]) if not target: return _err(f"Target folder not found: {p['target_path']}") folders = [] for fid in p["folder_ids"]: for sub in (root.GetSubFolderList() or []): if sub.GetUniqueId() == fid: folders.append(sub) return {"success": bool(mp.MoveFolders(folders, target))} elif action == "refresh": return {"success": bool(mp.RefreshFolders())} elif action == "create_timeline": create_name, existing, policy_result = _resolve_timeline_create_policy(proj, p) if policy_result: return policy_result tl = mp.CreateEmptyTimeline(create_name) return _ok( name=tl.GetName(), id=tl.GetUniqueId(), requested_name=p.get("name"), created_new=True, versioned_name=bool(existing and create_name != p.get("name")), ) if tl else _err("Failed to create timeline") elif action == "create_timeline_from_clips": create_name, existing, policy_result = _resolve_timeline_create_policy(proj, p) if policy_result: return policy_result if p.get("clip_infos") is not None: raw = p["clip_infos"] if not isinstance(raw, list): return _err("clip_infos must be a list") if not raw: return _err("clip_infos must be a non-empty list") for i, ci in enumerate(raw): _, row_err = _build_create_clip_info_dict(root, ci, i) if row_err: return row_err tl = mp.CreateEmptyTimeline(create_name) if not tl: return _err("Failed to create timeline from clip_infos") try: proj.SetCurrentTimeline(tl) except Exception: pass timeline_start = _timeline_start_frame(tl) built = [] for i, ci in enumerate(raw): append_ci = dict(ci) append_ci.setdefault("track_index", append_ci.get("trackIndex", 1)) row, row_err = _build_append_clip_info_dict(root, append_ci, i, timeline_start) if row_err: return row_err built.append(row) appended = mp.AppendToTimeline(built) if not appended: return _err("Failed to append clip_infos to created timeline") return _ok( name=tl.GetName(), id=tl.GetUniqueId(), requested_name=p.get("name"), created_new=True, versioned_name=bool(existing and create_name != p.get("name")), ) clip_ids = p.get("clip_ids") if not clip_ids: return _err("Provide clip_ids (simple) or clip_infos (positioned)") clips = [_find_clip(root, cid) for cid in clip_ids] clips = [c for c in clips if c] if not clips: return _err("No valid clips found") tl = mp.CreateTimelineFromClips(create_name, clips) return _ok( name=tl.GetName(), id=tl.GetUniqueId(), requested_name=p.get("name"), created_new=True, versioned_name=bool(existing and create_name != p.get("name")), ) if tl else _err("Failed to create timeline") elif action == "setup_multicam_timeline": return _setup_multicam_timeline(proj, mp, p) elif action == "import_timeline": tl = mp.ImportTimelineFromFile(p["path"], p.get("options", {})) return _ok(name=tl.GetName()) if tl else _err("Failed to import timeline") elif action == "delete_timelines": count = proj.GetTimelineCount() timelines = [] for i in range(1, count + 1): tl = proj.GetTimelineByIndex(i) if tl and tl.GetUniqueId() in p["timeline_ids"]: timelines.append(tl) return {"success": bool(mp.DeleteTimelines(timelines))} if timelines else _err("No timelines found") elif action == "append_to_timeline": if p.get("clip_infos") is not None: raw = p["clip_infos"] if not isinstance(raw, list): return _err("clip_infos must be a list") if not raw: return _err("clip_infos must be a non-empty list") timeline_start = _timeline_start_frame(proj.GetCurrentTimeline()) built = [] for i, ci in enumerate(raw): row, row_err = _build_append_clip_info_dict(root, ci, i, timeline_start) if row_err: return row_err built.append(row) result = mp.AppendToTimeline(built) if not result: return _err("Failed to append clip_infos to timeline") items_out = [] for i, item in enumerate(result): item_out, item_err = _serialize_appended_timeline_item(item, i) if item_err: return item_err items_out.append(item_out) return _ok(count=len(result), items=items_out) clip_ids = p.get("clip_ids") if not clip_ids: return _err("Provide clip_ids (simple append) or clip_infos (positioned append)") clips = [_find_clip(root, cid) for cid in clip_ids] clips = [c for c in clips if c] result = mp.AppendToTimeline(clips) return _ok(count=len(result) if result else 0) elif action == "import_media": if p.get("clip_infos") is not None: raw = p["clip_infos"] if not isinstance(raw, list) or not raw: return _err("clip_infos must be a non-empty list") for i, ci in enumerate(raw): if not isinstance(ci, dict): return _err(f"clip_infos[{i}] must be an object") if not ci.get("FilePath"): return _err(f"clip_infos[{i}] requires FilePath") result = mp.ImportMedia(raw) else: paths = p.get("paths") if not paths: return _err("Provide paths (simple) or clip_infos (image sequences)") result = mp.ImportMedia(paths) return {"imported": len(result) if result else 0} elif action == "delete_clips": clips = [_find_clip(root, cid) for cid in p["clip_ids"]] clips = [c for c in clips if c] return {"success": bool(mp.DeleteClips(clips))} if clips else _err("No clips found") elif action == "move_clips": target = _navigate_folder(mp, p["target_path"]) if not target: return _err(f"Target folder not found: {p['target_path']}") clips = [_find_clip(root, cid) for cid in p["clip_ids"]] clips = [c for c in clips if c] return {"success": bool(mp.MoveClips(clips, target))} elif action == "relink": clips = [_find_clip(root, cid) for cid in p["clip_ids"]] clips = [c for c in clips if c] return {"success": bool(mp.RelinkClips(clips, p["folder_path"]))} elif action == "unlink": clips = [_find_clip(root, cid) for cid in p["clip_ids"]] clips = [c for c in clips if c] return {"success": bool(mp.UnlinkClips(clips))} elif action == "export_metadata": clip_ids = p.get("clip_ids") if clip_ids: clips = [_find_clip(root, cid) for cid in clip_ids] clips = [c for c in clips if c] return {"success": bool(mp.ExportMetadata(p["path"], clips))} return {"success": bool(mp.ExportMetadata(p["path"]))} elif action == "get_unique_id": return {"id": mp.GetUniqueId()} elif action == "create_stereo_clip": left = _find_clip(root, p["left_id"]) right = _find_clip(root, p["right_id"]) if not left or not right: return _err("Left or right clip not found") result = mp.CreateStereoClip(left, right) return _ok(name=result.GetName()) if result else _err("Failed to create stereo clip") elif action == "auto_sync_audio": clips = [_find_clip(root, cid) for cid in p["clip_ids"]] clips = [c for c in clips if c] return {"success": bool(mp.AutoSyncAudio(clips, p.get("settings", {})))} elif action == "get_selected": sel = mp.GetSelectedClips() if not sel: return {"clips": []} return {"clips": [{"name": c.GetName(), "id": c.GetUniqueId()} for c in sel]} elif action == "set_selected": clip = _find_clip(root, p["clip_id"]) return {"success": bool(mp.SetSelectedClip(clip))} if clip else _err("Clip not found") elif action == "get_clip_mattes": clip = _find_clip(root, p["clip_id"]) return {"mattes": mp.GetClipMatteList(clip)} if clip else _err("Clip not found") elif action == "get_timeline_mattes": folder = _navigate_folder(mp, p.get("folder_path", "")) or mp.GetCurrentFolder() result = mp.GetTimelineMatteList(folder) return {"mattes": len(result) if result else 0} elif action == "delete_clip_mattes": clip = _find_clip(root, p["clip_id"]) if not clip: return _err("Clip not found") return {"success": bool(mp.DeleteClipMattes(clip, p["paths"]))} elif action == "import_folder": return {"success": bool(mp.ImportFolderFromFile(p["path"], p.get("source_clips_path", "")))} elif action == "ingest_capabilities": return _media_pool_ingest_capabilities() elif action == "probe_media_pool": return _media_pool_probe(mp, p) elif action == "probe_ingest_item": return _media_pool_probe_ingest_items(mp, p) elif action == "safe_import_media": return _safe_import_media(mp, p) elif action == "safe_import_sequence": return _safe_import_sequence(mp, p) elif action == "safe_import_folder": return _safe_import_folder(mp, p) elif action == "organize_clips": return _organize_clips(mp, root, p) elif action == "copy_metadata": return _copy_metadata(root, p) elif action == "normalize_metadata": return _normalize_metadata(root, mp, p) elif action == "probe_clip_properties": return _probe_clip_properties(root, mp, p) elif action == "metadata_field_inventory": return _metadata_field_inventory(root, mp, p) elif action == "safe_relink": return _safe_relink(mp, root, p) elif action == "safe_unlink": return _safe_unlink(mp, root, p) elif action == "link_proxy_checked": return _link_proxy_checked(root, p) elif action == "link_full_resolution_checked": return _link_full_resolution_checked(root, p) elif action == "set_clip_marks": return _set_clip_marks(root, mp, p) elif action == "clear_clip_marks": return _clear_clip_marks(root, mp, p) elif action == "copy_clip_annotations": return _copy_clip_annotations(root, p) elif action == "media_pool_boundary_report": return _media_pool_boundary_report(mp, p) return _unknown(action, ["get_root_folder","get_current_folder","set_current_folder","add_subfolder","delete_folders","move_folders","refresh","create_timeline","create_timeline_from_clips","import_timeline","delete_timelines","append_to_timeline","import_media","delete_clips","move_clips","relink","unlink","export_metadata","get_unique_id","create_stereo_clip","auto_sync_audio","get_selected","set_selected","get_clip_mattes","get_timeline_mattes","delete_clip_mattes","import_folder",*_MEDIA_POOL_KERNEL_ACTIONS]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 12: folder # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Operations on Media Pool folders. Actions: get_clips(path?) -> {clips} — path like "Master/SubFolder", omit for current get_name(path?) -> {name} get_subfolders(path?) -> {subfolders} is_stale(path?) -> {stale} get_unique_id(path?) -> {id} export(path?, export_path) -> {success} transcribe_audio(path?, use_speaker_detection?) -> {success} — use_speaker_detection is Resolve 21+ clear_transcription(path?) -> {success} perform_audio_classification(path?) -> {success} — Resolve 21+ clear_audio_classification(path?) -> {success} — Resolve 21+ analyze_for_intellisearch(path?, identify_faces?, is_better_mode?) -> {success} — Resolve 21+, AI IntelliSearch Extra analyze_for_slate(path?, marker_color?) -> {success} — Resolve 21+, AI Slate ID Extra remove_motion_blur(path?, deblur_option?) -> {success, created} — Resolve 21+; renders NEW media (confirm-gated) """ p = params or {} _, _, mp, err = _get_mp() if err: return err folder_path = p.get("path", "") f = _navigate_folder(mp, folder_path) if folder_path else mp.GetCurrentFolder() if not f: return _err(f"Folder not found: {folder_path}") if action == "get_clips": clips = f.GetClipList() or [] return {"clips": [{"name": c.GetName(), "id": c.GetUniqueId()} for c in clips]} elif action == "get_name": return {"name": f.GetName()} elif action == "get_subfolders": subs = f.GetSubFolderList() or [] return {"subfolders": [{"name": s.GetName(), "id": s.GetUniqueId()} for s in subs]} elif action == "is_stale": return {"stale": bool(f.GetIsFolderStale())} elif action == "get_unique_id": return {"id": f.GetUniqueId()} elif action == "export": return {"success": bool(f.Export(p["export_path"]))} elif action == "transcribe_audio": usd = _first_param(p, "use_speaker_detection", "useSpeakerDetection") if usd is None: return {"success": bool(f.TranscribeAudio())} return {"success": bool(f.TranscribeAudio(bool(usd)))} elif action == "clear_transcription": return {"success": bool(f.ClearTranscription())} elif action == "perform_audio_classification": missing = _requires_method(f, "PerformAudioClassification", "21.0") if missing: return missing with _ai_ledger_timed("perform_audio_classification") as _rec: ok = bool(f.PerformAudioClassification()) _rec.success = ok return {"success": ok} elif action == "clear_audio_classification": missing = _requires_method(f, "ClearAudioClassification", "21.0") if missing: return missing with _ai_ledger_timed("clear_audio_classification") as _rec: ok = bool(f.ClearAudioClassification()) _rec.success = ok return {"success": ok} elif action == "analyze_for_intellisearch": missing = _requires_method(f, "AnalyzeForIntellisearch", "21.0") if missing: return missing identify_faces = bool(_first_param(p, "identify_faces", "identifyFaces", default=False)) is_better_mode = bool(_first_param(p, "is_better_mode", "isBetterMode", default=False)) with _ai_ledger_timed("analyze_for_intellisearch") as _rec: ok = bool(f.AnalyzeForIntellisearch(identify_faces, is_better_mode)) _rec.success = ok return {"success": ok} elif action == "analyze_for_slate": missing = _requires_method(f, "AnalyzeForSlate", "21.0") if missing: return missing marker_color = _first_param(p, "marker_color", "markerColor", default="Blue") if marker_color not in _MARKER_COLORS: return _err(f"Invalid marker_color {marker_color!r}. Valid colors: {', '.join(_MARKER_COLORS)}") with _ai_ledger_timed("analyze_for_slate") as _rec: ok = bool(f.AnalyzeForSlate(marker_color)) _rec.success = ok return {"success": ok} elif action == "remove_motion_blur": missing = _requires_method(f, "RemoveMotionBlur", "21.0") if missing: return missing deblur = _first_param(p, "deblur_option", "deblurOption", default=None) or {} if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required(): return _issue_confirm_token( action="folder.remove_motion_blur", params=p, preview={ "operation": "folder.remove_motion_blur", "warning": "Renders NEW deblurred media files for clips in the folder; source media is not modified.", "folder": f.GetName(), "deblur_option": deblur, "governance": _ai_governance_check("remove_motion_blur"), }, ) blocked = _consume_confirm_token(action="folder.remove_motion_blur", params=p) if blocked: return blocked with _ai_ledger_timed("remove_motion_blur") as _rec: result = f.RemoveMotionBlur(deblur) _rec.success = bool(result) created = [] total_bytes = 0 for pair in (result or []): try: orig, new = pair path, nbytes = _clip_file_size(new) if nbytes: total_bytes += nbytes created.append({"original": orig.GetName(), "new": new.GetName(), "new_id": new.GetUniqueId(), "output_path": path, "output_bytes": nbytes}) except Exception: continue # Folder deblur creates many files; record the first path + summed bytes. if created: _rec.output_path = created[0].get("output_path") _rec.output_bytes = total_bytes or None return {"success": bool(result), "created": created} return _unknown(action, ["get_clips","get_name","get_subfolders","is_stale","get_unique_id","export","transcribe_audio","clear_transcription","perform_audio_classification","clear_audio_classification","analyze_for_intellisearch","analyze_for_slate","remove_motion_blur"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 13: media_pool_item # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Operations on a media pool clip. Identify clip by clip_id. Actions: get_name(clip_id) -> {name} get_metadata(clip_id, key?) -> {metadata} set_metadata(clip_id, key, value) OR set_metadata(clip_id, metadata) -> {success} get_third_party_metadata(clip_id, key?) -> {metadata} set_third_party_metadata(clip_id, key, value) -> {success} get_media_id(clip_id) -> {media_id} get_clip_property(clip_id, key?) -> {properties} set_clip_property(clip_id, key, value) -> {success} get_clip_color(clip_id) -> {color} set_clip_color(clip_id, color) -> {success} clear_clip_color(clip_id) -> {success} link_proxy(clip_id, proxy_path) -> {success} unlink_proxy(clip_id) -> {success} replace_clip(clip_id, path) -> {success} set_name(clip_id, name) -> {success} link_full_resolution_media(clip_id, path) -> {success} monitor_growing_file(clip_id) -> {success} replace_clip_preserve_sub_clip(clip_id, path) -> {success} get_unique_id(clip_id) -> {id} transcribe_audio(clip_id, use_speaker_detection?) -> {success} — use_speaker_detection is Resolve 21+ clear_transcription(clip_id) -> {success} get_transcription(clip_id) -> {text, truncated, status, has_transcription} Read a clip's transcription. `truncated` flags when Resolve's preview property cut the text off (the full transcript is longer). extract_frames(clip_id, timestamps, output_dir?) -> {frame_paths, output_dir, count, errors} Extract still JPEGs from the clip's source at the given timestamps (seconds) via ffmpeg. Source-safe: reads source, writes only to a scratch dir. perform_audio_classification(clip_id) -> {success} — Resolve 21+ clear_audio_classification(clip_id) -> {success} — Resolve 21+ analyze_for_intellisearch(clip_id, identify_faces?, is_better_mode?) -> {success} — Resolve 21+, AI IntelliSearch Extra analyze_for_slate(clip_id, marker_color?) -> {success} — Resolve 21+, AI Slate ID Extra remove_motion_blur(clip_id, deblur_option?) -> {success, new, new_id} — Resolve 21+; renders NEW media (confirm-gated) get_audio_mapping(clip_id) -> {mapping} get_mark_in_out(clip_id) -> {mark} set_mark_in_out(clip_id, mark_in, mark_out, type?) -> {success} clear_mark_in_out(clip_id, type?) -> {success} open_in_viewer(clip_id, page?, mark_in_seconds?, mark_out_seconds?, clear_marks?) -> {success, clip_id, clip_name, folder_name, page, mark_set} — Switches to Media page (default) and selects the clip in the bin. Resolve auto-loads the selected clip into the source viewer on Media page. Optional: pre-set Mark In/Out on the clip so a shot's time range is highlighted when it loads. NOTE: Source-viewer auto-load is undocumented in the Scripting API; verified empirically on Resolve Studio 20.3.2.9. If BMD changes the behavior the clip will still be selected — editor double-clicks to load. """ p = params or {} _, _, mp, err = _get_mp() if err: return err # open_in_viewer needs (clip, parent_folder) to set the bin to the right folder. # Other actions just need the clip. if action == "open_in_viewer": clip, parent = _find_clip_with_parent(mp.GetRootFolder(), p.get("clip_id", "")) if not clip: return _err(f"Clip not found: {p.get('clip_id')}") page = (p.get("page") or "media").strip().lower() if page not in ("media", "cut", "edit"): return _err(f"Unsupported page for open_in_viewer: {page!r}. Use 'media' (recommended), 'cut', or 'edit'.") resolve_obj = get_resolve() if resolve_obj is None: return _err("Not connected to DaVinci Resolve. Is Resolve running?") if not resolve_obj.OpenPage(page): return _err(f"Failed to switch to {page} page") # V2 P14+B4: Optional mark in/out so a shot's time range is pre-highlighted # when the clip loads in source viewer. Caller passes seconds; we convert # to frames using the clip's fps from properties. mark_set: Optional[Dict[str, Any]] = None if p.get("clear_marks") or p.get("clearMarks"): try: clip.ClearMarkInOut(p.get("mark_type") or "all") mark_set = {"cleared": True} except Exception as exc: mark_set = {"cleared": False, "error": str(exc)} else: mark_in_s = p.get("mark_in_seconds") if p.get("mark_in_seconds") is not None else p.get("markInSeconds") mark_out_s = p.get("mark_out_seconds") if p.get("mark_out_seconds") is not None else p.get("markOutSeconds") if mark_in_s is not None or mark_out_s is not None: # Resolve fps from clip properties; fall back to 24 if not readable. try: fps_str = clip.GetClipProperty("FPS") or clip.GetClipProperty("Frame Rate") or "24" fps = float(str(fps_str).split()[0]) if fps <= 0: fps = 24.0 except Exception: fps = 24.0 in_frame = int(round(float(mark_in_s) * fps)) if mark_in_s is not None else 0 out_frame = int(round(float(mark_out_s) * fps)) if mark_out_s is not None else None if out_frame is None: # No out → caller wants only an "in" mark; use clip end. try: existing = clip.GetMarkInOut() or {} video = existing.get("video") or {} out_frame = int(video.get("out") or (in_frame + 1)) except Exception: out_frame = in_frame + 1 if out_frame <= in_frame: out_frame = in_frame + 1 try: mark_ok = bool(clip.SetMarkInOut(in_frame, out_frame, p.get("mark_type") or "all")) mark_set = { "applied": mark_ok, "in_frame": in_frame, "out_frame": out_frame, "fps_used": fps, "mark_in_seconds": mark_in_s, "mark_out_seconds": mark_out_s, } except Exception as exc: mark_set = {"applied": False, "error": str(exc)} # Navigate the bin to the clip's folder so the clip is visible to select. if parent is not None: try: mp.SetCurrentFolder(parent) except Exception: pass # not fatal; SetSelectedClip below may still work select_ok = bool(mp.SetSelectedClip(clip)) # Bring Resolve to the foreground so the editor doesn't have to # alt-tab. Default on; pass focus_app=false to suppress. focus_app = p.get("focus_app", p.get("focusApp", True)) focus_result: Optional[Dict[str, Any]] = None if focus_app: focus_result = _activate_resolve_window() # Jump the source viewer playhead to Mark In via Shift+I keystroke. # Resolve's scripting API has no direct "set source viewer playhead" # call, so we send the keyboard equivalent of "Go to In" once the # app is in the foreground. Default on when a mark was set. jump_result: Optional[Dict[str, Any]] = None wants_jump = p.get("jump_to_mark_in", p.get("jumpToMarkIn", True)) applied_mark = bool(mark_set and mark_set.get("applied")) if wants_jump and applied_mark and focus_result and focus_result.get("activated"): jump_result = _send_resolve_keystroke_go_to_mark_in() return { "success": select_ok, "clip_id": clip.GetUniqueId(), "clip_name": clip.GetName(), "folder_name": parent.GetName() if parent else None, "page": page, "mark_set": mark_set, "focus": focus_result, "playhead_jump": jump_result, "note": ( "Clip selected in bin. On Media page the source viewer auto-loads " "the selection. On Cut/Edit pages the clip is selected but the " "source viewer may not auto-load — call again with page='media' or " "double-click in Resolve." ) if page != "media" else ( "Clip selected and auto-loaded in Media page source viewer." + (" Mark In/Out set." if mark_set and mark_set.get("applied") else "") + (" Resolve activated." if focus_result and focus_result.get("activated") else "") + (" Playhead jumped to Mark In." if jump_result and jump_result.get("sent") else "") ), } clip = _find_clip(mp.GetRootFolder(), p.get("clip_id", "")) if not clip: return _err(f"Clip not found: {p.get('clip_id')}") if action == "get_name": return {"name": clip.GetName()} elif action == "get_metadata": return {"metadata": _ser(clip.GetMetadata(p.get("key", "")))} elif action == "set_metadata": if "metadata" in p: return {"success": bool(clip.SetMetadata(p["metadata"]))} return {"success": bool(clip.SetMetadata(p["key"], p["value"]))} elif action == "get_third_party_metadata": return {"metadata": _ser(clip.GetThirdPartyMetadata(p.get("key", "")))} elif action == "set_third_party_metadata": return {"success": bool(clip.SetThirdPartyMetadata(p["key"], p["value"]))} elif action == "get_media_id": return {"media_id": clip.GetMediaId()} elif action == "get_clip_property": return {"properties": _ser(clip.GetClipProperty(p.get("key", "")))} elif action == "set_clip_property": return {"success": bool(clip.SetClipProperty(p["key"], p["value"]))} elif action == "get_clip_color": return {"color": clip.GetClipColor()} elif action == "set_clip_color": return {"success": bool(clip.SetClipColor(p["color"]))} elif action == "clear_clip_color": return {"success": bool(clip.ClearClipColor())} elif action == "link_proxy": return {"success": bool(clip.LinkProxyMedia(p["proxy_path"]))} elif action == "unlink_proxy": return {"success": bool(clip.UnlinkProxyMedia())} elif action == "replace_clip": return {"success": bool(clip.ReplaceClip(p["path"]))} elif action == "set_name": missing = _requires_method(clip, "SetName", "20.2") if missing: return missing return {"success": bool(clip.SetName(p["name"]))} elif action == "link_full_resolution_media": missing = _requires_method(clip, "LinkFullResolutionMedia", "20.0") if missing: return missing full_res_path = p.get("path") or p.get("full_res_media_path") or p.get("fullResMediaPath") if not full_res_path: return _err("Provide path or full_res_media_path") return {"success": bool(clip.LinkFullResolutionMedia(full_res_path))} elif action == "monitor_growing_file": missing = _requires_method(clip, "MonitorGrowingFile", "20.0") if missing: return missing return {"success": bool(clip.MonitorGrowingFile())} elif action == "replace_clip_preserve_sub_clip": missing = _requires_method(clip, "ReplaceClipPreserveSubClip", "20.0") if missing: return missing replacement_path = p.get("path") or p.get("file_path") or p.get("filePath") if not replacement_path: return _err("Provide path or file_path") return {"success": bool(clip.ReplaceClipPreserveSubClip(replacement_path))} elif action == "get_unique_id": return {"id": clip.GetUniqueId()} elif action == "transcribe_audio": usd = _first_param(p, "use_speaker_detection", "useSpeakerDetection") if usd is None: return {"success": bool(clip.TranscribeAudio())} return {"success": bool(clip.TranscribeAudio(bool(usd)))} elif action == "clear_transcription": return {"success": bool(clip.ClearTranscription())} elif action == "get_transcription": raw = clip.GetClipProperty("Transcription") text = raw if isinstance(raw, str) else ("" if raw is None else str(raw)) try: status = clip.GetClipProperty("Transcription Status") except Exception: status = None return { "clip_id": p.get("clip_id"), "text": text, "truncated": _is_truncated(text), "status": status or None, "has_transcription": bool(text.strip()), } elif action == "extract_frames": return _extract_clip_frames(clip, p) elif action == "perform_audio_classification": missing = _requires_method(clip, "PerformAudioClassification", "21.0") if missing: return missing with _ai_ledger_timed("perform_audio_classification", clip_id=p.get("clip_id")) as _rec: ok = bool(clip.PerformAudioClassification()) _rec.success = ok return {"success": ok} elif action == "clear_audio_classification": missing = _requires_method(clip, "ClearAudioClassification", "21.0") if missing: return missing with _ai_ledger_timed("clear_audio_classification", clip_id=p.get("clip_id")) as _rec: ok = bool(clip.ClearAudioClassification()) _rec.success = ok return {"success": ok} elif action == "analyze_for_intellisearch": missing = _requires_method(clip, "AnalyzeForIntellisearch", "21.0") if missing: return missing identify_faces = bool(_first_param(p, "identify_faces", "identifyFaces", default=False)) is_better_mode = bool(_first_param(p, "is_better_mode", "isBetterMode", default=False)) with _ai_ledger_timed("analyze_for_intellisearch", clip_id=p.get("clip_id")) as _rec: ok = bool(clip.AnalyzeForIntellisearch(identify_faces, is_better_mode)) _rec.success = ok return {"success": ok} elif action == "analyze_for_slate": missing = _requires_method(clip, "AnalyzeForSlate", "21.0") if missing: return missing marker_color = _first_param(p, "marker_color", "markerColor", default="Blue") if marker_color not in _MARKER_COLORS: return _err(f"Invalid marker_color {marker_color!r}. Valid colors: {', '.join(_MARKER_COLORS)}") with _ai_ledger_timed("analyze_for_slate", clip_id=p.get("clip_id")) as _rec: ok = bool(clip.AnalyzeForSlate(marker_color)) _rec.success = ok return {"success": ok} elif action == "remove_motion_blur": missing = _requires_method(clip, "RemoveMotionBlur", "21.0") if missing: return missing deblur = _first_param(p, "deblur_option", "deblurOption", default=None) or {} if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required(): return _issue_confirm_token( action="media_pool_item.remove_motion_blur", params=p, preview={ "operation": "media_pool_item.remove_motion_blur", "warning": "Renders a NEW deblurred media file; the source clip is not modified.", "clip": clip.GetName(), "deblur_option": deblur, "governance": _ai_governance_check("remove_motion_blur"), }, ) blocked = _consume_confirm_token(action="media_pool_item.remove_motion_blur", params=p) if blocked: return blocked with _ai_ledger_timed("remove_motion_blur", clip_id=p.get("clip_id")) as _rec: new_clip = clip.RemoveMotionBlur(deblur) _rec.success = bool(new_clip) if new_clip: path, nbytes = _clip_file_size(new_clip) _rec.output_path = path _rec.output_bytes = nbytes if not new_clip: return {"success": False} return {"success": True, "new": new_clip.GetName(), "new_id": new_clip.GetUniqueId(), "output_path": _rec.output_path, "output_bytes": _rec.output_bytes} elif action == "get_audio_mapping": return {"mapping": clip.GetAudioMapping()} elif action == "get_mark_in_out": return _ser(clip.GetMarkInOut()) elif action == "set_mark_in_out": err, clean = _validate_params( p, {"mark_in": {"type": int, "required": True}, "mark_out": {"type": int, "required": True}}, invariants=[lambda c: f"mark_in ({c['mark_in']}) must be <= mark_out ({c['mark_out']})" if c["mark_in"] > c["mark_out"] else None], ) if err: return _err(err) return {"success": bool(clip.SetMarkInOut(clean["mark_in"], clean["mark_out"], p.get("type", "all")))} elif action == "clear_mark_in_out": return {"success": bool(clip.ClearMarkInOut(p.get("type", "all")))} return _unknown(action, ["get_name","get_metadata","set_metadata","get_third_party_metadata","set_third_party_metadata","get_media_id","get_clip_property","set_clip_property","get_clip_color","set_clip_color","clear_clip_color","link_proxy","unlink_proxy","replace_clip","set_name","link_full_resolution_media","monitor_growing_file","replace_clip_preserve_sub_clip","get_unique_id","transcribe_audio","clear_transcription","get_transcription","extract_frames","perform_audio_classification","clear_audio_classification","analyze_for_intellisearch","analyze_for_slate","remove_motion_blur","get_audio_mapping","get_mark_in_out","set_mark_in_out","clear_mark_in_out","open_in_viewer"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 14: media_pool_item_markers # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() def media_pool_item_markers(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Markers and flags on media pool clips. Identify clip by clip_id. Actions: add(clip_id, frame|frame_id|frameId, color?, name?, note?, duration?, custom_data?) -> {success, frame} get_all(clip_id) -> {markers} get_by_custom_data(clip_id, custom_data) -> {markers} update_custom_data(clip_id, frame|frame_id|frameId, custom_data) -> {success} get_custom_data(clip_id, frame|frame_id|frameId) -> {data} delete_by_color(clip_id, color) -> {success} delete_at_frame(clip_id, frame|frame_id|frameId) -> {success} delete_by_custom_data(clip_id, custom_data) -> {success} add_flag(clip_id, color) -> {success} get_flags(clip_id) -> {flags} clear_flags(clip_id, color) -> {success} set_name(clip_id, name) -> {success} link_full_resolution_media(clip_id, path) -> {success} monitor_growing_file(clip_id) -> {success} replace_clip_preserve_sub_clip(clip_id, path) -> {success} """ p = params or {} _, _, mp, err = _get_mp() if err: return err clip = _find_clip(mp.GetRootFolder(), p.get("clip_id", "")) if not clip: return _err(f"Clip not found: {p.get('clip_id')}") if action == "add": marker, marker_err = _marker_add_payload(p) if marker_err: return marker_err return _add_marker(clip, marker) elif action == "get_all": return {"markers": _ser(clip.GetMarkers())} elif action == "get_by_custom_data": return {"markers": _ser(clip.GetMarkerByCustomData(_first_param(p, "custom_data", "customData", default="")))} elif action == "update_custom_data": frame, frame_err = _marker_frame_from_params(p) if frame_err: return frame_err return {"success": bool(clip.UpdateMarkerCustomData(frame, _first_param(p, "custom_data", "customData", default="")))} elif action == "get_custom_data": frame, frame_err = _marker_frame_from_params(p) if frame_err: return frame_err return {"data": clip.GetMarkerCustomData(frame)} elif action == "delete_by_color": return {"success": bool(clip.DeleteMarkersByColor(p["color"]))} elif action == "delete_at_frame": frame, frame_err = _marker_frame_from_params(p) if frame_err: return frame_err return {"success": bool(clip.DeleteMarkerAtFrame(frame))} elif action == "delete_by_custom_data": return {"success": bool(clip.DeleteMarkerByCustomData(_first_param(p, "custom_data", "customData", default="")))} elif action == "add_flag": return {"success": bool(clip.AddFlag(p["color"]))} elif action == "get_flags": return {"flags": clip.GetFlagList()} elif action == "clear_flags": return {"success": bool(clip.ClearFlags(p["color"]))} elif action == "set_name": missing = _requires_method(clip, "SetName", "20.2") if missing: return missing return {"success": bool(clip.SetName(p["name"]))} elif action == "link_full_resolution_media": missing = _requires_method(clip, "LinkFullResolutionMedia", "20.0") if missing: return missing full_res_path = p.get("path") or p.get("full_res_media_path") or p.get("fullResMediaPath") if not full_res_path: return _err("Provide path or full_res_media_path") return {"success": bool(clip.LinkFullResolutionMedia(full_res_path))} elif action == "monitor_growing_file": missing = _requires_method(clip, "MonitorGrowingFile", "20.0") if missing: return missing return {"success": bool(clip.MonitorGrowingFile())} elif action == "replace_clip_preserve_sub_clip": missing = _requires_method(clip, "ReplaceClipPreserveSubClip", "20.0") if missing: return missing replacement_path = p.get("path") or p.get("file_path") or p.get("filePath") if not replacement_path: return _err("Provide path or file_path") return {"success": bool(clip.ReplaceClipPreserveSubClip(replacement_path))} return _unknown(action, ["add","get_all","get_by_custom_data","update_custom_data","get_custom_data","delete_by_color","delete_at_frame","delete_by_custom_data","add_flag","get_flags","clear_flags","set_name","link_full_resolution_media","monitor_growing_file","replace_clip_preserve_sub_clip"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 15: media_analysis # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, ctx: Optional[Context] = None) -> Dict[str, Any]: """Project-scoped media analysis and guarded metadata publishing. - Lead any analysis flow with capabilities() + get_caps() to know what's available and what the current budget looks like. - For per-clip work: analyze_clip(clip_id=...). For bins/projects/timelines: analyze_bin, analyze_project, analyze_sequence (return job_id for long runs). - Vision uses host_chat_paths: analyze returns frame_paths + JSON schema; the host reads frames, produces JSON, then calls commit_vision to merge. - For caps refusal recovery: set_caps_preset (preset=generous|unlimited) or wait for day rollover. capabilities -> {tools, transcription, vision} get_caps -> {preset, caps, presets_available, usage?} set_caps_preset -> {success, preset, overrides} plan -> {clips, artifacts, estimated_seconds} analyze_clip|file -> {clips, manifest: {success, clip_count, successful_clip_count, failed_clip_count, partial_success?, caps_refusal_clip_count?, clips: [...], error?}} analyze_bin|project|sequence -> {job_id, status, clips, manifest, ...} commit_vision -> {analysis_json, marker_plan_json, metadata_publish} summarize -> {clips_summarized, summary, provenance: {source_reports, missing_reports}} review_timeline_markers -> {path, samples, vision_review?} start_batch_job -> {job_id, status} batch_job_status -> {job_id, status, progress, recent_events, clip_states} All actions may return {"error": {code, category, retryable, message, remediation, reason?}}. Errors with code=CAPS_REFUSAL also carry a `caps_refusal` block at the clip level and a `caps_refusal_clip_count` aggregate at the manifest level. Actions: capabilities() -> {tools, transcription, vision} install_guidance() -> {missing} — guidance only; never installs anything get_caps(clip_id?, job_id?) -> {preset, caps, presets_available, usage?} — effective caps + usage rollup (vision tokens consumed per scope, percent of budget). set_caps_preset(preset, overrides?) -> {success, preset, overrides} — preset is minimal | standard | generous | unlimited. Overrides is a dict of {field: int|"unlimited"} that wins over the preset. get_usage(scope?, scope_key?, clip_id?, job_id?) -> {scope, usage} — raw usage rollup for one scope (clip | job | day). get_resolve_ai_usage(session_only?, op?, limit?) -> {summary, recent} — ledger of Resolve 21 local AI ops (audio classification, IntelliSearch, slate, motion-deblur, speech). Tracks invocations, wall-clock, and files/bytes created by the two media-creating ops. Separate from get_caps/get_usage (those meter Claude-side tokens; these ops don't spend them). get_ai_governance() -> {tier, thresholds, usage, tiers_available} — soft governance tier (off|lenient|standard|strict) for the media-creating AI ops vs this session's render usage. Advisory; surfaced in the confirm preview + panel, never blocks. set_ai_governance(preset, overrides?) -> {success, tier, overrides} — set the governance tier. Overrides keys: deblur_runs, speech_runs, render_bytes, render_wall_clock_ms (int or "unlimited"). resolve_output_root(analysis_root?, source_paths?) -> {project_root} plan(target, depth?, analysis_root?, transcription?, vision?, dry_run?) -> {clips, artifacts} analyze_file(path|file_path, dry_run?, session_only?, persist?) -> {clips, manifest} analyze_clip(clip_id|selected, dry_run?, session_only?, persist?) -> {clips, manifest} analyze_bin(path|bin_path, recursive?, dry_run?, session_only?, persist?) -> {clips, manifest} analyze_project(recursive?, dry_run?, session_only?, persist?) -> {clips, manifest} analyze_sequence(timeline_index?, track_types?, dry_run?, session_only?, persist?) -> {clips, manifest} analyze_timeline(...) -> alias for analyze_sequence on the current timeline detect_sync_events(paths?|target?, event_types?, windows?) -> {files, alignment} add_sync_event_markers(target?|paths?|detections?, confirm?) -> {added, skipped} publish_clip_metadata(target?, fields?, slate_detection?, timed_markers?|write_markers?, dry_run?, confirm?) -> {results} commit_vision(clip_id|file_path|clip_dir, visual, vision_token?, analysis_root?, publish_metadata?, dry_run?, confirm?) -> {analysis_json, marker_plan_json, metadata_publish} review_timeline_markers(max_samples?, analysis_root?, vision?) -> {path, samples, vision_review?} summarize(analysis_root?) -> project summary from existing clip reports get_report(report_path?) -> load manifest or a report under the analysis root build_index(analysis_root?) -> rebuild the single-user SQLite index from existing reports index_status(analysis_root?) -> summarize index path, size, and row counts query_index(query, limit?, result_types?) -> search clips, markers, and transcripts start_batch_job(target, depth?, ...) -> create a durable slice-runnable analysis job; target may be selected, bin, project, sequence, file, or {type:"clips", clip_ids:[...]} run_batch_job_slice(job_id, max_clips?, max_seconds?) -> process a bounded job slice and refresh the index after successful clips batch_job_status(job_id) -> progress, recent events, and clip states list_batch_jobs() -> recent durable analysis jobs cancel_batch_job(job_id) -> stop future slices resume_batch_job(job_id) -> requeue a canceled or interrupted job cleanup_artifacts(frames_only=true) -> remove generated frame artifacts only Analysis outputs stay under a davinci-resolve-mcp-analysis project root and are validated so they are never written beside source media. Executed Resolve-target analysis defaults to execution, persisted inspectable artifacts, metadata writeback, and Media Pool clip markers unless disabled. The SQLite index is a single-user derived cache stored beside the JSON reports; it stores text/metadata only, never sampled image bytes. Batch jobs are single-user operational state stored beside the analysis root; each slice exits cleanly so agents can poll progress without spending tokens on long media processing. Executed Resolve-target analysis writes metadata and Media Pool clip markers by default; pass dry_run=true, publish_metadata=false, or timed_markers=no to disable writeback. If ask_before_metadata_publish is enabled, publish_clip_metadata returns a confirmation prompt until confirm=true. Vision uses host_chat_paths by default: analyze actions return a deferred payload with absolute frame_paths and a JSON schema. The host chat reads each frame as a local image, produces JSON per the schema, and calls media_analysis(action="commit_vision", params={clip_id, visual, vision_token}) to merge the result, rebuild Media Pool clip markers, and publish vision-dependent metadata to Resolve. Works with any MCP client whose chat model is vision-capable; no sampling/createMessage support required. """ p = _media_analysis_apply_setup_defaults(action, dict(params or {})) # First-run frame-sampling prompt: if the user has never chosen a sampling # mode (and didn't pass one this call), ask before spending any vision # tokens. Re-running with sampling_mode= saves it as the default; # passing sampling_mode explicitly any time is a one-off that skips this. _sampling_decision = p.get("_sampling_mode_decision") if isinstance(p, dict) else None if ( isinstance(_sampling_decision, dict) and _sampling_decision.get("prompt_required") and action in {"analyze_clip", "analyze_file", "analyze_bin", "analyze_project", "analyze_sequence", "analyze_timeline", "start_batch_job"} ): return { "success": True, "status": "confirmation_required", "confirmation_required": True, "sampling_mode_prompt": _media_analysis_sampling_mode_prompt(), "recommended_sampling_mode": _media_analysis_module.RECOMMENDED_SAMPLING_MODE, "message": ( "Choose a frame-sampling mode for visual analysis. Re-run with " "sampling_mode set to one of fixed/per_minute/adaptive_capped/adaptive " "(the chosen value is saved as your default), or set it in the control " "panel under Analysis Modes. Pass sampling_mode per-call any time for a one-off." ), "preferences_path": _media_analysis_preferences_path(), } # E2 — capture original action + scope before the dispatch may rewrite # `action` (e.g. analyze_clip → plan). Used by _e2_wrap below to attach # an `escalation` block when a (scope, action) pair fails repeatedly. _e2_track_action = action if action in { "analyze_clip", "analyze_file", "analyze_bin", "analyze_project", "analyze_sequence", "analyze_timeline", "commit_vision", } else None _e2_track_scope: Optional[str] = None if _e2_track_action == "analyze_clip": _e2_track_scope = p.get("clip_id") or (p.get("target") or {}).get("clip_id") elif _e2_track_action == "analyze_file": _e2_track_scope = p.get("path") or p.get("file_path") or (p.get("target") or {}).get("path") elif _e2_track_action == "analyze_bin": bin_path = p.get("bin_path") or p.get("path") or (p.get("target") or {}).get("path") _e2_track_scope = ("bin:" + bin_path) if bin_path else "bin:Master" elif _e2_track_action == "analyze_project": _e2_track_scope = "project" elif _e2_track_action in {"analyze_sequence", "analyze_timeline"}: _e2_track_scope = "sequence" elif _e2_track_action == "commit_vision": _e2_track_scope = p.get("clip_id") def _e2_wrap(resp: Any) -> Any: """Attach failure_tracker escalation when (scope, action) has failed N times in window.""" if not _e2_track_action or not isinstance(resp, dict): return resp return _record_action_outcome(_e2_track_scope, _e2_track_action, resp) if action == "capabilities": return _media_analysis_capabilities_for_request(ctx) if action == "recheck_capabilities": # Re-runs detection (shutil.which / importlib.util.find_spec) so a tool # an agent just installed flips from missing → available without the # user reloading the dashboard. Optional `previous` param compares against # a prior snapshot and returns a `changed` diff for "before/after" reporting. fresh = _media_analysis_capabilities_for_request(ctx) previous = (p.get("previous") or {}) if isinstance(p, dict) else {} prev_tools = (previous.get("tools") or {}) if isinstance(previous, dict) else {} new_tools = fresh.get("tools") or {} changed: Dict[str, Dict[str, Any]] = {} for name, new_entry in new_tools.items(): prev_avail = bool((prev_tools.get(name) or {}).get("available")) if isinstance(prev_tools.get(name), dict) else None new_avail = bool(new_entry.get("available")) if prev_avail is None: continue if prev_avail != new_avail: changed[name] = {"was": prev_avail, "now": new_avail} return { "success": True, "capabilities": fresh, "changed": changed, } if action == "get_caps": # Effective caps + per-scope usage rollup. Cheap, no Resolve required — # consults the preference file + the per-project token-usage DB. from src.utils import analysis_caps as _ac active = _ac.resolve_caps(_caps_preset_provider(), _caps_overrides_provider()) out: Dict[str, Any] = { "success": True, "preset": active.preset, "caps": active.to_dict(), "presets_available": _ac.list_presets(), } # Usage rollup is per-project; if no project context, skip silently. try: vctx = _destructive_versioning_provider() if vctx is not None: _resolve_h, _proj_h, project_root, _name = vctx out["usage"] = _ac.get_usage_rollup( project_root=project_root, caps=active, clip_id=p.get("clip_id"), job_id=p.get("job_id"), ) except Exception as exc: out["usage_error"] = f"{type(exc).__name__}: {exc}" return out if action == "get_resolve_ai_usage": # Ledger of Resolve-local 21.0 AI ops (audio classification, IntelliSearch, # slate, motion-deblur, speech generation). Distinct from get_caps/get_usage, # which meter Claude-side analysis tokens — these ops don't spend those. try: vctx = _destructive_versioning_provider() if vctx is None: return _err("No project context — open a Resolve project first") _r, _proj, project_root, _name = vctx session_only = bool(p.get("session_only", False)) session_id = _AI_LEDGER_SESSION_ID if session_only else None limit = int(p.get("limit", 50)) return { "success": True, "session_id": _AI_LEDGER_SESSION_ID, "scope": "session" if session_only else "project", "summary": _resolve_ai_ledger.get_summary(project_root=project_root, session_id=session_id), "recent": _resolve_ai_ledger.get_usage(project_root=project_root, session_id=session_id, op=p.get("op"), limit=limit), } except Exception as exc: return _err(f"{type(exc).__name__}: {exc}") if action == "get_ai_governance": # Soft-tier governance for the media-creating AI ops (deblur / speech). # Advisory only — reports this session's render usage vs the active tier. try: project_root = _ai_ledger_root() st = _resolve_ai_governance.status( project_root=project_root, session_id=_AI_LEDGER_SESSION_ID, preset=_ai_governance_preset(), overrides=_ai_governance_overrides(), ) return {"success": True, "session_id": _AI_LEDGER_SESSION_ID, **st} except Exception as exc: return _err(f"{type(exc).__name__}: {exc}") if action == "set_ai_governance": preset = (p.get("preset") or "").strip().lower() if preset not in _resolve_ai_governance.VALID_TIERS: return _err(f"unknown tier '{preset}'. Valid: {sorted(_resolve_ai_governance.VALID_TIERS)}") prefs = _read_media_analysis_preferences() prefs["resolve_ai_governance_preset"] = preset if isinstance(p.get("overrides"), dict): prefs["resolve_ai_governance_overrides"] = p["overrides"] path = _media_analysis_preferences_path() os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as fh: json.dump(prefs, fh, indent=2) return {"success": True, "tier": preset, "overrides": prefs.get("resolve_ai_governance_overrides") or {}} if action == "set_caps_preset": preset = (p.get("preset") or "").strip().lower() from src.utils import analysis_caps as _ac if preset not in _ac.VALID_PRESETS: return _err(f"unknown preset '{preset}'. Valid: {sorted(_ac.VALID_PRESETS)}") # Update preference file directly (same pattern as set_defaults). prefs = _read_media_analysis_preferences() prefs["analysis_caps_preset"] = preset if isinstance(p.get("overrides"), dict): prefs["analysis_caps_overrides"] = p["overrides"] path = _media_analysis_preferences_path() os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as fh: json.dump(prefs, fh, indent=2) return {"success": True, "preset": preset, "overrides": prefs.get("analysis_caps_overrides") or {}} if action == "get_usage": from src.utils import analysis_caps as _ac try: vctx = _destructive_versioning_provider() if vctx is None: return _err("No project context — open a Resolve project first") _r, _proj, project_root, _name = vctx scope = (p.get("scope") or "day").strip().lower() scope_key = p.get("scope_key") or p.get("clip_id") or p.get("job_id") return { "success": True, "scope": scope, "scope_key": scope_key, "usage": _ac.get_current_usage( project_root=project_root, scope=scope, scope_key=scope_key, ), } except Exception as exc: return _err(f"{type(exc).__name__}: {exc}") if action == "install_guidance": caps = _media_analysis_capabilities_for_request(ctx) guidance = media_analysis_install_guidance(caps) if caps.get("vision", {}).get("available"): guidance.get("missing", {}).pop("vision", None) guidance["host_chat_paths_vision"] = { "available": True, "requires": "No package install; the host MCP client's chat model must be able to read local image files.", "provider": HOST_CHAT_PATHS_PROVIDER, "commit_action": {"tool": "media_analysis", "action": "commit_vision"}, "schema_reference": VISION_SCHEMA_REFERENCE, } return guidance pm, proj, err = _check() if err: return _e2_wrap(err) project_name, project_id = _project_name_and_id(proj) if p.get("project_name") or p.get("projectName"): project_name = p.get("project_name") or p.get("projectName") if p.get("project_id") or p.get("projectId"): project_id = p.get("project_id") or p.get("projectId") if action == "resolve_output_root": return resolve_media_analysis_output_root( project_name=project_name, project_id=project_id, analysis_root=p.get("analysis_root"), source_paths=p.get("source_paths") or p.get("sourcePaths") or [], create=bool(p.get("create", False)), ) if action in { "summarize", "get_report", "build_index", "rebuild_index", "index_status", "query_index", "batch_job_status", "list_batch_jobs", "run_batch_job_slice", "cancel_batch_job", "resume_batch_job", "cleanup_artifacts", }: root = resolve_media_analysis_output_root( project_name=project_name, project_id=project_id, analysis_root=p.get("analysis_root"), source_paths=[], create=bool(p.get("create", False)), ) if not root.get("success"): return root project_root = root["project_root"] if action == "summarize": return summarize_media_analysis_reports(project_root) if action == "get_report": return load_media_analysis_report( project_root, report_path=p.get("report_path") or p.get("path"), clip_dir=p.get("clip_dir"), ) # V2 B6: chat ↔ panel state sharing if action == "get_panel_state": from src.utils.analysis_memory import read_panel_state, panel_state_path state = read_panel_state(project_root) return { "success": True, "project_root": project_root, "panel_state_path": panel_state_path(project_root), "panel_state": state, } if action == "set_panel_state": from src.utils.analysis_memory import write_panel_state updates = p.get("state") or p.get("panel_state") or {} if not isinstance(updates, dict): return _err("set_panel_state requires `state` to be an object") merge = _media_analysis_bool(p.get("merge", True), True) written_by = p.get("written_by") or p.get("writtenBy") or "chat" return write_panel_state(project_root, updates, written_by=written_by, merge=merge) # V2 session-start context (loads soul + memory + heartbeat + panel state) if action == "session_start_context": from src.utils.analysis_memory import session_start_context return session_start_context(project_root) # V2 C4: correction tools — per-clip sidecar JSON with provenance + changelog if action == "update_shot_field": return _v2_update_field(project_root, p, entity_type="shot") if action == "update_clip_field": return _v2_update_field(project_root, p, entity_type="clip") if action == "get_field_history": return _v2_get_field_history(project_root, p) if action == "revert_field": return _v2_revert_field(project_root, p) if action == "list_corrections": return _v2_list_corrections(project_root, p) if action in {"build_index", "rebuild_index"}: return build_analysis_index(project_root, index_path=p.get("index_path") or p.get("indexPath")) if action == "index_status": return analysis_index_status(project_root, index_path=p.get("index_path") or p.get("indexPath")) if action == "query_index": return query_analysis_index( project_root, p.get("query", ""), limit=p.get("limit", 20), result_types=p.get("result_types") or p.get("resultTypes"), index_path=p.get("index_path") or p.get("indexPath"), ) if action == "list_batch_jobs": return list_media_analysis_batch_jobs(project_root, limit=p.get("limit", 50)) if action == "batch_job_status": job_id = p.get("job_id") or p.get("jobId") or p.get("id") if not job_id: return _err("batch_job_status requires job_id") return media_analysis_batch_job_status( project_root, str(job_id), include_clips=_media_analysis_bool(p.get("include_clips", p.get("includeClips")), True), include_events=_media_analysis_bool(p.get("include_events", p.get("includeEvents")), True), ) if action == "run_batch_job_slice": job_id = p.get("job_id") or p.get("jobId") or p.get("id") if not job_id: return _err("run_batch_job_slice requires job_id") return run_media_analysis_batch_job_slice( project_root, str(job_id), max_clips=p.get("max_clips", p.get("maxClips", 1)), max_seconds=p.get("max_seconds", p.get("maxSeconds")), capabilities=detect_media_analysis_capabilities(), ) if action == "cancel_batch_job": job_id = p.get("job_id") or p.get("jobId") or p.get("id") if not job_id: return _err("cancel_batch_job requires job_id") return cancel_media_analysis_batch_job(project_root, str(job_id)) if action == "resume_batch_job": job_id = p.get("job_id") or p.get("jobId") or p.get("id") if not job_id: return _err("resume_batch_job requires job_id") return resume_media_analysis_batch_job(project_root, str(job_id)) return cleanup_media_analysis_artifacts(project_root, frames_only=bool(p.get("frames_only", True))) if action == "review_timeline_markers": tl = proj.GetCurrentTimeline() if not tl: return _err("No current timeline") review = _timeline_marker_thumbnail_review(proj, tl, p) if not review.get("success"): return review vision = p.get("vision") or {} if _media_analysis_bool(vision.get("enabled"), default=False): provider = vision.get("provider") or HOST_CHAT_PATHS_PROVIDER if provider in HOST_CHAT_VISION_PROVIDERS: review["vision_review"] = _media_analysis_host_chat_image_review_payload( review.get("path"), { "timeline": {"name": tl.GetName(), "id": tl.GetUniqueId()}, "samples": review.get("samples", []), "review_prompt": review.get("review_prompt"), }, vision, ) else: review["vision_review"] = { "success": False, "status": "skipped", "provider": provider, "reason": f"Timeline marker image review uses host_chat_paths; unknown provider '{provider}'.", } return review if action == "detect_sync_events": records, normalized_target, warnings, target_err = _media_analysis_sync_event_records(proj, p) if target_err: if warnings: target_err["warnings"] = warnings return target_err result = detect_media_sync_events(records or [], p) result["target"] = normalized_target if warnings: result.setdefault("warnings", []) result["warnings"] = warnings + list(result.get("warnings") or []) return result if action == "add_sync_event_markers": detection = p.get("detection") or p.get("detections") if not isinstance(detection, dict): records, normalized_target, warnings, target_err = _media_analysis_sync_event_records(proj, p) if target_err: if warnings: target_err["warnings"] = warnings return target_err detection = detect_media_sync_events(records or [], p) detection["target"] = normalized_target if warnings: detection.setdefault("warnings", []) detection["warnings"] = warnings + list(detection.get("warnings") or []) return _apply_sync_event_markers(proj, detection, p) if action == "publish_clip_metadata": return await _publish_clip_metadata_from_analysis(proj, p, ctx) if action == "commit_vision": visual = p.get("visual") or p.get("visual_analysis") or p.get("visualAnalysis") if visual is None: return _err("commit_vision requires `visual` (JSON object matching the vision schema)") clip_id = p.get("clip_id") or p.get("clipId") file_path = p.get("file_path") or p.get("filePath") or p.get("path") clip_dir = p.get("clip_dir") or p.get("clipDir") vision_token = p.get("vision_token") or p.get("visionToken") analysis_root = p.get("analysis_root") or p.get("analysisRoot") or p.get("project_root") or p.get("projectRoot") if not analysis_root: source_paths = [file_path] if file_path else None root_info = resolve_media_analysis_output_root(source_paths=source_paths, project_name=project_name, project_id=project_id) if not root_info.get("success"): return root_info analysis_root = root_info.get("project_root") commit = commit_visual_analysis( project_root=analysis_root, visual=visual, clip_id=str(clip_id) if clip_id else None, file_path=file_path, clip_dir=clip_dir, vision_token=str(vision_token) if vision_token else None, ) if not commit.get("success"): return commit record = commit.get("record") or {} committed_clip_id = record.get("clip_id") or clip_id if committed_clip_id and _media_analysis_metadata_writeback_enabled(p): publish_params = _media_analysis_apply_setup_defaults("publish_clip_metadata", { **p, "target": {"type": "clip", "clip_id": str(committed_clip_id)}, }) publish_params["target"] = {"type": "clip", "clip_id": str(committed_clip_id)} if not _has_any_param(publish_params, "dry_run", "dryRun"): publish_params["dry_run"] = False if not _has_any_param(publish_params, "confirm", "confirmed", "apply"): publish_params["confirm"] = True committed_report_path = commit.get("analysis_json") if committed_report_path and os.path.isfile(committed_report_path): publish_params["_pre_resolved_report_paths"] = { str(committed_clip_id): committed_report_path, } commit["metadata_publish"] = await _publish_clip_metadata_from_analysis(proj, publish_params, ctx) else: commit["metadata_publish"] = { "success": True, "status": "skipped", "reason": ( "Metadata writeback skipped — commit_vision called against a non-Resolve clip " "or with publish_metadata=false. Analysis files were updated on disk." ), } return commit if action == "start_batch_job": p["dry_run"] = False p["persist"] = True p["session_only"] = False p["cleanup_frames"] = _media_analysis_bool(p.get("cleanup_frames", p.get("cleanupFrames")), True) p["target"] = _media_analysis_target_dict(p.get("target"), p) if p["target"].get("_invalid_target"): return _err(p["target"]["_invalid_target"]) mp = None target_type = str(p["target"].get("type") or p.get("target_type") or "clip").strip().lower() if target_type != "file": mp = proj.GetMediaPool() if not mp: return _err("Failed to get MediaPool") records, normalized_target, warnings, target_err = _media_analysis_records_from_target(mp, p, project=proj) if target_err: if warnings: target_err["warnings"] = warnings return target_err created = create_media_analysis_batch_job( project_name=project_name, project_id=project_id, records=records or [], target=normalized_target, params=p, capabilities=detect_media_analysis_capabilities(), name=p.get("name") or p.get("job_name") or p.get("jobName"), ) if warnings: created.setdefault("warnings", warnings) return created if action in {"analyze_file", "analyze_clip", "analyze_bin", "analyze_project", "analyze_timeline", "analyze_sequence"}: dry_run_default = bool(_media_analysis_effective_preferences().get("dry_run_first_default")) p["dry_run"] = _media_analysis_bool(p.get("dry_run"), dry_run_default) target = _media_analysis_target_dict(p.get("target"), p) if target.get("_invalid_target"): return _err(target["_invalid_target"]) if action == "analyze_file": target.update({"type": "file", "path": p.get("path") or p.get("file_path") or p.get("filePath") or target.get("path")}) elif action == "analyze_clip": target.update({"type": "clip", "clip_id": p.get("clip_id") or target.get("clip_id"), "selected": p.get("selected", target.get("selected", False))}) elif action == "analyze_bin": target.update({"type": "bin", "path": p.get("bin_path") or p.get("path") or target.get("path") or "Master", "recursive": p.get("recursive", target.get("recursive", True))}) elif action == "analyze_project": target.update({"type": "project", "recursive": p.get("recursive", target.get("recursive", True))}) elif action in {"analyze_timeline", "analyze_sequence"}: target.update({ "type": "sequence", "timeline_index": p.get("timeline_index") or p.get("timelineIndex") or target.get("timeline_index") or target.get("timelineIndex"), "track_types": p.get("track_types") or p.get("trackTypes") or target.get("track_types") or target.get("trackTypes"), }) p["target"] = target # E3 — `prefer_handle` opt-in. When true AND this isn't a dry-run, # divert to the durable batch-job machinery so the call returns a # job_id immediately instead of blocking on vision/transcription. # Default false: existing blocking semantics are preserved. # The start_batch_job handler lives ABOVE this block in the dispatch # chain, so we can't just rewrite `action` and fall through — we # re-enter the tool with the rewritten action via await so the # handler chain restarts from the top. if _media_analysis_bool(p.get("prefer_handle"), False) and not p.get("dry_run"): return await media_analysis("start_batch_job", p, ctx) action = "plan" if action == "coverage_report": # Pure-read coverage assessment. Editorial / color / online / producer # contexts call this first and lead recommendations with `evidence_base`. target = _media_analysis_target_dict(p.get("target"), p) if target.get("_invalid_target"): return _err(target["_invalid_target"]) target_type = str(target.get("type") or p.get("target_type") or "clip").strip().lower() mp = None if target_type != "file": mp = proj.GetMediaPool() if not mp: return _err("Failed to get MediaPool") p["target"] = target records, normalized_target, warnings, target_err = _media_analysis_records_from_target(mp, p, project=proj) if target_err: if warnings: target_err["warnings"] = warnings return target_err coverage = build_media_analysis_coverage_report( project_name=project_name, project_id=project_id, records=records or [], target=normalized_target, params=p, capabilities=_media_analysis_capabilities_for_request(ctx), ) if warnings: coverage["warnings"] = warnings return coverage if action == "plan": p["dry_run"] = _media_analysis_bool(p.get("dry_run"), True) persist = _media_analysis_bool(p.get("persist"), False) if "session_only" in p: p["session_only"] = _media_analysis_bool(p.get("session_only"), False) else: p["session_only"] = (not p["dry_run"]) and (not persist) if persist: p["session_only"] = False if p["session_only"] and not p["dry_run"]: p["cleanup_frames"] = _media_analysis_bool(p.get("cleanup_frames"), True) if not p.get("analysis_root"): p["_reuse_default_analysis_root"] = True session_root = tempfile.mkdtemp(prefix="davinci-resolve-mcp-analysis-session-") p["analysis_root"] = session_root p["_session_temp_base_root"] = session_root target = _media_analysis_target_dict(p.get("target"), p) if target.get("_invalid_target"): return _err(target["_invalid_target"]) target_type = str(target.get("type") or p.get("target_type") or "clip").strip().lower() mp = None if target_type != "file": mp = proj.GetMediaPool() if not mp: return _e2_wrap(_err("Failed to get MediaPool")) records, normalized_target, warnings, target_err = _media_analysis_records_from_target(mp, p, project=proj) if target_err: if warnings: target_err["warnings"] = warnings return _e2_wrap(target_err) caps = _media_analysis_capabilities_for_request(ctx) plan = build_media_analysis_plan( project_name=project_name, project_id=project_id, records=records or [], target=normalized_target, params=p, capabilities=caps, ) if p.get("_setup_defaults_applied"): plan["setup_defaults_applied"] = p.get("_setup_defaults_applied") if warnings: plan["warnings"] = warnings if plan.get("capability_gaps") and not bool(p.get("dry_run", True)): return _e2_wrap(_media_analysis_missing_capabilities_response(plan)) if not bool(p.get("dry_run", True)): executed = await execute_media_analysis_plan_async( plan, params=p, capabilities=caps, ) # V2 P9: compact the wire response unless verbose=true. Full payload # is always on disk under analysis_json. Saves 100k+ chars per call # and avoids forcing consumers to file-shovel via jq. verbose = _media_analysis_bool(p.get("verbose", p.get("verboseResponse", p.get("include_full_manifest"))), False) result = { "success": bool(executed.get("success")), "plan": plan, "manifest": _compact_manifest_for_response(executed, verbose=verbose), "response_format": "verbose" if verbose else "compact", } if executed.get("status"): result["status"] = executed.get("status") if executed.get("reuse_summary"): result["reuse_summary"] = executed.get("reuse_summary") if executed.get("error"): result["error"] = executed.get("error") if executed.get("status") == "reuse_blocked": result["blocked_clip_count"] = executed.get("blocked_clip_count") return _e2_wrap(result) if executed.get("vision_pending"): result["status"] = "pending_host_vision_analysis" result["pending_action"] = executed.get("pending_action") result["next_step_instructions"] = ( "Visual analysis is deferred to the host chat. For each clip in " "manifest.clips with vision_status='pending_host_analysis', open " "analysis_json on disk to retrieve the full visual.prompt, " "schema_reference, frame_paths[] and shot_table[]. Read those frames " "as local images, produce a JSON object matching the schema, and call " "media_analysis(action='commit_vision', params={clip_id, visual, vision_token}). " "Resolve clip-level metadata (Description, Keywords, Comments) is published " "automatically after commit_vision finalizes. Pass verbose=true to inline the " "full visual payload in the response instead of reading from disk." ) return _e2_wrap(result) if ( result["success"] and target_type != "file" and not _media_analysis_bool(p.get("dry_run"), True) and _media_analysis_metadata_writeback_enabled(p) ): publish_params = _media_analysis_apply_setup_defaults("publish_clip_metadata", { **p, "target": normalized_target, }) publish_params["target"] = normalized_target if not _has_any_param(publish_params, "dry_run", "dryRun"): publish_params["dry_run"] = False publish_result = await _publish_clip_metadata_from_analysis(proj, publish_params, ctx) result["metadata_publish"] = _compact_metadata_publish_for_response(publish_result, verbose=verbose) result["success"] = bool(result["success"] and publish_result.get("success")) return _e2_wrap(result) # D3 — dry-run plan path: seed the partial-success keys with defaults # so callers can rely on the schema being uniform between dry-run plans # and executed manifests. No clips have run yet, so completed/failed # are both empty. plan.setdefault("partial_success", False) plan.setdefault("completed_clip_ids", []) plan.setdefault("failed_clip_ids", []) return _e2_wrap(plan) return _unknown(action, [ "capabilities", "recheck_capabilities", "install_guidance", "resolve_output_root", "plan", "analyze_file", "analyze_clip", "analyze_bin", "analyze_project", "analyze_sequence", "analyze_timeline", "detect_sync_events", "add_sync_event_markers", "publish_clip_metadata", "commit_vision", "review_timeline_markers", "summarize", "get_report", "build_index", "rebuild_index", "index_status", "query_index", "start_batch_job", "run_batch_job_slice", "batch_job_status", "list_batch_jobs", "cancel_batch_job", "resume_batch_job", "cleanup_artifacts", ]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 16: timeline # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Timeline version-on-mutate, archive, rollback, and brain-edit history (C6). Every destructive timeline op auto-archives the working timeline to the Archive bin under an analysis_run_id; these actions let you inspect and control that. Actions: begin_run(label?, initiator?, analysis_run_id?) -> {success, analysis_run_id, label, initiator, started_at} Open a run. Subsequent destructive calls without an explicit analysis_run_id auto-thread this one — so a multi-step brain operation creates ONE archive instead of N. Pair with end_run to capture the cumulative metric delta. end_run(analysis_run_id?) -> {success, analysis_run_id, ended_at, summary} Close the active run (or a specific one). Aggregates brain_edits into per-metric rollup in analysis_runs.summary_json. list_runs(limit?) -> {success, runs} Recent runs with their summaries, newest first. archive_current(reason?, analysis_run_id?) -> {success, timeline_name, archived_timeline_name, version, archive_bin, row_id} Manually checkpoint the current timeline. If analysis_run_id is supplied and that run already archived the current timeline, this is a no-op. list_versions(timeline_name) -> [{version, archived_timeline_name, created_at, ...}] Version chain for `timeline_name`, oldest first. Includes any retention- collapsed versions (drt_export_path populated). get_history(timeline_name?, analysis_run_id?, limit?) -> [{edit_type, target_metric, before_value, after_value, delta, ...}] Brain-edit history. Filter by timeline_name or analysis_run_id; defaults to the most recent 50 across the project. rollback(timeline_name, version, analysis_run_id?) -> {success, restored_timeline_name, archive_of_previous} Restore an archived version. Archives current state first; restored copy gets a "_rolled_back_" suffix. prune(timeline_name, keep_n=10) -> {success, pruned, kept, details} Collapse old versions to .drt exports under _soul/timeline_versions//, delete the archived timeline from the bin, keep the DB row for rollback. registry() -> {entries, registry_path} Read the cross-project brain_edits registry that lives one level above each project root (analogous to analysis_registry.json). """ r = get_resolve() if r is None: return _err("Resolve not available") ctx = _destructive_versioning_provider() if ctx is None: return _err("No current project / can't resolve project root") resolve_h, project_h, project_root, project_name = ctx p = params or {} if action == "begin_run": return _analysis_runs.begin_run( project_root=project_root, label=p.get("label"), initiator=p.get("initiator"), analysis_run_id=p.get("analysis_run_id"), ) if action == "end_run": return _analysis_runs.end_run( project_root=project_root, analysis_run_id=p.get("analysis_run_id"), ) if action == "list_runs": return { "success": True, "runs": _analysis_runs.list_runs(project_root, limit=int(p.get("limit", 50))), } if action == "archive_current": return _timeline_versioning.archive_current_timeline( resolve=resolve_h, project=project_h, project_root=project_root, reason=p.get("reason"), analysis_run_id=p.get("analysis_run_id"), ) if action == "list_versions": if not p.get("timeline_name"): return _err("timeline_name required") rows = _timeline_versioning.list_timeline_versions( project_root=project_root, timeline_name=str(p["timeline_name"]), ) return {"success": True, "versions": rows} if action == "diff_versions": if not p.get("timeline_name") or "from_version" not in p or "to_version" not in p: return _err("timeline_name, from_version, to_version required") return { "success": True, **_timeline_versioning.diff_versions( project_root=project_root, timeline_name=str(p["timeline_name"]), from_version=int(p["from_version"]), to_version=int(p["to_version"]), ), } if action == "get_history": rows = _brain_edits.get_brain_edit_history( project_root=project_root, timeline_name=p.get("timeline_name"), analysis_run_id=p.get("analysis_run_id"), limit=int(p.get("limit", 50)), ) return {"success": True, "edits": rows} if action == "rollback": if not p.get("timeline_name") or "version" not in p: return _err("timeline_name and version required") return _timeline_versioning.rollback_to_version( resolve=resolve_h, project=project_h, project_root=project_root, timeline_name=str(p["timeline_name"]), version=int(p["version"]), analysis_run_id=p.get("analysis_run_id"), ) if action == "prune": if not p.get("timeline_name"): return _err("timeline_name required") return _timeline_versioning.prune_archived_versions( resolve=resolve_h, project=project_h, project_root=project_root, timeline_name=str(p["timeline_name"]), keep_n=int(p.get("keep_n", 10)), ) if action == "registry": return {"success": True, **_brain_edits.read_brain_edits_registry(project_root)} if action == "media_pool_changes": return { "success": True, "changes": _media_pool_changes.get_media_pool_change_history( project_root=project_root, analysis_run_id=p.get("analysis_run_id"), action=p.get("media_pool_action"), limit=int(p.get("limit", 50)), ), } return _err(f"Unknown action: {action}") @mcp.tool() @_destructive_op("timeline") def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Timeline operations: tracks, clips, import/export, generators, titles. PREFER probe_* / *_capabilities / *_boundary_report / *_checked / dry_run variants where they exist. Raw mutators are kept for advanced callers but bypass guardrails. DESTRUCTIVE actions auto-archive the current timeline (C6); pass strict=true for refuse-on-archive-failure behavior on the catastrophic ops. Actions: list() -> {timelines} get_current() -> {name, id, start_frame, end_frame, start_timecode} set_current(index) -> {success} — 1-based index get_name() -> {name} set_name(name) -> {success} get_start_frame() -> {frame} get_end_frame() -> {frame} get_start_timecode() -> {timecode} set_start_timecode(timecode) -> {success} get_track_count(track_type) -> {count} — video, audio, subtitle add_track(track_type, options?) -> {success} — options dict (newTrackOptions per docs line 327): {audio_type, index}. audio_type: 'mono', 'stereo', '5.1', '7.1', 'adaptive1'..'adaptive36' for audio. index: 1-based slot; appended if omitted/out of bounds. delete_track(track_type, index) -> {success} CATASTROPHIC. Deletes a track and all items on it. Strict mode auto-applies. get_track_sub_type(track_type, index) -> {sub_type} set_track_enable(track_type, index, enabled) -> {success} get_track_enabled(track_type, index) -> {enabled} set_track_lock(track_type, index, locked) -> {success} get_track_locked(track_type, index) -> {locked} get_track_name(track_type, index) -> {name} set_track_name(track_type, index, name) -> {success} get_items(track_type, index) -> {items} clip_where(filters|track_type?, track_index?, name_contains?, duration_lt?, duration_gt?) -> {clips, match_count, total_clips} Find clips on the current timeline matching named filters (AND). Pass filters inline or as a `filters` dict. Live filters: track_type, track_index, name_contains, duration_lt, duration_gt (frames). No clip enumeration needed. delete_clips(clip_ids, ripple?) -> {success} — clip_ids: list of unique IDs DESTRUCTIVE. ripple=True is CATASTROPHIC (closes the gap; cannot be selectively undone). set_clips_linked(clip_ids, linked) -> {success} duplicate(name?) -> {success, name} duplicate_clips(clip_ids?, selected?, target_track_index?, track_offset?, placement?, record_frame?, record_frame_offset?, copy_properties?, include_linked?) -> {results, count} — Video clips only. Re-places the same MediaPool media with the same source trim on the current timeline (like Alt-drag) via AppendToTimeline. clip_ids: timeline item unique IDs from get_items / get_current_video_item; selected=True uses Resolve's selected/current item when available. target_track_index overrides track_offset; placement supports same_time, offset, at_playhead, track_above, after_source, and next_gap. copy_properties may include transform, crop, composite, audio, retime, clip_color, markers, flags, enabled, cache, voice_isolation, fusion, grades, takes, and transitions (reported unsupported by Resolve API). include_linked=True duplicates linked audio and restores link state. # example: action_help(name='') copy_clips(...) -> {results, count} — alias for duplicate_clips. move_clips(...) -> {results, count, deleted_sources} — duplicate, then delete successfully duplicated sources. copy_range/duplicate_range(start_frame, end_frame, record_frame, ...) -> {results, count} overwrite_range(start_frame, end_frame, record_frame, ...) -> {results, count} lift_range(start_frame, end_frame, allow_partial_item_delete?, ripple?) -> {success, deleted} DESTRUCTIVE. Removes items in a record-frame range. ripple=True closes the gap. story_spine_report() -> {beats, track_summaries, source_ranges, audio_spine} create_variant_from_ranges(name, ranges, markers?, cdl?, dry_run?) -> {success, id, items} # example: action_help(name='') bulk_set_item_properties(ops, dry_run?, readback?) -> {results, op_count} # example: action_help(name='') apply_look_to_items(target_ids, cdl?|copy_from_item_id?, dry_run?) -> {success} # example: action_help(name='') thumbnail_contact_sheet(frames?|max_samples?, analysis_root?) -> {path, samples} marker_thumbnail_review(max_samples?, analysis_root?) -> {path, samples, review_guidance} edit_kernel_capabilities() -> {supported, partially_supported, unsupported} probe_edit_kernel_item(clip_ids? selected? timeline_item?) -> {items, count} title_property_scan(clip_id|timeline_item_id|timeline_item) -> {properties, text_key_candidates, fusion_comp_count, ...} — Undocumented generator/Text+ GetProperty map on an item (keys are not in public API docs). set_title_text(clip_id|..., text, property_key?, as_styled_xml?, try_plain_first?, try_heuristic_keys?, readback?) -> {success, property_key?, attempts} — SetProperty on a heuristic or explicit key; tries plain string then minimal styled XML unless as_styled_xml=True. # example: action_help(name='') bulk_set_title_text(ops, ...) -> {results, op_count} — list of set_title_text payloads (same params per op). create_compound_clip(clip_ids, info?) -> {success} create_fusion_clip(clip_ids) -> {success} import_into_timeline(path, options?) -> {success} UNSAFE. No path sandboxing. Prefer import_timeline_checked. export(path, type, subtype?) -> {success} — type: AAF, EDL, FCPXML, etc. UNSAFE. No path sandboxing. Prefer export_timeline_checked. get_setting(name?) -> {settings} set_setting(name, value) -> {success} insert_generator(name) -> {success} insert_fusion_generator(name) -> {success} insert_fusion_composition() -> {success} insert_ofx_generator(name) -> {success} insert_title(name) -> {success} insert_fusion_title(name) -> {success} get_unique_id() -> {id} get_node_graph() -> {available} get_media_pool_item() -> {name, id} get_transcript(with_timecodes?) -> {text, cue_count, has_subtitles, cues} Read the timeline's subtitle track(s) as transcript text. propose_cuts(cues?, long_pause_frames?) -> {cuts, cut_count, basis_cue_count, pass, note} DRY-RUN. Mechanically detect candidate cuts (filler words, long pauses, repeated lines) from the subtitle transcript. Proposes only; applies nothing. apply_cuts(cuts, dry_run?, confirm_token?, allow_partial_item_delete?) -> {applied, total, results} Apply a CutList (from propose_cuts) as lift/ripple deletes. DRY-RUN by default; applying is DESTRUCTIVE — confirm-token gated and a timeline version is archived first. Cuts apply latest-first. get_mark_in_out() -> {mark} set_mark_in_out(mark_in, mark_out, type?) -> {success} clear_mark_in_out(type?) -> {success} convert_to_stereo() -> {success} get_items_in_track(track_type, track_index) -> {items} get_voice_isolation_state(track_index) -> {isEnabled, amount} set_voice_isolation_state(track_index, state) -> {success} extract_source_frame_ranges(handles?, gap_max?, skip_extensions?) -> {timeline_name, frame_ranges, occurrences, ...} — Same logic as Pr/extract_timeline_frames.py get_resolve_api_frames: all video clips on the current timeline; clip name = basename of Media Pool File Path when set; skips audio extensions. source_range_final and frame_ranges tuples are inclusive/inclusive endpoints per that script. Default handles=24, gap_max=30. Use handles=0 for gap-only auto handles. conform_capabilities() -> {supported, partially_supported, unsupported, export_aliases} probe_timeline_structure(track_types?, include_markers?, include_clip_properties?) -> {tracks, markers} detect_gaps_overlaps(track_types?, min_gap?) -> {gaps, overlaps} source_range_report(handles?, merge?) -> {ranges, occurrences} export_timeline_checked(path, format?|type?, subtype?, require_temp_path?, dry_run?) -> {success, path, size} import_timeline_checked(path, options?, timeline_name?, import_source_clips?, require_temp_path?, dry_run?) -> {success, name, id} compare_timelines(right_timeline_id?|right_timeline_index?|left_snapshot?, right_snapshot?) -> {match, differences} probe_interchange_roundtrip(format?, output_dir?, cleanup_imported?) -> {success, export, import, comparison} detect_missing_media() -> {missing, missing_count} build_relink_plan(search_roots) -> {candidates} conform_boundary_report(...) -> {capabilities, timeline, gaps_overlaps, source_ranges, missing_media} audio_capabilities() -> {supported, partially_supported, unsupported} probe_audio_item(track_type?, track_index?, item_index?) -> {summary, audio_properties, source_audio_mapping} probe_audio_track(track_index?) -> {track_count, enabled, locked, sub_type, voice_isolation} safe_set_audio_properties(properties, restore?, dry_run?, track_type?, track_index?, item_index?) -> {success, results} audio_mix_capability_report(...) -> {capabilities, mix_recommendations} voice_isolation_capabilities(track_index?, track_type?, item_index?) -> {timeline_track, item} audio_mapping_report(clip_ids?) -> {timeline_items, media_pool_items} safe_auto_sync_audio(clip_ids|selected, settings?, dry_run?) -> {success} transcription_capabilities(clip_ids?|selected?) -> {clip_methods, folder} subtitle_generation_probe(settings?, allow_generate?) -> {success} fairlight_boundary_report(...) -> {capabilities, track, item, audio_mapping, transcription} For long-form per-action guidance and a worked example, call: timeline(action="action_help", params={"name": ""}) """ p = params or {} # action_help is pull-on-demand metadata; no Resolve connection needed. if action == "action_help": return _action_help("timeline", p) pm, proj, err = _check() if err: return err # Actions that don't need a current timeline if action == "list": count = proj.GetTimelineCount() timelines = [] for i in range(1, count + 1): tl = proj.GetTimelineByIndex(i) if tl: timelines.append({"name": tl.GetName(), "id": tl.GetUniqueId(), "index": i}) return {"timelines": timelines} elif action == "set_current": tl = proj.GetTimelineByIndex(p["index"]) return {"success": bool(proj.SetCurrentTimeline(tl))} if tl else _err(f"No timeline at index {p['index']}") elif action == "edit_kernel_capabilities": return _timeline_edit_kernel_capabilities() elif action == "conform_capabilities": return _conform_capabilities() elif action == "audio_capabilities": return _audio_capabilities() elif action == "import_timeline_checked": _, _, mp, mp_err = _get_mp() if mp_err: return mp_err return _import_timeline_checked(proj, mp, p) elif action == "safe_auto_sync_audio": _, _, mp, mp_err = _get_mp() if mp_err: return mp_err return _safe_auto_sync_audio(mp, p) elif action == "transcription_capabilities": _, _, mp, mp_err = _get_mp() if mp_err: return mp_err return _transcription_capabilities(mp, p) elif action == "compare_timelines" and isinstance(p.get("left_snapshot"), dict) and isinstance(p.get("right_snapshot"), dict): return _compare_timelines(proj, proj.GetCurrentTimeline(), p) # Remaining actions need current timeline tl = proj.GetCurrentTimeline() if not tl: return _err("No current timeline") if action == "clip_where": return _timeline_clip_where(tl, p) if action == "get_current": return {"name": tl.GetName(), "id": tl.GetUniqueId(), "start_frame": tl.GetStartFrame(), "end_frame": tl.GetEndFrame(), "start_timecode": tl.GetStartTimecode()} elif action == "get_name": return {"name": tl.GetName()} elif action == "set_name": return {"success": bool(tl.SetName(p["name"]))} elif action == "get_start_frame": return {"frame": tl.GetStartFrame()} elif action == "get_end_frame": return {"frame": tl.GetEndFrame()} elif action == "get_start_timecode": return {"timecode": tl.GetStartTimecode()} elif action == "set_start_timecode": return {"success": bool(tl.SetStartTimecode(p["timecode"]))} elif action == "get_track_count": return {"count": tl.GetTrackCount(p["track_type"])} elif action == "add_track": opts_in = p.get("options") or {} new_track_options: Dict[str, Any] = {} if "audio_type" in opts_in: new_track_options["audioType"] = opts_in["audio_type"] elif "audioType" in opts_in: new_track_options["audioType"] = opts_in["audioType"] if "index" in opts_in: new_track_options["index"] = opts_in["index"] if new_track_options: return {"success": bool(tl.AddTrack(p["track_type"], new_track_options))} return {"success": bool(tl.AddTrack(p["track_type"]))} elif action == "delete_track": # B2 — catastrophic: deletes track + every item on it. if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required(): try: items_on_track = tl.GetItemListInTrack(p["track_type"], p["index"]) or [] item_count = len(items_on_track) except Exception: item_count = None return _issue_confirm_token( action="timeline.delete_track", params=p, preview={"operation": "timeline.delete_track", "warning": "Deletes a track AND every clip on it.", "track_type": p.get("track_type"), "track_index": p.get("index"), "items_lost": item_count}, ) blocked = _consume_confirm_token(action="timeline.delete_track", params=p) if blocked: return blocked return {"success": bool(tl.DeleteTrack(p["track_type"], p["index"]))} elif action == "get_track_sub_type": return {"sub_type": tl.GetTrackSubType(p["track_type"], p["index"])} elif action == "set_track_enable": return {"success": bool(tl.SetTrackEnable(p["track_type"], p["index"], p["enabled"]))} elif action == "get_track_enabled": return {"enabled": bool(tl.GetIsTrackEnabled(p["track_type"], p["index"]))} elif action == "set_track_lock": return {"success": bool(tl.SetTrackLock(p["track_type"], p["index"], p["locked"]))} elif action == "get_track_locked": return {"locked": bool(tl.GetIsTrackLocked(p["track_type"], p["index"]))} elif action == "get_track_name": return {"name": tl.GetTrackName(p["track_type"], p["index"])} elif action == "set_track_name": return {"success": bool(tl.SetTrackName(p["track_type"], p["index"], p["name"]))} elif action == "get_items": items = tl.GetItemListInTrack(p["track_type"], p["index"]) return {"items": [{"name": it.GetName(), "id": it.GetUniqueId(), "start": it.GetStart(), "end": it.GetEnd(), "duration": it.GetDuration()} for it in (items or [])]} elif action == "delete_clips": # Find timeline items by unique IDs ids_set = set(p["clip_ids"]) found = [] for tt in ["video", "audio", "subtitle"]: for ti in range(1, tl.GetTrackCount(tt) + 1): for it in (tl.GetItemListInTrack(tt, ti) or []): if it.GetUniqueId() in ids_set: found.append(it) # B2 — ripple=True is catastrophic; require confirmation. ripple = bool(p.get("ripple", False)) if ripple: if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required(): return _issue_confirm_token( action="timeline.delete_clips_ripple", params=p, preview={"operation": "timeline.delete_clips", "ripple": True, "warning": "ripple=True closes the gap left by deleted items; cannot be selectively undone.", "clip_count": len(found), "clip_ids_found": [it.GetUniqueId() for it in found]}, ) blocked = _consume_confirm_token(action="timeline.delete_clips_ripple", params=p) if blocked: return blocked return {"success": bool(tl.DeleteClips(found, ripple))} elif action == "set_clips_linked": ids_set = set(p["clip_ids"]) found = [] for tt in ["video", "audio"]: for ti in range(1, tl.GetTrackCount(tt) + 1): for it in (tl.GetItemListInTrack(tt, ti) or []): if it.GetUniqueId() in ids_set: found.append(it) return {"success": bool(tl.SetClipsLinked(found, p["linked"]))} elif action == "duplicate": dup = tl.DuplicateTimeline(p.get("name", tl.GetName() + " Copy")) return _ok(name=dup.GetName()) if dup else _err("Failed to duplicate") elif action == "duplicate_clips": return _timeline_duplicate_clips_impl(proj, tl, p) elif action == "copy_clips": return _timeline_duplicate_clips_impl(proj, tl, p) elif action == "move_clips": return _timeline_duplicate_clips_impl(proj, tl, p, delete_sources=True) elif action in {"copy_range", "duplicate_range"}: return _timeline_copy_range_impl(proj, tl, p) elif action == "overwrite_range": return _timeline_copy_range_impl(proj, tl, p, overwrite=True) elif action == "lift_range": return _timeline_lift_range_impl(tl, p) elif action == "story_spine_report": return _timeline_story_spine_report(tl, p) elif action == "create_variant_from_ranges": return _timeline_create_variant_from_ranges(proj, tl, p) elif action == "bulk_set_item_properties": return _timeline_bulk_set_item_properties(tl, p) elif action == "apply_look_to_items": return _timeline_apply_look_to_items(tl, p) elif action == "thumbnail_contact_sheet": return _timeline_thumbnail_contact_sheet(proj, tl, p) elif action == "marker_thumbnail_review": return _timeline_marker_thumbnail_review(proj, tl, p) elif action == "edit_kernel_capabilities": return _timeline_edit_kernel_capabilities() elif action == "probe_edit_kernel_item": return _timeline_probe_edit_kernel_item(tl, p) elif action == "title_property_scan": return _timeline_title_property_scan(tl, p) elif action == "set_title_text": return _timeline_set_title_text(tl, p) elif action == "bulk_set_title_text": return _timeline_bulk_set_title_text(tl, p) elif action == "create_compound_clip": ids_set = set(p["clip_ids"]) found = [] for tt in ["video", "audio", "subtitle"]: for ti in range(1, (tl.GetTrackCount(tt) or 0) + 1): for it in (tl.GetItemListInTrack(tt, ti) or []): if it.GetUniqueId() in ids_set: found.append(it) if not found: return _err("None of the provided clip IDs were found in the timeline") result = tl.CreateCompoundClip(found, p.get("info", {})) return _ok() if result else _err("Failed to create compound clip") elif action == "create_fusion_clip": ids_set = set(p["clip_ids"]) found = [] for tt in ["video", "audio", "subtitle"]: for ti in range(1, (tl.GetTrackCount(tt) or 0) + 1): for it in (tl.GetItemListInTrack(tt, ti) or []): if it.GetUniqueId() in ids_set: found.append(it) if not found: return _err("None of the provided clip IDs were found in the timeline") result = tl.CreateFusionClip(found) return _ok() if result else _err("Failed to create Fusion clip") elif action == "import_into_timeline": return {"success": bool(tl.ImportIntoTimeline(p["path"], p.get("options", {})))} elif action == "export": return {"success": bool(tl.Export(p["path"], p["type"], p.get("subtype", "")))} elif action == "get_setting": return {"settings": _ser(tl.GetSetting(p.get("name", "")))} elif action == "set_setting": return {"success": bool(tl.SetSetting(p["name"], p["value"]))} elif action == "insert_generator": r = tl.InsertGeneratorIntoTimeline(p["name"]) return _ok() if r else _err("Failed to insert generator") elif action == "insert_fusion_generator": r = tl.InsertFusionGeneratorIntoTimeline(p["name"]) return _ok() if r else _err("Failed to insert Fusion generator") elif action == "insert_fusion_composition": r = tl.InsertFusionCompositionIntoTimeline() return _ok() if r else _err("Failed to insert Fusion composition") elif action == "insert_ofx_generator": r = tl.InsertOFXGeneratorIntoTimeline(p["name"]) return _ok() if r else _err("Failed to insert OFX generator") elif action == "insert_title": r = tl.InsertTitleIntoTimeline(p["name"]) return _ok() if r else _err("Failed to insert title") elif action == "insert_fusion_title": r = tl.InsertFusionTitleIntoTimeline(p["name"]) return _ok() if r else _err("Failed to insert Fusion title") elif action == "get_unique_id": return {"id": tl.GetUniqueId()} elif action == "get_node_graph": g = tl.GetNodeGraph() return {"available": g is not None} elif action == "get_media_pool_item": mpi = tl.GetMediaPoolItem() return {"name": mpi.GetName(), "id": mpi.GetUniqueId()} if mpi else {"name": None, "id": None} elif action == "get_transcript": return _timeline_transcript(tl, with_timecodes=bool(p.get("with_timecodes"))) elif action == "propose_cuts": # Dry-run only: detect mechanical cuts (fillers, long pauses, repeats) # from the timeline's subtitle transcript. Proposes; never edits. cues = p.get("cues") if cues is None: cues = _timeline_transcript(tl, with_timecodes=True)["cues"] return _build_cut_list(cues, long_pause_frames=int(p.get("long_pause_frames", 48))) elif action == "apply_cuts": # Apply a CutList (from propose_cuts) to the timeline. DRY-RUN by default; # applying is destructive (confirm-token gated, a version is archived # first by the destructive hook). Cuts are applied latest-first so ripple # deletes don't invalidate earlier spans. cuts = p.get("cuts") if not isinstance(cuts, list): return _err("apply_cuts requires 'cuts' (a list, e.g. from propose_cuts)") applicable = [ c for c in cuts if isinstance(c, dict) and c.get("action") in ("lift", "ripple_delete") and isinstance(c.get("span"), dict) and c["span"].get("start") is not None and c["span"].get("end") is not None ] applicable.sort(key=lambda c: c["span"]["start"], reverse=True) plan = [{"action": c["action"], "span": c["span"], "kind": c.get("kind")} for c in applicable] if p.get("dry_run", True): return { "dry_run": True, "would_apply": len(applicable), "plan": plan, "note": "No edits made. Re-run with dry_run=false (and a confirm_token) to apply.", } if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required(): return _issue_confirm_token( action="timeline.apply_cuts", params=p, preview={ "operation": "timeline.apply_cuts", "warning": "Applies lift/ripple deletes to the timeline; ripple closes " "gaps and cannot be selectively undone. A timeline version is " "archived first.", "would_apply": len(applicable), "plan": plan, }, ) blocked = _consume_confirm_token(action="timeline.apply_cuts", params=p) if blocked: return blocked allow_partial = bool(p.get("allow_partial_item_delete", True)) results = [] for c in applicable: sp = c["span"] res = _timeline_lift_range_impl(tl, { "start_frame": sp["start"], "end_frame": sp["end"], "ripple": c["action"] == "ripple_delete", "allow_partial_item_delete": allow_partial, }) results.append({"action": c["action"], "span": sp, "result": res}) applied = sum(1 for r in results if isinstance(r["result"], dict) and r["result"].get("success")) return {"success": True, "applied": applied, "total": len(applicable), "results": results} elif action == "get_mark_in_out": return _ser(tl.GetMarkInOut()) elif action == "set_mark_in_out": err, clean = _validate_params( p, {"mark_in": {"type": int, "required": True}, "mark_out": {"type": int, "required": True}}, invariants=[lambda c: f"mark_in ({c['mark_in']}) must be <= mark_out ({c['mark_out']})" if c["mark_in"] > c["mark_out"] else None], ) if err: return _err(err) return {"success": bool(tl.SetMarkInOut(clean["mark_in"], clean["mark_out"], p.get("type", "all")))} elif action == "clear_mark_in_out": return {"success": bool(tl.ClearMarkInOut(p.get("type", "all")))} elif action == "convert_to_stereo": return {"success": bool(tl.ConvertTimelineToStereo())} elif action == "get_items_in_track": return {"items": _ser(tl.GetItemListInTrack(p["track_type"], p["track_index"]))} elif action == "get_voice_isolation_state": missing = _requires_method(tl, "GetVoiceIsolationState", "20.1") if missing: return missing state = tl.GetVoiceIsolationState(p["track_index"]) return _ser(state) if state else {"isEnabled": False, "amount": 0} elif action == "set_voice_isolation_state": missing = _requires_method(tl, "SetVoiceIsolationState", "20.1") if missing: return missing return {"success": bool(tl.SetVoiceIsolationState(p["track_index"], p["state"]))} elif action == "extract_source_frame_ranges": p = params or {} handles = int(p.get("handles", 24)) gap_max = int(p.get("gap_max", 30)) audio_ext = tuple( x.lower() for x in p.get( "skip_extensions", (".wav", ".mp3", ".aiff", ".aac", ".m4a"), ) ) def _ifr(v): """Resolve sometimes returns None — skip clip if unset.""" if v is None: return None try: return int(round(float(v))) except (TypeError, ValueError): return None clip_rows = [] nvid = tl.GetTrackCount("video") or 0 for track_index in range(1, nvid + 1): clips = tl.GetItemListInTrack("video", track_index) or [] for clip in clips: name = clip.GetName() or "" try: mpi = clip.GetMediaPoolItem() if mpi: fp = mpi.GetClipProperty("File Path") if fp: name = os.path.basename(str(fp).replace("\\", "/")) except Exception: pass low = name.lower() if any(low.endswith(ext) for ext in audio_ext): continue try: t_start = _ifr(clip.GetStart()) t_end_excl = _ifr(clip.GetEnd()) lo = _ifr(clip.GetLeftOffset()) if lo is None and _has_method(clip, "GetSourceStartFrame"): lo = _ifr(clip.GetSourceStartFrame()) except Exception: continue if t_start is None or t_end_excl is None or lo is None: continue duration_tl = t_end_excl - t_start if duration_tl < 0: continue source_boundary = lo + duration_tl timeline_end_inc = t_end_excl - 1 clip_rows.append({ "clip": clip, "name": name, "track": track_index, "timeline_start": t_start, "timeline_end_inclusive": timeline_end_inc, "source_boundary": source_boundary, "offset": lo, }) frame_ranges: Dict[str, List[List[int]]] = {} occurrences = [] for row in clip_rows: clip = row["clip"] name = row["name"] track_index = row["track"] t_start = row["timeline_start"] t_end_inc = row["timeline_end_inclusive"] source_start = row["offset"] source_end = row["source_boundary"] clips_on_track = tl.GetItemListInTrack("video", track_index) or [] max_handle = handles if handles == 0: max_handle = 0 for other in clips_on_track: oe = _ifr(other.GetEnd()) os_ = _ifr(other.GetStart()) if oe is None or os_ is None: continue other_end_inc = oe - 1 if other_end_inc < t_start: gap = t_start - other_end_inc - 1 if 0 < gap <= gap_max: max_handle = max(max_handle, gap) if os_ > t_end_inc: gap = os_ - t_end_inc - 1 if 0 < gap <= gap_max: max_handle = max(max_handle, gap) final_s = max(0, int(source_start) - int(max_handle)) final_e = int(source_end) - 1 + int(max_handle) frame_ranges.setdefault(name, []).append([final_s, final_e]) uid = clip.GetUniqueId() occurrences.append({ "clip_name": name, "timeline_item_id": "" if uid is None else str(uid), "track": track_index, "timeline_start": t_start, "timeline_end_inclusive": t_end_inc, "source_used_inclusive_end": source_end - 1, "handle_frames_applied": max_handle, "source_range_final": [final_s, final_e], }) return _ok( timeline_name=tl.GetName() or "", handles_param=handles, gap_max=gap_max, clip_count=len(occurrences), frame_ranges=frame_ranges, occurrences=occurrences, notes=( "Same rules as Pr/extract_timeline_frames.py get_resolve_api_frames (video only, " "current timeline). frame_ranges lists can be merged/overlapped per clip name." ), ) elif action == "conform_capabilities": return _conform_capabilities() elif action == "probe_timeline_structure": return _timeline_conform_snapshot(tl, p) elif action == "detect_gaps_overlaps": return _detect_gaps_overlaps_from_snapshot(_timeline_conform_snapshot(tl, p), p) elif action == "source_range_report": return _source_ranges_from_snapshot(_timeline_conform_snapshot(tl, p), p) elif action == "export_timeline_checked": return _export_timeline_checked(tl, p) elif action == "import_timeline_checked": _, _, mp, mp_err = _get_mp() if mp_err: return mp_err return _import_timeline_checked(proj, mp, p) elif action == "compare_timelines": return _compare_timelines(proj, tl, p) elif action == "probe_interchange_roundtrip": _, _, mp, mp_err = _get_mp() if mp_err: return mp_err return _probe_interchange_roundtrip(proj, mp, tl, p) elif action == "detect_missing_media": return _detect_missing_media(tl, p) elif action == "build_relink_plan": return _build_relink_plan(tl, p) elif action == "conform_boundary_report": return _conform_boundary_report(tl, p) elif action == "audio_capabilities": return _audio_capabilities() elif action == "probe_audio_item": return _probe_audio_item(tl, p) elif action == "probe_audio_track": return _audio_track_probe(tl, p) elif action == "safe_set_audio_properties": return _safe_set_audio_properties(tl, p) elif action == "audio_mix_capability_report": _, _, mp, mp_err = _get_mp() if mp_err: return mp_err return _audio_mix_capability_report(proj, mp, tl, p) elif action == "voice_isolation_capabilities": return _voice_isolation_capabilities(tl, p) elif action == "audio_mapping_report": _, _, mp, mp_err = _get_mp() if mp_err: return mp_err return _audio_mapping_report(mp, tl, p) elif action == "safe_auto_sync_audio": _, _, mp, mp_err = _get_mp() if mp_err: return mp_err return _safe_auto_sync_audio(mp, p) elif action == "transcription_capabilities": _, _, mp, mp_err = _get_mp() if mp_err: return mp_err return _transcription_capabilities(mp, p) elif action == "subtitle_generation_probe": return _subtitle_generation_probe(tl, p) elif action == "fairlight_boundary_report": _, _, mp, mp_err = _get_mp() if mp_err: return mp_err return _fairlight_boundary_report(proj, mp, tl, p) return _unknown(action, ["list","get_current","set_current","get_name","set_name","get_start_frame","get_end_frame","get_start_timecode","set_start_timecode","get_track_count","add_track","delete_track","get_track_sub_type","set_track_enable","get_track_enabled","set_track_lock","get_track_locked","get_track_name","set_track_name","get_items","delete_clips","set_clips_linked","duplicate","duplicate_clips","copy_clips","move_clips","copy_range","duplicate_range","overwrite_range","lift_range","story_spine_report","create_variant_from_ranges","bulk_set_item_properties","apply_look_to_items","thumbnail_contact_sheet","marker_thumbnail_review","edit_kernel_capabilities","probe_edit_kernel_item","title_property_scan","set_title_text","bulk_set_title_text","create_compound_clip","create_fusion_clip","import_into_timeline","export","get_setting","set_setting","insert_generator","insert_fusion_generator","insert_fusion_composition","insert_ofx_generator","insert_title","insert_fusion_title","get_unique_id","get_node_graph","get_media_pool_item","get_transcript","propose_cuts","apply_cuts","get_mark_in_out","set_mark_in_out","clear_mark_in_out","convert_to_stereo","get_items_in_track","get_voice_isolation_state","set_voice_isolation_state","extract_source_frame_ranges","audio_mix_capability_report",*_TIMELINE_CONFORM_KERNEL_ACTIONS,*_TIMELINE_AUDIO_KERNEL_ACTIONS]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 16: timeline_markers # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() @_destructive_op("timeline_markers") def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> Any: """Markers and playhead operations on the current timeline. Actions: add(frame|frame_id|frameId|timecode?, color?, name?, note?, duration?, custom_data?) -> {success, frame} If frame/timecode is omitted, add uses the current playhead timecode. get_all() -> {markers} get_by_custom_data(custom_data) -> {markers} update_custom_data(frame|frame_id|frameId|timecode, custom_data) -> {success} get_custom_data(frame|frame_id|frameId|timecode) -> {data} delete_by_color(color) -> {success} delete_at_frame(frame|frame_id|frameId|timecode) -> {success} delete_by_custom_data(custom_data) -> {success} get_current_timecode() -> {timecode} set_current_timecode(timecode) -> {success} get_current_video_item() -> {name, id} get_thumbnail() -> {thumbnail} get_thumbnail_image() -> MCP image content for the current Color-page frame annotation_capabilities() -> {scopes, marker_colors, frame_aliases} probe_annotations(scope?, ...) -> {scopes, count} normalize_marker_payload(frame|timecode?, color?, name?, note?, duration?, custom_data?) -> {marker} copy_annotations(source={scope,...}, target={scope,...}, include_flags?, include_clip_color?) -> {success} move_annotations(source={scope,...}, target={scope,...}) -> {success} sync_marker_custom_data(scope?, frame|timecode, custom_data, ...) -> {success} clear_annotations_by_scope(scope?, color?, custom_data?, all?, clear_flags?, clear_clip_color?) -> {success} export_review_report(scope?, include_capabilities?) -> {title, annotations, capabilities?} annotation_boundary_report(scope?) -> {capabilities, annotations} """ p = params or {} _, tl, err = _get_tl() if err: return err if action == "add": marker, marker_err = _marker_add_payload(p, tl=tl, default_to_current=True) if marker_err: return marker_err return _add_marker(tl, marker) elif action == "get_all": return {"markers": _ser(tl.GetMarkers())} elif action == "get_by_custom_data": return {"markers": _ser(tl.GetMarkerByCustomData(_first_param(p, "custom_data", "customData", default="")))} elif action == "update_custom_data": frame, frame_err = _marker_frame_from_params(p, tl=tl) if frame_err: return frame_err return {"success": bool(tl.UpdateMarkerCustomData(frame, _first_param(p, "custom_data", "customData", default="")))} elif action == "get_custom_data": frame, frame_err = _marker_frame_from_params(p, tl=tl) if frame_err: return frame_err return {"data": tl.GetMarkerCustomData(frame)} elif action == "delete_by_color": return {"success": bool(tl.DeleteMarkersByColor(p["color"]))} elif action == "delete_at_frame": frame, frame_err = _marker_frame_from_params(p, tl=tl) if frame_err: return frame_err return {"success": bool(tl.DeleteMarkerAtFrame(frame))} elif action == "delete_by_custom_data": return {"success": bool(tl.DeleteMarkerByCustomData(_first_param(p, "custom_data", "customData", default="")))} elif action == "get_current_timecode": return {"timecode": tl.GetCurrentTimecode()} elif action == "set_current_timecode": return {"success": bool(tl.SetCurrentTimecode(p["timecode"]))} elif action == "get_current_video_item": it = tl.GetCurrentVideoItem() return {"name": it.GetName(), "id": it.GetUniqueId()} if it else {"name": None, "id": None} elif action == "get_thumbnail": return _ser(tl.GetCurrentClipThumbnailImage()) elif action == "get_thumbnail_image": thumbnail = tl.GetCurrentClipThumbnailImage() if not thumbnail: return _err("No thumbnail available. Open the Color page with a current clip selected.") try: return Image(data=_thumbnail_data_to_png_bytes(thumbnail), format="png") except ValueError as exc: return _err(str(exc)) elif action == "annotation_capabilities": return _annotation_capabilities() elif action == "probe_annotations": return _probe_annotations(tl, p) elif action == "normalize_marker_payload": return _normalize_marker_payload_action(tl, p) elif action == "copy_annotations": return _copy_annotations(tl, p) elif action == "move_annotations": return _copy_annotations(tl, p, move=True) elif action == "sync_marker_custom_data": return _sync_marker_custom_data(tl, p) elif action == "clear_annotations_by_scope": return _clear_annotations_by_scope(tl, p) elif action == "export_review_report": return _export_review_report(tl, p) elif action == "annotation_boundary_report": return _annotation_boundary_report(tl, p) return _unknown(action, ["add","get_all","get_by_custom_data","update_custom_data","get_custom_data","delete_by_color","delete_at_frame","delete_by_custom_data","get_current_timecode","set_current_timecode","get_current_video_item","get_thumbnail","get_thumbnail_image",*_ANNOTATION_KERNEL_ACTIONS]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 17: timeline_ai # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() @_destructive_op("timeline_ai") def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """AI and analysis operations on the current timeline. Actions: create_subtitles(settings?) -> {success} — auto-caption from audio detect_scene_cuts() -> {success} analyze_dolby_vision(clip_ids?, analysis_type?) -> {success} grab_still() -> {success} grab_all_stills(source?) -> {count} """ p = params or {} _, tl, err = _get_tl() if err: return err if action == "create_subtitles": return {"success": bool(tl.CreateSubtitlesFromAudio(p.get("settings", {})))} elif action == "detect_scene_cuts": return {"success": bool(tl.DetectSceneCuts())} elif action == "analyze_dolby_vision": clip_ids = p.get("clip_ids", []) items = [] if clip_ids: for tt in ["video"]: for ti in range(1, tl.GetTrackCount(tt) + 1): for it in (tl.GetItemListInTrack(tt, ti) or []): if it.GetUniqueId() in clip_ids: items.append(it) analysis_type = p.get("analysis_type") return {"success": bool(tl.AnalyzeDolbyVision(items, analysis_type))} elif action == "grab_still": still = tl.GrabStill() return _ok() if still else _err("Failed to grab still") elif action == "grab_all_stills": stills = tl.GrabAllStills(p.get("source", 1)) return {"count": len(stills) if stills else 0} return _unknown(action, ["create_subtitles","detect_scene_cuts","analyze_dolby_vision","grab_still","grab_all_stills"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 18: timeline_item # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() @_destructive_op("timeline_item") def timeline_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Properties, transforms, speed, keyframes, and metadata for a timeline item. Identify by track_type, track_index, item_index. Actions: get_name(track_type?, track_index?, item_index?) -> {name} get_property(key?, ...) -> {properties} set_property(key, value, ...) -> {success} get_duration(...) -> {duration} get_start(...) -> {start} get_end(...) -> {end} get_source_start_frame(...) -> {frame} get_source_end_frame(...) -> {frame} get_source_start_time(...) -> {time} get_source_end_time(...) -> {time} get_left_offset(...) -> {offset} get_right_offset(...) -> {offset} set_clip_enabled(enabled, ...) -> {success} get_clip_enabled(...) -> {enabled} update_sidecar(...) -> {success} get_unique_id(...) -> {id} get_media_pool_item(...) -> {name, id} get_stereo_convergence(...) -> {values} get_stereo_left_window(...) -> {params} get_stereo_right_window(...) -> {params} get_linked_items(...) -> {items} get_track_type_and_index(...) -> {track_type, track_index} get_source_audio_mapping(...) -> {mapping} load_burnin_preset(name, ...) -> {success} set_name(name, ...) -> {success} get_voice_isolation_state(...) -> {state} set_voice_isolation_state(state, ...) -> {success} get_retime(...) -> {process, motion_estimation} set_retime(process?, motion_estimation?, ...) -> {success} — process: nearest, frame_blend, optical_flow (or 0-3); motion_estimation: 0-6 get_transform(...) -> {Pan, Tilt, ZoomX, ZoomY, RotationAngle, ...} set_transform(Pan?, Tilt?, ZoomX?, ZoomY?, RotationAngle?, AnchorPointX?, AnchorPointY?, Pitch?, Yaw?, FlipX?, FlipY?, ...) -> {success} get_crop(...) -> {CropLeft, CropRight, CropTop, CropBottom, CropSoftness, CropRetain} set_crop(CropLeft?, CropRight?, CropTop?, CropBottom?, CropSoftness?, CropRetain?, ...) -> {success} get_composite(...) -> {Opacity, CompositeMode} set_composite(Opacity?, CompositeMode?, ...) -> {success} get_audio(...) -> {Volume, Pan, AudioSyncOffset, ...} set_audio(Volume?, Pan?, ...) -> {success} get_keyframes(property, ...) -> {property, count, keyframes} add_keyframe(property, frame, value, ...) -> {success} modify_keyframe(property, frame, new_value?, new_frame?, ...) -> {success} delete_keyframe(property, frame, ...) -> {success} set_keyframe_interpolation(property, frame, interpolation, ...) -> {success} — Linear, Bezier, EaseIn, EaseOut, EaseInOut Default: track_type="video", track_index=1, item_index=0 """ p = params or {} tl, item, err = _get_item(p) if err: return err if action == "get_name": return {"name": item.GetName()} elif action == "get_property": return {"properties": _ser(item.GetProperty(p.get("key", "")))} elif action == "set_property": return {"success": bool(item.SetProperty(p["key"], p["value"]))} elif action == "get_duration": return {"duration": item.GetDuration()} elif action == "get_start": return {"start": item.GetStart()} elif action == "get_end": return {"end": item.GetEnd()} elif action == "get_source_start_frame": return {"frame": item.GetSourceStartFrame()} elif action == "get_source_end_frame": return {"frame": item.GetSourceEndFrame()} elif action == "get_source_start_time": return {"time": item.GetSourceStartTime()} elif action == "get_source_end_time": return {"time": item.GetSourceEndTime()} elif action == "get_left_offset": return {"offset": item.GetLeftOffset()} elif action == "get_right_offset": return {"offset": item.GetRightOffset()} elif action == "set_clip_enabled": return {"success": bool(item.SetClipEnabled(p["enabled"]))} elif action == "get_clip_enabled": return {"enabled": bool(item.GetClipEnabled())} elif action == "update_sidecar": return {"success": bool(item.UpdateSidecar())} elif action == "get_unique_id": return {"id": item.GetUniqueId()} elif action == "get_media_pool_item": mpi = item.GetMediaPoolItem() return {"name": mpi.GetName(), "id": mpi.GetUniqueId()} if mpi else {"name": None, "id": None} elif action == "get_stereo_convergence": return {"values": _ser(item.GetStereoConvergenceValues())} elif action == "get_stereo_left_window": return {"params": _ser(item.GetStereoLeftFloatingWindowParams())} elif action == "get_stereo_right_window": return {"params": _ser(item.GetStereoRightFloatingWindowParams())} elif action == "get_linked_items": linked = item.GetLinkedItems() or [] return {"items": [{"name": it.GetName(), "id": it.GetUniqueId()} for it in linked]} elif action == "get_track_type_and_index": result = item.GetTrackTypeAndIndex() return {"track_type": result[0], "track_index": result[1]} if result else _err("Failed") elif action == "get_source_audio_mapping": return {"mapping": item.GetSourceAudioChannelMapping()} elif action == "load_burnin_preset": return {"success": bool(item.LoadBurnInPreset(p["name"]))} elif action == "set_name": missing = _requires_method(item, "SetName", "20.2") if missing: return missing return {"success": bool(item.SetName(p["name"]))} elif action == "get_voice_isolation_state": missing = _requires_method(item, "GetVoiceIsolationState", "20.1") if missing: return missing state = item.GetVoiceIsolationState() return {"state": _ser(state) if state else {"isEnabled": False, "amount": 0}} elif action == "set_voice_isolation_state": missing = _requires_method(item, "SetVoiceIsolationState", "20.1") if missing: return missing return {"success": bool(item.SetVoiceIsolationState(p["state"]))} # ── Retime ── elif action == "get_retime": return {"process": item.GetProperty("RetimeProcess"), "motion_estimation": item.GetProperty("MotionEstimation")} elif action == "set_retime": # RetimeProcess: 0=project, 1=nearest, 2=frame_blend, 3=optical_flow # MotionEstimation: 0=project, 1=standard_faster, 2=standard_better, 3=enhanced_faster, 4=enhanced_better, 5=speed_warp_better, 6=speed_warp_faster process_map = {"project": 0, "nearest": 1, "frame_blend": 2, "optical_flow": 3} results = {} if "process" in p: val = p["process"] if isinstance(val, str): val = process_map.get(val.lower()) if val is None: return _err(f"Invalid process. Use: {', '.join(process_map.keys())} or integer 0-3") results["process"] = bool(item.SetProperty("RetimeProcess", val)) if "motion_estimation" in p: results["motion_estimation"] = bool(item.SetProperty("MotionEstimation", p["motion_estimation"])) return _ok(**results) if results else _err("Specify process (0-3 or name) and/or motion_estimation (0-6)") # ── Transform ── elif action == "get_transform": keys = ["Pan", "Tilt", "ZoomX", "ZoomY", "ZoomGang", "RotationAngle", "AnchorPointX", "AnchorPointY", "Pitch", "Yaw", "FlipX", "FlipY"] return {k: item.GetProperty(k) for k in keys} elif action == "set_transform": valid = {"Pan", "Tilt", "ZoomX", "ZoomY", "ZoomGang", "RotationAngle", "AnchorPointX", "AnchorPointY", "Pitch", "Yaw", "FlipX", "FlipY"} results = {} for k, v in p.items(): if k in valid: results[k] = bool(item.SetProperty(k, v)) return _ok(**results) if results else _err(f"Specify one or more of: {', '.join(sorted(valid))}") # ── Crop ── elif action == "get_crop": keys = ["CropLeft", "CropRight", "CropTop", "CropBottom", "CropSoftness", "CropRetain"] return {k: item.GetProperty(k) for k in keys} elif action == "set_crop": valid = {"CropLeft", "CropRight", "CropTop", "CropBottom", "CropSoftness", "CropRetain"} results = {} for k, v in p.items(): if k in valid: results[k] = bool(item.SetProperty(k, v)) return _ok(**results) if results else _err(f"Specify one or more of: {', '.join(sorted(valid))}") # ── Composite ── elif action == "get_composite": return {"Opacity": item.GetProperty("Opacity"), "CompositeMode": item.GetProperty("CompositeMode")} elif action == "set_composite": results = {} if "Opacity" in p: results["Opacity"] = bool(item.SetProperty("Opacity", p["Opacity"])) if "CompositeMode" in p: results["CompositeMode"] = bool(item.SetProperty("CompositeMode", p["CompositeMode"])) return _ok(**results) if results else _err("Specify Opacity and/or CompositeMode") # ── Audio ── elif action == "get_audio": keys = ["Volume", "Pan", "AudioSyncOffsetIsManual", "AudioSyncOffset"] return {k: item.GetProperty(k) for k in keys} elif action == "set_audio": valid = {"Volume", "Pan", "AudioSyncOffsetIsManual", "AudioSyncOffset"} results = {} for k, v in p.items(): if k in valid: results[k] = bool(item.SetProperty(k, v)) return _ok(**results) if results else _err(f"Specify one or more of: {', '.join(sorted(valid))}") # ── Keyframes ── elif action == "get_keyframes": prop = p["property"] count = item.GetKeyframeCount(prop) if count == 0: return {"property": prop, "count": 0, "keyframes": []} kfs = [] for i in range(count): kf = item.GetKeyframeAtIndex(prop, i) val = item.GetPropertyAtKeyframeIndex(prop, i) kfs.append({"frame": kf.get("frame") if isinstance(kf, dict) else kf, "value": val}) return {"property": prop, "count": count, "keyframes": kfs} elif action == "add_keyframe": return {"success": bool(item.AddKeyframe(p["property"], p["frame"], p["value"]))} elif action == "modify_keyframe": kw = {} if "new_value" in p: kw["value"] = p["new_value"] if "new_frame" in p: kw["frame"] = p["new_frame"] return {"success": bool(item.ModifyKeyframe(p["property"], p["frame"], **kw))} elif action == "delete_keyframe": return {"success": bool(item.DeleteKeyframe(p["property"], p["frame"]))} elif action == "set_keyframe_interpolation": valid = ["Linear", "Bezier", "EaseIn", "EaseOut", "EaseInOut"] if p.get("interpolation") not in valid: return _err(f"Invalid interpolation. Must be one of: {', '.join(valid)}") return {"success": bool(item.SetKeyframeInterpolation(p["property"], p["frame"], p["interpolation"]))} return _unknown(action, ["get_name","get_property","set_property","get_duration","get_start","get_end","get_source_start_frame","get_source_end_frame","get_source_start_time","get_source_end_time","get_left_offset","get_right_offset","set_clip_enabled","get_clip_enabled","update_sidecar","get_unique_id","get_media_pool_item","get_stereo_convergence","get_stereo_left_window","get_stereo_right_window","get_linked_items","get_track_type_and_index","get_source_audio_mapping","load_burnin_preset","set_name","get_voice_isolation_state","set_voice_isolation_state","get_retime","set_retime","get_transform","set_transform","get_crop","set_crop","get_composite","set_composite","get_audio","set_audio","get_keyframes","add_keyframe","modify_keyframe","delete_keyframe","set_keyframe_interpolation"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 19: timeline_item_markers # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() @_destructive_op("timeline_item_markers") def timeline_item_markers(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Markers, flags, and clip color on timeline items. Identify by track_type, track_index, item_index. Actions: add(frame|frame_id|frameId, color?, name?, note?, duration?, custom_data?, ...) -> {success, frame} get_all(...) -> {markers} get_by_custom_data(custom_data, ...) -> {markers} update_custom_data(frame|frame_id|frameId, custom_data, ...) -> {success} get_custom_data(frame|frame_id|frameId, ...) -> {data} delete_by_color(color, ...) -> {success} delete_at_frame(frame|frame_id|frameId, ...) -> {success} delete_by_custom_data(custom_data, ...) -> {success} add_flag(color, ...) -> {success} get_flags(...) -> {flags} clear_flags(color, ...) -> {success} get_clip_color(...) -> {color} set_clip_color(color, ...) -> {success} clear_clip_color(...) -> {success} Default: track_type="video", track_index=1, item_index=0 """ p = params or {} _, item, err = _get_item(p) if err: return err if action == "add": marker, marker_err = _marker_add_payload(p) if marker_err: return marker_err return _add_marker(item, marker) elif action == "get_all": return {"markers": _ser(item.GetMarkers())} elif action == "get_by_custom_data": return {"markers": _ser(item.GetMarkerByCustomData(_first_param(p, "custom_data", "customData", default="")))} elif action == "update_custom_data": frame, frame_err = _marker_frame_from_params(p) if frame_err: return frame_err return {"success": bool(item.UpdateMarkerCustomData(frame, _first_param(p, "custom_data", "customData", default="")))} elif action == "get_custom_data": frame, frame_err = _marker_frame_from_params(p) if frame_err: return frame_err return {"data": item.GetMarkerCustomData(frame)} elif action == "delete_by_color": return {"success": bool(item.DeleteMarkersByColor(p["color"]))} elif action == "delete_at_frame": frame, frame_err = _marker_frame_from_params(p) if frame_err: return frame_err return {"success": bool(item.DeleteMarkerAtFrame(frame))} elif action == "delete_by_custom_data": return {"success": bool(item.DeleteMarkerByCustomData(_first_param(p, "custom_data", "customData", default="")))} elif action == "add_flag": return {"success": bool(item.AddFlag(p["color"]))} elif action == "get_flags": return {"flags": item.GetFlagList()} elif action == "clear_flags": return {"success": bool(item.ClearFlags(p["color"]))} elif action == "get_clip_color": return {"color": item.GetClipColor()} elif action == "set_clip_color": return {"success": bool(item.SetClipColor(p["color"]))} elif action == "clear_clip_color": return {"success": bool(item.ClearClipColor())} return _unknown(action, ["add","get_all","get_by_custom_data","update_custom_data","get_custom_data","delete_by_color","delete_at_frame","delete_by_custom_data","add_flag","get_flags","clear_flags","get_clip_color","set_clip_color","clear_clip_color"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 20: timeline_item_fusion # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() @_destructive_op("timeline_item_fusion") def timeline_item_fusion(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Fusion composition operations on timeline items. Identify by track_type, track_index, item_index. Actions: add_comp(...) -> {success} get_comp_count(...) -> {count} get_comp_names(...) -> {names} get_comp_by_name(name, ...) -> {available} get_comp_by_index(index, ...) -> {available} export_comp(path, index, ...) -> {success} import_comp(path, ...) -> {success} delete_comp(name, ...) -> {success} load_comp(name, ...) -> {success} rename_comp(old_name, new_name, ...) -> {success} get_cache_enabled(...) -> {enabled} — Fusion output cache status set_cache(value, ...) -> {success} — value: "Auto", "On", or "Off" Default: track_type="video", track_index=1, item_index=0 """ p = params or {} _, item, err = _get_item(p) if err: return err if action == "add_comp": comp = item.AddFusionComp() return _ok() if comp else _err("Failed to add Fusion comp") elif action == "get_comp_count": return {"count": item.GetFusionCompCount()} elif action == "get_comp_names": return {"names": _ser(item.GetFusionCompNameList())} elif action == "get_comp_by_name": comp = item.GetFusionCompByName(p["name"]) return {"available": comp is not None} elif action == "get_comp_by_index": comp = item.GetFusionCompByIndex(p["index"]) return {"available": comp is not None} elif action == "export_comp": return {"success": bool(item.ExportFusionComp(p["path"], p["index"]))} elif action == "import_comp": comp = item.ImportFusionComp(p["path"]) return _ok() if comp else _err("Failed to import comp") elif action == "delete_comp": return {"success": bool(item.DeleteFusionCompByName(p["name"]))} elif action == "load_comp": comp = item.LoadFusionCompByName(p["name"]) return _ok() if comp else _err("Failed to load comp") elif action == "rename_comp": return {"success": bool(item.RenameFusionCompByName(p["old_name"], p["new_name"]))} elif action == "get_cache_enabled": return {"enabled": item.GetIsFusionOutputCacheEnabled()} elif action == "set_cache": return {"success": bool(item.SetFusionOutputCache(p["value"]))} return _unknown(action, ["add_comp","get_comp_count","get_comp_names","get_comp_by_name","get_comp_by_index","export_comp","import_comp","delete_comp","load_comp","rename_comp","get_cache_enabled","set_cache"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 21: timeline_item_color # ═══════════════════════════════════════════════════════════════════════════════ _COLOR_GRADE_KERNEL_ACTIONS = [ "grade_capabilities", "grade_evidence_base", "bulk_match_to_hero", "propose_grade", "probe_grade_item", "probe_node_graph", "safe_set_cdl", "safe_copy_grade", "safe_apply_drx", "safe_export_lut", "grade_version_snapshot", "grade_version_restore", "color_group_capabilities", "gallery_capabilities", "grade_boundary_report", ] _COLOR_ITEM_METHODS = [ "SetCDL", "CopyGrades", "AddVersion", "GetCurrentVersion", "GetVersionNameList", "LoadVersionByName", "RenameVersionByName", "DeleteVersionByName", "GetNodeGraph", "GetColorGroup", "AssignToColorGroup", "RemoveFromColorGroup", "ExportLUT", "GetIsColorOutputCacheEnabled", "SetColorOutputCache", "GetIsFusionOutputCacheEnabled", "SetFusionOutputCache", "ResetAllNodeColors", "Stabilize", "SmartReframe", "CreateMagicMask", "RegenerateMagicMask", ] _GRAPH_METHODS = [ "GetNumNodes", "GetLUT", "SetLUT", "GetNodeCacheMode", "SetNodeCacheMode", "GetNodeLabel", "GetToolsInNode", "SetNodeEnabled", "ApplyGradeFromDRX", "ApplyArriCdlLut", "ResetAllGrades", ] _LUT_EXPORT_TYPES = { "17": "EXPORT_LUT_17PTCUBE", "17pt": "EXPORT_LUT_17PTCUBE", "17ptcube": "EXPORT_LUT_17PTCUBE", "export_lut_17ptcube": "EXPORT_LUT_17PTCUBE", "33": "EXPORT_LUT_33PTCUBE", "33pt": "EXPORT_LUT_33PTCUBE", "33ptcube": "EXPORT_LUT_33PTCUBE", "export_lut_33ptcube": "EXPORT_LUT_33PTCUBE", "65": "EXPORT_LUT_65PTCUBE", "65pt": "EXPORT_LUT_65PTCUBE", "65ptcube": "EXPORT_LUT_65PTCUBE", "export_lut_65ptcube": "EXPORT_LUT_65PTCUBE", "panasonic": "EXPORT_LUT_PANASONICVLUT", "panasonicvlut": "EXPORT_LUT_PANASONICVLUT", "export_lut_panasonicvlut": "EXPORT_LUT_PANASONICVLUT", } def _grade_temp_path_ok(path): return _render_temp_path_ok(path) def _resolve_lut_export_type(export_type, resolve_obj=None): if isinstance(export_type, int) and not isinstance(export_type, bool): return export_type, None raw = str(export_type if export_type is not None else "33ptcube").strip() key = raw.lower().replace("-", "").replace("_", "").replace(" ", "") const_name = _LUT_EXPORT_TYPES.get(key) if not const_name and raw.startswith("EXPORT_LUT_"): const_name = raw if not const_name: return None, _err(f"Unknown LUT export type: {raw}") if resolve_obj and hasattr(resolve_obj, const_name): return getattr(resolve_obj, const_name), None return const_name, None def _validate_cdl_payload(cdl): if not isinstance(cdl, dict): return None, _err( "cdl must be an object", code="INVALID_CDL", category="invalid_input", remediation="Pass cdl as a dict with NodeIndex, Slope, Offset, Power, Saturation.", ) errors = [] node_index = cdl.get("NodeIndex", 1) if isinstance(node_index, bool): errors.append("NodeIndex must be an integer") else: try: node_index = int(node_index) if node_index < 1: errors.append("NodeIndex must be >= 1") except (TypeError, ValueError): errors.append("NodeIndex must be an integer") normalized = dict(cdl) normalized["NodeIndex"] = node_index for key in ("Slope", "Offset", "Power"): value = cdl.get(key, [1.0, 1.0, 1.0] if key != "Offset" else [0.0, 0.0, 0.0]) if isinstance(value, str): parts = value.split() elif isinstance(value, (list, tuple)): parts = list(value) else: errors.append(f"{key} must be a 3-value list or space-separated string") continue if len(parts) != 3: errors.append(f"{key} must contain exactly 3 values") continue try: normalized[key] = [float(part) for part in parts] except (TypeError, ValueError): errors.append(f"{key} values must be numeric") saturation = cdl.get("Saturation", 1.0) try: normalized["Saturation"] = float(saturation) except (TypeError, ValueError): errors.append("Saturation must be numeric") return {"valid": not errors, "errors": errors, "cdl": normalized}, None def _graph_snapshot(g, *, include_nodes=True, max_nodes=3): if not g: return {"available": False} out = { "available": True, "methods": _callable_method_names(g, _GRAPH_METHODS), "num_nodes": None, "nodes": [], "errors": [], } try: out["num_nodes"] = g.GetNumNodes() except Exception as exc: out["errors"].append({"method": "GetNumNodes", "error": str(exc)}) return out if not include_nodes: return out try: max_nodes = max(0, int(max_nodes)) except (TypeError, ValueError): max_nodes = 3 for node_index in range(1, min(out["num_nodes"] or 0, max_nodes) + 1): row = {"node_index": node_index} for key, method_name in ( ("lut", "GetLUT"), ("cache_mode", "GetNodeCacheMode"), ("label", "GetNodeLabel"), ("tools", "GetToolsInNode"), ): if not _has_method(g, method_name): continue try: row[key] = _ser(getattr(g, method_name)(node_index)) except Exception as exc: row.setdefault("errors", []).append({"method": method_name, "error": str(exc)}) out["nodes"].append(row) return out def _color_graph_from_params(proj, item, p: Dict[str, Any]): source = p.get("source", "item") if source == "item": graph = item.GetNodeGraph(p["layer_index"]) if "layer_index" in p else item.GetNodeGraph() return graph, "item", None if source == "timeline": _, tl, err = _get_tl() if err: return None, source, err return tl.GetNodeGraph(), "timeline", None if source in ("color_group_pre", "color_group_post"): group_name = p.get("group_name") if not group_name: return None, source, _err("group_name is required for color group graph sources") for group in proj.GetColorGroupsList() or []: if group.GetName() == group_name: graph = group.GetPreClipNodeGraph() if source == "color_group_pre" else group.GetPostClipNodeGraph() return graph, source, None return None, source, _err(f"Color group '{group_name}' not found") return None, source, _err(f"Unknown color graph source: {source}") def _grade_version_snapshot(item, p: Dict[str, Any]): out = {"current": None, "local": [], "remote": [], "errors": []} try: out["current"] = _ser(item.GetCurrentVersion()) except Exception as exc: out["errors"].append({"method": "GetCurrentVersion", "error": str(exc)}) for version_type, key in ((0, "local"), (1, "remote")): try: out[key] = _ser(item.GetVersionNameList(version_type) or []) except Exception as exc: out["errors"].append({"method": f"GetVersionNameList({version_type})", "error": str(exc)}) return out def _grade_item_snapshot(item, proj=None, p: Optional[Dict[str, Any]] = None): p = p or {} out = { "name": None, "id": None, "methods": _callable_method_names(item, _COLOR_ITEM_METHODS), "versions": _grade_version_snapshot(item, p), "node_graph": None, "color_group": None, "cache": {}, "errors": [], } for key, method_name in (("name", "GetName"), ("id", "GetUniqueId")): if _has_method(item, method_name): try: out[key] = getattr(item, method_name)() except Exception as exc: out["errors"].append({"method": method_name, "error": str(exc)}) try: graph = item.GetNodeGraph(p["layer_index"]) if "layer_index" in p else item.GetNodeGraph() out["node_graph"] = _graph_snapshot(graph, include_nodes=p.get("include_nodes", True), max_nodes=p.get("max_nodes", 3)) except Exception as exc: out["node_graph"] = {"available": False, "error": str(exc)} try: group = item.GetColorGroup() out["color_group"] = group.GetName() if group else None except Exception as exc: out["errors"].append({"method": "GetColorGroup", "error": str(exc)}) for key, method_name in ( ("color_output", "GetIsColorOutputCacheEnabled"), ("fusion_output", "GetIsFusionOutputCacheEnabled"), ): if _has_method(item, method_name): try: out["cache"][key] = _ser(getattr(item, method_name)()) except Exception as exc: out["errors"].append({"method": method_name, "error": str(exc)}) return out def _grade_capabilities(item, proj): gallery = proj.GetGallery() if proj and _has_method(proj, "GetGallery") else None groups = proj.GetColorGroupsList() if proj and _has_method(proj, "GetColorGroupsList") else [] return { "item_methods": _callable_method_names(item, _COLOR_ITEM_METHODS), "graph_sources": ["item", "timeline", "color_group_pre", "color_group_post"], "graph_methods": _GRAPH_METHODS, "lut_export_types": sorted(set(_LUT_EXPORT_TYPES.values())), "version_types": {"local": 0, "remote": 1}, "grade_modes": {"no_keyframes": 0, "source_timecode_aligned": 1, "start_frames_aligned": 2}, "gallery_available": gallery is not None, "color_group_count": len(groups or []), "guards": { "safe_export_lut_requires_temp_path": True, "safe_apply_drx_requires_temp_path_by_default": True, "safe_copy_grade_requires_target_timeline_item_ids": True, "ai_tools_report_callability_but_are_not_forced_by_boundary_report": True, }, "boundaries": [ "Node graph internals are only partially inspectable through Resolve's public API.", "ApplyGradeFromDRX replaces the target graph; there is no append mode.", "LUT export writes files and is guarded to temp paths by default.", "Stabilize, Smart Reframe, and Magic Mask can be asynchronous and page dependent.", ], } def _probe_color_node_graph(proj, item, p: Dict[str, Any]): graph, source, err = _color_graph_from_params(proj, item, p) if err: return err snapshot = _graph_snapshot( graph, include_nodes=p.get("include_nodes", True), max_nodes=p.get("max_nodes", 3), ) snapshot["source"] = source return snapshot def _safe_set_cdl(item, p: Dict[str, Any]): validation, err = _validate_cdl_payload(p.get("cdl")) if err: return err if not validation["valid"]: return {"success": False, "validation": validation} normalized = _normalize_cdl(validation["cdl"]) if p.get("dry_run"): return _ok(validation=validation, normalized=normalized) return { "success": bool(item.SetCDL(normalized)), "validation": validation, "normalized": normalized, } def _timeline_items_for_grade_copy(tl, target_ids): targets = [] missing = set(target_ids or []) if not target_ids: return targets, [] for track_index in range(1, tl.GetTrackCount("video") + 1): for candidate in (tl.GetItemListInTrack("video", track_index) or []): item_id = candidate.GetUniqueId() if item_id in missing: targets.append(candidate) missing.remove(item_id) return targets, sorted(missing) def _safe_copy_grade(item, p: Dict[str, Any]): target_ids = p.get("target_ids") or [] if not isinstance(target_ids, list) or not target_ids: return _err("target_ids must be a non-empty list of timeline item IDs", code="MISSING_TARGET_IDS", category="invalid_input") _, tl, err = _get_tl() if err: return err targets, missing = _timeline_items_for_grade_copy(tl, target_ids) target_summaries = [_timeline_item_summary(target) for target in targets] if p.get("dry_run"): return _ok(targets=target_summaries, missing=missing, would_copy=not missing) if missing: return {"success": False, "targets": target_summaries, "missing": missing} # F6 / B2 — Whole-grade copy is a destructive replacement on every target. # Gameplan §3 B2 lists safe_copy_grade among ops that must require confirmation. if "confirm_token" not in p and "confirmToken" not in p: if _confirm_token_required(): preview = { "operation": "safe_copy_grade", "warning": "Replaces the entire node graph on every successfully resolved target item.", "target_count": len(target_summaries), "target_ids": [t.get("timeline_item_id") for t in target_summaries], } return _issue_confirm_token(action="safe_copy_grade", params=p, preview=preview) blocked = _consume_confirm_token(action="safe_copy_grade", params=p) if blocked: return blocked return {"success": bool(item.CopyGrades(targets)), "targets": target_summaries, "missing": missing} def _safe_export_lut(item, p: Dict[str, Any]): path = p.get("path") if not path: return _err("path is required") if p.get("require_temp_path", True) and not _grade_temp_path_ok(path): return _err("path must be under the system temp directory unless require_temp_path=False") folder = os.path.dirname(os.path.abspath(path)) if folder: os.makedirs(folder, exist_ok=True) resolve_obj = get_resolve() export_type, type_err = _resolve_lut_export_type(p.get("type", "33ptcube"), resolve_obj) if type_err: return type_err if p.get("dry_run"): return _ok(path=path, type=export_type, would_export=True) before_exists = os.path.exists(path) success = bool(item.ExportLUT(export_type, path)) return { "success": success, "path": path, "type": export_type, "file_exists": os.path.exists(path), "size": os.path.getsize(path) if os.path.exists(path) else 0, "overwrote_existing": before_exists and os.path.exists(path), } def _safe_apply_drx(proj, item, p: Dict[str, Any]): path = p.get("path") if not path: return _err("path is required", code="MISSING_PATH", category="invalid_input") if not os.path.isfile(path): return _err(f"DRX file not found: {path}", code="DRX_NOT_FOUND", category="invalid_input") if p.get("require_temp_path", True) and not _grade_temp_path_ok(path): return _err("DRX path must be under the system temp directory unless require_temp_path=False", code="DRX_PATH_NOT_TEMP", category="invalid_input") graph, source, err = _color_graph_from_params(proj, item, p) if err: return err if not _has_method(graph, "ApplyGradeFromDRX"): return _err(f"{source} graph does not expose ApplyGradeFromDRX", code="APPLY_DRX_UNSUPPORTED", category="unsupported") if p.get("dry_run"): return _ok(path=path, source=source, grade_mode=p.get("grade_mode", p.get("mode", 0)), would_apply=True) # B2 — Whole-grade replacement is catastrophic; require confirmation. if "confirm_token" not in p and "confirmToken" not in p: if _confirm_token_required(): preview = { "operation": "safe_apply_drx", "warning": "REPLACES the target graph entirely. There is no append mode.", "path": path, "source": source, "grade_mode": p.get("grade_mode", p.get("mode", 0)), } return _issue_confirm_token(action="safe_apply_drx", params=p, preview=preview) blocked = _consume_confirm_token(action="safe_apply_drx", params=p) if blocked: return blocked success = bool(graph.ApplyGradeFromDRX(path, p.get("grade_mode", p.get("mode", 0)))) return {"success": success, "path": path, "source": source} def _grade_version_restore(item, p: Dict[str, Any]): name = p.get("name") if not name: return _err("name is required") version_type = p.get("type", 0) snapshot = _grade_version_snapshot(item, p) names = snapshot["local"] if version_type == 0 else snapshot["remote"] if name not in names: return {"success": False, "snapshot": snapshot, "error": f"Version '{name}' not found"} if p.get("dry_run"): return _ok(snapshot=snapshot, would_load=name, type=version_type) return {"success": bool(item.LoadVersionByName(name, version_type)), "snapshot": snapshot} def _color_group_capabilities(proj): groups = proj.GetColorGroupsList() or [] return { "count": len(groups), "groups": [ { "name": group.GetName(), "pre_clip_graph": _graph_snapshot(group.GetPreClipNodeGraph(), include_nodes=False), "post_clip_graph": _graph_snapshot(group.GetPostClipNodeGraph(), include_nodes=False), } for group in groups ], "project_methods": _callable_method_names(proj, ["GetColorGroupsList", "AddColorGroup", "DeleteColorGroup"]), } def _gallery_capabilities(proj): gallery = proj.GetGallery() if not gallery: return {"available": False} still_albums = gallery.GetGalleryStillAlbums() or [] power_albums = gallery.GetGalleryPowerGradeAlbums() or [] return { "available": True, "still_albums": [{"name": gallery.GetAlbumName(album), "index": index} for index, album in enumerate(still_albums)], "power_grade_albums": [{"name": gallery.GetAlbumName(album), "index": index} for index, album in enumerate(power_albums)], "current_album_available": gallery.GetCurrentStillAlbum() is not None, "methods": _callable_method_names( gallery, [ "GetAlbumName", "SetAlbumName", "GetCurrentStillAlbum", "SetCurrentStillAlbum", "GetGalleryStillAlbums", "GetGalleryPowerGradeAlbums", "CreateGalleryStillAlbum", "CreateGalleryPowerGradeAlbum", ], ), } def _grade_boundary_report(proj, item, p: Dict[str, Any]): report = { "capabilities": _grade_capabilities(item, proj), "item": _grade_item_snapshot(item, proj, p), "color_groups": _color_group_capabilities(proj), "gallery": _gallery_capabilities(proj), } if p.get("include_timeline_graph", True): report["timeline_graph"] = _probe_color_node_graph(proj, item, {"source": "timeline", "include_nodes": False}) return report # ─────────────────────────────────────────────────────────────────────────── # B4 — action_help dict. Long-form per-action guidance + examples are pulled # on demand via the action_help sub-action so the top-level docstring (sent on # every tool catalog turn) can stay short. # ─────────────────────────────────────────────────────────────────────────── _ACTION_HELP: Dict[str, Dict[str, Dict[str, Any]]] = { "timeline_item_color": { "safe_set_cdl": { "summary": "Set CDL values on an existing node. Validates input; supports dry_run.", "params": "cdl={NodeIndex, Slope, Offset, Power, Saturation}, dry_run?, track_type?, track_index?, item_index?", "returns": "{success, validation, normalized}", "example": ( 'timeline_item_color(action="safe_set_cdl", params={\n' ' "track_type": "video", "track_index": 1, "item_index": 0,\n' ' "cdl": {"NodeIndex": 1,\n' ' "Slope": [1.00, 0.98, 0.95],\n' ' "Offset": [0.00, 0.00, 0.00],\n' ' "Power": [1.00, 1.00, 1.00],\n' ' "Saturation": 1.0},\n' ' "dry_run": True\n' '})' ), }, "safe_copy_grade": { "summary": "Copy the source item's grade to one or more target items.", "params": "target_ids: [str], dry_run?", "returns": "{success, targets, missing}", "example": ( 'timeline_item_color(action="safe_copy_grade", params={\n' ' "track_type": "video", "track_index": 1, "item_index": 0, # source = hero\n' ' "target_ids": ["TimelineItem-abc", "TimelineItem-def"],\n' ' "dry_run": True\n' '})' ), }, "safe_apply_drx": { "summary": "Apply a .drx grade. REPLACES the target graph; gated by confirm_token.", "params": "path: str, source?, grade_mode? (0=no keyframes, 1=source TC aligned, 2=start aligned), require_temp_path?, confirm_token?", "returns": "{success, path, source}", "example": ( 'timeline_item_color(action="safe_apply_drx", params={\n' ' "track_type": "video", "track_index": 1, "item_index": 0,\n' ' "path": "/tmp/show_look_v3.drx",\n' ' "grade_mode": 0,\n' ' "require_temp_path": True\n' '}) # first call returns {status: "confirmation_required", confirm_token}\n' ' # re-call with confirm_token to apply' ), }, "grade_evidence_base": { "summary": "PREFERRED pre-flight read. Composes version_snapshot + node_graph + color_group + coverage_report.", "params": "include_coverage?, max_nodes?, min_source_trust? (medium|high), track_type?, track_index?, item_index?", "returns": "{evidence_base: str, structured: {…}, warnings, notes}", "example": ( 'timeline_item_color(action="grade_evidence_base", params={\n' ' "track_type": "video", "track_index": 1, "item_index": 0,\n' ' "include_coverage": True, "min_source_trust": "medium"\n' '})\n' '# Lead your response to the user with the returned evidence_base line.' ), }, "bulk_match_to_hero": { "summary": "Map-reduce: propose per-target CDLs or copy-grade plans from a hero shot.", "params": "hero_id, target_ids: [str], method ('cdl_delta'|'copy_grade'), min_source_trust?, dry_run?, confirm_token?", "returns": "{hero, proposals: [...], blocked: [...], confirm_token?}", "example": ( 'timeline_item_color(action="bulk_match_to_hero", params={\n' ' "hero_id": "TimelineItem-hero",\n' ' "target_ids": ["TimelineItem-a", "TimelineItem-b", "TimelineItem-c"],\n' ' "method": "copy_grade",\n' ' "min_source_trust": "high",\n' ' "dry_run": True\n' '})' ), }, "propose_grade": { "summary": "Validated structured-output action. Turns a color recommendation into a parsed artifact.", "params": "target_id, evidence_base: str, frame_paths: [str], operation_class enum, cdl_delta_or_artifact, unsupported_request_explanation?, execute?", "returns": "{accepted: bool, validation, plan_id?, preview_path?, error?}", "example": ( 'timeline_item_color(action="propose_grade", params={\n' ' "target_id": "TimelineItem-abc",\n' ' "evidence_base": "evidence base: hero shot 2A graded …",\n' ' "frame_paths": ["/path/untreated.jpg", "/path/current.jpg"],\n' ' "operation_class": "direct",\n' ' "cdl_delta_or_artifact": {"cdl": {…}},\n' ' "execute": False\n' '})' ), }, }, "timeline": { "duplicate_clips": { "summary": "Re-place the same MediaPool media with the same source trim. Video clips only.", "params": "clip_ids?|selected?, target_track_index?|track_offset?, placement? (same_time|offset|at_playhead|track_above|after_source|next_gap), record_frame?, record_frame_offset?, copy_properties?, include_linked?", "returns": "{results, count}", "example": ( 'timeline(action="duplicate_clips", params={\n' ' "clip_ids": ["TimelineItem-abc"],\n' ' "placement": "next_gap",\n' ' "track_offset": 1,\n' ' "copy_properties": ["transform", "grades", "markers"],\n' ' "include_linked": True\n' '})' ), }, "create_variant_from_ranges": { "summary": "Build a variant timeline from N source ranges. Source-safe; supports dry_run.", "params": "name, ranges: [{source_clip_id|clip_id, start_frame, end_frame, record_frame?, track_type?}], markers?, cdl?, dry_run?", "returns": "{success, id, items}", "example": ( 'timeline(action="create_variant_from_ranges", params={\n' ' "name": "v02_tighter_act1",\n' ' "ranges": [\n' ' {"source_clip_id": "TimelineItem-abc", "start_frame": 108000, "end_frame": 108120},\n' ' {"source_clip_id": "TimelineItem-def", "start_frame": 108300, "end_frame": 108400}\n' ' ],\n' ' "markers": [{"frame": 0, "color": "Blue", "name": "Act1 turn"}],\n' ' "dry_run": True\n' '})' ), }, "apply_look_to_items": { "summary": "Apply one CDL or copied grade to many items.", "params": "target_ids: [str], cdl?|copy_from_item_id?, dry_run?", "returns": "{targets, missing, dry_run, cdl_results?, copy_grades?, success}", "example": ( 'timeline(action="apply_look_to_items", params={\n' ' "target_ids": ["TimelineItem-1", "TimelineItem-2", "TimelineItem-3"],\n' ' "cdl": {"NodeIndex": 1, "Slope": [1.0, 0.97, 0.93],\n' ' "Offset": [0.0, 0.0, 0.0], "Power": [1.0, 1.0, 1.0],\n' ' "Saturation": 1.0},\n' ' "dry_run": True\n' '})' ), }, "bulk_set_item_properties": { "summary": "Batch SetProperty/clip_color/enabled across many items.", "params": "ops: [{timeline_item_id|clip_id, properties|transform|crop|composite|audio, clip_color?, enabled?}], dry_run?, readback?", "returns": "{success, results, op_count}", "example": ( 'timeline(action="bulk_set_item_properties", params={\n' ' "ops": [\n' ' {"timeline_item_id": "TimelineItem-abc",\n' ' "properties": {"ClipColor": "Teal", "ZoomX": 1.05}},\n' ' {"timeline_item_id": "TimelineItem-def",\n' ' "properties": {"ClipColor": "Teal"}}\n' ' ],\n' ' "dry_run": True, "readback": True\n' '})' ), }, "set_title_text": { "summary": "SetProperty on a heuristic or explicit Text+ key.", "params": "clip_id|timeline_item_id, text, property_key?, as_styled_xml?, try_plain_first?, try_heuristic_keys?, readback?", "returns": "{success, property_key?, attempts}", "example": ( 'timeline(action="set_title_text", params={\n' ' "timeline_item_id": "TimelineItem-title-1",\n' ' "text": "FADE IN",\n' ' "try_heuristic_keys": True,\n' ' "readback": True\n' '})' ), }, "delete_clips": { "summary": "Delete timeline items. ripple=True closes the gap (catastrophic; confirm_token required).", "params": "clip_ids: [str], ripple?, confirm_token?", "returns": "{success}", "example": ( 'timeline(action="delete_clips", params={\n' ' "clip_ids": ["TimelineItem-abc"], "ripple": True\n' '}) # first call returns confirm_token if ripple=True; re-call with it to delete' ), }, }, "graph": { "apply_grade_from_drx": { "summary": "REPLACES the entire node graph from a .drx. Confirm_token required.", "params": "path, grade_mode? (0/1/2), source?", "returns": "{success}", "example": ( 'graph(action="apply_grade_from_drx", params={\n' ' "path": "/tmp/look.drx", "grade_mode": 0, "source": "item"\n' '}) # first call returns confirm_token; re-call with it to apply' ), }, "reset_all_grades": { "summary": "Wipes the entire grade on the source. Confirm_token required.", "params": "source?", "returns": "{success}", "example": ( 'graph(action="reset_all_grades", params={"source": "item"})' ), }, }, } def _bulk_match_to_hero(proj, p: Dict[str, Any]) -> Dict[str, Any]: """C1 — map-reduce shot matching: propose per-target CDLs or copy-grade plans from a hero. Methods: - copy_grade: emit a plan to CopyGrades from hero to each target. Execute uses hero.CopyGrades([targets]). - cdl_delta: NOT YET IMPLEMENTED — returns invalid_input with a clear remediation. Two-step contract: 1. First call (no confirm_token, dry_run=true by default): returns {proposals, blocked, confirm_token} 2. Second call with confirm_token: executes the planned mutations. """ hero_id = p.get("hero_id") target_ids = p.get("target_ids") or [] method = (p.get("method") or "copy_grade").strip().lower() if not hero_id: return _err("hero_id is required", code="MISSING_HERO_ID", category="invalid_input", remediation="Pass hero_id: the timeline item unique ID for the reference shot.") if not isinstance(target_ids, list) or not target_ids: return _err("target_ids must be a non-empty list of timeline item IDs", code="MISSING_TARGETS", category="invalid_input") if method not in {"copy_grade", "cdl_delta"}: return _err(f"method must be 'copy_grade' or 'cdl_delta' (got {method!r})", code="INVALID_METHOD", category="invalid_input") _, tl, err = _get_tl() if err: return err # Resolve hero hero = _find_timeline_item_by_id(tl, hero_id) if hero is None: return _err(f"hero item not found in current timeline: {hero_id}", code="HERO_NOT_FOUND", category="invalid_input", remediation="Call timeline(action='get_items', ...) to enumerate valid timeline item IDs.") # Resolve hero evidence base (used for the leading line + min_trust gate) hero_evidence = _grade_evidence_base(proj, hero, {"include_coverage": False, "max_nodes": 4}) hero_summary = { "id": hero_id, "name": getattr(hero, "GetName", lambda: None)(), "evidence_base": hero_evidence.get("evidence_base"), } # Resolve targets targets: List[Any] = [] blocked: List[Dict[str, Any]] = [] found_ids = set() for track_index in range(1, tl.GetTrackCount("video") + 1): for it in (tl.GetItemListInTrack("video", track_index) or []): try: uid = it.GetUniqueId() except Exception: continue if uid in target_ids and uid != hero_id: targets.append(it) found_ids.add(uid) for tid in target_ids: if tid not in found_ids and tid != hero_id: blocked.append({"target_id": tid, "reason": "not_found_in_current_timeline", "error_code": "TARGET_NOT_FOUND"}) # Build proposals proposals: List[Dict[str, Any]] = [] if method == "copy_grade": for it in targets: try: name = it.GetName() except Exception: name = None proposals.append({ "target_id": it.GetUniqueId(), "name": name, "method": "copy_grade", "copy_source": hero_id, "warnings": [], }) else: # cdl_delta for it in targets: try: name = it.GetName() except Exception: name = None proposals.append({ "target_id": it.GetUniqueId(), "name": name, "method": "cdl_delta", "proposed_cdl": None, "warnings": [ "cdl_delta is not yet implemented — use method='copy_grade' for now, " "or hand-roll safe_set_cdl per target." ], }) # Return early as a structured invalid_input so the caller doesn't proceed to execute. return _err( "method='cdl_delta' is not implemented yet", code="CDL_DELTA_UNIMPLEMENTED", category="unsupported", remediation="Use method='copy_grade' or call safe_set_cdl per target with hand-rolled values.", ) dry_run = bool(p.get("dry_run", True)) payload: Dict[str, Any] = { "hero": hero_summary, "proposals": proposals, "blocked": blocked, "method": method, "dry_run": dry_run, } if dry_run: # A dry-run that produced at least one proposal is a successful # validation, even if some targets are blocked — the caller can still # act on the resolvable subset. success=False is reserved for the # case where the call produced no actionable output. payload["success"] = bool(proposals) return payload # Execute path: confirm-token gated. if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required(): issued = _issue_confirm_token( action="timeline_item_color.bulk_match_to_hero", params=p, preview={ "operation": "bulk_match_to_hero", "method": method, "hero_id": hero_id, "target_count": len(proposals), "blocked": blocked, "warning": "Mutates grade state on every successfully resolved target item.", }, ) issued.update(payload) # surface proposals in the issued response too return issued blocked_token = _consume_confirm_token(action="timeline_item_color.bulk_match_to_hero", params=p) if blocked_token: return blocked_token if blocked: payload["success"] = False payload["error_summary"] = "refusing to execute because some targets could not be resolved" return payload # Execute copy_grade in one API call. try: success = bool(hero.CopyGrades(targets)) except Exception as exc: return _err(f"CopyGrades failed: {exc}", code="COPY_GRADES_FAILED", category="resolve_api_failed", retryable=False) payload["success"] = success payload["executed"] = True return payload _PROPOSE_GRADE_OPERATION_CLASSES = frozenset({"direct", "opaque", "asset", "review_only", "unsupported"}) def _propose_grade_validate(p: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Validate a propose_grade payload. Returns None if valid, else a structured error.""" if not isinstance(p, dict): return _err("params must be an object", code="INVALID_PARAMS", category="invalid_input") target_id = p.get("target_id") if not target_id: return _err("target_id is required", code="MISSING_TARGET_ID", category="invalid_input", remediation="Pass target_id: the timeline item unique ID this proposal applies to.") evidence_base = p.get("evidence_base") if not evidence_base or not isinstance(evidence_base, str): return _err("evidence_base is required and must be a non-empty string", code="MISSING_EVIDENCE_BASE", category="invalid_input", remediation="Call timeline_item_color(action='grade_evidence_base') and use its evidence_base string.") if not evidence_base.lstrip().lower().startswith("evidence base:"): return _err("evidence_base must start with 'evidence base:' — call grade_evidence_base first", code="EVIDENCE_BASE_MALFORMED", category="invalid_input") frame_paths = p.get("frame_paths") or [] if not isinstance(frame_paths, list) or not frame_paths: return _err("frame_paths must be a non-empty list of absolute paths", code="MISSING_FRAME_PATHS", category="invalid_input", remediation="Include the frame(s) used to make this grade decision.") operation_class = (p.get("operation_class") or "").strip().lower() if operation_class not in _PROPOSE_GRADE_OPERATION_CLASSES: return _err( f"operation_class must be one of {sorted(_PROPOSE_GRADE_OPERATION_CLASSES)} (got {operation_class!r})", code="INVALID_OPERATION_CLASS", category="invalid_input", ) artifact = p.get("cdl_delta_or_artifact") or {} if operation_class == "direct": cdl = artifact.get("cdl") if isinstance(artifact, dict) else None if not cdl: return _err("operation_class='direct' requires cdl_delta_or_artifact.cdl", code="DIRECT_REQUIRES_CDL", category="invalid_input") elif operation_class == "opaque": if not (isinstance(artifact, dict) and (artifact.get("drx_path") or artifact.get("copy_source"))): return _err("operation_class='opaque' requires drx_path or copy_source", code="OPAQUE_REQUIRES_ARTIFACT", category="invalid_input") elif operation_class == "asset": if not (isinstance(artifact, dict) and artifact.get("lut_path")): return _err("operation_class='asset' requires lut_path", code="ASSET_REQUIRES_LUT_PATH", category="invalid_input") elif operation_class == "review_only": if len(frame_paths) < 2: return _err("operation_class='review_only' requires at least 2 frame_paths (e.g. untreated + current)", code="REVIEW_NEEDS_FRAMES", category="invalid_input") elif operation_class == "unsupported": expl = p.get("unsupported_request_explanation") if not expl or not isinstance(expl, str): return _err( "operation_class='unsupported' requires unsupported_request_explanation: str", code="UNSUPPORTED_NEEDS_EXPLANATION", category="invalid_input", ) return None def _propose_grade(proj, p: Dict[str, Any]) -> Dict[str, Any]: """C2 — Structured-output action. Validates a color recommendation as a proper artifact. With execute=False (default): returns {accepted: True, plan_id, validation, preview_path?}. With execute=True: routes through bulk_match_to_hero / safe_set_cdl / safe_apply_drx based on operation_class. Confirm_token gating applies as usual. """ err = _propose_grade_validate(p) if err: return err plan_id = f"plan_{_uuid.uuid4().hex[:12]}" operation_class = p["operation_class"].strip().lower() payload: Dict[str, Any] = { "accepted": True, "plan_id": plan_id, "operation_class": operation_class, "target_id": p["target_id"], "evidence_base": p["evidence_base"], "frame_paths": list(p.get("frame_paths") or []), "validation": {"valid": True, "notes": []}, } if not p.get("execute"): payload["executed"] = False return payload # review_only never mutates — no confirm_token needed. if operation_class == "review_only": payload["executed"] = False payload["validation"]["notes"].append("review_only: no mutation; frame references recorded.") return payload # Execute path for mutating classes — gate behind confirm_token like other destructive ops. if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required(): issued = _issue_confirm_token( action="timeline_item_color.propose_grade", params=p, preview={ "operation": "propose_grade.execute", "plan_id": plan_id, "operation_class": operation_class, "target_id": p["target_id"], "warning": "Mutates grade state on the target item.", }, ) issued.update(payload) return issued blocked = _consume_confirm_token(action="timeline_item_color.propose_grade", params=p) if blocked: return blocked # Routing — operation_class determines which mutator runs. # For C2 v1 we wire the two simple paths (direct CDL, review_only no-op) and leave # opaque/asset as recorded plans for the caller to execute via the matching safe_* action. if operation_class == "review_only": payload["executed"] = False payload["validation"]["notes"].append("review_only: no mutation; frame references recorded.") return payload if operation_class == "direct": # Resolve the target item via timeline lookup, then call safe_set_cdl. _, tl, err = _get_tl() if err: return err target_item = _find_timeline_item_by_id(tl, p["target_id"]) if target_item is None: return _err(f"target_id not found in current timeline: {p['target_id']}", code="TARGET_NOT_FOUND", category="invalid_input") cdl_payload = (p.get("cdl_delta_or_artifact") or {}).get("cdl") result = _safe_set_cdl(target_item, {"cdl": cdl_payload}) payload["executed"] = True payload["execution"] = result payload["success"] = bool(result.get("success")) # F5 — propose_grade.execute writes a brain_edits row per gameplan §3 C2: # "metric=color_intent, rationale=". The inner _safe_set_cdl # call doesn't go through the destructive_hook wrapper, so we log here. if payload["success"]: try: ctx = _destructive_versioning_provider() if ctx is not None: _, proj_h, project_root, project_name = ctx run_id = _destructive_hook._extract_analysis_run_id(p, project_root=project_root) initiator = _destructive_hook._extract_initiator(p) timeline_name = None try: cur_tl = proj_h.GetCurrentTimeline() if cur_tl is not None: timeline_name = cur_tl.GetName() except Exception: timeline_name = None _brain_edits.log_brain_edit( project_root=project_root, analysis_run_id=run_id, edit_type="timeline_item_color.propose_grade", tool_name="timeline_item_color", action_name="propose_grade", timeline_before=timeline_name, timeline_after=timeline_name, target_metric=_brain_edits.METRIC_COLOR_INTENT, rationale=p.get("evidence_base"), params={ "target_id": p.get("target_id"), "operation_class": operation_class, "plan_id": plan_id, "frame_paths": list(p.get("frame_paths") or []), "cdl": cdl_payload, }, result_summary={ "success": payload["success"], "normalized": result.get("normalized"), }, project_name=project_name, initiator=initiator, ) except Exception as exc: logger.warning("propose_grade brain_edit log failed (non-fatal): %s", exc) return payload # opaque/asset/unsupported require the caller to follow up with the matching safe_* action. payload["executed"] = False payload["validation"]["notes"].append( f"operation_class={operation_class}: plan accepted but propose_grade does not execute this class directly. " "Use the matching safe_* action (safe_apply_drx, graph.set_lut, etc.)." ) return payload def _action_help(tool_name: str, p: Dict[str, Any]) -> Dict[str, Any]: """Return long-form guidance + example for a named action on tool_name.""" name = (p or {}).get("name") or (p or {}).get("action_name") catalog = _ACTION_HELP.get(tool_name, {}) if not name: return _ok(tool=tool_name, available=sorted(catalog.keys()), note="Call action_help with params.name to retrieve guidance for a specific action.") entry = catalog.get(name) if not entry: return _err( f"No help registered for {tool_name}.{name}.", code="HELP_NOT_REGISTERED", category="invalid_input", remediation=f"Call action_help() with no name for the list of actions with detailed help.", ) return _ok(tool=tool_name, action=name, **entry) def _grade_evidence_line(*, item_name, version_label, num_nodes, has_lut, group_name, coverage_summary, coverage_warnings_count): """Compose the one-line evidence_base sentence for color recommendations.""" bits = [] name = item_name or "current item" parts = [] if version_label: parts.append(f"Version {version_label}") if num_nodes is not None: parts.append(f"{num_nodes} nodes") if has_lut: parts.append("LUT") if group_name: parts.append(f"group={group_name}") grade_state = f" ({', '.join(parts)})" if parts else " (default graph)" bits.append(f"evidence base: hero shot {name} graded{grade_state}") if coverage_summary is not None: total = coverage_summary.get("clips_total", 0) analyzed = coverage_summary.get("clips_analyzed", 0) bits.append(f"{analyzed} of {total} target shots have current visual reports") relink = coverage_summary.get("clips_reuse_blocked", 0) + coverage_summary.get("clips_superseded_by_relink", 0) if relink: bits.append(f"{relink} superseded_by_relink") if coverage_warnings_count: bits.append(f"{coverage_warnings_count} warnings") return "; ".join(bits) def _grade_evidence_base(proj, item, p: Dict[str, Any]) -> Dict[str, Any]: """Composite read: grade version snapshot + node graph + color group + coverage. Pure read; never mutates. Mirrors media_analysis.coverage_report's `evidence_base` line so color recommendations lead with one composed summary instead of synthesizing across three calls. Returns: {evidence_base: str, structured: {version_snapshot, node_graph, color_group, coverage?, capabilities}, warnings: [...]} """ structured: Dict[str, Any] = {} warnings: List[str] = [] # Version snapshot snap = _grade_version_snapshot(item, p) structured["version_snapshot"] = snap current_version = snap.get("current") version_label = None if isinstance(current_version, dict): # Resolve's GetCurrentVersion returns {"versionName": ..., "versionType": ...} # (camelCase). Accept the historical PascalCase keys too for safety. version_label = ( current_version.get("versionName") or current_version.get("VersionName") or current_version.get("name") ) elif isinstance(current_version, str): version_label = current_version # Strip a leading "Version " prefix so the formatted line doesn't duplicate # it ("Version Version 1"). if isinstance(version_label, str): stripped = version_label.strip() if stripped.lower().startswith("version "): stripped = stripped[len("version "):].strip() version_label = stripped or None # Node graph node_graph_probe = _probe_color_node_graph( proj, item, {"source": p.get("source", "item"), "include_nodes": True, "max_nodes": int(p.get("max_nodes", 8))}, ) structured["node_graph"] = node_graph_probe num_nodes = node_graph_probe.get("num_nodes") has_lut = False for n in (node_graph_probe.get("nodes") or []): if n.get("lut"): has_lut = True break # Color group group_name = None try: group = item.GetColorGroup() group_name = group.GetName() if group else None except Exception as exc: warnings.append(f"GetColorGroup failed: {exc}") structured["color_group"] = {"name": group_name} # Item identity item_name = None try: item_name = item.GetName() except Exception: pass # Optional coverage (best-effort; coverage_report needs a media pool item) coverage_summary = None coverage_warnings_count = 0 if p.get("include_coverage", True): mp_item = _timeline_item_media_pool_item(item) if mp_item is not None: try: project_name = proj.GetName() if proj else None project_id = proj.GetUniqueId() if proj else None clip_id = mp_item.GetUniqueId() target = {"type": "clip", "clip_ids": [clip_id]} # Build a minimal records list directly to avoid the media_pool lookup re-entry. cov_params = dict(p) cov_params["target"] = target cov_params.setdefault("min_source_trust", "medium") records, normalized_target, cov_warnings, target_err = _media_analysis_records_from_target( proj.GetMediaPool(), cov_params, project=proj, ) if target_err: warnings.append(f"coverage_report skipped: {target_err.get('error', {}).get('message', target_err.get('error'))}") else: coverage = build_media_analysis_coverage_report( project_name=project_name, project_id=project_id, records=records or [], target=normalized_target, params=cov_params, capabilities={}, ) structured["coverage"] = coverage coverage_summary = coverage.get("summary") coverage_warnings_count = len(coverage.get("warnings") or []) + len(cov_warnings or []) except Exception as exc: warnings.append(f"coverage_report failed: {exc}") else: warnings.append("no media pool item for coverage_report") # Capabilities (small; useful at the top so the agent knows what's possible) structured["capabilities"] = _grade_capabilities(item, proj) evidence = _grade_evidence_line( item_name=item_name, version_label=version_label, num_nodes=num_nodes, has_lut=has_lut, group_name=group_name, coverage_summary=coverage_summary, coverage_warnings_count=coverage_warnings_count, ) return { "success": True, "evidence_base": evidence, "structured": structured, "warnings": warnings, "notes": [ "grade_evidence_base is a pure read — it never mutates.", "Lead any color recommendation with the `evidence_base` line.", ], } @mcp.tool() @_destructive_op("timeline_item_color") def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Color grading, versions, LUTs, cache, and AI tools on timeline items. Identify by track_type, track_index, item_index. - For ANY grade recommendation, lead with action="grade_evidence_base" — composes version_snapshot + node_graph + color_group + coverage_report into one line. - For batch matching, use bulk_match_to_hero (returns confirm_token; dry_run first). - To formalize a recommendation, use propose_grade — validated structured output. - PREFER safe_*/probe_*/*_capabilities/*_boundary_report variants over raw mutators. grade_evidence_base -> {evidence_base: str, structured: {coverage, version_snapshot, node_graph, color_group, warnings}} bulk_match_to_hero -> {hero, proposals: [{target_id, name, proposed_cdl|copy_source, warnings}], blocked: [...], confirm_token?} propose_grade -> {accepted: bool, validation, plan_id?, preview_path?, error?} safe_set_cdl -> {success, validation, normalized} safe_copy_grade -> {success, targets, missing} safe_apply_drx -> {success, path, source} # first call may return confirm_token grade_capabilities -> {item_methods, graph_sources, lut_export_types, guards} grade_boundary_report -> {capabilities, item, color_groups, gallery} All actions may return {"error": {code, category, retryable, message, remediation, reason?}}. PREFER the safe_* / probe_* / *_capabilities / *_boundary_report variants. They validate inputs, support dry_run, and return structured errors. Raw actions are kept for advanced callers but bypass guardrails. Read-only / boundary actions (safe to call any time): grade_evidence_base(include_coverage?, max_nodes?, ...) -> {evidence_base, structured, warnings} PREFERRED PRE-FLIGHT for any color recommendation. Composes version_snapshot + node_graph + color_group + coverage_report into one one-line `evidence_base` summary. Lead your response with the `evidence_base` line. # example: action_help(name='') grade_capabilities(...) -> {item_methods, graph_sources, lut_export_types, guards} probe_grade_item(...) -> {methods, versions, node_graph, color_group, cache} probe_node_graph(source?, max_nodes?, ...) -> {available, num_nodes, nodes} grade_version_snapshot(...) -> {current, local, remote} color_group_capabilities(...) -> {count, groups} gallery_capabilities(...) -> {available, still_albums, power_grade_albums} grade_boundary_report(...) -> {capabilities, item, color_groups, gallery} get_current_version(...) -> {version} get_version_names(type?, ...) -> {names} get_node_graph(layer_index?, ...) -> {available} get_color_group(...) -> {name} get_color_cache(...) -> {enabled} get_fusion_cache(...) -> {enabled} Guarded mutators (PREFERRED for grade work): safe_set_cdl(cdl, dry_run?, ...) -> {success, validation, normalized} Validates input, supports dry_run, returns normalized CDL. Use this for primary corrections. # example: action_help(name='') safe_copy_grade(target_ids, dry_run?, ...) -> {success, targets, missing} Copies grade to N items; dry_run reports targets without mutating. # example: action_help(name='') safe_apply_drx(path, source?, grade_mode?, require_temp_path?) -> {success} REPLACES the target graph. Captures a version snapshot first; require_temp_path defaults True. # example: action_help(name='') safe_export_lut(type?, path, require_temp_path?) -> {success, path, size} Sandboxed LUT export. grade_version_restore(name, type?, dry_run?, ...) -> {success} Raw mutators (UNSAFE direct mutation — prefer the safe_* sibling): set_cdl(cdl, ...) -> {success} UNSAFE. No validation; no dry_run. Prefer safe_set_cdl. copy_grades(target_ids, ...) -> {success} UNSAFE. No target existence check. Prefer safe_copy_grade. export_lut(type, path, ...) -> {success} UNSAFE. No path sandboxing. Prefer safe_export_lut. reset_all_node_colors(...) -> {success} DESTRUCTIVE. Erases per-node color labels on the item's graph. add_version(name, type?, ...) -> {success} — type: 0=local, 1=remote load_version(name, type?, ...) -> {success} rename_version(old_name, new_name, type?, ...) -> {success} delete_version(name, type?, ...) -> {success} DESTRUCTIVE. Deletes a named grade version. assign_color_group(group_name, ...) -> {success} remove_from_color_group(...) -> {success} set_color_cache(enabled, ...) -> {success} set_fusion_cache(enabled, ...) -> {success} stabilize(...) -> {success} smart_reframe(...) -> {success} create_magic_mask(mode, ...) -> {success} — mode: "F" forward, "B" backward, "BI" bidirectional regenerate_magic_mask(...) -> {success} Default: track_type="video", track_index=1, item_index=0 For long-form per-action guidance and a worked example, call: timeline_item_color(action="action_help", params={"name": ""}) """ p = params or {} if action == "action_help": return _action_help("timeline_item_color", p) _, item, err = _get_item(p) if err: return err _, proj, _ = _check() if action == "grade_capabilities": return _grade_capabilities(item, proj) elif action == "grade_evidence_base": return _grade_evidence_base(proj, item, p) elif action == "bulk_match_to_hero": return _bulk_match_to_hero(proj, p) elif action == "propose_grade": return _propose_grade(proj, p) elif action == "probe_grade_item": return _grade_item_snapshot(item, proj, p) elif action == "probe_node_graph": return _probe_color_node_graph(proj, item, p) elif action == "safe_set_cdl": return _safe_set_cdl(item, p) elif action == "safe_copy_grade": return _safe_copy_grade(item, p) elif action == "safe_apply_drx": return _safe_apply_drx(proj, item, p) elif action == "safe_export_lut": return _safe_export_lut(item, p) elif action == "grade_version_snapshot": return _grade_version_snapshot(item, p) elif action == "grade_version_restore": return _grade_version_restore(item, p) elif action == "color_group_capabilities": return _color_group_capabilities(proj) elif action == "gallery_capabilities": return _gallery_capabilities(proj) elif action == "grade_boundary_report": return _grade_boundary_report(proj, item, p) elif action == "set_cdl": return {"success": bool(item.SetCDL(_normalize_cdl(p["cdl"])))} elif action == "copy_grades": # Find target items by IDs _, tl, _ = _get_tl() targets = [] target_ids = set(p["target_ids"]) if tl: for tt in ["video"]: for ti in range(1, tl.GetTrackCount(tt) + 1): for it in (tl.GetItemListInTrack(tt, ti) or []): if it.GetUniqueId() in target_ids: targets.append(it) return {"success": bool(item.CopyGrades(targets))} elif action == "add_version": return {"success": bool(item.AddVersion(p["name"], p.get("type", 0)))} elif action == "get_current_version": return {"version": _ser(item.GetCurrentVersion())} elif action == "get_version_names": return {"names": _ser(item.GetVersionNameList(p.get("type", 0)))} elif action == "load_version": return {"success": bool(item.LoadVersionByName(p["name"], p.get("type", 0)))} elif action == "rename_version": return {"success": bool(item.RenameVersionByName(p["old_name"], p["new_name"], p.get("type", 0)))} elif action == "delete_version": return {"success": bool(item.DeleteVersionByName(p["name"], p.get("type", 0)))} elif action == "get_node_graph": g = item.GetNodeGraph(p["layer_index"]) if "layer_index" in p else item.GetNodeGraph() return {"available": g is not None} elif action == "get_color_group": g = item.GetColorGroup() return {"name": g.GetName() if g else None} elif action == "assign_color_group": groups = proj.GetColorGroupsList() or [] for g in groups: if g.GetName() == p["group_name"]: return {"success": bool(item.AssignToColorGroup(g))} return _err(f"Color group '{p['group_name']}' not found") elif action == "remove_from_color_group": return {"success": bool(item.RemoveFromColorGroup())} elif action == "export_lut": return {"success": bool(item.ExportLUT(p["type"], p["path"]))} elif action == "get_color_cache": return {"enabled": item.GetIsColorOutputCacheEnabled()} elif action == "set_color_cache": return {"success": bool(item.SetColorOutputCache(p["enabled"]))} elif action == "get_fusion_cache": return {"enabled": item.GetIsFusionOutputCacheEnabled()} elif action == "set_fusion_cache": return {"success": bool(item.SetFusionOutputCache(p["enabled"]))} elif action == "reset_all_node_colors": missing = _requires_method(item, "ResetAllNodeColors", "20.2") if missing: return missing return {"success": bool(item.ResetAllNodeColors())} elif action == "stabilize": return {"success": bool(item.Stabilize())} elif action == "smart_reframe": return {"success": bool(item.SmartReframe())} elif action == "create_magic_mask": return {"success": bool(item.CreateMagicMask(p.get("mode", "F")))} elif action == "regenerate_magic_mask": return {"success": bool(item.RegenerateMagicMask())} return _unknown(action, ["set_cdl","copy_grades","add_version","get_current_version","get_version_names","load_version","rename_version","delete_version","get_node_graph","get_color_group","assign_color_group","remove_from_color_group","export_lut","get_color_cache","set_color_cache","get_fusion_cache","set_fusion_cache","reset_all_node_colors","stabilize","smart_reframe","create_magic_mask","regenerate_magic_mask",*_COLOR_GRADE_KERNEL_ACTIONS]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 22: timeline_item_takes # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() @_destructive_op("timeline_item_takes") def timeline_item_takes(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Take management on timeline items. Identify by track_type, track_index, item_index. Actions: add(clip_id, start_frame?, end_frame?, ...) -> {success} get_count(...) -> {count} get_selected_index(...) -> {index} get_by_index(index, ...) -> {take} select(index, ...) -> {success} delete(index, ...) -> {success} finalize(...) -> {success} Default: track_type="video", track_index=1, item_index=0 """ p = params or {} _, item, err = _get_item(p) if err: return err if action == "add": _, _, mp, mp_err = _get_mp() if mp_err: return mp_err clip = _find_clip(mp.GetRootFolder(), p["clip_id"]) if not clip: return _err(f"Clip not found: {p['clip_id']}") return {"success": bool(item.AddTake(clip, p.get("start_frame", 0), p.get("end_frame", 0)))} elif action == "get_count": return {"count": item.GetTakesCount()} elif action == "get_selected_index": return {"index": item.GetSelectedTakeIndex()} elif action == "get_by_index": return _ser(item.GetTakeByIndex(p["index"])) elif action == "select": return {"success": bool(item.SelectTakeByIndex(p["index"]))} elif action == "delete": return {"success": bool(item.DeleteTakeByIndex(p["index"]))} elif action == "finalize": return {"success": bool(item.FinalizeTake())} return _unknown(action, ["add","get_count","get_selected_index","get_by_index","select","delete","finalize"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 23: gallery # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() def gallery(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Gallery album management. Actions: get_album_name(album_index?) -> {name} set_album_name(name, album_index?) -> {success} get_current_album() -> {available} set_current_album(album_index) -> {success} get_still_albums() -> {albums} get_power_grade_albums() -> {albums} create_still_album() -> {success} create_power_grade_album() -> {success} album_index is 0-based into the still albums list. """ p = params or {} _, proj, err = _check() if err: return err gal = proj.GetGallery() if not gal: return _err("Gallery not available") if action == "get_album_name": albums = gal.GetGalleryStillAlbums() or [] idx = p.get("album_index", 0) if idx < len(albums): return {"name": gal.GetAlbumName(albums[idx])} return _err("Album index out of range") elif action == "set_album_name": albums = gal.GetGalleryStillAlbums() or [] idx = p.get("album_index", 0) if idx < len(albums): return {"success": bool(gal.SetAlbumName(albums[idx], p["name"]))} return _err("Album index out of range") elif action == "get_current_album": album = gal.GetCurrentStillAlbum() return {"available": album is not None} elif action == "set_current_album": albums = gal.GetGalleryStillAlbums() or [] idx = p.get("album_index", 0) if idx < len(albums): return {"success": bool(gal.SetCurrentStillAlbum(albums[idx]))} return _err("Album index out of range") elif action == "get_still_albums": albums = gal.GetGalleryStillAlbums() or [] return {"albums": [{"name": gal.GetAlbumName(a), "index": i} for i, a in enumerate(albums)]} elif action == "get_power_grade_albums": albums = gal.GetGalleryPowerGradeAlbums() or [] return {"albums": [{"name": gal.GetAlbumName(a), "index": i} for i, a in enumerate(albums)]} elif action == "create_still_album": album = gal.CreateGalleryStillAlbum() return _ok() if album else _err("Failed to create still album") elif action == "create_power_grade_album": album = gal.CreateGalleryPowerGradeAlbum() return _ok() if album else _err("Failed to create power grade album") return _unknown(action, ["get_album_name","set_album_name","get_current_album","set_current_album","get_still_albums","get_power_grade_albums","create_still_album","create_power_grade_album"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 24: gallery_stills # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() def gallery_stills(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Manage stills in gallery albums (best results on Color page). Actions: get_stills(album_index?) -> {count} get_label(still_index, album_index?) -> {label} set_label(still_index, label, album_index?) -> {success} import_stills(paths, album_index?) -> {success} export_stills(folder_path, prefix?, format?, album_index?) -> {success} grab_and_export(folder_path, prefix?, format?, album_index?, delete_after?, cleanup?) -> {files} delete_stills(still_indices, album_index?) -> {success} album_index defaults to current album. still_index is 0-based. format for export: dpx, cin, tif, jpg, png, ppm, bmp, xpm, drx (default: dpx). grab_and_export grabs a still from the current frame and exports it immediately, keeping the live GalleryStill reference (more reliable than separate grab + export). Requires Color page. Automatically produces a companion .drx grade file. File data is inlined in the response (DRX as text, images as base64). cleanup (default true) deletes exported files from disk after inlining. """ p = params or {} _, proj, err = _check() if err: return err gal = proj.GetGallery() if not gal: return _err("Gallery not available") album_idx = p.get("album_index") if album_idx is not None: albums = gal.GetGalleryStillAlbums() or [] if album_idx < len(albums): album = albums[album_idx] else: return _err("Album index out of range") else: album = gal.GetCurrentStillAlbum() if not album: albums = gal.GetGalleryStillAlbums() or [] album = albums[0] if albums else None if not album: return _err("No still album available") if action == "get_stills": stills = album.GetStills() or [] return {"count": len(stills)} elif action == "get_label": stills = album.GetStills() or [] idx = p.get("still_index", 0) if idx < len(stills): return {"label": album.GetLabel(stills[idx])} return _err("Still index out of range") elif action == "set_label": stills = album.GetStills() or [] idx = p.get("still_index", 0) if idx < len(stills): return {"success": bool(album.SetLabel(stills[idx], p["label"]))} return _err("Still index out of range") elif action == "import_stills": return {"success": bool(album.ImportStills(p["paths"]))} elif action == "export_stills": stills = album.GetStills() or [] if not stills: return _err("No stills to export") return {"success": bool(album.ExportStills(stills, p["folder_path"], p.get("prefix", "still"), p.get("format", "dpx")))} elif action == "grab_and_export": import time, os folder_path = p.get("folder_path") if not folder_path: return _err("folder_path is required") prefix = p.get("prefix", "still") fmt = p.get("format", "dpx") delete_after = p.get("delete_after", True) # Redirect sandbox/temp paths that Resolve can't access folder_path = _resolve_safe_dir(folder_path) os.makedirs(folder_path, exist_ok=True) # Snapshot directory before export before = set(os.listdir(folder_path)) # Grab still — requires Color page with a clip under the playhead _, tl, err2 = _get_tl() if err2: return err2 still = tl.GrabStill() if not still: return _err("GrabStill failed — ensure Color page is active with a clip under the playhead") time.sleep(0.5) # Export using the live still reference with format fallback chain export_ok = False used_format = fmt for try_fmt in [fmt, "tif", "dpx"]: result = album.ExportStills([still], folder_path, prefix, try_fmt) if result: export_ok = True used_format = try_fmt break time.sleep(0.3) # Clean up still from gallery if delete_after: album.DeleteStills([still]) if not export_ok: return _err("ExportStills failed — ensure the Gallery panel is open on the Color page (Workspace > Gallery)") # Wait for filesystem time.sleep(0.3) # Find new files after = set(os.listdir(folder_path)) new_files = sorted(after - before) file_details = [] for f in new_files: fpath = os.path.join(folder_path, f) entry = {"name": f, "path": fpath, "size": os.path.getsize(fpath)} # Inline file data so cleanup can safely remove files try: with open(fpath, "rb") as fh: raw = fh.read() if f.endswith(".drx"): # DRX files are small XML — inline as text try: entry["data"] = raw.decode("utf-8") except UnicodeDecodeError: entry["data_base64"] = base64.b64encode(raw).decode("ascii") else: entry["data_base64"] = base64.b64encode(raw).decode("ascii") except OSError: pass file_details.append(entry) # Cleanup: remove exported files now that data is inlined (default: True) cleanup = p.get("cleanup", True) if cleanup: for f in file_details: try: os.remove(f["path"]) except OSError: pass # Remove the directory if empty try: if os.path.isdir(folder_path) and not os.listdir(folder_path): os.rmdir(folder_path) except OSError: pass return {"files": file_details, "format": used_format, "folder": folder_path, "cleaned_up": cleanup} elif action == "delete_stills": stills = album.GetStills() or [] to_delete = [stills[i] for i in p["still_indices"] if i < len(stills)] return {"success": bool(album.DeleteStills(to_delete))} if to_delete else _err("No valid still indices") return _unknown(action, ["get_stills","get_label","set_label","import_stills","export_stills","grab_and_export","delete_stills"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 25: graph # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() @_destructive_op("graph") def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Node graph operations (color grading nodes). Source can be timeline, timeline item, or color group. PREFER timeline_item_color.probe_node_graph / safe_apply_drx for inspection and replacement. These raw graph actions do not capture a version snapshot before mutating. Read-only: get_num_nodes(source?, ...) -> {count} get_lut(node_index, source?, ...) -> {lut} get_node_cache(node_index, source?, ...) -> {cache} get_node_label(node_index, source?, ...) -> {label} get_tools_in_node(node_index, source?, ...) -> {tools} Mutators (UNSAFE — bypass grade-version snapshot; prefer timeline_item_color.safe_* where available): set_lut(node_index, lut_path, source?, ...) -> {success} UNSAFE. Assigns a LUT to an existing node. No snapshot/dry_run. set_node_cache(node_index, cache_value, source?, ...) -> {success} set_node_enabled(node_index, enabled, source?, ...) -> {success} apply_grade_from_drx(path, grade_mode?, source?, ...) -> {success} CATASTROPHIC. REPLACES the entire node graph. Prefer timeline_item_color.safe_apply_drx (which snapshots the current version first). All grade_modes are full replacement — there is no append mode. grade_mode: 0="No keyframes" (default), 1="Source Timecode aligned", 2="Start Frames aligned" apply_arri_cdl_lut(source?, ...) -> {success} DESTRUCTIVE. Bakes a CDL LUT into the graph. reset_all_grades(source?, ...) -> {success} CATASTROPHIC. Wipes the entire grade on the source. source: "timeline" (default), "item" (needs track_type/track_index/item_index), "color_group_pre"/"color_group_post" (needs group_name) For long-form per-action guidance and a worked example, call: graph(action="action_help", params={"name": ""}) """ p = params or {} if action == "action_help": return _action_help("graph", p) source = p.get("source", "timeline") # Get the graph object based on source g = None if source == "timeline": _, tl, err = _get_tl() if err: return err g = tl.GetNodeGraph() elif source == "item": _, item, err = _get_item(p) if err: return err g = item.GetNodeGraph(p["layer_index"]) if "layer_index" in p else item.GetNodeGraph() elif source in ("color_group_pre", "color_group_post"): _, proj, err = _check() if err: return err groups = proj.GetColorGroupsList() or [] for cg in groups: if cg.GetName() == p.get("group_name"): g = cg.GetPreClipNodeGraph() if source == "color_group_pre" else cg.GetPostClipNodeGraph() break if g is None: return _err(f"Color group '{p.get('group_name')}' not found") if not g: return _err("No node graph available for the specified source") if action == "get_num_nodes": return {"count": g.GetNumNodes()} elif action == "get_lut": return {"lut": g.GetLUT(p["node_index"])} elif action == "set_lut": return {"success": bool(g.SetLUT(p["node_index"], p["lut_path"]))} elif action == "get_node_cache": return {"cache": g.GetNodeCacheMode(p["node_index"])} elif action == "set_node_cache": return {"success": bool(g.SetNodeCacheMode(p["node_index"], p["cache_value"]))} elif action == "get_node_label": return {"label": g.GetNodeLabel(p["node_index"])} elif action == "get_tools_in_node": return {"tools": _ser(g.GetToolsInNode(p["node_index"]))} elif action == "set_node_enabled": return {"success": bool(g.SetNodeEnabled(p["node_index"], p["enabled"]))} elif action == "apply_grade_from_drx": if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required(): return _issue_confirm_token( action="graph.apply_grade_from_drx", params=p, preview={"operation": "graph.apply_grade_from_drx", "warning": "REPLACES the entire node graph. There is no append mode.", "path": p.get("path"), "grade_mode": p.get("grade_mode", p.get("mode", 0))}, ) blocked = _consume_confirm_token(action="graph.apply_grade_from_drx", params=p) if blocked: return blocked return {"success": bool(g.ApplyGradeFromDRX(p["path"], p.get("grade_mode", p.get("mode", 0))))} elif action == "apply_arri_cdl_lut": return {"success": bool(g.ApplyArriCdlLut())} elif action == "reset_all_grades": if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required(): return _issue_confirm_token( action="graph.reset_all_grades", params=p, preview={"operation": "graph.reset_all_grades", "warning": "Wipes the entire grade on the source. Cannot be selectively undone."}, ) blocked = _consume_confirm_token(action="graph.reset_all_grades", params=p) if blocked: return blocked return {"success": bool(g.ResetAllGrades())} return _unknown(action, ["get_num_nodes","get_lut","set_lut","get_node_cache","set_node_cache","get_node_label","get_tools_in_node","set_node_enabled","apply_grade_from_drx","apply_arri_cdl_lut","reset_all_grades"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 26: color_group # ═══════════════════════════════════════════════════════════════════════════════ @mcp.tool() def color_group(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Manage color groups and their node graphs. Actions: list() -> {groups} get_name(group_name) -> {name} set_name(group_name, new_name) -> {success} get_clips(group_name) -> {clips} get_pre_clip_graph(group_name) -> {available, num_nodes} get_post_clip_graph(group_name) -> {available, num_nodes} """ p = params or {} _, proj, err = _check() if err: return err if action == "list": groups = proj.GetColorGroupsList() or [] return {"groups": [g.GetName() for g in groups]} # All other actions need a group groups = proj.GetColorGroupsList() or [] group = None for g in groups: if g.GetName() == p.get("group_name"): group = g break if not group: return _err(f"Color group '{p.get('group_name')}' not found") if action == "get_name": return {"name": group.GetName()} elif action == "set_name": return {"success": bool(group.SetName(p["new_name"]))} elif action == "get_clips": tl = proj.GetCurrentTimeline() clips = group.GetClipsInTimeline(tl) if tl else [] return {"clips": [{"name": c.GetName(), "id": c.GetUniqueId()} for c in (clips or [])]} elif action == "get_pre_clip_graph": g = group.GetPreClipNodeGraph() return {"available": g is not None, "num_nodes": g.GetNumNodes() if g else 0} elif action == "get_post_clip_graph": g = group.GetPostClipNodeGraph() return {"available": g is not None, "num_nodes": g.GetNumNodes() if g else 0} return _unknown(action, ["list","get_name","set_name","get_clips","get_pre_clip_graph","get_post_clip_graph"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 27: fusion_comp # ═══════════════════════════════════════════════════════════════════════════════ _FUSION_GROUP_KERNEL_ACTIONS = [ "group_settings_export", "group_settings_splice_inputs", "group_settings_load", "bulk_set_expressions", "probe_group_published_inputs", ] def _find_fusion_group(comp, group_name: str): tool = comp.FindTool(group_name) if tool: attrs = tool.GetAttrs() or {} if attrs.get("TOOLS_RegID") == "GroupOperator": return tool tool_list = comp.GetToolList(False, "GroupOperator") or {} for idx in tool_list: candidate = tool_list[idx] attrs = candidate.GetAttrs() or {} if attrs.get("TOOLS_Name") == group_name: return candidate return None def _resolve_setting_path(p: Dict[str, Any], key: str = "path") -> Tuple[Optional[str], Optional[Dict[str, Any]]]: raw = p.get(key) if not raw or not str(raw).strip(): return None, _err(f"{key} is required") return os.path.abspath(str(raw)), None def _fusion_group_advisory(include: bool) -> Dict[str, Any]: if not include: return {} return { "guardrails": list(FUSION_GROUP_GUARDRAILS), "commit_checklist": list(FUSION_COMMIT_CHECKLIST), } def _fusion_group_settings_export(comp, p: Dict[str, Any]) -> Dict[str, Any]: group_name = p.get("group_name") if not group_name: return _err("group_name is required") path, path_err = _resolve_setting_path(p) if path_err: return path_err group = _find_fusion_group(comp, str(group_name)) if not group: return _err(f"GroupOperator {group_name!r} not found in comp") os.makedirs(os.path.dirname(path) or ".", exist_ok=True) try: group.SaveSettings(path) except Exception as exc: return _err(f"SaveSettings failed: {exc}") if not os.path.isfile(path): return _err(f"SaveSettings did not create file at {path!r}") try: parsed = parse_setting_file(path, group_name=str(group_name)) except Exception as exc: return { "success": True, "path": path, "group_name": group_name, "parse_warning": f"saved, but summary parse failed: {exc}", **_fusion_group_advisory(p.get("include_advisory", False)), } return { "success": True, "path": path, "group_name": group_name, "published_inputs": parsed["published_inputs"], "input_count": parsed["input_count"], **_fusion_group_advisory(p.get("include_advisory", False)), } def _fusion_group_settings_splice_inputs(p: Dict[str, Any]) -> Dict[str, Any]: source_path, src_err = _resolve_setting_path(p, "source_path") if src_err: return src_err if not os.path.isfile(source_path): return _err(f"source_path not found: {source_path}") template_path, tpl_err = _resolve_setting_path(p, "template_path") if tpl_err: return tpl_err if not os.path.isfile(template_path): return _err(f"template_path not found: {template_path}") dest_path = p.get("dest_path") if dest_path: dest_path = os.path.abspath(str(dest_path)) else: root, ext = os.path.splitext(source_path) dest_path = f"{root}.patched{ext or '.setting'}" try: summary = splice_inputs_block( source_path, template_path, dest_path, source_group_name=p.get("source_group_name") or p.get("group_name"), template_group_name=p.get("template_group_name"), ) except Exception as exc: return _err(f"splice failed: {exc}") return { "success": True, "dest_path": summary["dest_path"], "summary": summary, **_fusion_group_advisory(p.get("include_advisory", False)), } def _fusion_group_settings_load(comp, p: Dict[str, Any]) -> Dict[str, Any]: group_name = p.get("group_name") if not group_name: return _err("group_name is required") settings_path, path_err = _resolve_setting_path(p, "settings_path") if path_err: return path_err if not os.path.isfile(settings_path): return _err(f"settings_path not found: {settings_path}") group = _find_fusion_group(comp, str(group_name)) if not group: return _err(f"GroupOperator {group_name!r} not found in comp") backup_path = p.get("backup_path") if backup_path: backup_path = os.path.abspath(str(backup_path)) else: backup_path = default_backup_path(settings_path) try: os.makedirs(os.path.dirname(backup_path) or ".", exist_ok=True) group.SaveSettings(backup_path) except Exception as exc: return _err(f"backup SaveSettings failed: {exc}") undo_name = p.get("undo_name", f"MCP group_settings_load {group_name}") undo_started = False keep_undo = False error_message: Optional[str] = None try: try: comp.StartUndo(undo_name) undo_started = True except Exception: undo_started = False comp.Lock() try: group.LoadSettings(settings_path) keep_undo = True finally: comp.Unlock() except Exception as exc: error_message = str(exc) finally: if undo_started: try: comp.EndUndo(keep_undo) except Exception: pass if error_message is not None: return _err( f"LoadSettings failed: {error_message}", remediation=f"Group state preserved by backup at {backup_path}", ) return { "success": True, "group_name": group_name, "settings_path": settings_path, "backup_path": backup_path, "warning": ( "Group preserved. If Edit Controls don't refresh, select the group in " "Fusion and use UI Load Settings to remap InstanceInput order." ), **_fusion_group_advisory(p.get("include_advisory", False)), } def _fusion_comp_bulk_set_expressions(p: Dict[str, Any]) -> Dict[str, Any]: ops = p.get("ops") if not isinstance(ops, list) or not ops: return _err( "bulk_set_expressions requires params.ops: non-empty list of objects. " "Each op must include tool_name, input_name, expression, and a timeline scope: " "clip_id, timeline_item_id, or timeline_item={track_type, track_index, item_index}. " "Optional per-op: comp_name, comp_index, time, undo_name." ) results: List[Dict[str, Any]] = [] for index, op in enumerate(ops): if not isinstance(op, dict): results.append({"index": index, "error": "op must be an object"}) continue if not _has_fusion_timeline_scope(op): results.append({"index": index, "error": "timeline scope is required for bulk_set_expressions"}) continue missing = [key for key in ("tool_name", "input_name", "expression") if key not in op] if missing: results.append({"index": index, "error": f"missing required field(s): {', '.join(missing)}"}) continue comp, comp_err = _resolve_fusion_comp(op, require_timeline_scope=True) if comp_err: results.append({"index": index, "error": comp_err.get("error", str(comp_err))}) continue tool = comp.FindTool(op["tool_name"]) if not tool: results.append({"index": index, "error": f"Tool {op['tool_name']!r} not found"}) continue undo_name = op.get("undo_name", f"MCP bulk_set_expressions #{index}") undo_started = False keep_undo = False error_message: Optional[str] = None try: try: comp.StartUndo(undo_name) undo_started = True except Exception: undo_started = False comp.Lock() try: time = op.get("time", 0) inp = tool[op["input_name"]] if not inp: raise ValueError(f"Input {op['input_name']!r} not found on {op['tool_name']!r}") inp.SetExpression(str(op["expression"]), time) keep_undo = True finally: comp.Unlock() except Exception as exc: error_message = str(exc) finally: if undo_started: try: comp.EndUndo(keep_undo) except Exception: pass if error_message is not None: results.append({"index": index, "error": error_message}) elif keep_undo: results.append({"index": index, "success": True, "expression": op["expression"]}) return {"results": results, "op_count": len(ops)} def _fusion_probe_group_published_inputs(comp, p: Dict[str, Any]) -> Dict[str, Any]: group_name = p.get("group_name") if not group_name: return _err("group_name is required") group = _find_fusion_group(comp, str(group_name)) if not group: return _err(f"GroupOperator {group_name!r} not found in comp") max_inputs = p.get("max_inputs", 32) try: max_inputs = int(max_inputs) except (TypeError, ValueError): max_inputs = 32 time = p.get("time", 0) live_inputs: List[Dict[str, Any]] = [] for index in range(1, max_inputs + 1): slot = f"Input{index}" try: inp = group[slot] except Exception: break if not inp: break row: Dict[str, Any] = {"slot": slot} try: attrs = inp.GetAttrs() or {} row["name"] = attrs.get("INPS_Name", "") row["type"] = attrs.get("INPS_DataType", "") except Exception: pass try: row["value"] = _ser(inp[time]) except Exception as exc: row["value_error"] = str(exc) try: row["expression"] = inp.GetExpression(time) except Exception: row["expression"] = None live_inputs.append(row) file_summary: Optional[Dict[str, Any]] = None settings_path = p.get("settings_path") if settings_path and os.path.isfile(str(settings_path)): try: file_summary = parse_setting_file(str(settings_path), group_name=str(group_name)) except Exception as exc: file_summary = {"error": str(exc)} return { "group_name": group_name, "live_inputs": live_inputs, "live_input_count": len(live_inputs), "file_summary": file_summary, **_fusion_group_advisory(p.get("include_advisory", False)), } def _fusion_comp_bulk_set_inputs(p: Dict[str, Any]) -> Dict[str, Any]: """Apply set_input across many explicitly scoped timeline-item Fusion comps.""" ops = p.get("ops") if not isinstance(ops, list) or not ops: return _err( "bulk_set_inputs requires params.ops: non-empty list of objects. " "Each op must include tool_name, input_name, value, and a timeline scope: " "clip_id, timeline_item_id, or timeline_item={track_type, track_index, item_index}. " "Optional per-op: comp_name, comp_index, time, undo_name." ) results: List[Dict[str, Any]] = [] for index, op in enumerate(ops): if not isinstance(op, dict): results.append({"index": index, "error": "op must be an object"}) continue if not _has_fusion_timeline_scope(op): results.append({ "index": index, "error": "timeline scope is required for bulk_set_inputs", }) continue missing = [key for key in ("tool_name", "input_name", "value") if key not in op] if missing: results.append({"index": index, "error": f"missing required field(s): {', '.join(missing)}"}) continue comp, comp_err = _resolve_fusion_comp(op, require_timeline_scope=True) if comp_err: results.append({"index": index, "error": comp_err.get("error", str(comp_err))}) continue tool = comp.FindTool(op["tool_name"]) if not tool: results.append({"index": index, "error": f"Tool {op['tool_name']!r} not found"}) continue undo_name = op.get("undo_name", f"MCP bulk_set_inputs #{index}") undo_started = False keep_undo = False error_message = None try: try: comp.StartUndo(undo_name) undo_started = True except Exception: undo_started = False comp.Lock() try: if "time" in op: tool.SetInput(op["input_name"], op["value"], op["time"]) else: tool.SetInput(op["input_name"], op["value"]) keep_undo = True finally: comp.Unlock() except Exception as exc: error_message = str(exc) finally: if undo_started: try: comp.EndUndo(keep_undo) except Exception: pass if error_message is not None: results.append({"index": index, "error": error_message}) elif keep_undo: results.append({"index": index, "success": True}) return {"results": results, "op_count": len(ops)} _FUSION_KERNEL_ACTIONS = [ "fusion_graph_capabilities", "probe_fusion_comp", "probe_fusion_tool", "safe_add_tool", "safe_set_inputs", "safe_connect_tools", "fusion_boundary_report", ] _COMMON_FUSION_TOOLS = [ "MediaIn", "MediaOut", "Background", "TextPlus", "Merge", "Transform", "Blur", "ColorCorrector", "RectangleMask", "EllipseMask", "Glow", ] def _fusion_tool_summary(tool, *, include_io=False): attrs = tool.GetAttrs() or {} out = { "name": attrs.get("TOOLS_Name", ""), "type": attrs.get("TOOLS_RegID", ""), "attrs": _ser(attrs), } if include_io: inputs = [] input_list = tool.GetInputList() or {} for idx in input_list: inp = input_list[idx] inp_attrs = inp.GetAttrs() or {} connected = inp.GetConnectedOutput() connected_to = None if connected: conn_tool = connected.GetTool() if conn_tool: connected_to = (conn_tool.GetAttrs() or {}).get("TOOLS_Name", "") inputs.append( { "name": inp_attrs.get("INPS_Name", ""), "id": inp_attrs.get("INPS_ID", ""), "type": inp_attrs.get("INPS_DataType", ""), "connected_to": connected_to, } ) outputs = [] output_list = tool.GetOutputList() or {} for idx in output_list: output = output_list[idx] output_attrs = output.GetAttrs() or {} outputs.append( { "name": output_attrs.get("OUTS_Name", ""), "id": output_attrs.get("OUTS_ID", ""), "type": output_attrs.get("OUTS_DataType", ""), } ) out["inputs"] = inputs out["outputs"] = outputs return out def _fusion_comp_snapshot(comp, p: Dict[str, Any]): attrs = comp.GetAttrs() or {} tools = [] tool_list = comp.GetToolList() or {} max_tools = p.get("max_tools", 20) try: max_tools = int(max_tools) except (TypeError, ValueError): max_tools = 20 include_io = bool(p.get("include_io", False)) for idx in list(tool_list)[:max_tools]: tools.append(_fusion_tool_summary(tool_list[idx], include_io=include_io)) return { "name": attrs.get("COMPS_Name", ""), "tool_count": len(tool_list), "attrs": _ser(attrs), "tools": tools, } def _fusion_graph_capabilities(comp): attrs = comp.GetAttrs() or {} return { "comp": { "name": attrs.get("COMPS_Name", ""), "tool_count": len(comp.GetToolList() or {}), }, "common_tools": list(_COMMON_FUSION_TOOLS), "supported": [ "timeline-item Fusion comp targeting", "tool list and attr inspection", "tool input/output inspection", "safe tool creation", "safe batch input writes with optional readback", "validated tool connections", "timeline item comp import/export through timeline_item_fusion", ], "boundaries": [ "Tool availability varies by Resolve/Fusion build.", "Some inputs are write-only or coerce values without reliable readback.", "Fusion page current comp and timeline-item comp scopes are different.", "Comp rendering requires a valid graph and can be page/state dependent.", ], } def _safe_add_fusion_tool(comp, p: Dict[str, Any]): tool_type = p.get("tool_type") if not tool_type: return _err("tool_type is required") if p.get("dry_run"): return _ok(tool_type=tool_type, name=p.get("name"), would_add=True) comp.Lock() try: tool = comp.AddTool(tool_type, p.get("x", -1), p.get("y", -1)) if not tool: return _err(f"Failed to add tool '{tool_type}'. Check the tool ID is valid.") if p.get("name"): tool.SetAttrs({"TOOLS_Name": p["name"]}) return _ok(tool=_fusion_tool_summary(tool, include_io=p.get("include_io", True))) finally: comp.Unlock() def _probe_fusion_tool(comp, p: Dict[str, Any]): name = p.get("tool_name") or p.get("name") if not name: return _err("tool_name is required") tool = comp.FindTool(name) if not tool: return {"found": False, "tool_name": name} return {"found": True, "tool": _fusion_tool_summary(tool, include_io=p.get("include_io", True))} def _safe_set_fusion_inputs(comp, p: Dict[str, Any]): tool_name = p.get("tool_name") inputs = p.get("inputs") if not tool_name: return _err("tool_name is required") if not isinstance(inputs, dict) or not inputs: return _err("inputs must be a non-empty object") tool = comp.FindTool(tool_name) if not tool: return _err(f"Tool '{tool_name}' not found") if p.get("dry_run"): return _ok(tool_name=tool_name, inputs=inputs, would_set=True) results = {} comp.Lock() try: for input_name, value in inputs.items(): try: if "time" in p: tool.SetInput(input_name, value, p["time"]) else: tool.SetInput(input_name, value) row = {"success": True} if p.get("readback", True): try: row["value"] = _ser(tool.GetInput(input_name, p["time"])) if "time" in p else _ser(tool.GetInput(input_name)) except Exception as exc: row["readback_error"] = str(exc) results[input_name] = row except Exception as exc: results[input_name] = {"success": False, "error": str(exc)} finally: comp.Unlock() return {"success": all(row.get("success") for row in results.values()), "tool_name": tool_name, "results": results} def _safe_connect_fusion_tools(comp, p: Dict[str, Any]): target_name = p.get("target_tool") source_name = p.get("source_tool") input_name = p.get("input_name") if not target_name or not source_name or not input_name: return _err("target_tool, source_tool, and input_name are required") target = comp.FindTool(target_name) if not target: return _err(f"Target tool '{target_name}' not found") source = comp.FindTool(source_name) if not source: return _err(f"Source tool '{source_name}' not found") if p.get("dry_run"): return _ok(target_tool=target_name, source_tool=source_name, input_name=input_name, would_connect=True) comp.Lock() try: success = bool(target.ConnectInput(input_name, source)) finally: comp.Unlock() return {"success": success, "target_tool": target_name, "source_tool": source_name, "input_name": input_name} def _fusion_boundary_report(comp, p: Dict[str, Any]): return { "capabilities": _fusion_graph_capabilities(comp), "composition": _fusion_comp_snapshot(comp, {**p, "include_io": p.get("include_io", True)}), } @mcp.tool() def _parse_pos(raw): """Normalize a FlowView position return into (x, y) floats, or None. Depending on the bridge, position reads come back as a 1-indexed Lua table, a dict ({1: x, 2: y} / {"x": .., "y": ..}), or an (x, y) tuple/list. Be liberal in what we accept. """ if raw is None: return None if isinstance(raw, dict): for ka, kb in ((1, 2), ("1", "2"), ("x", "y"), ("X", "Y")): if ka in raw and kb in raw: try: return float(raw[ka]), float(raw[kb]) except (TypeError, ValueError): return None vals = list(raw.values()) if len(vals) >= 2: try: return float(vals[0]), float(vals[1]) except (TypeError, ValueError): return None return None if isinstance(raw, (list, tuple)): if len(raw) >= 2: try: return float(raw[0]), float(raw[1]) except (TypeError, ValueError): return None return None # Lua table object with numeric indexing. try: return float(raw[1]), float(raw[2]) except Exception: return None def _fusion_flow_view(comp): """Return the comp's FlowView (node-graph canvas), or None if unavailable.""" try: cf = comp.CurrentFrame if cf is None: return None return cf.FlowView except Exception: return None def _iter_fusion_tools(comp): """Yield (tool_name, tool) for every tool in the comp.""" tools = comp.GetToolList() or {} for idx in tools: t = tools[idx] try: name = t.GetAttrs().get("TOOLS_Name", "") except Exception: name = "" yield name, t def _fusion_tool_names(comp): """Return the set-friendly list of tool names currently in the comp.""" return [name for name, _ in _iter_fusion_tools(comp)] def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Fusion composition node graph operations. Target comp: - Timeline item: pass clip_id, timeline_item_id, or timeline_item={track_type, track_index, item_index}. Optional comp_name or 1-based comp_index selects a specific comp; otherwise the first comp is used. - Fusion page: omit timeline scope and this uses Fusion().GetCurrentComp(). Use timeline_item_fusion to add/delete/import/export comps on items. Actions: add_tool(tool_type, x?, y?, name?) -> {tool_name, tool_type} delete_tool(tool_name) -> {success} get_tool_list(type?) -> {tools, count} find_tool(name) -> {found, name, type, attrs} connect(target_tool, input_name, source_tool, output_name?) -> {success} disconnect(tool_name, input_name) -> {success} get_inputs(tool_name) -> {inputs} get_outputs(tool_name) -> {outputs} set_input(tool_name, input_name, value, time?) -> {success} get_input(tool_name, input_name, time?) -> {value} set_attrs(tool_name, attrs) -> {success} get_attrs(tool_name) -> {attrs} add_keyframe(tool_name, input_name, time, value) -> {success} get_keyframes(tool_name, input_name) -> {keyframes} delete_keyframe(tool_name, input_name, time) -> {success} get_comp_info() -> {name, tool_count, attrs} get_position(tool_name) -> {tool_name, x, y} — read a node's FlowView position set_position(tool_name, x, y) -> {success, x, y, readback} — move a node copy_tool(tool_name, name?, x?, y?) -> {success, new_tool, new_tools} — duplicate a node auto_arrange(tool_names?, direction?, spacing?, x?, y?) -> {success, arranged, count} Lay tools out in a row (direction="horizontal", default) or column ("vertical"). set_frame_range(start, end) -> {success} get_frame_range() -> {start, end} — read the comp's render frame range render() -> {success} start_undo(name?) -> {success} end_undo(keep?) -> {success} bulk_set_inputs(ops) -> {results, op_count} — each op requires timeline scope plus tool_name, input_name, value bulk_set_expressions(ops) -> {results, op_count} — each op requires timeline scope plus tool_name, input_name, expression group_settings_export(group_name, path, include_advisory?) -> {path, published_inputs, input_count} group_settings_splice_inputs(source_path, template_path, dest_path?, source_group_name?, template_group_name?) -> {dest_path, summary} Replace a source .setting's `Inputs = ordered() { ... }` block with the matching block from template_path. Both files are read; neither GroupOperator is loaded into Resolve. Inner tools and outer structure are preserved. group_settings_load(group_name, settings_path, backup_path?, undo_name?) -> {settings_path, backup_path} Wraps the LoadSettings call in StartUndo/EndUndo + comp.Lock for reversibility. probe_group_published_inputs(group_name, settings_path?, max_inputs?, time?) -> {live_inputs, file_summary?} fusion_graph_capabilities(...) -> {supported, boundaries, common_tools} probe_fusion_comp(include_io?, max_tools?) -> {name, tool_count, tools} probe_fusion_tool(tool_name, include_io?) -> {found, tool} safe_add_tool(tool_type, name?, dry_run?) -> {success, tool} safe_set_inputs(tool_name, inputs, readback?) -> {success, results} safe_connect_tools(target_tool, input_name, source_tool, dry_run?) -> {success} fusion_boundary_report(include_io?) -> {capabilities, composition} Common tool_type values: Merge, Background, TextPlus, Transform, Blur, ColorCorrector, RectangleMask, EllipseMask, Tracker, MediaIn, MediaOut, Loader, Saver, Glow, FilmGrain, CornerPositioner, DeltaKeyer, UltraKeyer """ p = params or {} if action == "bulk_set_inputs": return _fusion_comp_bulk_set_inputs(p) if action == "bulk_set_expressions": return _fusion_comp_bulk_set_expressions(p) if action == "group_settings_splice_inputs": return _fusion_group_settings_splice_inputs(p) comp, comp_err = _resolve_fusion_comp(p) if comp_err: return comp_err if action == "group_settings_export": return _fusion_group_settings_export(comp, p) if action == "group_settings_load": return _fusion_group_settings_load(comp, p) if action == "probe_group_published_inputs": return _fusion_probe_group_published_inputs(comp, p) if action == "fusion_graph_capabilities": return _fusion_graph_capabilities(comp) elif action == "probe_fusion_comp": return _fusion_comp_snapshot(comp, p) elif action == "probe_fusion_tool": return _probe_fusion_tool(comp, p) elif action == "safe_add_tool": return _safe_add_fusion_tool(comp, p) elif action == "safe_set_inputs": return _safe_set_fusion_inputs(comp, p) elif action == "safe_connect_tools": return _safe_connect_fusion_tools(comp, p) elif action == "fusion_boundary_report": return _fusion_boundary_report(comp, p) # --- Node Management --- if action == "add_tool": tool_type = p.get("tool_type") if not tool_type: return _err("tool_type is required (e.g. 'Merge', 'Transform', 'TextPlus', 'Background')") x = p.get("x", -1) y = p.get("y", -1) comp.Lock() try: tool = comp.AddTool(tool_type, x, y) if not tool: return _err(f"Failed to add tool '{tool_type}'. Check the tool ID is valid.") name = p.get("name") if name: tool.SetAttrs({"TOOLS_Name": name}) attrs = tool.GetAttrs() return {"tool_name": attrs.get("TOOLS_Name", ""), "tool_type": attrs.get("TOOLS_RegID", tool_type)} finally: comp.Unlock() elif action == "delete_tool": tool = comp.FindTool(p["tool_name"]) if not tool: return _err(f"Tool '{p['tool_name']}' not found") comp.Lock() try: tool.Delete() return _ok() finally: comp.Unlock() elif action == "get_tool_list": filter_type = p.get("type") if filter_type: tools = comp.GetToolList(False, filter_type) else: tools = comp.GetToolList() result = [] if tools: for idx in tools: t = tools[idx] attrs = t.GetAttrs() result.append({ "name": attrs.get("TOOLS_Name", ""), "type": attrs.get("TOOLS_RegID", ""), }) return {"tools": result, "count": len(result)} elif action == "find_tool": tool = comp.FindTool(p["name"]) if not tool: return {"found": False} attrs = tool.GetAttrs() return {"found": True, "name": attrs.get("TOOLS_Name", ""), "type": attrs.get("TOOLS_RegID", ""), "attrs": _ser(attrs)} # --- Wiring --- elif action == "connect": target = comp.FindTool(p["target_tool"]) if not target: return _err(f"Target tool '{p['target_tool']}' not found") source = comp.FindTool(p["source_tool"]) if not source: return _err(f"Source tool '{p['source_tool']}' not found") comp.Lock() try: result = target.ConnectInput(p["input_name"], source) return {"success": bool(result)} finally: comp.Unlock() elif action == "disconnect": tool = comp.FindTool(p["tool_name"]) if not tool: return _err(f"Tool '{p['tool_name']}' not found") comp.Lock() try: result = tool.ConnectInput(p["input_name"], None) return {"success": bool(result)} finally: comp.Unlock() elif action == "get_inputs": tool = comp.FindTool(p["tool_name"]) if not tool: return _err(f"Tool '{p['tool_name']}' not found") input_list = tool.GetInputList() inputs = [] if input_list: for idx in input_list: inp = input_list[idx] attrs = inp.GetAttrs() connected = inp.GetConnectedOutput() conn_info = None if connected: conn_tool = connected.GetTool() if conn_tool: conn_info = conn_tool.GetAttrs().get("TOOLS_Name", "") inputs.append({ "name": attrs.get("INPS_Name", ""), "id": attrs.get("INPS_ID", ""), "type": attrs.get("INPS_DataType", ""), "connected_to": conn_info, }) return {"inputs": inputs} elif action == "get_outputs": tool = comp.FindTool(p["tool_name"]) if not tool: return _err(f"Tool '{p['tool_name']}' not found") output_list = tool.GetOutputList() outputs = [] if output_list: for idx in output_list: out = output_list[idx] attrs = out.GetAttrs() outputs.append({ "name": attrs.get("OUTS_Name", ""), "id": attrs.get("OUTS_ID", ""), "type": attrs.get("OUTS_DataType", ""), }) return {"outputs": outputs} # --- Parameters --- elif action == "set_input": tool = comp.FindTool(p["tool_name"]) if not tool: return _err(f"Tool '{p['tool_name']}' not found") comp.Lock() try: if "time" in p: tool.SetInput(p["input_name"], p["value"], p["time"]) else: tool.SetInput(p["input_name"], p["value"]) return _ok() finally: comp.Unlock() elif action == "get_input": tool = comp.FindTool(p["tool_name"]) if not tool: return _err(f"Tool '{p['tool_name']}' not found") if "time" in p: val = tool.GetInput(p["input_name"], p["time"]) else: val = tool.GetInput(p["input_name"]) return {"value": _ser(val)} elif action == "set_attrs": tool = comp.FindTool(p["tool_name"]) if not tool: return _err(f"Tool '{p['tool_name']}' not found") tool.SetAttrs(p["attrs"]) return _ok() elif action == "get_attrs": tool = comp.FindTool(p["tool_name"]) if not tool: return _err(f"Tool '{p['tool_name']}' not found") return {"attrs": _ser(tool.GetAttrs())} # --- Keyframes --- elif action == "add_keyframe": tool = comp.FindTool(p["tool_name"]) if not tool: return _err(f"Tool '{p['tool_name']}' not found") comp.Lock() try: inp = tool[p["input_name"]] if not inp: return _err(f"Input '{p['input_name']}' not found on tool '{p['tool_name']}'") # Attach a BezierSpline modifier the first time this input is animated. # Without a spline, `inp[time] = value` only sets a STATIC value (the last # write wins) and never creates a keyframe. See the Fusion Scripting Guide. # Optional `modifier` lets callers pass e.g. "Path" for Point inputs. try: _already_animated = inp.GetConnectedOutput() is not None except Exception: _already_animated = False if not _already_animated: tool.AddModifier(p["input_name"], p.get("modifier", "BezierSpline")) tool[p["input_name"]][p["time"]] = p["value"] return _ok() finally: comp.Unlock() elif action == "get_keyframes": tool = comp.FindTool(p["tool_name"]) if not tool: return _err(f"Tool '{p['tool_name']}' not found") inp = tool[p["input_name"]] if not inp: return _err(f"Input '{p['input_name']}' not found on tool '{p['tool_name']}'") # GetKeyFrames() returns {1-based index: frame_position}, NOT # {time: value}. Iterating it directly puts the index in `time` and the # frame position in `value`. Read the actual keyframed value back from # the input at each frame position via GetInput. keyframes = [] kfs = inp.GetKeyFrames() if kfs: for idx in sorted(kfs): frame = kfs[idx] value = tool.GetInput(p["input_name"], frame) keyframes.append({"time": frame, "value": _ser(value)}) return {"keyframes": keyframes} elif action == "delete_keyframe": tool = comp.FindTool(p["tool_name"]) if not tool: return _err(f"Tool '{p['tool_name']}' not found") comp.Lock() try: inp = tool[p["input_name"]] if not inp: return _err(f"Input '{p['input_name']}' not found on tool '{p['tool_name']}'") inp.RemoveKeyFrame(p["time"]) return _ok() finally: comp.Unlock() # --- Composition Control --- elif action == "get_comp_info": attrs = comp.GetAttrs() return { "name": attrs.get("COMPS_Name", ""), "tool_count": len(comp.GetToolList() or {}), "attrs": _ser(attrs), } elif action == "set_frame_range": comp.SetAttrs({"COMPN_RenderStartTime": p["start"], "COMPN_RenderEndTime": p["end"]}) return _ok() elif action == "get_frame_range": attrs = comp.GetAttrs() or {} return { "start": attrs.get("COMPN_RenderStartTime"), "end": attrs.get("COMPN_RenderEndTime"), } elif action == "render": comp.Render() return _ok() elif action == "start_undo": comp.StartUndo(p.get("name", "MCP Operation")) return _ok() elif action == "end_undo": comp.EndUndo(p.get("keep", True)) return _ok() # --- Node Layout (FlowView) --- elif action == "get_position": tool = comp.FindTool(p["tool_name"]) if not tool: return _err(f"Tool '{p['tool_name']}' not found") flow = _fusion_flow_view(comp) if flow is None: return _err("FlowView unavailable on this comp") pos = _parse_pos(flow.GetPosTable(tool)) if pos is None: return _err(f"Could not read position for tool '{p['tool_name']}'") return {"tool_name": p["tool_name"], "x": pos[0], "y": pos[1]} elif action == "set_position": if "x" not in p or "y" not in p: return _err("set_position requires x and y") tool = comp.FindTool(p["tool_name"]) if not tool: return _err(f"Tool '{p['tool_name']}' not found") flow = _fusion_flow_view(comp) if flow is None: return _err("FlowView unavailable on this comp") x, y = float(p["x"]), float(p["y"]) comp.Lock() try: flow.SetPos(tool, x, y) finally: comp.Unlock() # SetPos has no reliable return; confirm by reading the position back. pos = _parse_pos(flow.GetPosTable(tool)) return { "success": True, "tool_name": p["tool_name"], "x": x, "y": y, "readback": {"x": pos[0], "y": pos[1]} if pos else None, } elif action == "copy_tool": src = comp.FindTool(p["tool_name"]) if not src: return _err(f"Tool '{p['tool_name']}' not found") regid = src.GetAttrs().get("TOOLS_RegID") if not regid: return _err(f"Could not determine the tool type of '{p['tool_name']}'") # Duplicate by adding a same-type tool and loading the source's settings # through a temp .setting FILE. Passing SaveSettings()'s in-memory table # back into Paste/LoadSettings fails across the Python bridge; a file path # round-trips reliably. New node is found by diffing tool names. before = set(_fusion_tool_names(comp)) fd, settings_path = tempfile.mkstemp(suffix=".setting") os.close(fd) new_tool = None comp.Lock() try: if not src.SaveSettings(settings_path): return _err(f"SaveSettings failed for '{p['tool_name']}'") new_tool = comp.AddTool(regid, -32768, -32768) if not new_tool: return _err(f"Failed to create a duplicate of '{p['tool_name']}'") new_tool.LoadSettings(settings_path) finally: comp.Unlock() try: os.remove(settings_path) except OSError: pass new_names = [n for n in _fusion_tool_names(comp) if n not in before] new_name = new_names[0] if new_names else new_tool.GetAttrs().get("TOOLS_Name", "") rename = p.get("name") if rename: comp.Lock() try: new_tool.SetAttrs({"TOOLS_Name": rename}) finally: comp.Unlock() new_name = rename if "x" in p and "y" in p: flow = _fusion_flow_view(comp) if flow is not None: comp.Lock() try: flow.SetPos(new_tool, float(p["x"]), float(p["y"])) finally: comp.Unlock() return { "success": True, "source": p["tool_name"], "new_tool": new_name, "new_tools": new_names, } elif action == "auto_arrange": flow = _fusion_flow_view(comp) if flow is None: return _err("FlowView unavailable on this comp") names = p.get("tool_names") if names: tools = [(n, comp.FindTool(n)) for n in names] tools = [(n, t) for n, t in tools if t] else: tools = list(_iter_fusion_tools(comp)) if not tools: return _err("No tools to arrange") direction = (p.get("direction") or "horizontal").strip().lower() spacing = float(p.get("spacing", 2.0)) x0, y0 = float(p.get("x", 0.0)), float(p.get("y", 0.0)) arranged = [] comp.Lock() try: for i, (name, tool) in enumerate(tools): if direction == "vertical": x, y = x0, y0 + i * spacing else: x, y = x0 + i * spacing, y0 flow.SetPos(tool, x, y) arranged.append({"tool_name": name, "x": x, "y": y}) finally: comp.Unlock() return {"success": True, "arranged": arranged, "count": len(arranged)} return _unknown(action, [ "add_tool","delete_tool","get_tool_list","find_tool", "connect","disconnect","get_inputs","get_outputs", "set_input","get_input","set_attrs","get_attrs", "add_keyframe","get_keyframes","delete_keyframe", "get_comp_info","set_frame_range","get_frame_range","render", "start_undo","end_undo", "get_position","set_position","copy_tool","auto_arrange", "bulk_set_inputs", "bulk_set_expressions", *_FUSION_GROUP_KERNEL_ACTIONS, *_FUSION_KERNEL_ACTIONS, ]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 28: fuse_plugin # ═══════════════════════════════════════════════════════════════════════════════ # Fusion's naming rule (Fuse SDK p. 40): identifiers must match this pattern, # else the resulting comp will save but fail to reopen. _FUSE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _FUSE_MARKER = "@mcp-fuse" def _fuses_dir() -> str: return get_resolve_plugin_paths()["fuses_dir"] def _validate_fuse_name(name: str) -> Optional[Dict[str, Any]]: if not name or not _FUSE_NAME_RE.match(name): return _err(f"Invalid Fuse name '{name}'. Must match [A-Za-z_][A-Za-z0-9_]* " "(Fuse SDK requirement; bad names produce comps that won't reopen).") return None def _fuse_path(name: str) -> str: return os.path.join(_fuses_dir(), f"{name}.fuse") def _validate_lua_syntax(source: str) -> Dict[str, Any]: """Run `luac -p` if available. Returns {'valid': bool, 'errors': str|None, 'checker': 'luac'|'unavailable'}.""" luac = None for candidate in ("luac", "luac5.1", "luac5.3", "luac5.4"): try: subprocess.run([candidate, "-v"], capture_output=True, check=True, timeout=5, stdin=subprocess.DEVNULL) luac = candidate break except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired): continue if luac is None: return {"valid": True, "errors": None, "checker": "unavailable"} with tempfile.NamedTemporaryFile(mode="w", suffix=".lua", delete=False, encoding="utf-8") as f: f.write(source) tmp = f.name try: result = subprocess.run([luac, "-p", tmp], capture_output=True, text=True, timeout=10, stdin=subprocess.DEVNULL) if result.returncode == 0: return {"valid": True, "errors": None, "checker": luac} return {"valid": False, "errors": result.stderr.strip() or result.stdout.strip(), "checker": luac} finally: try: os.unlink(tmp) except OSError: pass def _validate_glsl_minimal(source: str) -> Dict[str, Any]: """Cheap GLSL sanity check — verifies the required ShadePixel signature is present and braces balance. Real GLSL validation needs glslangValidator, which isn't bundled with Resolve.""" if "ShadePixel" not in source: return {"valid": False, "errors": "Missing required `void ShadePixel(inout FuPixel f)`", "checker": "minimal"} if source.count("{") != source.count("}"): return {"valid": False, "errors": "Unbalanced braces in shader source.", "checker": "minimal"} return {"valid": True, "errors": None, "checker": "minimal"} @mcp.tool() def fuse_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Author and install Fusion Fuse plugins (.fuse files). Fuses are Lua plugins (or GLSL view-LUT shaders) that Fusion loads at startup. A NEW Fuse requires a Resolve restart to register; existing Fuses can be edited and reloaded from the Inspector's Edit/Reload buttons without restart. The MCP cannot trigger reload — that's a UI-only action. Actions: path() -> {fuses_dir} list() -> {fuses} — Fuses with the @mcp-fuse marker comment. list(all=true) -> {fuses} — All .fuse files in the directory. install(name, source, overwrite?) -> {success, path} — name: [A-Za-z_][A-Za-z0-9_]* — source: full Fuse source (Lua, or Lua+GLSL for view LUTs) — overwrite: bool (default false) remove(name) -> {success} read(name) -> {source} validate(source, type?) -> {valid, errors, checker} — type: 'lua' (default) | 'glsl' template(kind, name, options?) -> {source, kind, name} — Returns generated source. Pass it to install() to write to disk. — kind: one of the keys returned by list_templates(). See docs/authoring/fuse-dctl-authoring.md for the per-kind option spec. list_templates() -> {kinds} """ p = params or {} if action == "path": return {"fuses_dir": _fuses_dir()} if action == "list_templates": return {"kinds": sorted(fuse_templates.TEMPLATES.keys())} if action == "list": d = _fuses_dir() if not os.path.isdir(d): return {"fuses": []} show_all = bool(p.get("all", False)) out = [] for fn in sorted(os.listdir(d)): if not fn.endswith(".fuse"): continue full = os.path.join(d, fn) try: with open(full, "r", encoding="utf-8", errors="replace") as f: head = f.read(512) except OSError: continue mcp_managed = _FUSE_MARKER in head if show_all or mcp_managed: out.append({"name": fn[:-5], "path": full, "mcp_managed": mcp_managed}) return {"fuses": out} if action == "install": name = p.get("name", "") invalid = _validate_fuse_name(name) if invalid: return invalid source = p.get("source") if not isinstance(source, str) or not source.strip(): return _err("install requires a non-empty 'source' string.") d = _fuses_dir() os.makedirs(d, exist_ok=True) path = _fuse_path(name) if os.path.exists(path) and not p.get("overwrite", False): return _err(f"Fuse '{name}' already exists at {path}. " "Pass overwrite=true to replace it.") try: with open(path, "w", encoding="utf-8") as f: f.write(source) except OSError as e: return _err(f"Failed to write Fuse: {e}") return _ok(path=path, note="Restart DaVinci Resolve to register a new Fuse. " "Existing Fuses can be reloaded via the Inspector.") if action == "remove": name = p.get("name", "") invalid = _validate_fuse_name(name) if invalid: return invalid path = _fuse_path(name) if not os.path.isfile(path): return _err(f"No Fuse named '{name}' at {path}") try: os.unlink(path) except OSError as e: return _err(f"Failed to remove Fuse: {e}") return _ok(path=path) if action == "read": name = p.get("name", "") invalid = _validate_fuse_name(name) if invalid: return invalid path = _fuse_path(name) if not os.path.isfile(path): return _err(f"No Fuse named '{name}' at {path}") try: with open(path, "r", encoding="utf-8") as f: return {"source": f.read(), "path": path} except OSError as e: return _err(f"Failed to read Fuse: {e}") if action == "validate": source = p.get("source") if not isinstance(source, str): return _err("validate requires a 'source' string.") kind = p.get("type", "lua") if kind == "glsl": return _validate_glsl_minimal(source) return _validate_lua_syntax(source) if action == "template": kind = p.get("kind", "") name = p.get("name", "") invalid = _validate_fuse_name(name) if invalid: return invalid gen = fuse_templates.TEMPLATES.get(kind) if gen is None: return _err(f"Unknown template kind '{kind}'. Valid: " f"{sorted(fuse_templates.TEMPLATES.keys())}") try: source = gen(name, p.get("options")) except (ValueError, KeyError, TypeError) as e: return _err(f"Template generation failed: {e}") return {"source": source, "kind": kind, "name": name} return _unknown(action, ["path", "list", "install", "remove", "read", "validate", "template", "list_templates"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 29: dctl # ═══════════════════════════════════════════════════════════════════════════════ # Fuse identifier rules don't apply to DCTL filenames, but we still want safe # filesystem names. Disallow path separators and shell-hostile characters. _DCTL_NAME_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_ \-]{0,127}$") _DCTL_MARKER = "@mcp-dctl" _DCTL_VALID_EXT = (".dctl", ".dctle") def _dctl_dir(category: str = "lut") -> str: """Return the install root for a given DCTL category. 'lut' → regular LUT folder, picked up by RefreshLUTList() 'aces_idt' → ACES Transforms/IDT, scanned only at Resolve startup 'aces_odt' → ACES Transforms/ODT, scanned only at Resolve startup """ paths = get_resolve_plugin_paths() if category == "lut": return paths["dctl_dir"] if category == "aces_idt": return paths["aces_idt_dir"] if category == "aces_odt": return paths["aces_odt_dir"] raise ValueError(f"Unknown DCTL category '{category}'. " "Valid: lut, aces_idt, aces_odt") def _validate_dctl_name(name: str) -> Optional[Dict[str, Any]]: if not name or not _DCTL_NAME_RE.match(name): return _err(f"Invalid DCTL name '{name}'. " "Must match [A-Za-z0-9_][A-Za-z0-9_ \\-]{0,127}.") return None def _resolve_dctl_subdir(subdir: Optional[str]) -> Optional[str]: """Validate a subdir string and return it as a normalized POSIX-style path relative segment. Returns None for no subdir, or raises ValueError.""" if not subdir: return None if "\\" in subdir: subdir = subdir.replace("\\", "/") parts = [p.strip() for p in subdir.split("/") if p.strip()] if not parts: return None for p in parts: if p in (".", "..") or "/" in p or "\\" in p: raise ValueError(f"Unsafe subdir segment: '{p}'") if p.startswith("."): raise ValueError(f"Hidden subdir not allowed: '{p}'") return os.path.join(*parts) def _dctl_path(name: str, subdir: Optional[str] = None, ext: str = ".dctl", category: str = "lut") -> str: sd = _resolve_dctl_subdir(subdir) root = _dctl_dir(category) base = root if sd is None else os.path.join(root, sd) return os.path.join(base, f"{name}{ext}") def _validate_dctl_source(source: str) -> Dict[str, Any]: """Lightweight DCTL sanity check. Verifies a transform() or transition() entry point is present, brace balance is intact, and warns about float literals missing the `f` suffix (a common cause of unhelpful build errors per docs/notes/dctl-notes.md). """ warnings = [] has_transform = "transform(" in source and "__DEVICE__" in source has_transition = "transition(" in source and "TRANSITION_PROGRESS" in source if not (has_transform or has_transition): return {"valid": False, "errors": "Missing __DEVICE__ transform() or transition() entry point.", "warnings": warnings, "checker": "minimal"} if source.count("{") != source.count("}"): return {"valid": False, "errors": "Unbalanced braces.", "warnings": warnings, "checker": "minimal"} # Find decimal literals without an 'f' suffix in C-like contexts. This is # a heuristic, not a parser — it skips lines starting with // and lines # inside DEFINE_UI_PARAMS where Python-style numbers are also accepted. import re as _re suspicious = _re.compile(r"(? Dict[str, Any]: """Author and install DCTL files (Color page custom shaders + ACES transforms). Regular DCTLs live under Resolve's LUT directory and appear as LUT-style entries in the LUT browser, the Clip/Node LUT picker, and the ResolveFX DCTL plugin. After install, call project_settings(action='refresh_luts') to make Resolve pick up the new file. ACES DCTLs (IDT/ODT) live in a separate ACES Transforms directory tree and are scanned only at Resolve startup — install requires a Resolve restart, NOT a LUT refresh. See docs/notes/dctl-notes.md for the full spec and docs/authoring/fuse-dctl-authoring.md for the experimental-tools coverage matrix. Actions: path(category?, subdir?) -> {dctl_dir} — category: 'lut' (default) | 'aces_idt' | 'aces_odt' list(category?, subdir?, all?) -> {files} — Default: only files with @mcp-dctl marker. Pass all=true for everything. install(name, source, category?, subdir?, ext?, overwrite?) -> {success, path} — name: filesystem-safe identifier — source: DCTL source as a string — category: 'lut' (default) | 'aces_idt' | 'aces_odt' — subdir: optional folder under the install root — ext: '.dctl' (default) or '.dctle' (encrypted) remove(name, category?, subdir?, ext?) -> {success} read(name, category?, subdir?, ext?) -> {source, encrypted} validate(source) -> {valid, errors, warnings, checker} template(kind, name, options?) -> {source, kind, name, suggested_category} — kind: 'transform' | 'transform_alpha' | 'transition' | 'matrix' | 'kernel' | 'lut_apply' | 'aces_idt' | 'aces_odt' — `aces_*` kinds set suggested_category to 'aces_idt'/'aces_odt'; pass that as the `category` argument to install(). list_templates() -> {kinds, kind_categories} """ p = params or {} def _category(default: str = "lut") -> Tuple[Optional[Dict[str, Any]], str]: cat = p.get("category", default) if cat not in _DCTL_VALID_CATEGORIES: return _err(f"Invalid category '{cat}'. " f"Valid: {list(_DCTL_VALID_CATEGORIES)}"), default return None, cat if action == "path": err, cat = _category() if err: return err try: normalized = _resolve_dctl_subdir(p.get("subdir")) except ValueError as e: return _err(str(e)) root = _dctl_dir(cat) out = root if normalized is None else os.path.join(root, normalized) return {"dctl_dir": out, "category": cat} if action == "list_templates": return { "kinds": sorted(dctl_templates.TEMPLATES.keys()), "kind_categories": dict(dctl_templates.KIND_CATEGORY), } if action == "list": err, cat = _category() if err: return err try: sd = _resolve_dctl_subdir(p.get("subdir")) except ValueError as e: return _err(str(e)) root = _dctl_dir(cat) root = root if sd is None else os.path.join(root, sd) if not os.path.isdir(root): return {"files": []} show_all = bool(p.get("all", False)) out = [] for fn in sorted(os.listdir(root)): if not fn.lower().endswith(_DCTL_VALID_EXT): continue full = os.path.join(root, fn) mcp_managed = False try: with open(full, "r", encoding="utf-8", errors="replace") as f: head = f.read(512) mcp_managed = _DCTL_MARKER in head except OSError: continue if show_all or mcp_managed: out.append({"name": os.path.splitext(fn)[0], "ext": os.path.splitext(fn)[1], "path": full, "mcp_managed": mcp_managed, "category": cat}) return {"files": out} if action == "install": name = p.get("name", "") invalid = _validate_dctl_name(name) if invalid: return invalid source = p.get("source") if not isinstance(source, str) or not source.strip(): return _err("install requires a non-empty 'source' string.") ext = p.get("ext", ".dctl") if ext not in _DCTL_VALID_EXT: return _err(f"ext must be one of {list(_DCTL_VALID_EXT)}") err, cat = _category() if err: return err try: sd = _resolve_dctl_subdir(p.get("subdir")) except ValueError as e: return _err(str(e)) root = _dctl_dir(cat) target_dir = root if sd is None else os.path.join(root, sd) os.makedirs(target_dir, exist_ok=True) path = os.path.join(target_dir, f"{name}{ext}") if os.path.exists(path) and not p.get("overwrite", False): return _err(f"DCTL '{name}{ext}' already exists at {path}. " "Pass overwrite=true to replace it.") try: with open(path, "w", encoding="utf-8") as f: f.write(source) except OSError as e: return _err(f"Failed to write DCTL: {e}") if cat == "lut": note = ("Call project_settings(action='refresh_luts') to make " "Resolve pick up the new DCTL.") else: note = ("ACES DCTLs are scanned only at Resolve startup. " "Restart Resolve before this transform appears.") return _ok(path=path, category=cat, note=note) if action == "remove": name = p.get("name", "") invalid = _validate_dctl_name(name) if invalid: return invalid ext = p.get("ext", ".dctl") if ext not in _DCTL_VALID_EXT: return _err(f"ext must be one of {list(_DCTL_VALID_EXT)}") err, cat = _category() if err: return err try: sd = _resolve_dctl_subdir(p.get("subdir")) except ValueError as e: return _err(str(e)) target = _dctl_path(name, sd, ext, cat) if not os.path.isfile(target): return _err(f"No DCTL named '{name}{ext}' at {target}") try: os.unlink(target) except OSError as e: return _err(f"Failed to remove DCTL: {e}") return _ok(path=target) if action == "read": name = p.get("name", "") invalid = _validate_dctl_name(name) if invalid: return invalid ext = p.get("ext", ".dctl") if ext not in _DCTL_VALID_EXT: return _err(f"ext must be one of {list(_DCTL_VALID_EXT)}") err, cat = _category() if err: return err try: sd = _resolve_dctl_subdir(p.get("subdir")) except ValueError as e: return _err(str(e)) target = _dctl_path(name, sd, ext, cat) if not os.path.isfile(target): return _err(f"No DCTL named '{name}{ext}' at {target}") try: with open(target, "r", encoding="utf-8", errors="replace") as f: src = f.read() except OSError as e: return _err(f"Failed to read DCTL: {e}") return {"source": src, "path": target, "encrypted": ext == ".dctle", "category": cat} if action == "validate": source = p.get("source") if not isinstance(source, str): return _err("validate requires a 'source' string.") return _validate_dctl_source(source) if action == "template": kind = p.get("kind", "") name = p.get("name", "") invalid = _validate_dctl_name(name) if invalid: return invalid gen = dctl_templates.TEMPLATES.get(kind) if gen is None: return _err(f"Unknown template kind '{kind}'. Valid: " f"{sorted(dctl_templates.TEMPLATES.keys())}") try: source = gen(name, p.get("options")) except (ValueError, KeyError, TypeError) as e: return _err(f"Template generation failed: {e}") return { "source": source, "kind": kind, "name": name, "suggested_category": dctl_templates.KIND_CATEGORY.get(kind, "lut"), } return _unknown(action, ["path", "list", "install", "remove", "read", "validate", "template", "list_templates"]) # ═══════════════════════════════════════════════════════════════════════════════ # TOOL 30: script_plugin # ═══════════════════════════════════════════════════════════════════════════════ # Resolve-page Lua/Python scripts must be filesystem-safe identifiers. _SCRIPT_NAME_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_ \-]{0,127}$") _SCRIPT_MARKER = "@mcp-script" _SCRIPT_VALID_LANG = ("lua", "py") _SCRIPT_LANG_ALIASES = {"python": "py", "python3": "py"} _SCRIPT_LANG_EXT = {"lua": ".lua", "py": ".py"} def _scripts_dir(category: str) -> str: paths = get_resolve_plugin_paths() valid = paths["scripts_categories"] if category not in valid: raise ValueError(f"Invalid category '{category}'. Valid: {list(valid)}") return os.path.join(paths["scripts_root"], category) def _validate_script_name(name: str) -> Optional[Dict[str, Any]]: if not name or not _SCRIPT_NAME_RE.match(name): return _err(f"Invalid script name '{name}'. " "Must match [A-Za-z0-9_][A-Za-z0-9_ \\-]{0,127}.") return None def _normalize_script_language(language: Any, default: str = "lua") -> str: if language is None: language = default if not isinstance(language, str): return str(language) value = language.strip().lower() return _SCRIPT_LANG_ALIASES.get(value, value) def _validate_script_language(language: str) -> Optional[Dict[str, Any]]: if language not in _SCRIPT_VALID_LANG: return _err(f"Invalid language '{language}'. " f"Valid: {list(_SCRIPT_VALID_LANG)}; aliases: ['python', 'python3']") return None def _script_path(name: str, category: str, language: str) -> str: language = _normalize_script_language(language) return os.path.join(_scripts_dir(category), f"{name}{_SCRIPT_LANG_EXT[language]}") def _validate_script_source(source: str, language: str) -> Dict[str, Any]: """Cheap syntax check. Lua → luac -p if available; Python → compile().""" language = _normalize_script_language(language) if language == "py": try: compile(source, "