davinci-resolve-mcp 2.26.0 → 2.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -624,6 +624,103 @@ FRAME_CAPS = {
624
624
  }
625
625
  HARD_FRAME_CAP = 512
626
626
 
627
+ # ── Frame-sampling modes ─────────────────────────────────────────────────────
628
+ # How many frames a clip gets is governed by a `sampling_mode`. `depth` still
629
+ # governs *which* analysis layers run; the mode governs frame coverage + cost.
630
+ #
631
+ # fixed "Economy" — flat N frames (depth-derived / max_analysis_frames),
632
+ # independent of clip length. Most predictable cost.
633
+ # per_minute "Balanced" — N = clamp(minutes * frames_per_minute, floor, ceiling).
634
+ # Cost is linear in footage length; content-blind.
635
+ # adaptive_capped "Thorough" — content-aware (per-shot boundaries + flashes), bounded
636
+ # by [floor, frame_ceiling]. Best coverage, bounded cost.
637
+ # adaptive "Thorough (uncapped)" — content-aware, bounded only by the absolute HARD_FRAME_CAP.
638
+ # Use only when clips are known to be short/few.
639
+ #
640
+ # The math-layer default is `adaptive` so any caller that doesn't thread a
641
+ # sampling config keeps the legacy demand-driven behaviour. The *product*
642
+ # default (what new analysis runs use) is resolved at the preference layer in
643
+ # server.py and recommends "adaptive_capped" (Thorough).
644
+ SAMPLING_MODES = {"fixed", "per_minute", "adaptive", "adaptive_capped"}
645
+ DEFAULT_SAMPLING_MODE = "adaptive"
646
+ RECOMMENDED_SAMPLING_MODE = "adaptive_capped"
647
+ DEFAULT_FRAMES_PER_MINUTE = 4.0
648
+ DEFAULT_FRAME_FLOOR = 3
649
+ DEFAULT_FRAME_CEILING = 80
650
+
651
+ # Thoroughness ranking — used for cache reuse: a richer prior report satisfies a
652
+ # cheaper mode, but switching *up* forces a re-sample.
653
+ SAMPLING_MODE_RANK = {"fixed": 0, "per_minute": 1, "adaptive_capped": 2, "adaptive": 3}
654
+
655
+ # User-facing labels (prompt + control panel).
656
+ SAMPLING_MODE_LABELS = {
657
+ "fixed": "Economy",
658
+ "per_minute": "Balanced",
659
+ "adaptive_capped": "Thorough",
660
+ "adaptive": "Thorough (uncapped)",
661
+ }
662
+
663
+ _SAMPLING_MODE_ALIASES = {
664
+ "economy": "fixed", "fixed": "fixed", "flat": "fixed",
665
+ "balanced": "per_minute", "per_minute": "per_minute", "perminute": "per_minute",
666
+ "per-minute": "per_minute", "duration": "per_minute",
667
+ "thorough": "adaptive_capped", "adaptive_capped": "adaptive_capped",
668
+ "adaptive-capped": "adaptive_capped", "capped": "adaptive_capped",
669
+ "thorough_uncapped": "adaptive", "thorough (uncapped)": "adaptive",
670
+ "adaptive": "adaptive", "uncapped": "adaptive",
671
+ }
672
+
673
+
674
+ def normalize_sampling_mode(value: Any, default: Optional[str] = None) -> Optional[str]:
675
+ """Resolve a user-supplied mode string (label or key) to a canonical mode."""
676
+ raw = str(value or "").strip().lower().replace("_", "_")
677
+ return _SAMPLING_MODE_ALIASES.get(raw, default)
678
+
679
+
680
+ def _resolve_sampling_config(params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
681
+ """Read sampling mode + tunables from analysis params, applying defaults."""
682
+ params = params or {}
683
+
684
+ def _first(*keys: str) -> Any:
685
+ for key in keys:
686
+ if key in params and params[key] is not None:
687
+ return params[key]
688
+ return None
689
+
690
+ mode = normalize_sampling_mode(
691
+ _first("sampling_mode", "samplingMode"), default=DEFAULT_SAMPLING_MODE
692
+ ) or DEFAULT_SAMPLING_MODE
693
+
694
+ def _pos_float(value: Any, fallback: float) -> float:
695
+ try:
696
+ f = float(value)
697
+ except (TypeError, ValueError):
698
+ return fallback
699
+ return f if f > 0 else fallback
700
+
701
+ rate = _pos_float(_first("frames_per_minute", "framesPerMinute"), DEFAULT_FRAMES_PER_MINUTE)
702
+ floor = int(_pos_float(_first("frame_floor", "frameFloor"), DEFAULT_FRAME_FLOOR))
703
+ ceiling = int(_pos_float(_first("frame_ceiling", "frameCeiling"), DEFAULT_FRAME_CEILING))
704
+ if ceiling < floor:
705
+ ceiling = floor
706
+ return {
707
+ "mode": mode,
708
+ "frames_per_minute": rate,
709
+ "frame_floor": floor,
710
+ "frame_ceiling": ceiling,
711
+ }
712
+
713
+
714
+ def _clamp_int(value: Any, low: int, high: int) -> int:
715
+ if high < low:
716
+ high = low
717
+ v = int(value)
718
+ if v < low:
719
+ return low
720
+ if v > high:
721
+ return high
722
+ return v
723
+
627
724
 
628
725
  def slugify(value: Any, fallback: str = "untitled") -> str:
629
726
  raw = str(value or "").strip().lower()
@@ -1441,13 +1538,14 @@ def analysis_request_signature(
1441
1538
  depth: str,
1442
1539
  options: Dict[str, Any],
1443
1540
  frame_count: int,
1541
+ sampling: Optional[Dict[str, Any]] = None,
1444
1542
  ) -> Dict[str, Any]:
1445
1543
  """Return the cache signature for a requested analysis profile."""
1446
1544
  transcription = options.get("transcription") or {}
1447
1545
  vision = options.get("vision") or {}
1448
1546
  marker_plan = options.get("marker_plan") or {}
1449
1547
  vision_prompt = vision.get("prompt") or DEFAULT_VISION_ANALYSIS_PROMPT
1450
- return {
1548
+ signature = {
1451
1549
  "analysis_version": ANALYSIS_VERSION,
1452
1550
  "depth": depth,
1453
1551
  "analysis_keyframe_budget": int(frame_count or 0),
@@ -1506,6 +1604,16 @@ def analysis_request_signature(
1506
1604
  },
1507
1605
  }),
1508
1606
  }
