davinci-resolve-mcp 2.132.1 → 2.134.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.
@@ -14,6 +14,18 @@ the raw runs are in `docs/reference/evidence/`.
14
14
  `-nogui` — the flat round trip, the complex-cut round trip, and the moved-media
15
15
  relink. Choose a format on its properties, never on whether you have a UI.
16
16
 
17
+ **Precondition first, though: headless requires external scripting to connect
18
+ without the GUI.** That held everywhere it was measured here, but at least one
19
+ field setup (Studio 21.0.4.5, scripting routed through an external bridge
20
+ process — issue #172) boots `-nogui` into an instance that *never answers*
21
+ `scriptapp('Resolve')`, and an unscriptable headless instance still holds the
22
+ one-per-machine singleton, so the GUI cannot start either. Preflight in 30
23
+ seconds before committing a loop to `-nogui`:
24
+ `python scripts/resolve_headless.py run -- python -c "print('ok')"` — a clean
25
+ `ok` proves boot-to-scriptable; a `FAILED: no scripting response` now cleans up
26
+ the instance it started, and `stop --force` TERM/KILLs a wedged one (unclean:
27
+ expect project locks and a slow next boot).
28
+
17
29
  **There is no single best format.** Three measurements pull in different
18
30
  directions, and the right choice depends on which one you are up against:
19
31
 
@@ -45,6 +45,7 @@ window. `render.verify_output` covers the container-level checks.
45
45
  | Audio cross-fades | `transitions[].trackType: 'audio'` | v2.116 |
46
46
  | Constant retimes, forward | `cuts[].speed` (e.g. `0.5`) | v2.113 |
47
47
  | Constant retimes, reverse | `cuts[].reverse` | v2.114 |
48
+ | Freeze frames | `cuts[].freeze` (holds source frame `srcIn`) | v2.134 |
48
49
  | Audio placements, A1–A8 | `cuts[].audioOnly + track` | v2.115 |
49
50
  | Built-in generators | `elements: [{type:'generator', generatorName}]` | v2.110 |
50
51
  | Custom start timecode | `spec.startFrame` / `preserveStartTimecode` | v2.117 |
@@ -54,6 +55,7 @@ window. `render.verify_output` covers the container-level checks.
54
55
  | Subtitles | `spec.subtitles` / `spec.subtitlesSrt` (raw SRT) | v2.128 |
55
56
  | Compound clips | survive `extract_from_drp` → `.drt` (inner containers kept, recursive) | v2.130 |
56
57
  | Compound authoring | `spec.compounds` (multiple compose; nested edits render) | v2.131–2.132 |
58
+ | Nested compounds | `compounds[].compounds` (depth-2 playback render-verified) | v2.134 |
57
59
  | Fusion titles | `elements: [{type:'title', text}]` — **21-gen hosts only** | v2.108 |
58
60
 
59
61
  `assemble_from_interchange` drives the same engine from an EDL / OTIO /
@@ -93,6 +95,15 @@ the clip's `<In>`/`<Duration>` window into it in record-domain frames.
93
95
  Reverse is the same map with the Y endpoints swapped, and `In` then measures
94
96
  from the source end.
95
97
 
98
+ **Freezes are a third shape, not a flat retime.** A flat line in the retime's
99
+ frame domain reads back frozen but *renders moving* — the one divergence that
100
+ runs in the "working" direction. The real freeze (harvested from a live EDL
101
+ `M2 000.0` import, render-proven frozen by freezedetect) is a flat line in
102
+ **seconds**: `YMin = YMax = Y = frozen position` (source frame / fps),
103
+ `XMax = 60000` (a fixed sentinel, not the clip length), and the clip's `<In>`
104
+ left **empty**. `cuts[].freeze: true` authors exactly that; EDL `M2 000.0`
105
+ and zero-speed warps route to it through `assemble_from_interchange`.
106
+
96
107
  **Timeline origin.** Template timelines start at frame 86400
97
108
  (01:00:00:00 @ 24fps). Clips placed before the origin are silently dropped
98
109
  by Resolve on import — `startFrame` is timeline-absolute.
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.132.1"
40
+ VERSION = "2.134.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.132.1",
3
+ "version": "2.134.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -201,9 +201,10 @@ export function eventsToOTIO(events, opts = {}) {
201
201
  * fps (v2.104.6 convention, measured against Resolve); the assemble template
202
202
  * timeline runs 24fps with origin 86400, so rec/src frames convert as
203
203
  * round(frames × 24 / nominalFps). Placement anchors the EARLIEST video
204
- * event at the origin. Honesty ledger in the returned report: flattened
205
- * zero-speed freezes (constant retimes forward AND reverse — are AUTHORED
206
- * as real Sm2TimeMaps, r19 keyed form, readback/render-verified), authored vs
204
+ * event at the origin. Honesty ledger in the returned report: authored
205
+ * retimes (constant speed forward AND reverse, and zero-speed FREEZES all
206
+ * AUTHORED as real Sm2TimeMaps, r19 keyed form, readback/render-verified;
207
+ * freezes render-proven frozen via freezedetect, E55/E56), authored vs
207
208
  * dropped transitions (cross-dissolves are AUTHORED when the predecessor
208
209
  * abuts the cut and both sides have handle media — render-verified on
209
210
  * 19.1.3.7; otherwise dropped with the reason, as a cut at the boundary),
@@ -286,7 +287,11 @@ export function eventsToAssembleSpec(events, opts = {}) {
286
287
  if ((e.speed ?? 100) !== 100 || e.reverse) {
287
288
  const spd = Math.abs(e.speed ?? 100);
288
289
  if (!(spd > 0)) {
289
- flattenedRetimes.push({ index: e.index, source: e.source, speed: e.speed, reverse: !!e.reverse, reason: 'zero speed (freeze) not supported — played forward at 100%' });
290
+ // FREEZE (EDL M2 000.0 / zero-speed OTIO warp): authored as the real
291
+ // freeze Sm2TimeMap harvested live in E55 — holds srcIn for the
292
+ // whole cut (render-proven frozen via freezedetect on 19.1.3.7).
293
+ cut.freeze = true;
294
+ authoredRetimes.push({ index: e.index, source: e.source, speed: 0, freeze: true });
290
295
  } else {
291
296
  // Constant speed, forward or reverse: authored as a real Sm2TimeMap
292
297
  // on the cut (r19 keyed form; readback/render-verified on 19.1.3.7 —
@@ -608,13 +613,23 @@ export function verifyRoundtrip(inputEvents, exportedEvents, opts = {}) {
608
613
  return m ? `${m[1]}${m[2] || '1'}` : String(t);
609
614
  };
610
615
  const canonSource = (x) => String(x || '').replace(/\.[^.]+$/, '').toLowerCase();
611
- const vids = (evts) => evts.filter((e) => /^V\d*$/.test(String(e.track)) && e.recIn != null && e.recOut != null);
616
+ // Reel/tape aliases: an EDL names sources by REEL (CUTSRC) while the
617
+ // re-export names them by file basename (cut_src) — the sourceMap that
618
+ // drove the assemble is the authority linking the two. Keys and values
619
+ // canonicalize exactly like event sources.
620
+ const aliases = {};
621
+ for (const [k, v] of Object.entries(opts.sourceAliases || {})) aliases[canonSource(k)] = canonSource(v);
622
+ const mapSource = (s) => aliases[s] ?? s;
623
+ // recOut > recIn: an EDL dissolve writes a ZERO-duration outgoing leg
624
+ // before the D event — a pairing placeholder, never a rendered clip, and
625
+ // no export reproduces it.
626
+ const vids = (evts) => evts.filter((e) => /^V\d*$/.test(String(e.track)) && e.recIn != null && e.recOut != null && e.recOut > e.recIn);
612
627
  const norm = (evts) => {
613
628
  const v = vids(evts);
614
629
  if (!v.length) return [];
615
630
  const off = Math.min(...v.map((e) => e.recIn));
616
631
  return v
617
- .map((e) => ({ track: canonTrack(e.track), source: canonSource(e.source), recIn: e.recIn - off, recOut: e.recOut - off, srcIn: e.srcIn ?? 0 }))
632
+ .map((e) => ({ track: canonTrack(e.track), source: mapSource(canonSource(e.source)), recIn: e.recIn - off, recOut: e.recOut - off, srcIn: e.srcIn ?? 0 }))
618
633
  .sort((a, b) => a.track.localeCompare(b.track) || a.recIn - b.recIn);
619
634
  };
620
635
  const a = norm(inputEvents);
@@ -51,7 +51,7 @@ const assembleSchema = z.object({
51
51
  spec: z
52
52
  .object({})
53
53
  .passthrough()
54
- .describe("assembleTimeline spec: { timelineName?, startFrame? (timeline start frame @24, default 86400=01:00:00:00 — sets the start TIMECODE, render-verified on 19), media?: {mediaFilePath, spec:{width,height,frameCount,fps}, cuts:[{startFrame,durationFrames,srcIn?,track? (1-based video track; >1 = video-only, render-verified stacking),speed?/reverse? (constant retime, e.g. 0.5, forward or backwards; video-only; readback+render-verified on 19),audioOnly?+track? (explicit AUDIO placement on audio track 1-8; presence suppresses the A1 mirror; render-verified on 19)}]} | [same, ...] (multi-source needs media_pool.capture_media_template run once per file), transitions?: [{track, atFrame, durationFrames?, trackType? ('video' dissolve | 'audio' cross-fade, both render-verified on 19)}], markers?: [{frame (timeline-absolute), color? (16 names), name?, note?, duration?, customData?}] (readback-verified on 19), compounds?: [{name, startFrame (parent, absolute), durationFrames, track?, cuts:[{mediaFilePath, startFrame (INNER, 0-based), durationFrames, srcIn?}]}] (multiple PARALLEL compounds compose — render-verified; a compound nested INSIDE another imports+reads back but renders BLACK (measured); inner cuts need captured templates w/ native clips), subtitles?: [{startFrame (timeline-absolute), durationFrames, text}] + subtitlesSrt? (raw SRT, cues anchor at the origin; readback-verified on 19; angle-bracket runs read as SRT markup), elements?: [{type:'title'|'generator', track, startFrame, durationFrames?, text?, generatorName? ('Solid Color'|'SMPTE Color Bar'|'Grey Scale' render-verified on 19), ...}] }. startFrame is timeline-absolute (origin 86400)."),
54
+ .describe("assembleTimeline spec: { timelineName?, startFrame? (timeline start frame @24, default 86400=01:00:00:00 — sets the start TIMECODE, render-verified on 19), media?: {mediaFilePath, spec:{width,height,frameCount,fps}, cuts:[{startFrame,durationFrames,srcIn?,track? (1-based video track; >1 = video-only, render-verified stacking),speed?/reverse? (constant retime, e.g. 0.5, forward or backwards; video-only; readback+render-verified on 19),freeze? (true = hold source frame srcIn for the whole cut; video-only; render-proven frozen on 19 via freezedetect),audioOnly?+track? (explicit AUDIO placement on audio track 1-8; presence suppresses the A1 mirror; render-verified on 19)}]} | [same, ...] (multi-source needs media_pool.capture_media_template run once per file), transitions?: [{track, atFrame, durationFrames?, trackType? ('video' dissolve | 'audio' cross-fade, both render-verified on 19)}], markers?: [{frame (timeline-absolute), color? (16 names), name?, note?, duration?, customData?}] (readback-verified on 19), compounds?: [{name, startFrame (parent, absolute), durationFrames, track?, cuts:[{mediaFilePath, startFrame (INNER, 0-based), durationFrames, srcIn?}], compounds?: [same, nested — frames inner-relative]}] (multiple PARALLEL compounds compose AND compounds NEST depth-2 playback render-verified on 19 (the old depth-2 black was a missing SequenceSetup key, fixed); deeper nesting composes but is unverified; inner cuts need captured templates w/ native clips), subtitles?: [{startFrame (timeline-absolute), durationFrames, text}] + subtitlesSrt? (raw SRT, cues anchor at the origin; readback-verified on 19; angle-bracket runs read as SRT markup), elements?: [{type:'title'|'generator', track, startFrame, durationFrames?, text?, generatorName? ('Solid Color'|'SMPTE Color Bar'|'Grey Scale' render-verified on 19), ...}] }. startFrame is timeline-absolute (origin 86400)."),
55
55
  outputPath: z.string().describe('Absolute path where the importable .drt will be written'),
56
56
  targetAppVersion: z
57
57
  .union([z.string(), z.number()])
@@ -153,7 +153,7 @@ function requirePathArg(args, key, action) {
153
153
  export const drtTool = {
154
154
  name: 'drt',
155
155
  description:
156
- 'DaVinci Resolve Timeline (.drt) operations — offline, no Resolve required. Actions: assemble_from_interchange (EDL/OTIO/XML/AAF + sourceMap → IMPORTABLE RENDERING native .drt in one call; retimes flatten; cross-dissolves are AUTHORED when the cut abuts with handles both sides (render-verified on 19), else dropped with reason; ledger in `conform`), 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).',
156
+ 'DaVinci Resolve Timeline (.drt) operations — offline, no Resolve required. Actions: assemble_from_interchange (EDL/OTIO/XML/AAF + sourceMap → IMPORTABLE RENDERING native .drt in one call; retimes AUTHOR — constant speed fwd/rev AND zero-speed freezes (EDL M2 000.0; render-proven frozen); cross-dissolves are AUTHORED when the cut abuts with handles both sides (render-verified on 19), else dropped with reason; ledger in `conform`), 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).',
157
157
  async handler({ action, args }) {
158
158
  if (action === 'parse') {
159
159
  const p = parseSchema.parse(requirePathArg(args, 'drtPath', 'parse'));
@@ -288,8 +288,7 @@ export const drtTool = {
288
288
  stamped,
289
289
  conform: report,
290
290
  note:
291
- 'Import with timeline.import_timeline_checked (timeline is named after the FILE). Dissolves/cross-fades, forward+reverse retimes, multi-track video, audio events and markers are AUTHORED when geometry allows; everything else drops WITH a reason — see `conform` for the ledger.' +
292
- 'Retimes are flattened and transitions become cuts — see `conform` for the ledger.',
291
+ 'Import with timeline.import_timeline_checked (timeline is named after the FILE). Dissolves/cross-fades, forward+reverse retimes, multi-track video, audio events and markers are AUTHORED when geometry allows; everything else drops WITH a reason — see `conform` for the ledger.',
293
292
  };
294
293
  }
295
294
  if (action === 'assemble') {
@@ -156,8 +156,17 @@ export const editorialTool = {
156
156
  exported: z.array(z.any()).describe('Normalized events of the re-export (parse_interchange on the exported OTIO/EDL/XML)'),
157
157
  recTol: z.number().optional(),
158
158
  srcTol: z.number().optional(),
159
+ sourceMap: z.record(z.object({ mediaFilePath: z.string() }).passthrough()).optional()
160
+ .describe('The SAME reel→{mediaFilePath} map the assemble used — lets an EDL reel (CUTSRC) match the re-export\'s file basename (cut_src)'),
159
161
  }).parse(args);
160
- return verifyRoundtrip(p.input, p.exported, { recTol: p.recTol, srcTol: p.srcTol });
162
+ // EDL reels vs exported basenames: derive the alias table from the
163
+ // sourceMap that drove the assemble (the one authority linking them).
164
+ const sourceAliases = {};
165
+ for (const [reel, src] of Object.entries(p.sourceMap || {})) {
166
+ const base = String(src.mediaFilePath).split('/').pop();
167
+ if (base) sourceAliases[reel] = base;
168
+ }
169
+ return verifyRoundtrip(p.input, p.exported, { recTol: p.recTol, srcTol: p.srcTol, sourceAliases });
161
170
  }
162
171
  if (action === 'marker_roundtrip') {
163
172
  const p = markerSchema.parse(args);
@@ -25,7 +25,7 @@ const { createEmptyProject, addMediaClip, DEFAULT_START_FRAME } = require('./aut
25
25
  const { loadMediaTemplate, transplantMediaElement, insertMediaElement } = require('./media-template-cache');
26
26
  const JSZip = require('jszip');
27
27
  const { cutSourceIntoClips } = require('./cut-media');
28
- const { buildConstantSpeedTimemapKeyed } = require('./media-timemap');
28
+ const { buildConstantSpeedTimemapKeyed, buildFreezeTimemapKeyed } = require('./media-timemap');
29
29
  const { encodeTimelineMarkersBlob } = require('./timeline-markers-blob');
30
30
  const { placeSubtitles, parseSrt } = require('./place-subtitles');
31
31
  const { placeCompound } = require('./place-compound');
@@ -137,7 +137,21 @@ async function assembleTimeline(spec = {}) {
137
137
  // captured before clip elements were harvested.
138
138
  if (caches[i] && caches[i].videoClipElement) out.donorClipVideo = caches[i].videoClipElement;
139
139
  if (caches[i] && caches[i].audioClipElement) out.donorClipAudio = caches[i].audioClipElement;
140
- if (cut.reverse || (cut.speed !== undefined && cut.speed !== 1)) {
140
+ if (cut.freeze || cut.speed === 0) {
141
+ // FREEZE frame (r19 keyed map harvested from a live EDL M2 000.0
142
+ // import, E55; render-proven frozen). Holds source frame srcIn for
143
+ // the whole cut. The frozen clip's <In> stays EMPTY; the position
144
+ // rides in the map (seconds domain, XMax 60000 sentinel).
145
+ if (cut.reverse) throw new RangeError('assembleTimeline: cut.freeze cannot combine with reverse');
146
+ const fpsF = Math.round(src.spec.fps || 24);
147
+ out.timemap = buildFreezeTimemapKeyed({
148
+ freezeFrame: cut.srcIn ?? 0, sourceFrames: src.spec.frameCount, fps: fpsF,
149
+ uniqueId: randomUUID(),
150
+ }).toString('hex');
151
+ out.emptyIn = true;
152
+ delete out.freeze;
153
+ delete out.speed;
154
+ } else if (cut.reverse || (cut.speed !== undefined && cut.speed !== 1)) {
141
155
  // Constant-speed retime (forward only). The Sm2TimeMap spans the
142
156
  // whole source stretched by 1/speed; the clip windows into it with
143
157
  // RECORD-domain In/Duration (measured live on 19.1.3.7), so the
@@ -200,6 +214,17 @@ async function assembleTimeline(spec = {}) {
200
214
  }));
201
215
  }
202
216
 
217
+ // Pin the PARENT container id BEFORE any compound inserts an inner
218
+ // container: entry listing is name-sorted, so an inner container can
219
+ // alphabetically precede the parent and every later "first container"
220
+ // lookup — the next compound's item, subtitles, markers — then targets
221
+ // the compound's INNER timeline instead (measured: CMP_B landed inside
222
+ // CMP_A; subtitles authored with a compound in the spec vanished into
223
+ // the inner container and the imported timeline had no subtitle track).
224
+ const zipPin = await JSZip.loadAsync(buffer);
225
+ const parentEntryPin = Object.keys(zipPin.files).find((n) => !zipPin.files[n].dir && /SeqContainer\/.+\.xml$/.test(n));
226
+ const parentContainerId = ((await zipPin.file(parentEntryPin).async('string')).match(/<Sm2SequenceContainer DbId="([^"]+)"/) || [])[1];
227
+
203
228
  // Compound clips: an empty nested timeline is spliced in from the
204
229
  // harvested donor shape, then its content is placed with the ORDINARY cuts
205
230
  // machinery targeting the inner container (inner origin is FRAME 0).
@@ -208,47 +233,44 @@ async function assembleTimeline(spec = {}) {
208
233
  // Pool elements for compound-only sources (files not in spec.media).
209
234
  const specFiles = new Set((Array.isArray(media) ? media : media ? [media] : []).map((src) => src.mediaFilePath));
210
235
  const insertedExtra = new Set();
211
- for (const comp of spec.compounds) {
212
- for (const cut of comp.cuts || []) {
213
- const fp = cut.mediaFilePath;
214
- if (!fp || specFiles.has(fp) || insertedExtra.has(fp)) continue;
215
- const cache = loadMediaTemplate(fp);
216
- if (!cache) continue; // the per-cut refusal below names it properly
217
- const zipC = await JSZip.loadAsync(buffer);
218
- const mpP = 'MediaPool/Master/MpFolder.xml';
219
- zipC.file(mpP, insertMediaElement(await zipC.file(mpP).async('string'), cache.poolElement));
220
- buffer = await zipC.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
221
- insertedExtra.add(fp);
222
- }
236
+ const walkCompoundCuts = (comps) => (comps || []).flatMap((c) => [...(c.cuts || []), ...walkCompoundCuts(c.compounds)]);
237
+ for (const cut of walkCompoundCuts(spec.compounds)) {
238
+ const fp = cut.mediaFilePath;
239
+ if (!fp || specFiles.has(fp) || insertedExtra.has(fp)) continue;
240
+ const cache = loadMediaTemplate(fp);
241
+ if (!cache) continue; // the per-cut refusal below names it properly
242
+ const zipC = await JSZip.loadAsync(buffer);
243
+ const mpP = 'MediaPool/Master/MpFolder.xml';
244
+ zipC.file(mpP, insertMediaElement(await zipC.file(mpP).async('string'), cache.poolElement));
245
+ buffer = await zipC.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
246
+ insertedExtra.add(fp);
223
247
  }
224
- // Pin the PARENT container id BEFORE inserting inner containers: entry
225
- // listing is name-sorted, so an inner container can alphabetically
226
- // precede the parent and swallow the next compound's item (measured
227
- // CMP_B landed inside CMP_A's inner timeline).
228
- const zip0 = await JSZip.loadAsync(buffer);
229
- const parentEntry0 = Object.keys(zip0.files).find((n) => !zip0.files[n].dir && /SeqContainer\/.+\.xml$/.test(n));
230
- const parentContainerId = ((await zip0.file(parentEntry0).async('string')).match(/<Sm2SequenceContainer DbId="([^"]+)"/) || [])[1];
231
- for (const [ci, comp] of spec.compounds.entries()) {
232
- if (!comp || typeof comp !== 'object') throw new TypeError(`assembleTimeline: compounds[${ci}] must be an object`);
248
+ // Recursive: compounds nest (comp.compounds, frames inner-relative like
249
+ // cuts). Depth-2 playback is RENDER-VERIFIED on 19.1.3.7 (E56, white 234
250
+ // through two levels) the historical depth-2 black was one missing key,
251
+ // SequenceSetup, in the donor template's embedded-sequence blob. Depths
252
+ // beyond 2 compose structurally but are not render-verified.
253
+ const placeTree = async (comp, parentUuid, label) => {
254
+ if (!comp || typeof comp !== 'object') throw new TypeError(`assembleTimeline: ${label} must be an object`);
233
255
  const res = await placeCompound(buffer, {
234
- name: comp.name || `Compound ${ci + 1}`,
256
+ name: comp.name,
235
257
  startFrame: comp.startFrame,
236
258
  durationFrames: comp.durationFrames,
237
259
  track: comp.track,
238
- timelineUuid: parentContainerId,
260
+ timelineUuid: parentUuid,
239
261
  });
240
262
  buffer = res.buffer;
241
263
  const innerCuts = (comp.cuts || []).map((cut, i) => {
242
- if (!cut.mediaFilePath) throw new TypeError(`assembleTimeline: compounds[${ci}].cuts[${i}].mediaFilePath is required`);
264
+ if (!cut.mediaFilePath) throw new TypeError(`assembleTimeline: ${label}.cuts[${i}].mediaFilePath is required`);
243
265
  if (!Number.isInteger(cut.startFrame) || cut.startFrame < 0 || (cut.startFrame + (cut.durationFrames || 0)) > comp.durationFrames) {
244
- throw new RangeError(`assembleTimeline: compounds[${ci}].cuts[${i}] must sit inside [0, ${comp.durationFrames}) — inner frames are 0-based`);
266
+ throw new RangeError(`assembleTimeline: ${label}.cuts[${i}] must sit inside [0, ${comp.durationFrames}) — inner frames are 0-based`);
245
267
  }
246
268
  const cache = loadMediaTemplate(cut.mediaFilePath);
247
269
  if (!cache) {
248
- throw new Error(`assembleTimeline: compounds[${ci}].cuts[${i}] — no captured native media template for ${cut.mediaFilePath}. Run media_pool.capture_media_template(path) with Resolve open.`);
270
+ throw new Error(`assembleTimeline: ${label}.cuts[${i}] — no captured native media template for ${cut.mediaFilePath}. Run media_pool.capture_media_template(path) with Resolve open.`);
249
271
  }
250
272
  if (!cache.videoClipElement) {
251
- throw new Error(`assembleTimeline: compounds[${ci}].cuts[${i}] — the media template for ${cut.mediaFilePath} predates native clip capture. Re-run media_pool.capture_media_template(path) with Resolve open.`);
273
+ throw new Error(`assembleTimeline: ${label}.cuts[${i}] — the media template for ${cut.mediaFilePath} predates native clip capture. Re-run media_pool.capture_media_template(path) with Resolve open.`);
252
274
  }
253
275
  const out = { ...cut, mediaRef: cache.mediaRef };
254
276
  delete out.mediaFilePath;
@@ -263,6 +285,19 @@ async function assembleTimeline(spec = {}) {
263
285
  const cutRes = await cutSourceIntoClips(buffer, { cuts: innerCuts, timelineUuid: res.innerContainerId });
264
286
  buffer = cutRes.buffer;
265
287
  }
288
+ for (const [cj, child] of (comp.compounds || []).entries()) {
289
+ if (child && typeof child === 'object' && !Number.isInteger(child.startFrame)) {
290
+ throw new TypeError(`assembleTimeline: ${label}.compounds[${cj}].startFrame must be an integer (INNER frames, 0-based)`);
291
+ }
292
+ await placeTree({ name: child.name || `Compound ${cj + 1}`, ...child }, res.innerContainerId, `${label}.compounds[${cj}]`);
293
+ }
294
+ };
295
+ for (const [ci, comp] of spec.compounds.entries()) {
296
+ await placeTree(
297
+ { ...(comp && typeof comp === 'object' ? comp : {}), name: (comp && comp.name) || `Compound ${ci + 1}` },
298
+ parentContainerId,
299
+ `compounds[${ci}]`,
300
+ );
266
301
  }
267
302
  }
268
303
 
@@ -280,7 +315,7 @@ async function assembleTimeline(spec = {}) {
280
315
  throw new RangeError(`assembleTimeline: subtitle at frame ${sub.startFrame} is before the timeline origin ${originFrame}`);
281
316
  }
282
317
  }
283
- ({ buffer } = await placeSubtitles(buffer, { subtitles }));
318
+ ({ buffer } = await placeSubtitles(buffer, { subtitles, timelineUuid: parentContainerId }));
284
319
  }
285
320
 
286
321
  if (Array.isArray(spec.markers) && spec.markers.length) {
@@ -289,9 +324,17 @@ async function assembleTimeline(spec = {}) {
289
324
  // <Sequence> references). Encoder byte-exact vs a live 19.1.3.7 export.
290
325
  // Marker frames here are TIMELINE-ABSOLUTE for consistency with cuts;
291
326
  // the blob stores them start-relative.
327
+ // Resolved against the PINNED parent container — a compound's inner
328
+ // container also matches the "any SeqContainer entry" pattern, and
329
+ // attaching the blob to the inner sequence hides every marker.
292
330
  const zipM = await JSZip.loadAsync(buffer);
293
- const seqName = Object.keys(zipM.files).find((n) => !zipM.files[n].dir && /SeqContainer\/.+\.xml$/.test(n));
294
- const seqXml2 = await zipM.file(seqName).async('string');
331
+ const seqNames = Object.keys(zipM.files).filter((n) => !zipM.files[n].dir && /SeqContainer\/.+\.xml$/.test(n));
332
+ let seqXml2 = null;
333
+ for (const n of seqNames) {
334
+ const xmlN = await zipM.file(n).async('string');
335
+ if (xmlN.includes(`<Sm2SequenceContainer DbId="${parentContainerId}"`)) { seqXml2 = xmlN; break; }
336
+ }
337
+ if (!seqXml2) throw new Error('assembleTimeline: pinned parent SeqContainer not found for markers');
295
338
  const seqIdM = (seqXml2.match(/<Sequence>([0-9a-f-]{36})<\/Sequence>/) || [])[1];
296
339
  if (!seqIdM) throw new Error('assembleTimeline: cannot find the Sm2Sequence id for markers');
297
340
  const rel = spec.markers.map((m) => {
@@ -26,7 +26,7 @@ const {
26
26
  replaceTrackVec,
27
27
  emptyTrackClone,
28
28
  } = require('./seq-surgery');
29
- const { clipDbId, setClipStart, setClipDuration, setClipIn } = require('./splice-clips');
29
+ const { clipDbId, setClipStart, setClipDuration, setClipIn, setClipInEmpty } = require('./splice-clips');
30
30
 
31
31
  /**
32
32
  * @param {Buffer|string} drpInput
@@ -90,7 +90,9 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
90
90
  let c = freshDbIds(native ? native.trim() : donor);
91
91
  c = setClipStart(c, cut.startFrame);
92
92
  c = setClipDuration(c, cut.durationFrames);
93
- c = setClipIn(c, cut.srcIn ?? 0);
93
+ // A frozen clip's <In> is EMPTY (measured E55 harvest); the frozen
94
+ // position rides in the timemap, and a windowed In breaks the render.
95
+ c = cut.emptyIn ? setClipInEmpty(c) : setClipIn(c, cut.srcIn ?? 0);
94
96
  if (cut.mediaRef) {
95
97
  // Multi-source: point this cut at ITS source's transplanted pool
96
98
  // element instead of the donor's.
@@ -259,9 +259,55 @@ function buildConstantSpeedTimemapKeyed({ speed, sourceFrames, fps = 24, uniqueI
259
259
  ] });
260
260
  }
261
261
 
262
+ /**
263
+ * FREEZE frame map — r19 keyed form, harvested from a live 19.1.3.7 EDL
264
+ * `M2 <reel> 000.0` import (E55, 2026-08-31; render-proven frozen by
265
+ * freezedetect). A real freeze is NOT the flat frame-domain line the earlier
266
+ * synthetic attempt used (that one reads back frozen but RENDERS moving —
267
+ * the readback-blind divergence measured in E41-era work). The engine's
268
+ * shape is a flat line in SECONDS with two extra conventions:
269
+ *
270
+ * YMin = YMax = Y(kf0) = Y(kf1) = frozen source position in SECONDS
271
+ * XMax = 60000 (a fixed sentinel domain, not the clip length)
272
+ * LastValidYOffset = (sourceFrames-1)/fps (whole-source extent, as always)
273
+ *
274
+ * The clip's <In> is EMPTY on a frozen item (record windowing does not
275
+ * apply to a constant map). Byte-exact vs the harvest for equal inputs.
276
+ */
277
+ function buildFreezeTimemapKeyed({ freezeFrame, sourceFrames, fps = 24, uniqueId }) {
278
+ if (!Number.isInteger(freezeFrame) || freezeFrame < 0) throw new TypeError('buildFreezeTimemapKeyed: freezeFrame must be a non-negative integer (source frame to hold)');
279
+ if (!Number.isInteger(sourceFrames) || sourceFrames < 1) throw new TypeError('buildFreezeTimemapKeyed: sourceFrames must be a positive integer');
280
+ if (freezeFrame >= sourceFrames) throw new RangeError(`buildFreezeTimemapKeyed: freezeFrame ${freezeFrame} outside source (${sourceFrames} frames)`);
281
+ const freezeSec = freezeFrame / fps;
282
+ const XMAX_SENTINEL = 60000;
283
+ const kf = (X, Y) => encodeKeyedDict({ hdr: 1, entries: [
284
+ { key: 'interp', type: 0x02, subType: 0, value: 0 },
285
+ { key: 'YOut', type: T_DOUBLE, subType: 0, value: 0 },
286
+ { key: 'YIn', type: T_DOUBLE, subType: 0, value: 0 },
287
+ { key: 'Y', type: T_DOUBLE, subType: 0, value: Y },
288
+ { key: 'XOut', type: T_DOUBLE, subType: 0, value: 0 },
289
+ { key: 'XIn', type: T_DOUBLE, subType: 0, value: 0 },
290
+ { key: 'X', type: T_DOUBLE, subType: 0, value: X },
291
+ ] }).toString('hex');
292
+ const keyframes = encodeKeyedDict({ hdr: 1, entries: [
293
+ { key: '1', type: T_BYTES, subType: 0, value: kf(XMAX_SENTINEL, freezeSec) },
294
+ { key: '0', type: T_BYTES, subType: 0, value: kf(0, freezeSec) },
295
+ ] }).toString('hex');
296
+ return encodeKeyedDict({ hdr: 1, entries: [
297
+ { key: 'YMin', type: T_DOUBLE, subType: 0, value: freezeSec },
298
+ { key: 'YMax', type: T_DOUBLE, subType: 0, value: freezeSec },
299
+ { key: 'XMax', type: T_DOUBLE, subType: 0, value: XMAX_SENTINEL },
300
+ { key: 'UniqueId', type: T_STRING, subType: 0, value: uniqueId },
301
+ { key: 'LastValidYOffset', type: T_DOUBLE, subType: 0, value: (sourceFrames - 1) / fps },
302
+ { key: 'KeyframesBA', type: T_BYTES, subType: 0, value: keyframes },
303
+ { key: 'DbType', type: T_STRING, subType: 0, value: 'Sm2TimeMap' },
304
+ ] });
305
+ }
306
+
262
307
  module.exports = {
263
308
  decodeTimemap, encodeTimemap, encodeRetimedTimemap,
264
309
  identityTimemap, buildConstantSpeedTimemap, buildConstantSpeedTimemapKeyed,
310
+ buildFreezeTimemapKeyed,
265
311
  buildTimemap, decodeProtobuf,
266
312
  TYPE_LINEAR,
267
313
  };
@@ -61,6 +61,14 @@ function setClipIn(clipXml, framePos) {
61
61
  if (/<In>[^<]*<\/In>/.test(clipXml)) return clipXml.replace(/<In>[^<]*<\/In>/, enc);
62
62
  return clipXml; // no In element to set
63
63
  }
64
+ // A FROZEN clip carries an EMPTY <In/> (harvested live from an EDL M2 000.0
65
+ // freeze on 19.1.3.7) — the frozen position lives in its Sm2TimeMap, and
66
+ // record windowing does not apply to a constant map.
67
+ function setClipInEmpty(clipXml) {
68
+ if (/<In\s*\/>/.test(clipXml)) return clipXml;
69
+ if (/<In>[^<]*<\/In>/.test(clipXml)) return clipXml.replace(/<In>[^<]*<\/In>/, '<In/>');
70
+ return clipXml;
71
+ }
64
72
 
65
73
  // Rewrite each clip on a track via fn(clipXml) -> clipXml. Clips have unique DbIds so a
66
74
  // first-occurrence replace per clip is unambiguous.
@@ -445,5 +453,5 @@ async function rippleTimeline(drpInput, opts = {}) {
445
453
  module.exports = {
446
454
  moveClip, deleteClip, trimClip, trimClipHead, splitClip, rippleTimeline,
447
455
  // low-level clip-XML helpers, reused by cut-media.js
448
- clipDbId, clipStart, clipDuration, clipIn, setClipStart, setClipDuration, setClipIn,
456
+ clipDbId, clipStart, clipDuration, clipIn, setClipStart, setClipDuration, setClipIn, setClipInEmpty,
449
457
  };
@@ -29,7 +29,7 @@
29
29
  <SlateTC>-68719476737</SlateTC>
30
30
  <Sequence>
31
31
  <Sm2Sequence DbId="d7cfd3f0-e8b9-4048-867c-30ed6c427d83">
32
- <FieldsBlob>000000010000000500000012005400680075006d0062006e00610069006c0000000c00000015200000000100000006000000100055006e0069007100750065004900640000000a000000004800300061006500660031003000370064002d0030006600390035002d0034003800610034002d0061003500330032002d003200620037003500630032006100380030003900620035000000100049006d0067005700690064007400680000000200000000d0000000140049006d0067005100750061006c006900740079000000020000000001000000120049006d00670048006500690067006800740000000200000000750000000c0044006200540079007000650000000a000000001400420074005400680075006d006e00610069006c0000000c0042007500660066006500720000000c0000001410ffd8ffe000104a46494600010101004800480000ffdb004300080606070605080707070909080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c2837292c30313434341f27393d38323c2e333432ffdb0043010909090c0b0c180d0d1832211c213232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232ffc0001108007500d003012200021101031101ffc4001f0000010501010101010100000000000000000102030405060708090a0bffc400b5100002010303020403050504040000017d01020300041105122131410613516107227114328191a1082342b1c11552d1f02433627282090a161718191a25262728292a3435363738393a434445464748494a535455565758595a636465666768696a737475767778797a838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae1e2e3e4e5e6e7e8e9eaf1f2f3f4f5f6f7f8f9faffc4001f0100030101010101010101010000000000000102030405060708090a0bffc400b51100020102040403040705040400010277000102031104052131061241510761711322328108144291a1b1c109233352f0156272d10a162434e125f11718191a262728292a35363738393a434445464748494a535455565758595a636465666768696a737475767778797a82838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae2e3e4e5e6e7e8e9eaf2f3f4f5f6f7f8f9faffda000c03010002110311003f00f9fe8a28a002bb9f0fff00c80edbfe05ff00a11ae1abb9f0ff00fc80edbfe05ffa11ae1cc3f84bd7fccfaae10ff7e9ff0081fe7134ebb2f09f873784d4af63e383046ddffda23f97e7e9597e17d10ea97e25994fd9612198e061dbb2f3fafb7d457a4d74e4b972a9fed15568b65dfccf0bc4be3396113ca3032b4daf7e4b78a7f6579b5bf65a75d39af16ffcb9ff00c0ff00f65ae6aba5f16ffcb9ff00c0ff00f65ae6abf47c1ff063fd753def0f3fe49bc37fdbff00fa7241599e20ff00901dcffc07ff004215a7599e20ff00901dcffc07ff004215db43f8b1f547d3669fee35bfc12fc99c3514515f447e3615f62d7c755f62d7e1de33ff00cc0ffdc4ff00dc67a7977daf97ea1451457e1a7a670de20ff90e5cff00c07ff4115995a7e20ff90e5cff00c07ff4115995f4543f851f447e379a7fbf56ff001cbf3615cd78b3fe5cff00e07ffb2d74b5cd78b7fe5cff00e07ffb2d7760ff008d1feba1f4de1e7fc94986ff00b7ff00f4dc8b7e13f11323c7a65e3e50e16090f553d94fb7a7a74e9d3b7af18af46f0a6b6752b236d3b96ba8072ccd9322f63f8743f87ad7e8b92e62e7fecf55ebd1fe9fe47d1f89bc191a17ce7031b45bfde45746fedaf57a4bcf5eaced3c3fff0021cb6ff817fe826bb9ae1bc3ff00f21cb6ff00817fe826bb9aebcc3f8abd3fccf9de10ff00719ff8dfe510a28a2b84faa3e00a28a2800aef3c3313cfa4d9c312ee92462aa3d4972057075ec1f0a74e5bab58aedf056d836d07fbc59b07f000fe38ac2b5078870a4babfd1dcf532ecd619453c463a7f629b6bcdb945457cdb48f46d2b4f8f4bd3a1b54032a32ec3f89bb9ff3ed5768a2beae108c22a11d91f80e271357155a75eb3bca4db6fbb7ab39af16ff00cb9ffc0fff0065ae6aba5f16ff00cb9ffc0fff0065ae6abdec1ff063fd753fa57c3cff00926f0dff006fff00e9c905667883fe40773ff01ffd08569d667883fe40773ff01ffd08576d0fe2c7d51f4d9a7fb8d6ff0004bf2670d451457d11f8d857d8b5f1d57d8b5f8778cfff00303ff713ff00719e9e5df6be5fa8514515f869e99c37883fe43973ff0001ff00d04566569f883fe43973ff0001ff00d0456657d150fe147d11f8de69fefd5bfc72fcd85735e2dff973ff0081ff00ecb5d2d735e2dff973ff0081ff00ecb5dd83fe347fae87d37879ff0025261bfedfff00d37239aab5a75ec9a75fc37717de8db247f78771f88aab457bd1938494a2ecd1fd2f5e853c452951aaaf19269a7d53d1a3ddbc2f3c773aa594f136e8e452ca7d8a1aef6bc6be136a065d416c5d86612cf18279da55b200f63dff00daaf65afa9ab885888c2aaeabf1bbb9f81e0b279e4d57118196bc951d9f78b8c5c5fdcd5fcee828a28ac8f40f8028a28a002be89f873a5cda578234f49d1925b8537254b03f2bfcc8463d5369f5e7f0af9dabebbbbd3a1d2248b4cb72c60b3b786de32e724aa46aa33ef815db8049d5d7b1f2fc5b394700945ef249fdcdfe6915e8a28af64fcd8e6bc5bff002e7ff03ffd96b9aae97c5bff002e7ff03ffd96b9aaf7307fc18ff5d4fe9cf0f3fe49bc37fdbfff00a7241599e20ff901dcff00c07ff4215a7599e20ff901dcff00c07ff4215db43f8b1f547d3669fee35bfc12fc99c3514515f447e3615f62d7c755f62d7e1de33ffcc0ff00dc4ffdc67a7977daf97ea1451457e1a7a670de20ff0090e5cffc07ff004115995a7e20ff0090e5cffc07ff004115995f4543f851f447e379a7fbf56ff1cbf3615cd78b7fe5cffe07ff00b2d74b5cd78b7fe5cffe07ff00b2d7760ff8d1feba1f4de1e7fc94986ffb7fff004dc8e6a8ad0d134393c49ac5be9115efd8a4b9240b8f2bccd9805beee467a63af7a75f78ebc35e1bbfb8d0aefc109a8dce9b2b59cb7a75278bed2f1928d26c0a42ee2a5b6e4e338c9afa5a3859d68f3459fd039967b87cbeaaa5562db6afa5bcd756bb1a5e02d5d346f1ae99349bcc73cc2d1820049329d8bd7b066527be01ebd2be8faf99b46f10e89f10758b5f0ce91e1bff00847350ba7f320d5a2bf79dad9a2fdee4261724f978cee18ce7b57ae7c29d5b58d5346d6e3d6f526d42e6c3599ec96e1a258f7222a63e55e07249efd7ad7ab86a52a50e5933e073cc7d1c7e2556a316b449ded76f5d74bf4b2f91ded14515d078e7c0145145006ef82a259fc79e1d85c651f53b6561ec65515f54f883fe43973ff01ffd0457cb3e04ff009287e1affb0adaff00e8d5afa9bc41ff0021cb9ff80ffe822bbb2ffe2bf4ff0023e578bffdc61fe35f948cca28a2bd83f3839af16ffcb9ff00c0ff00f65ae6aba5f16ffcb9ff00c0ff00f65ae6abdcc1ff00063fd753fa73c3cff926f0dff6ff00fe9c905667883fe40773ff0001ff00d08569d667883fe40773ff0001ff00d08576d0fe2c7d51f4d9a7fb8d6ff04bf2670d451457d11f8d857d8b5f1d57d8b5f8778cff00f303ff00713ff719e9e5df6be5fa8514515f869e99c37883fe43973ff01ffd04566569f883fe43973ff01ffd0456657d150fe147d11f8de69fefd5bfc72fcd85735e2dff00973ff81ffecb5d2d735e2dff00973ff81ffecb5dd83fe347fae87d37879ff25261bfedff00fd3721ff000e7fe47ed2bfdf7ffd16d5e57e3bff009287e25ffb0add7fe8d6aeff00c3da55deb9aedae9b63aa4da5dcce484bc833be2c293918653c804751d6bcd3c4b67369fe2ad5ecae2ee4bc9edef668a4b9933ba665720b9c927248cf53d7ad7d9e5ff00c37ea7eafc61fefb0ff0afcd9d67c12ff92bda17fdbc7fe93c95ef3f083fe3cbc5dff6335e7f28ebe6ef877a3de6bde3bd374cb0d5a7d26ea6f3765ec19df16227638c329e4023a8eb5f447c0fb596cbc3de23b49ee5eea683c41731bdc3e774aca9182c724f248cf53d6bbcf933d3e8a28a00f8028a28a00d2f0f6a29a3f8974ad4e40e63b3bc86e18200588470c700f19e3bd7d69e20ff0090e5cffc07ff004115f1cd7d5b65ac7fc241a6586ac6485e4bab482494c272a24f2d77a8e4f46dc304e411835dd97ff15fa7f91f2bc5ff00ee30ff001afca44d451457b07e70735e2dff00973ff81ffecb5cd574be2dff00973ff81ffecb5cd57b983fe0c7faea7f4e7879ff0024de1bfedfff00d3920accf107fc80ee7fe03ffa10ad3accf107fc80ee7fe03ffa10aeda1fc58faa3e9b34ff0071adfe097e4ce1a8a28afa23f1b0afb16be3aafb16bf0ef19ffe607fee27fee33d3cbbed7cbf50a28a2bf0d3d3386f107fc872e7fe03ff00a08accad3f107fc872e7fe03ff00a08accafa2a1fc28fa23f1bcd3fdfab7f8e5f9b0ae6bc5bff2e7ff0003ff00d96ba5ae6bc5bff2e7ff0003ff00d96bbb07fc68ff005d0fa6f0f3fe4a4c37fdbfff00a6e43fe1cffc8fda57fbefff00a2dabcafc77ff250fc4bff00615baffd1ad5d6092e223bed2eee2d271f727b790a489f461c8f4fc6ba3b6f13d9456b0c775e11f0edfdc2a0596eeeec5649a7603992463cb3b1e493d4926beb30988a74a0d4bb9fb5f10e4d8bc7626352824d28db7b756721f04bfe4af685ff006f1ffa4f257bcfc21ff8f2f177fd8cf79fca3af359b5c4d55adb4dd2742d1741bebcba82da2d4f4cb4f26e2df7caaa4a3a90464120e0f2091debdb7c19e0fb6f05e9775656d7b7779f6abb7bb966bb60d233b2a839200cfddcf3ce49af4a9558d55cd13e331d80ad81a8a957566d5fbe9aff0091d1d14515a1c47c0383e9460fa1af5ab3c66b7ecfb578b5738e4fb1f8ff00c03ebeb70a7b3ff97dff0092ff00f6c78360fa57bcfc28bdf3fc1b15ab7deb677c71fc2ccc47ebbab7ecfb574167d4572478b1e1e6a4a95ffedeff00807cee71c2f4f17859d09d4d5dacedb34d6b6beba5d6eb7bf93868ae96d3eed1375aaff8885adbeadff93fff006a7c056e06f65ff3117ffb77ff00b63cd7c5bff2e7ff0003ff00d96b9aaf5e9ab3e6ef5ea61fc49e48287d57ff0027ff00ed0fb7c9388bfb072da780f67cfc97d6fcb7bc9cb6b3ef6dcf31accf107fc80ee7fe03ff00a10af5298d507fbd5df47c48f7d4beadb7f7ff00fb433cc3c49e7a13a3f55f89357e7eeadfc8781d15efcb532d7d1d0f11bdaffcc35bfedfff00ed0f837c4b6ff975ff00937fc03e7bc57d8b9ae112a64af9ee2c8ffad7ec3fe5d7b3e6fef5f9b97fc36b72f9ee6b438c7d85ff00737bff007bff00b53b5a2b8b9bfd5d61de7435f374bc36e7ff0098affc93ff00b73b6971b7b47fc0ff00c9bffb535fc41ff21cb9ff0080ff00e822b32b9bbcef58179d0d7d161fc37bc547eb3b7f73ff00b73c1a986fadd79d6bdb99b76ded777f23d0eb9af16ffcb9ff00c0ff00f65ae06f3bd67c7f78d7a987f0c7924aa7d6b6fee7ff006e7d370c617fb2f32a58fbf3725f4daf78b8efaf7bec7534562c3daafc3dabaa5c03cbff00311ff927ff006c7ecb438a3daffcbab7fdbdff0000f41f85566d3f8dedee39096e8e723a64a3003f224fe15efb5f29435a10f6ada9f0a2a10e5556ff00f6effc13cfc6e5f5335c4baf3a9cab4495af656daf757d6ef65bdba1f4ed15f33b542f5cb5f27f65f6eff2ff008242e13bff00cbeffc97ff00b633ad754d3d7ef5fda8faccbfe35b76bae690b8ddaa590fadc27f8d78a515f1d532884f79339eaf14d6a9bd35f7b3e87b4f12684b8ddad69c3eb749fe35d1586bda3c88244d5ac590f46170841fc735f2b5773e1fff00901db7fc0bff004235e4e3720a7087373b36ca6abcd7112a33f76cafa7aa5fa9f49e9dab69b73958350b594a8c90932b63eb83534d7501e93467fe042bc1b4ad4a5d2b508eea2e76f0c99c075ee0ff009eb8af52b1bd8351b38eeadd898dc77ea0f707de8cb7847098c4ef55a92e965b773e438e658ec86a4670a6a7467b4bb4bac5fe6bbebd99ab757d690e3cdba863ddd37c8067f3acc9752b039c5edb1ffb6abfe3581e2dff00973ff81ffecb5cd57d461bc39c2ce0a7ede5f723ab20e1ea79ee574b1f566e2e7cda25a2b49c7afa1dbcb7d667a5d407feda0aa13ea165129924bbb7441d59a4000fc735cbd667883fe40773ff0001ff00d08577d1f0eb0bcea3ede5abec8798787385850a95bdbcaf14decba2b9daaebba3ff00d056c7ff000213fc6a65d7f47ffa0b587fe04a7f8d785d15f4747c3bc352dab4bee47e7cf87293fb6cf795f1068a3fe62f61ff008129fe35bcb6775ff3ed37fdf06be68afb16be538d25feaafd5fd87ef3da736fa5b9797b7f88df0dc2142bdef51ab7923919acee8c7816d31ff801ac7bbd32fd81db65727e9137f857a3515f234fc46c5436a11fbd9dd4f82e8537a557f723c5efac6ed1ca3db4cae3aa94208fc2b02eb4fbd607167707e919ff000af4ef107fc872e7fe03ff00a08accafa2c3f88b8a5052f611d7cd9f2f5b10f09889d18aba8b6bee763ca6eb48d4db3b74ebb3f481bfc2b31b4cbfb761e7d95cc5bb38df132e7f315ed35cd78b7fe5cffe07ff00b2d7a987f12f1539a87b08ebe6cfa7e14c4bcd334a5819ae553e6d56fa45cbf43848ade61d6371f8569d9585e5ce7ecf693cdb71bbcb8cb633eb8ab36b6d35e5cc76f021796438503fcf4af53d274b8748b05b5889639dcee7f89bb9f6e95ef65fc578ac6c9de92515d6efee3ed78bb1984e17a3050973d69ed17a69d5bb74e8bbbf4679d5b689ab4b2048f4bbd773d156ddc93fa56ac5e1bd7475d17511ff006eaffe15ea3e1fff0090e5b7fc0bff004135dcd698bcfaa539f2a8239387b8cabe330eeb4a925676ddf64ff53e7a6f0e6b9ff405d47ff015ff00c2a26f0debbff404d4bff015ff00c2be8aa2bcaad9bceaef147d1ae2bacbfe5dafbd9f00514515e41f2815dcf87ffe4076dff02ffd08d14570e61fc25ebfe67d57087fbf4ffc0ff389a75b9e17d5a7d3f548add70f05cc8b1ba1ec49c061ee33f8fe4414579d85a92a75a3283b3b9f619f612862f2caf47111528b8bd1f92ba7ea9ea9f4674de2dff973ff0081ff00ecb5cd51457ec383fe0c7faea7c87879ff0024de1bfedfff00d3920accf107fc80ee7fe03ffa10a28aeda1fc58faa3e9b34ff71adfe097e4ce1a8a28afa23f1b0afb168a2bf0ef19ff00e607fee27fee33d3cbbed7cbf50a28a2bf0d3d3386f107fc872e7fe03ffa08acca28afa2a1fc28fa23f1bcd3fdfab7f8e5f9b0ae6bc5bff2e7ff0003ff00d968a2bbb07fc68ff5d0fa6f0f3fe4a4c37fdbff00fa6e474be17d1edf4fd362b95f9ee2e23576723a0201da3dbf9fe58dda28afd83094e34e84630565647c8e7f8caf8cccebd6c449ca5ccd5df64ec97a25b234fc3fff0021cb6ff817fe826bb9a28af3f30fe2af4ff33ebf843fdc67fe37f94428a28ae13ea8ffd90000001800530065007100750065006e0063006500540079007000650000000200000000010000000c0053006500710052006500660000000a000000004800330035006300370034006300340061002d0063003300610030002d0034006100370038002d0039003600310064002d003900350039003400640066003600380034003600300038000000300049007300520065006e006400650072004300610063006800650042004100560065007200730069006f006e006500640000000100010000002400460069007200730074004d00610069006e0042007500730046006f0072006d00610074000000020000000002</FieldsBlob>
32
+ <FieldsBlob>000000010000000600000012005400680075006d0062006e00610069006c0000000c00000015200000000100000006000000100055006e0069007100750065004900640000000a000000004800300061006500660031003000370064002d0030006600390035002d0034003800610034002d0061003500330032002d003200620037003500630032006100380030003900620035000000100049006d0067005700690064007400680000000200000000d0000000140049006d0067005100750061006c006900740079000000020000000001000000120049006d00670048006500690067006800740000000200000000750000000c0044006200540079007000650000000a000000001400420074005400680075006d006e00610069006c0000000c0042007500660066006500720000000c0000001410ffd8ffe000104a46494600010101004800480000ffdb004300080606070605080707070909080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c2837292c30313434341f27393d38323c2e333432ffdb0043010909090c0b0c180d0d1832211c213232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232ffc0001108007500d003012200021101031101ffc4001f0000010501010101010100000000000000000102030405060708090a0bffc400b5100002010303020403050504040000017d01020300041105122131410613516107227114328191a1082342b1c11552d1f02433627282090a161718191a25262728292a3435363738393a434445464748494a535455565758595a636465666768696a737475767778797a838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae1e2e3e4e5e6e7e8e9eaf1f2f3f4f5f6f7f8f9faffc4001f0100030101010101010101010000000000000102030405060708090a0bffc400b51100020102040403040705040400010277000102031104052131061241510761711322328108144291a1b1c109233352f0156272d10a162434e125f11718191a262728292a35363738393a434445464748494a535455565758595a636465666768696a737475767778797a82838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae2e3e4e5e6e7e8e9eaf2f3f4f5f6f7f8f9faffda000c03010002110311003f00f9fe8a28a002bb9f0fff00c80edbfe05ff00a11ae1abb9f0ff00fc80edbfe05ffa11ae1cc3f84bd7fccfaae10ff7e9ff0081fe7134ebb2f09f873784d4af63e383046ddffda23f97e7e9597e17d10ea97e25994fd9612198e061dbb2f3fafb7d457a4d74e4b972a9fed15568b65dfccf0bc4be3396113ca3032b4daf7e4b78a7f6579b5bf65a75d39af16ffcb9ff00c0ff00f65ae6aba5f16ffcb9ff00c0ff00f65ae6abf47c1ff063fd753def0f3fe49bc37fdbff00fa7241599e20ff00901dcffc07ff004215a7599e20ff00901dcffc07ff004215db43f8b1f547d3669fee35bfc12fc99c3514515f447e3615f62d7c755f62d7e1de33ff00cc0ffdc4ff00dc67a7977daf97ea1451457e1a7a670de20ff90e5cff00c07ff4115995a7e20ff90e5cff00c07ff4115995f4543f851f447e379a7fbf56ff001cbf3615cd78b3fe5cff00e07ffb2d74b5cd78b7fe5cff00e07ffb2d7760ff008d1feba1f4de1e7fc94986ff00b7ff00f4dc8b7e13f11323c7a65e3e50e16090f553d94fb7a7a74e9d3b7af18af46f0a6b6752b236d3b96ba8072ccd9322f63f8743f87ad7e8b92e62e7fecf55ebd1fe9fe47d1f89bc191a17ce7031b45bfde45746fedaf57a4bcf5eaced3c3fff0021cb6ff817fe826bb9ae1bc3ff00f21cb6ff00817fe826bb9aebcc3f8abd3fccf9de10ff00719ff8dfe510a28a2b84faa3e00a28a2800aef3c3313cfa4d9c312ee92462aa3d4972057075ec1f0a74e5bab58aedf056d836d07fbc59b07f000fe38ac2b5078870a4babfd1dcf532ecd619453c463a7f629b6bcdb945457cdb48f46d2b4f8f4bd3a1b54032a32ec3f89bb9ff3ed5768a2beae108c22a11d91f80e271357155a75eb3bca4db6fbb7ab39af16ff00cb9ffc0fff0065ae6aba5f16ff00cb9ffc0fff0065ae6abdec1ff063fd753fa57c3cff00926f0dff006fff00e9c905667883fe40773ff01ffd08569d667883fe40773ff01ffd08576d0fe2c7d51f4d9a7fb8d6ff0004bf2670d451457d11f8d857d8b5f1d57d8b5f8778cfff00303ff713ff00719e9e5df6be5fa8514515f869e99c37883fe43973ff0001ff00d04566569f883fe43973ff0001ff00d0456657d150fe147d11f8de69fefd5bfc72fcd85735e2dff973ff0081ff00ecb5d2d735e2dff973ff0081ff00ecb5dd83fe347fae87d37879ff0025261bfedfff00d37239aab5a75ec9a75fc37717de8db247f78771f88aab457bd1938494a2ecd1fd2f5e853c452951aaaf19269a7d53d1a3ddbc2f3c773aa594f136e8e452ca7d8a1aef6bc6be136a065d416c5d86612cf18279da55b200f63dff00daaf65afa9ab885888c2aaeabf1bbb9f81e0b279e4d57118196bc951d9f78b8c5c5fdcd5fcee828a28ac8f40f8028a28a002be89f873a5cda578234f49d1925b8537254b03f2bfcc8463d5369f5e7f0af9dabebbbbd3a1d2248b4cb72c60b3b786de32e724aa46aa33ef815db8049d5d7b1f2fc5b394700945ef249fdcdfe6915e8a28af64fcd8e6bc5bff002e7ff03ffd96b9aae97c5bff002e7ff03ffd96b9aaf7307fc18ff5d4fe9cf0f3fe49bc37fdbfff00a7241599e20ff901dcff00c07ff4215a7599e20ff901dcff00c07ff4215db43f8b1f547d3669fee35bfc12fc99c3514515f447e3615f62d7c755f62d7e1de33ffcc0ff00dc4ffdc67a7977daf97ea1451457e1a7a670de20ff0090e5cffc07ff004115995a7e20ff0090e5cffc07ff004115995f4543f851f447e379a7fbf56ff1cbf3615cd78b7fe5cffe07ff00b2d74b5cd78b7fe5cffe07ff00b2d7760ff8d1feba1f4de1e7fc94986ffb7fff004dc8e6a8ad0d134393c49ac5be9115efd8a4b9240b8f2bccd9805beee467a63af7a75f78ebc35e1bbfb8d0aefc109a8dce9b2b59cb7a75278bed2f1928d26c0a42ee2a5b6e4e338c9afa5a3859d68f3459fd039967b87cbeaaa5562db6afa5bcd756bb1a5e02d5d346f1ae99349bcc73cc2d1820049329d8bd7b066527be01ebd2be8faf99b46f10e89f10758b5f0ce91e1bff00847350ba7f320d5a2bf79dad9a2fdee4261724f978cee18ce7b57ae7c29d5b58d5346d6e3d6f526d42e6c3599ec96e1a258f7222a63e55e07249efd7ad7ab86a52a50e5933e073cc7d1c7e2556a316b449ded76f5d74bf4b2f91ded14515d078e7c0145145006ef82a259fc79e1d85c651f53b6561ec65515f54f883fe43973ff01ffd0457cb3e04ff009287e1affb0adaff00e8d5afa9bc41ff0021cb9ff80ffe822bbb2ffe2bf4ff0023e578bffdc61fe35f948cca28a2bd83f3839af16ffcb9ff00c0ff00f65ae6aba5f16ffcb9ff00c0ff00f65ae6abdcc1ff00063fd753fa73c3cff926f0dff6ff00fe9c905667883fe40773ff0001ff00d08569d667883fe40773ff0001ff00d08576d0fe2c7d51f4d9a7fb8d6ff04bf2670d451457d11f8d857d8b5f1d57d8b5f8778cff00f303ff00713ff719e9e5df6be5fa8514515f869e99c37883fe43973ff01ffd04566569f883fe43973ff01ffd0456657d150fe147d11f8de69fefd5bfc72fcd85735e2dff00973ff81ffecb5d2d735e2dff00973ff81ffecb5dd83fe347fae87d37879ff25261bfedff00fd3721ff000e7fe47ed2bfdf7ffd16d5e57e3bff009287e25ffb0add7fe8d6aeff00c3da55deb9aedae9b63aa4da5dcce484bc833be2c293918653c804751d6bcd3c4b67369fe2ad5ecae2ee4bc9edef668a4b9933ba665720b9c927248cf53d7ad7d9e5ff00c37ea7eafc61fefb0ff0afcd9d67c12ff92bda17fdbc7fe93c95ef3f083fe3cbc5dff6335e7f28ebe6ef877a3de6bde3bd374cb0d5a7d26ea6f3765ec19df16227638c329e4023a8eb5f447c0fb596cbc3de23b49ee5eea683c41731bdc3e774aca9182c724f248cf53d6bbcf933d3e8a28a00f8028a28a00d2f0f6a29a3f8974ad4e40e63b3bc86e18200588470c700f19e3bd7d69e20ff0090e5cffc07ff004115f1cd7d5b65ac7fc241a6586ac6485e4bab482494c272a24f2d77a8e4f46dc304e411835dd97ff15fa7f91f2bc5ff00ee30ff001afca44d451457b07e70735e2dff00973ff81ffecb5cd574be2dff00973ff81ffecb5cd57b983fe0c7faea7f4e7879ff0024de1bfedfff00d3920accf107fc80ee7fe03ffa10ad3accf107fc80ee7fe03ffa10aeda1fc58faa3e9b34ff0071adfe097e4ce1a8a28afa23f1b0afb16be3aafb16bf0ef19ffe607fee27fee33d3cbbed7cbf50a28a2bf0d3d3386f107fc872e7fe03ff00a08accad3f107fc872e7fe03ff00a08accafa2a1fc28fa23f1bcd3fdfab7f8e5f9b0ae6bc5bff2e7ff0003ff00d96ba5ae6bc5bff2e7ff0003ff00d96bbb07fc68ff005d0fa6f0f3fe4a4c37fdbfff00a6e43fe1cffc8fda57fbefff00a2dabcafc77ff250fc4bff00615baffd1ad5d6092e223bed2eee2d271f727b790a489f461c8f4fc6ba3b6f13d9456b0c775e11f0edfdc2a0596eeeec5649a7603992463cb3b1e493d4926beb30988a74a0d4bb9fb5f10e4d8bc7626352824d28db7b756721f04bfe4af685ff006f1ffa4f257bcfc21ff8f2f177fd8cf79fca3af359b5c4d55adb4dd2742d1741bebcba82da2d4f4cb4f26e2df7caaa4a3a90464120e0f2091debdb7c19e0fb6f05e9775656d7b7779f6abb7bb966bb60d233b2a839200cfddcf3ce49af4a9558d55cd13e331d80ad81a8a957566d5fbe9aff0091d1d14515a1c47c0383e9460fa1af5ab3c66b7ecfb578b5738e4fb1f8ff00c03ebeb70a7b3ff97dff0092ff00f6c78360fa57bcfc28bdf3fc1b15ab7deb677c71fc2ccc47ebbab7ecfb574167d4572478b1e1e6a4a95ffedeff00807cee71c2f4f17859d09d4d5dacedb34d6b6beba5d6eb7bf93868ae96d3eed1375aaff8885adbeadff93fff006a7c056e06f65ff3117ffb77ff00b63cd7c5bff2e7ff0003ff00d96b9aaf5e9ab3e6ef5ea61fc49e48287d57ff0027ff00ed0fb7c9388bfb072da780f67cfc97d6fcb7bc9cb6b3ef6dcf31accf107fc80ee7fe03ff00a10af5298d507fbd5df47c48f7d4beadb7f7ff00fb433cc3c49e7a13a3f55f89357e7eeadfc8781d15efcb532d7d1d0f11bdaffcc35bfedfff00ed0f837c4b6ff975ff00937fc03e7bc57d8b9ae112a64af9ee2c8ffad7ec3fe5d7b3e6fef5f9b97fc36b72f9ee6b438c7d85ff00737bff007bff00b53b5a2b8b9bfd5d61de7435f374bc36e7ff0098affc93ff00b73b6971b7b47fc0ff00c9bffb535fc41ff21cb9ff0080ff00e822b32b9bbcef58179d0d7d161fc37bc547eb3b7f73ff00b73c1a986fadd79d6bdb99b76ded777f23d0eb9af16ffcb9ff00c0ff00f65ae06f3bd67c7f78d7a987f0c7924aa7d6b6fee7ff006e7d370c617fb2f32a58fbf3725f4daf78b8efaf7bec7534562c3daafc3dabaa5c03cbff00311ff927ff006c7ecb438a3daffcbab7fdbdff0000f41f85566d3f8dedee39096e8e723a64a3003f224fe15efb5f29435a10f6ada9f0a2a10e5556ff00f6effc13cfc6e5f5335c4baf3a9cab4495af656daf757d6ef65bdba1f4ed15f33b542f5cb5f27f65f6eff2ff008242e13bff00cbeffc97ff00b633ad754d3d7ef5fda8faccbfe35b76bae690b8ddaa590fadc27f8d78a515f1d532884f79339eaf14d6a9bd35f7b3e87b4f12684b8ddad69c3eb749fe35d1586bda3c88244d5ac590f46170841fc735f2b5773e1fff00901db7fc0bff004235e4e3720a7087373b36ca6abcd7112a33f76cafa7aa5fa9f49e9dab69b73958350b594a8c90932b63eb83534d7501e93467fe042bc1b4ad4a5d2b508eea2e76f0c99c075ee0ff009eb8af52b1bd8351b38eeadd898dc77ea0f707de8cb7847098c4ef55a92e965b773e438e658ec86a4670a6a7467b4bb4bac5fe6bbebd99ab757d690e3cdba863ddd37c8067f3acc9752b039c5edb1ffb6abfe3581e2dff00973ff81ffecb5cd57d461bc39c2ce0a7ede5f723ab20e1ea79ee574b1f566e2e7cda25a2b49c7afa1dbcb7d667a5d407feda0aa13ea165129924bbb7441d59a4000fc735cbd667883fe40773ff0001ff00d08577d1f0eb0bcea3ede5abec8798787385850a95bdbcaf14decba2b9daaebba3ff00d056c7ff000213fc6a65d7f47ffa0b587fe04a7f8d785d15f4747c3bc352dab4bee47e7cf87293fb6cf795f1068a3fe62f61ff008129fe35bcb6775ff3ed37fdf06be68afb16be538d25feaafd5fd87ef3da736fa5b9797b7f88df0dc2142bdef51ab7923919acee8c7816d31ff801ac7bbd32fd81db65727e9137f857a3515f234fc46c5436a11fbd9dd4f82e8537a557f723c5efac6ed1ca3db4cae3aa94208fc2b02eb4fbd607167707e919ff000af4ef107fc872e7fe03ff00a08accafa2c3f88b8a5052f611d7cd9f2f5b10f09889d18aba8b6bee763ca6eb48d4db3b74ebb3f481bfc2b31b4cbfb761e7d95cc5bb38df132e7f315ed35cd78b7fe5cffe07ff00b2d7a987f12f1539a87b08ebe6cfa7e14c4bcd334a5819ae553e6d56fa45cbf43848ade61d6371f8569d9585e5ce7ecf693cdb71bbcb8cb633eb8ab36b6d35e5cc76f021796438503fcf4af53d274b8748b05b5889639dcee7f89bb9f6e95ef65fc578ac6c9de92515d6efee3ed78bb1984e17a3050973d69ed17a69d5bb74e8bbbf4679d5b689ab4b2048f4bbd773d156ddc93fa56ac5e1bd7475d17511ff006eaffe15ea3e1fff0090e5b7fc0bff004135dcd698bcfaa539f2a8239387b8cabe330eeb4a925676ddf64ff53e7a6f0e6b9ff405d47ff015ff00c2a26f0debbff404d4bff015ff00c2be8aa2bcaad9bceaef147d1ae2bacbfe5dafbd9f00514515e41f2815dcf87ffe4076dff02ffd08d14570e61fc25ebfe67d57087fbf4ffc0ff389a75b9e17d5a7d3f548add70f05cc8b1ba1ec49c061ee33f8fe4414579d85a92a75a3283b3b9f619f612862f2caf47111528b8bd1f92ba7ea9ea9f4674de2dff973ff0081ff00ecb5cd51457ec383fe0c7faea7c87879ff0024de1bfedfff00d3920accf107fc80ee7fe03ffa10a28aeda1fc58faa3e9b34ff71adfe097e4ce1a8a28afa23f1b0afb168a2bf0ef19ff00e607fee27fee33d3cbbed7cbf50a28a2bf0d3d3386f107fc872e7fe03ffa08acca28afa2a1fc28fa23f1bcd3fdfab7f8e5f9b0ae6bc5bff2e7ff0003ff00d968a2bbb07fc68ff5d0fa6f0f3fe4a4c37fdbff00fa6e474be17d1edf4fd362b95f9ee2e23576723a0201da3dbf9fe58dda28afd83094e34e84630565647c8e7f8caf8cccebd6c449ca5ccd5df64ec97a25b234fc3fff0021cb6ff817fe826bb9a28af3f30fe2af4ff33ebf843fdc67fe37f94428a28ae13ea8ffd90000001800530065007100750065006e0063006500540079007000650000000200000000010000001a00530065007100750065006e00630065005300650074007500700000000c000000015a000000000000000300000000000000000000000000000000000000000000005c000000000000000500000000000007800000000000000438000000000000000000000000000000000000803f00000000000000180000000000000000ffffffffffffffff0000000000000000fffffffffffffffe000000000000000100000000000000c40000000000000005000000000000002b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000007800000000000000438000000000000000000000000000000000000803fffffffffffffffff00000000000000010000000000000221000000000000000000000000000000000000000000000000000000000000000000000000000000000000003f0000003f000000000000000a1800200ee802028003040000000c0053006500710052006500660000000a000000004800330035006300370034006300340061002d0063003300610030002d0034006100370038002d0039003600310064002d003900350039003400640066003600380034003600300038000000300049007300520065006e006400650072004300610063006800650042004100560065007200730069006f006e006500640000000100010000002400460069007200730074004d00610069006e0042007500730046006f0072006d00610074000000020000000002</FieldsBlob>
33
33
  <UniqueSequenceId>b84501d8-e67f-48a9-a6a2-dba874d6d8fd</UniqueSequenceId>
34
34
  <MediaExtents>00000000000000000000000000003040</MediaExtents>
35
35
  <FrameRate>00000000000038400000000000000000</FrameRate>
@@ -5,6 +5,7 @@
5
5
  python scripts/resolve_headless.py guard # exit non-zero unless it is safe to start
6
6
  python scripts/resolve_headless.py start # boot -nogui and wait until scriptable
7
7
  python scripts/resolve_headless.py stop # Quit() and wait for the process to go
8
+ python scripts/resolve_headless.py stop --force # TERM/KILL a wedged, unanswering instance
8
9
  python scripts/resolve_headless.py run -- python my_batch.py # guard, start, run, stop
9
10
 
10
11
  Why a wrapper rather than a line in a Makefile:
@@ -122,6 +123,21 @@ def cmd_guard() -> int:
122
123
  return 0
123
124
 
124
125
 
126
+ def _kill_process(proc: subprocess.Popen, grace: float = 10.0) -> bool:
127
+ """TERM then KILL the process we launched; True when it is gone."""
128
+ proc.terminate()
129
+ try:
130
+ proc.wait(timeout=grace)
131
+ return True
132
+ except subprocess.TimeoutExpired:
133
+ proc.kill()
134
+ try:
135
+ proc.wait(timeout=grace)
136
+ return True
137
+ except subprocess.TimeoutExpired:
138
+ return False
139
+
140
+
125
141
  def start(timeout: float) -> int:
126
142
  guard = cmd_guard()
127
143
  if guard != 0:
@@ -131,25 +147,41 @@ def start(timeout: float) -> int:
131
147
  print("REFUSE: no DaVinci Resolve install found.", file=sys.stderr)
132
148
  return 3
133
149
  print(f"starting: {' '.join(command)}")
134
- subprocess.Popen(command, stdin=subprocess.DEVNULL,
135
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
150
+ proc = subprocess.Popen(command, stdin=subprocess.DEVNULL,
151
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
136
152
  started = time.monotonic()
137
153
  if wait_until_scriptable(timeout) is None:
154
+ # An unscriptable -nogui instance still holds the singleton, so the
155
+ # GUI cannot launch either — leaving it behind wedges the machine
156
+ # worse than never starting (issue #172). Kill only what WE spawned.
138
157
  print(f"FAILED: no scripting response within {timeout:.0f}s.", file=sys.stderr)
158
+ print("cleaning up the unscriptable instance this script started...", file=sys.stderr)
159
+ if _kill_process(proc):
160
+ print("cleaned up. Headless requires external scripting to connect without "
161
+ "the GUI; on this setup it did not — use the GUI, or raise --timeout "
162
+ "if this machine is just slow to boot Resolve.", file=sys.stderr)
163
+ else:
164
+ print(f"could not kill pid {proc.pid} — remove it by hand (kill -9 {proc.pid}).",
165
+ file=sys.stderr)
139
166
  return 4
140
167
  print(f"ready in {time.monotonic() - started:.1f}s")
141
168
  return 0
142
169
 
143
170
 
144
- def stop(timeout: float) -> int:
171
+ def stop(timeout: float, force: bool = False) -> int:
145
172
  mode = rr.runtime_mode()
146
173
  if not mode["running"]:
147
174
  print("nothing to stop")
148
175
  return 0
149
176
  resolve = _connect()
150
177
  if resolve is None:
151
- print("REFUSE: Resolve is running but not answering; not killing it.", file=sys.stderr)
152
- return 5
178
+ if not force:
179
+ print("REFUSE: Resolve is running but not answering; not killing it. "
180
+ "If it never became scriptable (a wedged -nogui boot, a stuck "
181
+ "modal), rerun with --force to TERM/KILL it — unclean: expect "
182
+ "project locks and a slow next boot.", file=sys.stderr)
183
+ return 5
184
+ return _force_stop(timeout)
153
185
  started = time.monotonic()
154
186
  resolve.Quit()
155
187
  while time.monotonic() - started < timeout:
@@ -157,10 +189,55 @@ def stop(timeout: float) -> int:
157
189
  if not (rr.resolve_processes() or []):
158
190
  print(f"stopped in {time.monotonic() - started:.1f}s")
159
191
  return 0
160
- print(f"FAILED: still running {timeout:.0f}s after Quit().", file=sys.stderr)
192
+ if force:
193
+ print(f"still running {timeout:.0f}s after Quit(); escalating.", file=sys.stderr)
194
+ return _force_stop(timeout)
195
+ print(f"FAILED: still running {timeout:.0f}s after Quit(). "
196
+ f"Rerun with --force to TERM/KILL it.", file=sys.stderr)
161
197
  return 6
162
198
 
163
199
 
200
+ def _force_stop(timeout: float) -> int:
201
+ """TERM, then KILL, every running Resolve process (unix only)."""
202
+ import platform
203
+ import signal
204
+
205
+ if platform.system().lower() == "windows":
206
+ print("FAILED: --force is unix-only here; use `taskkill /F /IM Resolve.exe`.",
207
+ file=sys.stderr)
208
+ return 7
209
+
210
+ def pids() -> List[int]:
211
+ found: set = set()
212
+ for name in ("Resolve", "resolve"):
213
+ out = subprocess.run(["pgrep", "-x", name], capture_output=True,
214
+ text=True, check=False)
215
+ found.update(int(p) for p in out.stdout.split() if p.strip().isdigit())
216
+ return sorted(found)
217
+
218
+ for sig, grace in ((signal.SIGTERM, min(timeout, 10.0)), (signal.SIGKILL, min(timeout, 10.0))):
219
+ targets = pids()
220
+ if not targets:
221
+ print("force-stopped (unclean — expect a slower next boot).")
222
+ return 0
223
+ for pid in targets:
224
+ print(f"sending {sig.name} to pid {pid}", file=sys.stderr)
225
+ try:
226
+ os.kill(pid, sig)
227
+ except ProcessLookupError:
228
+ pass
229
+ except PermissionError:
230
+ print(f"no permission to signal pid {pid}", file=sys.stderr)
231
+ deadline = time.monotonic() + grace
232
+ while time.monotonic() < deadline:
233
+ time.sleep(0.5)
234
+ if not pids():
235
+ print("force-stopped (unclean — expect a slower next boot).")
236
+ return 0
237
+ print("FAILED: Resolve survived SIGKILL; inspect by hand.", file=sys.stderr)
238
+ return 7
239
+
240
+
164
241
  def cmd_run(command: List[str], timeout: float) -> int:
165
242
  """Guard, start if needed, run the command, stop only what we started."""
166
243
  mode = rr.runtime_mode()
@@ -200,7 +277,10 @@ def main() -> int:
200
277
  sub.add_parser("status")
201
278
  sub.add_parser("guard")
202
279
  sub.add_parser("start")
203
- sub.add_parser("stop")
280
+ stop_p = sub.add_parser("stop")
281
+ stop_p.add_argument("--force", action="store_true",
282
+ help="escalate to TERM/KILL when Resolve is running but not "
283
+ "answering, or survives Quit() (unclean shutdown)")
204
284
  run = sub.add_parser("run")
205
285
  run.add_argument("argv", nargs=argparse.REMAINDER,
206
286
  help="command to run; put it after a bare --")
@@ -213,7 +293,7 @@ def main() -> int:
213
293
  if args.command == "start":
214
294
  return start(args.timeout)
215
295
  if args.command == "stop":
216
- return stop(args.timeout)
296
+ return stop(args.timeout, force=getattr(args, "force", False))
217
297
  argv = [a for a in args.argv if a != "--"]
218
298
  if not argv:
219
299
  print("run: nothing to run (put the command after `--`)", file=sys.stderr)
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.132.1"
90
+ VERSION = "2.134.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.132.1"
14
+ VERSION = "2.134.0"
15
15
 
16
16
  import base64
17
17
  import os