Skip to content

Commit d0206e5

Browse files
committed
feat(drt): timeline markers authored offline — blob decoded, byte-exact codec, full color map
- Found the marker home in a .drp/.drt: project.xml's Sm2SequenceLockableBlob (inside LocableBlobSet — Resolve's own spelling), BlobOwner = the timeline's Sm2Sequence DbId. FieldsBlob = keyed-dict{BlobData} → [u32 10001][u32 len] [0x81][zstd frame][protobuf marker entries: frame varint + color bit + note/durationString/name strings + customData field 6]. - Resolve emits RAW-BLOCK zstd for small payloads and accepts it on import — so the new codec needs no zstd library (raw + RLE blocks; compressed blocks refuse with a clear error). - Full 16-color bit map harvested live (one marker per color): sequential powers of two, 256 unassigned. The old marker-encoder.js map was WRONG (Yellow 16 not 8, Purple 128 not 131072) and its bytes never matched a real export — deprecated with a pointer to the new module. - timeline-markers-blob.js: encode/decode, BYTE-EXACT against Resolve 19.1.3.7's own 2-marker export (fixture checked in). - drt.assemble: spec.markers (timeline-ABSOLUTE frames, converted to the blob's start-relative space; before-origin refuses). E29 live proof: offline-authored Red note+duration-12 and Mint customData markers read back perfectly through the marker API after import. - Guide table row; tool doc; 1 new handler test (fixture round-trip + owner wiring + payload decode). Suites after last edit (incl. version bump): Node 832 pass / 0 fail; Python 3101 passed + 799 subtests.
1 parent 4886056 commit d0206e5

12 files changed

Lines changed: 241 additions & 7 deletions

File tree

docs/guides/native-drt-authoring.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ window. `render.verify_output` covers the container-level checks.
4848
| Audio placements, A1–A8 | `cuts[].audioOnly + track` | v2.115 |
4949
| Built-in generators | `elements: [{type:'generator', generatorName}]` | v2.110 |
5050
| Custom start timecode | `spec.startFrame` / `preserveStartTimecode` | v2.117 |
51+
| Timeline markers | `spec.markers` (16 colors, notes, durations, customData) | v2.118 |
5152
| Fusion titles | `elements: [{type:'title', text}]`**21-gen hosts only** | v2.108 |
5253

5354
`assemble_from_interchange` drives the same engine from an EDL / OTIO /

install.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737

3838
# ─── Version ──────────────────────────────────────────────────────────────────
3939

40-
VERSION = "2.117.0"
40+
VERSION = "2.118.0"
4141
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
4242
# Resolve's scripting bridge loads into newer interpreters on recent builds
4343
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "davinci-resolve-mcp",
3-
"version": "2.117.0",
3+
"version": "2.118.0",
44
"description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
55
"license": "MIT",
66
"author": "Samuel Gursky <samgursky@gmail.com>",

resolve-advanced/server/tools/drt.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const assembleSchema = z.object({
4848
spec: z
4949
.object({})
5050
.passthrough()
51-
.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)}], 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)."),
51+
.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), 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)."),
5252
outputPath: z.string().describe('Absolute path where the importable .drt will be written'),
5353
targetAppVersion: z
5454
.union([z.string(), z.number()])

resolve-advanced/test/drt-assemble-extract.test.mjs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import os from 'node:os';
1010
import path from 'node:path';
1111
import JSZip from 'jszip';
1212
import { drtTool } from '../server/lib.mjs';
13+
import { createRequire } from 'node:module';
1314

1415
const tmp = (ext) => path.join(os.tmpdir(), `drtns-${Math.random().toString(36).slice(2)}${ext}`);
1516

