davinci-resolve-mcp 2.104.10 → 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,112 @@
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
+
61
+ ## What's New in v2.105.0
62
+
63
+ **Native-schema DRT authoring — the parked "project, not a lap" — shipped.**
64
+ Tool-authored .drt files that Resolve's ImportTimelineFromFile actually
65
+ accepts, live-verified end to end on Studio 19.1.3.7.
66
+
67
+ The door was already half-open: the repo's template-splice engine
68
+ (assembleTimeline + the real Resolve-21 empty-project capture) authors
69
+ native-schema .drp archives, and the final bisection showed a .drt IS a .drp
70
+ that ImportTimelineFromFile accepts. What stood between them was the version
71
+ gate (the template stamps DbPrjVer 17; a 19.1.3 host wants 14) and a set of
72
+ extraction traps nobody had mapped.
73
+
74
+ ### Added
75
+
76
+ - **`drt.assemble`** — spec → importable native-schema .drt (titles,
77
+ generators, transitions), with `targetAppVersion` stamping for pre-21
78
+ hosts. Live-verified: assembled archives import with every element intact.
79
+ - **`drt.extract_from_drp` rebuilt on the measured recipe**: keep
80
+ project.xml + MediaPool + the SeqContainer at its ORIGINAL uuid path, drop
81
+ Gallery, and remove other timelines' Sm2MpTimelineClip blocks (matched via
82
+ the kept container's track Sequence DbIds) so they don't arrive as ghost
83
+ empty timelines. The Python extractor behind `timeline.import_from_drp`
84
+ implements the same recipe. Live-verified: single-timeline extracts from a
85
+ two-timeline project import cleanly, one timeline, clips intact.
86
+
87
+ ### The .drt import contract, fully mapped (api_truth rewritten)
88
+
89
+ A whole saved-project export renamed .drt imports, clips intact.
90
+ Requirements: project.xml; MpFolder.xml (it holds the Sm2Sequence/Sm2Timeline
91
+ objects); the SeqContainer's ORIGINAL uuid path — renaming it "succeeds"
92
+ with an EMPTY timeline, no error, the nastiest variant; version stamps at or
93
+ below the host; native blob schema; and a SAVED source project —
94
+ ExportProject snapshots the saved DB state, so an unsaved timeline exports
95
+ empty tracks (the trap that produced v2.104.7's "necessary but not
96
+ sufficient" verdict, now corrected). Every Sm2MpTimelineClip block imports
97
+ as a timeline; extras arrive as ghosts unless removed.
98
+
99
+ ### Fixed in passing
100
+
101
+ - **project_db lookups can no longer hang on an unresponsive library root.**
102
+ Mid-session, macOS rendered the Lite sandbox container path unresponsive at
103
+ the filesystem level (`ls` itself hung) — which froze the Node test suite
104
+ and would have frozen every projectName lookup. Roots are now probed with a
105
+ deadline (`responsiveRoots`); unresponsive ones are skipped and NAMED in
106
+ the not-found error. The root-walking tests are hermetic now — suites must
107
+ not depend on machine paths that an OS can wedge.
108
+ - The flat-authored-shape refusal in import_timeline_checked now points at
109
+ `drt.assemble` as the importable authoring route.
110
+
5
111
  ## What's New in v2.104.10
6
112
 
7
113
  Stones turned on the live-validation backlog, on Studio 19.1.3.7.
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.104.10-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.104.10-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.104.10 版 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
 
@@ -530,8 +530,8 @@ values, or automation-hostile modal prompts.
530
530
 
531
531
  - **Object:** `MediaPool`
532
532
  - **Signature:** `(drtPath, {importSourceClips, ...}) -> Timeline`
533
- - **Behavior:** Measured by bisection on Studio 19.1.3.7 against a real .drt export: (1) the archive MUST contain project.xml — the same archive minus only that entry is refused, while removing MediaPool/MpFolder.xml or renaming the SeqContainer path changes nothing; (2) the container XML must be Resolve's native blob-based schema — a syntactic SeqContainer with flat template elements is refused even beside a genuine project.xml; (3) the imported timeline is named after the FILE (real_2997.drt -> 'real_2997'), not the container's internal name — a third naming authority beside FCP7 (internal name wins) and OTIO (timelineName option wins); and (4) a refused import can raise a modal error dialog that BLOCKS the scripting call until a human dismisses it — the call neither returns nor times out.
534
- - **Workaround / current handling:** Import only .drt files Resolve itself exported — the sufficient set is unmapped beyond that: even a .drp-sourced native container repacked WITH its project.xml was refused (measured 19.1.3.7), so extraction-based .drt import cannot be relied on either. Name the timeline by naming the FILE. Never batch speculative .drt imports unattended — one refusal can hold the whole session hostage behind its dialog. timeline.import_timeline_checked detects the authored template shape up front and refuses with this diagnosis instead of importing; for a reliable programmatic import use OTIO or FCP7 XML.
533
+ - **Behavior:** Fully mapped by bisection on Studio 19.1.3.7: a .drt IS a .drp that ImportTimelineFromFile accepts — a whole saved-project export renamed .drt imports, clips intact. Requirements: (1) project.xml present; (2) MediaPool/MpFolder.xml present — it holds the Sm2Sequence/Sm2Timeline objects; (3) the SeqContainer keeps its ORIGINAL uuid path — renaming it 'succeeds' with an EMPTY timeline (items=0, no error), the nastiest variant; (4) version stamps at or below the host's ProjectVersion; (5) native blob schema — flat template containers are refused; (6) the source must be a SAVED export (ExportProject snapshots the saved DB state, so an unsaved timeline exports EMPTY tracks). Every Sm2MpTimelineClip block in MpFolder imports as a timeline: extra blocks arrive as ghost empty timelines unless removed (match blocks via the kept container's track <Sequence> DbIds). The imported timeline is named after the FILE, and a refused import can raise a modal dialog that BLOCKS the scripting call until a human dismisses it.
534
+ - **Workaround / current handling:** Follow the recipe: drt.extract_from_drp implements it (original container path, MpFolder carried, ghost blocks removed, Gallery dropped), and drt.assemble authors importable native-schema archives from scratch (template-spliced; pass targetAppVersion on pre-21 hosts). Save the project before ExportProject. Name the timeline by naming the FILE. Never batch speculative .drt imports unattended — one refusal can hold the session hostage behind its dialog; timeline.import_timeline_checked refuses the flat authored shape up front for exactly that reason.
535
535
  - **Tags:** timeline, import, silent-failure, headless
