davinci-resolve-mcp 4.17.0 → 4.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +11 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/server/subtitle-db.mjs +23 -12
- package/resolve-advanced/server/subtitle-timeline.mjs +77 -1
- package/scripts/edit_subtitle_timeline.mjs +5 -4
- package/src/granular/common.py +1 -1
- package/src/server.py +19 -2
- package/src/utils/destructive_hook.py +2 -0
- package/src/utils/execution_lifecycle.py +1 -0
- package/src/utils/media_analysis.py +42 -18
- package/src/utils/subtitle_live.py +149 -56
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,80 @@
|
|
|
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.0 — Edit captions with Resolve open
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`timeline_ai.list_captions_live` and `timeline_ai.write_captions_live`**
|
|
10
|
+
(#330, thanks @sidevconcept). Caption text and per-word timing can now be
|
|
11
|
+
edited without quitting Resolve or patching the open Project.db.
|
|
12
|
+
`write_captions_live` takes the same `replace` / `add` / `delete` shape as
|
|
13
|
+
the offline `project_db` `write_captions`. It exports the timeline as a
|
|
14
|
+
native DRT, applies the edit with the same caption codec and edit checks,
|
|
15
|
+
and imports the result as a new timeline revision. The revision is selected
|
|
16
|
+
only after Resolve's own re-export of it keeps the requested text, caption
|
|
17
|
+
bounds and word frames. If any step fails, the original timeline stays
|
|
18
|
+
selected and unchanged.
|
|
19
|
+
- A caption `id` is its start frame as a string, because Resolve issues new
|
|
20
|
+
item IDs on every export. The write returns the IDs to use for the next
|
|
21
|
+
edit, including for captions it added or moved. Edits on a track where two
|
|
22
|
+
captions share a start frame are refused as ambiguous.
|
|
23
|
+
- `write_captions_live` is registered as a destructive action with a native
|
|
24
|
+
dry run (medium risk, recoverable). `list_captions_live` is read-only.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- `set_subtitle_preset` now shares its export → edit → import → re-export
|
|
29
|
+
steps with the caption writer. Its behaviour and result fields are the same.
|
|
30
|
+
|
|
31
|
+
### Limits
|
|
32
|
+
|
|
33
|
+
- Word times keep the existing codec's 60 Hz tick quantisation.
|
|
34
|
+
- Adding to an empty subtitle track needs an existing caption as a template.
|
|
35
|
+
Per-caption Fusion compositions are refused.
|
|
36
|
+
- The structural readback does not check every grade, marker, Fusion
|
|
37
|
+
composition or audio property carried through the native timeline import.
|
|
38
|
+
|
|
39
|
+
### Validation
|
|
40
|
+
|
|
41
|
+
- Python suite with the PR merged on `main`: 4,294 tests. The only failures
|
|
42
|
+
are the 4 `source_media_guard` path checks that always fail in a scratch
|
|
43
|
+
worktree.
|
|
44
|
+
- Advanced Node suite on Node 20 with a fresh `npm ci`: 1,218 tests, 1,157
|
|
45
|
+
passed, 0 failed, 61 skipped.
|
|
46
|
+
- Live-validated by the contributor on Studio 21.1.0.17 with a disposable
|
|
47
|
+
project copy: three captions rewritten (one containing `$`), a moved
|
|
48
|
+
caption's ID reused, an added caption deleted by its returned ID, and a
|
|
49
|
+
burned-in render checked across nine word phases. Not live-validated on the
|
|
50
|
+
maintainer's Studio 19.1.3.7.
|
|
51
|
+
|
|
52
|
+
## What's New in v4.17.1 — Media analysis decodes each file once
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- **The readthrough pass of media analysis runs ffmpeg once instead of four
|
|
57
|
+
times** (#329, thanks @Dev-next-gen). Loudness, scene scoring, black frames
|
|
58
|
+
and silence each used to be a separate ffmpeg run, and each run decoded the
|
|
59
|
+
whole file: `-af` alone does not drop the video stream from a null output,
|
|
60
|
+
so the audio passes decoded every video frame too. The four filters now
|
|
61
|
+
share one run through `split`/`asplit`. On a 1080p H.264 file the
|
|
62
|
+
readthrough took 8.1 s instead of 22.1 s, and CPU time fell from 84.8 s to
|
|
63
|
+
30.5 s; ProRes and HEVC dropped by about the same share. The results are
|
|
64
|
+
the same as before. The parsers are unchanged.
|
|
65
|
+
- The four measurements now share one exit code and one timeout. The single
|
|
66
|
+
run gets four times the old per-pass timeout, the budget of the four runs it
|
|
67
|
+
replaces. If that run times out, all four report `success: false`, where
|
|
68
|
+
before the other three could still succeed.
|
|
69
|
+
|
|
70
|
+
### Validation
|
|
71
|
+
|
|
72
|
+
- Python suite with the PR merged on `main`: 4,291 tests. The only failures
|
|
73
|
+
are the 4 `source_media_guard` path checks that always fail in a scratch
|
|
74
|
+
worktree. The new test compares the single run against the four separate
|
|
75
|
+
runs with the real ffmpeg on an A/V file, a video-only file and an
|
|
76
|
+
audio-only file.
|
|
77
|
+
- No Resolve behaviour changed; this is ffmpeg only.
|
|
78
|
+
|
|
5
79
|
## What's New in v4.17.0 — Power windows for Resolve 21
|
|
6
80
|
|
|
7
81
|
### Added
|
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
|
-
> 本翻译对应 v4.
|
|
15
|
+
> 本翻译对应 v4.18.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
package/docs/SKILL.md
CHANGED
|
@@ -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`), `
|
|
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.
|
|
40
|
+
VERSION = "4.18.0"
|
|
41
41
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
42
42
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
43
43
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
182
|
-
const qc = checkCaptions([decoded], { 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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
88
|
+
const xmlUnescape = (s) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/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
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// Structured stdin bridge used by the live Python server.
|
|
2
|
-
import { editSubtitleTimeline, inspectSubtitleTimeline
|
|
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 (
|
|
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;
|
package/src/granular/common.py
CHANGED
|
@@ -92,7 +92,7 @@ if not logging.getLogger().handlers:
|
|
|
92
92
|
handlers=[logging.StreamHandler()],
|
|
93
93
|
)
|
|
94
94
|
|
|
95
|
-
VERSION = "4.
|
|
95
|
+
VERSION = "4.18.0"
|
|
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.
|
|
14
|
+
VERSION = "4.18.0"
|
|
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
|
|
@@ -28814,6 +28816,16 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
28814
28816
|
inspect rendered frames separately. Successful staging exports are removed;
|
|
28815
28817
|
failure results report retained staging for diagnosis. Unbundled dry runs need an
|
|
28816
28818
|
existing preset_reference to avoid live capture. Never patches Project.db.
|
|
28819
|
+
list_captions_live(track?) -> {fps, captions:[{id,text,start,end,words}]} — decoded
|
|
28820
|
+
from a native DRT export (timeline frames); read-only, Resolve stays open.
|
|
28821
|
+
write_captions_live(track?, replace?, add?, delete?, template_caption_id?, revision_name?, dry_run?)
|
|
28822
|
+
Same caption shape as advanced project_db write_captions, but with Resolve open:
|
|
28823
|
+
replace:[{id,text,start,end,words:[{text,start,end}]}], add:[{text,start,end,words}],
|
|
28824
|
+
delete:[id]. text = words joined by spaces; words must tile the caption.
|
|
28825
|
+
id = the caption start frame as a string (Resolve re-mints item IDs per export).
|
|
28826
|
+
Native export -> caption codec -> native import as a selected revision;
|
|
28827
|
+
Resolve's re-export is decoded and must match text, bounds and word frames.
|
|
28828
|
+
Original retained and re-selected on failure. Never patches Project.db.
|
|
28817
28829
|
detect_scene_cuts(background?) -> {success | job_id}
|
|
28818
28830
|
analyze_dolby_vision(clip_ids?, analysis_type?, background?) -> {success | job_id}
|
|
28819
28831
|
grab_still() -> {success}
|
|
@@ -28837,6 +28849,11 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
28837
28849
|
# Match the safety decorator's boolean interpretation before any capture/import.
|
|
28838
28850
|
return _set_live_subtitle_preset(get_resolve(), proj, tl,
|
|
28839
28851
|
{**p, "dry_run": _coerce_bool(p.get("dry_run", p.get("dryRun")))}, _resolve_safe_dir)
|
|
28852
|
+
elif action == "list_captions_live":
|
|
28853
|
+
return _list_live_captions(get_resolve(), tl, p, _resolve_safe_dir)
|
|
28854
|
+
elif action == "write_captions_live":
|
|
28855
|
+
return _write_live_captions(get_resolve(), proj, tl,
|
|
28856
|
+
{**p, "dry_run": _coerce_bool(p.get("dry_run", p.get("dryRun")))}, _resolve_safe_dir)
|
|
28840
28857
|
elif action == "detect_scene_cuts":
|
|
28841
28858
|
return _run_maybe_background(
|
|
28842
28859
|
"timeline_ai.detect_scene_cuts", p, lambda: {"success": bool(tl.DetectSceneCuts())}
|
|
@@ -28861,7 +28878,7 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
28861
28878
|
elif action == "grab_all_stills":
|
|
28862
28879
|
stills = tl.GrabAllStills(p.get("source", 1))
|
|
28863
28880
|
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"])
|
|
28881
|
+
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
28882
|
|
|
28866
28883
|
|
|
28867
28884
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -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
|
|
@@ -2781,7 +2781,13 @@ def _media_duration_seconds(record: Dict[str, Any], technical: Dict[str, Any]) -
|
|
|
2781
2781
|
return None
|
|
2782
2782
|
|
|
2783
2783
|
|
|
2784
|
-
def _ffmpeg_stderr_filter(
|
|
2784
|
+
def _ffmpeg_stderr_filter(
|
|
2785
|
+
path: str,
|
|
2786
|
+
video_filter: Optional[str] = None,
|
|
2787
|
+
audio_filter: Optional[str] = None,
|
|
2788
|
+
frames: Optional[int] = None,
|
|
2789
|
+
timeout: int = COMMAND_TIMEOUT_SECONDS,
|
|
2790
|
+
) -> Tuple[int, str]:
|
|
2785
2791
|
args = ["ffmpeg", "-hide_banner", "-nostats", "-i", path]
|
|
2786
2792
|
if video_filter:
|
|
2787
2793
|
args.extend(["-vf", video_filter])
|
|
@@ -2790,7 +2796,7 @@ def _ffmpeg_stderr_filter(path: str, video_filter: Optional[str] = None, audio_f
|
|
|
2790
2796
|
if frames is not None:
|
|
2791
2797
|
args.extend(["-frames:v", str(frames)])
|
|
2792
2798
|
args.extend(["-f", "null", "-"])
|
|
2793
|
-
code, _, stderr = _run_command(args)
|
|
2799
|
+
code, _, stderr = _run_command(args, timeout=timeout)
|
|
2794
2800
|
return code, stderr
|
|
2795
2801
|
|
|
2796
2802
|
|
|
@@ -2934,25 +2940,45 @@ def _parse_idet(stderr: str) -> Dict[str, Any]:
|
|
|
2934
2940
|
}
|
|
2935
2941
|
|
|
2936
2942
|
|
|
2943
|
+
# The four whole-file measurements `_readthrough_analysis` makes. They used to be four
|
|
2944
|
+
# ffmpeg runs, and every run decoded the whole file: `-af` alone does not drop the video
|
|
2945
|
+
# stream from a null output, so the loudness and silence passes decoded every frame just
|
|
2946
|
+
# as the scene and black passes did, and each video pass decoded the audio too. One run
|
|
2947
|
+
# with `split`/`asplit` decodes each stream once and feeds all four filters. Each parser
|
|
2948
|
+
# below matches only its own filter's lines, so sharing one stderr changes no result.
|
|
2949
|
+
# A missing stream behaves as it did per pass: ffmpeg skips the -vf or -af it cannot
|
|
2950
|
+
# use, exits 0, and that filter's parser finds nothing.
|
|
2951
|
+
_SCENE_SCORE_FILTER = "select='gt(scene,0)',metadata=print:key=lavfi.scene_score,showinfo"
|
|
2952
|
+
_BLACKDETECT_FILTER = "blackdetect=d=0.5:pix_th=0.10"
|
|
2953
|
+
_LOUDNESS_FILTER = "ebur128=peak=true"
|
|
2954
|
+
_SILENCEDETECT_FILTER = "silencedetect=noise=-50dB:d=1"
|
|
2955
|
+
_READTHROUGH_VIDEO_FILTER = f"split[scene][black];[scene]{_SCENE_SCORE_FILTER},nullsink;[black]{_BLACKDETECT_FILTER}"
|
|
2956
|
+
_READTHROUGH_AUDIO_FILTER = f"asplit[loud][silence];[loud]{_LOUDNESS_FILTER},anullsink;[silence]{_SILENCEDETECT_FILTER}"
|
|
2957
|
+
|
|
2958
|
+
|
|
2937
2959
|
def _readthrough_analysis(path: str) -> Dict[str, Any]:
|
|
2938
2960
|
result: Dict[str, Any] = {"success": True}
|
|
2939
2961
|
|
|
2940
|
-
|
|
2962
|
+
# The one run carries the timeout budget of the four it replaces, so a file whose
|
|
2963
|
+
# slowest pass (scene scoring) finished in time before still finishes in time.
|
|
2964
|
+
code, stderr = _ffmpeg_stderr_filter(
|
|
2965
|
+
path,
|
|
2966
|
+
video_filter=_READTHROUGH_VIDEO_FILTER,
|
|
2967
|
+
audio_filter=_READTHROUGH_AUDIO_FILTER,
|
|
2968
|
+
timeout=COMMAND_TIMEOUT_SECONDS * 4,
|
|
2969
|
+
)
|
|
2970
|
+
|
|
2941
2971
|
result["loudness"] = {
|
|
2942
|
-
"success":
|
|
2943
|
-
"metrics": _parse_loudness(
|
|
2972
|
+
"success": code == 0,
|
|
2973
|
+
"metrics": _parse_loudness(stderr),
|
|
2944
2974
|
}
|
|
2945
2975
|
|
|
2946
|
-
# Adaptive scene detection.
|
|
2976
|
+
# Adaptive scene detection. The scene branch dumps a (pts_time, scene_score) pair
|
|
2947
2977
|
# for every frame whose scene score is > 0 (i.e. every non-first frame); we then
|
|
2948
2978
|
# compute a content-aware threshold from that distribution and keep peaks above it.
|
|
2949
2979
|
# Replaces the legacy hardcoded ``gt(scene,0.3)``, which was too coarse on
|
|
2950
2980
|
# high-motion content (missed real cuts) and too sensitive on locked-off content.
|
|
2951
|
-
|
|
2952
|
-
path,
|
|
2953
|
-
video_filter="select='gt(scene,0)',metadata=print:key=lavfi.scene_score,showinfo",
|
|
2954
|
-
)
|
|
2955
|
-
scene_score_pairs = _parse_scene_score_pairs(scene_stderr)
|
|
2981
|
+
scene_score_pairs = _parse_scene_score_pairs(stderr)
|
|
2956
2982
|
scene_threshold, scene_threshold_stats = _adaptive_scene_threshold(scene_score_pairs)
|
|
2957
2983
|
adaptive_scene_items = [
|
|
2958
2984
|
{"time_seconds": pts, "score": score}
|
|
@@ -2960,22 +2986,20 @@ def _readthrough_analysis(path: str) -> Dict[str, Any]:
|
|
|
2960
2986
|
if score is not None and score > scene_threshold
|
|
2961
2987
|
]
|
|
2962
2988
|
result["scenes"] = {
|
|
2963
|
-
"success":
|
|
2989
|
+
"success": code == 0,
|
|
2964
2990
|
"items": adaptive_scene_items,
|
|
2965
2991
|
"threshold": scene_threshold,
|
|
2966
2992
|
"threshold_stats": scene_threshold_stats,
|
|
2967
2993
|
}
|
|
2968
2994
|
|
|
2969
|
-
black_code, black_stderr = _ffmpeg_stderr_filter(path, video_filter="blackdetect=d=0.5:pix_th=0.10")
|
|
2970
2995
|
result["black_frames"] = {
|
|
2971
|
-
"success":
|
|
2972
|
-
"items": _parse_blackdetect(
|
|
2996
|
+
"success": code == 0,
|
|
2997
|
+
"items": _parse_blackdetect(stderr),
|
|
2973
2998
|
}
|
|
2974
2999
|
|
|
2975
|
-
silence_code, silence_stderr = _ffmpeg_stderr_filter(path, audio_filter="silencedetect=noise=-50dB:d=1")
|
|
2976
3000
|
result["silence"] = {
|
|
2977
|
-
"success":
|
|
2978
|
-
"items": _parse_silencedetect(
|
|
3001
|
+
"success": code == 0,
|
|
3002
|
+
"items": _parse_silencedetect(stderr),
|
|
2979
3003
|
}
|
|
2980
3004
|
|
|
2981
3005
|
idet_code, idet_stderr = _ffmpeg_stderr_filter(path, video_filter="idet", frames=500)
|
|
@@ -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
|
|
94
|
-
"""
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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"]})
|