1607
+ # Recorded outside signature_hash so it doesn't bust pre-existing caches;
1608
+ # mode changes are reconciled by thoroughness rank in _report_cache_state.
1609
+ if sampling:
1610
+ signature["analysis_sampling"] = {
1611
+ "mode": sampling.get("mode"),
1612
+ "frames_per_minute": sampling.get("frames_per_minute"),
1613
+ "frame_floor": sampling.get("frame_floor"),
1614
+ "frame_ceiling": sampling.get("frame_ceiling"),
1615
+ }
1616
+ return signature
1509
1617
 
1510
1618
 
1511
1619
  def _timestamp_from_analyzed_at(value: Any) -> Optional[float]:
@@ -1659,6 +1767,7 @@ def build_plan(
1659
1767
  }
1660
1768
  gaps = _required_capability_gaps(depth, options, caps)
1661
1769
  frame_count = _bounded_frame_count(depth, params.get("max_analysis_frames"))
1770
+ sampling_config = _resolve_sampling_config(params)
1662
1771
  transcription_enabled = _coerce_bool((options.get("transcription") or {}).get("enabled"), default=DEFAULT_TRANSCRIPTION_ENABLED)
1663
1772
  notes = [
1664
1773
  "Plans describe analysis before execution.",
@@ -1721,11 +1830,12 @@ def build_plan(
1721
1830
  clip_plans = []
1722
1831
  for record in records:
1723
1832
  artifacts = _artifact_paths(root["project_root"], record, depth, options)
1724
- request_signature = analysis_request_signature(record, depth, options, frame_count)
1833
+ request_signature = analysis_request_signature(record, depth, options, frame_count, sampling=sampling_config)
1725
1834
  existing: Optional[Dict[str, Any]] = None
1726
1835
  clip_plan = {
1727
1836
  "record": record,
1728
1837
  "analysis_keyframe_budget": frame_count,
1838
+ "sampling": sampling_config,
1729
1839
  "analysis_signature": request_signature,
1730
1840
  "cache_status": "not_checked",
1731
1841
  "artifacts": artifacts,
@@ -1853,6 +1963,8 @@ def build_plan(
1853
1963
  "estimated_seconds": per_clip_seconds * len(records),
1854
1964
  "estimated_seconds_after_reuse": per_clip_seconds * max(0, len(records) - reusable_count),
1855
1965
  "analysis_keyframe_budget_per_clip": frame_count,
1966
+ "sampling": sampling_config,
1967
+ "sampling_mode": sampling_config.get("mode"),
1856
1968
  "reuse_existing": reuse_existing,
1857
1969
  "force_refresh": force_refresh,
1858
1970
  "reuse_policy": reuse_policy,
@@ -2414,24 +2526,19 @@ def _cut_boundary_analysis(
2414
2526
  }
2415
2527
 
2416
2528
 
2417
- def _compute_demand_driven_budget(
2418
- requested_budget: int,
2419
- cut_analysis: Optional[Dict[str, Any]],
2529
+ def _demand_frame_count(
2530
+ cut_analysis: Dict[str, Any],
2420
2531
  duration_seconds: Optional[float],
2421
2532
  ) -> int:
2422
- """Compute the effective frame-sampling budget driven by analysis demand.
2533
+ """Frames the content *demands* so vision can populate the per-shot schema.
2423
2534
 
2424
- Demand sources (so vision can populate the V2 per-shot schema):
2535
+ Demand sources:
2425
2536
  - Per shot: 1 representative (midpoint) + 2 boundary frames + duration-scaled extras
2426
2537
  (+1 for shots >5s, +1 for shots >15s, +1 per additional 15s beyond 30s)
2427
2538
  - Per flash_candidate: 1 mid-frame for vision adjudication (preserve all)
2428
2539
  - Per cut_point: a small buffer for cuts not covered by shot boundaries
2429
-
2430
- Safety ceiling: HARD_FRAME_CAP capped by duration so a 10s clip cannot request 500 frames.
2540
+ - Clip-level: first_usable, last_usable, midpoint
2431
2541
  """
2432
- if not isinstance(cut_analysis, dict):
2433
- return min(max(int(requested_budget or 0), 0), HARD_FRAME_CAP)
2434
-
2435
2542
  per_shot_demand = 0
2436
2543
  for shot in cut_analysis.get("shot_ranges") or []:
2437
2544
  if not isinstance(shot, dict):
@@ -2458,13 +2565,84 @@ def _compute_demand_driven_budget(
2458
2565
  # Clip-level frames (first_usable, last_usable, midpoint)
2459
2566
  clip_buffer = 4
2460
2567
 
2461
- demand = per_shot_demand + flash_count + cut_buffer + clip_buffer
2568
+ return per_shot_demand + flash_count + cut_buffer + clip_buffer
2569
+
2570
+
2571
+ def _compute_demand_driven_budget(
2572
+ requested_budget: int,
2573
+ cut_analysis: Optional[Dict[str, Any]],
2574
+ duration_seconds: Optional[float],
2575
+ sampling: Optional[Dict[str, Any]] = None,
2576
+ ) -> int:
2577
+ """Resolve the effective frame-sampling budget for the active sampling mode.
2578
+
2579
+ Modes (see SAMPLING_MODES):
2580
+ - fixed: flat `requested_budget`, duration-independent.
2581
+ - per_minute: clamp(minutes * frames_per_minute, floor, ceiling); content-blind.
2582
+ - adaptive_capped: content demand (see _demand_frame_count), clamped to [floor, ceiling].
2583
+ - adaptive: content demand, clamped only by a generous duration-scaled HARD_FRAME_CAP
2584
+ (legacy behaviour; the default when no sampling config is threaded).
2585
+
2586
+ `requested_budget` (depth-derived / max_analysis_frames) acts as a floor for the
2587
+ adaptive modes so an explicit request is never undercut.
2588
+ """
2589
+ sampling = sampling or {}
2590
+ mode = normalize_sampling_mode(sampling.get("mode"), default=DEFAULT_SAMPLING_MODE) or DEFAULT_SAMPLING_MODE
2591
+ rate = sampling.get("frames_per_minute") or DEFAULT_FRAMES_PER_MINUTE
2592
+ floor = int(sampling.get("frame_floor") or DEFAULT_FRAME_FLOOR)
2593
+ ceiling = int(sampling.get("frame_ceiling") or DEFAULT_FRAME_CEILING)
2594
+ if ceiling < floor:
2595
+ ceiling = floor
2596
+ requested = max(int(requested_budget or 0), 0)
2597
+ minutes = max(0.0, float(duration_seconds or 0) / 60.0)
2598
+ per_minute_count = int(round(minutes * float(rate)))
2599
+
2600
+ if mode == "fixed":
2601
+ return min(requested, HARD_FRAME_CAP)
2602
+
2603
+ if mode == "per_minute":
2604
+ return _clamp_int(per_minute_count, floor, min(ceiling, HARD_FRAME_CAP))
2605
+
2606
+ # Adaptive modes need shot/cut analysis. Without it, fall back to a duration
2607
+ # estimate (adaptive_capped) or the legacy requested-only budget (adaptive).
2608
+ if not isinstance(cut_analysis, dict):
2609
+ if mode == "adaptive_capped":
2610
+ return _clamp_int(max(requested, per_minute_count), floor, min(ceiling, HARD_FRAME_CAP))
2611
+ return min(max(requested, 0), HARD_FRAME_CAP)
2612
+
2613
+ demand = _demand_frame_count(cut_analysis, duration_seconds)
2614
+ target = max(requested, demand, floor)
2462
2615
 
2463
- # Duration-scaled safety ceiling: clips can't request hundreds of frames per second.
2464
- # Floor at 64 so short clips still have headroom; ceiling at HARD_FRAME_CAP.
2465
- duration_cap = max(64, min(HARD_FRAME_CAP, int((duration_seconds or 0) * 2)))
2616
+ if mode == "adaptive_capped":
2617
+ return _clamp_int(target, floor, min(ceiling, HARD_FRAME_CAP))
2466
2618
 
2467
- return min(max(int(requested_budget or 0), demand), duration_cap)
2619
+ # adaptive (uncapped): only the absolute hard cap, scaled by duration so a
2620
+ # 10s clip cannot request 500 frames. Floor at 64 for short-clip headroom.
2621
+ duration_cap = max(64, min(HARD_FRAME_CAP, int(float(duration_seconds or 0) * 2)))
2622
+ return _clamp_int(target, floor, duration_cap)
2623
+
2624
+
2625
+ def _even_interval_samples(
2626
+ duration: float,
2627
+ count: int,
2628
+ frame_step: float,
2629
+ ) -> List[Dict[str, Any]]:
2630
+ """Content-blind evenly-spaced samples (Economy / Balanced modes).
2631
+
2632
+ Returns exactly `count` frames at the midpoints of `count` equal slices of
2633
+ [0, duration], so cost is a clean function of `count` and never inflated by
2634
+ shot/cut demand. Used when the user has chosen a predictable, content-blind mode.
2635
+ """
2636
+ if count <= 0 or duration <= 0:
2637
+ return []
2638
+ out: List[Dict[str, Any]] = []
2639
+ for i in range(count):
2640
+ t = duration * (i + 0.5) / count
2641
+ out.append({
2642
+ "time_seconds": _clamp_sample_time(float(t), duration),
2643
+ "selection_reason": "interval",
2644
+ })
2645
+ return out
2468
2646
 
2469
2647
 
2470
2648
  def _sample_times(
@@ -2474,21 +2652,25 @@ def _sample_times(
2474
2652
  *,
2475
2653
  fps: Optional[float] = None,
2476
2654
  cut_analysis: Optional[Dict[str, Any]] = None,
2655
+ sampling: Optional[Dict[str, Any]] = None,
2477
2656
  ) -> List[Dict[str, Any]]:
2478
- """Two-pass frame allocation.
2657
+ """Frame allocation. Content-blind for Economy/Balanced; demand-driven otherwise.
2479
2658
 
2480
- Pass 1 (reservations, always allocated demand-driven, not budget-bounded):
2481
- - Per shot: shot_representative (midpoint), shot_start, shot_end boundaries,
2482
- duration-scaled progress samples (+1 for shots >5s, +1 for shots >15s,
2483
- +1 per 15s beyond 30s).
2484
- - Per flash_candidate: mid-frame for vision adjudication.
2659
+ Economy (fixed) / Balanced (per_minute): exactly `budget` evenly-spaced frames
2660
+ (see _even_interval_samples) predictable cost, ignores shot structure.
2485
2661
 
2486
- Pass 2 (priority fill, consumes remaining budget):
2487
- - cut_before/cut_after pairs (for cuts not covered by shot boundaries)
2488
- - first_usable, last_usable, scene_change, midpoint, interval fillers
2662
+ Thorough (adaptive / adaptive_capped) two-pass demand-driven allocation:
2663
+ Pass 1 (reservations, always allocated demand-driven, not budget-bounded):
2664
+ - Per shot: shot_representative (midpoint), shot_start, shot_end boundaries,
2665
+ duration-scaled progress samples (+1 for shots >5s, +1 for shots >15s,
2666
+ +1 per 15s beyond 30s).
2667
+ - Per flash_candidate: mid-frame for vision adjudication.
2668
+ Pass 2 (priority fill, consumes remaining budget):
2669
+ - cut_before/cut_after pairs (for cuts not covered by shot boundaries)
2670
+ - first_usable, last_usable, scene_change, midpoint, interval fillers
2489
2671
 
2490
- The caller passes `budget` as the soft target. Reservations always land
2491
- (demand-driven); priority fill is what `budget` constrains.
2672
+ The caller passes `budget` as the soft target. Reservations always land
2673
+ (demand-driven); priority fill is what `budget` constrains.
2492
2674
 
2493
2675
  Returns a time-sorted list of sample candidates.
2494
2676
  """
@@ -2498,6 +2680,12 @@ def _sample_times(
2498
2680
  cut_analysis = cut_analysis if isinstance(cut_analysis, dict) else {}
2499
2681
  frame_step = _frame_step_seconds(fps)
2500
2682
 
2683
+ # Content-blind modes: even-interval sampling of exactly `budget` frames so
2684
+ # cost stays predictable and is not inflated by per-shot reservations.
2685
+ mode = normalize_sampling_mode((sampling or {}).get("mode"), default=DEFAULT_SAMPLING_MODE) or DEFAULT_SAMPLING_MODE
2686
+ if mode in {"fixed", "per_minute"}:
2687
+ return _even_interval_samples(duration, budget, frame_step)
2688
+
2501
2689
  # ===================== Pass 1: Reservations =====================
2502
2690
  reserved: List[Dict[str, Any]] = []
2503
2691
 
@@ -2729,6 +2917,7 @@ def _motion_and_keyframes(
2729
2917
  fps: Optional[float] = None,
2730
2918
  cut_analysis: Optional[Dict[str, Any]] = None,
2731
2919
  write_frames: bool = True,
2920
+ sampling: Optional[Dict[str, Any]] = None,
2732
2921
  ) -> Dict[str, Any]:
2733
2922
  sampled = []
2734
2923
  previous_raw = None
@@ -2736,8 +2925,8 @@ def _motion_and_keyframes(
2736
2925
  if isinstance(cut_analysis, dict):
2737
2926
  required_boundary_frames += len(cut_analysis.get("cut_points") or []) * 2
2738
2927
  required_boundary_frames += len(cut_analysis.get("flash_frame_candidates") or [])
2739
- effective_budget = _compute_demand_driven_budget(budget, cut_analysis, duration)
2740
- times = _sample_times(duration, scene_items, effective_budget, fps=fps, cut_analysis=cut_analysis)
2928
+ effective_budget = _compute_demand_driven_budget(budget, cut_analysis, duration, sampling=sampling)
2929
+ times = _sample_times(duration, scene_items, effective_budget, fps=fps, cut_analysis=cut_analysis, sampling=sampling)
2741
2930
  frames_dir = artifacts.get("frames_dir")
2742
2931
  for index, sample in enumerate(times, 1):
2743
2932
  time_seconds = float(sample.get("time_seconds") or 0.0)
@@ -2971,6 +3160,15 @@ def _report_cache_state(
2971
3160
  if report_budget < request_budget:
2972
3161
  issues.append("analysis_keyframe_budget_lower_than_requested")
2973
3162
 
3163
+ # Sampling-mode reconciliation: a prior report sampled under a less-thorough
3164
+ # mode can't satisfy a request for a more-thorough one. The reverse (richer
3165
+ # report, cheaper request) is reused as a free upgrade.
3166
+ report_mode = (report_signature.get("analysis_sampling") or {}).get("mode")
3167
+ request_mode = (request_signature.get("analysis_sampling") or {}).get("mode")
3168
+ if request_mode and report_mode and request_mode != report_mode:
3169
+ if SAMPLING_MODE_RANK.get(request_mode, 0) > SAMPLING_MODE_RANK.get(report_mode, 0):
3170
+ issues.append("sampling_mode_increased")
3171
+
2974
3172
  report_layers = report_signature.get("layers") or {}
2975
3173
  request_layers = request_signature.get("layers") or {}
2976
3174
  report_vision = report_layers.get("vision") or {}
@@ -4747,6 +4945,7 @@ async def execute_plan_async(
4747
4945
  fps=fps,
4748
4946
  cut_analysis=readthrough.get("cut_analysis"),
4749
4947
  write_frames=keep_frame_artifacts_for_vision or not _coerce_bool(params.get("cleanup_frames"), default=False),
4948
+ sampling=clip_plan.get("sampling"),
4750
4949
  )
4751
4950
  if artifacts.get("motion_json"):
4752
4951
  _write_json(artifacts["motion_json"], motion)