davinci-resolve-mcp 2.94.2 → 2.95.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 +84 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/reference/api-limitations.md +5 -5
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/server/tools/drp.mjs +42 -1
- package/resolve-advanced/vendor/drp-format/README.md +17 -0
- package/resolve-advanced/vendor/drp-format/__tests__/compound-nav.test.js +116 -0
- package/resolve-advanced/vendor/drp-format/__tests__/fixtures/compound-nav-r21.drp +0 -0
- package/resolve-advanced/vendor/drp-format/compound-nav.js +232 -0
- package/resolve-advanced/vendor/drp-format/index.js +7 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
- package/src/utils/api_truth.py +58 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,90 @@
|
|
|
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.95.0
|
|
6
|
+
|
|
7
|
+
Compound clips can now be walked into and edited offline — the one capability
|
|
8
|
+
where the offline tier beats the live scripting API outright.
|
|
9
|
+
|
|
10
|
+
### Added — `drp` nested-sequence actions
|
|
11
|
+
|
|
12
|
+
`list_nested`, `read_nested`, `read_nested_titles`, `set_nested_title_text`
|
|
13
|
+
(`resolve-advanced/vendor/drp-format/compound-nav.js`). Enumerate every compound
|
|
14
|
+
clip and nested timeline in a `.drp`, walk into either, and rewrite the title
|
|
15
|
+
text inside.
|
|
16
|
+
|
|
17
|
+
**A compound clip and a nested timeline are the same shape on disk.** Both appear
|
|
18
|
+
in `MediaPool/Master/MpFolder.xml` as a media-pool element (`Sm2MpCompoundClip` /
|
|
19
|
+
`Sm2MpTimelineClip`) carrying an inline `<Sequence><Sm2Sequence DbId="X">`, and
|
|
20
|
+
the `SeqContainer/<uuid>.xml` whose tracks carry `<Sequence>X</Sequence>` holds
|
|
21
|
+
the contents. The join is on that **Sm2Sequence DbId, not the container's own
|
|
22
|
+
DbId** — which is referenced by nothing else in the package. One asymmetry: a
|
|
23
|
+
compound's contents are rebased to `Start` 0 while a nested timeline's keep
|
|
24
|
+
timeline-absolute TC.
|
|
25
|
+
|
|
26
|
+
**Why this matters.** `MediaPoolItem.GetTimeline()` (21.0.4+) resolves through
|
|
27
|
+
the timeline handle: it returns the inner Timeline for `Type='Timeline'` and
|
|
28
|
+
**`None` for `Type='Compound'`**, and a compounded Text+ reports
|
|
29
|
+
`GetFusionCompCount() == 0`. So compounding a title severs its text permanently
|
|
30
|
+
as far as scripting is concerned — there is no API route back to it at any
|
|
31
|
+
version. Offline, the distinction does not exist.
|
|
32
|
+
|
|
33
|
+
Verified end to end on Studio 21.0.4.5: text rewritten offline inside a compound,
|
|
34
|
+
imported into Resolve, re-exported by Resolve, and read back **from Resolve's own
|
|
35
|
+
export** unchanged — so Resolve genuinely parses the write rather than tolerating
|
|
36
|
+
it. Ten unit tests against a committed fixture exported from 21.0.4.5 holding a
|
|
37
|
+
compound-of-media, a compound-of-Text+, and a nested timeline.
|
|
38
|
+
|
|
39
|
+
## What's New in v2.94.3
|
|
40
|
+
|
|
41
|
+
A systematic sweep of catalogued API gaps against **Studio 21.0.4.5**, prompted
|
|
42
|
+
by an external report. Two ledger entries were wrong and are corrected; a new
|
|
43
|
+
harness proves the routes that DO work.
|
|
44
|
+
|
|
45
|
+
### Fixed — transitions are not invisible to scripting
|
|
46
|
+
|
|
47
|
+
- **`Transition create / copy / clone` claimed transitions applied in the UI are
|
|
48
|
+
"invisible to and unmodifiable by scripts". Both halves were wrong.** A
|
|
49
|
+
12-frame Cross Dissolve applied through the Edit-page right-click menu
|
|
50
|
+
enumerates in `GetItemListInTrack` as `GetName() == 'Cross Dissolve'`,
|
|
51
|
+
`GetStart() == 86426`, `GetDuration() == 12` — centered on a cut at 86432 —
|
|
52
|
+
with a stable `GetUniqueId()`. One authored offline into a `.drp` and imported
|
|
53
|
+
reads identically, so the creating route is irrelevant. It is also removable:
|
|
54
|
+
`DeleteClips([transition], False)` returns True and leaves both adjacent clips
|
|
55
|
+
untouched. **The discriminator** is `GetProperty()`: empty on a transition, 26
|
|
56
|
+
transform keys on a clip. Automated QC of existing transitions is therefore
|
|
57
|
+
possible. Genuinely missing: creation, cloning, and any type/alignment detail —
|
|
58
|
+
the kind is knowable only from the name string.
|
|
59
|
+
|
|
60
|
+
### Fixed — CreateProject is not an "Untitled project" problem
|
|
61
|
+
|
|
62
|
+
- **`CreateProject` returned None with a NAMED project current and no modal on
|
|
63
|
+
screen** (screenshot-confirmed), while `OpenPage('edit')` succeeded in the same
|
|
64
|
+
session — so the connection was healthy and the documented modal is not the
|
|
65
|
+
only mechanism. Loading any clean project unblocked it on the next call. The
|
|
66
|
+
entry's recommended workaround was `CloseProject`, which **discards unsaved
|
|
67
|
+
changes** — acceptable for a throwaway Untitled project, destructive when the
|
|
68
|
+
current project is a named one belonging to another session. It now recommends
|
|
69
|
+
`LoadProject(clean project)` instead.
|
|
70
|
+
|
|
71
|
+
### Added — `tests/live_workaround_verification.py`
|
|
72
|
+
|
|
73
|
+
The sibling of the gap harness: where that one proves absence, this proves
|
|
74
|
+
presence, performing each operation and reading the result back. Seven routes
|
|
75
|
+
verified on 21.0.4.5 — nested-timeline title placement with a later text edit,
|
|
76
|
+
ripple delete (with a non-ripple control showing the gap), the take selector as
|
|
77
|
+
an in-place source swap, `GetSelectedClips`, and constant retime through both
|
|
78
|
+
OTIO `LinearTimeWarp` and EDL `M2` (each a true 200%: 96 source frames over a
|
|
79
|
+
48-frame record, alongside a 1.0 control). It also records the compound-clip trap
|
|
80
|
+
as a deliberate `ROUTE FAILS`.
|
|
81
|
+
|
|
82
|
+
### Changed — the gap harness covers 13 gaps, up from 8
|
|
83
|
+
|
|
84
|
+
Added control-paired repros for the Source/Auto Track Selector (locking V1 blocks
|
|
85
|
+
the insert rather than redirecting it, while a media-backed clip targets V2
|
|
86
|
+
fine), transitions, native multicam creation, per-caption subtitle text, and
|
|
87
|
+
per-clip audio channel mapping. **13/13 confirmed missing** on 21.0.4.5.
|
|
88
|
+
|
|
5
89
|
## What's New in v2.94.2
|
|
6
90
|
|
|
7
91
|
Changelog repair. No code 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.95.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
|
@@ -89,8 +89,8 @@ equivalent, blocking full automation.
|
|
|
89
89
|
### Transition create / copy / clone
|
|
90
90
|
|
|
91
91
|
- **Object:** `Timeline / TimelineItem`
|
|
92
|
-
- **Behavior:**
|
|
93
|
-
- **Workaround / current handling:**
|
|
92
|
+
- **Behavior:** There is no method to ADD or CLONE an edit transition — no AddTransition/CreateTransition/AddVideoTransition on Timeline or TimelineItem (dir(), 21.0.4.5). CORRECTION, measured on Studio 21.0.4.5 (2026-08-12): this entry previously said transitions applied in the UI are 'invisible to and unmodifiable by scripts'. BOTH HALVES WERE WRONG and are withdrawn. A transition IS a first-class timeline item: a 12-frame Cross Dissolve applied through the Edit-page right-click menu enumerates in GetItemListInTrack('video', 1) as GetName()=='Cross Dissolve', GetStart()==86426, GetDuration()==12 — centered on a cut at 86432 — with a stable GetUniqueId() and a working GetTrackTypeAndIndex(). A transition authored offline into a .drp and imported reads IDENTICALLY, so the route that created it does not matter. It is also REMOVABLE: Timeline.DeleteClips([transition], False) returns True and deletes it, leaving both adjacent clips at their original starts and durations. THE DISCRIMINATOR between a transition item and a clip item is GetProperty(): a transition returns an EMPTY dict where a video clip returns 26 transform keys; it also has no MediaPoolItem and no Fusion comp. WHAT IS GENUINELY MISSING: creation, cloning, and any type/alignment/parameter detail — the transition's kind is knowable ONLY from its name string, and there is no way to read its alignment (centered/start/end) or edit its duration.
|
|
93
|
+
- **Workaround / current handling:** Automated QC of existing transitions IS possible and is the main practical need — enumerate GetItemListInTrack, treat any item whose GetProperty() is empty and whose GetMediaPoolItem() is None as a transition, and read its name, start and duration. Removal is scriptable via Timeline.DeleteClips. To CREATE one, either apply it in the Resolve UI, or author it offline and import: the advanced server's drp place_transition writes a cross dissolve at an abutting cut ({track, atFrame, durationFrames}) and it round-trips into Resolve 21.0.4.5 reading back at the expected centered range.
|
|
94
94
|
- **Tags:** missing-method, timeline, transition
|
|
95
95
|
|
|
96
96
|
### Cloud project enumeration / export / user management
|
|
@@ -367,11 +367,11 @@ values, or automation-hostile modal prompts.
|
|
|
367
367
|
- **Workaround / current handling:** Treat a trailing ellipsis as truncation (see media_pool_item get_transcription's `truncated` flag).
|
|
368
368
|
- **Tags:** transcription, truncation
|
|
369
369
|
|
|
370
|
-
### ProjectManager.CreateProject (
|
|
370
|
+
### ProjectManager.CreateProject (blocked by the current project)
|
|
371
371
|
|
|
372
372
|
- **Object:** `ProjectManager`
|
|
373
|
-
- **Behavior:** Returns None and pops a modal 'Save Current Project' dialog when the current unsaved/Untitled project blocks the switch. SaveProject() on an Untitled project re-triggers the same modal.
|
|
374
|
-
- **Workaround / current handling:**
|
|
373
|
+
- **Behavior:** Returns None and pops a modal 'Save Current Project' dialog when the current unsaved/Untitled project blocks the switch. SaveProject() on an Untitled project re-triggers the same modal. BROADER THAN 'UNTITLED', measured on Studio 21.0.4.5 (2026-08-12): with a NAMED project current (a conform project left open on the Deliver page), CreateProject returned None for a brand-new name and SaveProject returned False — with NO modal on screen, confirmed by screenshot, so the dialog is not the only mechanism. The connection was healthy throughout: OpenPage('edit') returned True in the same session, and moving to the Edit page did not help. Loading ANY clean project immediately unblocked it — CreateProject succeeded on the very next call. So the trigger is the current project, not the 'Untitled' name, and a bare False/None is the only signal.
|
|
374
|
+
- **Workaround / current handling:** LoadProject(any clean project) first, then CreateProject; restore the caller's project afterward. PREFER THAT OVER CloseProject, which this entry used to recommend: CloseProject DISCARDS unsaved changes, which is fine for a throwaway Untitled project but destroys work when the current project is a named one belonging to someone else's session — and the failure is NOT limited to Untitled projects, so that is a live risk. Never assume a bare None here means the name is taken; check GetProjectListInCurrentFolder().
|
|
375
375
|
- **Tags:** project, modal, silent-failure
|
|
376
376
|
|
|
377
377
|
### TimelineItem.GetSourceStartFrame
|
package/install.py
CHANGED
|
@@ -36,7 +36,7 @@ from src.utils.update_check import (
|
|
|
36
36
|
|
|
37
37
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
38
38
|
|
|
39
|
-
VERSION = "2.
|
|
39
|
+
VERSION = "2.95.0"
|
|
40
40
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
41
41
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
42
42
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Edit: move_clip, delete_clip, trim_clip, trim_clip_head, split_clip, ripple_timeline
|
|
9
9
|
* Media: relink_media, repoint_media
|
|
10
10
|
* Grade: inject_grades, extract_node_graphs, diff
|
|
11
|
+
* Nested: list_nested, read_nested, read_nested_titles, set_nested_title_text (compounds + nested timelines)
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import fs from 'node:fs/promises';
|
|
@@ -86,6 +87,26 @@ const S = {
|
|
|
86
87
|
repoint_media: z.object({ ...io, mappings: z.array(z.object({}).passthrough()).describe('[{from,to,fromSpec,toSpec}] spec={width,height,frameCount,fps}') }),
|
|
87
88
|
inject_grades: z.object({ ...io, grades: z.array(z.object({}).passthrough()).describe('[{ clipId|resolveId, drxContent }]') }),
|
|
88
89
|
extract_node_graphs: z.object({ drpPath: io.drpPath, includeBodyHex: z.boolean().optional() }),
|
|
90
|
+
list_nested: z.object({
|
|
91
|
+
drpPath: io.drpPath,
|
|
92
|
+
}),
|
|
93
|
+
read_nested: z.object({
|
|
94
|
+
drpPath: io.drpPath,
|
|
95
|
+
name: z.string().describe('Compound or nested-timeline name as it appears in the media pool'),
|
|
96
|
+
}),
|
|
97
|
+
read_nested_titles: z.object({
|
|
98
|
+
drpPath: io.drpPath,
|
|
99
|
+
name: z.string().describe('Compound or nested-timeline name'),
|
|
100
|
+
}),
|
|
101
|
+
set_nested_title_text: z.object({
|
|
102
|
+
...io,
|
|
103
|
+
name: z.string().describe('Compound or nested-timeline name'),
|
|
104
|
+
titleIndex: z.number().int().nonnegative().optional().describe('Which title inside, if several (default 0)'),
|
|
105
|
+
text: z.string().optional().describe('New on-screen text. No double quotes.'),
|
|
106
|
+
font: z.string().optional(),
|
|
107
|
+
style: z.string().optional(),
|
|
108
|
+
size: z.number().optional(),
|
|
109
|
+
}),
|
|
89
110
|
extract_group_grades: z.object({
|
|
90
111
|
drpPath: io.drpPath,
|
|
91
112
|
groups: z.array(z.string()).optional().describe('Color group names; default = all groups in the project'),
|
|
@@ -108,7 +129,7 @@ async function writeOp(fnName, drpPath, opts, outputPath) {
|
|
|
108
129
|
export const drpTool = {
|
|
109
130
|
name: 'drp',
|
|
110
131
|
description:
|
|
111
|
-
'DaVinci Resolve project (.drp) authoring + editing — offline, no Resolve required. Actions: create_empty_project, assemble_timeline, add_media_clip, place_fusion_title, place_generator, place_transition, move_clip, delete_clip, trim_clip, trim_clip_head, split_clip, ripple_timeline, relink_media, repoint_media, inject_grades, extract_node_graphs, extract_group_grades, diff, extract_lut_refs.',
|
|
132
|
+
'DaVinci Resolve project (.drp) authoring + editing — offline, no Resolve required. Actions: create_empty_project, assemble_timeline, add_media_clip, place_fusion_title, place_generator, place_transition, move_clip, delete_clip, trim_clip, trim_clip_head, split_clip, ripple_timeline, relink_media, repoint_media, inject_grades, extract_node_graphs, extract_group_grades, diff, extract_lut_refs, list_nested, read_nested, read_nested_titles, set_nested_title_text.',
|
|
112
133
|
async handler({ action, args }) {
|
|
113
134
|
const gen = drp();
|
|
114
135
|
|
|
@@ -221,6 +242,26 @@ export const drpTool = {
|
|
|
221
242
|
}
|
|
222
243
|
return { drpPath: p.drpPath, clipsWithGrade: graphs.length, totalClips: index.clipsById.size, graphs };
|
|
223
244
|
}
|
|
245
|
+
// --- Nested sequences: compounds AND nested timelines. The scripting API
|
|
246
|
+
// cannot walk into a COMPOUND at all (MediaPoolItem.GetTimeline() is None
|
|
247
|
+
// for Type='Compound'), so these have no live counterpart.
|
|
248
|
+
if (action === 'list_nested') {
|
|
249
|
+
const p = S.list_nested.parse(args);
|
|
250
|
+
return { nested: await gen.listNestedSequences(p.drpPath) };
|
|
251
|
+
}
|
|
252
|
+
if (action === 'read_nested') {
|
|
253
|
+
const p = S.read_nested.parse(args);
|
|
254
|
+
return await gen.readNestedSequence(p.drpPath, { name: p.name });
|
|
255
|
+
}
|
|
256
|
+
if (action === 'read_nested_titles') {
|
|
257
|
+
const p = S.read_nested_titles.parse(args);
|
|
258
|
+
return { titles: await gen.readNestedTitles(p.drpPath, { name: p.name }) };
|
|
259
|
+
}
|
|
260
|
+
if (action === 'set_nested_title_text') {
|
|
261
|
+
const p = S.set_nested_title_text.parse(args);
|
|
262
|
+
const { drpPath, outputPath, ...opts } = p;
|
|
263
|
+
return writeOp('setNestedTitleText', drpPath, opts, outputPath);
|
|
264
|
+
}
|
|
224
265
|
if (action === 'extract_group_grades') {
|
|
225
266
|
const p = S.extract_group_grades.parse(args);
|
|
226
267
|
const groups = await decodeGroupGrades(p.drpPath, { groups: p.groups, includePreClip: p.includePreClip });
|
|
@@ -141,4 +141,21 @@ internal-state/cache `FieldsBlob`s (`classifyBlob` flags them; Node has no built
|
|
|
141
141
|
protobuf blobs' *field names* (wire structure is decoded; names need Resolve's private `.proto`); the
|
|
142
142
|
Fusion comp beyond the title inputs we edit.
|
|
143
143
|
|
|
144
|
+
**Nested sequences — compounds AND nested timelines (`compound-nav.js`):** enumerate, walk
|
|
145
|
+
into, and rewrite title text inside either. **A compound clip and a nested timeline are the same
|
|
146
|
+
shape on disk:** both appear in `MediaPool/Master/MpFolder.xml` as a media-pool element
|
|
147
|
+
(`Sm2MpCompoundClip` / `Sm2MpTimelineClip`) carrying an inline `<Sequence><Sm2Sequence DbId="X">`,
|
|
148
|
+
and the `SeqContainer/<uuid>.xml` whose tracks carry `<Sequence>X</Sequence>` holds the contents.
|
|
149
|
+
The join is on that **Sm2Sequence DbId, not the container's own DbId** — the container id is
|
|
150
|
+
referenced by nothing else in the package. One asymmetry worth knowing: a compound's contents are
|
|
151
|
+
rebased to `Start` 0, while a nested timeline's keep timeline-absolute TC.
|
|
152
|
+
|
|
153
|
+
> This is the one place the offline tier beats the live API outright. `MediaPoolItem.GetTimeline()`
|
|
154
|
+
> (21.0.4+) resolves through the timeline handle: it returns the inner Timeline for
|
|
155
|
+
> `Type='Timeline'` and **`None` for `Type='Compound'`**, and a compounded Text+ reports
|
|
156
|
+
> `GetFusionCompCount() == 0`. So compounding a title severs its text permanently as far as
|
|
157
|
+
> scripting is concerned. Offline the distinction does not exist. Verified end to end on Studio
|
|
158
|
+
> 21.0.4.5: text rewritten offline inside a compound, imported, re-exported by Resolve, and read
|
|
159
|
+
> back **from Resolve's own export** unchanged.
|
|
160
|
+
|
|
144
161
|
**Limit:** media authoring is h264-only (cross-codec needs a per-codec template).
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compound-nav — walking into compound clips and nested timelines.
|
|
3
|
+
*
|
|
4
|
+
* Fixture: compound-nav-r21.drp, exported from DaVinci Resolve Studio 21.0.4.5
|
|
5
|
+
* (DbPrjVer 17). It holds, deliberately, one of each interesting case:
|
|
6
|
+
* CMP_MEDIA compound wrapping a media clip
|
|
7
|
+
* CMP_TITLE compound wrapping a Text+ (text set to "INSIDE COMPOUND")
|
|
8
|
+
* ZZ_CMP_TL the outer timeline
|
|
9
|
+
* ZZ_INNER_TL a plain nested timeline holding a Text+
|
|
10
|
+
*
|
|
11
|
+
* The compound cases are the point: the scripting API cannot reach into them at
|
|
12
|
+
* all. MediaPoolItem.GetTimeline() returns None for Type='Compound' (measured on
|
|
13
|
+
* 21.0.4.5) while working for Type='Timeline', and a compounded Text+ reports
|
|
14
|
+
* GetFusionCompCount()==0. On disk there is no such asymmetry.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const test = require('node:test');
|
|
18
|
+
const assert = require('node:assert');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
|
|
22
|
+
const nav = require('../compound-nav');
|
|
23
|
+
|
|
24
|
+
const FIXTURE = path.join(__dirname, 'fixtures', 'compound-nav-r21.drp');
|
|
25
|
+
const hasFixture = fs.existsSync(FIXTURE);
|
|
26
|
+
const maybe = hasFixture ? test : test.skip;
|
|
27
|
+
|
|
28
|
+
maybe('listNestedSequences finds both compounds and both timelines', async () => {
|
|
29
|
+
const list = await nav.listNestedSequences(FIXTURE);
|
|
30
|
+
const byName = Object.fromEntries(list.map((e) => [e.name, e]));
|
|
31
|
+
|
|
32
|
+
assert.equal(list.length, 4);
|
|
33
|
+
assert.equal(byName.CMP_MEDIA.kind, 'compound');
|
|
34
|
+
assert.equal(byName.CMP_TITLE.kind, 'compound');
|
|
35
|
+
assert.equal(byName.ZZ_CMP_TL.kind, 'timeline');
|
|
36
|
+
assert.equal(byName.ZZ_INNER_TL.kind, 'timeline');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
maybe('every nested sequence resolves to its own SeqContainer', async () => {
|
|
40
|
+
const list = await nav.listNestedSequences(FIXTURE);
|
|
41
|
+
for (const e of list) {
|
|
42
|
+
assert.ok(e.sequenceDbId, `${e.name} has no Sm2Sequence DbId`);
|
|
43
|
+
assert.ok(e.entry, `${e.name} did not resolve to a SeqContainer`);
|
|
44
|
+
}
|
|
45
|
+
// The join is one-to-one: no two media pool items share a container.
|
|
46
|
+
const entries = list.map((e) => e.entry);
|
|
47
|
+
assert.equal(new Set(entries).size, entries.length);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
maybe('readNestedSequence returns the items inside a compound', async () => {
|
|
51
|
+
const media = await nav.readNestedSequence(FIXTURE, { name: 'CMP_MEDIA' });
|
|
52
|
+
assert.equal(media.kind, 'compound');
|
|
53
|
+
assert.equal(media.tracks.length, 1);
|
|
54
|
+
assert.equal(media.tracks[0].items.length, 1);
|
|
55
|
+
assert.equal(media.tracks[0].items[0].name, 'retime_src.mp4');
|
|
56
|
+
assert.equal(media.tracks[0].items[0].duration, 48);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
maybe('compound contents are rebased to 0; a nested timeline keeps timeline TC', async () => {
|
|
60
|
+
const cmp = await nav.readNestedSequence(FIXTURE, { name: 'CMP_TITLE' });
|
|
61
|
+
const tl = await nav.readNestedSequence(FIXTURE, { name: 'ZZ_INNER_TL' });
|
|
62
|
+
assert.equal(cmp.tracks[0].items[0].start, 0);
|
|
63
|
+
assert.equal(tl.tracks[0].items[0].start, 86400);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
maybe('readNestedTitles decodes text the scripting API cannot reach', async () => {
|
|
67
|
+
const titles = await nav.readNestedTitles(FIXTURE, { name: 'CMP_TITLE' });
|
|
68
|
+
assert.equal(titles.length, 1);
|
|
69
|
+
assert.equal(titles[0].text, 'INSIDE COMPOUND');
|
|
70
|
+
assert.equal(titles[0].font, 'Open Sans');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
maybe('setNestedTitleText rewrites the text inside a compound', async () => {
|
|
74
|
+
const res = await nav.setNestedTitleText(FIXTURE, {
|
|
75
|
+
name: 'CMP_TITLE', text: 'REWRITTEN',
|
|
76
|
+
});
|
|
77
|
+
assert.equal(res.before.text, 'INSIDE COMPOUND');
|
|
78
|
+
assert.equal(res.after.text, 'REWRITTEN');
|
|
79
|
+
|
|
80
|
+
// Re-read from the produced buffer, not from the in-memory result.
|
|
81
|
+
const again = await nav.readNestedTitles(res.buffer, { name: 'CMP_TITLE' });
|
|
82
|
+
assert.equal(again[0].text, 'REWRITTEN');
|
|
83
|
+
// Untouched inputs survive.
|
|
84
|
+
assert.equal(again[0].font, 'Open Sans');
|
|
85
|
+
assert.equal(again[0].size, 0.09);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
maybe('setNestedTitleText leaves the other compound alone', async () => {
|
|
89
|
+
const res = await nav.setNestedTitleText(FIXTURE, {
|
|
90
|
+
name: 'CMP_TITLE', text: 'ONLY THIS ONE',
|
|
91
|
+
});
|
|
92
|
+
const other = await nav.readNestedSequence(res.buffer, { name: 'CMP_MEDIA' });
|
|
93
|
+
assert.equal(other.tracks[0].items[0].name, 'retime_src.mp4');
|
|
94
|
+
assert.equal(other.tracks[0].items[0].duration, 48);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
maybe('unknown name throws rather than silently doing nothing', async () => {
|
|
98
|
+
await assert.rejects(
|
|
99
|
+
() => nav.readNestedSequence(FIXTURE, { name: 'NOPE' }),
|
|
100
|
+
/no compound or timeline named/,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
maybe('a double quote in the text is refused (it would break the Lua blob)', async () => {
|
|
105
|
+
await assert.rejects(
|
|
106
|
+
() => nav.setNestedTitleText(FIXTURE, { name: 'CMP_TITLE', text: 'say "hi"' }),
|
|
107
|
+
/must not contain double quotes/,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
maybe('titleIndex out of range names the count it saw', async () => {
|
|
112
|
+
await assert.rejects(
|
|
113
|
+
() => nav.setNestedTitleText(FIXTURE, { name: 'CMP_TITLE', text: 'x', titleIndex: 7 }),
|
|
114
|
+
/has 1 title\(s\); no index 7/,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
Binary file
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compound-nav — walk INTO compound clips and nested timelines in a .drp.
|
|
3
|
+
*
|
|
4
|
+
* A compound clip and a nested timeline are the same shape on disk: a sequence
|
|
5
|
+
* inside a sequence. Both appear in `MediaPool/Master/MpFolder.xml` as a media
|
|
6
|
+
* pool element (`Sm2MpCompoundClip` / `Sm2MpTimelineClip`) carrying an inline
|
|
7
|
+
* `<Sequence><Sm2Sequence DbId="X">`, and the `SeqContainer/<uuid>.xml` whose
|
|
8
|
+
* tracks reference `<Sequence>X</Sequence>` holds the contents. The container's
|
|
9
|
+
* own filename/DbId is referenced by NOTHING else in the package — the join is
|
|
10
|
+
* on the Sm2Sequence DbId, not the container id.
|
|
11
|
+
*
|
|
12
|
+
* This matters because the SCRIPTING API cannot do it. MediaPoolItem.GetTimeline()
|
|
13
|
+
* (Resolve 21.0.4+) resolves through the timeline handle: it returns the inner
|
|
14
|
+
* Timeline for a `Type='Timeline'` item and None for a `Type='Compound'` one.
|
|
15
|
+
* Compounding a Text+ therefore severs its text permanently as far as the API is
|
|
16
|
+
* concerned — FusionCompCount drops 1 -> 0 and nothing reaches the tools inside.
|
|
17
|
+
* Offline, the distinction does not exist: both are just a nested <Sequence>.
|
|
18
|
+
*
|
|
19
|
+
* Verified against a Resolve Studio 21.0.4.5 export (DbPrjVer 17) containing a
|
|
20
|
+
* compound-of-media, a compound-of-Text+, and a nested timeline.
|
|
21
|
+
*
|
|
22
|
+
* @module drp-format/compound-nav
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const { loadDrpZip, splitTrackElements, splitClipElements, getItemsInner } = require('./seq-surgery');
|
|
26
|
+
|
|
27
|
+
const MP_FOLDER = 'MediaPool/Master/MpFolder.xml';
|
|
28
|
+
|
|
29
|
+
const KINDS = {
|
|
30
|
+
Sm2MpCompoundClip: 'compound',
|
|
31
|
+
Sm2MpTimelineClip: 'timeline',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const tagValue = (xml, tag) => {
|
|
35
|
+
const m = xml.match(new RegExp(`<${tag}>([^<]*)</${tag}>`));
|
|
36
|
+
return m ? m[1] : null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The Sm2Sequence DbId a media pool element points at.
|
|
41
|
+
* Read from the element's own <Sequence> child, NOT from any later sibling.
|
|
42
|
+
*/
|
|
43
|
+
function sequenceDbIdOf(elementXml) {
|
|
44
|
+
const m = elementXml.match(/<Sequence>\s*<Sm2Sequence\s+DbId="([^"]+)"/);
|
|
45
|
+
return m ? m[1] : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Every SeqContainer entry, mapped by the Sm2Sequence DbId its tracks carry. */
|
|
49
|
+
async function containersBySequenceId(zip) {
|
|
50
|
+
const out = new Map();
|
|
51
|
+
for (const name of Object.keys(zip.files)) {
|
|
52
|
+
if (!/^SeqContainer\/[^/]+\.xml$/.test(name)) continue;
|
|
53
|
+
const xml = await zip.file(name).async('string');
|
|
54
|
+
const seq = tagValue(xml, 'Sequence');
|
|
55
|
+
if (seq) out.set(seq, { entry: name, xml });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* List every nested sequence in the package — compounds AND timelines.
|
|
62
|
+
*
|
|
63
|
+
* @param {Buffer|string} drpInput
|
|
64
|
+
* @returns {Promise<Array<{kind:'compound'|'timeline', name:string,
|
|
65
|
+
* sequenceDbId:string, entry:string|null, mediaPoolItemId:string|null,
|
|
66
|
+
* videoTracks:number, itemCount:number}>>}
|
|
67
|
+
*/
|
|
68
|
+
async function listNestedSequences(drpInput) {
|
|
69
|
+
const zip = await loadDrpZip(drpInput);
|
|
70
|
+
const mp = await zip.file(MP_FOLDER).async('string');
|
|
71
|
+
const bySeq = await containersBySequenceId(zip);
|
|
72
|
+
|
|
73
|
+
const results = [];
|
|
74
|
+
for (const [tag, kind] of Object.entries(KINDS)) {
|
|
75
|
+
const re = new RegExp(`<${tag}\\b[\\s\\S]*?</${tag}>`, 'g');
|
|
76
|
+
for (const m of mp.matchAll(re)) {
|
|
77
|
+
const el = m[0];
|
|
78
|
+
const sequenceDbId = sequenceDbIdOf(el);
|
|
79
|
+
const found = sequenceDbId ? bySeq.get(sequenceDbId) : null;
|
|
80
|
+
let videoTracks = 0;
|
|
81
|
+
let itemCount = 0;
|
|
82
|
+
if (found) {
|
|
83
|
+
const vec = found.xml.match(/<VideoTrackVec>([\s\S]*?)<\/VideoTrackVec>/);
|
|
84
|
+
const tracks = vec ? splitTrackElements(vec[1]) : [];
|
|
85
|
+
videoTracks = tracks.length;
|
|
86
|
+
itemCount = tracks.reduce(
|
|
87
|
+
(n, t) => n + splitClipElements(getItemsInner(t) || '').length, 0);
|
|
88
|
+
}
|
|
89
|
+
results.push({
|
|
90
|
+
kind,
|
|
91
|
+
name: tagValue(el, 'Name'),
|
|
92
|
+
sequenceDbId,
|
|
93
|
+
entry: found ? found.entry : null,
|
|
94
|
+
mediaPoolItemId: tagValue(el, 'UniqueMediaPoolItemId'),
|
|
95
|
+
videoTracks,
|
|
96
|
+
itemCount,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return results;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read the contents of one nested sequence by media pool name.
|
|
105
|
+
*
|
|
106
|
+
* @param {Buffer|string} drpInput
|
|
107
|
+
* @param {object} opts
|
|
108
|
+
* @param {string} opts.name - the compound / timeline name in the media pool.
|
|
109
|
+
* @returns {Promise<{kind:string, name:string, entry:string,
|
|
110
|
+
* tracks:Array<{index:number, items:Array<{type:string, name:string,
|
|
111
|
+
* start:number|null, duration:number|null, dbId:string|null}>}>}>}
|
|
112
|
+
*/
|
|
113
|
+
async function readNestedSequence(drpInput, opts = {}) {
|
|
114
|
+
const { name } = opts;
|
|
115
|
+
if (!name) throw new TypeError('readNestedSequence: name is required');
|
|
116
|
+
const zip = await loadDrpZip(drpInput);
|
|
117
|
+
const list = await listNestedSequences(drpInput);
|
|
118
|
+
const hit = list.find((e) => e.name === name);
|
|
119
|
+
if (!hit) throw new Error(`readNestedSequence: no compound or timeline named ${JSON.stringify(name)}`);
|
|
120
|
+
if (!hit.entry) throw new Error(`readNestedSequence: ${name} has no SeqContainer (sequence ${hit.sequenceDbId})`);
|
|
121
|
+
|
|
122
|
+
const xml = await zip.file(hit.entry).async('string');
|
|
123
|
+
const vec = xml.match(/<VideoTrackVec>([\s\S]*?)<\/VideoTrackVec>/);
|
|
124
|
+
const tracks = (vec ? splitTrackElements(vec[1]) : []).map((t, i) => ({
|
|
125
|
+
index: i + 1,
|
|
126
|
+
items: splitClipElements(getItemsInner(t) || '').map((c) => {
|
|
127
|
+
const typeMatch = c.match(/<(Sm2Ti[A-Za-z]+)\b/);
|
|
128
|
+
const idMatch = c.match(/<Sm2Ti[A-Za-z]+\s+DbId="([^"]+)"/);
|
|
129
|
+
const num = (tag) => {
|
|
130
|
+
const v = tagValue(c, tag);
|
|
131
|
+
return v == null ? null : parseInt(v, 10);
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
type: typeMatch ? typeMatch[1] : 'unknown',
|
|
135
|
+
name: tagValue(c, 'Name'),
|
|
136
|
+
start: num('Start'),
|
|
137
|
+
duration: num('Duration'),
|
|
138
|
+
dbId: idMatch ? idMatch[1] : null,
|
|
139
|
+
};
|
|
140
|
+
}),
|
|
141
|
+
}));
|
|
142
|
+
return { kind: hit.kind, name: hit.name, entry: hit.entry, tracks };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Locate the nested sequence's container entry + xml, or throw. */
|
|
146
|
+
async function resolveEntry(zip, drpInput, name) {
|
|
147
|
+
const list = await listNestedSequences(drpInput);
|
|
148
|
+
const hit = list.find((e) => e.name === name);
|
|
149
|
+
if (!hit) throw new Error(`no compound or timeline named ${JSON.stringify(name)}`);
|
|
150
|
+
if (!hit.entry) throw new Error(`${name} has no SeqContainer (sequence ${hit.sequenceDbId})`);
|
|
151
|
+
return { hit, xml: await zip.file(hit.entry).async('string') };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const COMPOSITION_BA = /<CompositionBA>([^<]*)<\/CompositionBA>/g;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Read the title inputs of every Fusion title inside a nested sequence.
|
|
158
|
+
*
|
|
159
|
+
* This is the operation the scripting API cannot perform on a compound at all:
|
|
160
|
+
* MediaPoolItem.GetTimeline() is None for Type='Compound', and the compounded
|
|
161
|
+
* item reports GetFusionCompCount()==0, so there is no route to the text.
|
|
162
|
+
*
|
|
163
|
+
* @param {Buffer|string} drpInput
|
|
164
|
+
* @param {object} opts
|
|
165
|
+
* @param {string} opts.name
|
|
166
|
+
* @returns {Promise<Array<{index:number, text:string, font:string, style:string,
|
|
167
|
+
* size:number, vJustify:number, hJustify:number}>>}
|
|
168
|
+
*/
|
|
169
|
+
async function readNestedTitles(drpInput, opts = {}) {
|
|
170
|
+
const { name } = opts;
|
|
171
|
+
if (!name) throw new TypeError('readNestedTitles: name is required');
|
|
172
|
+
const zip = await loadDrpZip(drpInput);
|
|
173
|
+
const { xml } = await resolveEntry(zip, drpInput, name);
|
|
174
|
+
const { decodeTitleInputs } = require('./composition-text');
|
|
175
|
+
const out = [];
|
|
176
|
+
let i = 0;
|
|
177
|
+
for (const m of xml.matchAll(COMPOSITION_BA)) {
|
|
178
|
+
out.push({ index: i++, ...decodeTitleInputs(m[1]) });
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Rewrite the text (and optionally font/style/size/justification) of a Fusion
|
|
185
|
+
* title INSIDE a compound or nested timeline.
|
|
186
|
+
*
|
|
187
|
+
* @param {Buffer|string} drpInput
|
|
188
|
+
* @param {object} opts
|
|
189
|
+
* @param {string} opts.name - compound / timeline name.
|
|
190
|
+
* @param {number} [opts.titleIndex=0] - which title inside, if several.
|
|
191
|
+
* @param {string} [opts.text] - new on-screen text (no double quotes).
|
|
192
|
+
* @param {string} [opts.font] @param {string} [opts.style] @param {number} [opts.size]
|
|
193
|
+
* @returns {Promise<{buffer:Buffer, entry:string, name:string, titleIndex:number,
|
|
194
|
+
* before:object, after:object}>}
|
|
195
|
+
*/
|
|
196
|
+
async function setNestedTitleText(drpInput, opts = {}) {
|
|
197
|
+
const { name, titleIndex = 0, ...inputs } = opts;
|
|
198
|
+
if (!name) throw new TypeError('setNestedTitleText: name is required');
|
|
199
|
+
if (typeof inputs.text === 'string' && inputs.text.includes('"')) {
|
|
200
|
+
throw new Error('setNestedTitleText: text must not contain double quotes');
|
|
201
|
+
}
|
|
202
|
+
const zip = await loadDrpZip(drpInput);
|
|
203
|
+
const { hit, xml } = await resolveEntry(zip, drpInput, name);
|
|
204
|
+
const { decodeTitleInputs, setTitleInputs } = require('./composition-text');
|
|
205
|
+
|
|
206
|
+
const blobs = [...xml.matchAll(COMPOSITION_BA)];
|
|
207
|
+
if (!blobs[titleIndex]) {
|
|
208
|
+
throw new Error(`setNestedTitleText: ${name} has ${blobs.length} title(s); no index ${titleIndex}`);
|
|
209
|
+
}
|
|
210
|
+
const target = blobs[titleIndex];
|
|
211
|
+
const before = decodeTitleInputs(target[1]);
|
|
212
|
+
const rewritten = setTitleInputs(target[1], inputs);
|
|
213
|
+
const after = decodeTitleInputs(rewritten);
|
|
214
|
+
|
|
215
|
+
// Splice by offset — the same hex can legitimately appear more than once.
|
|
216
|
+
const start = target.index;
|
|
217
|
+
const patched = xml.slice(0, start)
|
|
218
|
+
+ `<CompositionBA>${rewritten}</CompositionBA>`
|
|
219
|
+
+ xml.slice(start + target[0].length);
|
|
220
|
+
|
|
221
|
+
zip.file(hit.entry, patched);
|
|
222
|
+
const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
223
|
+
return { buffer, entry: hit.entry, name, titleIndex, before, after };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = {
|
|
227
|
+
listNestedSequences,
|
|
228
|
+
readNestedSequence,
|
|
229
|
+
readNestedTitles,
|
|
230
|
+
setNestedTitleText,
|
|
231
|
+
sequenceDbIdOf,
|
|
232
|
+
};
|
|
@@ -142,6 +142,13 @@ module.exports = {
|
|
|
142
142
|
trimClip: require('./splice-clips').trimClip,
|
|
143
143
|
trimClipHead: require('./splice-clips').trimClipHead,
|
|
144
144
|
splitClip: require('./splice-clips').splitClip,
|
|
145
|
+
// Nested sequences — compounds AND nested timelines (compound-nav.js). The
|
|
146
|
+
// scripting API cannot walk into a compound at all: MediaPoolItem.GetTimeline()
|
|
147
|
+
// returns None for Type='Compound' while working for Type='Timeline'.
|
|
148
|
+
listNestedSequences: require('./compound-nav').listNestedSequences,
|
|
149
|
+
readNestedSequence: require('./compound-nav').readNestedSequence,
|
|
150
|
+
readNestedTitles: require('./compound-nav').readNestedTitles,
|
|
151
|
+
setNestedTitleText: require('./compound-nav').setNestedTitleText,
|
|
145
152
|
rippleTimeline: require('./splice-clips').rippleTimeline,
|
|
146
153
|
|
|
147
154
|
// Create a fresh, importable Resolve project (one empty timeline) from a bundled template.
|
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.95.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
package/src/utils/api_truth.py
CHANGED
|
@@ -525,14 +525,32 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
525
525
|
"submit": "bug",
|
|
526
526
|
},
|
|
527
527
|
{
|
|
528
|
-
"symbol": "ProjectManager.CreateProject (
|
|
528
|
+
"symbol": "ProjectManager.CreateProject (blocked by the current project)",
|
|
529
529
|
"object": "ProjectManager",
|
|
530
530
|
"reality": "Returns None and pops a modal 'Save Current Project' dialog "
|
|
531
531
|
"when the current unsaved/Untitled project blocks the switch. "
|
|
532
|
-
"SaveProject() on an Untitled project re-triggers the same modal."
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
532
|
+
"SaveProject() on an Untitled project re-triggers the same modal. "
|
|
533
|
+
"BROADER THAN 'UNTITLED', measured on Studio 21.0.4.5 "
|
|
534
|
+
"(2026-08-12): with a NAMED project current (a conform project "
|
|
535
|
+
"left open on the Deliver page), CreateProject returned None for "
|
|
536
|
+
"a brand-new name and SaveProject returned False — with NO modal "
|
|
537
|
+
"on screen, confirmed by screenshot, so the dialog is not the "
|
|
538
|
+
"only mechanism. The connection was healthy throughout: "
|
|
539
|
+
"OpenPage('edit') returned True in the same session, and moving "
|
|
540
|
+
"to the Edit page did not help. Loading ANY clean project "
|
|
541
|
+
"immediately unblocked it — CreateProject succeeded on the very "
|
|
542
|
+
"next call. So the trigger is the current project, not the "
|
|
543
|
+
"'Untitled' name, and a bare False/None is the only signal.",
|
|
544
|
+
"recommended": "LoadProject(any clean project) first, then CreateProject; "
|
|
545
|
+
"restore the caller's project afterward. PREFER THAT OVER "
|
|
546
|
+
"CloseProject, which this entry used to recommend: "
|
|
547
|
+
"CloseProject DISCARDS unsaved changes, which is fine for a "
|
|
548
|
+
"throwaway Untitled project but destroys work when the "
|
|
549
|
+
"current project is a named one belonging to someone else's "
|
|
550
|
+
"session — and the failure is NOT limited to Untitled "
|
|
551
|
+
"projects, so that is a live risk. Never assume a bare None "
|
|
552
|
+
"here means the name is taken; check "
|
|
553
|
+
"GetProjectListInCurrentFolder().",
|
|
536
554
|
"tags": ["project", "modal", "silent-failure"],
|
|
537
555
|
"submit": "bug",
|
|
538
556
|
},
|
|
@@ -663,11 +681,41 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
663
681
|
{
|
|
664
682
|
"symbol": "Transition create / copy / clone",
|
|
665
683
|
"object": "Timeline / TimelineItem",
|
|
666
|
-
"reality": "
|
|
667
|
-
"
|
|
668
|
-
"
|
|
669
|
-
|
|
670
|
-
|
|
684
|
+
"reality": "There is no method to ADD or CLONE an edit transition — no "
|
|
685
|
+
"AddTransition/CreateTransition/AddVideoTransition on Timeline "
|
|
686
|
+
"or TimelineItem (dir(), 21.0.4.5). CORRECTION, measured on "
|
|
687
|
+
"Studio 21.0.4.5 (2026-08-12): this entry previously said "
|
|
688
|
+
"transitions applied in the UI are 'invisible to and "
|
|
689
|
+
"unmodifiable by scripts'. BOTH HALVES WERE WRONG and are "
|
|
690
|
+
"withdrawn. A transition IS a first-class timeline item: a "
|
|
691
|
+
"12-frame Cross Dissolve applied through the Edit-page "
|
|
692
|
+
"right-click menu enumerates in GetItemListInTrack('video', 1) "
|
|
693
|
+
"as GetName()=='Cross Dissolve', GetStart()==86426, "
|
|
694
|
+
"GetDuration()==12 — centered on a cut at 86432 — with a stable "
|
|
695
|
+
"GetUniqueId() and a working GetTrackTypeAndIndex(). A "
|
|
696
|
+
"transition authored offline into a .drp and imported reads "
|
|
697
|
+
"IDENTICALLY, so the route that created it does not matter. It "
|
|
698
|
+
"is also REMOVABLE: Timeline.DeleteClips([transition], False) "
|
|
699
|
+
"returns True and deletes it, leaving both adjacent clips at "
|
|
700
|
+
"their original starts and durations. THE DISCRIMINATOR between "
|
|
701
|
+
"a transition item and a clip item is GetProperty(): a "
|
|
702
|
+
"transition returns an EMPTY dict where a video clip returns 26 "
|
|
703
|
+
"transform keys; it also has no MediaPoolItem and no Fusion "
|
|
704
|
+
"comp. WHAT IS GENUINELY MISSING: creation, cloning, and any "
|
|
705
|
+
"type/alignment/parameter detail — the transition's kind is "
|
|
706
|
+
"knowable ONLY from its name string, and there is no way to "
|
|
707
|
+
"read its alignment (centered/start/end) or edit its duration.",
|
|
708
|
+
"recommended": "Automated QC of existing transitions IS possible and is "
|
|
709
|
+
"the main practical need — enumerate GetItemListInTrack, "
|
|
710
|
+
"treat any item whose GetProperty() is empty and whose "
|
|
711
|
+
"GetMediaPoolItem() is None as a transition, and read its "
|
|
712
|
+
"name, start and duration. Removal is scriptable via "
|
|
713
|
+
"Timeline.DeleteClips. To CREATE one, either apply it in the "
|
|
714
|
+
"Resolve UI, or author it offline and import: the advanced "
|
|
715
|
+
"server's drp place_transition writes a cross dissolve at an "
|
|
716
|
+
"abutting cut ({track, atFrame, durationFrames}) and it "
|
|
717
|
+
"round-trips into Resolve 21.0.4.5 reading back at the "
|
|
718
|
+
"expected centered range.",
|
|
671
719
|
"tags": ["missing-method", "timeline", "transition"],
|
|
672
720
|
"submit": "missing",
|
|
673
721
|
},
|