@@ -136,3 +137,41 @@ test('spec.startFrame patches MediaExtents and moves the origin guard', async ()
136137
cuts: [{ startFrame: 86000, durationFrames: 24 }] } },
137138
}}), /before the timeline origin 86208/);
138139
});
140+
141+
test('spec.markers encode byte-exact and attach to the sequence owner', async () => {
142+
const requireC = createRequire(import.meta.url);
143+
const { encodeTimelineMarkersBlob, decodeTimelineMarkersBlob, MARKER_COLOR_BITS } =
144+
requireC('../vendor/drp-format/timeline-markers-blob.js');
145+
// Fixture: the Sm2SequenceLockableBlob FieldsBlob Resolve 19.1.3.7 itself
146+
// wrote for two markers (harvested via the marker API + ExportProject).
147+
const harvest = Buffer.from(
148+
(await fs.readFile(new URL('./fixtures-r19-markers-2.hex', import.meta.url), 'utf8')).trim(), 'hex');
149+
const dec = decodeTimelineMarkersBlob(harvest);
150+
assert.equal(dec.length, 2);
151+
assert.deepEqual(dec.map((m) => [m.frame, m.color, m.name]), [[60, 'Red', 'MK_BETA'], [24, 'Blue', 'MK_ALPHA']]);
152+
assert.ok(encodeTimelineMarkersBlob(dec).equals(harvest), 'byte-exact re-encode');
153+
assert.equal(Object.keys(MARKER_COLOR_BITS).length, 16);
154+
155+
const out = tmp('.drt');
156+
const res = await drtTool.handler({ action: 'assemble', args: {
157+
outputPath: out, targetAppVersion: '19.1.3',
158+
spec: { timelineName: 'MRK', media: {
159+
mediaFilePath: '/m/a.mp4', spec: { width: 640, height: 360, frameCount: 480, fps: 24 },
160+
cuts: [{ startFrame: 86400, durationFrames: 96 }],
161+
}, markers: [{ frame: 86424, color: 'Red', name: 'A', note: 'n', duration: 12 }] },
162+
}});
163+
const zip = await JSZip.loadAsync(await fs.readFile(out));
164+
const pj = await zip.file('project.xml').async('string');
165+
const blk = pj.match(/<Sm2SequenceLockableBlob[\s\S]*?<\/Sm2SequenceLockableBlob>/);
166+
assert.ok(blk, 'lockable blob inserted');
167+
const owner = blk[0].match(/<BlobOwner>([0-9a-f-]{36})<\/BlobOwner>/)[1];
168+
const seqName = Object.keys(zip.files).find((n) => !zip.files[n].dir && /^SeqContainer\//.test(n));
169+
const seq = await zip.file(seqName).async('string');
170+
assert.ok(seq.includes(`<Sequence>${owner}</Sequence>`), 'owner is the Sm2Sequence id');
171+
const fb = Buffer.from(blk[0].match(/<FieldsBlob>([0-9a-fA-F]*)<\/FieldsBlob>/)[1], 'hex');
172+
const decoded = decodeTimelineMarkersBlob(fb);
173+
assert.deepEqual(decoded, [{ frame: 24, color: 'Red', note: 'n', duration: 12, name: 'A', customData: '' }]);
174+
await fs.unlink(out);
175+
});
176+
177+
test('createRequire import for marker tests', () => { assert.ok(createRequire); });
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
0000000100000001000000100042006c006f006200440061007400610000000c000000006b00002711000000638128b52ffd2059c9020012570a2a083c1226000000020000001e0a1c08201a0b7365636f6e64206e6f74651a0231321a074d4b5f424554410a2908181225000000020000001d0a1b08021a0a6669727374206e6f74651a01311a084d4b5f414c504841

resolve-advanced/vendor/drp-format/assemble-timeline.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const { loadMediaTemplate, transplantMediaElement, insertMediaElement } = requir
2626
const JSZip = require('jszip');
2727
const { cutSourceIntoClips } = require('./cut-media');
2828
const { buildConstantSpeedTimemapKeyed } = require('./media-timemap');
29+
const { encodeTimelineMarkersBlob } = require('./timeline-markers-blob');
2930
const { randomUUID } = require('node:crypto');
3031
const { placeFusionTitle } = require('./place-fusion-title');
3132
const { placeGenerator } = require('./place-generator');
@@ -192,6 +193,33 @@ async function assembleTimeline(spec = {}) {
192193
}));
193194
}
194195

