davinci-resolve-mcp 4.9.2 → 4.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +8 -0
- package/docs/guides/subtitle-track-editing.md +227 -0
- package/docs/kernels/audio-fairlight-kernel.md +5 -0
- package/docs/reference/api-limitations.md +6 -6
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/README.md +6 -0
- package/resolve-advanced/server/db-patch.mjs +99 -18
- package/resolve-advanced/server/subtitle-db.mjs +285 -0
- package/resolve-advanced/server/tools/drt.mjs +8 -2
- package/resolve-advanced/server/tools/project_db.mjs +11 -2
- package/resolve-advanced/vendor/drp-format/subtitle-captions.js +122 -0
- package/resolve-advanced/vendor/drp-format/subtitle-preset.js +131 -0
- package/resolve-advanced/vendor/drp-format/subtitle-style.js +5 -1
- package/resolve-advanced/vendor/drp-format/timeline-markers-blob.js +1 -1
- package/src/granular/common.py +1 -1
- package/src/granular/timeline_item.py +3 -0
- package/src/server.py +1 -1
- package/src/utils/api_truth.py +49 -21
- package/src/utils/destructive_hook.py +10 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/** Transactional subtitle edits in a CLOSED local project library. */
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { openGuarded, requireResolveQuit, snapshotBackup } from './db-patch.mjs';
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const { decodeCaption, encodeCaption, checkCaptions } = require('../vendor/drp-format/subtitle-captions.js');
|
|
8
|
+
const { decodePreset, setPresetInputs } = require('../vendor/drp-format/subtitle-preset.js');
|
|
9
|
+
|
|
10
|
+
const target = { projectDb: z.string().optional(), projectName: z.string().optional() };
|
|
11
|
+
const track = { timeline: z.string(), track: z.number().int().min(1).default(1) };
|
|
12
|
+
const write = { dryRun: z.boolean().default(false), iConfirmProjectClosed: z.boolean().optional() };
|
|
13
|
+
const frame = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
|
|
14
|
+
const word = z.object({ text: z.string().min(1), start: z.number().nonnegative(), end: z.number().nonnegative() }).strict();
|
|
15
|
+
const caption = { text: z.string().min(1), start: frame, end: frame, words: z.array(word).min(1) };
|
|
16
|
+
const rgb = z.number().min(0).max(1);
|
|
17
|
+
const inputs = z.object({
|
|
18
|
+
font: z.string().min(1).optional(), fontStyle: z.string().min(1).optional(),
|
|
19
|
+
size: z.number().positive().max(1).optional(), position: z.tuple([rgb, rgb]).optional(),
|
|
20
|
+
textRed: rgb.optional(), textGreen: rgb.optional(), textBlue: rgb.optional(), textAlpha: rgb.optional(),
|
|
21
|
+
highlightRed: rgb.optional(), highlightGreen: rgb.optional(), highlightBlue: rgb.optional(),
|
|
22
|
+
outlineRed: rgb.optional(), outlineGreen: rgb.optional(), outlineBlue: rgb.optional(),
|
|
23
|
+
outlineEnabled: z.union([z.literal(0), z.literal(1)]).optional(), thickness: z.number().min(0).max(1).optional(),
|
|
24
|
+
}).strict();
|
|
25
|
+
export const subtitleSchemas = {
|
|
26
|
+
list_subtitle_presets: z.object({ ...target, timeline: z.string().optional() }).strict(),
|
|
27
|
+
list_captions: z.object({ ...target, ...track }).strict(),
|
|
28
|
+
check_captions: z.object({ ...target, ...track, maxCharacters: z.number().int().positive().default(24) }).strict(),
|
|
29
|
+
write_captions: z.object({ ...target, ...track, ...write,
|
|
30
|
+
add: z.array(z.object(caption).strict()).default([]),
|
|
31
|
+
replace: z.array(z.object({ id: z.string(), ...caption }).strict()).default([]),
|
|
32
|
+
delete: z.array(z.string()).default([]), templateCaptionId: z.string().optional(),
|
|
33
|
+
}).strict(),
|
|
34
|
+
copy_subtitle_preset: z.object({ ...target, ...track, ...write,
|
|
35
|
+
sourceProject: z.string().optional(), sourceProjectDb: z.string().optional(), sourceTimeline: z.string(),
|
|
36
|
+
sourceTrack: z.number().int().positive().default(1), replace: z.boolean().default(false), inputs: inputs.optional(),
|
|
37
|
+
}).strict(),
|
|
38
|
+
set_subtitle_preset: z.object({ ...target, ...track, ...write, inputs }).strict(),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const quote = (s) => `"${s.replace(/"/g, '""')}"`;
|
|
42
|
+
function guard(db, table, columns) {
|
|
43
|
+
const names = db.prepare(`PRAGMA table_info(${quote(table)})`).all().map((r) => r.name);
|
|
44
|
+
for (const c of columns) if (!names.includes(c)) throw new Error(`unsupported schema: missing ${table}.${c}`);
|
|
45
|
+
return names;
|
|
46
|
+
}
|
|
47
|
+
function tracks(db, timeline) {
|
|
48
|
+
guard(db, 'Sm2TiTrack', ['Sm2TiTrack_id', 'Sequence', 'Type']);
|
|
49
|
+
guard(db, 'Sm2Sequence', ['Sm2Sequence_id', 'Sm2Timeline_id', 'FrameRate']);
|
|
50
|
+
guard(db, 'Sm2Timeline', ['Sm2Timeline_id', 'Name']);
|
|
51
|
+
const timelines = db.prepare('SELECT Sm2Timeline_id id, Name FROM Sm2Timeline').all();
|
|
52
|
+
if (timeline != null && timelines.filter((r) => r.Name === timeline).length !== 1) {
|
|
53
|
+
throw new Error(`timeline "${timeline}" missing or ambiguous`);
|
|
54
|
+
}
|
|
55
|
+
const rows = db.prepare(`SELECT tr.Sm2TiTrack_id id, tl.Name timeline, tl.Sm2Timeline_id timelineId,
|
|
56
|
+
sq.Sm2Sequence_id sequenceId, sq.FrameRate fpsBlob
|
|
57
|
+
FROM Sm2TiTrack tr JOIN Sm2Sequence sq ON tr.Sequence=sq.Sm2Sequence_id
|
|
58
|
+
JOIN Sm2Timeline tl ON tl.Sm2Timeline_id=sq.Sm2Timeline_id WHERE tr.Type=2 ORDER BY tl.rowid,tr.rowid`).all();
|
|
59
|
+
// Resolve's vector association defines visible track order, not insertion order.
|
|
60
|
+
const associations = ['Sm2Sequence_Sm2TiTrack', 'Sm2SequenceContainer_Sm2TiTrack']
|
|
61
|
+
.filter((name) => db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name));
|
|
62
|
+
const order = associations.flatMap((name) => {
|
|
63
|
+
guard(db, name, ['DbOwner', 'DbAssociate', 'DbPropertyName', 'DbIndex']);
|
|
64
|
+
return db.prepare(`SELECT * FROM ${name} WHERE DbPropertyName='SubtitleTrackVec'`).all();
|
|
65
|
+
});
|
|
66
|
+
if (order.length) {
|
|
67
|
+
for (const r of rows) {
|
|
68
|
+
const links = order.filter((a) => a.DbAssociate === r.id);
|
|
69
|
+
if (links.length !== 1) throw new Error('unsupported subtitle track vector association');
|
|
70
|
+
r.order = links[0].DbIndex;
|
|
71
|
+
}
|
|
72
|
+
rows.sort((a, b) => a.timelineId.localeCompare(b.timelineId) || a.order - b.order);
|
|
73
|
+
} else if (rows.some((r, i) => rows.some((other, j) => j !== i && r.timelineId === other.timelineId))) {
|
|
74
|
+
throw new Error('multiple subtitle tracks require SubtitleTrackVec ordering');
|
|
75
|
+
}
|
|
76
|
+
const counts = new Map();
|
|
77
|
+
return rows.map((r) => {
|
|
78
|
+
const n = (counts.get(r.timelineId) || 0) + 1; counts.set(r.timelineId, n);
|
|
79
|
+
return { ...r, track: n };
|
|
80
|
+
}).filter((r) => timeline == null || r.timeline === timeline);
|
|
81
|
+
}
|
|
82
|
+
function selectedTrack(db, p) {
|
|
83
|
+
const row = tracks(db, p.timeline).find((r) => r.track === p.track);
|
|
84
|
+
if (!row) throw new Error(`no subtitle track ${p.track} on timeline "${p.timeline}"`);
|
|
85
|
+
const b = row.fpsBlob;
|
|
86
|
+
if (!b || b.length !== 16) throw new Error('unsupported timeline FrameRate encoding');
|
|
87
|
+
const fps = Buffer.from(b).readDoubleLE(0);
|
|
88
|
+
if (!Number.isFinite(fps) || fps < 1 || fps > 240) throw new Error('invalid timeline frame rate');
|
|
89
|
+
return { ...row, fps };
|
|
90
|
+
}
|
|
91
|
+
function items(db, tr, property) {
|
|
92
|
+
guard(db, 'Sm2TiItem', ['Sm2TiItem_id', 'DbType', 'Name', 'Start', 'Duration', 'FieldsBlob', 'Sm2TiTrack_id', 'CompositionTable']);
|
|
93
|
+
guard(db, 'Sm2TiItem_Sm2TiTrack', ['DbOwner', 'DbAssociate', 'DbPropertyName', 'DbIndex']);
|
|
94
|
+
const links = db.prepare('SELECT * FROM Sm2TiItem_Sm2TiTrack WHERE DbOwner=? AND DbPropertyName=? ORDER BY DbIndex').all(tr.id, property);
|
|
95
|
+
return links.map((link) => {
|
|
96
|
+
const row = db.prepare('SELECT * FROM Sm2TiItem WHERE Sm2TiItem_id=?').get(link.DbAssociate);
|
|
97
|
+
if (!row || row.Sm2TiTrack_id !== tr.id) throw new Error('dangling or inconsistent subtitle item association');
|
|
98
|
+
return { row, link };
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function captions(db, tr) {
|
|
102
|
+
return items(db, tr, 'Items').map(({ row, link }) => {
|
|
103
|
+
if (row.DbType !== 'Sm2TiGenerator' || row.PrettyType !== 'Subtitle') throw new Error('unsupported item on subtitle track');
|
|
104
|
+
const start = Number(row.Start), duration = Number(row.Duration);
|
|
105
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(duration)) throw new Error('invalid caption frame columns');
|
|
106
|
+
return { row, link, decoded: { id: row.Sm2TiItem_id,
|
|
107
|
+
...decodeCaption(row.FieldsBlob, { name: row.Name || '', start, end: start + duration, fps: tr.fps }) } };
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function preset(db, tr) {
|
|
111
|
+
const holders = items(db, tr, 'FusionCompHolderItems');
|
|
112
|
+
if (holders.length > 1) throw new Error('multiple preset holders on one track; refusing ambiguous edit');
|
|
113
|
+
if (!holders.length) return null;
|
|
114
|
+
guard(db, 'Sm2TiCompositionTable', ['Sm2TiCompositionTable_id', 'Sm2TiItem_id', 'CompositionBA']);
|
|
115
|
+
const { row, link } = holders[0];
|
|
116
|
+
if (row.DbType !== 'Sm2TiVideoClip') throw new Error('unsupported preset holder type');
|
|
117
|
+
const comp = db.prepare('SELECT * FROM Sm2TiCompositionTable WHERE Sm2TiCompositionTable_id=?').get(row.CompositionTable);
|
|
118
|
+
if (!comp || comp.Sm2TiItem_id !== row.Sm2TiItem_id) throw new Error('dangling preset composition');
|
|
119
|
+
return { row, link, comp, decoded: decodePreset(comp.CompositionBA) };
|
|
120
|
+
}
|
|
121
|
+
function insert(db, table, row) {
|
|
122
|
+
const cols = guard(db, table, Object.keys(row));
|
|
123
|
+
if (cols.length !== Object.keys(row).length) throw new Error(`source/target ${table} schema mismatch`);
|
|
124
|
+
const keys = Object.keys(row);
|
|
125
|
+
db.prepare(`INSERT INTO ${quote(table)} (${keys.map(quote).join(',')}) VALUES (${keys.map(() => '?').join(',')})`)
|
|
126
|
+
.run(...keys.map((k) => row[k]));
|
|
127
|
+
}
|
|
128
|
+
function exclusive(db, row) {
|
|
129
|
+
const links = db.prepare('SELECT * FROM Sm2TiItem_Sm2TiTrack WHERE DbAssociate=?').all(row.Sm2TiItem_id);
|
|
130
|
+
if (links.length !== 1) throw new Error('shared subtitle item; refusing to mutate');
|
|
131
|
+
for (const key of ['MediaRef', 'pLmVerTable', 'pAuxLmVerTable', 'OriginalClip', 'Sm2TiItem_Owner_id', 'TextRenderItemVec', 'Thumbnail', 'Group', 'ClipGroup']) {
|
|
132
|
+
if (row[key]) throw new Error(`unsupported subtitle dependency ${key}; refusing to mutate`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function removeItem(db, row) {
|
|
136
|
+
exclusive(db, row);
|
|
137
|
+
if (row.CompositionTable) {
|
|
138
|
+
const refs = db.prepare('SELECT Sm2TiItem_id FROM Sm2TiItem WHERE CompositionTable=?').all(row.CompositionTable);
|
|
139
|
+
const comp = db.prepare('SELECT * FROM Sm2TiCompositionTable WHERE Sm2TiCompositionTable_id=?').get(row.CompositionTable);
|
|
140
|
+
if (refs.length !== 1 || !comp || comp.Sm2MpMedia_id || comp.Sm2CompositionTableManager_id) throw new Error('shared/unsupported preset composition');
|
|
141
|
+
db.prepare('DELETE FROM Sm2TiCompositionTable WHERE Sm2TiCompositionTable_id=?').run(row.CompositionTable);
|
|
142
|
+
}
|
|
143
|
+
db.prepare('DELETE FROM Sm2TiItem_Sm2TiTrack WHERE DbAssociate=?').run(row.Sm2TiItem_id);
|
|
144
|
+
db.prepare('DELETE FROM Sm2TiItem WHERE Sm2TiItem_id=?').run(row.Sm2TiItem_id);
|
|
145
|
+
}
|
|
146
|
+
function captionEdit(db, p, mutate) {
|
|
147
|
+
const tr = selectedTrack(db, p), current = captions(db, tr);
|
|
148
|
+
const byId = new Map(current.map((c) => [c.decoded.id, c]));
|
|
149
|
+
const ids = [...p.delete, ...p.replace.map((c) => c.id)];
|
|
150
|
+
if (!p.add.length && !ids.length) throw new Error('no caption changes requested');
|
|
151
|
+
if (new Set(ids).size !== ids.length) throw new Error('duplicate/conflicting caption ids');
|
|
152
|
+
for (const id of ids) {
|
|
153
|
+
if (!byId.has(id)) throw new Error(`caption ${id} is not on the selected track`);
|
|
154
|
+
exclusive(db, byId.get(id).row);
|
|
155
|
+
}
|
|
156
|
+
const template = p.templateCaptionId ? byId.get(p.templateCaptionId) : current[0];
|
|
157
|
+
if (p.add.length && !template) throw new Error('adding captions requires an existing caption on this track as a schema template');
|
|
158
|
+
if (p.add.length) exclusive(db, template.row);
|
|
159
|
+
const staged = [];
|
|
160
|
+
for (const [fresh, list] of [[false, p.replace], [true, p.add]]) {
|
|
161
|
+
for (const c of list) {
|
|
162
|
+
if (c.text !== c.words.map((w) => w.text).join(' ') || c.words.some((w) => w.text !== w.text.trim())) {
|
|
163
|
+
throw new Error('caption text must equal the words joined with spaces; words must be trimmed');
|
|
164
|
+
}
|
|
165
|
+
const base = fresh ? template : byId.get(c.id);
|
|
166
|
+
if (base.row.CompositionTable) throw new Error('per-caption Fusion compositions are not supported');
|
|
167
|
+
const blob = encodeCaption(base.row.FieldsBlob, c, tr.fps, { fresh });
|
|
168
|
+
const id = fresh ? randomUUID() : c.id;
|
|
169
|
+
const decoded = { id, ...decodeCaption(blob, { name: c.text, start: c.start, end: c.end, fps: tr.fps }) };
|
|
170
|
+
const qc = checkCaptions([decoded], { fps: tr.fps });
|
|
171
|
+
// Tick quantisation can otherwise turn a positive input word into a blank.
|
|
172
|
+
// Half-tick boundary rounding is accepted; actual internal gaps are not.
|
|
173
|
+
const errors = qc.issues.filter((r) => r.severity !== 'warning');
|
|
174
|
+
if (errors.length) throw new Error(`invalid caption timing: ${JSON.stringify(errors)}`);
|
|
175
|
+
const row = { ...base.row, Sm2TiItem_id: id, Name: ` ${c.text}`, Start: String(c.start),
|
|
176
|
+
Duration: String(c.end - c.start), FieldsBlob: blob };
|
|
177
|
+
if (fresh) for (const key of ['MarkersBA', 'RenderCacheBA', 'ImportExportMetadataBA']) if (key in row) row[key] = null;
|
|
178
|
+
staged.push({ fresh, row, decoded });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const removed = new Set(ids);
|
|
182
|
+
const final = [...current.filter((c) => !removed.has(c.decoded.id)).map((c) => c.decoded), ...staged.map((c) => c.decoded)]
|
|
183
|
+
.sort((a, b) => a.start - b.start || a.id.localeCompare(b.id));
|
|
184
|
+
for (let i = 1; i < final.length; i++) if (final[i].start < final[i - 1].end) throw new Error('caption edits would leave overlapping captions');
|
|
185
|
+
if (mutate) {
|
|
186
|
+
for (const id of p.delete) removeItem(db, byId.get(id).row);
|
|
187
|
+
for (const c of staged) {
|
|
188
|
+
if (c.fresh) {
|
|
189
|
+
insert(db, 'Sm2TiItem', c.row);
|
|
190
|
+
} else db.prepare('UPDATE Sm2TiItem SET Name=?,Start=?,Duration=?,FieldsBlob=? WHERE Sm2TiItem_id=?')
|
|
191
|
+
.run(c.row.Name, c.row.Start, c.row.Duration, c.row.FieldsBlob, c.row.Sm2TiItem_id);
|
|
192
|
+
}
|
|
193
|
+
// Resolve enforces DbIndex>=0, and its (owner,property,index) primary key
|
|
194
|
+
// is ON CONFLICT REPLACE. Updating indices in place can silently DELETE a
|
|
195
|
+
// neighbour when captions swap order. Rebuild only this Items vector in the
|
|
196
|
+
// transaction, preserving each association's remaining columns.
|
|
197
|
+
db.prepare("DELETE FROM Sm2TiItem_Sm2TiTrack WHERE DbOwner=? AND DbPropertyName='Items'").run(tr.id);
|
|
198
|
+
final.forEach((c, i) => insert(db, 'Sm2TiItem_Sm2TiTrack', {
|
|
199
|
+
...(byId.get(c.id)?.link || template.link), DbOwner: tr.id, DbAssociate: c.id, DbPropertyName: 'Items', DbIndex: i,
|
|
200
|
+
}));
|
|
201
|
+
const back = captions(db, tr);
|
|
202
|
+
if (JSON.stringify(back.map((c) => c.decoded)) !== JSON.stringify(final) || back.some((c, i) => c.link.DbIndex !== i)) {
|
|
203
|
+
throw new Error('caption readback mismatch; transaction rolled back');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { timeline: p.timeline, track: p.track, fps: tr.fps, added: staged.filter((c) => c.fresh).map((c) => c.decoded.id),
|
|
207
|
+
replaced: p.replace.map((c) => c.id), deleted: p.delete, captions: final, check: checkCaptions(final, { fps: tr.fps }) };
|
|
208
|
+
}
|
|
209
|
+
function presetEdit(db, p, mutate, source) {
|
|
210
|
+
const tr = selectedTrack(db, p), before = preset(db, tr);
|
|
211
|
+
if (source && before && !p.replace) throw new Error('target already has a preset; use replace:true');
|
|
212
|
+
if (!source && !before) throw new Error('track has no animation preset; copy_subtitle_preset first');
|
|
213
|
+
const base = source || before;
|
|
214
|
+
if (before) exclusive(db, before.row);
|
|
215
|
+
if (!source && !Object.keys(p.inputs).length) throw new Error('no preset input changes requested');
|
|
216
|
+
const blob = p.inputs && Object.keys(p.inputs).length ? setPresetInputs(base.comp.CompositionBA, p.inputs) : Buffer.from(base.comp.CompositionBA);
|
|
217
|
+
const after = decodePreset(blob);
|
|
218
|
+
if (mutate) {
|
|
219
|
+
if (source) {
|
|
220
|
+
if (before) removeItem(db, before.row);
|
|
221
|
+
const itemId = randomUUID(), compId = randomUUID();
|
|
222
|
+
const row = { ...base.row, Sm2TiItem_id: itemId, Sm2TiTrack_id: tr.id, CompositionTable: compId };
|
|
223
|
+
if (row.Track) row.Track = tr.id;
|
|
224
|
+
const comp = { ...base.comp, Sm2TiCompositionTable_id: compId, Sm2TiItem_id: itemId, CompositionBA: blob };
|
|
225
|
+
insert(db, 'Sm2TiItem', row);
|
|
226
|
+
insert(db, 'Sm2TiCompositionTable', comp);
|
|
227
|
+
insert(db, 'Sm2TiItem_Sm2TiTrack', { ...base.link, DbOwner: tr.id, DbAssociate: itemId, DbIndex: 0 });
|
|
228
|
+
} else db.prepare('UPDATE Sm2TiCompositionTable SET CompositionBA=? WHERE Sm2TiCompositionTable_id=?')
|
|
229
|
+
.run(blob, before.comp.Sm2TiCompositionTable_id);
|
|
230
|
+
const back = preset(db, tr);
|
|
231
|
+
if (!Buffer.from(back.comp.CompositionBA).equals(blob) || JSON.stringify(back.decoded) !== JSON.stringify(after)) {
|
|
232
|
+
throw new Error('preset readback mismatch; transaction rolled back');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { timeline: p.timeline, track: p.track, before: before?.decoded || null, after,
|
|
236
|
+
holderDuration: Number(base.row.Duration), note: 'Holder duration preserved from reference; render validation is required.' };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function subtitleDbAction(action, args, resolveDbPath) {
|
|
240
|
+
// Snake-case project selectors from the workflow brief are accepted aliases.
|
|
241
|
+
const aliases = { source_project: 'sourceProject', source_timeline: 'sourceTimeline',
|
|
242
|
+
target_project: 'projectName', target_timeline: 'timeline', project: 'projectName' };
|
|
243
|
+
args = { ...args };
|
|
244
|
+
for (const [a, b] of Object.entries(aliases)) if (a in args) {
|
|
245
|
+
if (b in args) throw new Error(`provide either ${a} or ${b}`);
|
|
246
|
+
args[b] = args[a]; delete args[a];
|
|
247
|
+
}
|
|
248
|
+
const p = subtitleSchemas[action].parse(args), dbPath = await resolveDbPath(p);
|
|
249
|
+
const db = openGuarded(dbPath);
|
|
250
|
+
let source;
|
|
251
|
+
try {
|
|
252
|
+
if (action === 'list_subtitle_presets') return { presets: tracks(db, p.timeline).map((tr) => {
|
|
253
|
+
const value = preset(db, tr);
|
|
254
|
+
return { timeline: tr.timeline, timelineId: tr.timelineId, track: tr.track, trackId: tr.id,
|
|
255
|
+
preset: value ? { id: value.row.Sm2TiItem_id, ...value.decoded, holderDuration: Number(value.row.Duration) } : null };
|
|
256
|
+
}) };
|
|
257
|
+
if (action === 'list_captions' || action === 'check_captions') {
|
|
258
|
+
const tr = selectedTrack(db, p), list = captions(db, tr).map((c) => c.decoded);
|
|
259
|
+
return { timeline: p.timeline, track: p.track, fps: tr.fps, timeUnit: 'timeline_frames',
|
|
260
|
+
...(action === 'list_captions' ? { captions: list } : checkCaptions(list, { fps: tr.fps, maxCharacters: p.maxCharacters })) };
|
|
261
|
+
}
|
|
262
|
+
if (action === 'copy_subtitle_preset') {
|
|
263
|
+
const sourcePath = await resolveDbPath({ projectDb: p.sourceProjectDb, projectName: p.sourceProject });
|
|
264
|
+
const src = openGuarded(sourcePath);
|
|
265
|
+
try {
|
|
266
|
+
source = preset(src, selectedTrack(src, { timeline: p.sourceTimeline, track: p.sourceTrack }));
|
|
267
|
+
if (!source) throw new Error('source subtitle track has no animation preset');
|
|
268
|
+
exclusive(src, source.row);
|
|
269
|
+
if (source.comp.Sm2MpMedia_id || source.comp.Sm2CompositionTableManager_id) throw new Error('unsupported shared source composition');
|
|
270
|
+
} finally { src.close(); }
|
|
271
|
+
}
|
|
272
|
+
const preview = action === 'write_captions' ? captionEdit(db, p, false) : presetEdit(db, p, false, source);
|
|
273
|
+
if (p.dryRun) return { ...preview, dryRun: true, backup: null, verified: false };
|
|
274
|
+
} finally { db.close(); }
|
|
275
|
+
requireResolveQuit(p);
|
|
276
|
+
const backup = await snapshotBackup(dbPath);
|
|
277
|
+
requireResolveQuit(p); // Recheck after the asynchronous snapshot.
|
|
278
|
+
const writable = openGuarded(dbPath, { writable: true });
|
|
279
|
+
try {
|
|
280
|
+
const result = writable.transaction(() => action === 'write_captions'
|
|
281
|
+
? captionEdit(writable, p, true) : presetEdit(writable, p, true, source))();
|
|
282
|
+
return { ...result, dryRun: false, backup, verified: true, validation: 'database_readback_only',
|
|
283
|
+
reopenRequired: true };
|
|
284
|
+
} finally { writable.close(); }
|
|
285
|
+
}
|
|
@@ -472,11 +472,17 @@ export const drtTool = {
|
|
|
472
472
|
// scan, subfolders are not). Inner format byte-verified against the
|
|
473
473
|
// template harvest: protobuf{field2: keyedDict{"0": binId, ...},
|
|
474
474
|
// field4: time-varint} in the [u32 2][u32 len][0x81][zstd] wrapper.
|
|
475
|
-
const { zstdRawFrame } = requireCjs('../../vendor/drp-format/timeline-markers-blob.js');
|
|
475
|
+
const { zstdRawFrame, lenDelim } = requireCjs('../../vendor/drp-format/timeline-markers-blob.js');
|
|
476
476
|
const binIds = [...bins.values()].map((b) => b.id);
|
|
477
477
|
const childDict = encodeKeyedDict({ hdr: 1, entries: binIds.map((id, i) => ({ key: String(i), type: 0x0a, subType: 0, value: id })) });
|
|
478
|
+
// field 2's length is a protobuf VARINT, not one raw byte: one bin's
|
|
479
|
+
// dict is 95 bytes and fits, two bins' is 182 and does not, so
|
|
480
|
+
// `[0x12, 182]` emitted a 0xb6 continuation byte that swallowed the
|
|
481
|
+
// dict's first byte and read as length 54 — the registry parsed back
|
|
482
|
+
// empty with neither bin in it. lenDelim() writes the same single byte
|
|
483
|
+
// for the one-bin case, so that blob is unchanged.
|
|
478
484
|
const inner = Buffer.concat([
|
|
479
|
-
|
|
485
|
+
lenDelim(2, childDict),
|
|
480
486
|
Buffer.from([0x20]), Buffer.from('b6cba6a90d', 'hex'),
|
|
481
487
|
]);
|
|
482
488
|
const frame = zstdRawFrame(inner);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Closes documented scripting-API gaps by direct SQLite UPDATE. Every write is gated:
|
|
5
5
|
* project must be CLOSED + auto-backup + schema guard + read-back verify (db-patch.mjs).
|
|
6
|
-
*
|
|
6
|
+
* Uses better-sqlite3 or the node:sqlite fallback.
|
|
7
7
|
*
|
|
8
8
|
* list_folders — read media-pool folders (name + color + lock)
|
|
9
9
|
* rename_folder — Sm2MpFolder.Name (API has NO RenameSubFolder)
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { z } from 'zod';
|
|
21
|
+
import { subtitleDbAction, subtitleSchemas } from '../subtitle-db.mjs';
|
|
21
22
|
import { createRequire } from 'node:module';
|
|
22
23
|
import { resolveDbPath as _resolveDbPathRaw, responsiveRoots, openGuarded, backup, requireClosed } from '../db-patch.mjs';
|
|
23
24
|
|
|
@@ -104,8 +105,16 @@ function selectOne(db, table, col, value) {
|
|
|
104
105
|
export const projectDbTool = {
|
|
105
106
|
name: 'project_db',
|
|
106
107
|
description:
|
|
107
|
-
'Beyond-the-API
|
|
108
|
+
'Beyond-the-API Project.db patches: list_folders, rename_folder, set_folder_color, list_clips, set_clip_marks, relayout_node_graphs, list_subtitle_styles, set_subtitle_style (basic track font/position, NOT animation presets). ' +
|
|
109
|
+
'Subtitle actions: list_subtitle_presets(projectName|projectDb, timeline?) reads linked Fusion animation presets; ' +
|
|
110
|
+
'copy_subtitle_preset(sourceProject|sourceProjectDb, sourceTimeline, sourceTrack?, projectName|projectDb, timeline, track?, replace?, inputs?); ' +
|
|
111
|
+
'set_subtitle_preset(projectName|projectDb, timeline, track?, inputs) edits Word Highlight controls: font, fontStyle, size, position:[x,y], textRed/Green/Blue/Alpha, highlightRed/Green/Blue, outlineRed/Green/Blue, outlineEnabled:0|1, thickness; RGB 0..1. ' +
|
|
112
|
+
'list_captions(projectName|projectDb, timeline, track?) returns text, start/end and words plus originalWords; ' +
|
|
113
|
+
'write_captions(same selectors, add:[{text,start,end,words:[{text,start,end}]}]?, replace:[{id,text,start,end,words}]?, delete:[id]?, templateCaptionId?) edits/adds/deletes in one transaction; all times are absolute timeline frames, end exclusive, caption frames integers, word frames may be fractional; ' +
|
|
114
|
+
'check_captions(same selectors, maxCharacters?:24) flags timing gaps/zero lengths/overflow heuristics. Subtitle track defaults to 1. ' +
|
|
115
|
+
'New subtitle writes require Resolve fully QUIT (checked through the process list) and iConfirmProjectClosed:true; dryRun:true previews without writing. Unique SQLite snapshot backup, schema guards, atomic transaction and readback. Readback is not render verification. Uses better-sqlite3 or node:sqlite (Node 22.16+ / 23.8+); automatic Windows/macOS/Linux library discovery.',
|
|
108
116
|
async handler({ action, args }) {
|
|
117
|
+
if (Object.hasOwn(subtitleSchemas, action)) return subtitleDbAction(action, args, resolveDbPath);
|
|
109
118
|
if (action === 'list_folders') {
|
|
110
119
|
const p = listFoldersSchema.parse(args);
|
|
111
120
|
const db = openGuarded(await resolveDbPath(p), { table: 'Sm2MpFolder', column: 'Name' });
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** Resolve 21 caption FieldsBlob codec. Times exposed in timeline frames;
|
|
2
|
+
* f15/16 and f19/20 store 60 ticks/second; f21 is the original frame anchor.
|
|
3
|
+
* Unknown protobuf fields survive byte-for-byte. No source media is touched.
|
|
4
|
+
*/
|
|
5
|
+
const { unwrapEffectFilters, wrapEffectFilters } = require('./subtitle-style.js');
|
|
6
|
+
|
|
7
|
+
function varint(value) {
|
|
8
|
+
let n = BigInt(value);
|
|
9
|
+
if (n < 0n) throw new Error('negative protobuf integer');
|
|
10
|
+
const bytes = [];
|
|
11
|
+
do { bytes.push(Number(n & 127n) | (n > 127n ? 128 : 0)); n >>= 7n; } while (n);
|
|
12
|
+
return Buffer.from(bytes);
|
|
13
|
+
}
|
|
14
|
+
function parse(buf) {
|
|
15
|
+
let p = 0;
|
|
16
|
+
const read = () => {
|
|
17
|
+
let n = 0n;
|
|
18
|
+
for (let i = 0; i < 10 && p < buf.length; i++) {
|
|
19
|
+
const b = buf[p++];
|
|
20
|
+
if (i === 9 && b > 1) throw new Error('protobuf integer overflow');
|
|
21
|
+
n |= BigInt(b & 127) << BigInt(i * 7);
|
|
22
|
+
if (!(b & 128)) return n;
|
|
23
|
+
}
|
|
24
|
+
throw new Error('truncated protobuf integer');
|
|
25
|
+
};
|
|
26
|
+
const out = [];
|
|
27
|
+
while (p < buf.length) {
|
|
28
|
+
const start = p, tag = Number(read()), field = Math.floor(tag / 8), wire = tag % 8;
|
|
29
|
+
if (!Number.isSafeInteger(tag) || !field || field > 536870911) throw new Error('invalid protobuf tag');
|
|
30
|
+
let value, bytes;
|
|
31
|
+
if (wire === 0) value = read();
|
|
32
|
+
else {
|
|
33
|
+
const size = wire === 2 ? Number(read()) : wire === 1 ? 8 : wire === 5 ? 4 : -1;
|
|
34
|
+
if (!Number.isSafeInteger(size) || size < 0 || size > buf.length - p) throw new Error('truncated/unsupported protobuf field');
|
|
35
|
+
bytes = buf.subarray(p, p + size); p += size;
|
|
36
|
+
}
|
|
37
|
+
out.push({ field, wire, value, bytes, raw: buf.subarray(start, p) });
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
function field(id, value) {
|
|
42
|
+
return typeof value === 'number'
|
|
43
|
+
? Buffer.concat([varint(id * 8), varint(value)])
|
|
44
|
+
: Buffer.concat([varint(id * 8 + 2), varint(value.length), value]);
|
|
45
|
+
}
|
|
46
|
+
function safeNumber(n) {
|
|
47
|
+
const v = Number(n);
|
|
48
|
+
if (!Number.isSafeInteger(v) || v < 0) throw new Error('caption integer outside safe range');
|
|
49
|
+
return v;
|
|
50
|
+
}
|
|
51
|
+
function message(blob) {
|
|
52
|
+
const env = unwrapEffectFilters(Buffer.from(blob));
|
|
53
|
+
if (env.version !== 2) throw new Error('unsupported caption FieldsBlob version');
|
|
54
|
+
const outer = parse(env.protobuf), entries = outer.filter((r) => r.field === 1 && r.wire === 2);
|
|
55
|
+
if (entries.length !== 1) throw new Error('expected exactly one caption message');
|
|
56
|
+
return { outer, entry: entries[0], inner: parse(entries[0].bytes) };
|
|
57
|
+
}
|
|
58
|
+
function decodeCaption(blob, { name = '', start, end, fps }) {
|
|
59
|
+
if (!Number.isFinite(fps) || fps <= 0) throw new Error('positive timeline fps required');
|
|
60
|
+
if (!blob || !blob.length) return { text: name.trimStart(), start, end, words: [], originalWords: [], anchor: null };
|
|
61
|
+
const { inner } = message(blob);
|
|
62
|
+
const anchors = inner.filter((r) => r.field === 21 && r.wire === 0);
|
|
63
|
+
if (anchors.length > 1) throw new Error('multiple caption anchors');
|
|
64
|
+
const anchor = anchors.length ? safeNumber(anchors[0].value) : start;
|
|
65
|
+
const readWords = (textField, startField, endField) => {
|
|
66
|
+
const strings = inner.filter((r) => r.field === textField && r.wire === 2).map((r) => r.bytes.toString('utf8'));
|
|
67
|
+
const times = (id) => inner.filter((r) => r.field === id).flatMap((r) => {
|
|
68
|
+
if (r.wire === 0) return [safeNumber(r.value)];
|
|
69
|
+
throw new Error(`unsupported caption timing wire type for field ${id}`);
|
|
70
|
+
});
|
|
71
|
+
const starts = times(startField), ends = times(endField);
|
|
72
|
+
return { strings, starts, ends, words: strings.map((text, i) => ({ text: text.trimStart(),
|
|
73
|
+
start: starts[i] == null ? null : starts[i] * fps / 60 + start - anchor,
|
|
74
|
+
end: ends[i] == null ? null : ends[i] * fps / 60 + start - anchor })) };
|
|
75
|
+
};
|
|
76
|
+
const current = readWords(14, 15, 16), original = readWords(18, 19, 20);
|
|
77
|
+
const issues = [];
|
|
78
|
+
for (const [label, data] of [['current', current], ['original', original]]) {
|
|
79
|
+
if (data.strings.length !== data.starts.length || data.strings.length !== data.ends.length) issues.push(`${label}_word_count_mismatch`);
|
|
80
|
+
}
|
|
81
|
+
return { text: current.strings.length ? current.strings.join('').trimStart() : name.trimStart(),
|
|
82
|
+
start, end, words: current.words, originalWords: original.words, anchor, decodeIssues: issues };
|
|
83
|
+
}
|
|
84
|
+
function encodeCaption(blob, caption, fps, { fresh = false } = {}) {
|
|
85
|
+
const { outer, entry, inner } = message(blob);
|
|
86
|
+
const replaced = new Set([14, 15, 16, 21, ...(fresh ? [18, 19, 20] : [])]);
|
|
87
|
+
const parts = inner.filter((r) => !replaced.has(r.field)).map((r) => r.raw);
|
|
88
|
+
for (const w of caption.words) parts.push(field(14, Buffer.from(` ${w.text}`, 'utf8')));
|
|
89
|
+
for (const w of caption.words) parts.push(field(15, Math.round(w.start * 60 / fps)));
|
|
90
|
+
for (const w of caption.words) parts.push(field(16, Math.round(w.end * 60 / fps)));
|
|
91
|
+
parts.push(field(21, caption.start));
|
|
92
|
+
return wrapEffectFilters(Buffer.concat(outer.map((r) => r === entry ? field(1, Buffer.concat(parts)) : r.raw)));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function checkCaptions(captions, { maxCharacters = 24, fps = 30 } = {}) {
|
|
96
|
+
const issues = [], epsilon = 1e-6;
|
|
97
|
+
const sorted = [...captions].sort((a, b) => a.start - b.start);
|
|
98
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
99
|
+
const c = sorted[i], add = (code, extra = {}) => issues.push({ id: c.id, code, ...extra });
|
|
100
|
+
const boundaryTolerance = fps / 120 + epsilon; // nearest 60 Hz tick
|
|
101
|
+
if (!(c.end > c.start)) add('invalid_caption_duration');
|
|
102
|
+
if (i && c.start < sorted[i - 1].end) add('caption_overlap', { previousId: sorted[i - 1].id });
|
|
103
|
+
const length = Math.max(...c.text.split(/\r?\n/).map((line) => [...line].length));
|
|
104
|
+
if (length > maxCharacters) add('overflow_length', { characters: length, maxCharacters, severity: 'warning' });
|
|
105
|
+
for (const code of c.decodeIssues || []) add(code);
|
|
106
|
+
if (!c.words.length) add('missing_word_timings');
|
|
107
|
+
let cursor = c.start;
|
|
108
|
+
c.words.forEach((w, word) => {
|
|
109
|
+
if (!Number.isFinite(w.start) || !Number.isFinite(w.end)) { add('missing_word_timing', { word }); return; }
|
|
110
|
+
if (w.end <= w.start) add('zero_or_negative_word_duration', { word });
|
|
111
|
+
const tolerance = word === 0 ? boundaryTolerance : epsilon;
|
|
112
|
+
if (w.start > cursor + tolerance) add('word_gap', { word, start: cursor, end: w.start });
|
|
113
|
+
if (w.start < cursor - tolerance) add('word_overlap', { word });
|
|
114
|
+
if (w.start < c.start - boundaryTolerance || w.end > c.end + boundaryTolerance) add('word_outside_caption', { word });
|
|
115
|
+
cursor = Math.max(cursor, w.end);
|
|
116
|
+
});
|
|
117
|
+
if (c.words.length && cursor < c.end - boundaryTolerance) add('word_gap', { start: cursor, end: c.end });
|
|
118
|
+
}
|
|
119
|
+
return { ok: issues.length === 0, issues, fps, maxCharacters,
|
|
120
|
+
note: 'Length is a heuristic, not a pixel-boundary or render check. Review burned-in frames.' };
|
|
121
|
+
}
|
|
122
|
+
module.exports = { decodeCaption, encodeCaption, checkCaptions, parse, field };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/** Word Highlight's nested Fusion composition. Patch literal inputs only;
|
|
2
|
+
* never execute Lua or discard the surrounding keyed envelope/tool graph. */
|
|
3
|
+
const zlib = require('node:zlib');
|
|
4
|
+
const TEMPLATE = 'Templates/Edit/Titles/Subtitles/Animated/Word Highlight';
|
|
5
|
+
const INPUTS = {
|
|
6
|
+
font: 'Font', fontStyle: 'Style', size: 'Size', position: 'Center',
|
|
7
|
+
// The macro exposes Clone controls, but TextPlus renders shading element 1
|
|
8
|
+
// using these persisted inputs. Writing the clones alone leaves its fill
|
|
9
|
+
// at the template's default pale yellow (Green1=.92, Blue1=.52).
|
|
10
|
+
textRed: 'Red1', textGreen: 'Green1', textBlue: 'Blue1', textAlpha: 'Alpha1',
|
|
11
|
+
highlightRed: 'HighlightColorRed', highlightGreen: 'HighlightColorGreen', highlightBlue: 'HighlightColorBlue',
|
|
12
|
+
outlineRed: 'HOutlineR', outlineGreen: 'HOutlineG', outlineBlue: 'HOutlineB',
|
|
13
|
+
outlineEnabled: 'HOutline', thickness: 'HOutlineThickness',
|
|
14
|
+
};
|
|
15
|
+
function unpack(blob) {
|
|
16
|
+
const b = Buffer.from(blob);
|
|
17
|
+
const outer = zlib.inflateSync(b.subarray(4), { maxOutputLength: 32 * 1024 * 1024 });
|
|
18
|
+
if (b.readUInt32BE(0) !== outer.length) throw new Error('CompositionBA outer length mismatch');
|
|
19
|
+
const dataStart = outer.indexOf('Composition {');
|
|
20
|
+
if (dataStart < 4 || outer.readUInt32BE(dataStart - 4) !== outer.length - dataStart) {
|
|
21
|
+
throw new Error('unsupported CompositionBA keyed envelope (expected one composition)');
|
|
22
|
+
}
|
|
23
|
+
const marker = outer.indexOf('Compressed = true, }', dataStart);
|
|
24
|
+
if (marker < 0) throw new Error('unsupported uncompressed Fusion composition');
|
|
25
|
+
const lenOffset = outer.indexOf(0, marker) + 1;
|
|
26
|
+
if (lenOffset <= marker || lenOffset + 4 >= outer.length) throw new Error('invalid nested composition delimiter');
|
|
27
|
+
const inner = zlib.inflateSync(outer.subarray(lenOffset + 4), { maxOutputLength: 32 * 1024 * 1024, info: true });
|
|
28
|
+
if (inner.engine.bytesWritten !== outer.length - lenOffset - 4 || inner.buffer.length !== outer.readUInt32LE(lenOffset)) {
|
|
29
|
+
throw new Error('CompositionBA inner length/trailing bytes mismatch');
|
|
30
|
+
}
|
|
31
|
+
const header = outer.subarray(dataStart, lenOffset - 1).toString('utf8');
|
|
32
|
+
const ids = [...header.matchAll(/TEMPLATE_ID\s*=\s*"([^"\r\n]+)"/g)];
|
|
33
|
+
if (ids.length !== 1) throw new Error('composition must have one TEMPLATE_ID');
|
|
34
|
+
return { outer, dataStart, lenOffset, text: inner.buffer.toString('utf8'), templateId: ids[0][1] };
|
|
35
|
+
}
|
|
36
|
+
// Mask strings/comments while retaining offsets, so braces and input-like text
|
|
37
|
+
// inside strings cannot change the target table.
|
|
38
|
+
function mask(text) {
|
|
39
|
+
if (/\[(?:=*)\[/.test(text)) throw new Error('unsupported Lua long string in preset');
|
|
40
|
+
return text.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|--[^\r\n]*/g, (s) => ' '.repeat(s.length));
|
|
41
|
+
}
|
|
42
|
+
function table(text, pattern) {
|
|
43
|
+
const masked = mask(text), matches = [...masked.matchAll(pattern)];
|
|
44
|
+
if (matches.length !== 1) throw new Error('unsupported/ambiguous Word Highlight tool layout');
|
|
45
|
+
const open = masked.indexOf('{', matches[0].index);
|
|
46
|
+
let depth = 1, close = open + 1;
|
|
47
|
+
for (; close < masked.length && depth; close++) {
|
|
48
|
+
if (masked[close] === '{') depth++;
|
|
49
|
+
if (masked[close] === '}') depth--;
|
|
50
|
+
}
|
|
51
|
+
if (depth) throw new Error('unbalanced Fusion table');
|
|
52
|
+
return { start: open + 1, end: close - 1 };
|
|
53
|
+
}
|
|
54
|
+
function inputTable(text) {
|
|
55
|
+
const tool = table(text, /\bTemplate\s*=\s*TextPlus\s*\{/g);
|
|
56
|
+
const inputs = table(text.slice(tool.start, tool.end), /\bInputs\s*=\s*\{/g);
|
|
57
|
+
return { start: tool.start + inputs.start, end: tool.start + inputs.end };
|
|
58
|
+
}
|
|
59
|
+
function literal(value) {
|
|
60
|
+
if (typeof value === 'string') return JSON.stringify(value).replace(/\u2028/g, '\\n').replace(/\u2029/g, '\\n');
|
|
61
|
+
if (Array.isArray(value) && value.length === 2 && value.every(Number.isFinite)) return `{ ${value.join(', ')} }`;
|
|
62
|
+
if (Number.isFinite(value)) return String(value);
|
|
63
|
+
throw new Error('preset input must be a finite number, string, or position pair');
|
|
64
|
+
}
|
|
65
|
+
function parseLiteral(value) {
|
|
66
|
+
const s = value.trim();
|
|
67
|
+
if (/^"(?:[^"\\]|\\.)*"$/.test(s)) return JSON.parse(s);
|
|
68
|
+
if (/^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(s)) return Number(s);
|
|
69
|
+
const vector = /^\{\s*([^,]+),\s*([^,}]+),?\s*\}$/.exec(s);
|
|
70
|
+
if (vector && [Number(vector[1]), Number(vector[2])].every(Number.isFinite)) return [Number(vector[1]), Number(vector[2])];
|
|
71
|
+
throw new Error('input is not a supported literal');
|
|
72
|
+
}
|
|
73
|
+
function inputRange(text, name) {
|
|
74
|
+
const matches = [...mask(text).matchAll(new RegExp(`\\b${name}\\s*=\\s*Input\\s*\\{`, 'g'))];
|
|
75
|
+
if (!matches.length) return null;
|
|
76
|
+
if (matches.length !== 1) throw new Error(`ambiguous preset input ${name}`);
|
|
77
|
+
return { ...table(text, new RegExp(`\\b${name}\\s*=\\s*Input\\s*\\{`, 'g')), keyStart: matches[0].index };
|
|
78
|
+
}
|
|
79
|
+
function decodePreset(blob) {
|
|
80
|
+
const u = unpack(blob), values = {};
|
|
81
|
+
if (u.templateId === TEMPLATE) {
|
|
82
|
+
const range = inputTable(u.text), inputs = u.text.slice(range.start, range.end);
|
|
83
|
+
for (const [key, name] of Object.entries(INPUTS)) {
|
|
84
|
+
const r = inputRange(inputs, name);
|
|
85
|
+
if (!r) continue; // Missing inputs use Resolve's defaults; do not invent values.
|
|
86
|
+
const body = inputs.slice(r.start, r.end), value = /^\s*Value\s*=\s*([\s\S]*?),?\s*$/.exec(body);
|
|
87
|
+
if (value) { try { values[key] = parseLiteral(value[1].replace(/,\s*$/, '')); } catch { /* animated/expression */ } }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { templateId: u.templateId, inputs: values, parameterEditingSupported: u.templateId === TEMPLATE };
|
|
91
|
+
}
|
|
92
|
+
function setPresetInputs(blob, changes) {
|
|
93
|
+
const u = unpack(blob);
|
|
94
|
+
if (u.templateId !== TEMPLATE) throw new Error('parameter edits currently support Word Highlight only; clone other presets unchanged');
|
|
95
|
+
for (const key of Object.keys(changes)) if (!INPUTS[key]) throw new Error(`unknown preset input ${key}`);
|
|
96
|
+
const current = decodePreset(blob).inputs;
|
|
97
|
+
if (Object.entries(changes).every(([k, v]) => JSON.stringify(current[k]) === JSON.stringify(v))) return Buffer.from(blob);
|
|
98
|
+
const range = inputTable(u.text);
|
|
99
|
+
let inputs = u.text.slice(range.start, range.end);
|
|
100
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
101
|
+
const name = INPUTS[key];
|
|
102
|
+
if (!name) throw new Error(`unknown preset input ${key}`);
|
|
103
|
+
const r = inputRange(inputs, name), replacement = `${name} = Input { Value = ${literal(value)}, }, `;
|
|
104
|
+
if (r) {
|
|
105
|
+
const body = inputs.slice(r.start, r.end);
|
|
106
|
+
if (!/^\s*Value\s*=/.test(body) || /\b(?:Expression|SourceOp|Source)\s*=/.test(mask(body))) {
|
|
107
|
+
throw new Error(`refusing to replace connected/animated input ${name}`);
|
|
108
|
+
}
|
|
109
|
+
// Validate the entire body before replacing; preserve unusual inputs by refusing.
|
|
110
|
+
parseLiteral(body.replace(/^\s*Value\s*=\s*/, '').replace(/,\s*$/, ''));
|
|
111
|
+
const tail = inputs.slice(r.end + 1).replace(/^\s*,\s*/, '');
|
|
112
|
+
inputs = inputs.slice(0, r.keyStart) + replacement + tail;
|
|
113
|
+
} else {
|
|
114
|
+
// Resolve can omit the separator after its last input. Prepending a
|
|
115
|
+
// comma-terminated field preserves that valid Lua table unchanged.
|
|
116
|
+
inputs = ` ${replacement}${inputs}`;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const inner = Buffer.from(u.text.slice(0, range.start) + inputs + u.text.slice(range.end), 'utf8');
|
|
120
|
+
const len = Buffer.alloc(4); len.writeUInt32LE(inner.length);
|
|
121
|
+
const outer = Buffer.concat([u.outer.subarray(0, u.lenOffset), len, zlib.deflateSync(inner, { level: 9 })]);
|
|
122
|
+
outer.writeUInt32BE(outer.length - u.dataStart, u.dataStart - 4);
|
|
123
|
+
const outerLen = Buffer.alloc(4); outerLen.writeUInt32BE(outer.length);
|
|
124
|
+
const result = Buffer.concat([outerLen, zlib.deflateSync(outer)]);
|
|
125
|
+
const back = decodePreset(result);
|
|
126
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
127
|
+
if (JSON.stringify(back.inputs[key]) !== JSON.stringify(value)) throw new Error(`preset readback mismatch: ${key}`);
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
module.exports = { decodePreset, setPresetInputs, TEMPLATE, INPUTS };
|
|
@@ -154,7 +154,11 @@ function unwrapEffectFilters(raw) {
|
|
|
154
154
|
if (!payload.slice(0, 4).equals(ZSTD_MAGIC)) {
|
|
155
155
|
throw new Error('EffectFiltersBA 0x81 payload is not a zstd frame');
|
|
156
156
|
}
|
|
157
|
-
|
|
157
|
+
const zlib = require('node:zlib');
|
|
158
|
+
if (typeof zlib.zstdDecompressSync === 'function') {
|
|
159
|
+
return { version, marker, protobuf: zlib.zstdDecompressSync(payload) };
|
|
160
|
+
}
|
|
161
|
+
if (!fzstd) throw new Error("zstd decoding needs fzstd or Node with zlib.zstdDecompressSync");
|
|
158
162
|
return { version, marker, protobuf: Buffer.from(fzstd.decompress(new Uint8Array(payload))) };
|
|
159
163
|
}
|
|
160
164
|
throw new Error(`unknown EffectFiltersBA payload marker 0x${marker.toString(16)}`);
|
|
@@ -156,4 +156,4 @@ function decodeTimelineMarkersBlob(buf) {
|
|
|
156
156
|
return markers;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
module.exports = { encodeTimelineMarkersBlob, decodeTimelineMarkersBlob, MARKER_COLOR_BITS, zstdRawFrame };
|
|
159
|
+
module.exports = { encodeTimelineMarkersBlob, decodeTimelineMarkersBlob, MARKER_COLOR_BITS, zstdRawFrame, lenDelim };
|
package/src/granular/common.py
CHANGED
|
@@ -91,7 +91,7 @@ if not logging.getLogger().handlers:
|
|
|
91
91
|
handlers=[logging.StreamHandler()],
|
|
92
92
|
)
|
|
93
93
|
|
|
94
|
-
VERSION = "4.
|
|
94
|
+
VERSION = "4.10.0"
|
|
95
95
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
96
96
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
97
97
|
logger.info(f"Detected platform: {get_platform()}")
|
|
@@ -1390,6 +1390,9 @@ def ti_set_property(property_name: str, property_value: Any, item_index: int = 0
|
|
|
1390
1390
|
track_type: 'video' or 'audio'. Default: 'video'.
|
|
1391
1391
|
track_index: 1-based track index. Default: 1.
|
|
1392
1392
|
"""
|
|
1393
|
+
if track_type == "subtitle":
|
|
1394
|
+
return {"success": False, "error": "SetProperty is unsupported on subtitle items. "
|
|
1395
|
+
"Use advanced project_db write_captions / set_subtitle_preset with Resolve fully quit."}
|
|
1393
1396
|
item, err = _get_timeline_item(track_type, track_index, item_index)
|
|
1394
1397
|
if err:
|
|
1395
1398
|
return err
|