davinci-resolve-mcp 2.212.4 → 2.213.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 +53 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +2 -1
- package/docs/kernels/README.md +1 -1
- package/docs/kernels/color-grade-kernel.md +3 -1
- package/docs/kernels/timeline-conform-interchange-kernel.md +6 -2
- package/docs/reference/readwrite-symmetry.md +3 -3
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/README.md +7 -1
- package/resolve-advanced/server/tools/color_trace.mjs +221 -38
- package/resolve-advanced/server/tools/project_read.mjs +28 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +261 -1
- package/src/utils/destructive_hook.py +2 -0
- package/src/utils/execution_lifecycle.py +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,59 @@
|
|
|
2
2
|
|
|
3
3
|
Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
|
|
4
4
|
|
|
5
|
+
## What's New in v2.213.0 — a ColorTrace that matches on media, then applies
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`color_trace` matches on media identity, names last.** Native ColorTrace
|
|
10
|
+
keys on timecode, clip name and order inside one project, so a renamed clip,
|
|
11
|
+
a reordered cut, or a stringout cut into graded sections defeats it. The
|
|
12
|
+
advanced server's `color_trace plan` now reads both timelines from their
|
|
13
|
+
`Project.db` (any two projects, read-only, no Resolve) and matches in tiers:
|
|
14
|
+
same media (pool item id or file path) with the same in-point and duration;
|
|
15
|
+
same media with overlapping source range (the best overlap wins, so each
|
|
16
|
+
section of a stringout finds its own grade); same reel plus overlap; same
|
|
17
|
+
file name plus overlap (relocated media); then, only as a fallback, exact and
|
|
18
|
+
normalised clip names. Every match reports its `method`, `confidence`,
|
|
19
|
+
`sourceOverlap` and an `ambiguous` flag when two candidates tie.
|
|
20
|
+
- **`plan.json` + a lossless `.drx` per graded match.** With `emitDir` set the
|
|
21
|
+
plan writes one `.drx` per match by copying the source clip's grade body
|
|
22
|
+
byte for byte (no decode/re-encode, so OFX/ResolveFX nodes survive) and a
|
|
23
|
+
`plan.json` that names each target clip by (name, record start, duration).
|
|
24
|
+
- **`timeline_item_color.apply_trace_plan` — the live half.** Resolves every
|
|
25
|
+
plan entry to a clip on the CURRENT timeline, returns a dry-run resolution
|
|
26
|
+
table (`apply` or `skip` with a reason: `live_item_not_found`,
|
|
27
|
+
`ambiguous_live_item`, `below_min_confidence`, `drx_missing`,
|
|
28
|
+
`drx_path_not_temp`, `no-source-grade`, `unmatched`), then behind one
|
|
29
|
+
`confirm_token` for the whole batch runs `ApplyGradeFromDRX` per clip.
|
|
30
|
+
Registered as a destructive action (timeline archived to the Archive bin
|
|
31
|
+
first, rated with the other whole-grade replacements, native dry-run).
|
|
32
|
+
`version_name` adds a local version per clip before applying so the previous
|
|
33
|
+
grade stays intact; `min_confidence` (default 0.8) gates the name-only tiers
|
|
34
|
+
out unless you lower it. Unresolved entries are reported, never guessed.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- **`project_read.timeline_clips` reads the ACTIVE grade version.** The clip
|
|
39
|
+
join now follows the version table's `pActive` and dedupes to one row per
|
|
40
|
+
item, so a clip carrying several corrected versions no longer reads back as
|
|
41
|
+
several clips. Rows also carry the pool item id (`poolId`) and `hasGrade`.
|
|
42
|
+
- **Name normalisation only strips `v`-prefixed version tokens.** Stripping any
|
|
43
|
+
trailing number folded `SHOT_010` and `SHOT_020` onto one key, so a
|
|
44
|
+
name-tier match could cross shots.
|
|
45
|
+
|
|
46
|
+
### What was checked
|
|
47
|
+
|
|
48
|
+
- Offline: 11 new match-engine tests (stringout sections by overlap, straddling
|
|
49
|
+
ranges, ties → ambiguous, pool-id vs path, reel and basename tiers,
|
|
50
|
+
unreadable in-point degrading to `media-only`, name fallbacks, active-version
|
|
51
|
+
dedupe) and 9 driver tests (resolution reasons, stacked clips disambiguated
|
|
52
|
+
by duration, ambiguous live items skipped, token → apply with `version_name`,
|
|
53
|
+
partial failure reported, dispatch without an item). Both full suites green.
|
|
54
|
+
- **Not yet live-validated:** the end-to-end trace onto a real target timeline
|
|
55
|
+
with a render check afterwards. The plan step was live-verified against a
|
|
56
|
+
scratch DB in v2.136; the apply step is unit-tested against stubs only.
|
|
57
|
+
|
|
5
58
|
## What's New in v2.212.4 — both dependency manifests move together again
|
|
6
59
|
|
|
7
60
|
### Changed
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v2.
|
|
15
|
+
> 本翻译对应 v2.213.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
package/docs/SKILL.md
CHANGED
|
@@ -1857,7 +1857,8 @@ Key actions:
|
|
|
1857
1857
|
Color / Grade kernel actions (v2.11.0+) add safer grade inspection and
|
|
1858
1858
|
boundary helpers: `grade_capabilities`, `probe_grade_item`,
|
|
1859
1859
|
`probe_node_graph`, `safe_set_cdl`, `safe_copy_grade`, `safe_apply_drx`,
|
|
1860
|
-
`
|
|
1860
|
+
`apply_trace_plan` (the live half of the advanced server's identity-matched
|
|
1861
|
+
`color_trace`), `safe_export_lut`, `grade_version_snapshot`, `grade_version_restore`,
|
|
1861
1862
|
`color_group_capabilities`, `gallery_capabilities`, and
|
|
1862
1863
|
`grade_boundary_report`. See `docs/kernels/color-grade-kernel.md` for the live-tested
|
|
1863
1864
|
support map, and `docs/guides/color-decision-guide.md` for the practical distinction
|
package/docs/kernels/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Current kernel coverage: **136 actions** across **9 compound MCP tools**.
|
|
|
14
14
|
| Media Pool / ingest | `media_pool` | `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` |
|
|
15
15
|
| Render / Deliver | `render` | `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` |
|
|
16
16
|
| Review annotations | `timeline_markers` | `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` |
|
|
17
|
-
| Color / Grade | `timeline_item_color` | `grade_capabilities`, `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` |
|
|
17
|
+
| Color / Grade | `timeline_item_color` | `grade_capabilities`, `probe_grade_item`, `probe_node_graph`, `safe_set_cdl`, `safe_copy_grade`, `safe_apply_drx`, `apply_trace_plan`, `safe_export_lut`, `grade_version_snapshot`, `grade_version_restore`, `color_group_capabilities`, `gallery_capabilities`, `grade_boundary_report` |
|
|
18
18
|
| Fusion composition | `fusion_comp` | `fusion_graph_capabilities`, `probe_fusion_comp`, `probe_fusion_tool`, `safe_add_tool`, `safe_set_inputs`, `safe_connect_tools`, `fusion_boundary_report` |
|
|
19
19
|
| Conform / interchange | `timeline` | `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` |
|
|
20
20
|
| Audio / Fairlight | `timeline` | `audio_capabilities`, `probe_audio_item`, `probe_audio_track`, `safe_set_audio_properties`, `audio_mix_capability_report`, `voice_isolation_capabilities`, `audio_mapping_report`, `safe_auto_sync_audio`, `transcription_capabilities`, `subtitle_generation_probe`, `fairlight_boundary_report` |
|
|
@@ -33,6 +33,7 @@ All actions are exposed through `timeline_item_color`.
|
|
|
33
33
|
| `safe_set_cdl` | Validate and normalize CDL payloads before calling `SetCDL`; supports dry run. |
|
|
34
34
|
| `safe_copy_grade` | Resolve target timeline item IDs before calling `CopyGrades`; supports dry run. |
|
|
35
35
|
| `safe_apply_drx` | Validate DRX file existence and temp-path guard before calling `ApplyGradeFromDRX`. |
|
|
36
|
+
| `apply_trace_plan` | Apply the advanced server's `color_trace` plan to the current timeline: resolve each entry to a live clip by (name, record start, duration), dry-run resolution table, one confirm_token for the batch, timeline archived first, then `ApplyGradeFromDRX` per clip; `version_name` adds a local version per clip so the previous grade survives. Unresolved entries are reported, never guessed. |
|
|
36
37
|
| `safe_export_lut` | Resolve LUT export type aliases and require temp output paths by default. |
|
|
37
38
|
| `grade_version_snapshot` | Read current, local, and remote grade version names. |
|
|
38
39
|
| `grade_version_restore` | Safely load a named local/remote version after verifying it exists. |
|
|
@@ -114,7 +115,8 @@ The live actions above drive a *running* Resolve. The companion advanced server
|
|
|
114
115
|
(`davinci-resolve-advanced`, see `resolve-advanced/README.md`) *computes* grades
|
|
115
116
|
offline from extracted frames and reads/writes `.drx`/`.drp` grades with **no
|
|
116
117
|
Resolve running**. It emits an apply-ready `.drx`; **applying it is this kernel's
|
|
117
|
-
job** (`safe_apply_drx`
|
|
118
|
+
job** (`safe_apply_drx` for one clip, `apply_trace_plan` for a whole `color_trace`
|
|
119
|
+
plan). Node never drives Resolve.
|
|
118
120
|
|
|
119
121
|
`drx` grading/QC actions (frame-stats → arithmetic → `.drx`, all local,
|
|
120
122
|
deterministic, guarded — they refuse to fabricate a match rather than emit a
|
|
@@ -138,8 +138,12 @@ files and the project DB.
|
|
|
138
138
|
reference-render, scale-corrected, red/yellow/cyan verdicts; each cut is
|
|
139
139
|
compared clear of its transition windows, and Resolve's own FCP7 export
|
|
140
140
|
ingests with its `-1` edges resolved and its missing ticks tolerated).
|
|
141
|
-
- **`color_trace`** — cross-project clip matching
|
|
142
|
-
|
|
141
|
+
- **`color_trace`** — cross-project clip matching on media identity (pool id /
|
|
142
|
+
file path / reel / file name + source-range overlap; names last) → a trace
|
|
143
|
+
plan with a lossless `.drx` per graded match and a `plan.json`. The live half
|
|
144
|
+
is `timeline_item_color.apply_trace_plan` (dry-run resolution table → one
|
|
145
|
+
confirm_token → ApplyGradeFromDRX per resolved clip, timeline archived first).
|
|
146
|
+
Pairs with the color kernel's `drx grade_transfer` for single looks.
|
|
143
147
|
- **`offline_ref`** — offline-reference clips have **no scripting API** but live
|
|
144
148
|
inside `.drp`/`.drt` as `<OfflineClip>` entries; patch them here.
|
|
145
149
|
- **`editorial`** — `parse_interchange` (EDL/OTIO/XMEML natively; **AAF via pyaaf2**,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Read/Write Symmetry Audit
|
|
4
4
|
|
|
5
|
-
- write-style action occurrences scanned: **
|
|
5
|
+
- write-style action occurrences scanned: **117**
|
|
6
6
|
- write-style action occurrences with a matching read: **71**
|
|
7
7
|
- distinct high-signal `set_` actions without a direct/known readback: **4**
|
|
8
8
|
|
|
@@ -13,6 +13,6 @@
|
|
|
13
13
|
- `set_keyframe_interpolation`
|
|
14
14
|
- `set_node_enabled`
|
|
15
15
|
|
|
16
|
-
## Low-signal (create/add/insert/apply/import — usually expected):
|
|
16
|
+
## Low-signal (create/add/insert/apply/import — usually expected): 41 distinct names
|
|
17
17
|
|
|
18
|
-
`add_clip_mattes`, `add_comp`, `add_fusion_mask`, `add_subfolder`, `add_sync_event_markers`, `add_timeline_mattes`, `add_track`, `add_version`, `apply_arri_cdl_lut`, `apply_cuts`, `apply_fairlight_preset`, `apply_grade_from_drx`, `apply_look_to_items`, `apply_spec`, `create_compound_clip`, `create_fusion_clip`, `create_magic_mask`, `create_stereo_clip`, `create_subtitles`, `create_timeline`, `create_timeline_from_clips`, `create_variant_from_ranges`, `import_comp`, `import_folder`, `import_from_drp`, `import_into_timeline`, `import_media`, `import_preset`, `import_project`, `import_render`, `import_timeline`, `import_timeline_checked`, `import_to_pool`, `insert_audio`, `insert_fusion_composition`, `insert_fusion_generator`, `insert_fusion_title`, `insert_generator`, `insert_ofx_generator`, `insert_title`
|
|
18
|
+
`add_clip_mattes`, `add_comp`, `add_fusion_mask`, `add_subfolder`, `add_sync_event_markers`, `add_timeline_mattes`, `add_track`, `add_version`, `apply_arri_cdl_lut`, `apply_cuts`, `apply_fairlight_preset`, `apply_grade_from_drx`, `apply_look_to_items`, `apply_spec`, `apply_trace_plan`, `create_compound_clip`, `create_fusion_clip`, `create_magic_mask`, `create_stereo_clip`, `create_subtitles`, `create_timeline`, `create_timeline_from_clips`, `create_variant_from_ranges`, `import_comp`, `import_folder`, `import_from_drp`, `import_into_timeline`, `import_media`, `import_preset`, `import_project`, `import_render`, `import_timeline`, `import_timeline_checked`, `import_to_pool`, `insert_audio`, `insert_fusion_composition`, `insert_fusion_generator`, `insert_fusion_title`, `insert_generator`, `insert_ofx_generator`, `insert_title`
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "2.
|
|
40
|
+
VERSION = "2.213.0"
|
|
41
41
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
42
42
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
43
43
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -82,7 +82,13 @@ Each dispatches on an `action`. Highlights:
|
|
|
82
82
|
- **`conform`** — offline conform/relink QC engine (frame-oracle math, not filename matching),
|
|
83
83
|
reverse-clip DB repair, sequence lineage store + diff, per-cut frame QC (sampled clear of
|
|
84
84
|
transition windows; ingests Resolve's own FCP7 export — `-1` edges → junctions, no ticks needed).
|
|
85
|
-
- **`color_trace`** —
|
|
85
|
+
- **`color_trace`** — a ColorTrace that works: matches a graded SOURCE timeline against a TARGET
|
|
86
|
+
timeline (any two projects, from `Project.db`, read-only, no Resolve) on **media identity** —
|
|
87
|
+
pool item id / file path / reel / file name plus source-range overlap, so a stringout cut into
|
|
88
|
+
graded sections, a renamed clip, or relinked media still traces; clip names are the last resort.
|
|
89
|
+
Emits one lossless `.drx` per graded match plus a `plan.json`; the live server's
|
|
90
|
+
`timeline_item_color.apply_trace_plan` applies it (dry-run resolution table, one
|
|
91
|
+
confirm_token for the batch, timeline archived first).
|
|
86
92
|
- **`project_read` / `project_db`** — read/patch the Resolve project DB (SQLite or Postgres).
|
|
87
93
|
Includes `list_subtitle_styles` / `set_subtitle_style` — caption font family/size/weight/italic
|
|
88
94
|
and normalised position, which the scripting API cannot touch at all. Whole-track (not
|
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* color_trace tool — a better ColorTrace. Match clips between a SOURCE and
|
|
3
|
-
* TARGET timeline (cross-project,
|
|
4
|
-
* target clip to the source clip whose grade should carry over
|
|
3
|
+
* TARGET timeline (cross-project, identity-aware) → a trace plan mapping each
|
|
4
|
+
* target clip to the source clip whose grade should carry over, plus a lossless
|
|
5
|
+
* .drx per graded match and a plan.json the live server's
|
|
6
|
+
* `timeline_item_color.apply_trace_plan` consumes.
|
|
5
7
|
*
|
|
6
8
|
* Resolve's native ColorTrace matches by TC/name/order within a project and breaks
|
|
7
9
|
* on renames/reorders/retimes. This reads clip lists from ANY project's Project.db
|
|
8
|
-
* (READ-only, no Resolve, even cross-project) and matches
|
|
10
|
+
* (READ-only, no Resolve, even cross-project) and matches on MEDIA IDENTITY first,
|
|
11
|
+
* names last:
|
|
9
12
|
*
|
|
10
|
-
*
|
|
13
|
+
* media-exact same media (pool id or file path), same in-point + duration 1.00
|
|
14
|
+
* media-overlap same media, overlapping source range (best overlap wins) 0.95
|
|
15
|
+
* reel-overlap same reel name, overlapping source range 0.90
|
|
16
|
+
* basename-overlap same file name (relocated media), overlapping source range 0.85
|
|
17
|
+
* media-only same media, source range unreadable or disjoint 0.70
|
|
18
|
+
* exact-name clip name identical 0.75
|
|
19
|
+
* normalized-name clip name identical after version/separator normalisation 0.60
|
|
11
20
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
21
|
+
* A stringout — one long file cut into many graded sections — is the case native
|
|
22
|
+
* ColorTrace loses: every section shares a name and a file. media-overlap picks
|
|
23
|
+
* the section whose source range covers the target's, and flags `ambiguous`
|
|
24
|
+
* when two candidates tie.
|
|
25
|
+
*
|
|
26
|
+
* plan — source timeline + target timeline → { matches[], summary, planPath? }
|
|
16
27
|
*/
|
|
17
28
|
|
|
18
29
|
import fs from 'node:fs';
|
|
@@ -30,7 +41,20 @@ const side = (name) => ({
|
|
|
30
41
|
const planSchema = z.object({
|
|
31
42
|
...side('source'),
|
|
32
43
|
...side('target'),
|
|
33
|
-
emitDir: z
|
|
44
|
+
emitDir: z
|
|
45
|
+
.string()
|
|
46
|
+
.optional()
|
|
47
|
+
.describe(
|
|
48
|
+
'If set, write a .drx per matched+graded clip here plus plan.json (for timeline_item_color.apply_trace_plan on the live server). Use a temp-dir path: the live apply refuses .drx files outside the system temp dir by default.',
|
|
49
|
+
),
|
|
50
|
+
minConfidence: z
|
|
51
|
+
.number()
|
|
52
|
+
.min(0)
|
|
53
|
+
.max(1)
|
|
54
|
+
.optional()
|
|
55
|
+
.describe(
|
|
56
|
+
'Matches below this confidence get no .drx and status below-threshold (default 0 — everything is emitted, confidence is reported per match; the live apply has its own gate, default 0.8)',
|
|
57
|
+
),
|
|
34
58
|
});
|
|
35
59
|
|
|
36
60
|
const xmlEscape = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
@@ -77,41 +101,180 @@ export const drxEnvelope = (label, bodyHex) => {
|
|
|
77
101
|
};
|
|
78
102
|
|
|
79
103
|
// Normalize a clip name to a match key: drop path + extension, lowercase,
|
|
80
|
-
// collapse separators, strip trailing
|
|
81
|
-
|
|
104
|
+
// collapse separators, strip a trailing VERSION token (_v2, -V12). Only a
|
|
105
|
+
// v-prefixed token is a version: stripping any trailing number folded
|
|
106
|
+
// SHOT_010 and SHOT_020 onto one key and let a name-tier match cross shots.
|
|
107
|
+
export function normalize(name) {
|
|
82
108
|
let n = String(name || '')
|
|
83
109
|
.split('/')
|
|
84
110
|
.pop()
|
|
85
111
|
.replace(/\.[^.]+$/, '')
|
|
86
112
|
.toLowerCase();
|
|
87
113
|
n = n.replace(/[_\-.\s]+/g, ' ').trim();
|
|
88
|
-
n = n.replace(/\s+v
|
|
114
|
+
n = n.replace(/\s+v\d{1,3}$/, '').trim();
|
|
89
115
|
return n;
|
|
90
116
|
}
|
|
91
117
|
|
|
118
|
+
const num = (v) => {
|
|
119
|
+
if (v === null || v === undefined || v === '') return null;
|
|
120
|
+
const n = Number(v);
|
|
121
|
+
return Number.isFinite(n) ? n : null;
|
|
122
|
+
};
|
|
123
|
+
const lower = (v) => (v === null || v === undefined ? '' : String(v).trim().toLowerCase());
|
|
124
|
+
const basename = (p) => lower(p).split(/[\\/]/).pop();
|
|
125
|
+
|
|
126
|
+
/** Source-frame range of a clip, or null when the in-point is unreadable. */
|
|
127
|
+
function sourceRange(c) {
|
|
128
|
+
const a = num(c.sourceIn);
|
|
129
|
+
const d = num(c.duration);
|
|
130
|
+
if (a === null || d === null || d <= 0) return null;
|
|
131
|
+
return [a, a + d];
|
|
132
|
+
}
|
|
133
|
+
function overlapFrames(r1, r2) {
|
|
134
|
+
if (!r1 || !r2) return 0;
|
|
135
|
+
return Math.max(0, Math.min(r1[1], r2[1]) - Math.max(r1[0], r2[0]));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const TIERS = [
|
|
139
|
+
// [method, confidence, key(clip) → string|null, needsOverlap]
|
|
140
|
+
['media-exact', 1.0, null, 'exact'],
|
|
141
|
+
['media-overlap', 0.95, null, 'overlap'],
|
|
142
|
+
['reel-overlap', 0.9, (c) => lower(c.reel) || null, 'overlap'],
|
|
143
|
+
['basename-overlap', 0.85, (c) => basename(c.mediaPath) || null, 'overlap'],
|
|
144
|
+
['exact-name', 0.75, (c) => (c.name ? String(c.name) : null), 'none'],
|
|
145
|
+
['media-only', 0.7, null, 'none'],
|
|
146
|
+
['normalized-name', 0.6, (c) => normalize(c.name) || null, 'none'],
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
/** Media identity keys a clip answers to: its pool item id and its file path.
|
|
150
|
+
* Either equal on both sides means "the same media". */
|
|
151
|
+
function mediaKeys(c) {
|
|
152
|
+
const keys = [];
|
|
153
|
+
if (c.poolId) keys.push(`pool:${lower(c.poolId)}`);
|
|
154
|
+
if (c.mediaPath) keys.push(`path:${lower(c.mediaPath)}`);
|
|
155
|
+
return keys;
|
|
156
|
+
}
|
|
157
|
+
|
|
92
158
|
function buildIndex(clips) {
|
|
93
|
-
const
|
|
94
|
-
const
|
|
159
|
+
const idx = { media: new Map(), tiers: new Map() };
|
|
160
|
+
const add = (map, k, c) => {
|
|
161
|
+
if (!k) return;
|
|
162
|
+
if (!map.has(k)) map.set(k, []);
|
|
163
|
+
map.get(k).push(c);
|
|
164
|
+
};
|
|
95
165
|
for (const c of clips) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
166
|
+
for (const k of mediaKeys(c)) add(idx.media, k, c);
|
|
167
|
+
for (const [method, , keyFn] of TIERS) {
|
|
168
|
+
if (!keyFn) continue;
|
|
169
|
+
if (!idx.tiers.has(method)) idx.tiers.set(method, new Map());
|
|
170
|
+
add(idx.tiers.get(method), keyFn(c), c);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return idx;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Candidates that share media identity with the target (deduped). */
|
|
177
|
+
function mediaCandidates(target, idx) {
|
|
178
|
+
const seen = new Set();
|
|
179
|
+
const out = [];
|
|
180
|
+
for (const k of mediaKeys(target)) {
|
|
181
|
+
for (const c of idx.media.get(k) || []) {
|
|
182
|
+
const id = c.itemId || c;
|
|
183
|
+
if (seen.has(id)) continue;
|
|
184
|
+
seen.add(id);
|
|
185
|
+
out.push(c);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Pick the best candidate by source-range overlap. Ties (identical overlap)
|
|
192
|
+
* fall to the nearest record start and are flagged ambiguous. */
|
|
193
|
+
function bestByOverlap(target, candidates) {
|
|
194
|
+
const tr = sourceRange(target);
|
|
195
|
+
if (!tr) return null;
|
|
196
|
+
let best = null;
|
|
197
|
+
let bestOv = 0;
|
|
198
|
+
let ties = 0;
|
|
199
|
+
for (const c of candidates) {
|
|
200
|
+
const ov = overlapFrames(tr, sourceRange(c));
|
|
201
|
+
if (ov <= 0) continue;
|
|
202
|
+
if (ov > bestOv) {
|
|
203
|
+
best = c;
|
|
204
|
+
bestOv = ov;
|
|
205
|
+
ties = 0;
|
|
206
|
+
} else if (ov === bestOv) {
|
|
207
|
+
ties += 1;
|
|
208
|
+
const d = (x) => Math.abs(num(x.start) - num(target.start));
|
|
209
|
+
if (d(c) < d(best)) best = c;
|
|
210
|
+
}
|
|
100
211
|
}
|
|
101
|
-
|
|
212
|
+
if (!best) return null;
|
|
213
|
+
return { src: best, overlap: bestOv, fraction: bestOv / (tr[1] - tr[0]), ambiguous: ties > 0, candidates: candidates.length };
|
|
102
214
|
}
|
|
103
215
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
216
|
+
/** Match ONE target clip against the source index. Returns
|
|
217
|
+
* { src, method, confidence, ambiguous, candidates, sourceOverlap? } — src null when unmatched. */
|
|
218
|
+
export function matchClip(target, idx) {
|
|
219
|
+
const tr = sourceRange(target);
|
|
220
|
+
const tDur = num(target.duration);
|
|
221
|
+
const media = mediaCandidates(target, idx);
|
|
222
|
+
|
|
223
|
+
for (const [method, confidence, keyFn, mode] of TIERS) {
|
|
224
|
+
let candidates;
|
|
225
|
+
if (keyFn) candidates = idx.tiers.get(method)?.get(keyFn(target)) || [];
|
|
226
|
+
else candidates = media;
|
|
227
|
+
if (!candidates.length) continue;
|
|
228
|
+
|
|
229
|
+
if (mode === 'exact') {
|
|
230
|
+
const hits = candidates.filter((c) => tr && num(c.sourceIn) === tr[0] && num(c.duration) === tDur);
|
|
231
|
+
if (!hits.length) continue;
|
|
232
|
+
const src =
|
|
233
|
+
hits.length === 1 ? hits[0] : hits.reduce((a, b) => (Math.abs(num(b.start) - num(target.start)) < Math.abs(num(a.start) - num(target.start)) ? b : a));
|
|
234
|
+
return { src, method, confidence, ambiguous: hits.length > 1, candidates: hits.length, sourceOverlap: { frames: tDur, fraction: 1 } };
|
|
235
|
+
}
|
|
236
|
+
if (mode === 'overlap') {
|
|
237
|
+
const b = bestByOverlap(target, candidates);
|
|
238
|
+
if (!b) continue;
|
|
239
|
+
return {
|
|
240
|
+
src: b.src,
|
|
241
|
+
method,
|
|
242
|
+
confidence,
|
|
243
|
+
ambiguous: b.ambiguous,
|
|
244
|
+
candidates: b.candidates,
|
|
245
|
+
sourceOverlap: { frames: b.overlap, fraction: Number(b.fraction.toFixed(4)) },
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
// mode 'none' — first candidate, nearest record start on ties.
|
|
249
|
+
const src =
|
|
250
|
+
candidates.length === 1
|
|
251
|
+
? candidates[0]
|
|
252
|
+
: candidates.reduce((a, b) => (Math.abs(num(b.start) - num(target.start)) < Math.abs(num(a.start) - num(target.start)) ? b : a));
|
|
253
|
+
return { src, method, confidence, ambiguous: candidates.length > 1, candidates: candidates.length };
|
|
254
|
+
}
|
|
255
|
+
return { src: null, method: null, confidence: 0, ambiguous: false, candidates: 0 };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Match every target clip against the source clips (pure; no DB). */
|
|
259
|
+
export function matchClips(srcClips, tgtClips) {
|
|
260
|
+
const idx = buildIndex(srcClips);
|
|
261
|
+
return tgtClips.map((t) => ({ target: t, ...matchClip(t, idx) }));
|
|
109
262
|
}
|
|
110
263
|
|
|
264
|
+
const clipRef = (c) => ({
|
|
265
|
+
name: c.name,
|
|
266
|
+
start: num(c.start),
|
|
267
|
+
duration: num(c.duration),
|
|
268
|
+
sourceIn: num(c.sourceIn),
|
|
269
|
+
reel: c.reel || null,
|
|
270
|
+
mediaPath: c.mediaPath || null,
|
|
271
|
+
poolId: c.poolId || null,
|
|
272
|
+
});
|
|
273
|
+
|
|
111
274
|
export const colorTraceTool = {
|
|
112
275
|
name: 'color_trace',
|
|
113
276
|
description:
|
|
114
|
-
'Better ColorTrace — match clips between a SOURCE and TARGET timeline (cross-project, from Project.db, read-only, no Resolve) → a trace plan
|
|
277
|
+
'Better ColorTrace — match clips between a SOURCE and TARGET timeline (cross-project, from Project.db, read-only, no Resolve) on media identity (pool id / file path / reel / file name + source-range overlap), names last → a trace plan with a lossless .drx per graded match and a plan.json for timeline_item_color.apply_trace_plan on the live server. Action: plan.',
|
|
115
278
|
async handler({ action, args }) {
|
|
116
279
|
if (action === 'plan') {
|
|
117
280
|
const p = planSchema.parse(args);
|
|
@@ -122,43 +285,63 @@ export const colorTraceTool = {
|
|
|
122
285
|
if (!srcClips.length) throw new Error(`source timeline "${p.sourceTimeline}" has no video clips`);
|
|
123
286
|
if (!tgtClips.length) throw new Error(`target timeline "${p.targetTimeline}" has no video clips`);
|
|
124
287
|
if (p.emitDir) fs.mkdirSync(p.emitDir, { recursive: true });
|
|
288
|
+
const minConfidence = p.minConfidence ?? 0;
|
|
125
289
|
|
|
126
|
-
const
|
|
127
|
-
const
|
|
290
|
+
const byMethod = { unmatched: 0 };
|
|
291
|
+
for (const [m] of TIERS) byMethod[m] = 0;
|
|
128
292
|
let gradesReady = 0;
|
|
129
|
-
|
|
130
|
-
|
|
293
|
+
let ambiguous = 0;
|
|
294
|
+
let belowThreshold = 0;
|
|
295
|
+
const matches = matchClips(srcClips, tgtClips).map((m, i) => {
|
|
296
|
+
const t = m.target;
|
|
131
297
|
byMethod[m.method || 'unmatched'] += 1;
|
|
298
|
+
if (m.ambiguous) ambiguous += 1;
|
|
132
299
|
let gradeApply = null;
|
|
133
|
-
if (m.src && m.
|
|
300
|
+
if (m.src && m.confidence < minConfidence) {
|
|
301
|
+
belowThreshold += 1;
|
|
302
|
+
gradeApply = { status: 'below-threshold', sourceClip: m.src.name, confidence: m.confidence, minConfidence };
|
|
303
|
+
} else if (m.src && m.src.gradeBody) {
|
|
134
304
|
gradesReady += 1;
|
|
135
|
-
gradeApply = { status: 'ready', sourceClip: m.src.name };
|
|
305
|
+
gradeApply = { status: 'ready', sourceClip: m.src.name, sourceVersion: m.src.gradeVersion || null };
|
|
136
306
|
if (p.emitDir) {
|
|
137
307
|
// emit a .drx (lossless source-grade pass-through) for ApplyGradeFromDRX onto the target clip.
|
|
138
308
|
const drxPath = path.join(p.emitDir, `trace-${String(i).padStart(4, '0')}.drx`);
|
|
139
309
|
fs.writeFileSync(drxPath, drxEnvelope(m.src.name, m.src.gradeBody));
|
|
140
310
|
gradeApply.drxPath = drxPath;
|
|
141
|
-
gradeApply.applyVia = {
|
|
311
|
+
gradeApply.applyVia = {
|
|
312
|
+
api: 'timeline_item_color.apply_trace_plan',
|
|
313
|
+
target: { timeline: p.targetTimeline, clipName: t.name, start: num(t.start), duration: num(t.duration) },
|
|
314
|
+
};
|
|
142
315
|
}
|
|
143
316
|
} else if (m.src) {
|
|
144
317
|
gradeApply = { status: 'no-source-grade', sourceClip: m.src.name };
|
|
145
318
|
}
|
|
146
319
|
return {
|
|
147
|
-
|
|
148
|
-
|
|
320
|
+
index: i,
|
|
321
|
+
target: clipRef(t),
|
|
322
|
+
source: m.src ? clipRef(m.src) : null,
|
|
149
323
|
method: m.method,
|
|
150
324
|
confidence: m.confidence,
|
|
325
|
+
ambiguous: m.ambiguous,
|
|
326
|
+
candidates: m.candidates,
|
|
327
|
+
sourceOverlap: m.sourceOverlap || null,
|
|
151
328
|
gradeApply,
|
|
152
329
|
};
|
|
153
330
|
});
|
|
154
331
|
const matched = matches.filter((m) => m.source).length;
|
|
155
|
-
|
|
156
|
-
source: { timeline: p.sourceTimeline, clips: srcClips.length },
|
|
157
|
-
target: { timeline: p.targetTimeline, clips: tgtClips.length },
|
|
158
|
-
summary: { matched, unmatched: tgtClips.length - matched, gradesReady, byMethod },
|
|
332
|
+
const result = {
|
|
333
|
+
source: { projectDb: srcDb, timeline: p.sourceTimeline, clips: srcClips.length, graded: srcClips.filter((c) => c.gradeBody).length },
|
|
334
|
+
target: { projectDb: tgtDb, timeline: p.targetTimeline, clips: tgtClips.length },
|
|
335
|
+
summary: { matched, unmatched: tgtClips.length - matched, gradesReady, ambiguous, belowThreshold, byMethod },
|
|
159
336
|
emitDir: p.emitDir || null,
|
|
337
|
+
planPath: null,
|
|
160
338
|
matches,
|
|
161
339
|
};
|
|
340
|
+
if (p.emitDir) {
|
|
341
|
+
result.planPath = path.join(p.emitDir, 'plan.json');
|
|
342
|
+
fs.writeFileSync(result.planPath, JSON.stringify({ kind: 'color_trace.plan', createdAt: new Date().toISOString(), ...result }, null, 2));
|
|
343
|
+
}
|
|
344
|
+
return result;
|
|
162
345
|
}
|
|
163
346
|
throw new Error(`Unknown color_trace action: ${action}`);
|
|
164
347
|
},
|
|
@@ -35,16 +35,28 @@ const timelineClipsSchema = z.object({ ...dbTarget, timeline: z.string().describ
|
|
|
35
35
|
// column: file-relative source frames, calibrated frame-EXACT against a real
|
|
36
36
|
// turnover's AAF ground truth (A005C004 run: 36069/8863/8919/12804, through-
|
|
37
37
|
// edit continuity intact). Emitted as sourceIn.
|
|
38
|
+
// Identity columns for color_trace (2026-09-08): MediaRef → Sm2MpMedia gives the
|
|
39
|
+
// pool item's UniqueMediaPoolItemId (poolId); the version table's pActive names
|
|
40
|
+
// the ACTIVE grade version, so an item carrying several corrected versions
|
|
41
|
+
// reads back ONE row — the active one (or the first corrected one when the
|
|
42
|
+
// active version has no correction). Measured on a 21k-item show DB: no item
|
|
43
|
+
// held two corrected versions, but the dedupe below is what keeps that from
|
|
44
|
+
// becoming a duplicate-clip bug the day one does.
|
|
38
45
|
const TIMELINE_CLIPS_SQL = `
|
|
39
|
-
SELECT i.Name AS name, t.Type AS trackType, i.Start AS start, i.Duration AS duration,
|
|
46
|
+
SELECT i.Sm2TiItem_id AS itemId, i.Name AS name, t.Type AS trackType, i.Start AS start, i.Duration AS duration,
|
|
40
47
|
i.MediaReelNumber AS reel, i.MediaStartTime AS mediaStart, i."In" AS sourceIn,
|
|
41
48
|
i.MediaFilePath AS mediaPath,
|
|
49
|
+
m.UniqueMediaPoolItemId AS poolId,
|
|
42
50
|
i.Sm2TiTrack_id AS trackId,
|
|
51
|
+
v.Name AS gradeVersion,
|
|
52
|
+
CASE WHEN vt.pActive IS NOT NULL AND v."ListMgt::LmVersion_id" = vt.pActive THEN 1 ELSE 0 END AS gradeIsActive,
|
|
43
53
|
lower(hex(v.Body)) AS gradeBody
|
|
44
54
|
FROM Sm2TiItem i
|
|
45
55
|
JOIN Sm2TiTrack t ON i.Sm2TiTrack_id = t.Sm2TiTrack_id
|
|
46
56
|
JOIN Sm2Sequence s ON t.Sequence = s.Sm2Sequence_id
|
|
47
57
|
JOIN Sm2Timeline tl ON s.Sm2Timeline_id = tl.Sm2Timeline_id
|
|
58
|
+
LEFT JOIN Sm2MpMedia m ON m.Sm2MpMedia_id = i.MediaRef
|
|
59
|
+
LEFT JOIN "ListMgt::LmVersionTable" vt ON vt."ListMgt::LmVersionTable_id" = i.pLmVerTable
|
|
48
60
|
LEFT JOIN "ListMgt::LmVersion" v ON v."ListMgt::LmVersionTable_id" = i.pLmVerTable AND v.HasCorrection = '1'
|
|
49
61
|
WHERE tl.Name = ?`;
|
|
50
62
|
const tablesSchema = z.object({ ...dbTarget, withRowCounts: z.boolean().optional() });
|
|
@@ -107,12 +119,15 @@ export function readTimelineClips(dbPath, timeline, trackType = 'all', includeGr
|
|
|
107
119
|
const db = openGuarded(dbPath, { writable: false });
|
|
108
120
|
try {
|
|
109
121
|
let rows = db.prepare(TIMELINE_CLIPS_SQL).all(timeline);
|
|
122
|
+
rows = dedupeVersionRows(rows);
|
|
110
123
|
if (trackType === 'video') rows = rows.filter((r) => r.trackType === 0);
|
|
111
124
|
else if (trackType === 'audio') rows = rows.filter((r) => r.trackType !== 0);
|
|
112
125
|
rows.sort((a, b) => a.trackType - b.trackType || Number(a.start) - Number(b.start));
|
|
113
126
|
// grade Body is large + only needed for ColorTrace — strip it by default to avoid bloat.
|
|
114
127
|
for (const r of rows) {
|
|
128
|
+
r.hasGrade = !!r.gradeBody;
|
|
115
129
|
if (!includeGrade) delete r.gradeBody;
|
|
130
|
+
delete r.gradeIsActive;
|
|
116
131
|
r.sourceIn = decodeItemIn(r.sourceIn);
|
|
117
132
|
}
|
|
118
133
|
return rows;
|
|
@@ -121,6 +136,18 @@ export function readTimelineClips(dbPath, timeline, trackType = 'all', includeGr
|
|
|
121
136
|
}
|
|
122
137
|
}
|
|
123
138
|
|
|
139
|
+
/** One row per timeline item: an item with several corrected grade versions
|
|
140
|
+
* joins to several rows — keep the ACTIVE version's row, else the first. */
|
|
141
|
+
export function dedupeVersionRows(rows) {
|
|
142
|
+
const byItem = new Map();
|
|
143
|
+
for (const r of rows) {
|
|
144
|
+
const key = r.itemId || `${r.trackId}|${r.start}|${r.name}`;
|
|
145
|
+
const prev = byItem.get(key);
|
|
146
|
+
if (!prev || (!prev.gradeIsActive && r.gradeIsActive)) byItem.set(key, r);
|
|
147
|
+
}
|
|
148
|
+
return [...byItem.values()];
|
|
149
|
+
}
|
|
150
|
+
|
|
124
151
|
export const projectReadTool = {
|
|
125
152
|
name: 'project_read',
|
|
126
153
|
description:
|
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "2.
|
|
90
|
+
VERSION = "2.213.0"
|
|
91
91
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
92
92
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
93
93
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 353-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.213.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -1281,6 +1281,8 @@ _TOKEN_GATED_DESTRUCTIVE_ACTIONS = frozenset({
|
|
|
1281
1281
|
("edit_engine", "execute_swap"),
|
|
1282
1282
|
("graph", "apply_grade_from_drx"),
|
|
1283
1283
|
("graph", "reset_all_grades"),
|
|
1284
|
+
# Batch grade carry-over from a color_trace plan: plan → confirm → apply.
|
|
1285
|
+
("timeline_item_color", "apply_trace_plan"),
|
|
1284
1286
|
# 21.0 AI ops that render/generate NEW media files (additive, but expensive
|
|
1285
1287
|
# and irreversible without manual cleanup) — gated so they never run by
|
|
1286
1288
|
# surprise. They never modify source media.
|
|
@@ -26386,6 +26388,7 @@ _COLOR_GRADE_KERNEL_ACTIONS = [
|
|
|
26386
26388
|
"safe_set_cdl",
|
|
26387
26389
|
"safe_copy_grade",
|
|
26388
26390
|
"safe_apply_drx",
|
|
26391
|
+
"apply_trace_plan",
|
|
26389
26392
|
"safe_export_lut",
|
|
26390
26393
|
"grade_version_snapshot",
|
|
26391
26394
|
"grade_version_restore",
|
|
@@ -26861,6 +26864,232 @@ def _safe_apply_drx(proj, item, p: Dict[str, Any]):
|
|
|
26861
26864
|
return {"success": success, "path": path, "source": source}
|
|
26862
26865
|
|
|
26863
26866
|
|
|
26867
|
+
# ── color_trace apply driver ─────────────────────────────────────────────────
|
|
26868
|
+
#
|
|
26869
|
+
# The advanced (Node) server's `color_trace plan` matches a graded SOURCE
|
|
26870
|
+
# timeline against a TARGET timeline from the project DB (no Resolve needed),
|
|
26871
|
+
# writes one lossless .drx per graded match and a plan.json naming each target
|
|
26872
|
+
# clip by (name, record start, duration). This driver is the live half: it
|
|
26873
|
+
# resolves every plan entry to a clip on the CURRENT timeline, reports what it
|
|
26874
|
+
# would touch, and — behind a confirm_token, with the timeline archived first —
|
|
26875
|
+
# runs ApplyGradeFromDRX on each resolved clip. Every clip the plan could not
|
|
26876
|
+
# place on the live timeline is reported, never guessed.
|
|
26877
|
+
|
|
26878
|
+
_TRACE_SKIP_REASONS = (
|
|
26879
|
+
"unmatched", "no-source-grade", "below-threshold", "below_min_confidence",
|
|
26880
|
+
"drx_missing", "drx_path_not_temp", "live_item_not_found", "ambiguous_live_item",
|
|
26881
|
+
)
|
|
26882
|
+
|
|
26883
|
+
|
|
26884
|
+
def _load_trace_plan(p: Dict[str, Any]):
|
|
26885
|
+
plan = p.get("plan")
|
|
26886
|
+
plan_path = p.get("plan_path")
|
|
26887
|
+
if plan is None:
|
|
26888
|
+
if not plan_path:
|
|
26889
|
+
return None, _err(
|
|
26890
|
+
"plan_path (or an inline plan) is required",
|
|
26891
|
+
code="MISSING_PLAN", category="invalid_input",
|
|
26892
|
+
remediation="Run the advanced server's color_trace plan with emitDir set "
|
|
26893
|
+
"(a temp-dir path) and pass its planPath here.",
|
|
26894
|
+
)
|
|
26895
|
+
if not os.path.isfile(plan_path):
|
|
26896
|
+
return None, _err(f"plan not found: {plan_path}", code="PLAN_NOT_FOUND", category="invalid_input")
|
|
26897
|
+
try:
|
|
26898
|
+
with open(plan_path, encoding="utf-8") as fh:
|
|
26899
|
+
plan = json.load(fh)
|
|
26900
|
+
except Exception as exc:
|
|
26901
|
+
return None, _err(f"plan is not valid JSON: {exc}", code="PLAN_INVALID", category="invalid_input")
|
|
26902
|
+
if not isinstance(plan, dict) or not isinstance(plan.get("matches"), list):
|
|
26903
|
+
return None, _err("plan has no matches list (expected color_trace.plan output)",
|
|
26904
|
+
code="PLAN_INVALID", category="invalid_input")
|
|
26905
|
+
return plan, None
|
|
26906
|
+
|
|
26907
|
+
|
|
26908
|
+
def _live_video_items(tl) -> List[Dict[str, Any]]:
|
|
26909
|
+
"""Every video-track item on the timeline with the fields the plan keys on."""
|
|
26910
|
+
out: List[Dict[str, Any]] = []
|
|
26911
|
+
try:
|
|
26912
|
+
track_count = int(tl.GetTrackCount("video") or 0)
|
|
26913
|
+
except Exception as exc:
|
|
26914
|
+
logger.warning("apply_trace_plan: GetTrackCount(video) failed: %s", exc)
|
|
26915
|
+
track_count = 0
|
|
26916
|
+
for track_index in range(1, track_count + 1):
|
|
26917
|
+
for item_index, it in enumerate(tl.GetItemListInTrack("video", track_index) or []):
|
|
26918
|
+
try:
|
|
26919
|
+
out.append({
|
|
26920
|
+
"item": it,
|
|
26921
|
+
"track_index": track_index,
|
|
26922
|
+
"item_index": item_index,
|
|
26923
|
+
"id": it.GetUniqueId(),
|
|
26924
|
+
"name": it.GetName(),
|
|
26925
|
+
"start": int(it.GetStart()),
|
|
26926
|
+
"duration": int(it.GetDuration()),
|
|
26927
|
+
})
|
|
26928
|
+
except Exception as exc:
|
|
26929
|
+
logger.warning("apply_trace_plan: skipping unreadable item on V%s: %s", track_index, exc)
|
|
26930
|
+
return out
|
|
26931
|
+
|
|
26932
|
+
|
|
26933
|
+
def _resolve_trace_plan(tl, plan: Dict[str, Any], p: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
26934
|
+
"""Map each plan match onto a live clip. status: apply | skip (+reason)."""
|
|
26935
|
+
min_conf = float(p.get("min_confidence", 0.8))
|
|
26936
|
+
tol = int(p.get("start_tolerance", 0))
|
|
26937
|
+
require_temp = p.get("require_temp_path", True)
|
|
26938
|
+
live = _live_video_items(tl)
|
|
26939
|
+
rows: List[Dict[str, Any]] = []
|
|
26940
|
+
for m in plan["matches"]:
|
|
26941
|
+
tgt = m.get("target") or {}
|
|
26942
|
+
src = m.get("source") or {}
|
|
26943
|
+
ga = m.get("gradeApply") or {}
|
|
26944
|
+
drx = ga.get("drxPath")
|
|
26945
|
+
row: Dict[str, Any] = {
|
|
26946
|
+
"index": m.get("index"),
|
|
26947
|
+
"target": {"name": tgt.get("name"), "start": tgt.get("start"), "duration": tgt.get("duration")},
|
|
26948
|
+
"source": src.get("name"),
|
|
26949
|
+
"method": m.get("method"),
|
|
26950
|
+
"confidence": m.get("confidence"),
|
|
26951
|
+
"ambiguous": bool(m.get("ambiguous")),
|
|
26952
|
+
"drx_path": drx,
|
|
26953
|
+
"status": "skip",
|
|
26954
|
+
"reason": None,
|
|
26955
|
+
"live": None,
|
|
26956
|
+
}
|
|
26957
|
+
conf = row["confidence"] if isinstance(row["confidence"], (int, float)) else 0.0
|
|
26958
|
+
if not src:
|
|
26959
|
+
row["reason"] = "unmatched"
|
|
26960
|
+
elif ga.get("status") != "ready":
|
|
26961
|
+
row["reason"] = ga.get("status") or "no-source-grade"
|
|
26962
|
+
elif conf < min_conf:
|
|
26963
|
+
row["reason"] = "below_min_confidence"
|
|
26964
|
+
elif not drx or not os.path.isfile(drx):
|
|
26965
|
+
row["reason"] = "drx_missing"
|
|
26966
|
+
elif require_temp and not _grade_temp_path_ok(drx):
|
|
26967
|
+
row["reason"] = "drx_path_not_temp"
|
|
26968
|
+
else:
|
|
26969
|
+
name = tgt.get("name")
|
|
26970
|
+
start = tgt.get("start")
|
|
26971
|
+
cands = [
|
|
26972
|
+
x for x in live
|
|
26973
|
+
if x["name"] == name and isinstance(start, (int, float)) and abs(x["start"] - int(start)) <= tol
|
|
26974
|
+
]
|
|
26975
|
+
if len(cands) > 1:
|
|
26976
|
+
same_dur = [x for x in cands if x["duration"] == tgt.get("duration")]
|
|
26977
|
+
if len(same_dur) == 1:
|
|
26978
|
+
cands = same_dur
|
|
26979
|
+
if not cands:
|
|
26980
|
+
row["reason"] = "live_item_not_found"
|
|
26981
|
+
elif len(cands) > 1:
|
|
26982
|
+
row["reason"] = "ambiguous_live_item"
|
|
26983
|
+
row["live_candidates"] = [
|
|
26984
|
+
{"track_index": x["track_index"], "item_index": x["item_index"], "id": x["id"], "duration": x["duration"]}
|
|
26985
|
+
for x in cands
|
|
26986
|
+
]
|
|
26987
|
+
else:
|
|
26988
|
+
hit = cands[0]
|
|
26989
|
+
row["status"] = "apply"
|
|
26990
|
+
row["live"] = {k: hit[k] for k in ("track_index", "item_index", "id", "start", "duration")}
|
|
26991
|
+
row["_item"] = hit["item"]
|
|
26992
|
+
rows.append(row)
|
|
26993
|
+
return rows
|
|
26994
|
+
|
|
26995
|
+
|
|
26996
|
+
def _apply_trace_plan(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
26997
|
+
plan, err = _load_trace_plan(p)
|
|
26998
|
+
if err:
|
|
26999
|
+
return err
|
|
27000
|
+
_, tl, err = _get_tl()
|
|
27001
|
+
if err:
|
|
27002
|
+
return err
|
|
27003
|
+
live_name = tl.GetName()
|
|
27004
|
+
plan_tl = (plan.get("target") or {}).get("timeline")
|
|
27005
|
+
if plan_tl and plan_tl != live_name and not p.get("allow_timeline_mismatch"):
|
|
27006
|
+
return _err(
|
|
27007
|
+
f"plan targets timeline {plan_tl!r} but the current timeline is {live_name!r}",
|
|
27008
|
+
code="TIMELINE_MISMATCH", category="invalid_input",
|
|
27009
|
+
remediation=f"timeline(action='set_current', params={{'name': {plan_tl!r}}}) first, "
|
|
27010
|
+
"or pass allow_timeline_mismatch=True if the plan was built for this cut under another name.",
|
|
27011
|
+
)
|
|
27012
|
+
rows = _resolve_trace_plan(tl, plan, p)
|
|
27013
|
+
to_apply = [r for r in rows if r["status"] == "apply"]
|
|
27014
|
+
skipped_by_reason: Dict[str, int] = {}
|
|
27015
|
+
for r in rows:
|
|
27016
|
+
if r["status"] != "apply":
|
|
27017
|
+
skipped_by_reason[r["reason"] or "unknown"] = skipped_by_reason.get(r["reason"] or "unknown", 0) + 1
|
|
27018
|
+
summary = {
|
|
27019
|
+
"plan_matches": len(rows),
|
|
27020
|
+
"would_apply": len(to_apply),
|
|
27021
|
+
"skipped": len(rows) - len(to_apply),
|
|
27022
|
+
"skipped_by_reason": skipped_by_reason,
|
|
27023
|
+
"ambiguous_in_plan": sum(1 for r in rows if r["ambiguous"]),
|
|
27024
|
+
"min_confidence": float(p.get("min_confidence", 0.8)),
|
|
27025
|
+
}
|
|
27026
|
+
public = [{k: v for k, v in r.items() if not k.startswith("_")} for r in rows]
|
|
27027
|
+
grade_mode = p.get("grade_mode", 0)
|
|
27028
|
+
version_name = p.get("version_name")
|
|
27029
|
+
|
|
27030
|
+
if p.get("dry_run"):
|
|
27031
|
+
return _ok(dry_run=True, timeline=live_name, plan_source=plan.get("source"),
|
|
27032
|
+
summary=summary, resolution=public, grade_mode=grade_mode, version_name=version_name)
|
|
27033
|
+
if not to_apply:
|
|
27034
|
+
return _ok(timeline=live_name, applied=[], failed=[], summary=summary, resolution=public,
|
|
27035
|
+
note="nothing to apply — every plan entry was skipped (see skipped_by_reason)")
|
|
27036
|
+
if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
|
|
27037
|
+
preview = {
|
|
27038
|
+
"operation": "apply_trace_plan",
|
|
27039
|
+
"warning": "REPLACES the node graph of every listed target clip (ApplyGradeFromDRX has "
|
|
27040
|
+
"no append mode). The current timeline is archived to the Archive bin first.",
|
|
27041
|
+
"timeline": live_name,
|
|
27042
|
+
"will_apply": len(to_apply),
|
|
27043
|
+
"summary": summary,
|
|
27044
|
+
"targets": [
|
|
27045
|
+
{"name": r["target"]["name"], "start": r["target"]["start"], "source": r["source"],
|
|
27046
|
+
"method": r["method"], "confidence": r["confidence"], "ambiguous": r["ambiguous"]}
|
|
27047
|
+
for r in to_apply[:50]
|
|
27048
|
+
],
|
|
27049
|
+
"grade_mode": grade_mode,
|
|
27050
|
+
"version_name": version_name,
|
|
27051
|
+
}
|
|
27052
|
+
return _issue_confirm_token(action="apply_trace_plan", params=p, preview=preview)
|
|
27053
|
+
blocked = _consume_confirm_token(action="apply_trace_plan", params=p)
|
|
27054
|
+
if blocked:
|
|
27055
|
+
return blocked
|
|
27056
|
+
|
|
27057
|
+
applied: List[Dict[str, Any]] = []
|
|
27058
|
+
failed: List[Dict[str, Any]] = []
|
|
27059
|
+
for r in to_apply:
|
|
27060
|
+
item = r["_item"]
|
|
27061
|
+
rec = {k: v for k, v in r.items() if not k.startswith("_")}
|
|
27062
|
+
ok = False
|
|
27063
|
+
try:
|
|
27064
|
+
if version_name:
|
|
27065
|
+
# AddVersion switches the item to the NEW version, so the apply
|
|
27066
|
+
# lands there and the previous version stays intact.
|
|
27067
|
+
rec["version_added"] = bool(item.AddVersion(version_name, 0))
|
|
27068
|
+
graph = item.GetNodeGraph()
|
|
27069
|
+
if not _has_method(graph, "ApplyGradeFromDRX"):
|
|
27070
|
+
rec["error"] = "item graph does not expose ApplyGradeFromDRX"
|
|
27071
|
+
else:
|
|
27072
|
+
ok = bool(graph.ApplyGradeFromDRX(r["drx_path"], grade_mode))
|
|
27073
|
+
if not ok:
|
|
27074
|
+
rec["error"] = "ApplyGradeFromDRX returned False"
|
|
27075
|
+
except Exception as exc:
|
|
27076
|
+
rec["error"] = str(exc)
|
|
27077
|
+
rec["status"] = "applied" if ok else "failed"
|
|
27078
|
+
(applied if ok else failed).append(rec)
|
|
27079
|
+
summary.update({"applied": len(applied), "failed": len(failed)})
|
|
27080
|
+
return {
|
|
27081
|
+
"success": not failed,
|
|
27082
|
+
"timeline": live_name,
|
|
27083
|
+
"plan_source": plan.get("source"),
|
|
27084
|
+
"applied": applied,
|
|
27085
|
+
"failed": failed,
|
|
27086
|
+
"skipped": [r for r in public if r["status"] != "apply"],
|
|
27087
|
+
"summary": summary,
|
|
27088
|
+
"grade_mode": grade_mode,
|
|
27089
|
+
"version_name": version_name,
|
|
27090
|
+
}
|
|
27091
|
+
|
|
27092
|
+
|
|
26864
27093
|
def _grade_version_restore(item, p: Dict[str, Any]):
|
|
26865
27094
|
name = p.get("name")
|
|
26866
27095
|
if not name:
|
|
@@ -26980,6 +27209,25 @@ _ACTION_HELP: Dict[str, Dict[str, Dict[str, Any]]] = {
|
|
|
26980
27209
|
' # re-call with confirm_token to apply'
|
|
26981
27210
|
),
|
|
26982
27211
|
},
|
|
27212
|
+
"apply_trace_plan": {
|
|
27213
|
+
"summary": "Apply a color_trace plan (advanced server) to the CURRENT timeline: one ApplyGradeFromDRX per resolved clip. REPLACES those graphs; gated by confirm_token; timeline archived first.",
|
|
27214
|
+
"params": "plan_path: str (planPath from color_trace plan with emitDir) | plan: dict, dry_run?, min_confidence? (default 0.8), start_tolerance? (frames, default 0), grade_mode? (0=no keyframes, 1=source TC aligned, 2=start aligned), version_name? (AddVersion per clip before applying), require_temp_path? (default True), allow_timeline_mismatch?, confirm_token?",
|
|
27215
|
+
"returns": "{success, timeline, applied: [...], failed: [...], skipped: [...], summary: {plan_matches, would_apply, applied, failed, skipped_by_reason, ambiguous_in_plan}}; dry_run → {resolution: [{target, source, method, confidence, status, reason, live}]}",
|
|
27216
|
+
"example": (
|
|
27217
|
+
'# 1. advanced server (no Resolve needed):\n'
|
|
27218
|
+
'# color_trace(action="plan", {sourceProjectName: "SHOW_v07", sourceTimeline: "REEL_01 v07",\n'
|
|
27219
|
+
'# targetProjectName: "SHOW_v08", targetTimeline: "REEL_01 v08",\n'
|
|
27220
|
+
'# emitDir: "/tmp/trace-reel01"}) → planPath\n'
|
|
27221
|
+
'# 2. open the TARGET project + timeline in Resolve, then:\n'
|
|
27222
|
+
'timeline_item_color(action="apply_trace_plan", params={\n'
|
|
27223
|
+
' "plan_path": "/tmp/trace-reel01/plan.json", "dry_run": True\n'
|
|
27224
|
+
'}) # read resolution: every entry is apply | skip(reason)\n'
|
|
27225
|
+
'timeline_item_color(action="apply_trace_plan", params={\n'
|
|
27226
|
+
' "plan_path": "/tmp/trace-reel01/plan.json", "version_name": "traced v07"\n'
|
|
27227
|
+
'}) # first call returns {status: "confirmation_required", confirm_token}\n'
|
|
27228
|
+
' # re-call with confirm_token to apply'
|
|
27229
|
+
),
|
|
27230
|
+
},
|
|
26983
27231
|
"grade_evidence_base": {
|
|
26984
27232
|
"summary": "PREFERRED pre-flight read. Composes version_snapshot + node_graph + color_group + coverage_report.",
|
|
26985
27233
|
"params": "include_coverage?, max_nodes?, min_source_trust? (medium|high), track_type?, track_index?, item_index?",
|
|
@@ -27757,6 +28005,7 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
27757
28005
|
safe_set_cdl -> {success, validation, normalized, node_preflight, diagnosis?}
|
|
27758
28006
|
safe_copy_grade -> {success, targets, missing}
|
|
27759
28007
|
safe_apply_drx -> {success, path, source} # first call may return confirm_token
|
|
28008
|
+
apply_trace_plan -> {success, timeline, applied, failed, skipped, summary} # dry_run → {resolution}; first live call returns confirm_token
|
|
27760
28009
|
grade_capabilities -> {item_methods, graph_sources, lut_export_types, guards}
|
|
27761
28010
|
grade_boundary_report -> {capabilities, item, color_groups, gallery}
|
|
27762
28011
|
All actions may return {"error": {code, category, retryable, message, remediation, reason?}}.
|
|
@@ -27798,6 +28047,14 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
27798
28047
|
safe_apply_drx(path, source?, grade_mode?, require_temp_path?) -> {success}
|
|
27799
28048
|
REPLACES the target graph. Captures a version snapshot first; require_temp_path defaults True.
|
|
27800
28049
|
# example: action_help(name='<action_name>')
|
|
28050
|
+
apply_trace_plan(plan_path|plan, dry_run?, min_confidence?=0.8, start_tolerance?=0, grade_mode?=0, version_name?, require_temp_path?=True, allow_timeline_mismatch?, confirm_token?) -> {success, applied, failed, skipped, summary}
|
|
28051
|
+
The live half of the advanced server's color_trace (a ColorTrace that matches on media
|
|
28052
|
+
identity, cross-project, from the project DB). Resolves each plan entry to a clip on the
|
|
28053
|
+
CURRENT timeline by (name, record start, duration), then ApplyGradeFromDRX per clip —
|
|
28054
|
+
one confirm_token for the batch, timeline archived first. dry_run returns the resolution
|
|
28055
|
+
table without a token. version_name adds a new local version per clip before applying so
|
|
28056
|
+
the previous grade stays intact. Never guesses: unresolved entries are reported, not applied.
|
|
28057
|
+
# example: action_help(name='<action_name>')
|
|
27801
28058
|
safe_export_lut(type?, path, require_temp_path?) -> {success, path, size}
|
|
27802
28059
|
Sandboxed LUT export.
|
|
27803
28060
|
grade_version_restore(name, type?, dry_run?, ...) -> {success}
|
|
@@ -27837,6 +28094,9 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
27837
28094
|
p = _params(params)
|
|
27838
28095
|
if action == "action_help":
|
|
27839
28096
|
return _action_help("timeline_item_color", p)
|
|
28097
|
+
if action == "apply_trace_plan":
|
|
28098
|
+
# Timeline-scoped (walks every video track); does not need an item.
|
|
28099
|
+
return _apply_trace_plan(p)
|
|
27840
28100
|
_, item, err = _get_item(p)
|
|
27841
28101
|
if err:
|
|
27842
28102
|
return err
|
|
@@ -169,6 +169,7 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
|
|
|
169
169
|
"timeline_item_color": frozenset({
|
|
170
170
|
"set_cdl",
|
|
171
171
|
"copy_grades",
|
|
172
|
+
"apply_trace_plan",
|
|
172
173
|
"reset_all_node_colors",
|
|
173
174
|
"assign_color_group",
|
|
174
175
|
"remove_from_color_group",
|
|
@@ -277,6 +278,7 @@ DRY_RUN_DEFAULT_TRUE_ACTIONS: frozenset = frozenset({
|
|
|
277
278
|
|
|
278
279
|
NATIVE_DRY_RUN_ACTIONS: frozenset = frozenset({
|
|
279
280
|
("media_pool", "clear_clip_marks"),
|
|
281
|
+
("timeline_item_color", "apply_trace_plan"),
|
|
280
282
|
("media_pool", "set_clip_marks"),
|
|
281
283
|
("media_pool", "setup_multicam_timeline"),
|
|
282
284
|
("timeline", "apply_cuts"),
|
|
@@ -169,6 +169,9 @@ class RiskClassificationHook(LifecycleHook):
|
|
|
169
169
|
# CopyGrades overwrites each target's grade.
|
|
170
170
|
("graph", "apply_grade_from_drx"),
|
|
171
171
|
("timeline_item_color", "copy_grades"),
|
|
172
|
+
# apply_trace_plan runs ApplyGradeFromDRX over every clip a color_trace
|
|
173
|
+
# plan resolved — one confirm_token, N replaced graphs.
|
|
174
|
+
("timeline_item_color", "apply_trace_plan"),
|
|
172
175
|
# Takes. delete removes one; finalize collapses the item to the selected
|
|
173
176
|
# take and discards the rest.
|
|
174
177
|
("timeline_item_takes", "delete"),
|