196+
if (Array.isArray(spec.markers) && spec.markers.length) {
197+
// Timeline markers ride in project.xml as a Sm2SequenceLockableBlob whose
198+
// BlobOwner is the timeline's Sm2Sequence DbId (the uuid every track's
199+
// <Sequence> references). Encoder byte-exact vs a live 19.1.3.7 export.
200+
// Marker frames here are TIMELINE-ABSOLUTE for consistency with cuts;
201+
// the blob stores them start-relative.
202+
const zipM = await JSZip.loadAsync(buffer);
203+
const seqName = Object.keys(zipM.files).find((n) => !zipM.files[n].dir && /SeqContainer\/.+\.xml$/.test(n));
204+
const seqXml2 = await zipM.file(seqName).async('string');
205+
const seqIdM = (seqXml2.match(/<Sequence>([0-9a-f-]{36})<\/Sequence>/) || [])[1];
206+
if (!seqIdM) throw new Error('assembleTimeline: cannot find the Sm2Sequence id for markers');
207+
const rel = spec.markers.map((m) => {
208+
if (!Number.isInteger(m.frame) || m.frame < originFrame) {
209+
throw new RangeError(`assembleTimeline: marker frame ${m.frame} is before the timeline origin ${originFrame} (frames are timeline-absolute)`);
210+
}
211+
return { ...m, frame: m.frame - originFrame };
212+
});
213+
const blob = encodeTimelineMarkersBlob(rel);
214+
let pjX = await zipM.file('project.xml').async('string');
215+
const setM = pjX.match(/<LocableBlobSet>[\s\S]*?<\/LocableBlobSet>/);
216+
if (!setM) throw new Error('assembleTimeline: project.xml has no LocableBlobSet to hold markers');
217+
const el = `<Element>\n <Sm2SequenceLockableBlob DbId="${randomUUID()}">\n <FieldsBlob>${blob.toString('hex')}</FieldsBlob>\n <BlobOwner>${seqIdM}</BlobOwner>\n <DbSavedTime>0</DbSavedTime>\n </Sm2SequenceLockableBlob>\n </Element>\n `;
218+
pjX = pjX.replace('</LocableBlobSet>', `${el}</LocableBlobSet>`);
219+
zipM.file('project.xml', pjX);
220+
buffer = await zipM.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
221+
}
222+
195223
if (originFrame !== DEFAULT_START_FRAME) {
196224
const zipF = await JSZip.loadAsync(buffer);
197225
const mpP = 'MediaPool/Master/MpFolder.xml';

resolve-advanced/vendor/drp-format/marker-encoder.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
/**
22
* DaVinci Resolve Timeline Marker Encoder/Decoder
33
*
4+
* DEPRECATED for .drp/.drt authoring: this module's color map and emitted
5+
* bytes never matched a real Resolve export (measured 2026-08-30 — Yellow is
6+
* 16 not 8, Purple is 128, and the framing differs). Use
7+
* timeline-markers-blob.js, whose encoder is byte-exact against a live
8+
* 19.1.3.7 export and render/readback-verified through drt.assemble.
9+
*
410
* Encodes and decodes timeline markers into DaVinci Resolve's
511
* compressed protobuf format stored in Sm2SequenceLockableBlob.FieldsBlob.
612
*
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* timeline-markers-blob — encode/decode TIMELINE markers as the
3+
* Sm2SequenceLockableBlob.FieldsBlob that lives in project.xml.
4+
*
5+
* Ground truth (harvested live from Studio 19.1.3.7, all 16 colors + custom
6+
* data + durations, decoded round-trip against the API's own readback):
7+
*
8+
* FieldsBlob = keyed-dict { "BlobData": bytes }
9+
* BlobData = [u32BE 10001][u32BE innerLen][0x81][zstd frame]
10+
* zstd frame = magic 28b52ffd + single-segment header + RAW block(s)
11+
* (Resolve itself emits raw-block zstd for small payloads —
12+
* accepted on import; no real compressor needed)
13+
* payload = protobuf: field2 { repeated field1 MarkerEntry }
14+
* MarkerEntry= f1 varint frameRelative, f2 bytes {
15+
* [u32BE 2][u32BE innerLen] f1 bytes {
16+
* f1 varint colorBit,
17+
* f3 string note, f3 string durationString, f3 string name,
18+
* f6 string customData (present only when non-empty)
19+
* } }
20+
*
21+
* Marker frames are RELATIVE to the timeline start (frame 0 = first frame),
22+
* matching the scripting API's marker frame space. The blob attaches inside
23+
* project.xml's <LocableBlobSet> (Resolve's own spelling) with
24+
* <BlobOwner> = the timeline's Sm2Sequence DbId (the same uuid every track's
25+
* <Sequence> references).
26+
*
27+
* Color bits (measured, one marker per color): sequential powers of two with
28+
* 256 unassigned. This supersedes marker-encoder.js, whose map was wrong for
29+
* Yellow/Purple/Lavender and whose emitted bytes never matched a real export.
30+
*
31+
* @module drp-format/timeline-markers-blob
32+
*/
33+
34+
const { encodeKeyedDict, decodeKeyedDict } = require('./keyed-dict');
35+
36+
const MARKER_COLOR_BITS = {
37+
Blue: 2, Cyan: 4, Green: 8, Yellow: 16, Red: 32, Pink: 64, Purple: 128,
38+
Fuchsia: 512, Rose: 1024, Lavender: 2048, Sky: 4096, Mint: 8192,
39+
Lemon: 16384, Sand: 32768, Cocoa: 65536, Cream: 131072,
40+
};
41+
const BITS_TO_COLOR = Object.fromEntries(Object.entries(MARKER_COLOR_BITS).map(([k, v]) => [v, k]));
42+
43+
function varint(n) {
44+
const out = [];
45+
let v = n >>> 0;
46+
do { out.push((v & 0x7f) | (v > 0x7f ? 0x80 : 0)); v >>>= 7; } while (v);
47+
return Buffer.from(out);
48+
}
49+
const lenDelim = (field, payload) => Buffer.concat([varint((field << 3) | 2), varint(payload.length), payload]);
50+
const varField = (field, n) => Buffer.concat([varint(field << 3), varint(n)]);
51+
52+
function encodeMarkerEntry(m) {
53+
const colorBit = MARKER_COLOR_BITS[m.color] ?? MARKER_COLOR_BITS.Blue;
54+
const strs = [m.note ?? '', String(m.duration ?? 1), m.name ?? ''];
55+
const body = Buffer.concat([
56+
varField(1, colorBit),
57+
...strs.map((s) => lenDelim(3, Buffer.from(s, 'utf8'))),
58+
...(m.customData ? [lenDelim(6, Buffer.from(m.customData, 'utf8'))] : []),
59+
]);
60+
const inner = lenDelim(1, body);
61+
const head = Buffer.alloc(8);
62+
head.writeUInt32BE(2, 0);
63+
head.writeUInt32BE(inner.length, 4);
64+
const wrapped = Buffer.concat([head, inner]);
65+
return lenDelim(1, Buffer.concat([varField(1, m.frame), lenDelim(2, wrapped)]));
66+
}
67+
68+
/** zstd single-segment frame with one RAW block (no compression). */
69+
function zstdRawFrame(payload) {
70+
const magic = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
71+
let header;
72+
if (payload.length <= 255) {
73+
header = Buffer.from([0x20, payload.length]); // single-segment, 1-byte FCS
74+
} else {
75+
header = Buffer.alloc(5);
76+
header[0] = 0xa0; // single-segment, 4-byte FCS
77+
header.writeUInt32LE(payload.length, 1);
78+
}
79+
const block = Buffer.alloc(3);
80+
block.writeUIntLE((payload.length << 3) | 1, 0, 3); // last=1, type=raw
81+
return Buffer.concat([magic, header, block, payload]);
82+
}
83+
84+
function zstdRawInflate(buf) {
85+
if (buf.readUInt32LE(0) !== 0xfd2fb528) throw new Error('timeline-markers-blob: not a zstd frame');
86+
const fhd = buf[4];
87+
const single = (fhd >> 5) & 1;
88+
const fcsCode = fhd >> 6;
89+
let o = 5 + (single ? [1, 2, 4, 8][fcsCode] : [0, 2, 4, 8][fcsCode]);
90+
if (fhd & 0x03) throw new Error('timeline-markers-blob: dictionary frames unsupported');
91+
const out = [];
92+
for (;;) {
93+
const bh = buf.readUIntLE(o, 3); o += 3;
94+
const last = bh & 1, type = (bh >> 1) & 3, size = bh >> 3;
95+
if (type === 0) { out.push(buf.subarray(o, o + size)); o += size; }
96+
else if (type === 1) { out.push(Buffer.alloc(size, buf[o])); o += 1; }
97+
else throw new Error('timeline-markers-blob: compressed zstd block — use a real zstd decoder');
98+
if (last) break;
99+
}
100+
return Buffer.concat(out);
101+
}
102+
103+
/**
104+
* Encode timeline markers → Sm2SequenceLockableBlob FieldsBlob buffer.
105+
* @param {Array<{frame:number,color?:string,name?:string,note?:string,duration?:number,customData?:string}>} markers
106+
* frame is timeline-RELATIVE (0 = first frame of the timeline).
107+
*/
108+
function encodeTimelineMarkersBlob(markers) {
109+
for (const m of markers) {
110+
if (!Number.isInteger(m.frame) || m.frame < 0) throw new TypeError('encodeTimelineMarkersBlob: marker.frame must be a non-negative integer (timeline-relative)');
111+
if (m.color && !MARKER_COLOR_BITS[m.color]) {
112+
throw new Error(`encodeTimelineMarkersBlob: unknown color "${m.color}" (known: ${Object.keys(MARKER_COLOR_BITS).join(', ')})`);
113+
}
114+
}
115+
const entries = [...markers].sort((a, b) => b.frame - a.frame).map(encodeMarkerEntry);
116+
const pb = lenDelim(2, Buffer.concat(entries));
117+
const frame = zstdRawFrame(pb);
118+
const head = Buffer.alloc(8);
119+
head.writeUInt32BE(10001, 0);
120+
head.writeUInt32BE(frame.length + 1, 4);
121+
const blobData = Buffer.concat([head, Buffer.from([0x81]), frame]);
122+
return encodeKeyedDict({ hdr: 1, entries: [
123+
{ key: 'BlobData', type: 0x0c, subType: 0, value: blobData.toString('hex') },
124+
] });
125+
}
126+
127+
/** Decode a Sm2SequenceLockableBlob FieldsBlob → markers (raw/RLE zstd only). */
128+
function decodeTimelineMarkersBlob(buf) {
129+
const d = decodeKeyedDict(buf);
130+
const bd = d.entries.find((e) => e.key === 'BlobData');
131+
if (!bd) throw new Error('decodeTimelineMarkersBlob: no BlobData entry');
132+
const val = Buffer.from(bd.value, 'hex');
133+
if (val.readUInt32BE(0) !== 10001 || val[8] !== 0x81) throw new Error('decodeTimelineMarkersBlob: unexpected BlobData framing');
134+
const pb = zstdRawInflate(val.subarray(9));
135+
let o = 0;
136+
const rv = () => { let v = 0, s = 0; for (;;) { const b = pb[o++]; v |= (b & 0x7f) << s; if (!(b & 0x80)) return v >>> 0; s += 7; } };
137+
const markers = [];
138+
if (pb[o] === 0x12) { o++; rv(); }
139+
while (o < pb.length && pb[o] === 0x0a) {
140+
o++; const el = rv(); const end = o + el;
141+
const m = { frame: null, color: null, note: '', duration: 1, name: '', customData: '' };
142+
if (pb[o] === 0x08) { o++; m.frame = rv(); }
143+
if (pb[o] === 0x12) {
144+
o++; rv(); o += 8;
145+
if (pb[o] === 0x0a) { o++; rv(); }
146+
if (pb[o] === 0x08) { o++; m.color = BITS_TO_COLOR[rv()] ?? null; }
147+
const strs = [];
148+
while (o < end && pb[o] === 0x1a) { o++; const sl = rv(); strs.push(pb.subarray(o, o + sl).toString('utf8')); o += sl; }
149+
[m.note = '', , m.name = ''] = strs;
150+
m.duration = parseInt(strs[1] ?? '1', 10) || 1;
151+
if (o < end && pb[o] === 0x32) { o++; const sl = rv(); m.customData = pb.subarray(o, o + sl).toString('utf8'); o += sl; }
152+
}
153+
o = end;
154+
markers.push(m);
155+
}
156+
return markers;
157+
}
158+
159+
module.exports = { encodeTimelineMarkersBlob, decodeTimelineMarkersBlob, MARKER_COLOR_BITS };

0 commit comments

Comments
 (0)