davinci-resolve-mcp 2.73.2 → 2.75.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 +45 -0
- package/README.md +1 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/server/aaf_probe.py +145 -2
- package/resolve-advanced/vendor/drp-format/__tests__/set-framerate.test.js +62 -0
- package/resolve-advanced/vendor/drp-format/index.js +4 -0
- package/resolve-advanced/vendor/drp-format/set-framerate.js +97 -0
- package/resolve-advanced/vendor/drx-parameters/index.js +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,51 @@
|
|
|
2
2
|
|
|
3
3
|
Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
|
|
4
4
|
|
|
5
|
+
## What's New in v2.75.0
|
|
6
|
+
|
|
7
|
+
The offline AAF reader now recovers retime ratios instead of only flagging them.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Motion Control speed recovery.** OperationGroup parameters are read: a
|
|
12
|
+
constant `SpeedRatio` emits `speedRatio` (play rate) and corrects `speed`, so
|
|
13
|
+
consumers reading only `speed` are no longer told 100 for a 175% clip. Variable
|
|
14
|
+
timewarps (multi-point speed maps) report `speedVarying: true` rather than a
|
|
15
|
+
fabricated number — the reader's honest-refuse contract extends to speeds.
|
|
16
|
+
Note the stored AAF rational is RECORD/SOURCE (Edit Protocol output-over-input),
|
|
17
|
+
the inverse of play rate; the reader emits play rate, verified against the
|
|
18
|
+
length identity (sourceLen = recordLen / |ratio|) and Avid's own speed maps.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **Motion Control events inflated `recOut`.** Record advancement used the inner
|
|
23
|
+
source clip's length instead of the OperationGroup's declared record length, so
|
|
24
|
+
fast-motion clips claimed more record time than they occupy (and slow motion
|
|
25
|
+
claimed less). On a real 83-minute turnover this produced 40 spurious record
|
|
26
|
+
overlaps; with the declared length driving advancement, one remains — a
|
|
27
|
+
two-input blend genuinely sharing its record span.
|
|
28
|
+
|
|
29
|
+
## What's New in v2.74.0
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **`drp-format/set-framerate` — relabel a `.drp` timeline frame rate in place.**
|
|
34
|
+
`setTimelineFrameRate(drpInput, targetFps)` rewrites the timeline
|
|
35
|
+
`<FrameRate>` blob(s) to a new fps while leaving every clip's integer
|
|
36
|
+
Start/Duration/In/Out and every clip-level `<MediaFrameRate>` untouched — a
|
|
37
|
+
relabel, not a retime. Use it to fix a contaminated rate tag (e.g. an export
|
|
38
|
+
step that stamped 23.976 onto a 24.000 timeline whose frames are correct).
|
|
39
|
+
Offline-only by necessity: Resolve locks a timeline's frame rate once the
|
|
40
|
+
timeline exists, so an imported `.drp` can never be relabelled through
|
|
41
|
+
Resolve itself. `readTimelineFrameRates(drpInput)` reports the current
|
|
42
|
+
rate(s) without modifying anything. Both are exported from the `drp-format`
|
|
43
|
+
index; five node:test cases cover relabel, `MediaFrameRate` isolation,
|
|
44
|
+
idempotence, and input validation.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- Corrected a garbled doc comment in `drx-parameters/index.js`.
|
|
49
|
+
|
|
5
50
|
## What's New in v2.73.2
|
|
6
51
|
|
|
7
52
|
Two honesty fixes in the conform path, both found by running a real 83-minute
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
package/install.py
CHANGED
|
@@ -36,7 +36,7 @@ from src.utils.update_check import (
|
|
|
36
36
|
|
|
37
37
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
38
38
|
|
|
39
|
-
VERSION = "2.
|
|
39
|
+
VERSION = "2.75.0"
|
|
40
40
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
41
41
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
42
42
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -19,6 +19,16 @@ trusting, so the Node server shells out to this helper, which uses the pure-Pyth
|
|
|
19
19
|
Normalized event shape mirrors resolve-advanced/server/editorial.mjs `evt()`:
|
|
20
20
|
{ index, track, source, srcIn, srcOut, recIn, recOut, speed, reverse, transition, fps }
|
|
21
21
|
|
|
22
|
+
Retime (motion-effect) events additionally carry `"effect"` and, when the ratio is
|
|
23
|
+
recoverable from the OperationGroup's parameters (see _retime_fields):
|
|
24
|
+
* constant ratio → `"speedRatio"`: play-rate float (1.75 = 175%), `"speed"`:
|
|
25
|
+
round(playRate*100), `"reverse"`: true for backwards play.
|
|
26
|
+
* variable speed → `"speedVarying": true` and speed stays 100 — a timewarp has
|
|
27
|
+
no single honest number, so none is fabricated.
|
|
28
|
+
* unrecoverable → the flag alone, speed stays 100 (unchanged old contract).
|
|
29
|
+
For retimes, srcIn/srcOut are the SOURCE-side range while recIn/recOut span the
|
|
30
|
+
OperationGroup's DECLARED (record) length — they differ by the ratio.
|
|
31
|
+
|
|
22
32
|
Honest-refuse discipline (no fake parses):
|
|
23
33
|
* exit 3 → pyaaf2 not installed (stderr: AAF_PROBE_NO_PYAAF2)
|
|
24
34
|
* exit 4 → file unreadable / not an AAF (stderr: AAF_PROBE_UNREADABLE: <detail>)
|
|
@@ -40,6 +50,8 @@ Segment model (Avid Media Composer picture turnovers):
|
|
|
40
50
|
* Sequence — ordered `.components`, laid end to end.
|
|
41
51
|
* OperationGroup — effect wrapper. Its `.segments` are the effect INPUTS, and the
|
|
42
52
|
primary input is usually a nested Sequence (not a bare SourceClip).
|
|
53
|
+
Its `.parameters` carry the retime ratio for motion effects, and
|
|
54
|
+
its own declared length is the RECORD duration of the effect.
|
|
43
55
|
* Selector — an enabled/disabled layer variant; the live one is `Selected`.
|
|
44
56
|
* ScopeReference — "show the NestedScope layer beneath me": real record time, no
|
|
45
57
|
clip of this layer's own. Treated as a gap, like Filler.
|
|
@@ -224,6 +236,123 @@ def _operation_name(comp):
|
|
|
224
236
|
return ""
|
|
225
237
|
|
|
226
238
|
|
|
239
|
+
# ── Retime (Motion Control) parameter recovery ─────────────────────────────────
|
|
240
|
+
# Avid stores a retime's ratio on the OperationGroup's PARAMETERS. Verified against
|
|
241
|
+
# real Media Composer turnovers by cross-checking the inner SourceClip length vs the
|
|
242
|
+
# group's declared record length AND the PARAM_SPEED_MAP_U control-point values:
|
|
243
|
+
# * "SpeedRatio" (AAF Edit Protocol ParameterDef, a ConstantValue rational) is the
|
|
244
|
+
# RECORD/SOURCE length ratio — i.e. the INVERSE of the play rate. A 175% fast
|
|
245
|
+
# motion is stored as 4/7 (100 record frames consume 175 source frames); reverse
|
|
246
|
+
# play is a NEGATIVE rational (-1/1 = 100% backwards). Mixed-rate pulldown
|
|
247
|
+
# wrappers appear as 1000/1001.
|
|
248
|
+
# * "PARAM_SPEED_MAP_U" (Avid, a VaryingValue) has control points whose VALUES are
|
|
249
|
+
# play-rate scalars directly (1.75 = 175%, negative = reverse). More than one
|
|
250
|
+
# distinct value means the speed VARIES across the clip.
|
|
251
|
+
# * "PARAM_SPEED_RATIO_U" (Avid, a ConstantValue) belongs to the same *_U family
|
|
252
|
+
# as the speed map, so its value is a play-rate scalar, not a SpeedRatio.
|
|
253
|
+
|
|
254
|
+
# The AAF Edit Protocol parameter-definition id for SpeedRatio, so a file whose
|
|
255
|
+
# dictionary lost the human name still resolves.
|
|
256
|
+
_SPEED_RATIO_AUID = "72559a80-24d7-11d3-8a50-0050040ef7d2"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _op_parameters(op_group):
|
|
260
|
+
"""An OperationGroup's Parameter objects; [] when absent or unreadable."""
|
|
261
|
+
try:
|
|
262
|
+
prop = getattr(op_group, "parameters", None)
|
|
263
|
+
if prop is None:
|
|
264
|
+
return []
|
|
265
|
+
value = getattr(prop, "value", None)
|
|
266
|
+
return list(value if value is not None else prop)
|
|
267
|
+
except Exception:
|
|
268
|
+
return []
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _param_name(param):
|
|
272
|
+
try:
|
|
273
|
+
return str(param.name or "")
|
|
274
|
+
except Exception:
|
|
275
|
+
return ""
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _param_is(param, name, auid=None):
|
|
279
|
+
if _param_name(param) == name:
|
|
280
|
+
return True
|
|
281
|
+
if auid:
|
|
282
|
+
try:
|
|
283
|
+
return str(param.auid).lower() == auid
|
|
284
|
+
except Exception:
|
|
285
|
+
return False
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _pointlist_values(varying):
|
|
290
|
+
"""Control-point VALUES of a VaryingValue's point list, or None if unreadable."""
|
|
291
|
+
points = getattr(varying, "pointlist", None)
|
|
292
|
+
if points is None:
|
|
293
|
+
return None
|
|
294
|
+
inner = getattr(points, "value", None)
|
|
295
|
+
if inner is not None:
|
|
296
|
+
points = inner
|
|
297
|
+
try:
|
|
298
|
+
return [float(p.value) for p in points]
|
|
299
|
+
except Exception:
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _retime_fields(op_group):
|
|
304
|
+
"""Extra event fields recovered from a retime OperationGroup's parameters.
|
|
305
|
+
|
|
306
|
+
Returns one of:
|
|
307
|
+
{"speedRatio": <play-rate float>, "speed": <int %>, "reverse": <bool>}
|
|
308
|
+
{"speedVarying": True} — a variable-speed timewarp; no single honest number
|
|
309
|
+
{} — nothing recoverable (flag-only, speed stays 100)
|
|
310
|
+
"""
|
|
311
|
+
play = None
|
|
312
|
+
speed_map = None
|
|
313
|
+
for param in _op_parameters(op_group):
|
|
314
|
+
cls = type(param).__name__
|
|
315
|
+
if cls == "VaryingValue":
|
|
316
|
+
if _param_name(param) == "PARAM_SPEED_MAP_U":
|
|
317
|
+
speed_map = param
|
|
318
|
+
continue
|
|
319
|
+
if cls != "ConstantValue":
|
|
320
|
+
continue
|
|
321
|
+
if _param_is(param, "SpeedRatio", _SPEED_RATIO_AUID):
|
|
322
|
+
try:
|
|
323
|
+
value = param.value
|
|
324
|
+
num = int(value.numerator)
|
|
325
|
+
den = int(value.denominator)
|
|
326
|
+
except Exception:
|
|
327
|
+
continue
|
|
328
|
+
if num:
|
|
329
|
+
play = den / num # stored record/source → play rate is the inverse
|
|
330
|
+
elif _param_name(param) == "PARAM_SPEED_RATIO_U" and play is None:
|
|
331
|
+
try:
|
|
332
|
+
value = float(param.value)
|
|
333
|
+
except Exception:
|
|
334
|
+
continue
|
|
335
|
+
if value:
|
|
336
|
+
play = value # *_U family stores the play rate directly
|
|
337
|
+
if speed_map is not None:
|
|
338
|
+
values = _pointlist_values(speed_map)
|
|
339
|
+
if values is None:
|
|
340
|
+
# A speed map we cannot read: we can neither call the speed constant
|
|
341
|
+
# nor prove it varies — recover nothing rather than guess.
|
|
342
|
+
return {}
|
|
343
|
+
if len(set(values)) > 1:
|
|
344
|
+
return {"speedVarying": True}
|
|
345
|
+
if play is None and values and values[0]:
|
|
346
|
+
play = values[0] # a flat map's single value IS the constant play rate
|
|
347
|
+
if not play:
|
|
348
|
+
return {}
|
|
349
|
+
return {
|
|
350
|
+
"speedRatio": round(abs(play), 6),
|
|
351
|
+
"speed": int(round(abs(play) * 100)),
|
|
352
|
+
"reverse": play < 0,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
227
356
|
def _walk_segment(segment, *, track, fps, rec, state, depth=0, transition=None):
|
|
228
357
|
"""
|
|
229
358
|
Emit normalized events for ONE segment placed at record position `rec`.
|
|
@@ -289,10 +418,24 @@ def _walk_segment(segment, *, track, fps, rec, state, depth=0, transition=None):
|
|
|
289
418
|
_walk_segment(inp, track=track, fps=fps, rec=rec, state=state, depth=depth + 1, transition=transition)
|
|
290
419
|
op_name = _operation_name(segment)
|
|
291
420
|
if op_name and ("speed" in op_name.lower() or "motion" in op_name.lower()):
|
|
292
|
-
#
|
|
293
|
-
#
|
|
421
|
+
# A retime. Its ratio is recoverable from the group's PARAMETERS (see
|
|
422
|
+
# _retime_fields): a constant ratio updates speed/speedRatio so
|
|
423
|
+
# consumers reading only `speed` are no longer told 100; a variable
|
|
424
|
+
# timewarp is reported as speedVarying: true; an unreadable one keeps
|
|
425
|
+
# the old flag-only contract. A number is never fabricated.
|
|
426
|
+
extra = _retime_fields(segment)
|
|
294
427
|
for ev in state["events"][before:]:
|
|
295
428
|
ev["effect"] = op_name
|
|
429
|
+
ev.update(extra)
|
|
430
|
+
# The group's DECLARED length is the RECORD duration; the inner
|
|
431
|
+
# SourceClip's length is the SOURCE-side range — under a retime
|
|
432
|
+
# they differ by the ratio, so an event whose recOut was advanced
|
|
433
|
+
# by the source length inflates (fast motion) or undershoots
|
|
434
|
+
# (slow motion) its real record span. The container declared-
|
|
435
|
+
# length rule (this file's convention for rec advancement)
|
|
436
|
+
# applies to the events too.
|
|
437
|
+
if declared > 0:
|
|
438
|
+
ev["recOut"] = max(ev["recIn"], rec + declared)
|
|
296
439
|
return declared
|
|
297
440
|
|
|
298
441
|
# Unknown component — advance by its declared length, don't fake an event, and
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const JSZip = require('jszip');
|
|
6
|
+
const { setTimelineFrameRate, readTimelineFrameRates } = require('../set-framerate');
|
|
7
|
+
|
|
8
|
+
// A real Resolve export with one media clip — carries a timeline <FrameRate> blob
|
|
9
|
+
// and a clip-level <MediaFrameRate> that a relabel must NOT touch.
|
|
10
|
+
const TEMPLATE = path.join(__dirname, '..', 'templates', 'media-clip-h264.drp');
|
|
11
|
+
|
|
12
|
+
async function countTag(buf, tag) {
|
|
13
|
+
const zip = await JSZip.loadAsync(buf);
|
|
14
|
+
let n = 0;
|
|
15
|
+
for (const name of Object.keys(zip.files)) {
|
|
16
|
+
if (!name.endsWith('.xml')) continue;
|
|
17
|
+
const xml = await zip.files[name].async('string');
|
|
18
|
+
n += (xml.match(new RegExp(`<${tag}>[0-9a-fA-F]{32}</${tag}>`, 'g')) || []).length;
|
|
19
|
+
}
|
|
20
|
+
return n;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('readTimelineFrameRates reports the template timeline rate', async () => {
|
|
24
|
+
const rates = await readTimelineFrameRates(TEMPLATE);
|
|
25
|
+
assert.ok(rates.length > 0, 'expected at least one timeline FrameRate blob');
|
|
26
|
+
for (const r of rates) {
|
|
27
|
+
assert.ok(Number.isFinite(r.fps) && r.fps > 0, `decoded fps should be positive, got ${r.fps}`);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('setTimelineFrameRate relabels every timeline FrameRate blob', async () => {
|
|
32
|
+
const before = await readTimelineFrameRates(TEMPLATE);
|
|
33
|
+
const target = Math.abs(before[0].fps - 25) < 1e-6 ? 24 : 25;
|
|
34
|
+
|
|
35
|
+
const { buffer, changes, timelineFrameRates } = await setTimelineFrameRate(TEMPLATE, target);
|
|
36
|
+
assert.ok(changes.length > 0, 'a real rate change must be recorded');
|
|
37
|
+
assert.deepStrictEqual(timelineFrameRates, before.map(r => r.fps));
|
|
38
|
+
|
|
39
|
+
const after = await readTimelineFrameRates(buffer);
|
|
40
|
+
assert.strictEqual(after.length, before.length, 'blob count must not change');
|
|
41
|
+
for (const r of after) assert.ok(Math.abs(r.fps - target) < 1e-6, `expected ${target}, got ${r.fps}`);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('relabel leaves clip-level MediaFrameRate blobs untouched', async () => {
|
|
45
|
+
const original = fs.readFileSync(TEMPLATE);
|
|
46
|
+
const mediaBefore = await countTag(original, 'MediaFrameRate');
|
|
47
|
+
const { buffer } = await setTimelineFrameRate(original, 30);
|
|
48
|
+
const mediaAfter = await countTag(buffer, 'MediaFrameRate');
|
|
49
|
+
assert.strictEqual(mediaAfter, mediaBefore, 'MediaFrameRate must be left alone');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('relabel is idempotent: same target twice yields no recorded change the second time', async () => {
|
|
53
|
+
const first = await setTimelineFrameRate(TEMPLATE, 25);
|
|
54
|
+
const second = await setTimelineFrameRate(first.buffer, 25);
|
|
55
|
+
assert.strictEqual(second.changes.length, 0, 'second pass at same fps records no change');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('rejects invalid targets', async () => {
|
|
59
|
+
await assert.rejects(() => setTimelineFrameRate(TEMPLATE, 0), /positive finite number/);
|
|
60
|
+
await assert.rejects(() => setTimelineFrameRate(TEMPLATE, NaN), /positive finite number/);
|
|
61
|
+
await assert.rejects(() => setTimelineFrameRate(TEMPLATE, 'fast'), /positive finite number/);
|
|
62
|
+
});
|
|
@@ -153,6 +153,10 @@ module.exports = {
|
|
|
153
153
|
// Author a project with one media clip referencing an arbitrary h264 file, from scratch [P8].
|
|
154
154
|
addMediaClip: require('./author-project').addMediaClip,
|
|
155
155
|
|
|
156
|
+
// Relabel the timeline frame rate tag in place (not a retime — frame counts untouched).
|
|
157
|
+
setTimelineFrameRate: require('./set-framerate').setTimelineFrameRate,
|
|
158
|
+
readTimelineFrameRates: require('./set-framerate').readTimelineFrameRates,
|
|
159
|
+
|
|
156
160
|
// Offline media relink — repoint media to new paths in the Media Pool blobs (no Resolve).
|
|
157
161
|
relinkMedia: require('./relink-media').relinkMedia,
|
|
158
162
|
// Relink + fix cached specs (resolution/frames/fps) for a differently-formatted file [P8].
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* set-framerate — **relabel** a Resolve `.drp` timeline frame rate in place.
|
|
3
|
+
*
|
|
4
|
+
* This is a *relabel*, not a retime: it rewrites the timeline `FrameRate` blob(s) to a new fps
|
|
5
|
+
* while leaving every clip's integer Start/Duration/In/Out and every `MediaFrameRate` (clip
|
|
6
|
+
* *source* rate) untouched. The frame count is identical; only the rate the timeline is
|
|
7
|
+
* interpreted at changes. Use this to fix a contaminated tag — e.g. a caption/export step that
|
|
8
|
+
* stamped 23.976 onto a 24.000 timeline whose frames are actually correct.
|
|
9
|
+
*
|
|
10
|
+
* Why offline: Resolve locks a timeline's frame rate once the timeline exists (writable only when
|
|
11
|
+
* a project has 0 timelines), so an imported `.drp` can never be relabelled *through* Resolve —
|
|
12
|
+
* only retimed.
|
|
13
|
+
*
|
|
14
|
+
* Where fps lives in a real DRP (verified against templates/media-clip-h264.drp, a Resolve export):
|
|
15
|
+
* - timeline rate → `<FrameRate>[double fps][double 0]</FrameRate>` in MediaPool/<folder>/MpFolder.xml
|
|
16
|
+
* - clip source → `<MediaFrameRate>…</MediaFrameRate>` (LEFT ALONE — different tag)
|
|
17
|
+
* - project.xml / Gallery.xml carry no fps.
|
|
18
|
+
* The exact tag `<FrameRate>` never substring-matches `<MediaFrameRate>` (char before "FrameRate"
|
|
19
|
+
* is "a", not "<"), so a `<FrameRate>`-scoped rewrite is inherently clip-safe.
|
|
20
|
+
*
|
|
21
|
+
* @module drp-format/set-framerate
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const JSZip = require('jszip');
|
|
26
|
+
const { decodeRateBlob, encodeRateBlob } = require('./media-blobs');
|
|
27
|
+
|
|
28
|
+
// Exact timeline FrameRate blob element. `[^]` (not `.`) so a stray newline can't break the match.
|
|
29
|
+
const FRAMERATE_BLOB_RE = /<FrameRate>([0-9a-fA-F]{32})<\/FrameRate>/g;
|
|
30
|
+
|
|
31
|
+
async function loadZip(drpInput) {
|
|
32
|
+
const buf = Buffer.isBuffer(drpInput) ? drpInput : await fs.promises.readFile(drpInput);
|
|
33
|
+
return JSZip.loadAsync(buf);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Relabel every timeline FrameRate blob in a `.drp` to `targetFps`.
|
|
38
|
+
*
|
|
39
|
+
* @param {string|Buffer} drpInput Path to a `.drp` (or its Buffer).
|
|
40
|
+
* @param {number} targetFps New timeline fps (e.g. 24, 23.976, 25).
|
|
41
|
+
* @returns {Promise<{ buffer: Buffer, changes: Array<{entry:string, from:number, to:number}>, timelineFrameRates:number[] }>}
|
|
42
|
+
* @throws if the target is not a finite positive number or no timeline FrameRate blob is found.
|
|
43
|
+
*/
|
|
44
|
+
async function setTimelineFrameRate(drpInput, targetFps) {
|
|
45
|
+
if (typeof targetFps !== 'number' || !Number.isFinite(targetFps) || targetFps <= 0) {
|
|
46
|
+
throw new Error(`set-framerate: targetFps must be a positive finite number, got ${targetFps}`);
|
|
47
|
+
}
|
|
48
|
+
const zip = await loadZip(drpInput);
|
|
49
|
+
const newHex = encodeRateBlob(targetFps); // [double fps][double 0], 32 hex chars
|
|
50
|
+
|
|
51
|
+
const entries = [];
|
|
52
|
+
zip.forEach((p, e) => { if (!e.dir && /\.xml$/i.test(p)) entries.push(p); });
|
|
53
|
+
|
|
54
|
+
const changes = [];
|
|
55
|
+
const seenRates = [];
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
const xml = await zip.file(entry).async('string');
|
|
58
|
+
if (!FRAMERATE_BLOB_RE.test(xml)) continue;
|
|
59
|
+
FRAMERATE_BLOB_RE.lastIndex = 0;
|
|
60
|
+
const next = xml.replace(FRAMERATE_BLOB_RE, (_m, hex) => {
|
|
61
|
+
const from = decodeRateBlob(hex);
|
|
62
|
+
if (from != null) seenRates.push(from);
|
|
63
|
+
// Round-trip guard: only record a real change; still rewrite so the blob's
|
|
64
|
+
// trailing 8 bytes are canonicalised to zero.
|
|
65
|
+
if (from == null || Math.abs(from - targetFps) > 1e-6) {
|
|
66
|
+
changes.push({ entry, from: from ?? NaN, to: targetFps });
|
|
67
|
+
}
|
|
68
|
+
return `<FrameRate>${newHex}</FrameRate>`;
|
|
69
|
+
});
|
|
70
|
+
zip.file(entry, next);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (seenRates.length === 0) {
|
|
74
|
+
throw new Error('set-framerate: no timeline <FrameRate> blob found — not a .drp with a timeline?');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
78
|
+
return { buffer, changes, timelineFrameRates: seenRates };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Read-only: report the timeline frame rate(s) in a `.drp` without modifying it. */
|
|
82
|
+
async function readTimelineFrameRates(drpInput) {
|
|
83
|
+
const zip = await loadZip(drpInput);
|
|
84
|
+
const rates = [];
|
|
85
|
+
const entries = [];
|
|
86
|
+
zip.forEach((p, e) => { if (!e.dir && /\.xml$/i.test(p)) entries.push(p); });
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
const xml = await zip.file(entry).async('string');
|
|
89
|
+
for (const m of xml.matchAll(FRAMERATE_BLOB_RE)) {
|
|
90
|
+
const fps = decodeRateBlob(m[1]);
|
|
91
|
+
if (fps != null) rates.push({ entry, fps });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return rates;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { setTimelineFrameRate, readTimelineFrameRates };
|
package/src/granular/common.py
CHANGED
|
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
|
|
|
85
85
|
handlers=[logging.StreamHandler()],
|
|
86
86
|
)
|
|
87
87
|
|
|
88
|
-
VERSION = "2.
|
|
88
|
+
VERSION = "2.75.0"
|
|
89
89
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
90
90
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
91
91
|
logger.info(f"Detected platform: {get_platform()}")
|