davinci-resolve-mcp 2.105.0 → 2.106.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,62 @@
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.106.0
6
+
7
+ **Media clips in native DRT authoring — cut real footage into an importable,
8
+ RENDERING timeline.** The deepest silent-failure class this repo has hit, run
9
+ to ground and shipped, live-verified at every step on Studio 19.1.3.7.
10
+
11
+ ### The discovery chain
12
+
13
+ Adding media cuts to drt.assemble surfaced three buried traps in sequence.
14
+ First: repointing the bundled media template at a new file left the pool
15
+ entry's compressed identity blobs describing the ORIGINAL capture source —
16
+ and when that file still exists on the machine, Resolve silently links IT
17
+ (observed: authored timelines linked a client clip while every visible field
18
+ said the right path). Second: after teaching the Clip identity blobs the new
19
+ path (their layout: dir, filename, ctime-format mtime string, codec tag,
20
+ uuid, mtime-in-MICROSECONDS — a field first misread as file size), imports
21
+ read back perfectly and still failed to render: "Full resolution media not
22
+ found". Third: the render engine validates the pool entry's DEEP descriptors
23
+ (Radiometry, keyed-dict FieldsBlobs, stream data) that offline code cannot
24
+ synthesize. Structural readback cannot see any of this — only rendering can.
25
+
26
+ ### The architecture that works
27
+
28
+ - **`media_pool.capture_media_template(media_path)`** (live, once per file):
29
+ builds a disposable project around the file, lets Resolve describe it
30
+ natively, caches the pool media element + MediaRef id under
31
+ ~/.config/davinci-resolve-mcp/media-templates/, and switches your project
32
+ back.
33
+ - **`drt.assemble` grows media support**: `media: {mediaFilePath, spec,
34
+ cuts: [{startFrame, durationFrames, srcIn}]}` cuts ONE source into N
35
+ placements (new cut-media vendor primitive: donor clip cloned with fresh
36
+ DbIds and per-cut geometry on video + audio tracks; placement guards refuse
37
+ cuts before the timeline origin and reads past the media's end). At build
38
+ time the cached native element is TRANSPLANTED and MediaRefs rewired —
39
+ rendered output then matches a natively built timeline exactly (YAVG
40
+ 125.6/123.2 across cuts vs 123.2 native control). Without a cache the
41
+ result carries mediaDescriptor: 'repoint-fallback' and a warning naming
42
+ the capture action.
43
+ - **Version-matched templates**: a Resolve-21 template stamped down to 19
44
+ imports and reads back perfectly — and renders BLACK (the stamp clears the
45
+ gate, not the blob semantics). Both template generations now ship ('21'
46
+ original, '19' captured from 19.1.3.7); drt.assemble picks by
47
+ targetAppVersion.
48
+
49
+ ### Fixed
50
+
51
+ - `render.verify_output` never verifies a job whose JobStatus is not
52
+ Complete (a Failed job's stub passed the duration-ratio check during this
53
+ hunt).
54
+ - The repoint fallback's Clip identity blobs are now written with the
55
+ measured field semantics (mtime-µs, ctime string, dropped stale fields).
56
+
57
+ All of it is an api_truth entry: imported media renders only with NATIVE
58
+ pool descriptors; render-verify authored timelines, because structural
59
+ readback cannot see this class.
60
+
5
61
  ## What's New in v2.105.0
6
62
 
7
63
  **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.106.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.106.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.106.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.106.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.106.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -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,59 @@
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 } = 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
+ if (media) {
39
+ // Media authoring: ONE source file, cut into N placements — the template
40
+ // media pool holds one media entry, so multi-source is refused honestly
41
+ // rather than half-built.
42
+ if (Array.isArray(media)) {
43
+ throw new TypeError('assembleTimeline: media must be a single {mediaFilePath, spec, cuts} object — multi-source authoring is not supported yet');
44
+ }
45
+ const { mediaFilePath, spec: mediaSpec, cuts } = media;
46
+ base = await addMediaClip({ mediaFile: mediaFilePath, spec: mediaSpec, timelineName, templateVersion });
47
+ base.startFrame = DEFAULT_START_FRAME;
48
+ if (Array.isArray(cuts) && cuts.length) {
49
+ // Placement guards, loudly: clips before the timeline origin are
50
+ // DROPPED by Resolve on import with no error, and a source range past
51
+ // the media's end reads back as a truncated clip.
52
+ cuts.forEach((cut, i) => {
53
+ if (cut.startFrame < DEFAULT_START_FRAME) {
54
+ throw new RangeError(
55
+ `assembleTimeline: media.cuts[${i}].startFrame ${cut.startFrame} is before the timeline origin ${DEFAULT_START_FRAME} — Resolve silently drops it on import`,
56
+ );
57
+ }
58
+ if (mediaSpec && Number.isFinite(mediaSpec.frameCount) && Number.isFinite(mediaSpec.fps)) {
59
+ // srcIn/duration are TIMELINE frames (24fps template); the media's
60
+ // extent converts: frameCount / mediaFps × 24.
61
+ const maxTimelineFrames = Math.floor((mediaSpec.frameCount / mediaSpec.fps) * 24);
62
+ if ((cut.srcIn ?? 0) + cut.durationFrames > maxTimelineFrames) {
63
+ throw new RangeError(
64
+ `assembleTimeline: media.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)`,
65
+ );
66
+ }
67
+ }
68
+ });
69
+ const cutRes = await cutSourceIntoClips(base.buffer, { cuts });
70
+ base.buffer = cutRes.buffer;
71
+ }
72
+ } else {
73
+ base = await createEmptyProject({ timelineName, templateVersion });
74
+ }
75
+ const { buffer: baseBuffer, timelineName: tlName, startFrame } = base;
76
+ let buffer = baseBuffer;
36
77
 
