davinci-resolve-mcp 2.104.9 → 2.105.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,84 @@
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.105.0
6
+
7
+ **Native-schema DRT authoring — the parked "project, not a lap" — shipped.**
8
+ Tool-authored .drt files that Resolve's ImportTimelineFromFile actually
9
+ accepts, live-verified end to end on Studio 19.1.3.7.
10
+
11
+ The door was already half-open: the repo's template-splice engine
12
+ (assembleTimeline + the real Resolve-21 empty-project capture) authors
13
+ native-schema .drp archives, and the final bisection showed a .drt IS a .drp
14
+ that ImportTimelineFromFile accepts. What stood between them was the version
15
+ gate (the template stamps DbPrjVer 17; a 19.1.3 host wants 14) and a set of
16
+ extraction traps nobody had mapped.
17
+
18
+ ### Added
19
+
20
+ - **`drt.assemble`** — spec → importable native-schema .drt (titles,
21
+ generators, transitions), with `targetAppVersion` stamping for pre-21
22
+ hosts. Live-verified: assembled archives import with every element intact.
23
+ - **`drt.extract_from_drp` rebuilt on the measured recipe**: keep
24
+ project.xml + MediaPool + the SeqContainer at its ORIGINAL uuid path, drop
25
+ Gallery, and remove other timelines' Sm2MpTimelineClip blocks (matched via
26
+ the kept container's track Sequence DbIds) so they don't arrive as ghost
27
+ empty timelines. The Python extractor behind `timeline.import_from_drp`
28
+ implements the same recipe. Live-verified: single-timeline extracts from a
29
+ two-timeline project import cleanly, one timeline, clips intact.
30
+
31
+ ### The .drt import contract, fully mapped (api_truth rewritten)
32
+
33
+ A whole saved-project export renamed .drt imports, clips intact.
34
+ Requirements: project.xml; MpFolder.xml (it holds the Sm2Sequence/Sm2Timeline
35
+ objects); the SeqContainer's ORIGINAL uuid path — renaming it "succeeds"
36
+ with an EMPTY timeline, no error, the nastiest variant; version stamps at or
37
+ below the host; native blob schema; and a SAVED source project —
38
+ ExportProject snapshots the saved DB state, so an unsaved timeline exports
39
+ empty tracks (the trap that produced v2.104.7's "necessary but not
40
+ sufficient" verdict, now corrected). Every Sm2MpTimelineClip block imports
41
+ as a timeline; extras arrive as ghosts unless removed.
42
+
43
+ ### Fixed in passing
44
+
45
+ - **project_db lookups can no longer hang on an unresponsive library root.**
46
+ Mid-session, macOS rendered the Lite sandbox container path unresponsive at
47
+ the filesystem level (`ls` itself hung) — which froze the Node test suite
48
+ and would have frozen every projectName lookup. Roots are now probed with a
49
+ deadline (`responsiveRoots`); unresponsive ones are skipped and NAMED in
50
+ the not-found error. The root-walking tests are hermetic now — suites must
51
+ not depend on machine paths that an OS can wedge.
52
+ - The flat-authored-shape refusal in import_timeline_checked now points at
53
+ `drt.assemble` as the importable authoring route.
54
+
55
+ ## What's New in v2.104.10
56
+
57
+ Stones turned on the live-validation backlog, on Studio 19.1.3.7.
58
+
59
+ **AAF live import, validated at last** (marked "NOT live-validated" since
60
+ 2026-07-06): an EXPORT_AAF/EXPORT_AAF_NEW round trip imports cleanly with
61
+ importSourceClips=false, lands fully offline (the documented turnover shape),
62
+ and preserves the start timecode. The naming matrix across import formats is
63
+ now complete and in api_truth — FCP7 XML ignores timelineName (internal name
64
+ wins, the #171 trap); AAF honours timelineName when given, else its internal
65
+ name; OTIO honours timelineName; .drt names the timeline after the file. Only
66
+ FCP7 exhibits the returned-existing trap. The AAF post-import relink leg was
67
+ exercised too: under importSourceClips=false it correctly reports "no Media
68
+ Pool Items to relink" — the API relinks pool items, and none exist on that
69
+ path — so its precondition is now stated instead of assumed.
70
+
71
+ **safe_quick_export verifies its output.** RenderWithQuickExport's status
72
+ dict was the last render surface trusted without a file check: a success
73
+ status that wrote nothing read as an export. The files that actually landed
74
+ in TargetDir are now listed with size and ffprobe duration, and a success
75
+ status with no new file flips to an error.
76
+
77
+ **Housekeeping:** the delete-locked scratch project from the v2.104.7 session
78
+ is gone — the pre-restart DeleteProject had returned False while actually
79
+ succeeding, the documented DeleteProject lie caught in the wild. A clean
80
+ Resolve quit/relaunch verified the wedge entry's other half: Quit() works
81
+ when no orphaned render holds the pipeline.
82
+
5
83
  ## What's New in v2.104.9
6
84
 
7
85
  **The NTSC coverage gap, closed.** The conform fixtures were integer-rate
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.9-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.105.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.9-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.105.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.9 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.105.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -521,7 +521,7 @@ values, or automation-hostile modal prompts.
521
521
 
522
522
  - **Object:** `MediaPool`
523
523
  - **Signature:** `(filePath, {timelineName, importSourceClips, ...}) -> Timeline`
524
- - **Behavior:** For FCP7 XML, the sequence name INSIDE the file wins over the timelineName import option. When the internal name matches an existing timeline, the call returns that EXISTING timeline — no error, no new timeline — so an export→edit→re-import loop keying uniqueness on the option 'succeeds' while operating on one timeline forever (issue #171, Studio 21.0.4.5; wrapper behavior verified on 19.1.3.7). Distinct from the documented repeated-timelineName None return: here the option is fresh and the file's name is stale.
524
+ - **Behavior:** For FCP7 XML, the sequence name INSIDE the file wins over the timelineName import option. When the internal name matches an existing timeline, the call returns that EXISTING timeline — no error, no new timeline — so an export→edit→re-import loop keying uniqueness on the option 'succeeds' while operating on one timeline forever (issue #171, Studio 21.0.4.5; wrapper behavior verified on 19.1.3.7). Distinct from the documented repeated-timelineName None return: here the option is fresh and the file's name is stale. THE NAMING AUTHORITY DIFFERS PER FORMAT (all measured on 19.1.3.7): FCP7 XML ignores timelineName entirely (internal <name> wins); AAF honours timelineName when given and falls back to its internal name; OTIO honours timelineName; and .drt names the timeline after the FILE (see the .drt entry below). Only FCP7 exhibits the returned-existing trap.
525
525
  - **Workaround / current handling:** Rewrite the <sequence><name> inside the file to the intended name before importing — timeline.import_timeline_checked does this automatically for FCP7 XML and errors when a non-rewritable format still returns an existing timeline. Never treat a truthy return as proof of creation; check the returned timeline's id against the pre-import set.
526
526
  - **Reference:** [issue #171](https://github.com/samuelgursky/davinci-resolve-mcp/issues/171)
527
527
  - **Tags:** timeline, import, silent-failure, unreliable-return
@@ -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.9"
40
+ VERSION = "2.105.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.9",
3
+ "version": "2.105.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,37 @@ 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
+ const { buffer, timelineName, startFrame } = await assembleTimeline(p.spec);
202
+ let outBuf = buffer;
203
+ let stamped = null;
204
+ if (p.targetAppVersion !== undefined) {
205
+ const targetPV = resolveTargetProjectVersion({ targetAppVersion: p.targetAppVersion });
206
+ const appVer = `${p.targetAppVersion}${'.0'.repeat(Math.max(0, 4 - String(p.targetAppVersion).split('.').length))}`;
207
+ const zip = await JSZip.loadAsync(buffer);
208
+ const { out, elementPatches, stampPatches } = await applyVersionStamps(zip, targetPV, appVer);
209
+ outBuf = await out.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
210
+ stamped = { targetProjectVersion: targetPV, elementPatches, stampPatches };
211
+ }
212
+ await fs.writeFile(p.outputPath, outBuf);
213
+ return {
214
+ outputPath: p.outputPath,
215
+ bytes: outBuf.length,
216
+ timelineName,
217
+ startFrame,
218
+ stamped,
219
+ note:
220
+ 'Import with timeline.import_timeline_checked — the imported timeline is named after the FILE. ' +
221
+ 'On a host older than Resolve 21, pass targetAppVersion or the version gate refuses the archive.',
222
+ };
223
+ }
151
224
  if (action === 'extract_from_drp') {
152
225
  const p = extractFromDrpSchema.parse(args);
153
226
  const drpZip = await JSZip.loadAsync(await fs.readFile(p.drpPath));
@@ -156,16 +229,37 @@ export const drtTool = {
156
229
  if (idx >= seqEntries.length) {
157
230
  return { error: `timelineIndex ${idx} out of range (${seqEntries.length} SeqContainers)` };
158
231
  }
159
- const xml = await drpZip.file(seqEntries[idx]).async('string');
232
+ // The importable-.drt recipe, measured by bisection on Studio 19.1.3.7:
233
+ // a .drt IS a .drp that ImportTimelineFromFile accepts. project.xml and
234
+ // MediaPool/ are REQUIRED (the Sm2Sequence/Sm2Timeline objects live in
235
+ // MpFolder.xml); the SeqContainer must keep its ORIGINAL uuid path —
236
+ // renaming it imports an EMPTY timeline with no error; Gallery.xml is
237
+ // droppable. Other timelines' MpFolder blocks must go too, or each
238
+ // arrives as a ghost empty timeline (matched via the container tracks'
239
+ // <Sequence> DbId, which appears inside exactly one Sm2MpTimelineClip).
240
+ const keepEntry = seqEntries[idx];
241
+ const keepXml = await drpZip.file(keepEntry).async('string');
242
+ const keepSeqIds = [...keepXml.matchAll(/<Sequence>([0-9a-f-]{36})<\/Sequence>/g)].map((m) => m[1]);
160
243
  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'));
244
+ let droppedTimelines = 0;
245
+ for (const name of Object.keys(drpZip.files)) {
246
+ const entry = drpZip.files[name];
247
+ if (entry.dir) continue;
248
+ if (name === 'Gallery.xml') continue;
249
+ const isSeq = seqEntries.includes(name);
250
+ if (isSeq && name !== keepEntry) continue;
251
+ let content = await entry.async(name.endsWith('.xml') ? 'string' : 'nodebuffer');
252
+ if (name.endsWith('MpFolder.xml') && seqEntries.length > 1) {
253
+ content = content.replace(
254
+ /<Element>\s*<Sm2MpTimelineClip DbId="[^"]+">(?:(?!<\/Sm2MpTimelineClip>)[\s\S])*?<\/Sm2MpTimelineClip>\s*<\/Element>/g,
255
+ (block) => {
256
+ if (keepSeqIds.some((id) => block.includes(id))) return block;
257
+ droppedTimelines += 1;
258
+ return '';
259
+ },
260
+ );
261
+ }
262
+ out.file(name, content);
169
263
  }
170
264
  out.file(
171
265
  'metadata.json',
@@ -173,7 +267,8 @@ export const drtTool = {
173
267
  {
174
268
  source: 'extract_from_drp',
175
269
  sourceDrp: p.drpPath,
176
- sourceSeqContainer: seqEntries[idx],
270
+ sourceSeqContainer: keepEntry,
271
+ droppedTimelines,
177
272
  exportedFrom: 'davinci-resolve-advanced-mcp drt.extract_from_drp',
178
273
  },
179
274
  null,
@@ -182,7 +277,13 @@ export const drtTool = {
182
277
  );
183
278
  const outBuf = await out.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
184
279
  await fs.writeFile(p.outputPath, outBuf);
185
- return { outputPath: p.outputPath, bytes: outBuf.length, sourceSeqContainer: seqEntries[idx] };
280
+ return {
281
+ outputPath: p.outputPath,
282
+ bytes: outBuf.length,
283
+ sourceSeqContainer: keepEntry,
284
+ droppedTimelines,
285
+ 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.',
286
+ };
186
287
  }
187
288
  if (action === 'downgrade') {
188
289
  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 {
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.104.9"
90
+ VERSION = "2.105.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.104.9"
14
+ VERSION = "2.105.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -7158,12 +7158,12 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
7158
7158
  "dismisses it, so this import is not attempted at all.",
7159
7159
  category="invalid_input",
7160
7160
  remediation=(
7161
- "Authored DRTs serve offline/DB workflows, not live import. "
7162
- "For a live import, author OTIO instead "
7163
- "(editorial.convert_to_interchange target 'otio' imports "
7164
- "cleanly), or import a REAL Resolve .drt one Resolve "
7165
- "exported, or one pulled from a .drp with "
7166
- "drt.extract_from_drp."
7161
+ "Flat-authored DRTs serve offline/DB workflows, not live "
7162
+ "import. For an importable .drt, use drt.assemble "
7163
+ "(native-schema authoring from a spec; pass "
7164
+ "targetAppVersion e.g. '19.1' on pre-21 hosts), "
7165
+ "drt.extract_from_drp on a SAVED .drp export, or author "
7166
+ "OTIO (editorial.convert_to_interchange target 'otio')."
7167
7167
  ),
7168
7168
  )
7169
7169
  # ImportTimelineFromFile silently no-ops on the never-saved default project: it returns
@@ -7465,38 +7465,62 @@ def _drp_seq_containers(zf) -> List[Dict[str, Any]]:
7465
7465
 
7466
7466
 
7467
7467
  def _extract_seqcontainer_from_drp(drp_path: str, seq_entry: str, out_path: str) -> None:
7468
- """Write a .drt (zip) holding one SeqContainer plus the source's project.xml.
7469
-
7470
- ImportTimelineFromFile refuses a .drt without project.xml (measured by
7471
- bisection on Studio 19.1.3.7: a real export minus only project.xml is
7472
- refused; project.xml + container with no MpFolder imports). The source
7473
- .drp/.drt carries one copy it, or the extract cannot import.
7468
+ """Write an IMPORTABLE .drt holding one timeline from a .drp/.drt archive.
7469
+
7470
+ The recipe, measured by bisection on Studio 19.1.3.7: a .drt IS a .drp
7471
+ that ImportTimelineFromFile accepts. project.xml and MediaPool/ are
7472
+ REQUIRED (the Sm2Sequence/Sm2Timeline objects live in MpFolder.xml); the
7473
+ SeqContainer must keep its ORIGINAL uuid path renaming it imports an
7474
+ EMPTY timeline with no error; Gallery.xml is droppable; and other
7475
+ timelines' Sm2MpTimelineClip blocks must be removed from MpFolder.xml or
7476
+ each arrives as a ghost empty timeline (matched via the kept container's
7477
+ track <Sequence> DbIds, which appear inside exactly one block). Source
7478
+ must be a SAVED export — ExportProject snapshots the saved DB state.
7474
7479
  """
7475
7480
  import zipfile
7476
7481
 
7477
7482
  with zipfile.ZipFile(drp_path, "r") as zf:
7478
- xml = zf.read(seq_entry)
7479
- project_xml = None
7480
- for candidate in zf.namelist():
7481
- if candidate == "project.xml" or candidate.endswith("/project.xml"):
7482
- project_xml = zf.read(candidate)
7483
- break
7484
- with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as out:
7485
- out.writestr("Primary1/SeqContainer1.xml", xml)
7486
- if project_xml is not None:
7487
- out.writestr("project.xml", project_xml)
7488
- out.writestr(
7489
- "metadata.json",
7490
- json.dumps(
7491
- {
7492
- "source": "import_from_drp",
7493
- "sourceDrp": drp_path,
7494
- "sourceSeqContainer": seq_entry,
7495
- "exportedFrom": "davinci-resolve-mcp timeline.import_from_drp",
7496
- },
7497
- indent=2,
7498
- ),
7499
- )
7483
+ names = [n for n in zf.namelist() if not n.endswith("/")]
7484
+ seq_entries = [n for n in names if _SEQ_CONTAINER_RE.search(n)]
7485
+ keep_xml = zf.read(seq_entry).decode("utf-8", "replace")
7486
+ keep_seq_ids = re.findall(r"<Sequence>([0-9a-f-]{36})</Sequence>", keep_xml)
7487
+ with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as out:
7488
+ for name in names:
7489
+ if name == "Gallery.xml":
7490
+ continue
7491
+ if name in seq_entries and name != seq_entry:
7492
+ continue
7493
+ data = zf.read(name)
7494
+ if name.endswith("MpFolder.xml") and len(seq_entries) > 1:
7495
+ text = data.decode("utf-8", "replace")
7496
+
7497
+ def _keep_block(match):
7498
+ block = match.group(0)
7499
+ if any(sid in block for sid in keep_seq_ids):
7500
+ return block
7501
+ return ""
7502
+
7503
+ text = re.sub(
7504
+ r"<Element>\s*<Sm2MpTimelineClip DbId=\"[^\"]+\">"
7505
+ r"(?:(?!</Sm2MpTimelineClip>)[\s\S])*?"
7506
+ r"</Sm2MpTimelineClip>\s*</Element>",
7507
+ _keep_block,
7508
+ text,
7509
+ )
7510
+ data = text.encode("utf-8")
7511
+ out.writestr(name, data)
7512
+ out.writestr(
7513
+ "metadata.json",
7514
+ json.dumps(
7515
+ {
7516
+ "source": "import_from_drp",
7517
+ "sourceDrp": drp_path,
7518
+ "sourceSeqContainer": seq_entry,
7519
+ "exportedFrom": "davinci-resolve-mcp timeline.import_from_drp",
7520
+ },
7521
+ indent=2,
7522
+ ),
7523
+ )
7500
7524
 
7501
7525
 
7502
7526
  def _import_from_drp(proj, mp, p: Dict[str, Any]):
@@ -18975,8 +18999,40 @@ def _safe_quick_export(proj, p: Dict[str, Any]):
18975
18999
  return {"success": False, "validation": validation}
18976
19000
  if p.get("dry_run") or not p.get("allow_render", False):
18977
19001
  return _ok(would_render=False, preset=preset, params=params, validation=validation)
19002
+ before = set()
19003
+ if target_dir and os.path.isdir(target_dir):
19004
+ before = set(os.listdir(target_dir))
18978
19005
  status = _ser(proj.RenderWithQuickExport(preset, params))
18979
- return {"success": not (isinstance(status, dict) and status.get("error")), "status": status, "params": params}
19006
+ out: Dict[str, Any] = {
19007
+ "success": not (isinstance(status, dict) and status.get("error")),
19008
+ "status": status,
19009
+ "params": params,
19010
+ }
19011
+ # RenderWithQuickExport's status dict is trusted nowhere else in this
19012
+ # repo's render surface anymore (issue #164 taught that lesson) — check
19013
+ # what actually landed on disk. Best-effort: TargetDir is known, so a
19014
+ # successful export must have produced at least one new file there.
19015
+ if out["success"] and target_dir and os.path.isdir(target_dir):
19016
+ new_files = sorted(set(os.listdir(target_dir)) - before)
19017
+ outputs = []
19018
+ for name in new_files:
19019
+ path = os.path.join(target_dir, name)
19020
+ if not os.path.isfile(path):
19021
+ continue
19022
+ rec: Dict[str, Any] = {"path": path, "size_bytes": os.path.getsize(path)}
19023
+ probe = _ffprobe_media_summary(path)
19024
+ if probe:
19025
+ rec["duration_seconds"] = probe.get("duration_seconds")
19026
+ outputs.append(rec)
19027
+ out["outputs"] = outputs
19028
+ if not outputs:
19029
+ out["success"] = False
19030
+ out["error"] = (
19031
+ "RenderWithQuickExport reported success but wrote no file to "
19032
+ f"{target_dir} — treat the status dict as unreliable and check "
19033
+ "the Deliver page's render queue."
19034
+ )
19035
+ return out
18980
19036
 
18981
19037
 
18982
19038
  def _export_render_boundary_report(proj, p: Dict[str, Any]):
@@ -19049,7 +19105,10 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
19049
19105
  way to read back — see the SetRenderSettings api_truth entry.
19050
19106
  render_job_lifecycle_probe(target_dir, settings?, format?, codec?, custom_name?) -> {success, job_id, status_before_delete}
19051
19107
  quick_export_capabilities() -> {presets, safe_params, guards}
19052
- safe_quick_export(preset, target_dir?|params?, custom_name?, dry_run?, allow_render?) -> {success, status}
19108
+ safe_quick_export(preset, target_dir?|params?, custom_name?, dry_run?, allow_render?) -> {success, status, outputs}
19109
+ After a live export, the files that actually landed in TargetDir are
19110
+ listed with size and ffprobe duration; a success status that wrote no
19111
+ file flips success to false (the issue #164 trust lesson).
19053
19112
  export_render_boundary_report(include_matrix?, max_pairs?, include_quick_export?) -> {capabilities, settings, matrix?}
19054
19113
  list_delivery_targets(tier?, check_availability?) -> {targets, tiers, schema_version}
19055
19114
  resolve_delivery_target(target, overrides?) -> {format_id, codec_id, settings, qc_spec}
@@ -1866,7 +1866,13 @@ API_TRUTH: List[Dict[str, Any]] = [
1866
1866
  "(issue #171, Studio 21.0.4.5; wrapper behavior verified on "
1867
1867
  "19.1.3.7). Distinct from the documented repeated-"
1868
1868
  "timelineName None return: here the option is fresh and the "
1869
- "file's name is stale.",
1869
+ "file's name is stale. THE NAMING AUTHORITY DIFFERS PER "
1870
+ "FORMAT (all measured on 19.1.3.7): FCP7 XML ignores "
1871
+ "timelineName entirely (internal <name> wins); AAF honours "
1872
+ "timelineName when given and falls back to its internal "
1873
+ "name; OTIO honours timelineName; and .drt names the "
1874
+ "timeline after the FILE (see the .drt entry below). Only "
1875
+ "FCP7 exhibits the returned-existing trap.",
1870
1876
  "recommended": "Rewrite the <sequence><name> inside the file to the "
1871
1877
  "intended name before importing — "
1872
1878
  "timeline.import_timeline_checked does this "
@@ -1883,32 +1889,37 @@ API_TRUTH: List[Dict[str, Any]] = [
1883
1889
  "symbol": "MediaPool.ImportTimelineFromFile (.drt requirements and filename naming)",
1884
1890
  "object": "MediaPool",
1885
1891
  "signature": "(drtPath, {importSourceClips, ...}) -> Timeline",
1886
- "reality": "Measured by bisection on Studio 19.1.3.7 against a real "
1887
- ".drt export: (1) the archive MUST contain project.xml — "
1888
- "the same archive minus only that entry is refused, while "
1889
- "removing MediaPool/MpFolder.xml or renaming the "
1890
- "SeqContainer path changes nothing; (2) the container XML "
1891
- "must be Resolve's native blob-based schema a syntactic "
1892
- "SeqContainer with flat template elements is refused even "
1893
- "beside a genuine project.xml; (3) the imported timeline is "
1894
- "named after the FILE (real_2997.drt -> 'real_2997'), not "
1895
- "the container's internal namea third naming authority "
1896
- "beside FCP7 (internal name wins) and OTIO (timelineName "
1897
- "option wins); and (4) a refused import can raise a modal "
1898
- "error dialog that BLOCKS the scripting call until a human "
1899
- "dismisses it the call neither returns nor times out.",
1900
- "recommended": "Import only .drt files Resolve itself exported "
1901
- "the sufficient set is unmapped beyond that: even a "
1902
- ".drp-sourced native container repacked WITH its "
1903
- "project.xml was refused (measured 19.1.3.7), so "
1904
- "extraction-based .drt import cannot be relied on "
1905
- "either. Name the timeline by naming the FILE. Never "
1906
- "batch speculative .drt imports unattended one "
1907
- "refusal can hold the whole session hostage behind "
1908
- "its dialog. timeline.import_timeline_checked detects "
1909
- "the authored template shape up front and refuses "
1910
- "with this diagnosis instead of importing; for a "
1911
- "reliable programmatic import use OTIO or FCP7 XML.",
1892
+ "reality": "Fully mapped by bisection on Studio 19.1.3.7: a .drt IS "
1893
+ "a .drp that ImportTimelineFromFile accepts a whole "
1894
+ "saved-project export renamed .drt imports, clips intact. "
1895
+ "Requirements: (1) project.xml present; (2) MediaPool/"
1896
+ "MpFolder.xml present it holds the Sm2Sequence/"
1897
+ "Sm2Timeline objects; (3) the SeqContainer keeps its "
1898
+ "ORIGINAL uuid path renaming it 'succeeds' with an "
1899
+ "EMPTY timeline (items=0, no error), the nastiest variant; "
1900
+ "(4) version stamps at or below the host's ProjectVersion; "
1901
+ "(5) native blob schemaflat template containers are "
1902
+ "refused; (6) the source must be a SAVED export "
1903
+ "(ExportProject snapshots the saved DB state, so an "
1904
+ "unsaved timeline exports EMPTY tracks). Every "
1905
+ "Sm2MpTimelineClip block in MpFolder imports as a "
1906
+ "timeline: extra blocks arrive as ghost empty timelines "
1907
+ "unless removed (match blocks via the kept container's "
1908
+ "track <Sequence> DbIds). The imported timeline is named "
1909
+ "after the FILE, and a refused import can raise a modal "
1910
+ "dialog that BLOCKS the scripting call until a human "
1911
+ "dismisses it.",
1912
+ "recommended": "Follow the recipe: drt.extract_from_drp implements "
1913
+ "it (original container path, MpFolder carried, ghost "
1914
+ "blocks removed, Gallery dropped), and drt.assemble "
1915
+ "authors importable native-schema archives from "
1916
+ "scratch (template-spliced; pass targetAppVersion on "
1917
+ "pre-21 hosts). Save the project before ExportProject. "
1918
+ "Name the timeline by naming the FILE. Never batch "
1919
+ "speculative .drt imports unattended — one refusal "
1920
+ "can hold the session hostage behind its dialog; "
1921
+ "timeline.import_timeline_checked refuses the flat "
1922
+ "authored shape up front for exactly that reason.",
1912
1923
  "tags": ["timeline", "import", "silent-failure", "headless"],
1913
1924
  "submit": "bug",
1914
1925
  },