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.
- package/CHANGELOG.md +134 -0
- package/README.md +2 -2
- package/docs/SKILL.md +42 -4
- package/docs/install.md +8 -0
- package/docs/reference/api-limitations.md +9 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +268 -6
- package/src/utils/analysis_store.py +36 -1
- package/src/utils/api_truth.py +56 -0
- package/src/utils/destructive_hook.py +4 -1
- package/src/utils/edit_engine.py +34 -3
- package/src/utils/strata.py +565 -0
- package/src/utils/strata_analyzers.py +684 -0
- package/src/utils/strata_faces.py +347 -0
- package/src/utils/strata_queries.py +643 -0
- package/src/utils/strata_story.py +311 -0
- package/src/utils/timeline_brain_db.py +121 -1
- package/src/utils/timeline_versioning.py +19 -2
|
@@ -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
|
|
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
|
|
package/src/utils/api_truth.py
CHANGED
|
@@ -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
|
-
|
|
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",
|
package/src/utils/edit_engine.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|