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.
- package/AGENTS.md +3 -1
- package/CHANGELOG.md +51 -0
- package/README.md +3 -3
- package/bin/davinci-resolve-mcp.mjs +64 -7
- package/docs/SKILL.md +17 -3
- package/docs/guides/media-analysis-guide.md +27 -0
- package/docs/install.md +10 -1
- package/install.py +73 -8
- package/package.json +1 -1
- package/src/analysis_dashboard.py +82 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +287 -4
- package/src/utils/analysis_caps.py +28 -19
- package/src/utils/media_analysis.py +229 -30
|
@@ -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
|
-
|
|
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
|
|
2418
|
-
|
|
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
|
-
"""
|
|
2533
|
+
"""Frames the content *demands* so vision can populate the per-shot schema.
|
|
2423
2534
|
|
|
2424
|
-
Demand sources
|
|
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
|
-
|
|
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
|
-
|
|
2464
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
2657
|
+
"""Frame allocation. Content-blind for Economy/Balanced; demand-driven otherwise.
|
|
2479
2658
|
|
|
2480
|
-
|
|
2481
|
-
|
|
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
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
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
|
-
|
|
2491
|
-
|
|
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)
|