37
78
  for (const [i, el] of elements.entries()) {
38
79
  if (!el || typeof el !== 'object') throw new TypeError(`assembleTimeline: elements[${i}] must be an object`);
@@ -59,7 +100,32 @@ async function assembleTimeline(spec = {}) {
59
100
  }));
60
101
  }
61
102
 
62
- return { buffer, timelineName: tlName, startFrame };
103
+ // Native-descriptor transplant: when a live-captured media template exists
104
+ // for this file, swap the pool media element and rewire MediaRefs — the
105
+ // only measured way an authored timeline's media actually RENDERS (the
106
+ // repoint fallback imports and reads back fine but the render engine
107
+ // refuses or paints black; see media-template-cache).
108
+ let mediaDescriptor = 'none';
109
+ if (media && media.mediaFilePath) {
110
+ const cached = loadMediaTemplate(media.mediaFilePath);
111
+ if (cached) {
112
+ const zip = await JSZip.loadAsync(buffer);
113
+ const mpPath = 'MediaPool/Master/MpFolder.xml';
114
+ const seqNames = Object.keys(zip.files).filter((n) => /SeqContainer\/.+\.xml$/.test(n) || /\/SeqContainer\d*\.xml$/.test(n));
115
+ const mpXml = await zip.file(mpPath).async('string');
116
+ const seqXmls = [];
117
+ for (const n of seqNames) seqXmls.push(await zip.file(n).async('string'));
118
+ const res = transplantMediaElement(mpXml, seqXmls, cached);
119
+ zip.file(mpPath, res.mpXml);
120
+ seqNames.forEach((n, i) => zip.file(n, res.seqXmls[i]));
121
+ buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
122
+ mediaDescriptor = 'native-transplant';
123
+ } else {
124
+ mediaDescriptor = 'repoint-fallback';
125
+ }
126
+ }
127
+
128
+ return { buffer, timelineName: tlName, startFrame, mediaDescriptor };
63
129
  }
64
130
 
65
131
  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,85 @@
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
+ });
53
+
54
+ const zip = await loadDrpZip(drpInput);
55
+ const { entry, xml: seqXml } = await selectTargetSeq(zip, timelineUuid);
56
+ let xml = seqXml;
57
+ const clipDbIds = [];
58
+
59
+ for (const trackType of ['video', 'audio']) {
60
+ const { match, tracks } = getTrackVec(xml, trackType);
61
+ if (!tracks.length) continue;
62
+ const items = getItemsInner(tracks[0]);
63
+ const clips = splitClipElements(items);
64
+ if (!clips.length) continue; // audio-less media: nothing to cut on A1
65
+ const donor = clips[0];
66
+ const clones = cuts.map((cut) => {
67
+ let c = freshDbIds(donor);
68
+ c = setClipStart(c, cut.startFrame);
69
+ c = setClipDuration(c, cut.durationFrames);
70
+ c = setClipIn(c, cut.srcIn ?? 0);
71
+ return c;
72
+ });
73
+ if (trackType === 'video') {
74
+ for (const c of clones) clipDbIds.push(clipDbId(c));
75
+ }
76
+ tracks[0] = setItemsInner(tracks[0], clones.join(''));
77
+ xml = replaceTrackVec(xml, trackType, match, tracks);
78
+ }
79
+
80
+ zip.file(entry, xml);
81
+ const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
82
+ return { buffer, cutCount: cuts.length, clipDbIds };
83
+ }
84
+
85
+ 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,71 @@
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
+ module.exports = { CACHE_DIR, cachePathFor, loadMediaTemplate, transplantMediaElement };
@@ -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.106.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.106.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",