davinci-resolve-mcp 2.94.3 → 2.95.1
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 +78 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/reference/api-limitations.md +1 -1
- 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 +45 -4
- 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 +17 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,84 @@
|
|
|
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.1
|
|
6
|
+
|
|
7
|
+
Withdraws a recommendation this project shipped two releases ago. **`drp
|
|
8
|
+
place_fusion_title` produces a title Resolve never renders**, and v2.93.2/v2.93.3
|
|
9
|
+
told callers to use it.
|
|
10
|
+
|
|
11
|
+
### Fixed — the offline title is inert
|
|
12
|
+
|
|
13
|
+
Measured on Studio 21.0.4.5. The placed clip is structurally perfect: right
|
|
14
|
+
track, right frame, right duration, `PrettyType` = `Fusion Title`, and the text
|
|
15
|
+
genuinely is written into the `CompositionBA` — it decodes back. But Resolve
|
|
16
|
+
never instantiates the comp:
|
|
17
|
+
|
|
18
|
+
| | offline-placed | live-inserted |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| `GetFusionCompCount()` | 1 | 1 |
|
|
21
|
+
| `GetToolList()` | **`[]`** | `['Template','MediaOut1']` |
|
|
22
|
+
| Inspector Title tab | **blank** | shows `Text+` |
|
|
23
|
+
| Viewer | **black** | renders the text |
|
|
24
|
+
|
|
25
|
+
Reproduced four ways — alone, inside an edit chain, onto a bundled-template base
|
|
26
|
+
project, and onto a genuine 21.0.4.5 Resolve export. Ruled out: template
|
|
27
|
+
staleness (the bundled clip element is structurally identical to a real one, same
|
|
28
|
+
tags, size differing only by text length) and DbId rewriting (the comp blobs hold
|
|
29
|
+
no DbId references). Root cause open.
|
|
30
|
+
|
|
31
|
+
It passes every structural readback while being invisible on screen — the exact
|
|
32
|
+
silent-lie shape the `api_truth` ledger exists to catch. The **nested-timeline
|
|
33
|
+
route is unaffected** and remains the working answer.
|
|
34
|
+
|
|
35
|
+
### Narrowed — it is the Fusion-comp path, not clone-a-template
|
|
36
|
+
|
|
37
|
+
`place_generator`, built the same way, works: an imported Solid Color shows a
|
|
38
|
+
populated `Generator - Solid Color` Inspector with a live Color parameter. A
|
|
39
|
+
generator's parameters are a protobuf `EffectFiltersBA`, which Resolve reads; a
|
|
40
|
+
title's are a `CompositionBA` Fusion-comp blob, which it does not instantiate.
|
|
41
|
+
|
|
42
|
+
### Changed — "mapped" no longer implies "Resolve honours it"
|
|
43
|
+
|
|
44
|
+
The drp-format README's honest-scope section conflated file-level fidelity with
|
|
45
|
+
live behaviour. A blob that survives a write/read cycle has been *encoded*
|
|
46
|
+
correctly; only a live import proves it is *instantiated*. The section now says
|
|
47
|
+
so, and the Fusion-title row carries the warning.
|
|
48
|
+
|
|
49
|
+
## What's New in v2.95.0
|
|
50
|
+
|
|
51
|
+
Compound clips can now be walked into and edited offline — the one capability
|
|
52
|
+
where the offline tier beats the live scripting API outright.
|
|
53
|
+
|
|
54
|
+
### Added — `drp` nested-sequence actions
|
|
55
|
+
|
|
56
|
+
`list_nested`, `read_nested`, `read_nested_titles`, `set_nested_title_text`
|
|
57
|
+
(`resolve-advanced/vendor/drp-format/compound-nav.js`). Enumerate every compound
|
|
58
|
+
clip and nested timeline in a `.drp`, walk into either, and rewrite the title
|
|
59
|
+
text inside.
|
|
60
|
+
|
|
61
|
+
**A compound clip and a nested timeline are the same shape on disk.** Both appear
|
|
62
|
+
in `MediaPool/Master/MpFolder.xml` as a media-pool element (`Sm2MpCompoundClip` /
|
|
63
|
+
`Sm2MpTimelineClip`) carrying an inline `<Sequence><Sm2Sequence DbId="X">`, and
|
|
64
|
+
the `SeqContainer/<uuid>.xml` whose tracks carry `<Sequence>X</Sequence>` holds
|
|
65
|
+
the contents. The join is on that **Sm2Sequence DbId, not the container's own
|
|
66
|
+
DbId** — which is referenced by nothing else in the package. One asymmetry: a
|
|
67
|
+
compound's contents are rebased to `Start` 0 while a nested timeline's keep
|
|
68
|
+
timeline-absolute TC.
|
|
69
|
+
|
|
70
|
+
**Why this matters.** `MediaPoolItem.GetTimeline()` (21.0.4+) resolves through
|
|
71
|
+
the timeline handle: it returns the inner Timeline for `Type='Timeline'` and
|
|
72
|
+
**`None` for `Type='Compound'`**, and a compounded Text+ reports
|
|
73
|
+
`GetFusionCompCount() == 0`. So compounding a title severs its text permanently
|
|
74
|
+
as far as scripting is concerned — there is no API route back to it at any
|
|
75
|
+
version. Offline, the distinction does not exist.
|
|
76
|
+
|
|
77
|
+
Verified end to end on Studio 21.0.4.5: text rewritten offline inside a compound,
|
|
78
|
+
imported into Resolve, re-exported by Resolve, and read back **from Resolve's own
|
|
79
|
+
export** unchanged — so Resolve genuinely parses the write rather than tolerating
|
|
80
|
+
it. Ten unit tests against a committed fixture exported from 21.0.4.5 holding a
|
|
81
|
+
compound-of-media, a compound-of-Text+, and a nested timeline.
|
|
82
|
+
|
|
5
83
|
## What's New in v2.94.3
|
|
6
84
|
|
|
7
85
|
A systematic sweep of catalogued API gaps against **Studio 21.0.4.5**, prompted
|
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.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
|
@@ -67,7 +67,7 @@ equivalent, blocking full automation.
|
|
|
67
67
|
|
|
68
68
|
- **Object:** `Timeline`
|
|
69
69
|
- **Behavior:** There is no API to read or set the Source/Auto Track Selector (the Edit-page patch panel that picks the destination track). InsertTitleIntoTimeline, InsertFusionTitleIntoTimeline, InsertGeneratorIntoTimeline, InsertFusionGeneratorIntoTimeline, InsertOFXGeneratorIntoTimeline and InsertFusionCompositionIntoTimeline take no trackIndex and always drop the clip on the selector's current target (V1 in practice). Locking lower video tracks does NOT redirect the insert — verified live on 21.0.0: locking V1 makes the insert FAIL rather than land on V2. Titles/generators also can't be moved afterward (no MediaPoolItem, so AppendToTimeline clipInfo and MoveClips don't apply). RE-MEASURED on Studio 21.0.4.5 (2026-08-12, version read live), one fresh 3-video-track timeline per arm so no insert could fail on a collision: nothing locked -> lands V1; V1 locked via SetTrackLock -> returns None; V1 locked by CLICKING THE PADLOCK IN THE UI -> returns None, IDENTICAL to the API lock. That kills the recurring theory that the GUI lock advances the selector where the API lock does not — it does not, and a revision of resolve-advanced/vendor/drp-format/README.md that claimed otherwise as 'verified live' has been withdrawn as false. WHAT DOES WORK, and it is the whole mechanism: dragging the SOURCE PATCH badge onto V2 in the Edit-page patch panel sends the next insert to V2 (measured, same rig, nothing locked). The destination is the patch panel, never the lock state. In the track header the per-track badge column is the auto-track-selector toggle, while the source patch badge appears only on the patched track and dragging THAT is what re-targets. So THE SELECTOR itself is reachable only by GUI automation, and exposing read/write on the patch panel is a smaller, better-defined API request than adding trackIndex to all six Insert*IntoTimeline methods. Do not read that as 'a title cannot be placed on a chosen track from a script' — it can, without touching the selector at all, by nesting it (see the recommendation). The gap is in these six methods and in selector access, not in the outcome.
|
|
70
|
-
- **Workaround / current handling:** USE THE NESTED-TIMELINE ROUTE — the insert method is a dead end but the goal is not, and this is entirely public API. Measured end to end on Studio 21.0.4.5 (2026-08-12): put the title on its OWN timeline (CreateEmptyTimeline + InsertFusionTitleIntoTimeline; it lands on V1 there and that does not matter), then place THAT timeline's media pool item — Timeline.GetMediaPoolItem(), clip property Type='Timeline' — with AppendToTimeline's clipInfo trackIndex/recordFrame. It lands on the requested track at the requested frame, exactly. The text stays settable afterwards: placedItem.GetMediaPoolItem().GetTimeline() (Resolve 21.0.4+) opens the inner timeline, and the Text+ item there still reports GetFusionCompCount()==1, so GetFusionCompByIndex(1).FindTool('Template').SetInput('StyledText', ...) works and persists across processes. Duration is controllable the same way: duration = endFrame - startFrame, endFrame EXCLUSIVE (verified at 1, 119 and 120 frames). This composes — a PNG plus two titles each placed by trackIndex into one container timeline, and the container placed as a single clip, all elements individually reachable and editable through the nesting. TWO CONSTRAINTS: (a) every placed instance shares ONE media pool item, so a text edit propagates to all of them — use one source timeline per distinct card; (b) placements must not overlap on a track, or the append is silently rejected (see the overlapping-record entry). COMPOUND CLIPS ARE THE TRAP HERE: Timeline.CreateCompoundClip also gives a source-less title a MediaPoolItem and also places correctly, but it SEVERS the text — FusionCompCount drops 1->0 and GetTimeline() returns None for Type='Compound' while working correctly for Type='Timeline', so there is no route back to the Text+. Use a nested timeline, never a compound, when the text must stay editable. For clips that already have a MediaPoolItem, AppendToTimeline clipInfo 'trackIndex' was always the answer (exposed as media_pool append_to_timeline clip_infos). Do NOT reach for track locking in either form — it blocks the insert rather than re-targeting it. GUI automation of the patch panel works but is a last resort and is unverifiable from the API side; if used, read the landing track back with TimelineItem.GetTrackTypeAndIndex() and treat a wrong track as a failure.
|
|
70
|
+
- **Workaround / current handling:** USE THE NESTED-TIMELINE ROUTE — the insert method is a dead end but the goal is not, and this is entirely public API. Measured end to end on Studio 21.0.4.5 (2026-08-12): put the title on its OWN timeline (CreateEmptyTimeline + InsertFusionTitleIntoTimeline; it lands on V1 there and that does not matter), then place THAT timeline's media pool item — Timeline.GetMediaPoolItem(), clip property Type='Timeline' — with AppendToTimeline's clipInfo trackIndex/recordFrame. It lands on the requested track at the requested frame, exactly. The text stays settable afterwards: placedItem.GetMediaPoolItem().GetTimeline() (Resolve 21.0.4+) opens the inner timeline, and the Text+ item there still reports GetFusionCompCount()==1, so GetFusionCompByIndex(1).FindTool('Template').SetInput('StyledText', ...) works and persists across processes. Duration is controllable the same way: duration = endFrame - startFrame, endFrame EXCLUSIVE (verified at 1, 119 and 120 frames). This composes — a PNG plus two titles each placed by trackIndex into one container timeline, and the container placed as a single clip, all elements individually reachable and editable through the nesting. TWO CONSTRAINTS: (a) every placed instance shares ONE media pool item, so a text edit propagates to all of them — use one source timeline per distinct card; (b) placements must not overlap on a track, or the append is silently rejected (see the overlapping-record entry). COMPOUND CLIPS ARE THE TRAP HERE: Timeline.CreateCompoundClip also gives a source-less title a MediaPoolItem and also places correctly, but it SEVERS the text — FusionCompCount drops 1->0 and GetTimeline() returns None for Type='Compound' while working correctly for Type='Timeline', so there is no route back to the Text+. Use a nested timeline, never a compound, when the text must stay editable. For clips that already have a MediaPoolItem, AppendToTimeline clipInfo 'trackIndex' was always the answer (exposed as media_pool append_to_timeline clip_infos). Do NOT reach for track locking in either form — it blocks the insert rather than re-targeting it. GUI automation of the patch panel works but is a last resort and is unverifiable from the API side; if used, read the landing track back with TimelineItem.GetTrackTypeAndIndex() and treat a wrong track as a failure. DO NOT REACH FOR drp place_fusion_title HERE — an earlier revision of this entry recommended it and that recommendation is WITHDRAWN. Measured on Studio 21.0.4.5: it places a Fusion Title that is structurally perfect — right track, right frame, right duration, PrettyType 'Fusion Title', and the text really is written into the CompositionBA, which decodes back correctly — but Resolve never instantiates the comp. The imported item reports GetFusionCompCount()==1 while GetFusionCompByIndex(1).GetToolList() is EMPTY (a live-inserted Text+ returns ['Template','MediaOut1']), the Edit-page Inspector's Title tab is blank, and the viewer renders nothing. Reproduced four ways: placed alone, placed in an edit chain, onto a bundled-template base project, and onto a genuine 21.0.4.5 Resolve export. It therefore passes every structural readback while being invisible on screen — the exact silent-lie shape this ledger exists to catch. The nested-timeline route above is the one that actually works. See issue #74.
|
|
71
71
|
- **Reference:** [issue #74](https://github.com/samuelgursky/davinci-resolve-mcp/issues/74)
|
|
72
72
|
- **Tags:** missing-method, timeline, title, generator, track
|
|
73
73
|
|
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.1"
|
|
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 });
|
|
@@ -37,7 +37,7 @@ and `.../resolve-authoring-completion.md`.
|
|
|
37
37
|
**Place elements (track-targeted — the #74 bypass, offline)**
|
|
38
38
|
| Action | What it does |
|
|
39
39
|
|---|---|
|
|
40
|
-
| `place_fusion_title` | Text+ on a chosen track. Options: `text, font, style, size, vJustify, hJustify, color:{r,g,b}`, `trackIndex`, `startFrame`, `durationFrames` |
|
|
40
|
+
| `place_fusion_title` | ⚠️ **INERT on 21.0.4.5 — placed but not rendered, see below.** Text+ on a chosen track. Options: `text, font, style, size, vJustify, hJustify, color:{r,g,b}`, `trackIndex`, `startFrame`, `durationFrames` |
|
|
41
41
|
| `place_generator` | built-in generator (`generatorName`, e.g. "Solid Color") on a chosen track |
|
|
42
42
|
| `place_transition` | cross-dissolve at an abutting cut (`track`, `atFrame`, `durationFrames`) — clips need handle media |
|
|
43
43
|
|
|
@@ -117,16 +117,40 @@ Selector's current target (V1 in practice), which no API can read or set.
|
|
|
117
117
|
> locking. Treat it as such: it is not verifiable from the API side, since nothing in the
|
|
118
118
|
> scripting surface can confirm the selector landed where you dragged it.
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
> ⚠️ **`place_fusion_title` currently produces an INERT title — do not use it for this.**
|
|
121
|
+
> Measured on Studio 21.0.4.5: the placed clip is structurally perfect (right track, frame and
|
|
122
|
+
> duration, `PrettyType` = `Fusion Title`, and the text really is written into the
|
|
123
|
+
> `CompositionBA` — it decodes back), but **Resolve never instantiates the comp**. The imported
|
|
124
|
+
> item reports `GetFusionCompCount() == 1` while `GetFusionCompByIndex(1).GetToolList()` is
|
|
125
|
+
> **empty** (a live-inserted Text+ returns `['Template','MediaOut1']`), the Inspector's Title tab
|
|
126
|
+
> is blank, and the viewer renders nothing. Reproduced four ways: alone, in an edit chain, onto a
|
|
127
|
+
> bundled-template base, and onto a genuine 21.0.4.5 export. Ruled out so far — template staleness
|
|
128
|
+
> (the bundled clip element is structurally identical to a real one: same tags, size differing
|
|
129
|
+
> only by text length) and DbId rewriting (the comp blobs contain no DbId references). Root cause
|
|
130
|
+
> open. Use the live nested-timeline route until this is fixed.
|
|
131
|
+
>
|
|
132
|
+
> **The defect is specific to the Fusion-comp path, not to clone-a-template.** `place_generator`,
|
|
133
|
+
> built the same way, works: an imported Solid Color shows a populated `Generator - Solid Color`
|
|
134
|
+
> Inspector with a live Color parameter and renders on the timeline. The difference is where the
|
|
135
|
+
> parameters live — a generator's are a protobuf `EffectFiltersBA`, which Resolve reads happily,
|
|
136
|
+
> while a title's are a `CompositionBA` Fusion-comp blob, which it does not instantiate. That is
|
|
137
|
+
> the narrowest place to start looking.
|
|
122
138
|
|
|
123
139
|
---
|
|
124
140
|
|
|
125
141
|
## What's mapped (honest scope)
|
|
126
142
|
|
|
143
|
+
> **Read "mapped" as file-level fidelity, not as "Resolve honours it."** Those are different
|
|
144
|
+
> claims and this README conflated them. `place_fusion_title` round-trips byte-for-byte and its
|
|
145
|
+
> text decodes back correctly, yet Resolve builds no comp from it (see the ⚠️ above). A blob that
|
|
146
|
+
> survives a write/read cycle has been *encoded* correctly; only a live import proves it is
|
|
147
|
+
> *instantiated*. Everything below is the first claim. The second is only established where an
|
|
148
|
+
> entry says it was measured against a running Resolve build.
|
|
149
|
+
|
|
127
150
|
**Fully mapped (read + write, round-trips byte-for-byte):** project packaging; tracks
|
|
128
151
|
(`Sm2TiTrack`+Type+Sequence); clips; source in-points (`<In>`); Fusion titles
|
|
129
|
-
(text/font/style/size/justify/color
|
|
152
|
+
(text/font/style/size/justify/color — **encoded correctly but not instantiated by Resolve 21.0.4.5;
|
|
153
|
+
see the ⚠️ above**); generators; transitions; markers; grades (full DRX body);
|
|
130
154
|
media path + resolution + frame-count + fps. **Plus the full Media-Pool metadata layer:** the
|
|
131
155
|
keyed-dict format (`Geometry`/`Time`/`VideoMetadata`/`Proxy`/audio/small `FieldsBlob`) with typed
|
|
132
156
|
values (`keyed-dict.js`); audio config (`TracksBA`/`VirtualAudioTrackBA` → sample-rate/channels/codec
|
|
@@ -141,4 +165,21 @@ internal-state/cache `FieldsBlob`s (`classifyBlob` flags them; Node has no built
|
|
|
141
165
|
protobuf blobs' *field names* (wire structure is decoded; names need Resolve's private `.proto`); the
|
|
142
166
|
Fusion comp beyond the title inputs we edit.
|
|
143
167
|
|
|
168
|
+
**Nested sequences — compounds AND nested timelines (`compound-nav.js`):** enumerate, walk
|
|
169
|
+
into, and rewrite title text inside either. **A compound clip and a nested timeline are the same
|
|
170
|
+
shape on disk:** both appear in `MediaPool/Master/MpFolder.xml` as a media-pool element
|
|
171
|
+
(`Sm2MpCompoundClip` / `Sm2MpTimelineClip`) carrying an inline `<Sequence><Sm2Sequence DbId="X">`,
|
|
172
|
+
and the `SeqContainer/<uuid>.xml` whose tracks carry `<Sequence>X</Sequence>` holds the contents.
|
|
173
|
+
The join is on that **Sm2Sequence DbId, not the container's own DbId** — the container id is
|
|
174
|
+
referenced by nothing else in the package. One asymmetry worth knowing: a compound's contents are
|
|
175
|
+
rebased to `Start` 0, while a nested timeline's keep timeline-absolute TC.
|
|
176
|
+
|
|
177
|
+
> This is the one place the offline tier beats the live API outright. `MediaPoolItem.GetTimeline()`
|
|
178
|
+
> (21.0.4+) resolves through the timeline handle: it returns the inner Timeline for
|
|
179
|
+
> `Type='Timeline'` and **`None` for `Type='Compound'`**, and a compounded Text+ reports
|
|
180
|
+
> `GetFusionCompCount() == 0`. So compounding a title severs its text permanently as far as
|
|
181
|
+
> scripting is concerned. Offline the distinction does not exist. Verified end to end on Studio
|
|
182
|
+
> 21.0.4.5: text rewritten offline inside a compound, imported, re-exported by Resolve, and read
|
|
183
|
+
> back **from Resolve's own export** unchanged.
|
|
184
|
+
|
|
144
185
|
**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.1"
|
|
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
|
@@ -643,9 +643,23 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
643
643
|
"works but is a last resort and is unverifiable from the API "
|
|
644
644
|
"side; if used, read the landing track back with "
|
|
645
645
|
"TimelineItem.GetTrackTypeAndIndex() and treat a wrong track as "
|
|
646
|
-
"a failure.
|
|
647
|
-
"
|
|
648
|
-
"
|
|
646
|
+
"a failure. DO NOT REACH FOR drp place_fusion_title HERE — an "
|
|
647
|
+
"earlier revision of this entry recommended it and that "
|
|
648
|
+
"recommendation is WITHDRAWN. Measured on Studio 21.0.4.5: it "
|
|
649
|
+
"places a Fusion Title that is structurally perfect — right "
|
|
650
|
+
"track, right frame, right duration, PrettyType 'Fusion Title', "
|
|
651
|
+
"and the text really is written into the CompositionBA, which "
|
|
652
|
+
"decodes back correctly — but Resolve never instantiates the "
|
|
653
|
+
"comp. The imported item reports GetFusionCompCount()==1 while "
|
|
654
|
+
"GetFusionCompByIndex(1).GetToolList() is EMPTY (a live-inserted "
|
|
655
|
+
"Text+ returns ['Template','MediaOut1']), the Edit-page "
|
|
656
|
+
"Inspector's Title tab is blank, and the viewer renders nothing. "
|
|
657
|
+
"Reproduced four ways: placed alone, placed in an edit chain, "
|
|
658
|
+
"onto a bundled-template base project, and onto a genuine "
|
|
659
|
+
"21.0.4.5 Resolve export. It therefore passes every structural "
|
|
660
|
+
"readback while being invisible on screen — the exact silent-lie "
|
|
661
|
+
"shape this ledger exists to catch. The nested-timeline route "
|
|
662
|
+
"above is the one that actually works. See issue #74.",
|
|
649
663
|
"tags": ["missing-method", "timeline", "title", "generator", "track"],
|
|
650
664
|
"submit": "missing",
|
|
651
665
|
"issue": 74,
|