davinci-resolve-mcp 2.105.0 → 2.107.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 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 v2.107.0
6
+
7
+ **Multi-source media authoring.** `drt.assemble`'s media support grows from
8
+ one source to many: `media` accepts an array of `{mediaFilePath, spec, cuts}`
9
+ sources, each cut landing on the shared V1/A1 with its own source's
10
+ transplanted native descriptors. New plumbing: `insertMediaElement` appends
11
+ additional native pool elements into MpFolder's MediaVec (folder-parent id
12
+ adopted), and `cutSourceIntoClips` accepts a per-cut `mediaRef` so each clone
13
+ points at ITS source. Multi-source strictly requires a captured native
14
+ template for every source (the render-verified transplant path); the refusal
15
+ names `media_pool.capture_media_template` per missing file — a repoint
16
+ fallback that renders black across N sources would be a trap, not a feature.
17
+
18
+ Live-verified end to end on Studio 19.1.3.7 with luma fingerprints: a
19
+ timeline interleaving cuts from two sources (testsrc + solid white) imported
20
+ 6/6 linked and rendered each cut's OWN pixels — YAVG 125.6 / 234 / 125.5
21
+ across the three cuts, matching each source's signature exactly.
22
+
23
+ ## What's New in v2.106.0
24
+
25
+ **Media clips in native DRT authoring — cut real footage into an importable,
26
+ RENDERING timeline.** The deepest silent-failure class this repo has hit, run
27
+ to ground and shipped, live-verified at every step on Studio 19.1.3.7.
28
+
29
+ ### The discovery chain
30
+
31
+ Adding media cuts to drt.assemble surfaced three buried traps in sequence.
32
+ First: repointing the bundled media template at a new file left the pool
33
+ entry's compressed identity blobs describing the ORIGINAL capture source —
34
+ and when that file still exists on the machine, Resolve silently links IT
35
+ (observed: authored timelines linked a client clip while every visible field
36
+ said the right path). Second: after teaching the Clip identity blobs the new
37
+ path (their layout: dir, filename, ctime-format mtime string, codec tag,
38
+ uuid, mtime-in-MICROSECONDS — a field first misread as file size), imports
39
+ read back perfectly and still failed to render: "Full resolution media not
40
+ found". Third: the render engine validates the pool entry's DEEP descriptors
41
+ (Radiometry, keyed-dict FieldsBlobs, stream data) that offline code cannot
42
+ synthesize. Structural readback cannot see any of this — only rendering can.
43
+
44
+ ### The architecture that works
45
+
46
+ - **`media_pool.capture_media_template(media_path)`** (live, once per file):
47
+ builds a disposable project around the file, lets Resolve describe it
48
+ natively, caches the pool media element + MediaRef id under
49
+ ~/.config/davinci-resolve-mcp/media-templates/, and switches your project
50
+ back.
51
+ - **`drt.assemble` grows media support**: `media: {mediaFilePath, spec,
52
+ cuts: [{startFrame, durationFrames, srcIn}]}` cuts ONE source into N
53
+ placements (new cut-media vendor primitive: donor clip cloned with fresh
54
+ DbIds and per-cut geometry on video + audio tracks; placement guards refuse
55
+ cuts before the timeline origin and reads past the media's end). At build
56
+ time the cached native element is TRANSPLANTED and MediaRefs rewired —
57
+ rendered output then matches a natively built timeline exactly (YAVG
58
+ 125.6/123.2 across cuts vs 123.2 native control). Without a cache the
59
+ result carries mediaDescriptor: 'repoint-fallback' and a warning naming
60
+ the capture action.
61
+ - **Version-matched templates**: a Resolve-21 template stamped down to 19
62
+ imports and reads back perfectly — and renders BLACK (the stamp clears the
63
+ gate, not the blob semantics). Both template generations now ship ('21'
64
+ original, '19' captured from 19.1.3.7); drt.assemble picks by
65
+ targetAppVersion.
66
+
67
+ ### Fixed
68
+
69
+ - `render.verify_output` never verifies a job whose JobStatus is not
70
+ Complete (a Failed job's stub passed the duration-ratio check during this
71
+ hunt).
72
+ - The repoint fallback's Clip identity blobs are now written with the
73
+ measured field semantics (mtime-µs, ctime string, dropped stale fields).
74
+
75
+ All of it is an api_truth entry: imported media renders only with NATIVE
76
+ pool descriptors; render-verify authored timelines, because structural
77
+ readback cannot see this class.
78
+
5
79
  ## What's New in v2.105.0
6
80
 
7
81
  **Native-schema DRT authoring — the parked "project, not a lap" — shipped.**
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.105.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.107.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.105.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.107.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.105.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.107.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.105.0"
40
+ VERSION = "2.107.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.105.0",
3
+ "version": "2.107.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -33,7 +33,7 @@ const assembleSchema = z.object({
33
33
  spec: z
34
34
  .object({})
35
35
  .passthrough()
36
- .describe("assembleTimeline spec: { timelineName?, elements: [{type:'title'|'generator', track, startFrame, durationFrames?, text?, ...}], transitions? }. startFrame is timeline-absolute (origin 86400)."),
36
+ .describe("assembleTimeline spec: { timelineName?, media?: {mediaFilePath, spec:{width,height,frameCount,fps}, cuts:[{startFrame,durationFrames,srcIn?}]} | [same, ...] (multi-source needs media_pool.capture_media_template run once per file), elements?: [{type:'title'|'generator', track, startFrame, durationFrames?, text?, ...}], transitions? }. startFrame is timeline-absolute (origin 86400)."),
37
37
  outputPath: z.string().describe('Absolute path where the importable .drt will be written'),
38
38
  targetAppVersion: z
39
39
  .union([z.string(), z.number()])
@@ -198,7 +198,14 @@ export const drtTool = {
198
198
  // assembled + stamped to the host's ProjectVersion imports with every
199
199
  // element intact.
200
200
  const { assembleTimeline } = drp();
201
- const { buffer, timelineName, startFrame } = await assembleTimeline(p.spec);
201
+ // The template GENERATION must match the target host: a Resolve-21
202
+ // template stamped down imports on 19 but renders BLACK (measured) —
203
+ // the stamp clears the gate, not the blob semantics.
204
+ const spec = { ...p.spec };
205
+ if (spec.templateVersion === undefined && p.targetAppVersion !== undefined) {
206
+ spec.templateVersion = parseFloat(p.targetAppVersion) >= 21 ? 21 : 19;
207
+ }
208
+ const { buffer, timelineName, startFrame, mediaDescriptor } = await assembleTimeline(spec);
202
209
  let outBuf = buffer;
203
210
  let stamped = null;
204
211
  if (p.targetAppVersion !== undefined) {
@@ -216,6 +223,17 @@ export const drtTool = {
216
223
  timelineName,
217
224
  startFrame,
218
225
  stamped,
226
+ templateVersion: spec.templateVersion ?? 21,
227
+ mediaDescriptor: mediaDescriptor ?? 'none',
228
+ ...(mediaDescriptor === 'repoint-fallback'
229
+ ? {
230
+ warning:
231
+ 'No native media template cached for this file — the archive imports and reads back ' +
232
+ 'correctly but its media may not RENDER (black frames / "Full resolution media not ' +
233
+ 'found"). Run media_pool.capture_media_template(media_path) once with Resolve open, ' +
234
+ 'then re-assemble for a render-verified transplant.',
235
+ }
236
+ : {}),
219
237
  note:
220
238
  'Import with timeline.import_timeline_checked — the imported timeline is named after the FILE. ' +
221
239
  'On a host older than Resolve 21, pass targetAppVersion or the version gate refuses the archive.',
@@ -21,18 +21,109 @@
21
21
  * @module drp-format/assemble-timeline
22
22
  */
23
23
 
24
- const { createEmptyProject } = require('./author-project');
24
+ const { createEmptyProject, addMediaClip, DEFAULT_START_FRAME } = require('./author-project');
25
+ const { loadMediaTemplate, transplantMediaElement, insertMediaElement } = require('./media-template-cache');
26
+ const JSZip = require('jszip');
27
+ const { cutSourceIntoClips } = require('./cut-media');
25
28
  const { placeFusionTitle } = require('./place-fusion-title');
26
29
  const { placeGenerator } = require('./place-generator');
27
30
  const { placeTransition } = require('./place-transition');
28
31
 
29
32
  async function assembleTimeline(spec = {}) {
30
- const { timelineName, elements = [], transitions = [] } = spec;
33
+ const { timelineName, elements = [], transitions = [], media, templateVersion } = spec;
31
34
  if (!Array.isArray(elements)) throw new TypeError('assembleTimeline: elements must be an array');
32
35
  if (!Array.isArray(transitions)) throw new TypeError('assembleTimeline: transitions must be an array');
33
36
 
34
- const { buffer: base, timelineName: tlName, startFrame } = await createEmptyProject({ timelineName });
35
- let buffer = base;
37
+ let base;
38
+ let mediaDescriptorState = 'none';
39
+ if (media) {
40
+ // Media authoring: cut one or MORE sources into placements. Every source
41
+ // carries {mediaFilePath, spec, cuts}. Multi-source requires a native
42
+ // media template CAPTURED for every source (capture-once transplant is
43
+ // the only measured way authored media renders); single-source may fall
44
+ // back to descriptor repoint, which imports but usually will not render.
45
+ const sources = Array.isArray(media) ? media : [media];
46
+ if (!sources.length) throw new TypeError('assembleTimeline: media must not be empty');
47
+ const caches = sources.map((src) => loadMediaTemplate(src.mediaFilePath));
48
+ if (sources.length > 1) {
49
+ const missing = sources.filter((src, i) => !caches[i]).map((src) => src.mediaFilePath);
50
+ if (missing.length) {
51
+ throw new Error(
52
+ 'assembleTimeline: multi-source authoring requires a captured native media template for EVERY source — ' +
53
+ `missing for: ${missing.join(', ')}. Run media_pool.capture_media_template(path) with Resolve open for each.`,
54
+ );
55
+ }
56
+ }
57
+ const validateCuts = (src, label) => {
58
+ const mediaSpec = src.spec;
59
+ (src.cuts || []).forEach((cut, i) => {
60
+ if (cut.startFrame < DEFAULT_START_FRAME) {
61
+ throw new RangeError(
62
+ `assembleTimeline: ${label}.cuts[${i}].startFrame ${cut.startFrame} is before the timeline origin ${DEFAULT_START_FRAME} — Resolve silently drops it on import`,
63
+ );
64
+ }
65
+ if (mediaSpec && Number.isFinite(mediaSpec.frameCount) && Number.isFinite(mediaSpec.fps)) {
66
+ // srcIn/duration are TIMELINE frames (24fps template); the media's
67
+ // extent converts: frameCount / mediaFps × 24.
68
+ const maxTimelineFrames = Math.floor((mediaSpec.frameCount / mediaSpec.fps) * 24);
69
+ if ((cut.srcIn ?? 0) + cut.durationFrames > maxTimelineFrames) {
70
+ throw new RangeError(
71
+ `assembleTimeline: ${label}.cuts[${i}] reads past the media's end — (srcIn ?? 0) + durationFrames exceeds ${maxTimelineFrames} timeline frames (media ${mediaSpec.frameCount} frames @ ${mediaSpec.fps} fps on the 24fps template timeline)`,
72
+ );
73
+ }
74
+ }
75
+ });
76
+ };
77
+ sources.forEach((src, i) => validateCuts(src, sources.length > 1 ? `media[${i}]` : 'media'));
78
+
79
+ base = await addMediaClip({
80
+ mediaFile: sources[0].mediaFilePath, spec: sources[0].spec, timelineName, templateVersion,
81
+ });
82
+ base.startFrame = DEFAULT_START_FRAME;
83
+
84
+ // Transplant/insert native pool elements BEFORE cutting, so each cut can
85
+ // reference its source's MediaRef.
86
+ let zip = await JSZip.loadAsync(base.buffer);
87
+ const mpPath = 'MediaPool/Master/MpFolder.xml';
88
+ let mpXml = await zip.file(mpPath).async('string');
89
+ const seqNames = Object.keys(zip.files).filter((n) => /SeqContainer\/.+\.xml$/.test(n) || /\/SeqContainer\d*\.xml$/.test(n));
90
+ const seqXmls = [];
91
+ for (const n of seqNames) seqXmls.push(await zip.file(n).async('string'));
92
+ const mediaRefs = [];
93
+ if (caches[0]) {
94
+ const res = transplantMediaElement(mpXml, seqXmls, caches[0]);
95
+ mpXml = res.mpXml;
96
+ for (let i = 0; i < seqNames.length; i += 1) seqXmls[i] = res.seqXmls[i];
97
+ mediaRefs[0] = caches[0].mediaRef;
98
+ mediaDescriptorState = 'native-transplant';
99
+ } else {
100
+ mediaRefs[0] = null; // donor already points at the repointed entry
101
+ mediaDescriptorState = 'repoint-fallback';
102
+ }
103
+ for (let i = 1; i < sources.length; i += 1) {
104
+ mpXml = insertMediaElement(mpXml, caches[i].poolElement);
105
+ mediaRefs[i] = caches[i].mediaRef;
106
+ }
107
+ zip.file(mpPath, mpXml);
108
+ seqNames.forEach((n, i) => zip.file(n, seqXmls[i]));
109
+ base.buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
110
+
111
+ const allCuts = [];
112
+ sources.forEach((src, i) => {
113
+ (src.cuts || []).forEach((cut) => {
114
+ allCuts.push(mediaRefs[i] ? { ...cut, mediaRef: mediaRefs[i] } : { ...cut });
115
+ });
116
+ });
117
+ allCuts.sort((a, b) => a.startFrame - b.startFrame);
118
+ if (allCuts.length) {
119
+ const cutRes = await cutSourceIntoClips(base.buffer, { cuts: allCuts });
120
+ base.buffer = cutRes.buffer;
121
+ }
122
+ } else {
123
+ base = await createEmptyProject({ timelineName, templateVersion });
124
+ }
125
+ const { buffer: baseBuffer, timelineName: tlName, startFrame } = base;
126
+ let buffer = baseBuffer;
36
127
 
37
128
  for (const [i, el] of elements.entries()) {
38
129
  if (!el || typeof el !== 'object') throw new TypeError(`assembleTimeline: elements[${i}] must be an object`);
@@ -59,7 +150,7 @@ async function assembleTimeline(spec = {}) {
59
150
  }));
60
151
  }
61
152
 
62
- return { buffer, timelineName: tlName, startFrame };
153
+ return { buffer, timelineName: tlName, startFrame, mediaDescriptor: mediaDescriptorState };
63
154
  }
64
155
 
65
156
  module.exports = { assembleTimeline };
@@ -28,6 +28,36 @@ const DEFAULT_TIMELINE_NAME = 'Timeline 1';
28
28
  const MEDIA_TEMPLATE_DRP = path.join(__dirname, 'templates', 'media-clip-h264.drp');
29
29
  const MEDIA_TEMPLATE_SPEC = { width: 352, height: 262, frameCount: 4576, fps: 30000 / 1001 };
30
30
  const MEDIA_TEMPLATE_TL_NAME = 'MediaTemplate';
31
+
32
+ // Version-matched template registry. Measured live on Studio 19.1.3.7: an
33
+ // archive built from the Resolve-21 capture and version-STAMPED down imports
34
+ // with every clip reading back correctly — and renders BLACK. The stamp
35
+ // clears the import gate; the R21 blob semantics do not fully parse on 19,
36
+ // so the render tree is empty while the structural readback is perfect (the
37
+ // read-back-fine/render-dead class again). The only working fix is a
38
+ // template captured from the target generation, so both generations ship:
39
+ // '21' (original capture) and '19' (captured from Studio 19.1.3.7,
40
+ // 2026-08-30, identical timeline geometry: start 86400, media clip
41
+ // Start 86400 / Duration 3664 for the same 352x262 4576-frame 29.97 spec).
42
+ const TEMPLATES = {
43
+ 21: {
44
+ empty: TEMPLATE_DRP,
45
+ media: MEDIA_TEMPLATE_DRP,
46
+ mediaSpec: MEDIA_TEMPLATE_SPEC,
47
+ },
48
+ 19: {
49
+ empty: path.join(__dirname, 'templates', 'empty-project-r19.drp'),
50
+ media: path.join(__dirname, 'templates', 'media-clip-r19.drp'),
51
+ mediaSpec: MEDIA_TEMPLATE_SPEC,
52
+ },
53
+ };
54
+
55
+ function templateFor(templateVersion) {
56
+ const key = Number(templateVersion) || 21;
57
+ const entry = TEMPLATES[key >= 21 ? 21 : 19];
58
+ if (!entry) throw new Error(`no template generation for version ${templateVersion}`);
59
+ return entry;
60
+ }
31
61
  // The template timeline begins at 01:00:00:00 (24fps) — clips placed BEFORE this frame are
32
62
  // dropped by Resolve on import. Callers should place at >= startFrame.
33
63
  const DEFAULT_START_FRAME = 86400;
@@ -41,8 +71,8 @@ const DEFAULT_START_FRAME = 86400;
41
71
  * startFrame is the timeline origin (86400 = 01:00:00:00 @ 24fps); place clips at >= this.
42
72
  */
43
73
  async function createEmptyProject(opts = {}) {
44
- const { timelineName } = opts;
45
- const tmpl = await fs.promises.readFile(TEMPLATE_DRP);
74
+ const { timelineName, templateVersion } = opts;
75
+ const tmpl = await fs.promises.readFile(templateFor(templateVersion).empty);
46
76
 
47
77
  if (!timelineName || timelineName === DEFAULT_TIMELINE_NAME) {
48
78
  return { buffer: tmpl, timelineName: DEFAULT_TIMELINE_NAME, startFrame: DEFAULT_START_FRAME };
@@ -74,13 +104,13 @@ async function createEmptyProject(opts = {}) {
74
104
  * @returns {Promise<{buffer:Buffer, timelineName:string, mediaFile:string}>}
75
105
  */
76
106
  async function addMediaClip(opts = {}) {
77
- const { mediaFile, spec, timelineName, durationFrames } = opts;
107
+ const { mediaFile, spec, timelineName, durationFrames, templateVersion } = opts;
78
108
  if (!mediaFile || !path.isAbsolute(mediaFile)) throw new Error('addMediaClip: mediaFile must be an absolute path');
79
109
  if (!spec || ['width', 'height', 'frameCount', 'fps'].some((k) => typeof spec[k] !== 'number')) {
80
110
  throw new Error('addMediaClip: spec must be { width, height, frameCount, fps } (numbers)');
81
111
  }
82
112
 
83
- const tmpl = await fs.promises.readFile(MEDIA_TEMPLATE_DRP);
113
+ const tmpl = await fs.promises.readFile(templateFor(templateVersion).media);
84
114
  // Discover the template's current media path (the "from" for repoint).
85
115
  const zip0 = await JSZip.loadAsync(tmpl);
86
116
  let fromPath = null;
@@ -92,7 +122,7 @@ async function addMediaClip(opts = {}) {
92
122
  if (!fromPath) throw new Error('addMediaClip: could not read template media path');
93
123
 
94
124
  let { buffer } = await repointMedia(tmpl, {
95
- from: fromPath, to: mediaFile, fromSpec: MEDIA_TEMPLATE_SPEC, toSpec: spec,
125
+ from: fromPath, to: mediaFile, fromSpec: templateFor(templateVersion).mediaSpec, toSpec: spec,
96
126
  });
97
127
 
98
128
  // Rename the timeline (lives in the Media Pool).
@@ -0,0 +1,93 @@
1
+ /**
2
+ * cut-media — replace a timeline's single source clip with N cuts of it.
3
+ *
4
+ * The composable half of media authoring: addMediaClip yields a project whose
5
+ * timeline holds ONE clip of the (repointed) source on V1/A1; this clones that
6
+ * donor clip into a cut list — fresh DbIds, per-cut Start / Duration / source
7
+ * In — on both the video and audio tracks, so a real editorial cut of one
8
+ * source becomes an importable timeline. Source-IN semantics per splice-clips:
9
+ * <In> framePos is in TIMELINE frames (verified live), encoded as
10
+ * `frames|LE-double(frames*0.001)`.
11
+ *
12
+ * Single-source by design (the template media pool holds one media entry);
13
+ * multi-source needs media-pool entry synthesis and is refused honestly.
14
+ *
15
+ * @module drp-format/cut-media
16
+ */
17
+
18
+ const {
19
+ loadDrpZip,
20
+ selectTargetSeq,
21
+ splitClipElements,
22
+ getItemsInner,
23
+ setItemsInner,
24
+ freshDbIds,
25
+ getTrackVec,
26
+ replaceTrackVec,
27
+ } = require('./seq-surgery');
28
+ const { clipDbId, setClipStart, setClipDuration, setClipIn } = require('./splice-clips');
29
+
30
+ /**
31
+ * @param {Buffer|string} drpInput
32
+ * @param {object} opts
33
+ * @param {Array<{startFrame:number, durationFrames:number, srcIn?:number}>} opts.cuts
34
+ * Timeline placements. startFrame is timeline-absolute (origin 86400 on the
35
+ * bundled templates — clips before the origin are dropped by Resolve on
36
+ * import, silently). srcIn is the source in-point in TIMELINE frames.
37
+ * @param {string} [opts.timelineUuid]
38
+ * @returns {Promise<{buffer: Buffer, cutCount: number, clipDbIds: string[]}>}
39
+ */
40
+ async function cutSourceIntoClips(drpInput, opts = {}) {
41
+ const { cuts, timelineUuid } = opts;
42
+ if (!Array.isArray(cuts) || cuts.length === 0) {
43
+ throw new TypeError('cutSourceIntoClips: cuts must be a non-empty array');
44
+ }
45
+ cuts.forEach((cut, i) => {
46
+ if (!cut || !Number.isInteger(cut.startFrame) || !Number.isInteger(cut.durationFrames) || cut.durationFrames <= 0) {
47
+ throw new TypeError(`cutSourceIntoClips: cuts[${i}] needs integer startFrame and positive integer durationFrames`);
48
+ }
49
+ if (cut.srcIn !== undefined && (!Number.isInteger(cut.srcIn) || cut.srcIn < 0)) {
50
+ throw new TypeError(`cutSourceIntoClips: cuts[${i}].srcIn must be a non-negative integer`);
51
+ }
52
+ if (cut.mediaRef !== undefined && !/^[0-9a-f-]{36}$/.test(cut.mediaRef)) {
53
+ throw new TypeError(`cutSourceIntoClips: cuts[${i}].mediaRef must be a uuid`);
54
+ }
55
+ });
56
+
57
+ const zip = await loadDrpZip(drpInput);
58
+ const { entry, xml: seqXml } = await selectTargetSeq(zip, timelineUuid);
59
+ let xml = seqXml;
60
+ const clipDbIds = [];
61
+
62
+ for (const trackType of ['video', 'audio']) {
63
+ const { match, tracks } = getTrackVec(xml, trackType);
64
+ if (!tracks.length) continue;
65
+ const items = getItemsInner(tracks[0]);
66
+ const clips = splitClipElements(items);
67
+ if (!clips.length) continue; // audio-less media: nothing to cut on A1
68
+ const donor = clips[0];
69
+ const clones = cuts.map((cut) => {
70
+ let c = freshDbIds(donor);
71
+ c = setClipStart(c, cut.startFrame);
72
+ c = setClipDuration(c, cut.durationFrames);
73
+ c = setClipIn(c, cut.srcIn ?? 0);
74
+ if (cut.mediaRef) {
75
+ // Multi-source: point this cut at ITS source's transplanted pool
76
+ // element instead of the donor's.
77
+ c = c.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/, `<MediaRef>${cut.mediaRef}</MediaRef>`);
78
+ }
79
+ return c;
80
+ });
81
+ if (trackType === 'video') {
82
+ for (const c of clones) clipDbIds.push(clipDbId(c));
83
+ }
84
+ tracks[0] = setItemsInner(tracks[0], clones.join(''));
85
+ xml = replaceTrackVec(xml, trackType, match, tracks);
86
+ }
87
+
88
+ zip.file(entry, xml);
89
+ const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
90
+ return { buffer, cutCount: cuts.length, clipDbIds };
91
+ }
92
+
93
+ module.exports = { cutSourceIntoClips };
@@ -156,6 +156,7 @@ module.exports = {
156
156
 
157
157
  // Build a full importable timeline from a declarative spec (titles/generators/transitions).
158
158
  assembleTimeline: require('./assemble-timeline').assembleTimeline,
159
+ cutSourceIntoClips: require('./cut-media').cutSourceIntoClips,
159
160
 
160
161
  // Author a project with one media clip referencing an arbitrary h264 file, from scratch [P8].
161
162
  addMediaClip: require('./author-project').addMediaClip,
@@ -0,0 +1,83 @@
1
+ /**
2
+ * media-template-cache — native per-media descriptors, captured live once.
3
+ *
4
+ * Offline authoring cannot synthesize Resolve's media-identity descriptors:
5
+ * repointing a template's pool entry at a different file imports and reads
6
+ * back perfectly but the render engine refuses ("Full resolution media not
7
+ * found") or paints black — the entry's deep blobs (Radiometry, keyed-dict
8
+ * FieldsBlobs, stream descriptors) still describe the template's file.
9
+ * Measured fix (2026-08-30, Studio 19.1.3.7): TRANSPLANT the entire native
10
+ * media <Element> captured from a scratch project built around the target
11
+ * file, and rewire the timeline clips' <MediaRef> to the native id —
12
+ * rendered luma then matches the native control exactly.
13
+ *
14
+ * The Python server's media_pool.capture_media_template action writes these
15
+ * caches (one JSON per media file, keyed by sha1 of the absolute path) while
16
+ * Resolve is running; this module consumes them offline.
17
+ *
18
+ * @module drp-format/media-template-cache
19
+ */
20
+
21
+ const fs = require('node:fs');
22
+ const os = require('node:os');
23
+ const path = require('node:path');
24
+ const crypto = require('node:crypto');
25
+
26
+ const CACHE_DIR = process.env.DRP_MEDIA_TEMPLATE_DIR
27
+ || path.join(os.homedir(), '.config', 'davinci-resolve-mcp', 'media-templates');
28
+
29
+ function cachePathFor(mediaFilePath) {
30
+ const key = crypto.createHash('sha1').update(path.resolve(mediaFilePath)).digest('hex');
31
+ return path.join(CACHE_DIR, `${key}.json`);
32
+ }
33
+
34
+ /** Load the cached native descriptor for a media file, or null. */
35
+ function loadMediaTemplate(mediaFilePath) {
36
+ try {
37
+ const raw = fs.readFileSync(cachePathFor(mediaFilePath), 'utf8');
38
+ const data = JSON.parse(raw);
39
+ if (!data.poolElement || !data.mediaRef) return null;
40
+ return data;
41
+ } catch (e) {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** Replace the pool media <Element> holding `marker` and rewire MediaRefs. */
47
+ function transplantMediaElement(mpXml, seqXmls, { poolElement, mediaRef }) {
48
+ const idx = mpXml.indexOf('<Sm2MpVideoClip');
49
+ if (idx < 0) throw new Error('transplant: no Sm2MpVideoClip element in MpFolder');
50
+ const start = mpXml.lastIndexOf('<Element>', idx);
51
+ let depth = 0; let end = -1; let m;
52
+ const re = /<\/?Element>/g;
53
+ re.lastIndex = start;
54
+ while ((m = re.exec(mpXml))) {
55
+ if (m[0] === '<Element>') depth += 1; else depth -= 1;
56
+ if (depth === 0) { end = m.index + m[0].length; break; }
57
+ }
58
+ if (end < 0) throw new Error('transplant: unbalanced Element nesting');
59
+ const folderId = (mpXml.slice(start, end).match(/<MpFolder>([0-9a-f-]{36})<\/MpFolder>/) || [])[1];
60
+ let element = poolElement;
61
+ if (folderId) {
62
+ element = element.replace(/<MpFolder>[0-9a-f-]{36}<\/MpFolder>/, `<MpFolder>${folderId}</MpFolder>`);
63
+ }
64
+ const outMp = mpXml.slice(0, start) + element + mpXml.slice(end);
65
+ const outSeqs = seqXmls.map((seq) =>
66
+ seq.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/g, `<MediaRef>${mediaRef}</MediaRef>`),
67
+ );
68
+ return { mpXml: outMp, seqXmls: outSeqs };
69
+ }
70
+
71
+ /** Insert an ADDITIONAL native media <Element> into MpFolder's MediaVec. */
72
+ function insertMediaElement(mpXml, poolElement) {
73
+ const close = mpXml.indexOf('</MediaVec>');
74
+ if (close < 0) throw new Error('insertMediaElement: no MediaVec in MpFolder');
75
+ const folderId = (mpXml.match(/<Sm2MpVideoClip[^>]*>[\s\S]*?<MpFolder>([0-9a-f-]{36})<\/MpFolder>/) || [])[1];
76
+ let element = poolElement;
77
+ if (folderId) {
78
+ element = element.replace(/<MpFolder>[0-9a-f-]{36}<\/MpFolder>/, `<MpFolder>${folderId}</MpFolder>`);
79
+ }
80
+ return mpXml.slice(0, close) + element + mpXml.slice(close);
81
+ }
82
+
83
+ module.exports = { CACHE_DIR, cachePathFor, loadMediaTemplate, transplantMediaElement, insertMediaElement };
@@ -191,6 +191,114 @@ function patchSpecBlobs(mpXml, toName, fromSpec, toSpec) {
191
191
  * @param {string} [opts.from] @param {string} [opts.to] @param {object} [opts.fromSpec] @param {object} [opts.toSpec]
192
192
  * @returns {Promise<{buffer:Buffer, relinked:Array, specPatched:Array<{to:string,patched:boolean}>}>}
193
193
  */
194
+ let _fzstd = null;
195
+ try { _fzstd = require('fzstd'); } catch (e) { /* optional; zstd Clip blobs need it */ }
196
+ const _zlib = require('node:zlib');
197
+ const _crypto = require('node:crypto');
198
+ const _fs = require('node:fs');
199
+
200
+ /**
201
+ * Rewrite the media-identity <Clip> blobs in MpFolder.xml for a repoint.
202
+ *
203
+ * The blob is [u32BE version][u32BE len][0x81][compressed protobuf] where the
204
+ * protobuf is FLAT: f1 dir, f2/f6 filename, f3 mtime string, f7 uuid,
205
+ * f13 file size bytes, f14 media frame count. Resolve links media by THIS
206
+ * identity in preference to the plain-text MediaFilePath — measured live
207
+ * 2026-08-30: a template whose Clip blob still named its capture source made
208
+ * ImportTimelineFromFile link that ORIGINAL file (a real client asset) while
209
+ * every visible field said the new path, and the relinked render came out
210
+ * black. relinkMedia's raw-bytes blob pass cannot see through the
211
+ * compression, so this decompresses (zstd via optional fzstd, zlib, or raw
212
+ * deflate), patches the fields, and re-emits as zlib — the decoder framing
213
+ * accepts either codec.
214
+ */
215
+ function repointClipBlobsInXml(xml, { toDir, toName, toSpec }) {
216
+ let patched = 0;
217
+ const out = xml.replace(/<Clip>([0-9a-f]{40,})<\/Clip>/g, (whole, hex) => {
218
+ try {
219
+ const raw = Buffer.from(hex, 'hex');
220
+ if (raw.length < 10 || raw[8] !== 0x81) return whole;
221
+ const payload = raw.subarray(9);
222
+ let plain;
223
+ if (payload[0] === 0x28 && payload[1] === 0xb5 && payload[2] === 0x2f && payload[3] === 0xfd) {
224
+ if (!_fzstd) return whole;
225
+ plain = Buffer.from(_fzstd.decompress(new Uint8Array(payload)));
226
+ } else if (payload[0] === 0x78) {
227
+ plain = _zlib.inflateSync(payload);
228
+ } else {
229
+ plain = _zlib.inflateRawSync(payload);
230
+ }
231
+ // Parse flat top-level protobuf fields.
232
+ const fields = [];
233
+ let o = 0;
234
+ while (o < plain.length) {
235
+ const start = o;
236
+ let shift = 0; let tag = 0;
237
+ for (;;) { const b = plain[o++]; tag |= (b & 0x7f) << shift; if (!(b & 0x80)) break; shift += 7; }
238
+ const field = tag >>> 3; const wire = tag & 7;
239
+ if (wire === 0) { for (;;) { const b = plain[o++]; if (!(b & 0x80)) break; } }
240
+ else if (wire === 1) o += 8;
241
+ else if (wire === 5) o += 4;
242
+ else if (wire === 2) {
243
+ let len = 0; shift = 0;
244
+ for (;;) { const b = plain[o++]; len |= (b & 0x7f) << shift; if (!(b & 0x80)) break; shift += 7; }
245
+ o += len;
246
+ } else return whole; // unknown wire type: leave the blob alone
247
+ fields.push({ field, wire, bytes: plain.subarray(start, o) });
248
+ }
249
+ const varint = (v) => {
250
+ const bs = [];
251
+ let r = v;
252
+ while (r > 0x7f) { bs.push((r & 0x7f) | 0x80); r = Math.floor(r / 128); }
253
+ bs.push(r & 0x7f);
254
+ return Buffer.from(bs);
255
+ };
256
+ const strField = (field, str) => {
257
+ const body = Buffer.from(str, 'utf8');
258
+ return Buffer.concat([varint((field << 3) | 2), varint(body.length), body]);
259
+ };
260
+ const intField = (field, v) => Buffer.concat([varint(field << 3), varint(v)]);
261
+ let stat = null;
262
+ try { stat = _fs.statSync(`${toDir}/${toName}`); } catch (e) { /* offline authoring: file may not exist yet */ }
263
+ const rebuilt = fields.map((f) => {
264
+ if (f.field === 1) return strField(1, toDir);
265
+ if (f.field === 2) return strField(2, toName);
266
+ if (f.field === 6) return strField(6, toName);
267
+ if (f.field === 7) return strField(7, _crypto.randomUUID());
268
+ // f13 is the file's MTIME IN MICROSECONDS — verified by capturing the
269
+ // same file natively (Resolve wrote 1788121973071908 for an
270
+ // Aug-30-2026 mtime). Writing anything else here makes the render
271
+ // engine refuse with "Full resolution media not found" while every
272
+ // structural readback stays green. f14 is absent from native captures
273
+ // of plain mp4 media; a stale template value is dropped.
274
+ if (f.field === 13 && stat) return intField(13, Math.floor(stat.mtimeMs * 1000));
275
+ if (f.field === 14) return Buffer.alloc(0);
276
+ if (f.field === 3 && stat) {
277
+ // ctime() layout, matching Resolve's own stamps: 'Sun Aug 17 15:01:38 2025'
278
+ const d = stat.mtime;
279
+ const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
280
+ const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
281
+ const pad = (n) => String(n).padStart(2, '0');
282
+ const ctime = `${days[d.getDay()]} ${months[d.getMonth()]} ${String(d.getDate()).padStart(2, ' ')} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} ${d.getFullYear()}`;
283
+ return strField(3, ctime);
284
+ }
285
+ return f.bytes;
286
+ });
287
+ const fresh = Buffer.concat(rebuilt);
288
+ const compressed = _zlib.deflateSync(fresh);
289
+ const head = Buffer.alloc(9);
290
+ head.writeUInt32BE(raw.readUInt32BE(0), 0);
291
+ head.writeUInt32BE(1 + compressed.length, 4);
292
+ head[8] = 0x81;
293
+ patched += 1;
294
+ return `<Clip>${Buffer.concat([head, compressed]).toString('hex')}</Clip>`;
295
+ } catch (e) {
296
+ return whole; // never corrupt a blob we cannot fully parse
297
+ }
298
+ });
299
+ return { xml: out, patched };
300
+ }
301
+
194
302
  async function repointMedia(drpInput, opts = {}) {
195
303
  const mappings = opts.mappings
196
304
  || (opts.from && opts.to ? [{ from: opts.from, to: opts.to, fromSpec: opts.fromSpec, toSpec: opts.toSpec }] : []);
@@ -210,9 +318,21 @@ async function repointMedia(drpInput, opts = {}) {
210
318
  mp = res.xml;
211
319
  specPatched.push({ to: m.to, patched: res.patched });
212
320
  }
321
+ // 3) rewrite the compressed media-identity Clip blobs — the field Resolve
322
+ // actually links by (see repointClipBlobsInXml).
323
+ let clipBlobsPatched = 0;
324
+ for (const m of mappings) {
325
+ const res = repointClipBlobsInXml(mp, {
326
+ toDir: nodePath.dirname(m.to),
327
+ toName: nodePath.basename(m.to),
328
+ toSpec: m.toSpec,
329
+ });
330
+ mp = res.xml;
331
+ clipBlobsPatched += res.patched;
332
+ }
213
333
  zip.file(mpPath, mp);
214
334
  const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
215
- return { buffer, relinked: relinked.relinked, specPatched };
335
+ return { buffer, relinked: relinked.relinked, specPatched, clipBlobsPatched };
216
336
  }
217
337
 
218
338
  module.exports = { relinkMedia, repointMedia };
@@ -442,4 +442,8 @@ async function rippleTimeline(drpInput, opts = {}) {
442
442
  return { buffer, entry, timelineUuid: seqId, at, delta, shifted };
443
443
  }
444
444
 
445
- module.exports = { moveClip, deleteClip, trimClip, trimClipHead, splitClip, rippleTimeline };
445
+ module.exports = {
446
+ moveClip, deleteClip, trimClip, trimClipHead, splitClip, rippleTimeline,
447
+ // low-level clip-XML helpers, reused by cut-media.js
448
+ clipDbId, clipStart, clipDuration, clipIn, setClipStart, setClipDuration, setClipIn,
449
+ };
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.105.0"
90
+ VERSION = "2.107.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()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.105.0"
14
+ VERSION = "2.107.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -8973,6 +8973,7 @@ _MEDIA_POOL_KERNEL_ACTIONS = [
8973
8973
  "link_full_resolution_checked",
8974
8974
  "set_clip_marks",
8975
8975
  "get_clip_marks",
8976
+ "capture_media_template",
8976
8977
  "clear_clip_marks",
8977
8978
  "copy_clip_annotations",
8978
8979
  "media_pool_boundary_report",
@@ -13795,6 +13796,126 @@ def _set_clip_marks(root, mp, p: Dict[str, Any]):
13795
13796
  return {"success": all(row.get("success") for row in results), "count": len(results), "missing": missing, "results": results}
13796
13797
 
13797
13798
 
13799
+ def _capture_media_template(r, pm, p: Dict[str, Any]) -> Dict[str, Any]:
13800
+ """Capture a media file's NATIVE Resolve descriptors for offline authoring.
13801
+
13802
+ Offline authoring cannot synthesize the media-identity blobs Resolve's
13803
+ render engine validates (Radiometry, keyed-dict FieldsBlobs, stream
13804
+ descriptors): a repointed pool entry imports and reads back perfectly but
13805
+ renders black or fails with "Full resolution media not found" (measured
13806
+ 2026-08-30, Studio 19.1.3.7). This builds a disposable project around the
13807
+ file, lets Resolve describe it natively, and caches the pool media
13808
+ <Element> + the timeline clips' <MediaRef> id. drt.assemble then
13809
+ TRANSPLANTS the cached element, which renders identically to native.
13810
+
13811
+ Switches the current project to a scratch project during capture and
13812
+ switches back; requires the media file to exist locally.
13813
+ """
13814
+ import hashlib
13815
+ import zipfile as _zipfile
13816
+
13817
+ media_path = p.get("media_path") or p.get("path")
13818
+ if not media_path or not os.path.isabs(str(media_path)):
13819
+ return _err("capture_media_template requires media_path (absolute)")
13820
+ media_path = os.path.abspath(str(media_path))
13821
+ if not os.path.exists(media_path):
13822
+ return _err(f"media file does not exist: {media_path}")
13823
+
13824
+ cache_dir = os.environ.get("DRP_MEDIA_TEMPLATE_DIR") or os.path.join(
13825
+ os.path.expanduser("~"), ".config", "davinci-resolve-mcp", "media-templates"
13826
+ )
13827
+ os.makedirs(cache_dir, exist_ok=True)
13828
+ cache_path = os.path.join(
13829
+ cache_dir, hashlib.sha1(media_path.encode("utf-8")).hexdigest() + ".json"
13830
+ )
13831
+
13832
+ previous = None
13833
+ try:
13834
+ cur = pm.GetCurrentProject()
13835
+ previous = cur.GetName() if cur else None
13836
+ except Exception:
13837
+ previous = None
13838
+
13839
+ scratch = f"_mcp_media_tpl_{int(time.time())}"
13840
+ proj = pm.CreateProject(scratch)
13841
+ if not proj:
13842
+ return _err(f"Could not create scratch project {scratch!r}")
13843
+ drp_tmp = os.path.join(tempfile.gettempdir(), f"{scratch}.drp")
13844
+ try:
13845
+ mp_obj = proj.GetMediaPool()
13846
+ clips = mp_obj.ImportMedia([media_path])
13847
+ if not clips:
13848
+ return _err(f"Resolve could not import {media_path}")
13849
+ tl = mp_obj.CreateEmptyTimeline("MediaTemplate")
13850
+ from src.utils.resolve_writes import set_current_timeline, describe_switch_failure
13851
+ switched, detail = set_current_timeline(proj, tl)
13852
+ if not switched:
13853
+ return _err(describe_switch_failure(detail, "the media-template capture"))
13854
+ if not mp_obj.AppendToTimeline([clips[0]]):
13855
+ return _err("AppendToTimeline failed during capture")
13856
+ if not pm.SaveProject():
13857
+ return _err("SaveProject failed — ExportProject would snapshot an empty timeline")
13858
+ if not pm.ExportProject(scratch, drp_tmp):
13859
+ return _err("ExportProject failed during capture")
13860
+
13861
+ with _zipfile.ZipFile(drp_tmp) as zf:
13862
+ mp_xml = zf.read("MediaPool/Master/MpFolder.xml").decode("utf-8", "replace")
13863
+ seq_xml = ""
13864
+ for n in zf.namelist():
13865
+ if re.search(r"SeqContainer/.+\.xml$", n):
13866
+ seq_xml = zf.read(n).decode("utf-8", "replace")
13867
+ break
13868
+ marker = os.path.basename(media_path)
13869
+ idx = mp_xml.find(marker)
13870
+ if idx < 0:
13871
+ return _err("captured MpFolder does not name the media — capture aborted")
13872
+ start = mp_xml.rfind("<Element>", 0, idx)
13873
+ depth, end = 0, -1
13874
+ for m in re.finditer(r"</?Element>", mp_xml[start:]):
13875
+ depth += 1 if m.group(0) == "<Element>" else -1
13876
+ if depth == 0:
13877
+ end = start + m.end()
13878
+ break
13879
+ media_ref_m = re.search(r"<MediaRef>([0-9a-f-]{36})</MediaRef>", seq_xml)
13880
+ if end < 0 or not media_ref_m:
13881
+ return _err("could not isolate the media element / MediaRef from the capture")
13882
+ pool_element = mp_xml[start:end]
13883
+ media_ref = media_ref_m.group(1)
13884
+ if media_ref not in pool_element:
13885
+ return _err("MediaRef is not inside the captured element — schema drift; capture aborted")
13886
+
13887
+ stat = os.stat(media_path)
13888
+ payload = {
13889
+ "mediaFilePath": media_path,
13890
+ "capturedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
13891
+ "resolveVersion": r.GetVersionString(),
13892
+ "mtimeMs": stat.st_mtime * 1000.0,
13893
+ "sizeBytes": stat.st_size,
13894
+ "mediaRef": media_ref,
13895
+ "poolElement": pool_element,
13896
+ }
13897
+ with open(cache_path, "w", encoding="utf-8") as fh:
13898
+ json.dump(payload, fh)
13899
+ return _ok(
13900
+ cache_path=cache_path,
13901
+ media_ref=media_ref,
13902
+ pool_element_bytes=len(pool_element),
13903
+ resolve_version=payload["resolveVersion"],
13904
+ note=(
13905
+ "drt.assemble now transplants this native descriptor for this "
13906
+ "media file; re-capture if the file is rewritten (mtime/size "
13907
+ "recorded for staleness checks)."
13908
+ ),
13909
+ )
13910
+ finally:
13911
+ try:
13912
+ os.unlink(drp_tmp)
13913
+ except OSError:
13914
+ pass
13915
+ from src.utils.project_cleanup import delete_project_safely
13916
+ delete_project_safely(pm, scratch, switch_to=previous)
13917
+
13918
+
13798
13919
  def _get_clip_marks(root, mp, p: Dict[str, Any]):
13799
13920
  """Read mark in/out for a set of media-pool clips — the read twin of set_clip_marks."""
13800
13921
  resolved, err = _clips_from_params(root, mp, p)
@@ -19154,6 +19275,7 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
19154
19275
  code="JOB_NOT_FOUND", category="invalid_input",
19155
19276
  )
19156
19277
  result: Dict[str, Any] = {"job_id": job_id, "status": status}
19278
+ job_status = str(status.get("JobStatus") or "")
19157
19279
  target_dir = job.get("TargetDir")
19158
19280
  filename = job.get("OutputFilename")
19159
19281
  warnings: List[str] = []
@@ -19290,6 +19412,14 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
19290
19412
  warnings.append(
19291
19413
  "JobStatus is Complete but the output file does not exist."
19292
19414
  )
19415
+ if job_status and job_status != "Complete":
19416
+ # Spotted live: a Failed job that wrote a stub file otherwise
19417
+ # produced verified:true — a duration ratio means nothing when
19418
+ # Resolve itself says the job did not complete.
19419
+ warnings.append(
19420
+ f"JobStatus is {job_status!r}, not Complete"
19421
+ + (f": {status.get('Error')}" if status.get("Error") else "")
19422
+ )
19293
19423
  result["warnings"] = warnings
19294
19424
  result["verified"] = bool(
19295
19425
  output_path and result.get("output_exists") and not warnings
@@ -19580,6 +19710,13 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
19580
19710
  check_proxy_media_compatibility(clip_id, proxy_path|path, expected_codec?, expected_profile?) -> {compatible, mismatches}
19581
19711
  link_proxy_checked(clip_id, proxy_path|path, dry_run?, check_compatibility?, require_compatible?, expected_codec?, expected_profile?) -> {success}
19582
19712
  link_full_resolution_checked(clip_id, path|full_res_media_path, dry_run?) -> {success}
19713
+ capture_media_template(media_path) -> {cache_path, media_ref}
19714
+ Captures the file's NATIVE Resolve media descriptors into
19715
+ ~/.config/davinci-resolve-mcp/media-templates/ via a disposable
19716
+ project (switches projects during capture and switches back).
19717
+ Offline drt.assemble then transplants them — the only measured way
19718
+ authored media actually RENDERS; synthesized descriptors import and
19719
+ read back fine but render black or 'Full resolution media not found'.
19583
19720
  set_clip_marks(clip_ids|selected, mark_in, mark_out, type?, dry_run?) -> {success, results}
19584
19721
  clear_clip_marks(clip_ids|selected, type?, dry_run?) -> {success, results}
19585
19722
  copy_clip_annotations(source_clip_id, target_clip_ids, include_markers?, include_flags?, include_clip_color?, dry_run?) -> {success, results}
@@ -19961,6 +20098,8 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
19961
20098
  return _set_clip_marks(root, mp, p)
19962
20099
  elif action == "get_clip_marks":
19963
20100
  return _get_clip_marks(root, mp, p)
20101
+ elif action == "capture_media_template":
20102
+ return _capture_media_template(get_resolve(), get_resolve().GetProjectManager(), p)
19964
20103
  elif action == "clear_clip_marks":
19965
20104
  return _clear_clip_marks(root, mp, p)
19966
20105
  elif action == "copy_clip_annotations":
@@ -1885,6 +1885,35 @@ API_TRUTH: List[Dict[str, Any]] = [
1885
1885
  "submit": "bug",
1886
1886
  "issue": 171,
1887
1887
  },
1888
+ {
1889
+ "symbol": "Imported media renders only with NATIVE pool descriptors (repointed entries read back fine, render never)",
1890
+ "object": "MediaPool / render engine",
1891
+ "reality": "An imported archive's media links by the pool entry's DEEP "
1892
+ "descriptors (compressed Clip identity blobs, Radiometry, "
1893
+ "keyed-dict FieldsBlobs, stream data), not by the visible "
1894
+ "MediaFilePath. A pool entry whose visible fields all name "
1895
+ "the right file but whose deep blobs describe another "
1896
+ "imports cleanly, reads back perfectly (items, names, "
1897
+ "source frames, linked counts), and then either renders "
1898
+ "BLACK or fails with 'Full resolution media not found' — "
1899
+ "and worse, when the stale identity matches a file that "
1900
+ "exists on the machine, Resolve links THAT file (observed: "
1901
+ "a template captured from a client clip silently linked "
1902
+ "the client clip). Measured by bisection on Studio "
1903
+ "19.1.3.7: pristine native archives render (YAVG 123), "
1904
+ "every synthesized/repointed variant read back identically "
1905
+ "and did not.",
1906
+ "recommended": "Never synthesize pool media descriptors. Capture them "
1907
+ "natively once per file with "
1908
+ "media_pool.capture_media_template (builds a scratch "
1909
+ "project around the file, caches the native element), "
1910
+ "and author with drt.assemble, which transplants the "
1911
+ "cached element and rewires MediaRefs — rendered "
1912
+ "output then matches a natively built timeline "
1913
+ "exactly. Always render-verify authored timelines; "
1914
+ "structural readback cannot see this class.",
1915
+ "tags": ["media-pool", "import", "render", "silent-failure", "readback"],
1916
+ },
1888
1917
  {
1889
1918
  "symbol": "MediaPool.ImportTimelineFromFile (.drt requirements and filename naming)",
1890
1919
  "object": "MediaPool",