|
| 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