davinci-resolve-mcp 2.59.0 → 2.61.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.
@@ -335,9 +335,15 @@ def ingest_report(
335
335
  "SELECT created_at FROM clips WHERE clip_uuid = ?", (clip_uuid,)
336
336
  ).fetchone()
337
337
  created_at = str(existing["created_at"]) if existing else now
338
+ # True upsert — never INSERT OR REPLACE: REPLACE deletes the existing
339
+ # row first, and with PRAGMA foreign_keys=ON that cascade-deletes every
340
+ # child table (events/curves/transcript_words/story_beats included),
341
+ # wiping strata rows — human annotations among them — on every
342
+ # re-ingest. The v13 strata tables are NOT rebuilt by ingest, so the
343
+ # clips row must be updated in place.
338
344
  conn.execute(
339
345
  """
340
- INSERT OR REPLACE INTO clips
346
+ INSERT INTO clips
341
347
  (clip_uuid, clip_dir, resolve_clip_id, media_id, clip_name,
342
348
  file_path, bin_path, duration_seconds, fps, resolution,
343
349
  media_type, summary, overall_motion_level, cut_count,
@@ -345,6 +351,28 @@ def ingest_report(
345
351
  analyzed_at, vision_status, vision_committed_at,
346
352
  created_at, updated_at)
347
353
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
354
+ ON CONFLICT(clip_uuid) DO UPDATE SET
355
+ clip_dir=excluded.clip_dir,
356
+ resolve_clip_id=excluded.resolve_clip_id,
357
+ media_id=excluded.media_id,
358
+ clip_name=excluded.clip_name,
359
+ file_path=excluded.file_path,
360
+ bin_path=excluded.bin_path,
361
+ duration_seconds=excluded.duration_seconds,
362
+ fps=excluded.fps,
363
+ resolution=excluded.resolution,
364
+ media_type=excluded.media_type,
365
+ summary=excluded.summary,
366
+ overall_motion_level=excluded.overall_motion_level,
367
+ cut_count=excluded.cut_count,
368
+ shot_count=excluded.shot_count,
369
+ analysis_version=excluded.analysis_version,
370
+ depth=excluded.depth,
371
+ signature_hash=excluded.signature_hash,
372
+ analyzed_at=excluded.analyzed_at,
373
+ vision_status=excluded.vision_status,
374
+ vision_committed_at=excluded.vision_committed_at,
375
+ updated_at=excluded.updated_at
348
376
  """,
349
377
  (
350
378
  clip_uuid,
@@ -473,6 +501,12 @@ def ingest_report(
473
501
  ),
474
502
  )
475
503
 
504
+ # Word-level timestamps (v13): promote segments[*].words to rows so
505
+ # word-boundary queries never have to parse the report blob.
506
+ from src.utils import strata as _strata
507
+
508
+ words_written = _strata.ingest_transcript_words(conn, clip_uuid, transcription)
509
+
476
510
  # Sampled frames. Shot mapping uses frame_indices_used when present,
477
511
  # with a time-containment fallback (some commit paths don't record
478
512
  # which frame indices fed which shot).
@@ -576,6 +610,7 @@ def ingest_report(
576
610
  "shot_count": len(shot_entries),
577
611
  "subjective_fields_written": subj_written,
578
612
  "subjective_fields_preserved_human": subj_skipped_human,
613
+ "transcript_words_written": words_written,
579
614
  }
580
615
 
581
616
 
@@ -355,6 +355,38 @@ API_TRUTH: List[Dict[str, Any]] = [
355
355
  "tags": ["missing-method", "timeline", "edit"],
356
356
  "submit": "missing",
357
357
  },
358
+ {
359
+ "symbol": "Render in Place / bake a timeline clip to new media",
360
+ "object": "Timeline / TimelineItem / MediaPool",
361
+ "reality": "There is no scripting method for the Edit-page clip "
362
+ "context-menu action 'Render in Place', which bakes a clip "
363
+ "(including its Fusion composition and effects) into a NEW "
364
+ "rendered media file and drops that file back on the timeline "
365
+ "at the same position, replacing the source clip. No "
366
+ "Render*/Bake*/Freeze* method exists on Timeline, TimelineItem "
367
+ "or MediaPool in the Resolve scripting API reference (BMD docs) "
368
+ "or a dir() audit. NOTE the frequently-confused-but-distinct "
369
+ "sibling: the render *cache* (a temporary, non-destructive "
370
+ "cache of a clip's Color/Fusion output that reduces playback "
371
+ "load WITHOUT creating a new media file) IS scriptable — "
372
+ "TimelineItem.SetColorOutputCache / SetFusionOutputCache "
373
+ "('Render Cache Color/Fusion Output' menu actions) and "
374
+ "Graph.SetNodeCacheMode. Render in Place is the permanent, "
375
+ "media-producing bake; the render cache is the transient one.",
376
+ "recommended": "If the goal is only to reduce playback/render load, use "
377
+ "the render cache — exposed as timeline_item "
378
+ "get_color_cache/set_color_cache/get_fusion_cache/"
379
+ "set_fusion_cache and the Color-page graph node cache_mode "
380
+ "(no new media, fully reversible). If you genuinely need a "
381
+ "baked media file, render the clip's in/out range from the "
382
+ "Deliver page (proj.AddRenderJob with MarkIn/MarkOut) and "
383
+ "relink/append the result yourself, or run Render in Place "
384
+ "from the Resolve UI. There is no one-call API equivalent. "
385
+ "See issue #86.",
386
+ "tags": ["missing-method", "timeline", "render", "cache", "render-in-place", "bake"],
387
+ "submit": "missing",
388
+ "issue": 86,
389
+ },
358
390
  {
359
391
  "symbol": "Smart Bins / Power Bins creation",
360
392
  "object": "MediaPool",
@@ -508,6 +540,30 @@ API_TRUTH: List[Dict[str, Any]] = [
508
540
  "pass-through.",
509
541
  "tags": ["silent-failure", "color", "node-graph", "layout", "drx"],
510
542
  },
543
+ {
544
+ "symbol": "MediaPool.AppendToTimeline clipInfo endFrame (exclusive bound)",
545
+ "object": "MediaPool",
546
+ "signature": "([{mediaPoolItem, startFrame, endFrame, recordFrame, "
547
+ "trackIndex, mediaType}]) -> [TimelineItem]",
548
+ "reality": "clipInfo endFrame is an EXCLUSIVE bound: the appended item's "
549
+ "duration is endFrame - startFrame frames, not "
550
+ "endFrame - startFrame + 1. Verified by readback probe on "
551
+ "Resolve Studio 21.0 — appending {startFrame: 6254, "
552
+ "endFrame: 6348} yields a 94-frame item, and 130 such "
553
+ "appends whose absolute recordFrames advance by exactly "
554
+ "(endFrame - startFrame) assemble a gap-free track whose "
555
+ "total length equals the sum of (end - start). Code that "
556
+ "assumes an inclusive bound drifts one frame per clip: "
557
+ "advancing a record cursor by (end - start + 1) leaves a "
558
+ "1-frame hole between consecutive clips, and converting a "
559
+ "half-open range with (end - 1) shaves the range's last "
560
+ "frame.",
561
+ "recommended": "Treat [startFrame, endFrame) as half-open everywhere: "
562
+ "duration = endFrame - startFrame; next recordFrame = "
563
+ "previous recordFrame + duration; no ±1 corrections "
564
+ "when mirroring keep-ranges into clipInfos.",
565
+ "tags": ["timeline", "edit", "off-by-one", "readback"],
566
+ },
511
567
  ]
512
568
 
513
569
 
@@ -99,7 +99,10 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
99
99
  "set_track_enable",
100
100
  "set_track_name",
101
101
  "set_voice_isolation_state",
102
- "set_name",
102
+ # NOTE: timeline.set_name (rename) is intentionally NOT here. A rename is
103
+ # content-preserving and trivially reversible, so version-on-mutate added
104
+ # no recovery value — it only spawned redundant `_archived` copies (one per
105
+ # rename, and renaming an archive archived the archive). Issue #83.
103
106
  "set_start_timecode",
104
107
  "set_setting",
105
108
  "set_mark_in_out",
@@ -262,7 +262,10 @@ def plan_selects(
262
262
  if isinstance(clip_duration, (int, float)) and clip_duration:
263
263
  src_end = min(src_end, float(clip_duration))
264
264
  start_frame = int(round(src_start * fps))
265
- end_frame = max(start_frame + 1, int(round(src_end * fps)) - 1)
265
+ # AppendToTimeline clipInfo endFrame is a half-open (exclusive) bound
266
+ # duration = endFrame - startFrame. See api_truth "AppendToTimeline
267
+ # clipInfo endFrame". No -1: that would shave the last frame of every select.
268
+ end_frame = max(start_frame + 1, int(round(src_end * fps)))
266
269
  decision = {k: v for k, v in candidate.items() if not k.startswith("_")}
267
270
  decision["source_frame_range"] = [start_frame, end_frame]
268
271
  decisions.append(decision)
@@ -339,6 +342,29 @@ def _gaps_in_range(
339
342
  return gaps
340
343
 
341
344
 
345
+ def _dedupe_skipped(skipped: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
346
+ """Collapse identical (item, reason) skip rows into one entry with a count.
347
+
348
+ A timeline layer cut into many segments from one unanalyzed source clip
349
+ otherwise reports the same skip once per segment (87 identical rows in a
350
+ real two-layer session), which bloats the plan payload without adding
351
+ signal. First occurrence order is preserved.
352
+ """
353
+ out: List[Dict[str, Any]] = []
354
+ index: Dict[Tuple[Any, Any], Dict[str, Any]] = {}
355
+ for row in skipped:
356
+ key = (row.get("item"), row.get("reason"))
357
+ hit = index.get(key)
358
+ if hit is None:
359
+ hit = dict(row)
360
+ hit["count"] = 1
361
+ index[key] = hit
362
+ out.append(hit)
363
+ else:
364
+ hit["count"] += 1
365
+ return out
366
+
367
+
342
368
  def plan_tighten(
343
369
  project_root: str,
344
370
  *,
@@ -440,6 +466,7 @@ def plan_tighten(
440
466
  },
441
467
  })
442
468
 
469
+ skipped = _dedupe_skipped(skipped)
443
470
  if not lifts:
444
471
  return {
445
472
  "success": False,
@@ -492,7 +519,9 @@ def plan_tighten(
492
519
  audio_indices = [1]
493
520
  for seg_start, seg_end in segments:
494
521
  start_frame = int(round(seg_start * clip_fps))
495
- end_frame = max(start_frame + 1, int(round(seg_end * clip_fps)) - 1)
522
+ # Half-open (exclusive) endFrame duration = end - start. No -1, else
523
+ # every kept segment loses its last frame (see api_truth endFrame entry).
524
+ end_frame = max(start_frame + 1, int(round(seg_end * clip_fps)))
496
525
  keep_ranges.append({
497
526
  "clip_id": spec["resolve_clip_id"],
498
527
  "start_frame": start_frame,
@@ -642,7 +671,9 @@ def plan_swap(
642
671
  alt = dict(alt_clip)
643
672
  alt_fps = _clip_fps(alt)
644
673
  start_frame = int(round(float(alt_start) * alt_fps))
645
- end_frame = start_frame + int(round(needed_seconds * alt_fps)) - 1
674
+ # Half-open (exclusive) endFrame duration = end - start fills the slot
675
+ # exactly. No -1, else the replacement lands one frame short of the slot.
676
+ end_frame = start_frame + int(round(needed_seconds * alt_fps))
646
677
  return {
647
678
  "score": score,
648
679
  "clip_uuid": clip_uuid,