davinci-resolve-mcp 4.17.1 → 4.18.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 CHANGED
@@ -2,6 +2,87 @@
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 v4.18.1 — Names with `$` or `\` are written as typed
6
+
7
+ ### Fixed
8
+
9
+ - **`drt assemble_project` no longer rewrites a merged timeline's name when it
10
+ contains `$`** (#332, thanks @Dev-next-gen). The name was inserted with a
11
+ replacement string, and JavaScript expands `$&`, `$'` and `$$` in one. A
12
+ second timeline named `R2 $& $'` wrote an `MpFolder.xml` that no longer
13
+ parsed, and `Spot $$ 30s` came out as `Spot $ 30s`, so a `folder` move then
14
+ failed to find the timeline.
15
+ - **The same fix in 53 more places in the offline `.drp`/`.drt` writers.**
16
+ Caption text in `placeSubtitles`, generator, title and compound clip names,
17
+ media pool clip names and file paths, and every other value inserted into
18
+ the XML now goes in with a replacer function, so `$`-sequences stay literal.
19
+ A new guard test fails the advanced suite if a `replace()` call passes an
20
+ interpolated template as a string.
21
+ - **The audio-only fallback clone in `cut-media` now XML-escapes the source
22
+ file name and path.** A source path containing `&`, such as
23
+ `Smith & Jones/clip.mov`, wrote invalid XML.
24
+ - **Re-running media analysis on a clip no longer fails when the new analysis
25
+ text contains a backslash.** The managed block in the clip's metadata was
26
+ replaced with `re.sub` using the text as a replacement string, so a Windows
27
+ path raised `bad escape \F` and `\t` turned into a tab. The text is now
28
+ inserted as-is.
29
+
30
+ ### Validation
31
+
32
+ - Advanced Node suite on Node 20 with a fresh `npm ci`: 1,224 tests, 0 failed.
33
+ With the fixes reverted, the guard and the caption, generator and title tests
34
+ fail.
35
+ - Python suite: the new metadata test fails with `bad escape \F` without the
36
+ fix and passes with it.
37
+ - Offline file writers only. No Resolve API behaviour changed.
38
+
39
+ ## What's New in v4.18.0 — Edit captions with Resolve open
40
+
41
+ ### Added
42
+
43
+ - **`timeline_ai.list_captions_live` and `timeline_ai.write_captions_live`**
44
+ (#330, thanks @sidevconcept). Caption text and per-word timing can now be
45
+ edited without quitting Resolve or patching the open Project.db.
46
+ `write_captions_live` takes the same `replace` / `add` / `delete` shape as
47
+ the offline `project_db` `write_captions`. It exports the timeline as a
48
+ native DRT, applies the edit with the same caption codec and edit checks,
49
+ and imports the result as a new timeline revision. The revision is selected
50
+ only after Resolve's own re-export of it keeps the requested text, caption
51
+ bounds and word frames. If any step fails, the original timeline stays
52
+ selected and unchanged.
53
+ - A caption `id` is its start frame as a string, because Resolve issues new
54
+ item IDs on every export. The write returns the IDs to use for the next
55
+ edit, including for captions it added or moved. Edits on a track where two
56
+ captions share a start frame are refused as ambiguous.
57
+ - `write_captions_live` is registered as a destructive action with a native
58
+ dry run (medium risk, recoverable). `list_captions_live` is read-only.
59
+
60
+ ### Changed
61
+
62
+ - `set_subtitle_preset` now shares its export → edit → import → re-export
63
+ steps with the caption writer. Its behaviour and result fields are the same.
64
+
65
+ ### Limits
66
+
67
+ - Word times keep the existing codec's 60 Hz tick quantisation.
68
+ - Adding to an empty subtitle track needs an existing caption as a template.
69
+ Per-caption Fusion compositions are refused.
70
+ - The structural readback does not check every grade, marker, Fusion
71
+ composition or audio property carried through the native timeline import.
72
+
73
+ ### Validation
74
+
75
+ - Python suite with the PR merged on `main`: 4,294 tests. The only failures
76
+ are the 4 `source_media_guard` path checks that always fail in a scratch
77
+ worktree.
78
+ - Advanced Node suite on Node 20 with a fresh `npm ci`: 1,218 tests, 1,157
79
+ passed, 0 failed, 61 skipped.
80
+ - Live-validated by the contributor on Studio 21.1.0.17 with a disposable
81
+ project copy: three captions rewritten (one containing `$`), a moved
82
+ caption's ID reused, an added caption deleted by its returned ID, and a
83
+ burned-in render checked across nine word phases. Not live-validated on the
84
+ maintainer's Studio 19.1.3.7.
85
+
5
86
  ## What's New in v4.17.1 — Media analysis decodes each file once
6
87
 
7
88
  ### Changed
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-4.17.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.18.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(390%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-4.17.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.18.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(390%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v4.17.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.18.1 版 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
@@ -459,6 +459,15 @@ Operating rules an agent must know:
459
459
  Ordinary title presets use `timeline.insert_fusion_title` with the full ID,
460
460
  then scoped `fusion_comp` probes and input writes. See
461
461
  [title library automation](guides/title-library-automation.md) for acceptance.
462
+ - **Live caption text and word timing**: `timeline_ai.list_captions_live` reads
463
+ one subtitle track's captions from a native DRT export, with Resolve open.
464
+ `timeline_ai.write_captions_live` takes the same `replace` / `add` / `delete`
465
+ shape as `project_db` `write_captions` and imports the result as a selected
466
+ revision. A caption `id` is its start frame as a string, because Resolve
467
+ issues new item IDs on every export; use the IDs the write returns for the
468
+ next edit. Resolve's re-export must keep the text, bounds and word frames,
469
+ or the original stays selected. Adding to an empty track needs an existing
470
+ caption as a template; per-caption Fusion compositions are refused.
462
471
  - **Offline subtitle DB edits**: `project_db` `list_captions` / `write_captions` /
463
472
  `check_captions` cover text, bounds, explicit per-word timing, add and delete.
464
473
  `list_subtitle_presets` / `copy_subtitle_preset` / `set_subtitle_preset` handle
@@ -1964,7 +1973,8 @@ against it, and image content fails that validation.
1964
1973
 
1965
1974
  Key actions: `create_subtitles(settings?)` (human-readable settings resolved to
1966
1975
  `SUBTITLE_*`/`AUTO_CAPTION_*` enums + read-back verified — see
1967
- `subtitle_generation_probe`), `detect_scene_cuts`,
1976
+ `subtitle_generation_probe`), `list_captions_live`, `write_captions_live`,
1977
+ `detect_scene_cuts`,
1968
1978
  `grab_still`, `grab_all_stills(source?)`, `analyze_dolby_vision`
1969
1979
 
1970
1980
  ---
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "4.17.1"
40
+ VERSION = "4.18.1"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "4.17.1",
3
+ "version": "4.18.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -155,19 +155,20 @@ function removeItem(db, row) {
155
155
  db.prepare('DELETE FROM Sm2TiItem_Sm2TiTrack WHERE DbAssociate=?').run(row.Sm2TiItem_id);
156
156
  db.prepare('DELETE FROM Sm2TiItem WHERE Sm2TiItem_id=?').run(row.Sm2TiItem_id);
157
157
  }
158
- function captionEdit(db, p, mutate) {
159
- const tr = selectedTrack(db, p), current = captions(db, tr);
158
+ /** Validate and stage caption edits on decoded captions ({decoded, blob}); shared by Project.db and DRT writers. */
159
+ export function planCaptionEdit(current, p, fps, checkBase = () => {}) {
160
160
  const byId = new Map(current.map((c) => [c.decoded.id, c]));
161
+ if (byId.size !== current.length) throw new Error('duplicate caption ids on selected track; refusing ambiguous edit');
161
162
  const ids = [...p.delete, ...p.replace.map((c) => c.id)];
162
163
  if (!p.add.length && !ids.length) throw new Error('no caption changes requested');
163
164
  if (new Set(ids).size !== ids.length) throw new Error('duplicate/conflicting caption ids');
164
165
  for (const id of ids) {
165
166
  if (!byId.has(id)) throw new Error(`caption ${id} is not on the selected track`);
166
- exclusive(db, byId.get(id).row);
167
+ checkBase(byId.get(id));
167
168
  }
168
169
  const template = p.templateCaptionId ? byId.get(p.templateCaptionId) : current[0];
169
170
  if (p.add.length && !template) throw new Error('adding captions requires an existing caption on this track as a schema template');
170
- if (p.add.length) exclusive(db, template.row);
171
+ if (p.add.length) checkBase(template);
171
172
  const staged = [];
172
173
  for (const [fresh, list] of [[false, p.replace], [true, p.add]]) {
173
174
  for (const c of list) {
@@ -175,25 +176,35 @@ function captionEdit(db, p, mutate) {
175
176
  throw new Error('caption text must equal the words joined with spaces; words must be trimmed');
176
177
  }
177
178
  const base = fresh ? template : byId.get(c.id);
178
- if (base.row.CompositionTable) throw new Error('per-caption Fusion compositions are not supported');
179
- const blob = encodeCaption(base.row.FieldsBlob, c, tr.fps, { fresh });
179
+ const blob = encodeCaption(base.blob, c, fps, { fresh });
180
180
  const id = fresh ? randomUUID() : c.id;
181
- const decoded = { id, ...decodeCaption(blob, { name: c.text, start: c.start, end: c.end, fps: tr.fps }) };
182
- const qc = checkCaptions([decoded], { fps: tr.fps });
181
+ const decoded = { id, ...decodeCaption(blob, { name: c.text, start: c.start, end: c.end, fps }) };
182
+ const qc = checkCaptions([decoded], { fps });
183
183
  // Tick quantisation can otherwise turn a positive input word into a blank.
184
184
  // Half-tick boundary rounding is accepted; actual internal gaps are not.
185
185
  const errors = qc.issues.filter((r) => r.severity !== 'warning');
186
186
  if (errors.length) throw new Error(`invalid caption timing: ${JSON.stringify(errors)}`);
187
- const row = { ...base.row, Sm2TiItem_id: id, Name: ` ${c.text}`, Start: String(c.start),
188
- Duration: String(c.end - c.start), FieldsBlob: blob };
189
- if (fresh) for (const key of ['MarkersBA', 'RenderCacheBA', 'ImportExportMetadataBA']) if (key in row) row[key] = null;
190
- staged.push({ fresh, row, decoded });
187
+ staged.push({ fresh, base, caption: c, blob, decoded });
191
188
  }
192
189
  }
193
190
  const removed = new Set(ids);
194
191
  const final = [...current.filter((c) => !removed.has(c.decoded.id)).map((c) => c.decoded), ...staged.map((c) => c.decoded)]
195
192
  .sort((a, b) => a.start - b.start || a.id.localeCompare(b.id));
196
193
  for (let i = 1; i < final.length; i++) if (final[i].start < final[i - 1].end) throw new Error('caption edits would leave overlapping captions');
194
+ return { byId, template, staged, final };
195
+ }
196
+ function captionEdit(db, p, mutate) {
197
+ const tr = selectedTrack(db, p);
198
+ const plan = planCaptionEdit(captions(db, tr).map((c) => ({ ...c, blob: c.row.FieldsBlob })), p, tr.fps,
199
+ (c) => exclusive(db, c.row));
200
+ const { byId, template, final } = plan;
201
+ const staged = plan.staged.map(({ fresh, base, caption: c, blob, decoded }) => {
202
+ if (base.row.CompositionTable) throw new Error('per-caption Fusion compositions are not supported');
203
+ const row = { ...base.row, Sm2TiItem_id: decoded.id, Name: ` ${c.text}`, Start: String(c.start),
204
+ Duration: String(c.end - c.start), FieldsBlob: blob };
205
+ if (fresh) for (const key of ['MarkersBA', 'RenderCacheBA', 'ImportExportMetadataBA']) if (key in row) row[key] = null;
206
+ return { fresh, row, decoded };
207
+ });
197
208
  if (mutate) {
198
209
  for (const id of p.delete) removeItem(db, byId.get(id).row);
199
210
  for (const c of staged) {
@@ -3,9 +3,10 @@ import fs from 'node:fs/promises';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { createRequire } from 'node:module';
5
5
  import JSZip from 'jszip';
6
- import { subtitleSchemas } from './subtitle-db.mjs';
6
+ import { subtitleSchemas, planCaptionEdit } from './subtitle-db.mjs';
7
7
  const require = createRequire(import.meta.url);
8
8
  const { decodePreset, setPresetInputs } = require('../vendor/drp-format/subtitle-preset.js');
9
+ const { decodeCaption } = require('../vendor/drp-format/subtitle-captions.js');
9
10
  const REFERENCES = { 'Word Highlight': 'word-highlight-track.xml', Lollipop: 'lollipop-track.xml' };
10
11
 
11
12
  async function readTimeline(path) {
@@ -81,3 +82,78 @@ export async function editSubtitleTimeline({ source, output, track = 1, inputs =
81
82
  editableInputs: decoded.editableInputs,
82
83
  verification: 'exported_preset_readback', restart_required: false };
83
84
  }
85
+
86
+ // Caption items: same FieldsBlob codec as Project.db write_captions, but on the exported DRT.
87
+ const xmlEscape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
88
+ const xmlUnescape = (s) => s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
89
+ const tag = (xml, name) => {
90
+ const m = new RegExp(`<${name}>([^<]*)</${name}>|<${name}/>`).exec(xml);
91
+ if (!m) throw new Error(`caption item missing ${name}`);
92
+ return m[1] ?? '';
93
+ };
94
+ const setTag = (xml, name, value) => xml.replace(new RegExp(`<${name}>[^<]*</${name}>|<${name}/>`), () => `<${name}>${value}</${name}>`);
95
+ async function sequenceFps(zip, track) {
96
+ const sequence = /<Sequence>([0-9a-f-]{36})<\/Sequence>/i.exec(track)?.[1];
97
+ if (!sequence) throw new Error('subtitle track has no sequence reference');
98
+ for (const name of Object.keys(zip.files).filter((n) => n.endsWith('.xml'))) {
99
+ const xml = await zip.file(name).async('string'), at = xml.indexOf(`<Sm2Sequence DbId="${sequence}">`);
100
+ if (at < 0) continue;
101
+ const hex = /<FrameRate>([0-9a-fA-F]{32})<\/FrameRate>/.exec(xml.slice(at, xml.indexOf('</Sm2Sequence>', at)))?.[1];
102
+ const fps = hex ? Buffer.from(hex, 'hex').readDoubleLE(0) : NaN;
103
+ if (!Number.isFinite(fps) || fps < 1 || fps > 240) throw new Error('unsupported timeline FrameRate encoding');
104
+ return fps;
105
+ }
106
+ throw new Error('timeline sequence not found in export');
107
+ }
108
+ function captionItems(track, fps) {
109
+ const m = /<Items>([\s\S]*?)<\/Items>|<Items\/>/.exec(track);
110
+ if (!m) throw new Error('Unsupported track: missing Items field');
111
+ const inner = m[1] ?? '';
112
+ const elements = [...inner.matchAll(/<Element>\s*<Sm2TiGenerator DbId="([0-9a-f-]{36})">[\s\S]*?<\/Sm2TiGenerator>\s*<\/Element>/gi)];
113
+ // Anything besides plain subtitle generators would be dropped by the rebuild below.
114
+ if (elements.reduce((rest, e) => rest.replace(e[0], ''), inner).trim()) throw new Error('unsupported item on subtitle track');
115
+ // Resolve issues fresh DbIds on every export, so a caption's id is its start frame
116
+ // (captions on a track never overlap); DbIds are only carried in the XML.
117
+ const items = elements.map(([text, dbId]) => {
118
+ if (tag(text, 'PrettyType') !== 'Subtitle') throw new Error('unsupported item on subtitle track');
119
+ if (/<CompositionTable>/.test(text)) throw new Error('per-caption Fusion compositions are not supported');
120
+ const start = Number(tag(text, 'Start')), duration = Number(tag(text, 'Duration'));
121
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(duration)) throw new Error('invalid caption frame columns');
122
+ const blob = Buffer.from(tag(text, 'FieldsBlob'), 'hex');
123
+ return { text, dbId, blob, decoded: { id: String(start), ...decodeCaption(blob, { name: xmlUnescape(tag(text, 'Name')), start, end: start + duration, fps }) } };
124
+ });
125
+ return { items, field: m, separator: /^\s*/.exec(inner)[0] || '\n' };
126
+ }
127
+ async function readCaptions(source, track) {
128
+ const exported = await readTimeline(source), target = selectedTrack(exported.xml, track);
129
+ const fps = await sequenceFps(exported.zip, target.text);
130
+ return { exported, target, fps, ...captionItems(target.text, fps) };
131
+ }
132
+ export async function inspectCaptionTimeline({ source, track = 1 }) {
133
+ const { fps, items } = await readCaptions(source, track);
134
+ return { track, fps, timeUnit: 'timeline_frames', captions: items.map((c) => c.decoded) };
135
+ }
136
+ export async function editCaptionTimeline({ source, output, track = 1, add = [], replace = [], delete: remove = [], templateCaptionId }) {
137
+ const p = subtitleSchemas.write_captions.parse({ timeline: 'export', track, add, replace, delete: remove, templateCaptionId });
138
+ if (source === output) throw new Error('Output must be a new file');
139
+ const { exported, target, fps, items, field, separator } = await readCaptions(source, track);
140
+ const { byId, template, staged, final } = planCaptionEdit(items, p, fps);
141
+ const stagedById = new Map(staged.map((s) => [s.decoded.id, s]));
142
+ const elements = final.map((c) => {
143
+ const s = stagedById.get(c.id);
144
+ if (!s) return byId.get(c.id).text;
145
+ let text = s.fresh ? template.text.replace(template.dbId, randomUUID()) : s.base.text;
146
+ if (s.fresh) text = text.replace(/<(MarkersBA|RenderCacheBA|ImportExportMetadataBA)>[\s\S]*?<\/\1>/g, '<$1/>');
147
+ text = setTag(text, 'FieldsBlob', s.blob.toString('hex'));
148
+ text = setTag(text, 'Name', xmlEscape(` ${s.caption.text}`));
149
+ text = setTag(text, 'Start', String(s.caption.start));
150
+ return setTag(text, 'Duration', String(s.caption.end - s.caption.start));
151
+ });
152
+ const trackText = target.text.slice(0, field.index) + `<Items>${separator}${elements.join(separator)}${separator}</Items>`
153
+ + target.text.slice(field.index + field[0].length);
154
+ exported.zip.file(exported.entry, exported.xml.slice(0, target.offset) + trackText + exported.xml.slice(target.offset + target.text.length));
155
+ await fs.writeFile(output, await exported.zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }), { flag: 'wx' });
156
+ return { output, track, fps, added: staged.filter((s) => s.fresh).map((s) => String(s.caption.start)), replaced: p.replace.map((c) => c.id),
157
+ deleted: p.delete, captions: final.map((c) => ({ ...c, id: String(c.start) })),
158
+ verification: 'exported_caption_readback', restart_required: false };
159
+ }
@@ -467,7 +467,7 @@ export const drtTool = {
467
467
  } catch { return whole; }
468
468
  });
469
469
  // pool clip: uuid remap + keyed SeqRef patch + fresh numeric IDs
470
- let tlNew = applyBlobs(apply(tlEl)).replace(/<ID>\d+<\/ID>/g, `<ID>${nextPoolId}<\/ID>`.replace('<\/ID>', '</ID>'));
470
+ let tlNew = applyBlobs(apply(tlEl)).replace(/<ID>\d+<\/ID>/g, () => `<ID>${nextPoolId}<\/ID>`.replace('<\/ID>', '</ID>'));
471
471
  nextPoolId += 1;
472
472
  const fbM = tlNew.match(/(<Sm2Sequence DbId="[^"]+">\s*<FieldsBlob>)([0-9a-fA-F]+)(<\/FieldsBlob>)/);
473
473
  if (fbM) {
@@ -486,14 +486,14 @@ export const drtTool = {
486
486
  // Children live INSIDE <MediaVec> — an element appended after its
487
487
  // close parses fine and is silently invisible (measured: the whole
488
488
  // REEL_02 subtree vanished while every id chain checked out).
489
- mpXml = mpXml.replace('</MediaVec>', `${tlNew}\n</MediaVec>`);
489
+ mpXml = mpXml.replace('</MediaVec>', () => `${tlNew}\n</MediaVec>`);
490
490
  for (const el of mediaEls) {
491
491
  const id = el.match(/DbId="([^"]+)"/)[1];
492
- if (!mpXml.includes(`DbId="${id}"`)) mpXml = mpXml.replace('</MediaVec>', `${el}\n</MediaVec>`);
492
+ if (!mpXml.includes(`DbId="${id}"`)) mpXml = mpXml.replace('</MediaVec>', () => `${el}\n</MediaVec>`);
493
493
  }
494
494
  const pj2 = await zip.file('project.xml').async('string');
495
495
  for (const blob of pj2.match(/<Element>\s*<Sm2(?:Sequence|TiItem)LockableBlob DbId="[^"]+">[\s\S]*?<\/Sm2(?:Sequence|TiItem)LockableBlob>\s*<\/Element>/g) || []) {
496
- pjXml = pjXml.replace('</LocableBlobSet>', `${apply(blob)}</LocableBlobSet>`);
496
+ pjXml = pjXml.replace('</LocableBlobSet>', () => `${apply(blob)}</LocableBlobSet>`);
497
497
  }
498
498
  // THE timeline registry (found the hard way — two invisible merges):
499
499
  // project.xml's <TimelineHandleVec> lists each pool clip's embedded
@@ -503,7 +503,7 @@ export const drtTool = {
503
503
  const vec2 = pj2.match(/<TimelineHandleVec>([\s\S]*?)<\/TimelineHandleVec>/);
504
504
  if (vec2) {
505
505
  for (const h of vec2[1].match(/<Element>[^<]*<\/Element>/g) || []) {
506
- pjXml = pjXml.replace('</TimelineHandleVec>', ` ${apply(h)}\n </TimelineHandleVec>`);
506
+ pjXml = pjXml.replace('</TimelineHandleVec>', () => ` ${apply(h)}\n </TimelineHandleVec>`);
507
507
  }
508
508
  }
509
509
  // future merges must not collide with THIS timeline's fresh ids either
@@ -534,7 +534,7 @@ export const drtTool = {
534
534
  const hit = mpXml.match(tlRe);
535
535
  if (!hit) throw new Error(`assemble_project: could not locate pool clip for timeline ${names[i]} to move into folder ${folder}`);
536
536
  mpXml = mpXml.replace(hit[0], '');
537
- bin.clips.push(hit[0].replace(/<MpFolder>[^<]*<\/MpFolder>/, `<MpFolder>${bin.id}</MpFolder>`));
537
+ bin.clips.push(hit[0].replace(/<MpFolder>[^<]*<\/MpFolder>/, () => `<MpFolder>${bin.id}</MpFolder>`));
538
538
  }
539
539
  // Register the bins in Master's FieldsBlob — the parent folder blob
540
540
  // is the SUBFOLDER registry (measured: with it blanked, a bin's
@@ -213,7 +213,7 @@ async function convert(inputPath, outputPath, options = {}) {
213
213
  // Auto-generate output path if not provided
214
214
  if (!outputPath) {
215
215
  const ext = CONTAINER_EXTENSIONS[preset.container] || '.wav';
216
- outputPath = inputPath.replace(/\.[^.]+$/, `_${presetName}${ext}`);
216
+ outputPath = inputPath.replace(/\.[^.]+$/, () => `_${presetName}${ext}`);
217
217
  }
218
218
 
219
219
  onProgress(10);
@@ -0,0 +1,95 @@
1
+ // A string passed as the replacement to String.prototype.replace expands `$&`,
2
+ // `$'`, `` $` `` and `$$`, so a name or caption carrying them is rewritten on its
3
+ // way into the XML (#332: `R2 $& $'` as a timeline name made MpFolder.xml
4
+ // unparseable; `Spot $$ 30s` came out as `Spot $ 30s`). Interpolated
5
+ // replacements are therefore passed as functions, which insert their result
6
+ // literally. This file guards that rule and checks the writers that carry user
7
+ // text.
8
+
9
+ const test = require('node:test');
10
+ const assert = require('node:assert');
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+ const JSZip = require('jszip');
14
+ const { escapeXml } = require('../xml-builder');
15
+ const { createEmptyProject } = require('../author-project');
16
+ const { placeSubtitles } = require('../place-subtitles');
17
+ const { placeGenerator } = require('../place-generator');
18
+ const { placeFusionTitle } = require('../place-fusion-title');
19
+
20
+ const ROOT = path.resolve(__dirname, '..', '..', '..');
21
+ const TRICKY = "Spot $$ 30s $& $' $` end";
22
+
23
+ function sourceFiles(dir) {
24
+ const out = [];
25
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
26
+ if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
27
+ const full = path.join(dir, entry.name);
28
+ if (entry.isDirectory()) out.push(...sourceFiles(full));
29
+ else if (/\.(m?js)$/.test(entry.name)) out.push(full);
30
+ }
31
+ return out;
32
+ }
33
+
34
+ // `.replace(<string or regex literal>, `...${...}...`)` on one line. A template
35
+ // that uses a $-backreference ($1, $&, $<name>) needs the string form and is allowed.
36
+ const STRING_REPLACEMENT = /\.replace\(\s*(?:'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|\/(?:[^/\\\n]|\\.)+\/[a-z]*)\s*,\s*`((?:[^`\\]|\\.)*)`/g;
37
+ const BACKREF = /\$(?:[0-9&'`]|<[A-Za-z_]\w*>)/;
38
+
39
+ function offenders(text) {
40
+ const found = [];
41
+ for (const m of text.matchAll(STRING_REPLACEMENT)) {
42
+ const template = m[1];
43
+ if (!template.includes('${')) continue;
44
+ if (BACKREF.test(template.replace(/\$\{[^}]*\}/g, ''))) continue;
45
+ found.push(m[0].slice(0, 120));
46
+ }
47
+ return found;
48
+ }
49
+
50
+ test('no interpolated replace() replacement is passed as a string', () => {
51
+ const files = [...sourceFiles(path.join(ROOT, 'server')), ...sourceFiles(path.join(ROOT, 'vendor'))];
52
+ assert.ok(files.length > 50, `scanned ${files.length} files`);
53
+ const bad = [];
54
+ for (const file of files) {
55
+ for (const hit of offenders(fs.readFileSync(file, 'utf8'))) bad.push(`${path.relative(ROOT, file)}: ${hit}`);
56
+ }
57
+ assert.deepStrictEqual(bad, [], 'pass the replacement as () => `...` so $-sequences stay literal');
58
+ });
59
+
60
+ test('the guard catches the #332 form and allows the safe ones', () => {
61
+ assert.strictEqual(offenders("x.replace('</MediaVec>', `${el}\\n</MediaVec>`)").length, 1);
62
+ assert.strictEqual(offenders('x.replace(/<Name>[^<]*<\\/Name>/, `<Name>${n}</Name>`)').length, 1);
63
+ assert.strictEqual(offenders("x.replace('</MediaVec>', () => `${el}\\n</MediaVec>`)").length, 0);
64
+ assert.strictEqual(offenders('x.replace(/(<a>)<b\\/>/, `$1<b>${hex}</b>`)').length, 0);
65
+ assert.strictEqual(offenders("x.replace('a', 'b')").length, 0);
66
+ });
67
+
68
+ async function sequenceXml(buffer) {
69
+ const zip = await JSZip.loadAsync(buffer);
70
+ const parts = [];
71
+ for (const name of Object.keys(zip.files)) {
72
+ if (/SeqContainer\/[^/]+\.xml$|SeqContainer\d*\.xml$/.test(name)) parts.push(await zip.files[name].async('string'));
73
+ }
74
+ return parts.join('\n');
75
+ }
76
+
77
+ test('placeSubtitles keeps $-sequences in caption text', async () => {
78
+ const { buffer, startFrame } = await createEmptyProject({ timelineName: 'Subs' });
79
+ const res = await placeSubtitles(buffer, { subtitles: [{ startFrame, durationFrames: 48, text: TRICKY }] });
80
+ assert.ok((await sequenceXml(res.buffer)).includes(`<Name>${escapeXml(TRICKY)}</Name>`));
81
+ });
82
+
83
+ test('placeGenerator keeps $-sequences in the generator name', async () => {
84
+ const { buffer, startFrame } = await createEmptyProject({ timelineName: 'Gen' });
85
+ const res = await placeGenerator(buffer, { generatorName: TRICKY, trackIndex: 2, startFrame, durationFrames: 24 });
86
+ const xml = await sequenceXml(res.buffer);
87
+ assert.ok(xml.includes(`<PrettyType>${escapeXml(TRICKY)}</PrettyType>`));
88
+ assert.ok(xml.includes(`<Name>${escapeXml(TRICKY)}</Name>`));
89
+ });
90
+
91
+ test('placeFusionTitle keeps $-sequences in the clip name', async () => {
92
+ const { buffer, startFrame } = await createEmptyProject({ timelineName: 'Title' });
93
+ const res = await placeFusionTitle(buffer, { trackIndex: 2, startFrame, durationFrames: 24, name: TRICKY });
94
+ assert.ok((await sequenceXml(res.buffer)).includes(`<Name>${escapeXml(TRICKY)}</Name>`));
95
+ });
@@ -247,7 +247,7 @@ async function assembleTimeline(spec = {}) {
247
247
  if (!hit) throw new Error(`assembleTimeline: item markers — no ${kind} clip starting at ${cut.startFrame} on track ${cut.track ?? 1}`);
248
248
  const blob = encodeTimelineMarkersBlob(cut.markers);
249
249
  const el = `<Element>\n <Sm2TiItemLockableBlob DbId="${randomUUID()}">\n <FieldsBlob>${blob.toString('hex')}</FieldsBlob>\n <BlobOwner>${hit[1]}</BlobOwner>\n <DbSavedTime>0</DbSavedTime>\n </Sm2TiItemLockableBlob>\n </Element>\n `;
250
- pj = pj.replace('</LocableBlobSet>', `${el}</LocableBlobSet>`);
250
+ pj = pj.replace('</LocableBlobSet>', () => `${el}</LocableBlobSet>`);
251
251
  }
252
252
  zipIM.file('project.xml', pj);
253
253
  buffer = await zipIM.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
@@ -417,7 +417,7 @@ async function assembleTimeline(spec = {}) {
417
417
  const setM = pjX.match(/<LocableBlobSet>[\s\S]*?<\/LocableBlobSet>/);
418
418
  if (!setM) throw new Error('assembleTimeline: project.xml has no LocableBlobSet to hold markers');
419
419
  const el = `<Element>\n <Sm2SequenceLockableBlob DbId="${randomUUID()}">\n <FieldsBlob>${blob.toString('hex')}</FieldsBlob>\n <BlobOwner>${seqIdM}</BlobOwner>\n <DbSavedTime>0</DbSavedTime>\n </Sm2SequenceLockableBlob>\n </Element>\n `;
420
- pjX = pjX.replace('</LocableBlobSet>', `${el}</LocableBlobSet>`);
420
+ pjX = pjX.replace('</LocableBlobSet>', () => `${el}</LocableBlobSet>`);
421
421
  zipM.file('project.xml', pjX);
422
422
  buffer = await zipM.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
423
423
  }
@@ -27,6 +27,7 @@ const {
27
27
  emptyTrackClone,
28
28
  } = require('./seq-surgery');
29
29
  const { clipDbId, setClipStart, setClipDuration, setClipIn, setClipInEmpty } = require('./splice-clips');
30
+ const { escapeXml } = require('./xml-builder');
30
31
 
31
32
  /**
32
33
  * @param {Buffer|string} drpInput
@@ -96,7 +97,7 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
96
97
  if (cut.mediaRef) {
97
98
  // Multi-source: point this cut at ITS source's transplanted pool
98
99
  // element instead of the donor's.
99
- c = c.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/, `<MediaRef>${cut.mediaRef}</MediaRef>`);
100
+ c = c.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/, () => `<MediaRef>${cut.mediaRef}</MediaRef>`);
100
101
  }
101
102
  if (cut.srcMeta && !native && cut.audioOnly) {
102
103
  // Every clone must carry ITS OWN source identity — a donor clone
@@ -106,15 +107,15 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
106
107
  // A1 (measured, E15), and a TC-bearing video source fails the whole
107
108
  // render with "Full resolution media not found" (measured, E31).
108
109
  const { name, mediaFilePath, fps, frameCount } = cut.srcMeta;
109
- c = c.replace(/<Name>[\s\S]*?<\/Name>/, `<Name>${name}</Name>`);
110
- c = c.replace(/<MediaFilePath>[\s\S]*?<\/MediaFilePath>/, `<MediaFilePath>${mediaFilePath}</MediaFilePath>`);
110
+ c = c.replace(/<Name>[\s\S]*?<\/Name>/, () => `<Name>${escapeXml(name)}</Name>`);
111
+ c = c.replace(/<MediaFilePath>[\s\S]*?<\/MediaFilePath>/, () => `<MediaFilePath>${escapeXml(mediaFilePath)}</MediaFilePath>`);
111
112
  const rate = Buffer.alloc(16);
112
113
  rate.writeDoubleLE(fps, 0);
113
- c = c.replace(/<MediaFrameRate>[0-9a-fA-F]*<\/MediaFrameRate>/, `<MediaFrameRate>${rate.toString('hex')}</MediaFrameRate>`);
114
+ c = c.replace(/<MediaFrameRate>[0-9a-fA-F]*<\/MediaFrameRate>/, () => `<MediaFrameRate>${rate.toString('hex')}</MediaFrameRate>`);
114
115
  const idm = Buffer.alloc(9);
115
116
  idm.writeUInt8(0x02, 0);
116
117
  idm.writeDoubleBE((frameCount - 1) / fps, 1);
117
- c = c.replace(/<MediaTimemapBA>[0-9a-fA-F]*<\/MediaTimemapBA>/, `<MediaTimemapBA>${idm.toString('hex')}</MediaTimemapBA>`);
118
+ c = c.replace(/<MediaTimemapBA>[0-9a-fA-F]*<\/MediaTimemapBA>/, () => `<MediaTimemapBA>${idm.toString('hex')}</MediaTimemapBA>`);
118
119
  if (cut.audioOnly) {
119
120
  // Generic audio-clip FieldsBlob, verbatim from the live A2 harvest.
120
121
  // (Video clones keep their blob — repointClipBlobsInXml owns it.)
@@ -125,13 +126,13 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
125
126
  // Embedded source timecode base, in SECONDS (harvested with the media
126
127
  // template). Donor default 0 makes Resolve seek the wrong TC and fail
127
128
  // the render with "Full resolution media not found at <TC>".
128
- c = c.replace(/<MediaStartTime>[\s\S]*?<\/MediaStartTime>/, `<MediaStartTime>${cut.mediaStartTime}</MediaStartTime>`);
129
+ c = c.replace(/<MediaStartTime>[\s\S]*?<\/MediaStartTime>/, () => `<MediaStartTime>${cut.mediaStartTime}</MediaStartTime>`);
129
130
  }
130
131
  if (cut.timemap) {
131
132
  // Constant-speed retime: swap the identity MediaTimemapBA for the
132
133
  // caller-built Sm2TimeMap (r19 keyed form render/readback-verified;
133
134
  // the clip's <In>/<Duration> are RECORD-domain, measured live).
134
- c = c.replace(/<MediaTimemapBA>[0-9a-fA-F]*<\/MediaTimemapBA>/, `<MediaTimemapBA>${cut.timemap}</MediaTimemapBA>`);
135
+ c = c.replace(/<MediaTimemapBA>[0-9a-fA-F]*<\/MediaTimemapBA>/, () => `<MediaTimemapBA>${cut.timemap}</MediaTimemapBA>`);
135
136
  }
136
137
  return c;
137
138
  };
@@ -59,11 +59,11 @@ function transplantMediaElement(mpXml, seqXmls, { poolElement, mediaRef }) {
59
59
  const folderId = (mpXml.slice(start, end).match(/<MpFolder>([0-9a-f-]{36})<\/MpFolder>/) || [])[1];
60
60
  let element = poolElement;
61
61
  if (folderId) {
62
- element = element.replace(/<MpFolder>[0-9a-f-]{36}<\/MpFolder>/, `<MpFolder>${folderId}</MpFolder>`);
62
+ element = element.replace(/<MpFolder>[0-9a-f-]{36}<\/MpFolder>/, () => `<MpFolder>${folderId}</MpFolder>`);
63
63
  }
64
64
  const outMp = mpXml.slice(0, start) + element + mpXml.slice(end);
65
65
  const outSeqs = seqXmls.map((seq) =>
66
- seq.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/g, `<MediaRef>${mediaRef}</MediaRef>`),
66
+ seq.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/g, () => `<MediaRef>${mediaRef}</MediaRef>`),
67
67
  );
68
68
  return { mpXml: outMp, seqXmls: outSeqs };
69
69
  }
@@ -75,7 +75,7 @@ function insertMediaElement(mpXml, poolElement) {
75
75
  const folderId = (mpXml.match(/<Sm2MpVideoClip[^>]*>[\s\S]*?<MpFolder>([0-9a-f-]{36})<\/MpFolder>/) || [])[1];
76
76
  let element = poolElement;
77
77
  if (folderId) {
78
- element = element.replace(/<MpFolder>[0-9a-f-]{36}<\/MpFolder>/, `<MpFolder>${folderId}</MpFolder>`);
78
+ element = element.replace(/<MpFolder>[0-9a-f-]{36}<\/MpFolder>/, () => `<MpFolder>${folderId}</MpFolder>`);
79
79
  }
80
80
  return mpXml.slice(0, close) + element + mpXml.slice(close);
81
81
  }
@@ -72,22 +72,22 @@ async function placeCompound(drpInput, opts = {}) {
72
72
  // the cuts machinery, 0-based frames).
73
73
  let inner = fs.readFileSync(T('compound-inner-r19.xml'), 'utf8');
74
74
  inner = freshDbIds(inner);
75
- inner = inner.replace(/<Sm2SequenceContainer DbId="[^"]+"/, `<Sm2SequenceContainer DbId="${innerContainerId}"`);
76
- inner = inner.replace(/<Sequence>[0-9a-f-]{36}<\/Sequence>/g, `<Sequence>${innerSequenceId}</Sequence>`);
75
+ inner = inner.replace(/<Sm2SequenceContainer DbId="[^"]+"/, () => `<Sm2SequenceContainer DbId="${innerContainerId}"`);
76
+ inner = inner.replace(/<Sequence>[0-9a-f-]{36}<\/Sequence>/g, () => `<Sequence>${innerSequenceId}</Sequence>`);
77
77
  inner = inner.replace(/<Items>[\s\S]*?<\/Items>/g, '<Items/>');
78
78
  zip.file(`SeqContainer/${innerContainerId}.xml`, inner);
79
79
 
80
80
  // 2) Pool compound element: fresh identities + blob SeqRef patch.
81
81
  let pool = fs.readFileSync(T('compound-pool-r19.xml'), 'utf8');
82
- pool = pool.replace(/<Sm2MpCompoundClip DbId="[^"]+"/, `<Sm2MpCompoundClip DbId="${compoundId}"`);
83
- pool = pool.replace(/<Sm2Sequence DbId="[^"]+"/, `<Sm2Sequence DbId="${innerSequenceId}"`);
84
- pool = pool.replace(/<UniqueSequenceId>[^<]*<\/UniqueSequenceId>/, `<UniqueSequenceId>${crypto.randomUUID()}</UniqueSequenceId>`);
85
- pool = pool.replace(/<UniqueMediaPoolItemId>[^<]*<\/UniqueMediaPoolItemId>/, `<UniqueMediaPoolItemId>${crypto.randomUUID()}</UniqueMediaPoolItemId>`);
86
- pool = pool.replace(/<SmPTZRPreset DbId="[^"]+"/, `<SmPTZRPreset DbId="${crypto.randomUUID()}"`);
82
+ pool = pool.replace(/<Sm2MpCompoundClip DbId="[^"]+"/, () => `<Sm2MpCompoundClip DbId="${compoundId}"`);
83
+ pool = pool.replace(/<Sm2Sequence DbId="[^"]+"/, () => `<Sm2Sequence DbId="${innerSequenceId}"`);
84
+ pool = pool.replace(/<UniqueSequenceId>[^<]*<\/UniqueSequenceId>/, () => `<UniqueSequenceId>${crypto.randomUUID()}</UniqueSequenceId>`);
85
+ pool = pool.replace(/<UniqueMediaPoolItemId>[^<]*<\/UniqueMediaPoolItemId>/, () => `<UniqueMediaPoolItemId>${crypto.randomUUID()}</UniqueMediaPoolItemId>`);
86
+ pool = pool.replace(/<SmPTZRPreset DbId="[^"]+"/, () => `<SmPTZRPreset DbId="${crypto.randomUUID()}"`);
87
87
  // The embedded sequence's <Parent> points BACK at the compound's own id —
88
88
  // the reference sweep found it as the one un-rewired link (a dangling
89
89
  // Parent is presumably as fatal as the dangling MpFolder/SeqRef were).
90
- pool = pool.replace(/<Parent>[0-9a-f-]{36}<\/Parent>/, `<Parent>${compoundId}</Parent>`);
90
+ pool = pool.replace(/<Parent>[0-9a-f-]{36}<\/Parent>/, () => `<Parent>${compoundId}</Parent>`);
91
91
  {
92
92
  const fbM = pool.match(/(<Sm2Sequence DbId="[^"]+">\s*<FieldsBlob>)([0-9a-fA-F]+)(<\/FieldsBlob>)/);
93
93
  if (!fbM) throw new Error('placeCompound: embedded Sm2Sequence FieldsBlob not found');
@@ -98,12 +98,12 @@ async function placeCompound(drpInput, opts = {}) {
98
98
  const patched = encodeKeyedDict({ hdr: dict.hdr, entries: dict.entries }).toString('hex');
99
99
  pool = pool.replace(fbM[0], `${fbM[1]}${patched}${fbM[3]}`);
100
100
  }
101
- pool = pool.replace(/<Name>[^<]*<\/Name>/, `<Name>${escapeXml(name)}</Name>`);
101
+ pool = pool.replace(/<Name>[^<]*<\/Name>/, () => `<Name>${escapeXml(name)}</Name>`);
102
102
  // MediaExtents = [startSec, durationSec] LE doubles; inner starts at 0.
103
103
  const me = Buffer.alloc(16);
104
104
  me.writeDoubleLE(0, 0);
105
105
  me.writeDoubleLE(durationFrames / fps, 8);
106
- pool = pool.replace(/<MediaExtents>[0-9a-fA-F]*<\/MediaExtents>/, `<MediaExtents>${me.toString('hex')}</MediaExtents>`);
106
+ pool = pool.replace(/<MediaExtents>[0-9a-fA-F]*<\/MediaExtents>/, () => `<MediaExtents>${me.toString('hex')}</MediaExtents>`);
107
107
  // Attach to the primary MediaPool folder — and point the element's
108
108
  // <MpFolder> at THAT folder's DbId (the template's value references the
109
109
  // harvest project's folder; a dangling folder ref CRASHES Resolve on
@@ -112,22 +112,22 @@ async function placeCompound(drpInput, opts = {}) {
112
112
  let mpXml = await zip.file(mpPath).async('string');
113
113
  const folderId = (mpXml.match(/<Sm2MpFolder DbId="([^"]+)"/) || [])[1];
114
114
  if (!folderId) throw new Error('placeCompound: target MpFolder has no Sm2MpFolder DbId');
115
- pool = pool.replace(/<MpFolder>[0-9a-f-]{36}<\/MpFolder>/, `<MpFolder>${folderId}</MpFolder>`);
116
- mpXml = mpXml.replace('</MediaVec>', `${pool}\n </MediaVec>`);
115
+ pool = pool.replace(/<MpFolder>[0-9a-f-]{36}<\/MpFolder>/, () => `<MpFolder>${folderId}</MpFolder>`);
116
+ mpXml = mpXml.replace('</MediaVec>', () => `${pool}\n </MediaVec>`);
117
117
  zip.file(mpPath, mpXml);
118
118
 
119
119
  // 3) Parent item: template clone → fresh id, name, placement, MediaRef,
120
120
  // identity timemap over the inner content ((frames − 0.5)/fps, measured).
121
121
  let item = fs.readFileSync(T('compound-item-r19.xml'), 'utf8').trim();
122
122
  item = freshDbIds(item);
123
- item = item.replace(/<Name>[^<]*<\/Name>/, `<Name>${escapeXml(name)}</Name>`);
124
- item = item.replace(/<Start>\d+<\/Start>/, `<Start>${startFrame}</Start>`);
125
- item = item.replace(/<Duration>\d+<\/Duration>/, `<Duration>${durationFrames}</Duration>`);
126
- item = item.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/, `<MediaRef>${compoundId}</MediaRef>`);
123
+ item = item.replace(/<Name>[^<]*<\/Name>/, () => `<Name>${escapeXml(name)}</Name>`);
124
+ item = item.replace(/<Start>\d+<\/Start>/, () => `<Start>${startFrame}</Start>`);
125
+ item = item.replace(/<Duration>\d+<\/Duration>/, () => `<Duration>${durationFrames}</Duration>`);
126
+ item = item.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/, () => `<MediaRef>${compoundId}</MediaRef>`);
127
127
  const tm = Buffer.alloc(9);
128
128
  tm.writeUInt8(0x02, 0);
129
129
  tm.writeDoubleBE((durationFrames - 0.5) / fps, 1);
130
- item = item.replace(/<MediaTimemapBA>[0-9a-fA-F]*<\/MediaTimemapBA>/, `<MediaTimemapBA>${tm.toString('hex')}</MediaTimemapBA>`);
130
+ item = item.replace(/<MediaTimemapBA>[0-9a-fA-F]*<\/MediaTimemapBA>/, () => `<MediaTimemapBA>${tm.toString('hex')}</MediaTimemapBA>`);
131
131
 
132
132
  const { match: vec, tracks } = getTrackVec(parentXml, 'video');
133
133
  if (track > tracks.length) throw new Error(`placeCompound: parent video track ${track} does not exist (${tracks.length})`);
@@ -96,9 +96,9 @@ async function placeFusionTitle(drpInput, opts = {}) {
96
96
  throw new Error('placeFusionTitle: template is not a Fusion Title clip');
97
97
  }
98
98
  clip = freshDbIds(clip);
99
- clip = clip.replace(/<Start>\d+<\/Start>/, `<Start>${startFrame}</Start>`);
100
- clip = clip.replace(/<Duration>\d+<\/Duration>/, `<Duration>${durationFrames}</Duration>`);
101
- if (name) clip = clip.replace(/<Name>[\s\S]*?<\/Name>/, `<Name>${escapeXml(name)}</Name>`);
99
+ clip = clip.replace(/<Start>\d+<\/Start>/, () => `<Start>${startFrame}</Start>`);
100
+ clip = clip.replace(/<Duration>\d+<\/Duration>/, () => `<Duration>${durationFrames}</Duration>`);
101
+ if (name) clip = clip.replace(/<Name>[\s\S]*?<\/Name>/, () => `<Name>${escapeXml(name)}</Name>`);
102
102
  const titleInputs = { text, font, style, size, vJustify, hJustify, color };
103
103
  if (Object.values(titleInputs).some((v) => v !== undefined && v !== null)) {
104
104
  const m = clip.match(/<CompositionBA>([0-9a-fA-F]*)<\/CompositionBA>/);
@@ -75,17 +75,17 @@ async function placeGenerator(drpInput, opts = {}) {
75
75
 
76
76
  let gen = fs.readFileSync(snippetPathFor(templateVersion), 'utf8').trim();
77
77
  gen = freshDbIds(gen);
78
- gen = gen.replace(/<PrettyType>[\s\S]*?<\/PrettyType>/, `<PrettyType>${escapeXml(generatorName)}</PrettyType>`);
79
- gen = gen.replace(/<Name>[\s\S]*?<\/Name>/, `<Name>${escapeXml(generatorName)}</Name>`);
80
- gen = gen.replace(/<Start>\d+<\/Start>/, `<Start>${startFrame}</Start>`);
81
- gen = gen.replace(/<Duration>\d+<\/Duration>/, `<Duration>${durationFrames}</Duration>`);
78
+ gen = gen.replace(/<PrettyType>[\s\S]*?<\/PrettyType>/, () => `<PrettyType>${escapeXml(generatorName)}</PrettyType>`);
79
+ gen = gen.replace(/<Name>[\s\S]*?<\/Name>/, () => `<Name>${escapeXml(generatorName)}</Name>`);
80
+ gen = gen.replace(/<Start>\d+<\/Start>/, () => `<Start>${startFrame}</Start>`);
81
+ gen = gen.replace(/<Duration>\d+<\/Duration>/, () => `<Duration>${durationFrames}</Duration>`);
82
82
  const colorNorm = color != null ? normalizeColor(color) : null;
83
83
  if (colorNorm) {
84
84
  const blob = solidColorEffectBlob(colorNorm);
85
85
  if (!/<EffectFiltersBA\s*\/>|<EffectFiltersBA>\s*<\/EffectFiltersBA>/.test(gen)) {
86
86
  throw new Error('placeGenerator: template carries no empty <EffectFiltersBA/> to populate with the colour');
87
87
  }
88
- gen = gen.replace(/<EffectFiltersBA\s*\/>|<EffectFiltersBA>\s*<\/EffectFiltersBA>/, `<EffectFiltersBA>${blob}</EffectFiltersBA>`);
88
+ gen = gen.replace(/<EffectFiltersBA\s*\/>|<EffectFiltersBA>\s*<\/EffectFiltersBA>/, () => `<EffectFiltersBA>${blob}</EffectFiltersBA>`);
89
89
  }
90
90
 
91
91
  const { match: vtv, tracks } = getTrackVec(seqXml, 'video');
@@ -94,9 +94,9 @@ async function placeSubtitles(drpInput, opts = {}) {
94
94
  ' <UserDefinedName/>\n <LayersVec/>\n </Sm2TiTrack>\n </Element>\n ';
95
95
  let xml;
96
96
  if (/<SubtitleTrackVec\/>/.test(seqXml)) {
97
- xml = seqXml.replace('<SubtitleTrackVec/>', `<SubtitleTrackVec>${track}</SubtitleTrackVec>`);
97
+ xml = seqXml.replace('<SubtitleTrackVec/>', () => `<SubtitleTrackVec>${track}</SubtitleTrackVec>`);
98
98
  } else if (/<SubtitleTrackVec>/.test(seqXml)) {
99
- xml = seqXml.replace('</SubtitleTrackVec>', `${track}</SubtitleTrackVec>`);
99
+ xml = seqXml.replace('</SubtitleTrackVec>', () => `${track}</SubtitleTrackVec>`);
100
100
  } else {
101
101
  throw new Error('placeSubtitles: timeline has no SubtitleTrackVec');
102
102
  }
@@ -115,9 +115,9 @@ async function placeTransition(drpInput, opts = {}) {
115
115
  let trans = fs.readFileSync(trackType === 'audio' ? AUDIO_TEMPLATE_PATH : TEMPLATE_PATH, 'utf8').trim();
116
116
  trans = freshDbIds(trans);
117
117
  if (type === 'wipe') {
118
- trans = trans.replace(/<FieldsBlob>[0-9a-fA-F]*<\/FieldsBlob>/, `<FieldsBlob>${WIPE_FIELDS_BLOB}</FieldsBlob>`);
118
+ trans = trans.replace(/<FieldsBlob>[0-9a-fA-F]*<\/FieldsBlob>/, () => `<FieldsBlob>${WIPE_FIELDS_BLOB}</FieldsBlob>`);
119
119
  } else if (STYLE_PRETTY_TYPES[type]) {
120
- trans = trans.replace(/<PrettyType>Cross Dissolve<\/PrettyType>/, `<PrettyType>${STYLE_PRETTY_TYPES[type]}</PrettyType>`);
120
+ trans = trans.replace(/<PrettyType>Cross Dissolve<\/PrettyType>/, () => `<PrettyType>${STYLE_PRETTY_TYPES[type]}</PrettyType>`);
121
121
  }
122
122
  // Span geometry follows <Start>/<Duration>, NOT AlignmentType (the E61
123
123
  // harvest of Resolve's own EDL import carries AlignmentType 2 with the
@@ -132,8 +132,8 @@ async function placeTransition(drpInput, opts = {}) {
132
132
  }
133
133
  }
134
134
  const start = spanStart !== undefined ? spanStart : atFrame - Math.floor(durationFrames / 2);
135
- trans = trans.replace(/<Start>\d+<\/Start>/, `<Start>${start}</Start>`);
136
- trans = trans.replace(/<Duration>\d+<\/Duration>/, `<Duration>${durationFrames}</Duration>`);
135
+ trans = trans.replace(/<Start>\d+<\/Start>/, () => `<Start>${start}</Start>`);
136
+ trans = trans.replace(/<Duration>\d+<\/Duration>/, () => `<Duration>${durationFrames}</Duration>`);
137
137
  const transitionDbId = (trans.match(/<Sm2TiTransition DbId="([^"]+)"/) || [])[1] || null;
138
138
 
139
139
  // Insert the transition <Element> immediately after the left clip's <Element> in <Items>.
@@ -89,7 +89,7 @@ function getItemsInner(trackElement) {
89
89
  function setItemsInner(trackElement, inner) {
90
90
  const body = inner && inner.trim() ? inner : null;
91
91
  if (/<Items\s*\/>/.test(trackElement)) {
92
- return body ? trackElement.replace(/<Items\s*\/>/, `<Items>${body}</Items>`) : trackElement;
92
+ return body ? trackElement.replace(/<Items\s*\/>/, () => `<Items>${body}</Items>`) : trackElement;
93
93
  }
94
94
  return trackElement.replace(/<Items>[\s\S]*?<\/Items>/, body ? `<Items>${body}</Items>` : '<Items/>');
95
95
  }
@@ -37,10 +37,10 @@ function clipDuration(clipXml) {
37
37
  return m ? parseInt(m[1], 10) : null;
38
38
  }
39
39
  function setClipStart(clipXml, v) {
40
- return clipXml.replace(/<Start>\d+<\/Start>/, `<Start>${v}</Start>`);
40
+ return clipXml.replace(/<Start>\d+<\/Start>/, () => `<Start>${v}</Start>`);
41
41
  }
42
42
  function setClipDuration(clipXml, v) {
43
- return clipXml.replace(/<Duration>\d+<\/Duration>/, `<Duration>${v}</Duration>`);
43
+ return clipXml.replace(/<Duration>\d+<\/Duration>/, () => `<Duration>${v}</Duration>`);
44
44
  }
45
45
  // The source IN-point lives in <In> as `<framePos>|<LE-double of framePos*0.001>`, where framePos
46
46
  // is in TIMELINE frames (verified live: Resolve appended a clip with source-in 600 media-frames @29.97
@@ -143,7 +143,7 @@ async function moveClip(drpInput, opts = {}) {
143
143
  // Optionally retime.
144
144
  if (toStart !== undefined && toStart !== null) {
145
145
  if (!Number.isInteger(toStart)) throw new TypeError('moveClip: toStart must be an integer');
146
- clip = clip.replace(/<Start>\d+<\/Start>/, `<Start>${toStart}</Start>`);
146
+ clip = clip.replace(/<Start>\d+<\/Start>/, () => `<Start>${toStart}</Start>`);
147
147
  }
148
148
 
149
149
  // Grow to the destination track with empty clones, then insert.
@@ -1,11 +1,12 @@
1
1
  // Structured stdin bridge used by the live Python server.
2
- import { editSubtitleTimeline, inspectSubtitleTimeline } from '../resolve-advanced/server/subtitle-timeline.mjs';
2
+ import { editSubtitleTimeline, inspectSubtitleTimeline, editCaptionTimeline, inspectCaptionTimeline }
3
+ from '../resolve-advanced/server/subtitle-timeline.mjs';
4
+ const operations = { inspect: inspectSubtitleTimeline, inspect_captions: inspectCaptionTimeline, write_captions: editCaptionTimeline };
3
5
  try {
4
6
  let input = '';
5
7
  for await (const chunk of process.stdin) input += chunk;
6
- const request = JSON.parse(input);
7
- process.stdout.write(JSON.stringify(await (request.operation === 'inspect'
8
- ? inspectSubtitleTimeline(request) : editSubtitleTimeline(request))));
8
+ const { operation, ...request } = JSON.parse(input);
9
+ process.stdout.write(JSON.stringify(await (operations[operation] || editSubtitleTimeline)(request)));
9
10
  } catch (error) {
10
11
  process.stderr.write(error.message);
11
12
  process.exitCode = 1;
@@ -92,7 +92,7 @@ if not logging.getLogger().handlers:
92
92
  handlers=[logging.StreamHandler()],
93
93
  )
94
94
 
95
- VERSION = "4.17.1"
95
+ VERSION = "4.18.1"
96
96
  logger = logging.getLogger("davinci-resolve-mcp")
97
97
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
98
98
  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 377-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "4.17.1"
14
+ VERSION = "4.18.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -52,6 +52,8 @@ from src.utils.resolve211_alignment import auto_align
52
52
  from src.utils.resolve211_dctl import native_dctl_result
53
53
  from src.utils.resolve211_normalization import normalize_audio
54
54
  from src.utils.subtitle_live import set_subtitle_preset as _set_live_subtitle_preset
55
+ from src.utils.subtitle_live import list_captions_live as _list_live_captions
56
+ from src.utils.subtitle_live import write_captions_live as _write_live_captions
55
57
  from src.utils.title_library import list_title_presets as _list_installed_title_presets
56
58
  from src.utils.title_controls import title_text_targets as _title_text_targets
57
59
  from src.utils.title_controls import title_text_keys as _title_text_keys
@@ -13355,7 +13357,9 @@ def _media_analysis_replace_owned_block(existing: Any, body: Any) -> str:
13355
13357
  + re.escape(_MCP_METADATA_BLOCK_END)
13356
13358
  )
13357
13359
  if re.search(pattern, existing_text, flags=re.DOTALL):
13358
- return re.sub(pattern, block, existing_text, flags=re.DOTALL).strip()
13360
+ # A function replacement inserts the block literally: as a string, a
13361
+ # backslash in the analysis text (a Windows path) is read as an escape.
13362
+ return re.sub(pattern, lambda _match: block, existing_text, flags=re.DOTALL).strip()
13359
13363
  return f"{existing_text}\n\n{block}".strip() if existing_text else block
13360
13364
 
13361
13365
 
@@ -28814,6 +28818,16 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
28814
28818
  inspect rendered frames separately. Successful staging exports are removed;
28815
28819
  failure results report retained staging for diagnosis. Unbundled dry runs need an
28816
28820
  existing preset_reference to avoid live capture. Never patches Project.db.
28821
+ list_captions_live(track?) -> {fps, captions:[{id,text,start,end,words}]} — decoded
28822
+ from a native DRT export (timeline frames); read-only, Resolve stays open.
28823
+ write_captions_live(track?, replace?, add?, delete?, template_caption_id?, revision_name?, dry_run?)
28824
+ Same caption shape as advanced project_db write_captions, but with Resolve open:
28825
+ replace:[{id,text,start,end,words:[{text,start,end}]}], add:[{text,start,end,words}],
28826
+ delete:[id]. text = words joined by spaces; words must tile the caption.
28827
+ id = the caption start frame as a string (Resolve re-mints item IDs per export).
28828
+ Native export -> caption codec -> native import as a selected revision;
28829
+ Resolve's re-export is decoded and must match text, bounds and word frames.
28830
+ Original retained and re-selected on failure. Never patches Project.db.
28817
28831
  detect_scene_cuts(background?) -> {success | job_id}
28818
28832
  analyze_dolby_vision(clip_ids?, analysis_type?, background?) -> {success | job_id}
28819
28833
  grab_still() -> {success}
@@ -28837,6 +28851,11 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
28837
28851
  # Match the safety decorator's boolean interpretation before any capture/import.
28838
28852
  return _set_live_subtitle_preset(get_resolve(), proj, tl,
28839
28853
  {**p, "dry_run": _coerce_bool(p.get("dry_run", p.get("dryRun")))}, _resolve_safe_dir)
28854
+ elif action == "list_captions_live":
28855
+ return _list_live_captions(get_resolve(), tl, p, _resolve_safe_dir)
28856
+ elif action == "write_captions_live":
28857
+ return _write_live_captions(get_resolve(), proj, tl,
28858
+ {**p, "dry_run": _coerce_bool(p.get("dry_run", p.get("dryRun")))}, _resolve_safe_dir)
28840
28859
  elif action == "detect_scene_cuts":
28841
28860
  return _run_maybe_background(
28842
28861
  "timeline_ai.detect_scene_cuts", p, lambda: {"success": bool(tl.DetectSceneCuts())}
@@ -28861,7 +28880,7 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
28861
28880
  elif action == "grab_all_stills":
28862
28881
  stills = tl.GrabAllStills(p.get("source", 1))
28863
28882
  return {"count": len(stills) if stills else 0}
28864
- return _unknown(action, ["list_title_presets","create_subtitles","set_subtitle_preset","detect_scene_cuts","analyze_dolby_vision","grab_still","grab_all_stills"])
28883
+ return _unknown(action, ["list_title_presets","create_subtitles","set_subtitle_preset","list_captions_live","write_captions_live","detect_scene_cuts","analyze_dolby_vision","grab_still","grab_all_stills"])
28865
28884
 
28866
28885
 
28867
28886
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -192,6 +192,7 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
192
192
  "analyze_dolby_vision",
193
193
  "create_subtitles",
194
194
  "set_subtitle_preset",
195
+ "write_captions_live",
195
196
  }),
196
197
  "timeline_item": frozenset({
197
198
  "delete_keyframe",
@@ -378,6 +379,7 @@ NATIVE_DRY_RUN_ACTIONS: frozenset = frozenset({
378
379
  ("timeline", "replace_source_range"),
379
380
  ("timeline_ai", "create_subtitles"),
380
381
  ("timeline_ai", "set_subtitle_preset"),
382
+ ("timeline_ai", "write_captions_live"),
381
383
  ("script_plugin", "safe_install_extension"),
382
384
  ("script_plugin", "safe_remove_extension"),
383
385
  ("project_manager", "safe_project_delete"),
@@ -347,6 +347,7 @@ class RiskClassificationHook(LifecycleHook):
347
347
  # Analysis passes that write their results back onto the timeline.
348
348
  ("timeline_ai", "create_subtitles"),
349
349
  ("timeline_ai", "set_subtitle_preset"),
350
+ ("timeline_ai", "write_captions_live"),
350
351
  ("timeline_ai", "analyze_dolby_vision"),
351
352
  # Grade state that is replaced rather than removed. AddVersion also
352
353
  # switches the active version, so a later graph write lands on the new
@@ -74,12 +74,19 @@ def _capture_installed_preset(resolve, project, preset, output):
74
74
  raise RuntimeError(f"Preset capture cleanup refused; retained {capture.GetName()}")
75
75
 
76
76
 
77
- def _inventory(timeline):
78
- """Compare track counts and item names, bounds and media IDs only."""
77
+ def _inventory(timeline, edited_subtitle_track=None):
78
+ """Compare track counts and item names, bounds and media IDs only.
79
+
80
+ edited_subtitle_track is left out of the comparison when a call rewrites its
81
+ captions; those are verified by decoding the re-export instead.
82
+ """
79
83
  result = {}
80
84
  for kind in ("video", "audio", "subtitle"):
81
85
  tracks = []
82
86
  for index in range(1, int(timeline.GetTrackCount(kind) or 0) + 1):
87
+ if kind == "subtitle" and index == edited_subtitle_track:
88
+ tracks.append("edited")
89
+ continue
83
90
  items = []
84
91
  for item in timeline.GetItemListInTrack(kind, index) or []:
85
92
  media = item.GetMediaPoolItem()
@@ -90,8 +97,23 @@ def _inventory(timeline):
90
97
  return result
91
98
 
92
99
 
93
- def set_subtitle_preset(resolve, project, timeline, params, safe_dir):
94
- """Create and select a verified revision; restore original on any failure."""
100
+ def _codec(node, request, default_error="Subtitle codec failed"):
101
+ """Run one bridge operation and return its decoded JSON result."""
102
+ completed = subprocess.run([node, str(BRIDGE)], input=json.dumps(request),
103
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
104
+ timeout=60, check=False)
105
+ if completed.returncode:
106
+ err = completed.stderr.strip() or default_error
107
+ if "ERR_MODULE_NOT_FOUND" in err or "Cannot find package" in err:
108
+ err = subtitle_codec_gap() or (
109
+ f"{err} From {_ADVANCED} run: npm install --omit=dev --omit=optional"
110
+ )
111
+ raise RuntimeError(err)
112
+ return json.loads(completed.stdout)
113
+
114
+
115
+ def _preflight(timeline, params):
116
+ """Shared argument checks: (node, dry_run, track), or an error result."""
95
117
  node = shutil.which("node")
96
118
  if not node:
97
119
  return {"success": False, "error": "Node.js is required for the subtitle preset codec"}
@@ -102,23 +124,16 @@ def set_subtitle_preset(resolve, project, timeline, params, safe_dir):
102
124
  dry_run = params.get("dry_run", False)
103
125
  if not isinstance(dry_run, bool):
104
126
  return {"success": False, "error": "dry_run must be coerced to a boolean by the dispatcher"}
105
- inputs = params.get("inputs", {})
106
- if not isinstance(inputs, dict):
107
- return {"success": False, "error": "inputs must be an object"}
108
127
  track = params.get("track", 1)
109
128
  if isinstance(track, bool) or not isinstance(track, int) or not 1 <= track <= timeline.GetTrackCount("subtitle"):
110
129
  return {"success": False, "error": "Subtitle track index out of range"}
111
- reference = params.get("preset_reference")
112
- if reference and not Path(reference).is_file():
113
- return {"success": False, "error": "preset_reference must be an existing native DRT export"}
114
- installed = None
115
- if params.get("preset") not in (None, "Word Highlight", "Lollipop") and not reference:
116
- try:
117
- installed = find_subtitle_preset(params["preset"], params.get("templates_archive"))
118
- except (OSError, ValueError) as exc:
119
- return {"success": False, "error": str(exc)}
120
- if dry_run:
121
- return {"success": False, "error": "Uncached installed preset capture needs a live temporary timeline; use preset_reference for a mutation-free dry run"}
130
+ return node, dry_run, track
131
+
132
+
133
+ def _revise(resolve, project, timeline, params, safe_dir, *, dry_run, build, check, report, edited_track=None):
134
+ """Export, build(source, output, staging) a patched DRT, import it as a selected
135
+ revision and check(readback, patch) Resolve's re-export of it. The original is
136
+ never modified and stays active on any failure or preview."""
122
137
  name = params.get("revision_name") or f"{timeline.GetName()} - subtitles {uuid.uuid4().hex[:8]}"
123
138
  if any(project.GetTimelineByIndex(i).GetName() == name for i in range(1, project.GetTimelineCount() + 1)):
124
139
  return {"success": False, "error": "revision_name already exists"}
@@ -127,33 +142,14 @@ def set_subtitle_preset(resolve, project, timeline, params, safe_dir):
127
142
  source, output = staging / "original.drt", staging / "revision.drt"
128
143
  original_active = project.GetCurrentTimeline()
129
144
  original_timecode = timeline.GetCurrentTimecode()
130
- before = _inventory(timeline)
145
+ before = _inventory(timeline, edited_track)
131
146
  imported = None
132
147
  succeeded = False
133
148
  result = None
134
149
  try:
135
150
  if not timeline.Export(str(source), resolve.EXPORT_DRT):
136
151
  raise RuntimeError("Native timeline export failed")
137
- request = {"source": str(source), "output": str(output), "track": track, "inputs": inputs}
138
- if installed:
139
- captured = staging / "installed-title.drt"
140
- _capture_installed_preset(resolve, project, installed, captured)
141
- request.update(title_reference=str(captured), template_id=installed["template_id"])
142
- if reference:
143
- request.update(preset_reference=os.path.abspath(reference), reference_track=params.get("reference_track", 1))
144
- if params.get("preset") and not reference:
145
- request["preset"] = params["preset"]
146
- completed = subprocess.run([node, str(BRIDGE)], input=json.dumps(request),
147
- capture_output=True, text=True, encoding="utf-8", errors="replace",
148
- timeout=60, check=False)
149
- if completed.returncode:
150
- err = completed.stderr.strip() or "Subtitle codec failed"
151
- if "ERR_MODULE_NOT_FOUND" in err or "Cannot find package" in err:
152
- err = subtitle_codec_gap() or (
153
- f"{err} From {_ADVANCED} run: npm install --omit=dev --omit=optional"
154
- )
155
- raise RuntimeError(err)
156
- patch = json.loads(completed.stdout)
152
+ patch = build(source, output, staging)
157
153
  # The codec's output file is private staging, removed after success.
158
154
  patch.pop("output", None)
159
155
  if dry_run:
@@ -164,26 +160,18 @@ def set_subtitle_preset(resolve, project, timeline, params, safe_dir):
164
160
  imported = project.GetMediaPool().ImportTimelineFromFile(str(output), {"importSourceClips": False})
165
161
  if not imported:
166
162
  raise RuntimeError("Resolve refused the patched native timeline")
167
- if _inventory(imported) != before:
163
+ if _inventory(imported, edited_track) != before:
168
164
  raise RuntimeError("Imported edit differs in track counts, clip/caption names, bounds or media IDs")
169
165
  if not imported.SetName(name):
170
166
  raise RuntimeError("Failed to name subtitle revision")
171
167
  if imported.GetName() != name:
172
168
  raise RuntimeError("Subtitle revision name did not persist")
173
169
  # Re-export through Resolve to prove that it parsed and retained the
174
- # preset. The codec's own pre-import readback alone is insufficient.
170
+ # edit. The codec's own pre-import readback alone is insufficient.
175
171
  readback = staging / "resolve-readback.drt"
176
172
  if not imported.Export(str(readback), resolve.EXPORT_DRT):
177
173
  raise RuntimeError("Failed to re-export the imported subtitle revision")
178
- checked = subprocess.run([node, str(BRIDGE)],
179
- input=json.dumps({"operation": "inspect", "source": str(readback), "track": track}),
180
- capture_output=True, text=True, encoding="utf-8", errors="replace",
181
- timeout=60, check=False)
182
- if checked.returncode:
183
- raise RuntimeError(checked.stderr.strip() or "Imported preset readback failed")
184
- actual = json.loads(checked.stdout)
185
- if actual.get("templateId") != patch.get("templateId") or actual.get("inputs") != patch.get("after"):
186
- raise RuntimeError("Resolve did not retain the requested subtitle controls")
174
+ check(readback, patch)
187
175
  selected, detail = set_current_timeline(project, imported)
188
176
  if not selected:
189
177
  raise RuntimeError(describe_switch_failure(detail, "selecting subtitle revision"))
@@ -193,12 +181,8 @@ def set_subtitle_preset(resolve, project, timeline, params, safe_dir):
193
181
  warnings.append("Subtitle revision was selected, but its playhead could not be restored")
194
182
  succeeded = True
195
183
  result = {"success": True, "restart_required": False, "original_timeline": timeline.GetName(),
196
- "timeline": imported.GetName(), "timeline_id": imported.GetUniqueId(),
197
- "patch": patch,
198
- "verification": "resolve_reexport_preset_readback_and_live_edit_inventory",
199
- "verified_components": ["track_counts", "item_names", "item_bounds", "media_ids", "subtitle_preset_controls"],
200
- "unverified_components": ["markers", "grades", "other_fusion_compositions", "audio_content"],
201
- "render_verified": False}
184
+ "timeline": imported.GetName(), "timeline_id": imported.GetUniqueId(),
185
+ "patch": patch, **report, "render_verified": False}
202
186
  if warnings:
203
187
  result["warnings"] = warnings
204
188
  return result
@@ -231,3 +215,112 @@ def set_subtitle_preset(resolve, project, timeline, params, safe_dir):
231
215
  else:
232
216
  result["staging_retained"] = True
233
217
  result["staging_directory"] = str(staging)
218
+
219
+
220
+ def set_subtitle_preset(resolve, project, timeline, params, safe_dir):
221
+ """Create and select a verified revision; restore original on any failure."""
222
+ checked = _preflight(timeline, params)
223
+ if isinstance(checked, dict):
224
+ return checked
225
+ node, dry_run, track = checked
226
+ inputs = params.get("inputs", {})
227
+ if not isinstance(inputs, dict):
228
+ return {"success": False, "error": "inputs must be an object"}
229
+ reference = params.get("preset_reference")
230
+ if reference and not Path(reference).is_file():
231
+ return {"success": False, "error": "preset_reference must be an existing native DRT export"}
232
+ installed = None
233
+ if params.get("preset") not in (None, "Word Highlight", "Lollipop") and not reference:
234
+ try:
235
+ installed = find_subtitle_preset(params["preset"], params.get("templates_archive"))
236
+ except (OSError, ValueError) as exc:
237
+ return {"success": False, "error": str(exc)}
238
+ if dry_run:
239
+ return {"success": False, "error": "Uncached installed preset capture needs a live temporary timeline; use preset_reference for a mutation-free dry run"}
240
+
241
+ def build(source, output, staging):
242
+ request = {"source": str(source), "output": str(output), "track": track, "inputs": inputs}
243
+ if installed:
244
+ captured = staging / "installed-title.drt"
245
+ _capture_installed_preset(resolve, project, installed, captured)
246
+ request.update(title_reference=str(captured), template_id=installed["template_id"])
247
+ if reference:
248
+ request.update(preset_reference=os.path.abspath(reference), reference_track=params.get("reference_track", 1))
249
+ if params.get("preset") and not reference:
250
+ request["preset"] = params["preset"]
251
+ return _codec(node, request)
252
+
253
+ def check(readback, patch):
254
+ actual = _codec(node, {"operation": "inspect", "source": str(readback), "track": track},
255
+ "Imported preset readback failed")
256
+ if actual.get("templateId") != patch.get("templateId") or actual.get("inputs") != patch.get("after"):
257
+ raise RuntimeError("Resolve did not retain the requested subtitle controls")
258
+
259
+ return _revise(resolve, project, timeline, params, safe_dir, dry_run=dry_run, build=build, check=check, report={
260
+ "verification": "resolve_reexport_preset_readback_and_live_edit_inventory",
261
+ "verified_components": ["track_counts", "item_names", "item_bounds", "media_ids", "subtitle_preset_controls"],
262
+ "unverified_components": ["markers", "grades", "other_fusion_compositions", "audio_content"]})
263
+
264
+
265
+ def _caption_shape(captions):
266
+ """What a caption write must preserve. Item IDs are remapped on import."""
267
+ return [{"text": c["text"], "start": c["start"], "end": c["end"],
268
+ "words": [(w["text"], w["start"], w["end"]) for w in c["words"]]} for c in captions]
269
+
270
+
271
+ def list_captions_live(resolve, timeline, params, safe_dir):
272
+ """Decode one subtitle track's captions from a native export; mutates nothing."""
273
+ checked = _preflight(timeline, {**params, "dry_run": False})
274
+ if isinstance(checked, dict):
275
+ return checked
276
+ node, _, track = checked
277
+ staging = Path(safe_dir(str(ROOT / "logs" / "subtitle-live"))) / uuid.uuid4().hex
278
+ staging.mkdir(parents=True, exist_ok=False)
279
+ try:
280
+ source = staging / "original.drt"
281
+ if not timeline.Export(str(source), resolve.EXPORT_DRT):
282
+ return {"success": False, "error": "Native timeline export failed"}
283
+ return {"success": True, "timeline": timeline.GetName(),
284
+ **_codec(node, {"operation": "inspect_captions", "source": str(source), "track": track})}
285
+ except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as exc:
286
+ return {"success": False, "error": str(exc)}
287
+ finally:
288
+ # This UUID directory belongs only to this read.
289
+ shutil.rmtree(staging, ignore_errors=True)
290
+
291
+
292
+ def write_captions_live(resolve, project, timeline, params, safe_dir):
293
+ """Replace/add/delete caption text and word timing as a verified revision, Resolve open.
294
+
295
+ Same caption shape as advanced project_db write_captions (timeline frames, words
296
+ tile each caption). A caption id is its start frame as a string, as list_captions_live
297
+ reports it: Resolve issues fresh item IDs on every export.
298
+ """
299
+ checked = _preflight(timeline, params)
300
+ if isinstance(checked, dict):
301
+ return checked
302
+ node, dry_run, track = checked
303
+ edits = {key: params.get(key, []) for key in ("replace", "add", "delete")}
304
+ if not all(isinstance(value, list) for value in edits.values()):
305
+ return {"success": False, "error": "replace, add and delete must be lists"}
306
+ template = params.get("template_caption_id", params.get("templateCaptionId"))
307
+
308
+ def build(source, output, staging):
309
+ request = {"operation": "write_captions", "source": str(source), "output": str(output),
310
+ "track": track, **edits}
311
+ if template:
312
+ request["templateCaptionId"] = template
313
+ return _codec(node, request)
314
+
315
+ def check(readback, patch):
316
+ actual = _codec(node, {"operation": "inspect_captions", "source": str(readback), "track": track},
317
+ "Imported caption readback failed")
318
+ if _caption_shape(actual.get("captions", [])) != _caption_shape(patch.get("captions", [])):
319
+ raise RuntimeError("Resolve did not retain the requested caption text or word timing")
320
+
321
+ return _revise(resolve, project, timeline, params, safe_dir, dry_run=dry_run, build=build, check=check,
322
+ edited_track=track, report={
323
+ "verification": "resolve_reexport_caption_readback_and_live_edit_inventory",
324
+ "verified_components": ["track_counts", "item_names", "item_bounds", "media_ids",
325
+ "caption_text", "caption_bounds", "word_timing"],
326
+ "unverified_components": ["markers", "grades", "fusion_compositions", "audio_content"]})