davinci-resolve-mcp 2.132.1 → 2.133.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/docs/guides/headless-edit-loop.md +12 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/server/author-interchange.mjs +12 -2
- package/resolve-advanced/server/tools/drt.mjs +1 -2
- package/resolve-advanced/server/tools/editorial.mjs +10 -1
- package/resolve-advanced/vendor/drp-format/assemble-timeline.js +22 -10
- package/scripts/resolve_headless.py +88 -8
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
|
@@ -14,6 +14,18 @@ the raw runs are in `docs/reference/evidence/`.
|
|
|
14
14
|
`-nogui` — the flat round trip, the complex-cut round trip, and the moved-media
|
|
15
15
|
relink. Choose a format on its properties, never on whether you have a UI.
|
|
16
16
|
|
|
17
|
+
**Precondition first, though: headless requires external scripting to connect
|
|
18
|
+
without the GUI.** That held everywhere it was measured here, but at least one
|
|
19
|
+
field setup (Studio 21.0.4.5, scripting routed through an external bridge
|
|
20
|
+
process — issue #172) boots `-nogui` into an instance that *never answers*
|
|
21
|
+
`scriptapp('Resolve')`, and an unscriptable headless instance still holds the
|
|
22
|
+
one-per-machine singleton, so the GUI cannot start either. Preflight in 30
|
|
23
|
+
seconds before committing a loop to `-nogui`:
|
|
24
|
+
`python scripts/resolve_headless.py run -- python -c "print('ok')"` — a clean
|
|
25
|
+
`ok` proves boot-to-scriptable; a `FAILED: no scripting response` now cleans up
|
|
26
|
+
the instance it started, and `stop --force` TERM/KILLs a wedged one (unclean:
|
|
27
|
+
expect project locks and a slow next boot).
|
|
28
|
+
|
|
17
29
|
**There is no single best format.** Three measurements pull in different
|
|
18
30
|
directions, and the right choice depends on which one you are up against:
|
|
19
31
|
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "2.
|
|
40
|
+
VERSION = "2.133.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
|
@@ -608,13 +608,23 @@ export function verifyRoundtrip(inputEvents, exportedEvents, opts = {}) {
|
|
|
608
608
|
return m ? `${m[1]}${m[2] || '1'}` : String(t);
|
|
609
609
|
};
|
|
610
610
|
const canonSource = (x) => String(x || '').replace(/\.[^.]+$/, '').toLowerCase();
|
|
611
|
-
|
|
611
|
+
// Reel/tape aliases: an EDL names sources by REEL (CUTSRC) while the
|
|
612
|
+
// re-export names them by file basename (cut_src) — the sourceMap that
|
|
613
|
+
// drove the assemble is the authority linking the two. Keys and values
|
|
614
|
+
// canonicalize exactly like event sources.
|
|
615
|
+
const aliases = {};
|
|
616
|
+
for (const [k, v] of Object.entries(opts.sourceAliases || {})) aliases[canonSource(k)] = canonSource(v);
|
|
617
|
+
const mapSource = (s) => aliases[s] ?? s;
|
|
618
|
+
// recOut > recIn: an EDL dissolve writes a ZERO-duration outgoing leg
|
|
619
|
+
// before the D event — a pairing placeholder, never a rendered clip, and
|
|
620
|
+
// no export reproduces it.
|
|
621
|
+
const vids = (evts) => evts.filter((e) => /^V\d*$/.test(String(e.track)) && e.recIn != null && e.recOut != null && e.recOut > e.recIn);
|
|
612
622
|
const norm = (evts) => {
|
|
613
623
|
const v = vids(evts);
|
|
614
624
|
if (!v.length) return [];
|
|
615
625
|
const off = Math.min(...v.map((e) => e.recIn));
|
|
616
626
|
return v
|
|
617
|
-
.map((e) => ({ track: canonTrack(e.track), source: canonSource(e.source), recIn: e.recIn - off, recOut: e.recOut - off, srcIn: e.srcIn ?? 0 }))
|
|
627
|
+
.map((e) => ({ track: canonTrack(e.track), source: mapSource(canonSource(e.source)), recIn: e.recIn - off, recOut: e.recOut - off, srcIn: e.srcIn ?? 0 }))
|
|
618
628
|
.sort((a, b) => a.track.localeCompare(b.track) || a.recIn - b.recIn);
|
|
619
629
|
};
|
|
620
630
|
const a = norm(inputEvents);
|
|
@@ -288,8 +288,7 @@ export const drtTool = {
|
|
|
288
288
|
stamped,
|
|
289
289
|
conform: report,
|
|
290
290
|
note:
|
|
291
|
-
'Import with timeline.import_timeline_checked (timeline is named after the FILE). Dissolves/cross-fades, forward+reverse retimes, multi-track video, audio events and markers are AUTHORED when geometry allows; everything else drops WITH a reason — see `conform` for the ledger.'
|
|
292
|
-
'Retimes are flattened and transitions become cuts — see `conform` for the ledger.',
|
|
291
|
+
'Import with timeline.import_timeline_checked (timeline is named after the FILE). Dissolves/cross-fades, forward+reverse retimes, multi-track video, audio events and markers are AUTHORED when geometry allows; everything else drops WITH a reason — see `conform` for the ledger.',
|
|
293
292
|
};
|
|
294
293
|
}
|
|
295
294
|
if (action === 'assemble') {
|
|
@@ -156,8 +156,17 @@ export const editorialTool = {
|
|
|
156
156
|
exported: z.array(z.any()).describe('Normalized events of the re-export (parse_interchange on the exported OTIO/EDL/XML)'),
|
|
157
157
|
recTol: z.number().optional(),
|
|
158
158
|
srcTol: z.number().optional(),
|
|
159
|
+
sourceMap: z.record(z.object({ mediaFilePath: z.string() }).passthrough()).optional()
|
|
160
|
+
.describe('The SAME reel→{mediaFilePath} map the assemble used — lets an EDL reel (CUTSRC) match the re-export\'s file basename (cut_src)'),
|
|
159
161
|
}).parse(args);
|
|
160
|
-
|
|
162
|
+
// EDL reels vs exported basenames: derive the alias table from the
|
|
163
|
+
// sourceMap that drove the assemble (the one authority linking them).
|
|
164
|
+
const sourceAliases = {};
|
|
165
|
+
for (const [reel, src] of Object.entries(p.sourceMap || {})) {
|
|
166
|
+
const base = String(src.mediaFilePath).split('/').pop();
|
|
167
|
+
if (base) sourceAliases[reel] = base;
|
|
168
|
+
}
|
|
169
|
+
return verifyRoundtrip(p.input, p.exported, { recTol: p.recTol, srcTol: p.srcTol, sourceAliases });
|
|
161
170
|
}
|
|
162
171
|
if (action === 'marker_roundtrip') {
|
|
163
172
|
const p = markerSchema.parse(args);
|
|
@@ -200,6 +200,17 @@ async function assembleTimeline(spec = {}) {
|
|
|
200
200
|
}));
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
// Pin the PARENT container id BEFORE any compound inserts an inner
|
|
204
|
+
// container: entry listing is name-sorted, so an inner container can
|
|
205
|
+
// alphabetically precede the parent and every later "first container"
|
|
206
|
+
// lookup — the next compound's item, subtitles, markers — then targets
|
|
207
|
+
// the compound's INNER timeline instead (measured: CMP_B landed inside
|
|
208
|
+
// CMP_A; subtitles authored with a compound in the spec vanished into
|
|
209
|
+
// the inner container and the imported timeline had no subtitle track).
|
|
210
|
+
const zipPin = await JSZip.loadAsync(buffer);
|
|
211
|
+
const parentEntryPin = Object.keys(zipPin.files).find((n) => !zipPin.files[n].dir && /SeqContainer\/.+\.xml$/.test(n));
|
|
212
|
+
const parentContainerId = ((await zipPin.file(parentEntryPin).async('string')).match(/<Sm2SequenceContainer DbId="([^"]+)"/) || [])[1];
|
|
213
|
+
|
|
203
214
|
// Compound clips: an empty nested timeline is spliced in from the
|
|
204
215
|
// harvested donor shape, then its content is placed with the ORDINARY cuts
|
|
205
216
|
// machinery targeting the inner container (inner origin is FRAME 0).
|
|
@@ -221,13 +232,6 @@ async function assembleTimeline(spec = {}) {
|
|
|
221
232
|
insertedExtra.add(fp);
|
|
222
233
|
}
|
|
223
234
|
}
|
|
224
|
-
// Pin the PARENT container id BEFORE inserting inner containers: entry
|
|
225
|
-
// listing is name-sorted, so an inner container can alphabetically
|
|
226
|
-
// precede the parent and swallow the next compound's item (measured —
|
|
227
|
-
// CMP_B landed inside CMP_A's inner timeline).
|
|
228
|
-
const zip0 = await JSZip.loadAsync(buffer);
|
|
229
|
-
const parentEntry0 = Object.keys(zip0.files).find((n) => !zip0.files[n].dir && /SeqContainer\/.+\.xml$/.test(n));
|
|
230
|
-
const parentContainerId = ((await zip0.file(parentEntry0).async('string')).match(/<Sm2SequenceContainer DbId="([^"]+)"/) || [])[1];
|
|
231
235
|
for (const [ci, comp] of spec.compounds.entries()) {
|
|
232
236
|
if (!comp || typeof comp !== 'object') throw new TypeError(`assembleTimeline: compounds[${ci}] must be an object`);
|
|
233
237
|
const res = await placeCompound(buffer, {
|
|
@@ -280,7 +284,7 @@ async function assembleTimeline(spec = {}) {
|
|
|
280
284
|
throw new RangeError(`assembleTimeline: subtitle at frame ${sub.startFrame} is before the timeline origin ${originFrame}`);
|
|
281
285
|
}
|
|
282
286
|
}
|
|
283
|
-
({ buffer } = await placeSubtitles(buffer, { subtitles }));
|
|
287
|
+
({ buffer } = await placeSubtitles(buffer, { subtitles, timelineUuid: parentContainerId }));
|
|
284
288
|
}
|
|
285
289
|
|
|
286
290
|
if (Array.isArray(spec.markers) && spec.markers.length) {
|
|
@@ -289,9 +293,17 @@ async function assembleTimeline(spec = {}) {
|
|
|
289
293
|
// <Sequence> references). Encoder byte-exact vs a live 19.1.3.7 export.
|
|
290
294
|
// Marker frames here are TIMELINE-ABSOLUTE for consistency with cuts;
|
|
291
295
|
// the blob stores them start-relative.
|
|
296
|
+
// Resolved against the PINNED parent container — a compound's inner
|
|
297
|
+
// container also matches the "any SeqContainer entry" pattern, and
|
|
298
|
+
// attaching the blob to the inner sequence hides every marker.
|
|
292
299
|
const zipM = await JSZip.loadAsync(buffer);
|
|
293
|
-
const
|
|
294
|
-
|
|
300
|
+
const seqNames = Object.keys(zipM.files).filter((n) => !zipM.files[n].dir && /SeqContainer\/.+\.xml$/.test(n));
|
|
301
|
+
let seqXml2 = null;
|
|
302
|
+
for (const n of seqNames) {
|
|
303
|
+
const xmlN = await zipM.file(n).async('string');
|
|
304
|
+
if (xmlN.includes(`<Sm2SequenceContainer DbId="${parentContainerId}"`)) { seqXml2 = xmlN; break; }
|
|
305
|
+
}
|
|
306
|
+
if (!seqXml2) throw new Error('assembleTimeline: pinned parent SeqContainer not found for markers');
|
|
295
307
|
const seqIdM = (seqXml2.match(/<Sequence>([0-9a-f-]{36})<\/Sequence>/) || [])[1];
|
|
296
308
|
if (!seqIdM) throw new Error('assembleTimeline: cannot find the Sm2Sequence id for markers');
|
|
297
309
|
const rel = spec.markers.map((m) => {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
python scripts/resolve_headless.py guard # exit non-zero unless it is safe to start
|
|
6
6
|
python scripts/resolve_headless.py start # boot -nogui and wait until scriptable
|
|
7
7
|
python scripts/resolve_headless.py stop # Quit() and wait for the process to go
|
|
8
|
+
python scripts/resolve_headless.py stop --force # TERM/KILL a wedged, unanswering instance
|
|
8
9
|
python scripts/resolve_headless.py run -- python my_batch.py # guard, start, run, stop
|
|
9
10
|
|
|
10
11
|
Why a wrapper rather than a line in a Makefile:
|
|
@@ -122,6 +123,21 @@ def cmd_guard() -> int:
|
|
|
122
123
|
return 0
|
|
123
124
|
|
|
124
125
|
|
|
126
|
+
def _kill_process(proc: subprocess.Popen, grace: float = 10.0) -> bool:
|
|
127
|
+
"""TERM then KILL the process we launched; True when it is gone."""
|
|
128
|
+
proc.terminate()
|
|
129
|
+
try:
|
|
130
|
+
proc.wait(timeout=grace)
|
|
131
|
+
return True
|
|
132
|
+
except subprocess.TimeoutExpired:
|
|
133
|
+
proc.kill()
|
|
134
|
+
try:
|
|
135
|
+
proc.wait(timeout=grace)
|
|
136
|
+
return True
|
|
137
|
+
except subprocess.TimeoutExpired:
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
125
141
|
def start(timeout: float) -> int:
|
|
126
142
|
guard = cmd_guard()
|
|
127
143
|
if guard != 0:
|
|
@@ -131,25 +147,41 @@ def start(timeout: float) -> int:
|
|
|
131
147
|
print("REFUSE: no DaVinci Resolve install found.", file=sys.stderr)
|
|
132
148
|
return 3
|
|
133
149
|
print(f"starting: {' '.join(command)}")
|
|
134
|
-
subprocess.Popen(command, stdin=subprocess.DEVNULL,
|
|
135
|
-
|
|
150
|
+
proc = subprocess.Popen(command, stdin=subprocess.DEVNULL,
|
|
151
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
136
152
|
started = time.monotonic()
|
|
137
153
|
if wait_until_scriptable(timeout) is None:
|
|
154
|
+
# An unscriptable -nogui instance still holds the singleton, so the
|
|
155
|
+
# GUI cannot launch either — leaving it behind wedges the machine
|
|
156
|
+
# worse than never starting (issue #172). Kill only what WE spawned.
|
|
138
157
|
print(f"FAILED: no scripting response within {timeout:.0f}s.", file=sys.stderr)
|
|
158
|
+
print("cleaning up the unscriptable instance this script started...", file=sys.stderr)
|
|
159
|
+
if _kill_process(proc):
|
|
160
|
+
print("cleaned up. Headless requires external scripting to connect without "
|
|
161
|
+
"the GUI; on this setup it did not — use the GUI, or raise --timeout "
|
|
162
|
+
"if this machine is just slow to boot Resolve.", file=sys.stderr)
|
|
163
|
+
else:
|
|
164
|
+
print(f"could not kill pid {proc.pid} — remove it by hand (kill -9 {proc.pid}).",
|
|
165
|
+
file=sys.stderr)
|
|
139
166
|
return 4
|
|
140
167
|
print(f"ready in {time.monotonic() - started:.1f}s")
|
|
141
168
|
return 0
|
|
142
169
|
|
|
143
170
|
|
|
144
|
-
def stop(timeout: float) -> int:
|
|
171
|
+
def stop(timeout: float, force: bool = False) -> int:
|
|
145
172
|
mode = rr.runtime_mode()
|
|
146
173
|
if not mode["running"]:
|
|
147
174
|
print("nothing to stop")
|
|
148
175
|
return 0
|
|
149
176
|
resolve = _connect()
|
|
150
177
|
if resolve is None:
|
|
151
|
-
|
|
152
|
-
|
|
178
|
+
if not force:
|
|
179
|
+
print("REFUSE: Resolve is running but not answering; not killing it. "
|
|
180
|
+
"If it never became scriptable (a wedged -nogui boot, a stuck "
|
|
181
|
+
"modal), rerun with --force to TERM/KILL it — unclean: expect "
|
|
182
|
+
"project locks and a slow next boot.", file=sys.stderr)
|
|
183
|
+
return 5
|
|
184
|
+
return _force_stop(timeout)
|
|
153
185
|
started = time.monotonic()
|
|
154
186
|
resolve.Quit()
|
|
155
187
|
while time.monotonic() - started < timeout:
|
|
@@ -157,10 +189,55 @@ def stop(timeout: float) -> int:
|
|
|
157
189
|
if not (rr.resolve_processes() or []):
|
|
158
190
|
print(f"stopped in {time.monotonic() - started:.1f}s")
|
|
159
191
|
return 0
|
|
160
|
-
|
|
192
|
+
if force:
|
|
193
|
+
print(f"still running {timeout:.0f}s after Quit(); escalating.", file=sys.stderr)
|
|
194
|
+
return _force_stop(timeout)
|
|
195
|
+
print(f"FAILED: still running {timeout:.0f}s after Quit(). "
|
|
196
|
+
f"Rerun with --force to TERM/KILL it.", file=sys.stderr)
|
|
161
197
|
return 6
|
|
162
198
|
|
|
163
199
|
|
|
200
|
+
def _force_stop(timeout: float) -> int:
|
|
201
|
+
"""TERM, then KILL, every running Resolve process (unix only)."""
|
|
202
|
+
import platform
|
|
203
|
+
import signal
|
|
204
|
+
|
|
205
|
+
if platform.system().lower() == "windows":
|
|
206
|
+
print("FAILED: --force is unix-only here; use `taskkill /F /IM Resolve.exe`.",
|
|
207
|
+
file=sys.stderr)
|
|
208
|
+
return 7
|
|
209
|
+
|
|
210
|
+
def pids() -> List[int]:
|
|
211
|
+
found: set = set()
|
|
212
|
+
for name in ("Resolve", "resolve"):
|
|
213
|
+
out = subprocess.run(["pgrep", "-x", name], capture_output=True,
|
|
214
|
+
text=True, check=False)
|
|
215
|
+
found.update(int(p) for p in out.stdout.split() if p.strip().isdigit())
|
|
216
|
+
return sorted(found)
|
|
217
|
+
|
|
218
|
+
for sig, grace in ((signal.SIGTERM, min(timeout, 10.0)), (signal.SIGKILL, min(timeout, 10.0))):
|
|
219
|
+
targets = pids()
|
|
220
|
+
if not targets:
|
|
221
|
+
print("force-stopped (unclean — expect a slower next boot).")
|
|
222
|
+
return 0
|
|
223
|
+
for pid in targets:
|
|
224
|
+
print(f"sending {sig.name} to pid {pid}", file=sys.stderr)
|
|
225
|
+
try:
|
|
226
|
+
os.kill(pid, sig)
|
|
227
|
+
except ProcessLookupError:
|
|
228
|
+
pass
|
|
229
|
+
except PermissionError:
|
|
230
|
+
print(f"no permission to signal pid {pid}", file=sys.stderr)
|
|
231
|
+
deadline = time.monotonic() + grace
|
|
232
|
+
while time.monotonic() < deadline:
|
|
233
|
+
time.sleep(0.5)
|
|
234
|
+
if not pids():
|
|
235
|
+
print("force-stopped (unclean — expect a slower next boot).")
|
|
236
|
+
return 0
|
|
237
|
+
print("FAILED: Resolve survived SIGKILL; inspect by hand.", file=sys.stderr)
|
|
238
|
+
return 7
|
|
239
|
+
|
|
240
|
+
|
|
164
241
|
def cmd_run(command: List[str], timeout: float) -> int:
|
|
165
242
|
"""Guard, start if needed, run the command, stop only what we started."""
|
|
166
243
|
mode = rr.runtime_mode()
|
|
@@ -200,7 +277,10 @@ def main() -> int:
|
|
|
200
277
|
sub.add_parser("status")
|
|
201
278
|
sub.add_parser("guard")
|
|
202
279
|
sub.add_parser("start")
|
|
203
|
-
sub.add_parser("stop")
|
|
280
|
+
stop_p = sub.add_parser("stop")
|
|
281
|
+
stop_p.add_argument("--force", action="store_true",
|
|
282
|
+
help="escalate to TERM/KILL when Resolve is running but not "
|
|
283
|
+
"answering, or survives Quit() (unclean shutdown)")
|
|
204
284
|
run = sub.add_parser("run")
|
|
205
285
|
run.add_argument("argv", nargs=argparse.REMAINDER,
|
|
206
286
|
help="command to run; put it after a bare --")
|
|
@@ -213,7 +293,7 @@ def main() -> int:
|
|
|
213
293
|
if args.command == "start":
|
|
214
294
|
return start(args.timeout)
|
|
215
295
|
if args.command == "stop":
|
|
216
|
-
return stop(args.timeout)
|
|
296
|
+
return stop(args.timeout, force=getattr(args, "force", False))
|
|
217
297
|
argv = [a for a in args.argv if a != "--"]
|
|
218
298
|
if not argv:
|
|
219
299
|
print("run: nothing to run (put the command after `--`)", file=sys.stderr)
|
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "2.
|
|
90
|
+
VERSION = "2.133.0"
|
|
91
91
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
92
92
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
93
93
|
logger.info(f"Detected platform: {get_platform()}")
|