536
536
 
537
537
  ### Timeline.DeleteClips (requires the Edit page; flaky first attempt)
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.104.10"
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.104.10",
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>",
@@ -52,6 +52,44 @@ export const LITE_DB_ROOT = path.join(
52
52
  /** Every root searched when resolving a project by name, Studio first. */
53
53
  export const DB_ROOTS = [DISK_DB_ROOT, PROJECT_LIBRARY_ROOT, LITE_DB_ROOT];
54
54
 
55
+ /**
56
+ * Filter roots to the ones that answer a readdir within `deadlineMs`.
57
+ *
58
+ * macOS can render a path UNRESPONSIVE at the filesystem level — measured
59
+ * live 2026-08-30: `ls` on the Lite sandbox container hung indefinitely
60
+ * (File-Provider/container materialization), which made every projectName
61
+ * lookup and the test suite hang with it. A hung readdir cannot be
62
+ * cancelled; the race abandons it (one leaked threadpool op) and reports the
63
+ * root as skipped so the caller can say WHERE it could not look.
64
+ * Returns { roots, skipped: [{root, reason}] }.
65
+ */
66
+ export async function responsiveRoots(roots = DB_ROOTS, deadlineMs = 3000) {
67
+ const fsp = require('node:fs/promises');
68
+ const results = await Promise.all(
69
+ roots.map(async (root) => {
70
+ try {
71
+ const answer = fsp
72
+ .readdir(root)
73
+ .then(() => 'ok', (err) => (err && err.code === 'ENOENT' ? 'absent' : 'error'));
74
+ const timer = new Promise((resolve) => {
75
+ const t = setTimeout(() => resolve('timeout'), deadlineMs);
76
+ if (t.unref) t.unref();
77
+ });
78
+ const outcome = await Promise.race([answer, timer]);
79
+ return { root, outcome };
80
+ } catch {
81
+ return { root, outcome: 'error' };
82
+ }
83
+ }),
84
+ );
85
+ return {
86
+ roots: results.filter((r) => r.outcome === 'ok' || r.outcome === 'absent').map((r) => r.root),
87
+ skipped: results
88
+ .filter((r) => r.outcome === 'timeout')
89
+ .map((r) => ({ root: r.root, reason: `unresponsive after ${deadlineMs}ms (hung filesystem path — skipped)` })),
90
+ };
91
+ }
92
+
55
93
  export function loadSqlite() {
56
94
  try {
57
95
  return require('better-sqlite3');
@@ -85,17 +123,22 @@ export function findProjectDb(projectName, root = DISK_DB_ROOT) {
85
123
  return hits;
86
124
  }
87
125
 
88
- export function resolveDbPath({ projectDb, projectName }) {
126
+ export function resolveDbPath({ projectDb, projectName, roots, skippedRoots = [] }) {
89
127
  if (projectDb) return projectDb;
90
128
  if (!projectName) throw new Error('provide projectDb (path) or projectName');
129
+ const searchRoots = roots || DB_ROOTS;
91
130
  // Deduplicate: a project present under both roots must not read as ambiguous
92
131
  // just because the same file was found twice.
93
- const hits = [...new Set(DB_ROOTS.flatMap((root) => findProjectDb(projectName, root)))];
132
+ const hits = [...new Set(searchRoots.flatMap((root) => findProjectDb(projectName, root)))];
94
133
  if (!hits.length) {
95
134
  throw new Error(
96
- `no Project.db found for project "${projectName}". Searched the Studio libraries ` +
97
- `(${DISK_DB_ROOT} and ${PROJECT_LIBRARY_ROOT}) and the sandboxed free-edition ` +
98
- `library (${LITE_DB_ROOT}). ` +
135
+ `no Project.db found for project "${projectName}". Searched: ` +
136
+ `${searchRoots.join(', ')}. ` +
137
+ (skippedRoots.length
138
+ ? `SKIPPED unresponsive root(s): ${skippedRoots.map((r) => r.root).join(', ')} — ` +
139
+ 'a hung filesystem path (File Provider / sandbox container); its projects ' +
140
+ 'are invisible until macOS unwedges it. '
141
+ : '') +
99
142
  'If Resolve keeps its projects elsewhere — a relocated library, a network/Postgres ' +
100
143
  'database, or the free edition on Windows/Linux — pass projectDb with the full path.',
101
144
  );
@@ -19,7 +19,7 @@
19
19
  import fs from 'node:fs/promises';
20
20
  import { z } from 'zod';
21
21
  import JSZip from 'jszip';
22
- import { drt } from '../libs.mjs';
22
+ import { drt, drp } from '../libs.mjs';
23
23
  import { summarizeDrtTimelines } from '../sequences.mjs';
24
24
 
25
25
  const parseSchema = z.object({ drtPath: z.string().describe('Absolute path to a .drt (or .drp) file') });
@@ -29,6 +29,48 @@ const authorSchema = z.object({
29
29
  outputPath: z.string().describe('Absolute path where the .drt will be written'),
30
30
  });
31
31
  const validateSchema = z.object({ drtPath: z.string().describe('Absolute path to a .drt file') });
32
+ const assembleSchema = z.object({
33
+ spec: z
34
+ .object({})
35
+ .passthrough()
36
+ .describe("assembleTimeline spec: { timelineName?, elements: [{type:'title'|'generator', track, startFrame, durationFrames?, text?, ...}], transitions? }. startFrame is timeline-absolute (origin 86400)."),
37
+ outputPath: z.string().describe('Absolute path where the importable .drt will be written'),
38
+ targetAppVersion: z
39
+ .union([z.string(), z.number()])
40
+ .optional()
41
+ .describe("Resolve version that must import it, e.g. '19.1' — stamps ProjectVersion down from the template's Resolve-21 capture. Omit for 21+ hosts."),
42
+ });
43
+
44
+ /** Rewrite version stamps across every XML entry of a JSZip; returns patch counts. */
45
+ async function applyVersionStamps(zip, targetPV, appVer) {
46
+ const out = new JSZip();
47
+ let elementPatches = 0;
48
+ let stampPatches = 0;
49
+ const jobs = [];
50
+ zip.forEach((path, e) => {
51
+ if (e.dir) return;
52
+ jobs.push(
53
+ (async () => {
54
+ if (!path.endsWith('.xml')) {
55
+ out.file(path, await e.async('nodebuffer'));
56
+ return;
57
+ }
58
+ let xml = await e.async('string');
59
+ xml = xml.replace(/<ProjectVersion>\d+<\/ProjectVersion>/g, () => {
60
+ elementPatches += 1;
61
+ return `<ProjectVersion>${targetPV}</ProjectVersion>`;
62
+ });
63
+ xml = xml.replace(/DbAppVer="[^"]*" DbPrjVer="[^"]*"/g, () => {
64
+ stampPatches += 1;
65
+ return `DbAppVer="${appVer}" DbPrjVer="${targetPV}"`;
66
+ });
67
+ out.file(path, xml);
68
+ })(),
69
+ );
70
+ });
71
+ await Promise.all(jobs);
72
+ return { out, elementPatches, stampPatches };
73
+ }
32
74
  const injectIntoDrpSchema = z.object({
33
75
  drtPath: z.string().describe('Source .drt'),
34
76
  drpPath: z.string().describe('Target.drp to inject into'),
@@ -93,7 +135,7 @@ function requirePathArg(args, key, action) {
93
135
  export const drtTool = {
94
136
  name: 'drt',
95
137
  description:
96
- 'DaVinci Resolve Timeline (.drt) operations — offline, no Resolve required. Actions: parse, list_sequences (enumerate the timelines inside a .drp/.drt → [{id,name,eventCount,index}] to drive a "which sequence?" picker), author, validate, inject_into_drp, extract_from_drp (pull one SeqContainer out as a .drt — feed the .drt to the Python davinci-resolve MCP timeline.import_timeline_checked, or use timeline.import_from_drp to do both), downgrade (stamp <ProjectVersion> down so an OLDER Resolve will import a .drt/.drp from a newer one — pass targetAppVersion like "19.1.3" or targetProjectVersion).',
138
+ 'DaVinci Resolve Timeline (.drt) operations — offline, no Resolve required. Actions: assemble (spec → IMPORTABLE native-schema .drt via template-spliced real structures; pass targetAppVersion e.g. \'19.1\' for pre-21 hosts), parse, list_sequences (enumerate the timelines inside a .drp/.drt → [{id,name,eventCount,index}] to drive a "which sequence?" picker), author, validate, inject_into_drp, extract_from_drp (pull one SeqContainer out as a .drt — feed the .drt to the Python davinci-resolve MCP timeline.import_timeline_checked, or use timeline.import_from_drp to do both), downgrade (stamp <ProjectVersion> down so an OLDER Resolve will import a .drt/.drp from a newer one — pass targetAppVersion like "19.1.3" or targetProjectVersion).',
97
139
  async handler({ action, args }) {
98
140
  if (action === 'parse') {
99
141
  const p = parseSchema.parse(requirePathArg(args, 'drtPath', 'parse'));
@@ -148,6 +190,55 @@ export const drtTool = {
148
190
  await fs.writeFile(p.outputPath, outBuf);
149
191
  return { outputPath: p.outputPath, bytes: outBuf.length, seqContainersInjected: injected, projectFolder };
150
192
  }
193
+ if (action === 'assemble') {
194
+ const p = assembleSchema.parse(args);
195
+ // Native-schema authoring: template-spliced real Resolve structures
196
+ // (drp-format assembleTimeline), which ImportTimelineFromFile accepts —
197
+ // unlike drt.author's flat template. Measured live on Studio 19.1.3.7:
198
+ // assembled + stamped to the host's ProjectVersion imports with every
199
+ // element intact.
200
+ const { assembleTimeline } = drp();
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);
209
+ let outBuf = buffer;
210
+ let stamped = null;
211
+ if (p.targetAppVersion !== undefined) {
212
+ const targetPV = resolveTargetProjectVersion({ targetAppVersion: p.targetAppVersion });
213
+ const appVer = `${p.targetAppVersion}${'.0'.repeat(Math.max(0, 4 - String(p.targetAppVersion).split('.').length))}`;
214
+ const zip = await JSZip.loadAsync(buffer);
215
+ const { out, elementPatches, stampPatches } = await applyVersionStamps(zip, targetPV, appVer);
216
+ outBuf = await out.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
217
+ stamped = { targetProjectVersion: targetPV, elementPatches, stampPatches };
218
+ }
219
+ await fs.writeFile(p.outputPath, outBuf);
220
+ return {
221
+ outputPath: p.outputPath,
222
+ bytes: outBuf.length,
223
+ timelineName,
224
+ startFrame,
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
+ : {}),
237
+ note:
238
+ 'Import with timeline.import_timeline_checked — the imported timeline is named after the FILE. ' +
239
+ 'On a host older than Resolve 21, pass targetAppVersion or the version gate refuses the archive.',
240
+ };
241
+ }
151
242
  if (action === 'extract_from_drp') {
152
243
  const p = extractFromDrpSchema.parse(args);
153
244
  const drpZip = await JSZip.loadAsync(await fs.readFile(p.drpPath));
@@ -156,16 +247,37 @@ export const drtTool = {
156
247
  if (idx >= seqEntries.length) {
157
248
  return { error: `timelineIndex ${idx} out of range (${seqEntries.length} SeqContainers)` };
158
249
  }
159
- const xml = await drpZip.file(seqEntries[idx]).async('string');
250
+ // The importable-.drt recipe, measured by bisection on Studio 19.1.3.7:
251
+ // a .drt IS a .drp that ImportTimelineFromFile accepts. project.xml and
252
+ // MediaPool/ are REQUIRED (the Sm2Sequence/Sm2Timeline objects live in
253
+ // MpFolder.xml); the SeqContainer must keep its ORIGINAL uuid path —
254
+ // renaming it imports an EMPTY timeline with no error; Gallery.xml is
255
+ // droppable. Other timelines' MpFolder blocks must go too, or each
256
+ // arrives as a ghost empty timeline (matched via the container tracks'
257
+ // <Sequence> DbId, which appears inside exactly one Sm2MpTimelineClip).
258
+ const keepEntry = seqEntries[idx];
259
+ const keepXml = await drpZip.file(keepEntry).async('string');
260
+ const keepSeqIds = [...keepXml.matchAll(/<Sequence>([0-9a-f-]{36})<\/Sequence>/g)].map((m) => m[1]);
160
261
  const out = new JSZip();
161
- out.file('Primary1/SeqContainer1.xml', xml);
162
- // ImportTimelineFromFile refuses a .drt without project.xml (measured
163
- // by bisection on Studio 19.1.3.7), so carry the source's over.
164
- const projectEntry = Object.keys(drpZip.files).find(
165
- (n) => n === 'project.xml' || n.endsWith('/project.xml'),
166
- );
167
- if (projectEntry) {
168
- out.file('project.xml', await drpZip.file(projectEntry).async('string'));
262
+ let droppedTimelines = 0;
263
+ for (const name of Object.keys(drpZip.files)) {
264
+ const entry = drpZip.files[name];
265
+ if (entry.dir) continue;
266
+ if (name === 'Gallery.xml') continue;
267
+ const isSeq = seqEntries.includes(name);
268
+ if (isSeq && name !== keepEntry) continue;
269
+ let content = await entry.async(name.endsWith('.xml') ? 'string' : 'nodebuffer');
270
+ if (name.endsWith('MpFolder.xml') && seqEntries.length > 1) {
271
+ content = content.replace(
272
+ /<Element>\s*<Sm2MpTimelineClip DbId="[^"]+">(?:(?!<\/Sm2MpTimelineClip>)[\s\S])*?<\/Sm2MpTimelineClip>\s*<\/Element>/g,
273
+ (block) => {
274
+ if (keepSeqIds.some((id) => block.includes(id))) return block;
275
+ droppedTimelines += 1;
276
+ return '';
277
+ },
278
+ );
279
+ }
280
+ out.file(name, content);
169
281
  }
170
282
  out.file(
171
283
  'metadata.json',
@@ -173,7 +285,8 @@ export const drtTool = {
173
285
  {
174
286
  source: 'extract_from_drp',
175
287
  sourceDrp: p.drpPath,
176
- sourceSeqContainer: seqEntries[idx],
288
+ sourceSeqContainer: keepEntry,
289
+ droppedTimelines,
177
290
  exportedFrom: 'davinci-resolve-advanced-mcp drt.extract_from_drp',
178
291
  },
179
292
  null,
@@ -182,7 +295,13 @@ export const drtTool = {
182
295
  );
183
296
  const outBuf = await out.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
184
297
  await fs.writeFile(p.outputPath, outBuf);
185
- return { outputPath: p.outputPath, bytes: outBuf.length, sourceSeqContainer: seqEntries[idx] };
298
+ return {
299
+ outputPath: p.outputPath,
300
+ bytes: outBuf.length,
301
+ sourceSeqContainer: keepEntry,
302
+ droppedTimelines,
303
+ note: 'The imported timeline is named after the FILE. Source must be a SAVED project export — ExportProject snapshots the saved DB state, so unsaved edits are absent.',
304
+ };
186
305
  }
187
306
  if (action === 'downgrade') {
188
307
  const p = downgradeSchema.parse(args);
@@ -19,7 +19,17 @@
19
19
 
20
20
  import { z } from 'zod';
21
21
  import { createRequire } from 'node:module';
22
- import { resolveDbPath, openGuarded, backup, requireClosed } from '../db-patch.mjs';
22
+ import { resolveDbPath as _resolveDbPathRaw, responsiveRoots, openGuarded, backup, requireClosed } from '../db-patch.mjs';
23
+
24
+ // projectName discovery walks library roots that macOS can render UNRESPONSIVE
25
+ // (measured: a hung Lite sandbox container froze every lookup). Probe the
26
+ // roots with a deadline first and search only the ones that answer; the
27
+ // not-found error then names any skipped root.
28
+ async function resolveDbPath(p) {
29
+ if (p.projectDb || !p.projectName) return _resolveDbPathRaw(p);
30
+ const { roots, skipped } = await responsiveRoots();
31
+ return _resolveDbPathRaw({ ...p, roots, skippedRoots: skipped });
32
+ }
23
33
 
24
34
  const require = createRequire(import.meta.url);
25
35
 
@@ -96,7 +106,7 @@ export const projectDbTool = {
96
106
  async handler({ action, args }) {
97
107
  if (action === 'list_folders') {
98
108
  const p = listFoldersSchema.parse(args);
99
- const db = openGuarded(resolveDbPath(p), { table: 'Sm2MpFolder', column: 'Name' });
109
+ const db = openGuarded(await resolveDbPath(p), { table: 'Sm2MpFolder', column: 'Name' });
100
110
  try {
101
111
  return { folders: db.prepare('SELECT Name, ColorTag FROM Sm2MpFolder ORDER BY Name').all() };
102
112
  } finally {
@@ -106,7 +116,7 @@ export const projectDbTool = {
106
116
  if (action === 'rename_folder') {
107
117
  const p = renameFolderSchema.parse(args);
108
118
  requireClosed(p);
109
- const dbPath = resolveDbPath(p);
119
+ const dbPath = await resolveDbPath(p);
110
120
  const bak = backup(dbPath);
111
121
  const db = openGuarded(dbPath, { writable: true, table: 'Sm2MpFolder', column: 'Name' });
112
122
  try {
@@ -121,7 +131,7 @@ export const projectDbTool = {
121
131
  if (action === 'set_folder_color') {
122
132
  const p = folderColorSchema.parse(args);
123
133
  requireClosed(p);
124
- const dbPath = resolveDbPath(p);
134
+ const dbPath = await resolveDbPath(p);
125
135
  const bak = backup(dbPath);
126
136
  const db = openGuarded(dbPath, { writable: true, table: 'Sm2MpFolder', column: 'ColorTag' });
127
137
  try {
@@ -135,7 +145,7 @@ export const projectDbTool = {
135
145
  }
136
146
  if (action === 'list_clips') {
137
147
  const p = listClipsSchema.parse(args);
138
- const db = openGuarded(resolveDbPath(p), { table: 'Sm2MpMedia', column: 'MarkIn' });
148
+ const db = openGuarded(await resolveDbPath(p), { table: 'Sm2MpMedia', column: 'MarkIn' });
139
149
  try {
140
150
  return { clips: db.prepare('SELECT Name, MarkIn, MarkOut FROM Sm2MpMedia ORDER BY Name LIMIT 500').all() };
141
151
  } finally {
@@ -146,7 +156,7 @@ export const projectDbTool = {
146
156
  const p = clipMarksSchema.parse(args);
147
157
  requireClosed(p);
148
158
  if (p.markIn == null && p.markOut == null) throw new Error('provide markIn and/or markOut');
149
- const dbPath = resolveDbPath(p);
159
+ const dbPath = await resolveDbPath(p);
150
160
  const bak = backup(dbPath);
151
161
  const db = openGuarded(dbPath, { writable: true, table: 'Sm2MpMedia', column: 'MarkIn' });
152
162
  try {
@@ -163,7 +173,7 @@ export const projectDbTool = {
163
173
  const p = relayoutSchema.parse(args);
164
174
  const layout = require('../../vendor/drx-codec/node-layout.js');
165
175
  const layoutOpts = { originX: p.originX, originY: p.originY, spacingX: p.spacingX };
166
- const dbPath = resolveDbPath(p);
176
+ const dbPath = await resolveDbPath(p);
167
177
  const write = !p.dryRun;
168
178
  if (write) requireClosed(p);
169
179
  const bak = write ? backup(dbPath) : null;
@@ -228,7 +238,7 @@ export const projectDbTool = {
228
238
  if (action === 'list_subtitle_styles') {
229
239
  const p = listSubtitleStylesSchema.parse(args);
230
240
  const style = require('../../vendor/drp-format/subtitle-style.js');
231
- const db = openGuarded(resolveDbPath(p), { table: 'Sm2TiTrack', column: 'FieldsBlob' });
241
+ const db = openGuarded(await resolveDbPath(p), { table: 'Sm2TiTrack', column: 'FieldsBlob' });
232
242
  try {
233
243
  const counts = new Map();
234
244
  const tracks = [];
@@ -281,7 +291,7 @@ export const projectDbTool = {
281
291
 
282
292
  const write = !p.dryRun;
283
293
  if (write) requireClosed(p);
284
- const dbPath = resolveDbPath(p);
294
+ const dbPath = await resolveDbPath(p);
285
295
  const bak = write ? backup(dbPath) : null;
286
296
  const db = openGuarded(dbPath, { writable: write, table: 'Sm2TiTrack', column: 'FieldsBlob' });
287
297
  try {
@@ -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 };