davinci-resolve-mcp 2.80.2 → 2.82.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 +122 -0
- package/README.md +1 -1
- package/docs/README.md +9 -1
- package/docs/reference/api-limitations.md +20 -4
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/README.md +3 -0
- package/resolve-advanced/server/tools/project_db.mjs +129 -1
- package/resolve-advanced/vendor/drp-format/subtitle-style.js +416 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +95 -4
- package/src/utils/api_truth.py +84 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,128 @@
|
|
|
2
2
|
|
|
3
3
|
Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
|
|
4
4
|
|
|
5
|
+
## What's New in v2.82.0
|
|
6
|
+
|
|
7
|
+
Caption styling, which the scripting API cannot touch at all, is now readable
|
|
8
|
+
and writable — plus a correction to a claim this repo was about to send to
|
|
9
|
+
Blackmagic.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`project_db list_subtitle_styles` / `set_subtitle_style`** — read and patch
|
|
14
|
+
the caption style on a subtitle track: font family, point size, weight,
|
|
15
|
+
italic, and normalised on-screen position. The scripting API exposes none of
|
|
16
|
+
this (subtitle `TimelineItem`s return only the 21 transform/composite
|
|
17
|
+
properties, and every `subtitleFontName`/`subtitlePreset`-shaped setting key
|
|
18
|
+
returns `None`), but the style is persisted in `Sm2TiTrack.FieldsBlob` for
|
|
19
|
+
`Type = 2` tracks: a keyed-dict holding an `EffectFiltersBA` payload whose
|
|
20
|
+
effect 136 carries a Qt `QFont::toString()` descriptor (param 18) and a
|
|
21
|
+
position vector (param 17). New codec at
|
|
22
|
+
`resolve-advanced/vendor/drp-format/subtitle-style.js`.
|
|
23
|
+
|
|
24
|
+
Verified live on Resolve 21 (2026-08-06): a patched track opens without error
|
|
25
|
+
and, once Resolve next re-serialises it, is written back out in Resolve's own
|
|
26
|
+
zstd form with the patched values intact — so Resolve genuinely parses the
|
|
27
|
+
write rather than passing the bytes through. Read side verified against a
|
|
28
|
+
real project carrying 12 subtitle tracks.
|
|
29
|
+
|
|
30
|
+
Caveats, all reported by the tool: this is a whole-**track** style and not
|
|
31
|
+
per-caption, the project must be CLOSED, Resolve must be fully quit and
|
|
32
|
+
relaunched afterwards, and the track must already carry a style blob — a
|
|
33
|
+
freshly added subtitle track has none until it is styled once in the UI.
|
|
34
|
+
|
|
35
|
+
Only the font descriptor and position are named. The neighbouring parameters
|
|
36
|
+
vary across real projects but have not been correlated against the UI, so
|
|
37
|
+
they round-trip untouched and are reported as opaque rather than guessed at.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- **The `api_truth` Fairlight entry claimed more was missing than actually is.**
|
|
42
|
+
It read "only voice-isolation state and channel-mapping reads are scriptable",
|
|
43
|
+
which omits `Project.ApplyFairlightPresetToCurrentTimeline(name)` — already
|
|
44
|
+
exposed as `project_settings apply_fairlight_preset`, with the names coming
|
|
45
|
+
from `resolve_control get_fairlight_presets`. The real gap is *per-parameter*
|
|
46
|
+
control (volume/pan/EQ/automation/FairlightFX), not the whole surface. Since
|
|
47
|
+
this text generates `docs/reference/api-limitations.md`, the incorrect claim
|
|
48
|
+
was headed for the Blackmagic submission. The subtitle-styling entry got the
|
|
49
|
+
same treatment: it claimed no workaround existed, which the above now closes.
|
|
50
|
+
|
|
51
|
+
### Documented
|
|
52
|
+
|
|
53
|
+
- **AI Audio Assistant has no scripting method** — logged with the reason, which
|
|
54
|
+
is *not* that it is a menu command: the API has no generic menu-invocation
|
|
55
|
+
hook, so scriptability is per-feature, and `DetectSceneCuts`, `Stabilize`,
|
|
56
|
+
`SmartReframe` and `TranscribeAudio` are all menu commands that do have
|
|
57
|
+
methods. For a repeatable mix, save the Assistant's result as a Fairlight
|
|
58
|
+
preset once and apply it per-timeline (issues #127, #128).
|
|
59
|
+
|
|
60
|
+
## What's New in v2.81.0
|
|
61
|
+
|
|
62
|
+
One render bug where every readback agreed and the file disagreed, plus the two
|
|
63
|
+
community skill contributions that were open against it.
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- **`prepare_render_job` inherited the Deliver page's loaded preset, and could
|
|
68
|
+
queue an mp4 that rendered with no video stream** (issue #123, reported with
|
|
69
|
+
a full measurement by @chenyuxiaojin). `SetRenderSettings` applies the keys a
|
|
70
|
+
caller passes *on top of* whatever render state the Deliver page is holding
|
|
71
|
+
rather than replacing it, and a loaded preset carries more state than those
|
|
72
|
+
keys. Measured 2026-07-08: after an MP3 render through the stock **Audio
|
|
73
|
+
Only** preset, a job queued with an explicit `ExportVideo: true` and an `.mp4`
|
|
74
|
+
target returned `settings_success: true` and a real `job_id`, `list_jobs`
|
|
75
|
+
reported `IsExportVideo: true`, and the rendered file held only an AAC stream.
|
|
76
|
+
The single visible tell was 18 minutes of material "rendering" in ~10 seconds.
|
|
77
|
+
|
|
78
|
+
No caller-side check could have caught it, and the reason is worse than the
|
|
79
|
+
bug itself: the scripting API documents neither `GetRenderSettings` nor
|
|
80
|
+
`GetCurrentRenderPresetName`, so the inherited state cannot be read at all.
|
|
81
|
+
Detection is unreachable; only pinning is.
|
|
82
|
+
|
|
83
|
+
### Added
|
|
84
|
+
|
|
85
|
+
- **`from_preset` on `prepare_render_job`** (and through
|
|
86
|
+
`prepare_delivery_job`) runs `LoadRenderPreset` before the explicit settings
|
|
87
|
+
go on top, so a caller pins the base state instead of inheriting one.
|
|
88
|
+
`PresetName` flipping to `Custom` once the explicit settings land is expected.
|
|
89
|
+
The name is validated against `GetRenderPresetList` first, because
|
|
90
|
+
`LoadRenderPreset` refuses an unknown name with a bare `False` that is
|
|
91
|
+
indistinguishable from any other refusal — and a `False` of either kind now
|
|
92
|
+
refuses to queue rather than falling through to an inheriting render.
|
|
93
|
+
- **An inherited-state warning** when a job asks for `ExportVideo: true` without
|
|
94
|
+
a pin, naming the risk and saying plainly that the job readback is not a
|
|
95
|
+
witness for the rendered file — verify a `codec_type=video` stream before
|
|
96
|
+
reporting a deliverable. The `before` snapshot now also reports
|
|
97
|
+
`settings_readable: false` and what is unreadable, instead of leaving the gap
|
|
98
|
+
unnamed.
|
|
99
|
+
- **`resolve-tighten-recording` skill** (PR #126, @chenyuxiaojin) — the
|
|
100
|
+
subtractive counterpart to `resolve-rough-cut`: one long single-take recording
|
|
101
|
+
in, a tightened variant timeline out, original untouched. Measured live
|
|
102
|
+
against Studio 21.0.1.11 on a real 28.5-minute recording. Its centerpiece is
|
|
103
|
+
the coordinate-system trap between plan `keep_ranges` (source frames,
|
|
104
|
+
exclusive end) and `structural_diff.added` (record frames) — feed one where
|
|
105
|
+
the other is expected and every clip lands at the wrong moment of the right
|
|
106
|
+
file, with correct cut lengths and no error. Also documents the three classes
|
|
107
|
+
of content silence-driven tightening cannot hear, including the whisper
|
|
108
|
+
swallowed-retake blind spot (issue #125).
|
|
109
|
+
|
|
110
|
+
### Changed
|
|
111
|
+
|
|
112
|
+
- **`resolve-rough-cut` reconciled with the `api_truth` ledger** (PR #115,
|
|
113
|
+
@bolnet). Two rows contradicted the ledger the skill itself points at. Import
|
|
114
|
+
order was backwards — `ImportMedia` has no destination parameter and always
|
|
115
|
+
lands in the *current* folder, so the bin must be created and made current
|
|
116
|
+
*before* importing. And the traps table still asserted that a comp attached to
|
|
117
|
+
a media clip "never renders", a blanket claim the ledger retracted on
|
|
118
|
+
2026-08-02: a comp wired `MediaIn → Blur → MediaOut` does render, and an
|
|
119
|
+
unrooted `MediaOut` fails the render job outright rather than being silently
|
|
120
|
+
bypassed. Every row now names the build it was confirmed on, and a note
|
|
121
|
+
records that a **running** MCP keeps executing the version it started with, so
|
|
122
|
+
`git pull` does not refresh the ledger until restart.
|
|
123
|
+
- The skill index in `docs/README.md` now lists the two end-to-end assembly
|
|
124
|
+
recipes (`resolve-rough-cut`, `resolve-tighten-recording`), neither of which
|
|
125
|
+
had ever appeared there, and `resolve-edit` points at the tighten skill.
|
|
126
|
+
|
|
5
127
|
## What's New in v2.80.2
|
|
6
128
|
|
|
7
129
|
Agent tooling only. Ten Claude Code skills that this repository has shipped and
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
package/docs/README.md
CHANGED
|
@@ -61,7 +61,15 @@ Each skill is a directory containing `SKILL.md`. Claude Code does not discover
|
|
|
61
61
|
loose `.md` files in `.claude/skills/`; a skill placed at the top level of that
|
|
62
62
|
directory silently never loads.
|
|
63
63
|
|
|
64
|
-
Two
|
|
64
|
+
Two end-to-end assembly recipes sit alongside the domain skills. Where a domain
|
|
65
|
+
skill routes, these two walk a whole job:
|
|
66
|
+
|
|
67
|
+
- `resolve-rough-cut` (`.claude/skills/resolve-rough-cut/SKILL.md`) — **additive**:
|
|
68
|
+
select shots from a folder of many clips into an assembled timeline.
|
|
69
|
+
- `resolve-tighten-recording` (`.claude/skills/resolve-tighten-recording/SKILL.md`)
|
|
70
|
+
— **subtractive**: remove dead air from one long single-take recording.
|
|
71
|
+
|
|
72
|
+
Two more sit outside the domain routing:
|
|
65
73
|
|
|
66
74
|
- `house-style` (`.claude/skills/house-style/SKILL.md`) — accumulated editorial
|
|
67
75
|
corrections, so the same note is not given twice. Claude-only; append to it
|
|
@@ -12,7 +12,7 @@ that none exists).
|
|
|
12
12
|
|
|
13
13
|
**Verified on:** DaVinci Resolve Studio 21.0.2
|
|
14
14
|
|
|
15
|
-
**Totals:**
|
|
15
|
+
**Totals:** 26 missing capabilities, 29 bugs / unreliable behaviors.
|
|
16
16
|
|
|
17
17
|
The authoritative source is the runtime-queryable `api_truth` ledger
|
|
18
18
|
(`resolve_control api_truth "<query>"`); this document is generated from
|
|
@@ -131,10 +131,17 @@ equivalent, blocking full automation.
|
|
|
131
131
|
### Fairlight audio levels / pan / EQ / automation / FairlightFX
|
|
132
132
|
|
|
133
133
|
- **Object:** `TimelineItem / Timeline`
|
|
134
|
-
- **Behavior:** There is no API to set clip or track volume, pan, EQ, audio automation, or to add/configure FairlightFX. SetProperty covers video transform only; the audio surface is read-only (GetSourceAudioChannelMapping, GetAudioMapping, voice isolation). Verified via dir() + SetProperty docs AND by live mutating attempt on 21.0.0: SetProperty('Volume'|'Level'|'Gain'|'AudioVolume', 0) all return False (note 'Pan' is the VIDEO transform key, not audio pan, so it misleadingly succeeds).
|
|
135
|
-
- **Workaround / current handling:**
|
|
134
|
+
- **Behavior:** There is no API to set clip or track volume, pan, EQ, audio automation, or to add/configure FairlightFX. SetProperty covers video transform only; the audio surface is read-only (GetSourceAudioChannelMapping, GetAudioMapping, voice isolation). Verified via dir() + SetProperty docs AND by live mutating attempt on 21.0.0: SetProperty('Volume'|'Level'|'Gain'|'AudioVolume', 0) all return False (note 'Pan' is the VIDEO transform key, not audio pan, so it misleadingly succeeds). The gap is PER-PARAMETER control specifically: a whole saved mix CAN be applied wholesale via Project.ApplyFairlightPresetToCurrentTimeline(name), with the available names from Resolve.GetFairlightPresets() — so 'no Fairlight write path exists' would be too strong.
|
|
135
|
+
- **Workaround / current handling:** To reapply a known mix, save it once as a Fairlight preset in the UI and apply it per-timeline with ApplyFairlightPresetToCurrentTimeline (exposed as resolve_control get_fairlight_presets + project_settings apply_fairlight_preset). Dial individual levels/pan/EQ/automation/FairlightFX in the Fairlight UI; beyond presets, only voice-isolation state and channel-mapping reads are scriptable.
|
|
136
136
|
- **Tags:** missing-method, audio, fairlight
|
|
137
137
|
|
|
138
|
+
### AI Audio Assistant (one-click timeline auto-mix)
|
|
139
|
+
|
|
140
|
+
- **Object:** `Timeline / Project`
|
|
141
|
+
- **Behavior:** The Fairlight AI Audio Assistant — which analyses a timeline and generates a balanced dialogue/music/effects mix — has no scripting method. Nothing matching it appears in the Resolve scripting API reference or in a dir() audit of Resolve, Project, Timeline or TimelineItem. Note this is NOT because it is a menu command: the API has no generic menu-invocation hook at all, so scriptability is per-feature, and plenty of menu commands DO have methods (DetectSceneCuts, Stabilize, SmartReframe, CreateMagicMask, TranscribeAudio, RemoveMotionBlur, AnalyzeForIntellisearch). It is compounded by the per-parameter Fairlight gap above: even the mix it produces cannot be read back or reconstructed clip-by-clip.
|
|
142
|
+
- **Workaround / current handling:** No way to trigger it from a script. For a repeatable mix, run the Assistant once in the UI, save the result as a Fairlight preset, then apply that preset per-timeline with project_settings apply_fairlight_preset — content-adaptive per run is not achievable, a consistent template mix is.
|
|
143
|
+
- **Tags:** missing-method, audio, fairlight, ai, auto-mix
|
|
144
|
+
|
|
138
145
|
### Proxy / optimized-media generation
|
|
139
146
|
|
|
140
147
|
- **Object:** `MediaPoolItem`
|
|
@@ -175,7 +182,7 @@ equivalent, blocking full automation.
|
|
|
175
182
|
|
|
176
183
|
- **Object:** `TimelineItem / Timeline / Project`
|
|
177
184
|
- **Behavior:** There is no API method to set or query subtitle font family, font size, text color, background color, outline, shadow, position, alignment, or to apply/query subtitle style presets. TimelineItem.GetProperty() on subtitle items returns only transform/composite keys. Timeline.GetSetting() and Project.GetSetting() return None for all probed subtitle-style keys (e.g. 'subtitleFontName', 'subtitleFontSize', 'subtitleTextColor', 'subtitleBackgroundColor', 'subtitlePosition', 'subtitleAlignment', 'subtitlePreset', 'subtitleStyle'). Verified via dir(), GetProperty(), and GetSetting() on Resolve 21.0.0.48.
|
|
178
|
-
- **Workaround / current handling:** No workaround exists
|
|
185
|
+
- **Workaround / current handling:** No API workaround exists, but the style IS reachable below the API: it lives in Sm2TiTrack.FieldsBlob for Type=2 tracks, as an EffectFiltersBA payload whose effect 136 carries a Qt QFont descriptor (param 18) and a normalised position vector (param 17). Exposed as project_db list_subtitle_styles / set_subtitle_style (font family/size/weight/italic + position). Confirmed live on 21.0 (2026-08-06): Resolve opens a patched track and re-serialises it back to its own zstd form with the patched values intact, so it genuinely parses the write. Caveats: whole-TRACK style not per-caption, project must be CLOSED, Resolve must be fully quit and relaunched afterwards, and the track must already carry a style blob (a freshly added subtitle track has none until it is styled once in the UI). Burn-in overlays via Fusion titles remain a visual alternative but do not produce subtitle tracks.
|
|
179
186
|
- **Tags:** missing-method, subtitle, style, preset
|
|
180
187
|
|
|
181
188
|
### Speech recognition engine selection and SRT import
|
|
@@ -436,6 +443,15 @@ values, or automation-hostile modal prompts.
|
|
|
436
443
|
- **Reference:** [issue #59](https://github.com/samuelgursky/davinci-resolve-mcp/issues/59)
|
|
437
444
|
- **Tags:** render, deliver, silent-failure, id-vs-label
|
|
438
445
|
|
|
446
|
+
### Project.SetRenderSettings (inherits the loaded preset)
|
|
447
|
+
|
|
448
|
+
- **Object:** `Project`
|
|
449
|
+
- **Signature:** `({settings}) -> bool`
|
|
450
|
+
- **Behavior:** SetRenderSettings applies the passed keys ON TOP of whatever render state the Deliver page is holding; it does not replace it. A loaded preset carries more state than the keys a caller passes, and that state survives. Measured 2026-07-08: after a render through the stock 'Audio Only' preset, a job queued with an explicit ExportVideo=True and an .mp4 target returned settings_success=True and a real job id, GetRenderJobList reported IsExportVideo=True, and the rendered .mp4 contained only an AAC stream with NO video stream (ffprobe) — 18 minutes of material 'rendered' in ~10 seconds. The job readback is therefore NOT a witness for the rendered file. There is also no way to detect the inherited state: the scripting API documents no GetRenderSettings and no GetCurrentRenderPresetName, so the base state can be pinned but never read.
|
|
451
|
+
- **Workaround / current handling:** Pin the base state instead of inheriting one — prepare_render_job(from_preset='<a video preset>') runs LoadRenderPreset before the explicit settings go on top (PresetName flips to 'Custom' once they do, which is expected). Then verify the OUTPUT, not the job: ffprobe for a codec_type=video stream. A long timeline that completes in seconds is the tell.
|
|
452
|
+
- **Reference:** [issue #123](https://github.com/samuelgursky/davinci-resolve-mcp/issues/123)
|
|
453
|
+
- **Tags:** render, deliver, silent-failure, preset, readback-lies
|
|
454
|
+
|
|
439
455
|
### ProjectManager.SaveProject
|
|
440
456
|
|
|
441
457
|
- **Object:** `ProjectManager`
|
package/install.py
CHANGED
|
@@ -36,7 +36,7 @@ from src.utils.update_check import (
|
|
|
36
36
|
|
|
37
37
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
38
38
|
|
|
39
|
-
VERSION = "2.
|
|
39
|
+
VERSION = "2.82.0"
|
|
40
40
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
41
41
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
42
42
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -83,6 +83,9 @@ Each dispatches on an `action`. Highlights:
|
|
|
83
83
|
reverse-clip DB repair, sequence lineage store + diff, per-cut frame QC.
|
|
84
84
|
- **`color_trace`** — cross-project clip matching → a trace plan for carrying grades across a re-conform.
|
|
85
85
|
- **`project_read` / `project_db`** — read/patch the Resolve project DB (SQLite or Postgres).
|
|
86
|
+
Includes `list_subtitle_styles` / `set_subtitle_style` — caption font family/size/weight/italic
|
|
87
|
+
and normalised position, which the scripting API cannot touch at all. Whole-track (not
|
|
88
|
+
per-caption); project must be CLOSED and Resolve fully quit + relaunched afterwards.
|
|
86
89
|
- **`pipeline`** — the DB-as-truth pipeline foundation (see below).
|
|
87
90
|
- **`deliverable`** — deliverable QC / compliance: `deliverable_qc` (ffprobe a render vs its spec →
|
|
88
91
|
pass/fail per field), `loudness_qc` (ebur128 LUFS/true-peak/LRA), `reframe_blanking_check`,
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* set_clip_marks — Sm2MpMedia.MarkIn/MarkOut (set clip in/out points)
|
|
13
13
|
* relayout_node_graphs — rewrite node x/y in every graded ListMgt::LmVersion Body
|
|
14
14
|
* (whole-project "Cleanup Node Graph"; the UI command has NO scripting API)
|
|
15
|
+
* list_subtitle_styles — read caption style per subtitle track (font + position)
|
|
16
|
+
* set_subtitle_style — patch Sm2TiTrack.FieldsBlob caption style (font family/size/
|
|
17
|
+
* italic/weight + normalised position). The API exposes NO subtitle styling at all.
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
import { z } from 'zod';
|
|
@@ -52,6 +55,33 @@ const relayoutSchema = z.object({
|
|
|
52
55
|
iConfirmProjectClosed: confirm,
|
|
53
56
|
});
|
|
54
57
|
|
|
58
|
+
const listSubtitleStylesSchema = z.object({ ...dbTarget });
|
|
59
|
+
const setSubtitleStyleSchema = z.object({
|
|
60
|
+
...dbTarget,
|
|
61
|
+
timeline: z.string().describe('Timeline name carrying the subtitle track'),
|
|
62
|
+
track: z.number().int().min(1).optional().describe('1-based subtitle track index within that timeline (default 1)'),
|
|
63
|
+
family: z.string().optional().describe('Font family, e.g. "Montserrat"'),
|
|
64
|
+
pointSize: z.number().int().optional().describe('Font point size'),
|
|
65
|
+
weight: z.number().int().optional().describe('Qt font weight (50 = Regular, 75 = Bold)'),
|
|
66
|
+
italic: z.boolean().optional(),
|
|
67
|
+
styleName: z.string().optional().describe('Qt style name, e.g. "Regular" / "Bold" / "Oblique"'),
|
|
68
|
+
positionX: z.number().optional().describe('Normalised X (0-1, 0.5 = centre)'),
|
|
69
|
+
positionY: z.number().optional().describe('Normalised Y (0-1)'),
|
|
70
|
+
dryRun: z.boolean().optional().describe('Report the decoded before/after without writing'),
|
|
71
|
+
iConfirmProjectClosed: confirm,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Subtitle tracks are Sm2TiTrack rows with Type = 2, linked to their timeline via
|
|
75
|
+
// Sm2TiTrack.Sequence -> Sm2Sequence.Sm2Sequence_id -> Sm2Timeline. (Note the join
|
|
76
|
+
// column is `Sequence`, NOT the same-table `Sm2Sequence_id`, which is null here.)
|
|
77
|
+
const SUBTITLE_TRACK_QUERY = `
|
|
78
|
+
SELECT tl.Name AS timeline, tr.Sm2TiTrack_id AS id, tr.FieldsBlob AS blob
|
|
79
|
+
FROM Sm2TiTrack tr
|
|
80
|
+
JOIN Sm2Sequence sq ON tr.Sequence = sq.Sm2Sequence_id
|
|
81
|
+
JOIN Sm2Timeline tl ON sq.Sm2Timeline_id = tl.Sm2Timeline_id
|
|
82
|
+
WHERE tr.Type = 2
|
|
83
|
+
ORDER BY tl.Name, tr.rowid`;
|
|
84
|
+
|
|
55
85
|
function selectOne(db, table, col, value) {
|
|
56
86
|
const rows = db.prepare(`SELECT rowid AS rid, Name FROM ${table} WHERE Name = ?`).all(value);
|
|
57
87
|
if (!rows.length) throw new Error(`no ${table} named "${value}"`);
|
|
@@ -62,7 +92,7 @@ function selectOne(db, table, col, value) {
|
|
|
62
92
|
export const projectDbTool = {
|
|
63
93
|
name: 'project_db',
|
|
64
94
|
description:
|
|
65
|
-
'Beyond-the-API live Project.db patches (plain columns) — closes gaps the scripting API cannot. Project must be CLOSED (auto-backup + schema guard + verify). Actions: list_folders, rename_folder (no RenameSubFolder API), set_folder_color, list_clips, set_clip_marks, relayout_node_graphs (whole-project Cleanup Node Graph — rewrites node x/y in every graded version Body; grade content untouched; REQUIRES full Resolve quit+relaunch after patching, it caches open projects in memory). Needs optional better-sqlite3.',
|
|
95
|
+
'Beyond-the-API live Project.db patches (plain columns) — closes gaps the scripting API cannot. Project must be CLOSED (auto-backup + schema guard + verify). Actions: list_folders, rename_folder (no RenameSubFolder API), set_folder_color, list_clips, set_clip_marks, relayout_node_graphs (whole-project Cleanup Node Graph — rewrites node x/y in every graded version Body; grade content untouched; REQUIRES full Resolve quit+relaunch after patching, it caches open projects in memory), list_subtitle_styles, set_subtitle_style (caption font family/size/italic/weight + normalised position — the scripting API exposes NO subtitle styling; whole-track, not per-caption; same quit+relaunch requirement). Needs optional better-sqlite3.',
|
|
66
96
|
async handler({ action, args }) {
|
|
67
97
|
if (action === 'list_folders') {
|
|
68
98
|
const p = listFoldersSchema.parse(args);
|
|
@@ -195,6 +225,104 @@ export const projectDbTool = {
|
|
|
195
225
|
db.close();
|
|
196
226
|
}
|
|
197
227
|
}
|
|
228
|
+
if (action === 'list_subtitle_styles') {
|
|
229
|
+
const p = listSubtitleStylesSchema.parse(args);
|
|
230
|
+
const style = require('../../vendor/drp-format/subtitle-style.js');
|
|
231
|
+
const db = openGuarded(resolveDbPath(p), { table: 'Sm2TiTrack', column: 'FieldsBlob' });
|
|
232
|
+
try {
|
|
233
|
+
const counts = new Map();
|
|
234
|
+
const tracks = [];
|
|
235
|
+
for (const row of db.prepare(SUBTITLE_TRACK_QUERY).all()) {
|
|
236
|
+
const n = (counts.get(row.timeline) || 0) + 1;
|
|
237
|
+
counts.set(row.timeline, n);
|
|
238
|
+
const entry = { timeline: row.timeline, track: n };
|
|
239
|
+
try {
|
|
240
|
+
const dec = style.decodeSubtitleStyle(Buffer.from(row.blob));
|
|
241
|
+
entry.styled = dec.styled;
|
|
242
|
+
if (dec.styled) {
|
|
243
|
+
entry.font = dec.font && {
|
|
244
|
+
family: dec.font.family,
|
|
245
|
+
pointSize: dec.font.pointSize,
|
|
246
|
+
weight: dec.font.weight,
|
|
247
|
+
italic: dec.font.italic,
|
|
248
|
+
styleName: dec.font.styleName,
|
|
249
|
+
};
|
|
250
|
+
entry.position = dec.position;
|
|
251
|
+
entry.opaqueParams = dec.opaque.length;
|
|
252
|
+
}
|
|
253
|
+
} catch (e) {
|
|
254
|
+
// Report an undecodable track rather than failing the whole listing.
|
|
255
|
+
entry.error = e.message;
|
|
256
|
+
}
|
|
257
|
+
tracks.push(entry);
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
tracks,
|
|
261
|
+
note: tracks.some((t) => t.styled === false)
|
|
262
|
+
? 'Tracks with styled:false carry no style blob (Resolve writes a NumLayers-only stub until the track is styled once in the UI); set_subtitle_style cannot patch those.'
|
|
263
|
+
: undefined,
|
|
264
|
+
};
|
|
265
|
+
} finally {
|
|
266
|
+
db.close();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (action === 'set_subtitle_style') {
|
|
270
|
+
const p = setSubtitleStyleSchema.parse(args);
|
|
271
|
+
const style = require('../../vendor/drp-format/subtitle-style.js');
|
|
272
|
+
const changes = {};
|
|
273
|
+
for (const k of ['family', 'pointSize', 'weight', 'italic', 'styleName']) {
|
|
274
|
+
if (p[k] != null) changes[k] = p[k];
|
|
275
|
+
}
|
|
276
|
+
if ((p.positionX == null) !== (p.positionY == null)) {
|
|
277
|
+
throw new Error('positionX and positionY must be given together');
|
|
278
|
+
}
|
|
279
|
+
if (p.positionX != null) changes.position = { x: p.positionX, y: p.positionY };
|
|
280
|
+
if (!Object.keys(changes).length) throw new Error('nothing to change — pass family/pointSize/weight/italic/styleName and/or positionX+positionY');
|
|
281
|
+
|
|
282
|
+
const write = !p.dryRun;
|
|
283
|
+
if (write) requireClosed(p);
|
|
284
|
+
const dbPath = resolveDbPath(p);
|
|
285
|
+
const bak = write ? backup(dbPath) : null;
|
|
286
|
+
const db = openGuarded(dbPath, { writable: write, table: 'Sm2TiTrack', column: 'FieldsBlob' });
|
|
287
|
+
try {
|
|
288
|
+
const wanted = p.track || 1;
|
|
289
|
+
let seen = 0;
|
|
290
|
+
let target = null;
|
|
291
|
+
for (const row of db.prepare(SUBTITLE_TRACK_QUERY).all()) {
|
|
292
|
+
if (row.timeline !== p.timeline) continue;
|
|
293
|
+
seen += 1;
|
|
294
|
+
if (seen === wanted) { target = row; break; }
|
|
295
|
+
}
|
|
296
|
+
if (!target) {
|
|
297
|
+
throw new Error(`timeline "${p.timeline}" has no subtitle track ${wanted} (found ${seen}) — run list_subtitle_styles`);
|
|
298
|
+
}
|
|
299
|
+
const before = style.decodeSubtitleStyle(Buffer.from(target.blob));
|
|
300
|
+
const patched = style.encodeSubtitleStyle(Buffer.from(target.blob), changes);
|
|
301
|
+
const after = style.decodeSubtitleStyle(patched);
|
|
302
|
+
if (write) {
|
|
303
|
+
db.prepare('UPDATE Sm2TiTrack SET FieldsBlob = ? WHERE Sm2TiTrack_id = ?').run(patched, target.id);
|
|
304
|
+
const back = db.prepare('SELECT FieldsBlob FROM Sm2TiTrack WHERE Sm2TiTrack_id = ?').get(target.id);
|
|
305
|
+
const verify = style.decodeSubtitleStyle(Buffer.from(back.FieldsBlob));
|
|
306
|
+
if (verify.font.raw !== after.font.raw || JSON.stringify(verify.position) !== JSON.stringify(after.position)) {
|
|
307
|
+
throw new Error(`read-back verify failed on track ${target.id} — restore from backup ${bak}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
dryRun: !write,
|
|
312
|
+
backup: bak,
|
|
313
|
+
timeline: p.timeline,
|
|
314
|
+
track: wanted,
|
|
315
|
+
before: { font: before.font && before.font.raw, position: before.position },
|
|
316
|
+
after: { font: after.font && after.font.raw, position: after.position },
|
|
317
|
+
opaqueParamsPreserved: JSON.stringify(before.opaque) === JSON.stringify(after.opaque),
|
|
318
|
+
note: write
|
|
319
|
+
? 'Resolve caches open projects IN MEMORY: fully QUIT Resolve and relaunch before reopening this project, or the patched style will not be visible (and an oversave could revert it). This is a whole-TRACK style, not per-caption.'
|
|
320
|
+
: 'Dry run — nothing written. Re-run with dryRun:false + iConfirmProjectClosed:true (project CLOSED in Resolve).',
|
|
321
|
+
};
|
|
322
|
+
} finally {
|
|
323
|
+
db.close();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
198
326
|
throw new Error(`Unknown project_db action: ${action}`);
|
|
199
327
|
},
|
|
200
328
|
};
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DaVinci Resolve subtitle-track style codec.
|
|
3
|
+
*
|
|
4
|
+
* Reads and writes the caption style Resolve stores on a SUBTITLE track
|
|
5
|
+
* (`Sm2TiTrack` rows with `Type = 2`). The scripting API exposes none of this:
|
|
6
|
+
* subtitle TimelineItems return only the 21 transform/composite properties, and
|
|
7
|
+
* `Timeline`/`Project.GetSetting()` return null for every subtitle-style key. See
|
|
8
|
+
* the `Subtitle track styling and presets` entry in src/utils/api_truth.py.
|
|
9
|
+
*
|
|
10
|
+
* Container chain (outermost first):
|
|
11
|
+
*
|
|
12
|
+
* Sm2TiTrack.FieldsBlob keyed-dict (keyed-dict.js) — { NumLayers, EffectFiltersBA }
|
|
13
|
+
* -> EffectFiltersBA [u32 version=2][u32 payloadLen][u8 0x80|0x81][payload]
|
|
14
|
+
* 0x80 = raw protobuf, 0x81 = compressed (Resolve 21 writes ZSTD)
|
|
15
|
+
* -> protobuf repeated f1 = effect, each:
|
|
16
|
+
* f1 varint effect id (136 carries the text style)
|
|
17
|
+
* f9 LEN parameter, repeated and POSITIONAL —
|
|
18
|
+
* empty f9 entries are placeholders and
|
|
19
|
+
* MUST be preserved to keep param order
|
|
20
|
+
* f1 varint param id
|
|
21
|
+
* f3 LEN value wrapper -> f1 LEN -> one of:
|
|
22
|
+
* f1 varint integer
|
|
23
|
+
* f3 LEN string
|
|
24
|
+
* f2 fixed64 double (LITTLE-endian)
|
|
25
|
+
* f7 LEN double vector (BIG-endian)
|
|
26
|
+
*
|
|
27
|
+
* Note the endianness split: scalar doubles (f2) are little-endian, but the f7
|
|
28
|
+
* double vector used by the position parameter is big-endian. Both were confirmed
|
|
29
|
+
* against live Resolve 21 projects — reading either one with the wrong order
|
|
30
|
+
* yields plausible-looking garbage (~1e-319), so this is worth not "fixing".
|
|
31
|
+
*
|
|
32
|
+
* Only two parameters of effect 136 are given names here, because only these two
|
|
33
|
+
* are self-describing:
|
|
34
|
+
* param 18 — Qt QFont::toString() descriptor, e.g.
|
|
35
|
+
* "Helvetica,13,-1,5,50,0,0,0,0,0,Regular"
|
|
36
|
+
* (family, pointSize, pixelSize, styleHint, weight, italic,
|
|
37
|
+
* underline, strikeOut, fixedPitch, rawMode, styleName)
|
|
38
|
+
* param 17 — normalised position vector [x, y], origin top-left, e.g. [0.5, 0.109]
|
|
39
|
+
*
|
|
40
|
+
* Every other parameter (19, 34, 142, and effects 16/54/56) round-trips untouched
|
|
41
|
+
* and is reported under `opaque`. They vary across real projects but their meaning
|
|
42
|
+
* has NOT been correlated against the UI, so they are deliberately left unlabelled
|
|
43
|
+
* rather than guessed at.
|
|
44
|
+
*
|
|
45
|
+
* @module drp-format/subtitle-style
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
const { decodeKeyedDict, encodeKeyedDict } = require('./keyed-dict.js');
|
|
49
|
+
|
|
50
|
+
let fzstd = null;
|
|
51
|
+
try { fzstd = require('fzstd'); } catch (e) { /* optional; 0x81 zstd payloads need it */ }
|
|
52
|
+
|
|
53
|
+
const TEXT_STYLE_EFFECT = 136;
|
|
54
|
+
const PARAM_FONT = 18;
|
|
55
|
+
const PARAM_POSITION = 17;
|
|
56
|
+
|
|
57
|
+
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// protobuf primitives (field-preserving: we never drop unknown fields)
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
function readVarint(buf, off) {
|
|
64
|
+
let value = 0n;
|
|
65
|
+
let shift = 0n;
|
|
66
|
+
let n = 0;
|
|
67
|
+
while (off + n < buf.length) {
|
|
68
|
+
const byte = buf[off + n];
|
|
69
|
+
value |= BigInt(byte & 0x7f) << shift;
|
|
70
|
+
n += 1;
|
|
71
|
+
if (!(byte & 0x80)) return { value: Number(value), bytesRead: n };
|
|
72
|
+
shift += 7n;
|
|
73
|
+
}
|
|
74
|
+
throw new Error('truncated varint');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writeVarint(value) {
|
|
78
|
+
const bytes = [];
|
|
79
|
+
let remaining = value;
|
|
80
|
+
while (remaining > 0x7f) {
|
|
81
|
+
bytes.push((remaining & 0x7f) | 0x80);
|
|
82
|
+
remaining = Math.floor(remaining / 128);
|
|
83
|
+
}
|
|
84
|
+
bytes.push(remaining & 0x7f);
|
|
85
|
+
return Buffer.from(bytes);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse a protobuf message into a flat list of {field, wire, ...} records.
|
|
90
|
+
* Length-delimited payloads are kept as raw Buffers so unknown structure
|
|
91
|
+
* survives a re-encode byte-for-byte.
|
|
92
|
+
*/
|
|
93
|
+
function parseMessage(buf) {
|
|
94
|
+
const out = [];
|
|
95
|
+
let off = 0;
|
|
96
|
+
while (off < buf.length) {
|
|
97
|
+
const tag = readVarint(buf, off);
|
|
98
|
+
off += tag.bytesRead;
|
|
99
|
+
const field = tag.value >> 3;
|
|
100
|
+
const wire = tag.value & 7;
|
|
101
|
+
if (wire === 0) {
|
|
102
|
+
const v = readVarint(buf, off);
|
|
103
|
+
off += v.bytesRead;
|
|
104
|
+
out.push({ field, wire, value: v.value });
|
|
105
|
+
} else if (wire === 1) {
|
|
106
|
+
out.push({ field, wire, bytes: buf.slice(off, off + 8) });
|
|
107
|
+
off += 8;
|
|
108
|
+
} else if (wire === 2) {
|
|
109
|
+
const len = readVarint(buf, off);
|
|
110
|
+
off += len.bytesRead;
|
|
111
|
+
out.push({ field, wire, bytes: buf.slice(off, off + len.value) });
|
|
112
|
+
off += len.value;
|
|
113
|
+
} else if (wire === 5) {
|
|
114
|
+
out.push({ field, wire, bytes: buf.slice(off, off + 4) });
|
|
115
|
+
off += 4;
|
|
116
|
+
} else {
|
|
117
|
+
throw new Error(`unsupported protobuf wire type ${wire} at offset ${off}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function serializeMessage(records) {
|
|
124
|
+
const parts = [];
|
|
125
|
+
for (const rec of records) {
|
|
126
|
+
parts.push(writeVarint((rec.field << 3) | rec.wire));
|
|
127
|
+
if (rec.wire === 0) {
|
|
128
|
+
parts.push(writeVarint(rec.value));
|
|
129
|
+
} else if (rec.wire === 2) {
|
|
130
|
+
parts.push(writeVarint(rec.bytes.length));
|
|
131
|
+
parts.push(rec.bytes);
|
|
132
|
+
} else {
|
|
133
|
+
parts.push(rec.bytes);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return Buffer.concat(parts);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// EffectFiltersBA envelope
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
function unwrapEffectFilters(raw) {
|
|
144
|
+
if (raw.length < 9) throw new Error('EffectFiltersBA too short');
|
|
145
|
+
const version = raw.readUInt32BE(0);
|
|
146
|
+
const declared = raw.readUInt32BE(4);
|
|
147
|
+
const marker = raw[8];
|
|
148
|
+
const payload = raw.slice(9);
|
|
149
|
+
if (declared !== payload.length + 1) {
|
|
150
|
+
throw new Error(`EffectFiltersBA length mismatch: declared ${declared}, payload ${payload.length + 1}`);
|
|
151
|
+
}
|
|
152
|
+
if (marker === 0x80) return { version, marker, protobuf: payload };
|
|
153
|
+
if (marker === 0x81) {
|
|
154
|
+
if (!payload.slice(0, 4).equals(ZSTD_MAGIC)) {
|
|
155
|
+
throw new Error('EffectFiltersBA 0x81 payload is not a zstd frame');
|
|
156
|
+
}
|
|
157
|
+
if (!fzstd) throw new Error("EffectFiltersBA 0x81 payload is zstd; optional dep 'fzstd' is required to decode");
|
|
158
|
+
return { version, marker, protobuf: Buffer.from(fzstd.decompress(new Uint8Array(payload))) };
|
|
159
|
+
}
|
|
160
|
+
throw new Error(`unknown EffectFiltersBA payload marker 0x${marker.toString(16)}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Re-wrap a protobuf payload as EffectFiltersBA.
|
|
165
|
+
*
|
|
166
|
+
* Always emits the UNCOMPRESSED 0x80 form. This avoids depending on a zstd
|
|
167
|
+
* *compressor* (the bundled fzstd is decompress-only); the cost is a larger
|
|
168
|
+
* blob, which does not matter here.
|
|
169
|
+
*
|
|
170
|
+
* Confirmed live against Resolve 21 (2026-08-06): a subtitle track patched with
|
|
171
|
+
* a 0x80 payload opens without error, and once Resolve next re-serialises that
|
|
172
|
+
* track it writes the style back out as 0x81 zstd with the font descriptor and
|
|
173
|
+
* position preserved exactly — i.e. Resolve genuinely parses the 0x80 form into
|
|
174
|
+
* its in-memory model rather than passing the bytes through untouched.
|
|
175
|
+
*/
|
|
176
|
+
function wrapEffectFilters(protobuf, version = 2) {
|
|
177
|
+
const head = Buffer.alloc(9);
|
|
178
|
+
head.writeUInt32BE(version, 0);
|
|
179
|
+
head.writeUInt32BE(protobuf.length + 1, 4);
|
|
180
|
+
head[8] = 0x80;
|
|
181
|
+
return Buffer.concat([head, protobuf]);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// parameter access
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/** Split one effect into { idRecord, params: [{record, id, wrapper}] , others } */
|
|
189
|
+
function readEffect(effectBytes) {
|
|
190
|
+
const records = parseMessage(effectBytes);
|
|
191
|
+
const params = [];
|
|
192
|
+
for (const rec of records) {
|
|
193
|
+
if (rec.field !== 9 || rec.wire !== 2 || rec.bytes.length === 0) continue;
|
|
194
|
+
const inner = parseMessage(rec.bytes);
|
|
195
|
+
const idRec = inner.find((r) => r.field === 1 && r.wire === 0);
|
|
196
|
+
if (!idRec) continue;
|
|
197
|
+
params.push({ record: rec, id: idRec.value, inner });
|
|
198
|
+
}
|
|
199
|
+
const idRec = records.find((r) => r.field === 1 && r.wire === 0);
|
|
200
|
+
return { records, effectId: idRec ? idRec.value : null, params };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Decode the value wrapper (f3 -> f1 -> typed leaf) into a JS value. */
|
|
204
|
+
function readParamValue(wrapperBytes) {
|
|
205
|
+
const outer = parseMessage(wrapperBytes).find((r) => r.field === 1 && r.wire === 2);
|
|
206
|
+
if (!outer) return null;
|
|
207
|
+
const leaves = parseMessage(outer.bytes);
|
|
208
|
+
const result = {};
|
|
209
|
+
for (const leaf of leaves) {
|
|
210
|
+
if (leaf.field === 1 && leaf.wire === 0) result.int = leaf.value;
|
|
211
|
+
else if (leaf.field === 3 && leaf.wire === 2) result.string = leaf.bytes.toString('utf8');
|
|
212
|
+
else if (leaf.field === 2 && leaf.wire === 1) result.double = leaf.bytes.readDoubleLE(0);
|
|
213
|
+
else if (leaf.field === 7 && leaf.wire === 2) {
|
|
214
|
+
// BIG-endian double vector — see module note on the endianness split.
|
|
215
|
+
const vec = [];
|
|
216
|
+
for (let i = 0; i + 8 <= leaf.bytes.length; i += 8) vec.push(leaf.bytes.readDoubleBE(i));
|
|
217
|
+
result.vector = vec;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return Object.keys(result).length ? result : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Rebuild a value wrapper, replacing the string leaf. */
|
|
224
|
+
function writeStringParam(wrapperBytes, text) {
|
|
225
|
+
const outerRecords = parseMessage(wrapperBytes);
|
|
226
|
+
const outer = outerRecords.find((r) => r.field === 1 && r.wire === 2);
|
|
227
|
+
if (!outer) throw new Error('parameter wrapper has no inner message');
|
|
228
|
+
const leaves = parseMessage(outer.bytes);
|
|
229
|
+
const target = leaves.find((l) => l.field === 3 && l.wire === 2);
|
|
230
|
+
if (!target) throw new Error('parameter has no string leaf to replace');
|
|
231
|
+
target.bytes = Buffer.from(text, 'utf8');
|
|
232
|
+
outer.bytes = serializeMessage(leaves);
|
|
233
|
+
return serializeMessage(outerRecords);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Rebuild a value wrapper, replacing the big-endian double vector. */
|
|
237
|
+
function writeVectorParam(wrapperBytes, values) {
|
|
238
|
+
const outerRecords = parseMessage(wrapperBytes);
|
|
239
|
+
const outer = outerRecords.find((r) => r.field === 1 && r.wire === 2);
|
|
240
|
+
if (!outer) throw new Error('parameter wrapper has no inner message');
|
|
241
|
+
const leaves = parseMessage(outer.bytes);
|
|
242
|
+
const target = leaves.find((l) => l.field === 7 && l.wire === 2);
|
|
243
|
+
if (!target) throw new Error('parameter has no vector leaf to replace');
|
|
244
|
+
if (values.length * 8 !== target.bytes.length) {
|
|
245
|
+
throw new Error(`vector arity mismatch: existing ${target.bytes.length / 8} values, given ${values.length}`);
|
|
246
|
+
}
|
|
247
|
+
const buf = Buffer.alloc(values.length * 8);
|
|
248
|
+
values.forEach((v, i) => buf.writeDoubleBE(v, i * 8));
|
|
249
|
+
target.bytes = buf;
|
|
250
|
+
outer.bytes = serializeMessage(leaves);
|
|
251
|
+
return serializeMessage(outerRecords);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// QFont descriptor
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Parse a Qt QFont::toString() descriptor.
|
|
260
|
+
* Fields after the family are positional; unknown trailing fields are kept
|
|
261
|
+
* verbatim so a re-serialise is lossless.
|
|
262
|
+
*/
|
|
263
|
+
function parseFontDescriptor(descriptor) {
|
|
264
|
+
const parts = String(descriptor).split(',');
|
|
265
|
+
return {
|
|
266
|
+
family: parts[0],
|
|
267
|
+
pointSize: parts.length > 1 ? Number(parts[1]) : null,
|
|
268
|
+
pixelSize: parts.length > 2 ? Number(parts[2]) : null,
|
|
269
|
+
styleHint: parts.length > 3 ? Number(parts[3]) : null,
|
|
270
|
+
weight: parts.length > 4 ? Number(parts[4]) : null,
|
|
271
|
+
italic: parts.length > 5 ? parts[5] === '1' : null,
|
|
272
|
+
styleName: parts.length > 10 ? parts[10] : null,
|
|
273
|
+
raw: descriptor,
|
|
274
|
+
_parts: parts,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function buildFontDescriptor(parsed, changes) {
|
|
279
|
+
const parts = parsed._parts.slice();
|
|
280
|
+
if (changes.family != null) parts[0] = String(changes.family);
|
|
281
|
+
if (changes.pointSize != null && parts.length > 1) parts[1] = String(changes.pointSize);
|
|
282
|
+
if (changes.weight != null && parts.length > 4) parts[4] = String(changes.weight);
|
|
283
|
+
if (changes.italic != null && parts.length > 5) parts[5] = changes.italic ? '1' : '0';
|
|
284
|
+
if (changes.styleName != null && parts.length > 10) parts[10] = String(changes.styleName);
|
|
285
|
+
return parts.join(',');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// public API
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Decode the caption style from an Sm2TiTrack.FieldsBlob.
|
|
294
|
+
*
|
|
295
|
+
* @param {Buffer|string} fieldsBlob - raw blob or hex string
|
|
296
|
+
* @returns {{styled: boolean, font: object|null, position: {x: number, y: number}|null,
|
|
297
|
+
* opaque: Array, effects: Array}}
|
|
298
|
+
* `styled: false` means the track carries no EffectFiltersBA at all (Resolve
|
|
299
|
+
* writes a ~39-byte NumLayers-only stub for an unstyled subtitle track).
|
|
300
|
+
*/
|
|
301
|
+
function decodeSubtitleStyle(fieldsBlob) {
|
|
302
|
+
const buf = Buffer.isBuffer(fieldsBlob) ? fieldsBlob : Buffer.from(fieldsBlob, 'hex');
|
|
303
|
+
const dict = decodeKeyedDict(buf);
|
|
304
|
+
const entry = dict.entries.find((e) => e.key === 'EffectFiltersBA');
|
|
305
|
+
if (!entry || !entry.value) {
|
|
306
|
+
return { styled: false, font: null, position: null, opaque: [], effects: [] };
|
|
307
|
+
}
|
|
308
|
+
const { protobuf } = unwrapEffectFilters(Buffer.from(entry.value, 'hex'));
|
|
309
|
+
const effects = parseMessage(protobuf).filter((r) => r.field === 1 && r.wire === 2);
|
|
310
|
+
|
|
311
|
+
let font = null;
|
|
312
|
+
let position = null;
|
|
313
|
+
const opaque = [];
|
|
314
|
+
const summary = [];
|
|
315
|
+
|
|
316
|
+
for (const effectRec of effects) {
|
|
317
|
+
const effect = readEffect(effectRec.bytes);
|
|
318
|
+
const paramSummary = [];
|
|
319
|
+
for (const param of effect.params) {
|
|
320
|
+
const wrapper = param.inner.find((r) => r.field === 3 && r.wire === 2);
|
|
321
|
+
const value = wrapper ? readParamValue(wrapper.bytes) : null;
|
|
322
|
+
paramSummary.push({ id: param.id, value });
|
|
323
|
+
if (effect.effectId === TEXT_STYLE_EFFECT && param.id === PARAM_FONT && value && value.string) {
|
|
324
|
+
font = parseFontDescriptor(value.string);
|
|
325
|
+
} else if (effect.effectId === TEXT_STYLE_EFFECT && param.id === PARAM_POSITION && value && value.vector) {
|
|
326
|
+
position = { x: value.vector[0], y: value.vector[1] };
|
|
327
|
+
} else if (value) {
|
|
328
|
+
opaque.push({ effect: effect.effectId, param: param.id, value });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
summary.push({ effect: effect.effectId, params: paramSummary });
|
|
332
|
+
}
|
|
333
|
+
return { styled: true, font, position, opaque, effects: summary };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Return a new FieldsBlob with the caption style modified.
|
|
338
|
+
*
|
|
339
|
+
* Only the font descriptor and position are writable; every other parameter and
|
|
340
|
+
* effect is re-emitted untouched. Throws rather than silently no-op'ing when the
|
|
341
|
+
* track has no style blob to patch, or when a requested change has no target
|
|
342
|
+
* parameter — a silent no-op here would read as success.
|
|
343
|
+
*
|
|
344
|
+
* @param {Buffer|string} fieldsBlob
|
|
345
|
+
* @param {{family?: string, pointSize?: number, weight?: number, italic?: boolean,
|
|
346
|
+
* styleName?: string, position?: {x: number, y: number}}} changes
|
|
347
|
+
* @returns {Buffer} new FieldsBlob
|
|
348
|
+
*/
|
|
349
|
+
function encodeSubtitleStyle(fieldsBlob, changes = {}) {
|
|
350
|
+
const buf = Buffer.isBuffer(fieldsBlob) ? fieldsBlob : Buffer.from(fieldsBlob, 'hex');
|
|
351
|
+
const dict = decodeKeyedDict(buf);
|
|
352
|
+
const entry = dict.entries.find((e) => e.key === 'EffectFiltersBA');
|
|
353
|
+
if (!entry || !entry.value) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
'track has no EffectFiltersBA — it is an unstyled subtitle track; ' +
|
|
356
|
+
'style it once in the Resolve UI so a style blob exists to patch',
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
const envelope = unwrapEffectFilters(Buffer.from(entry.value, 'hex'));
|
|
360
|
+
const records = parseMessage(envelope.protobuf);
|
|
361
|
+
|
|
362
|
+
const wantsFont = changes.family != null || changes.pointSize != null ||
|
|
363
|
+
changes.weight != null || changes.italic != null || changes.styleName != null;
|
|
364
|
+
const wantsPosition = changes.position != null;
|
|
365
|
+
let fontDone = false;
|
|
366
|
+
let positionDone = false;
|
|
367
|
+
|
|
368
|
+
for (const effectRec of records) {
|
|
369
|
+
if (effectRec.field !== 1 || effectRec.wire !== 2) continue;
|
|
370
|
+
const effect = readEffect(effectRec.bytes);
|
|
371
|
+
if (effect.effectId !== TEXT_STYLE_EFFECT) continue;
|
|
372
|
+
let touched = false;
|
|
373
|
+
for (const param of effect.params) {
|
|
374
|
+
const wrapper = param.inner.find((r) => r.field === 3 && r.wire === 2);
|
|
375
|
+
if (!wrapper) continue;
|
|
376
|
+
if (wantsFont && param.id === PARAM_FONT) {
|
|
377
|
+
const current = readParamValue(wrapper.bytes);
|
|
378
|
+
if (!current || !current.string) continue;
|
|
379
|
+
const next = buildFontDescriptor(parseFontDescriptor(current.string), changes);
|
|
380
|
+
wrapper.bytes = writeStringParam(wrapper.bytes, next);
|
|
381
|
+
param.record.bytes = serializeMessage(param.inner);
|
|
382
|
+
fontDone = true;
|
|
383
|
+
touched = true;
|
|
384
|
+
} else if (wantsPosition && param.id === PARAM_POSITION) {
|
|
385
|
+
const current = readParamValue(wrapper.bytes);
|
|
386
|
+
if (!current || !current.vector) continue;
|
|
387
|
+
wrapper.bytes = writeVectorParam(wrapper.bytes, [changes.position.x, changes.position.y]);
|
|
388
|
+
param.record.bytes = serializeMessage(param.inner);
|
|
389
|
+
positionDone = true;
|
|
390
|
+
touched = true;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (touched) effectRec.bytes = serializeMessage(effect.records);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (wantsFont && !fontDone) throw new Error(`no font parameter (${PARAM_FONT}) found on effect ${TEXT_STYLE_EFFECT}`);
|
|
397
|
+
if (wantsPosition && !positionDone) throw new Error(`no position parameter (${PARAM_POSITION}) found on effect ${TEXT_STYLE_EFFECT}`);
|
|
398
|
+
|
|
399
|
+
entry.value = wrapEffectFilters(serializeMessage(records), envelope.version).toString('hex');
|
|
400
|
+
return encodeKeyedDict(dict);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
module.exports = {
|
|
404
|
+
decodeSubtitleStyle,
|
|
405
|
+
encodeSubtitleStyle,
|
|
406
|
+
// exposed for tests and for callers needing the lower layers
|
|
407
|
+
parseMessage,
|
|
408
|
+
serializeMessage,
|
|
409
|
+
unwrapEffectFilters,
|
|
410
|
+
wrapEffectFilters,
|
|
411
|
+
parseFontDescriptor,
|
|
412
|
+
buildFontDescriptor,
|
|
413
|
+
TEXT_STYLE_EFFECT,
|
|
414
|
+
PARAM_FONT,
|
|
415
|
+
PARAM_POSITION,
|
|
416
|
+
};
|
package/src/granular/common.py
CHANGED
|
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
|
|
|
85
85
|
handlers=[logging.StreamHandler()],
|
|
86
86
|
)
|
|
87
87
|
|
|
88
|
-
VERSION = "2.
|
|
88
|
+
VERSION = "2.82.0"
|
|
89
89
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
90
90
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
91
91
|
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 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.82.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -16124,13 +16124,66 @@ def _render_settings_snapshot(proj):
|
|
|
16124
16124
|
settings = _ser(proj.GetRenderSettings())
|
|
16125
16125
|
else:
|
|
16126
16126
|
settings = {"error": "GetRenderSettings unavailable"}
|
|
16127
|
-
|
|
16127
|
+
snapshot = {
|
|
16128
16128
|
"format_and_codec": _ser(proj.GetCurrentRenderFormatAndCodec()),
|
|
16129
16129
|
"mode": _ser(proj.GetCurrentRenderMode()),
|
|
16130
16130
|
"settings": settings,
|
|
16131
16131
|
"jobs": _ser(proj.GetRenderJobList() or []),
|
|
16132
16132
|
"is_rendering": bool(proj.IsRenderingInProgress()),
|
|
16133
16133
|
}
|
|
16134
|
+
# The Deliver page's loaded preset carries render state beyond the keys a
|
|
16135
|
+
# caller passes, and SetRenderSettings applies on TOP of it rather than
|
|
16136
|
+
# replacing it. The scripting API exposes no reader for either half — there
|
|
16137
|
+
# is no GetCurrentRenderPresetName, and GetRenderSettings is not in the
|
|
16138
|
+
# documented surface — so this snapshot cannot report what is being
|
|
16139
|
+
# inherited. Say that in the payload instead of leaving the gap unnamed.
|
|
16140
|
+
if not isinstance(settings, dict) or "error" in settings:
|
|
16141
|
+
snapshot["settings_readable"] = False
|
|
16142
|
+
snapshot["inherited_state"] = (
|
|
16143
|
+
"unreadable: no GetRenderSettings / GetCurrentRenderPresetName in the "
|
|
16144
|
+
"scripting API. Whatever preset the Deliver page holds survives into "
|
|
16145
|
+
"this job for every key not explicitly passed. Pass from_preset to pin "
|
|
16146
|
+
"the base state."
|
|
16147
|
+
)
|
|
16148
|
+
else:
|
|
16149
|
+
snapshot["settings_readable"] = True
|
|
16150
|
+
return snapshot
|
|
16151
|
+
|
|
16152
|
+
|
|
16153
|
+
def _render_preset_pin(proj, preset_name: str):
|
|
16154
|
+
"""LoadRenderPreset with the name validated against the live preset list.
|
|
16155
|
+
|
|
16156
|
+
Returns (result_dict, err). LoadRenderPreset returns a bare False for a name
|
|
16157
|
+
that does not exist, which is indistinguishable from any other refusal, so
|
|
16158
|
+
the name is checked first and a miss names the available presets.
|
|
16159
|
+
"""
|
|
16160
|
+
try:
|
|
16161
|
+
available = [str(x) for x in (proj.GetRenderPresetList() or [])]
|
|
16162
|
+
except Exception:
|
|
16163
|
+
available = []
|
|
16164
|
+
if available and preset_name not in available:
|
|
16165
|
+
return None, _err(
|
|
16166
|
+
f"Render preset not found: {preset_name}",
|
|
16167
|
+
code="RENDER_PRESET_NOT_FOUND",
|
|
16168
|
+
category="invalid_input",
|
|
16169
|
+
reason="LoadRenderPreset refuses an unknown name with a bare False; no job was queued.",
|
|
16170
|
+
remediation="Use render(action='list_presets') for the names this project carries.",
|
|
16171
|
+
state={"requested_preset": preset_name, "available_presets": available},
|
|
16172
|
+
)
|
|
16173
|
+
loaded = bool(proj.LoadRenderPreset(preset_name))
|
|
16174
|
+
if not loaded:
|
|
16175
|
+
return None, _err(
|
|
16176
|
+
f"Could not load render preset: {preset_name}",
|
|
16177
|
+
code="RENDER_PRESET_LOAD_FAILED",
|
|
16178
|
+
category="engine_refused",
|
|
16179
|
+
reason=(
|
|
16180
|
+
"LoadRenderPreset returned False. Refusing to queue: the job would "
|
|
16181
|
+
"otherwise inherit whatever unrelated state the Deliver page holds."
|
|
16182
|
+
),
|
|
16183
|
+
remediation="Check render(action='list_presets'), then retry.",
|
|
16184
|
+
state={"requested_preset": preset_name, "available_presets": available},
|
|
16185
|
+
)
|
|
16186
|
+
return {"preset": preset_name, "loaded": True}, None
|
|
16134
16187
|
|
|
16135
16188
|
|
|
16136
16189
|
def _validate_render_settings_payload(settings: Dict[str, Any], *, require_temp_target: bool = False):
|
|
@@ -16230,6 +16283,16 @@ def _prepare_render_job(proj, p: Dict[str, Any]):
|
|
|
16230
16283
|
if p.get("dry_run"):
|
|
16231
16284
|
return _ok(validation=validation, format=p.get("format"), codec=p.get("codec"))
|
|
16232
16285
|
before = _render_settings_snapshot(proj)
|
|
16286
|
+
# Pin the base render state before layering explicit settings on top. Without
|
|
16287
|
+
# this the job inherits the Deliver page's loaded preset for every key the
|
|
16288
|
+
# caller does not pass — an Audio Only preset plus ExportVideo:True has been
|
|
16289
|
+
# measured to queue a job that reads back IsExportVideo:True and renders an
|
|
16290
|
+
# mp4 with no video stream (issue #123).
|
|
16291
|
+
preset_pin = None
|
|
16292
|
+
if p.get("from_preset"):
|
|
16293
|
+
preset_pin, err = _render_preset_pin(proj, str(p["from_preset"]))
|
|
16294
|
+
if err:
|
|
16295
|
+
return err
|
|
16233
16296
|
format_success = None
|
|
16234
16297
|
if p.get("format") and p.get("codec"):
|
|
16235
16298
|
formats = _render_formats(proj)
|
|
@@ -16261,7 +16324,7 @@ def _prepare_render_job(proj, p: Dict[str, Any]):
|
|
|
16261
16324
|
)
|
|
16262
16325
|
settings_success = bool(proj.SetRenderSettings(settings))
|
|
16263
16326
|
job_id = proj.AddRenderJob() if settings_success else None
|
|
16264
|
-
|
|
16327
|
+
result = {
|
|
16265
16328
|
"success": bool(job_id),
|
|
16266
16329
|
"job_id": job_id,
|
|
16267
16330
|
"format_success": format_success,
|
|
@@ -16269,6 +16332,30 @@ def _prepare_render_job(proj, p: Dict[str, Any]):
|
|
|
16269
16332
|
"before": before,
|
|
16270
16333
|
"settings": settings,
|
|
16271
16334
|
}
|
|
16335
|
+
if preset_pin:
|
|
16336
|
+
result["preset_pinned"] = preset_pin
|
|
16337
|
+
elif settings.get("ExportVideo") is True:
|
|
16338
|
+
# No pin, and the caller is asking for video. This is the exact shape
|
|
16339
|
+
# that produced a video-less mp4 in issue #123: the queued job's
|
|
16340
|
+
# IsExportVideo readback agreed with the request and the file did not.
|
|
16341
|
+
# The job readback is not a witness for the rendered file, so say so
|
|
16342
|
+
# here rather than let success=True imply a verified deliverable.
|
|
16343
|
+
result["warnings"] = result.get("warnings", []) + [{
|
|
16344
|
+
"code": "RENDER_PRESET_STATE_INHERITED",
|
|
16345
|
+
"message": (
|
|
16346
|
+
"This job inherits the Deliver page's current render state for every "
|
|
16347
|
+
"key not passed in settings, and that state is not readable from the "
|
|
16348
|
+
"scripting API. A previously loaded audio-only preset has been measured "
|
|
16349
|
+
"to survive an explicit ExportVideo:true and render an mp4 with no video "
|
|
16350
|
+
"stream, while the job readback reported IsExportVideo:true."
|
|
16351
|
+
),
|
|
16352
|
+
"remediation": (
|
|
16353
|
+
"Pass from_preset='<a video preset>' to pin the base state, and verify "
|
|
16354
|
+
"the rendered file has a codec_type=video stream before reporting it "
|
|
16355
|
+
"delivered — a long timeline that 'renders' in seconds is the tell."
|
|
16356
|
+
),
|
|
16357
|
+
}]
|
|
16358
|
+
return result
|
|
16272
16359
|
|
|
16273
16360
|
|
|
16274
16361
|
# ── Delivery targets ────────────────────────────────────────────────────────
|
|
@@ -16585,7 +16672,11 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
16585
16672
|
probe_render_settings() -> {format_and_codec, mode, settings, jobs, is_rendering}
|
|
16586
16673
|
validate_render_settings(settings, require_temp_target?) -> {valid, errors, unknown_keys}
|
|
16587
16674
|
safe_set_render_settings(settings, dry_run?, restore?, require_temp_target?) -> {success, diff}
|
|
16588
|
-
prepare_render_job(target_dir, settings?, format?, codec?, custom_name?, dry_run?) -> {success, job_id}
|
|
16675
|
+
prepare_render_job(target_dir, settings?, format?, codec?, custom_name?, from_preset?, dry_run?) -> {success, job_id}
|
|
16676
|
+
from_preset pins the base render state (LoadRenderPreset) before the
|
|
16677
|
+
explicit settings go on top. Without it the job inherits the Deliver
|
|
16678
|
+
page's loaded preset for every key not passed, which the API gives no
|
|
16679
|
+
way to read back — see the SetRenderSettings api_truth entry.
|
|
16589
16680
|
render_job_lifecycle_probe(target_dir, settings?, format?, codec?, custom_name?) -> {success, job_id, status_before_delete}
|
|
16590
16681
|
quick_export_capabilities() -> {presets, safe_params, guards}
|
|
16591
16682
|
safe_quick_export(preset, target_dir?|params?, custom_name?, dry_run?, allow_render?) -> {success, status}
|
package/src/utils/api_truth.py
CHANGED
|
@@ -858,12 +858,46 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
858
858
|
"isolation). Verified via dir() + SetProperty docs AND by live "
|
|
859
859
|
"mutating attempt on 21.0.0: SetProperty('Volume'|'Level'|'Gain'"
|
|
860
860
|
"|'AudioVolume', 0) all return False (note 'Pan' is the VIDEO "
|
|
861
|
-
"transform key, not audio pan, so it misleadingly succeeds)."
|
|
862
|
-
|
|
863
|
-
|
|
861
|
+
"transform key, not audio pan, so it misleadingly succeeds). "
|
|
862
|
+
"The gap is PER-PARAMETER control specifically: a whole saved "
|
|
863
|
+
"mix CAN be applied wholesale via "
|
|
864
|
+
"Project.ApplyFairlightPresetToCurrentTimeline(name), with the "
|
|
865
|
+
"available names from Resolve.GetFairlightPresets() — so "
|
|
866
|
+
"'no Fairlight write path exists' would be too strong.",
|
|
867
|
+
"recommended": "To reapply a known mix, save it once as a Fairlight preset "
|
|
868
|
+
"in the UI and apply it per-timeline with "
|
|
869
|
+
"ApplyFairlightPresetToCurrentTimeline (exposed as "
|
|
870
|
+
"resolve_control get_fairlight_presets + project_settings "
|
|
871
|
+
"apply_fairlight_preset). Dial individual levels/pan/EQ/"
|
|
872
|
+
"automation/FairlightFX in the Fairlight UI; beyond presets, "
|
|
873
|
+
"only voice-isolation state and channel-mapping reads are "
|
|
874
|
+
"scriptable.",
|
|
864
875
|
"tags": ["missing-method", "audio", "fairlight"],
|
|
865
876
|
"submit": "missing",
|
|
866
877
|
},
|
|
878
|
+
{
|
|
879
|
+
"symbol": "AI Audio Assistant (one-click timeline auto-mix)",
|
|
880
|
+
"object": "Timeline / Project",
|
|
881
|
+
"reality": "The Fairlight AI Audio Assistant — which analyses a timeline "
|
|
882
|
+
"and generates a balanced dialogue/music/effects mix — has no "
|
|
883
|
+
"scripting method. Nothing matching it appears in the Resolve "
|
|
884
|
+
"scripting API reference or in a dir() audit of Resolve, "
|
|
885
|
+
"Project, Timeline or TimelineItem. Note this is NOT because it "
|
|
886
|
+
"is a menu command: the API has no generic menu-invocation hook "
|
|
887
|
+
"at all, so scriptability is per-feature, and plenty of menu "
|
|
888
|
+
"commands DO have methods (DetectSceneCuts, Stabilize, "
|
|
889
|
+
"SmartReframe, CreateMagicMask, TranscribeAudio, "
|
|
890
|
+
"RemoveMotionBlur, AnalyzeForIntellisearch). It is compounded by "
|
|
891
|
+
"the per-parameter Fairlight gap above: even the mix it produces "
|
|
892
|
+
"cannot be read back or reconstructed clip-by-clip.",
|
|
893
|
+
"recommended": "No way to trigger it from a script. For a repeatable mix, "
|
|
894
|
+
"run the Assistant once in the UI, save the result as a "
|
|
895
|
+
"Fairlight preset, then apply that preset per-timeline with "
|
|
896
|
+
"project_settings apply_fairlight_preset — content-adaptive "
|
|
897
|
+
"per run is not achievable, a consistent template mix is.",
|
|
898
|
+
"tags": ["missing-method", "audio", "fairlight", "ai", "auto-mix"],
|
|
899
|
+
"submit": "missing",
|
|
900
|
+
},
|
|
867
901
|
{
|
|
868
902
|
"symbol": "Proxy / optimized-media generation",
|
|
869
903
|
"object": "MediaPoolItem",
|
|
@@ -964,9 +998,23 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
964
998
|
"'subtitlePosition', 'subtitleAlignment', "
|
|
965
999
|
"'subtitlePreset', 'subtitleStyle'). Verified via dir(), "
|
|
966
1000
|
"GetProperty(), and GetSetting() on Resolve 21.0.0.48.",
|
|
967
|
-
"recommended": "No workaround exists
|
|
968
|
-
"
|
|
969
|
-
"
|
|
1001
|
+
"recommended": "No API workaround exists, but the style IS reachable "
|
|
1002
|
+
"below the API: it lives in Sm2TiTrack.FieldsBlob for "
|
|
1003
|
+
"Type=2 tracks, as an EffectFiltersBA payload whose "
|
|
1004
|
+
"effect 136 carries a Qt QFont descriptor (param 18) and "
|
|
1005
|
+
"a normalised position vector (param 17). Exposed as "
|
|
1006
|
+
"project_db list_subtitle_styles / set_subtitle_style "
|
|
1007
|
+
"(font family/size/weight/italic + position). Confirmed "
|
|
1008
|
+
"live on 21.0 (2026-08-06): Resolve opens a patched track "
|
|
1009
|
+
"and re-serialises it back to its own zstd form with the "
|
|
1010
|
+
"patched values intact, so it genuinely parses the write. "
|
|
1011
|
+
"Caveats: whole-TRACK style not per-caption, project must "
|
|
1012
|
+
"be CLOSED, Resolve must be fully quit and relaunched "
|
|
1013
|
+
"afterwards, and the track must already carry a style "
|
|
1014
|
+
"blob (a freshly added subtitle track has none until it "
|
|
1015
|
+
"is styled once in the UI). Burn-in overlays via Fusion "
|
|
1016
|
+
"titles remain a visual alternative but do not produce "
|
|
1017
|
+
"subtitle tracks.",
|
|
970
1018
|
"tags": ["missing-method", "subtitle", "style", "preset"],
|
|
971
1019
|
"submit": "missing",
|
|
972
1020
|
},
|
|
@@ -1373,6 +1421,36 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
1373
1421
|
"issue": 59,
|
|
1374
1422
|
"mitigation": ["_render_format_id", "_render_codec_id"],
|
|
1375
1423
|
},
|
|
1424
|
+
{
|
|
1425
|
+
"symbol": "Project.SetRenderSettings (inherits the loaded preset)",
|
|
1426
|
+
"object": "Project",
|
|
1427
|
+
"signature": "({settings}) -> bool",
|
|
1428
|
+
"reality": "SetRenderSettings applies the passed keys ON TOP of whatever "
|
|
1429
|
+
"render state the Deliver page is holding; it does not replace "
|
|
1430
|
+
"it. A loaded preset carries more state than the keys a caller "
|
|
1431
|
+
"passes, and that state survives. Measured 2026-07-08: after a "
|
|
1432
|
+
"render through the stock 'Audio Only' preset, a job queued with "
|
|
1433
|
+
"an explicit ExportVideo=True and an .mp4 target returned "
|
|
1434
|
+
"settings_success=True and a real job id, GetRenderJobList "
|
|
1435
|
+
"reported IsExportVideo=True, and the rendered .mp4 contained "
|
|
1436
|
+
"only an AAC stream with NO video stream (ffprobe) — 18 minutes "
|
|
1437
|
+
"of material 'rendered' in ~10 seconds. The job readback is "
|
|
1438
|
+
"therefore NOT a witness for the rendered file. There is also no "
|
|
1439
|
+
"way to detect the inherited state: the scripting API documents "
|
|
1440
|
+
"no GetRenderSettings and no GetCurrentRenderPresetName, so the "
|
|
1441
|
+
"base state can be pinned but never read.",
|
|
1442
|
+
"recommended": "Pin the base state instead of inheriting one — "
|
|
1443
|
+
"prepare_render_job(from_preset='<a video preset>') runs "
|
|
1444
|
+
"LoadRenderPreset before the explicit settings go on top "
|
|
1445
|
+
"(PresetName flips to 'Custom' once they do, which is "
|
|
1446
|
+
"expected). Then verify the OUTPUT, not the job: ffprobe for "
|
|
1447
|
+
"a codec_type=video stream. A long timeline that completes "
|
|
1448
|
+
"in seconds is the tell.",
|
|
1449
|
+
"tags": ["render", "deliver", "silent-failure", "preset", "readback-lies"],
|
|
1450
|
+
"submit": "bug",
|
|
1451
|
+
"issue": 123,
|
|
1452
|
+
"mitigation": ["_render_preset_pin", "_prepare_render_job"],
|
|
1453
|
+
},
|
|
1376
1454
|
{
|
|
1377
1455
|
"symbol": "Project.SetCurrentRenderFormatAndCodec",
|
|
1378
1456
|
"object": "Project",
|