davinci-resolve-mcp 2.66.0 → 2.67.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 +83 -0
- package/README.md +1 -1
- package/docs/SKILL.md +10 -0
- package/docs/kernels/render-deliver-kernel.md +32 -0
- package/docs/process/release-process.md +8 -1
- package/docs/reference/api-limitations.md +18 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/README.md +4 -1
- package/resolve-advanced/server/deliverable-spec-bridge.mjs +310 -0
- package/resolve-advanced/server/runner-apply-contract.mjs +16 -1
- package/resolve-advanced/server/tools/deliverable.mjs +20 -1
- package/src/granular/common.py +5 -1
- package/src/granular/project.py +22 -7
- package/src/server.py +271 -22
- package/src/utils/api_truth.py +43 -0
- package/src/utils/delivery_targets.py +846 -0
- package/src/utils/render_ids.py +68 -0
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
"""Delivery targets — named render intents that also emit their own QC spec.
|
|
2
|
+
|
|
3
|
+
A delivery target is one definition with three projections:
|
|
4
|
+
|
|
5
|
+
1. **Resolve render settings** — keys from `_RENDER_SETTING_KEYS`, fed to
|
|
6
|
+
`SetRenderSettings` / `prepare_render_job` on the live Python server.
|
|
7
|
+
2. **A QC spec** — the ffprobe-shaped vocabulary `checkDeliverable` consumes on
|
|
8
|
+
the advanced (Node) server, so a rendered file can be verified against the
|
|
9
|
+
same intent that produced it.
|
|
10
|
+
3. **A human label/description** for the agent and the control panel.
|
|
11
|
+
|
|
12
|
+
## Ids are spec-descriptive; platform names are aliases
|
|
13
|
+
|
|
14
|
+
Platform recommendations drift; container/codec/frame shape does not. So the
|
|
15
|
+
canonical ids describe the *deliverable*, and platform names (`youtube`,
|
|
16
|
+
`tiktok`, `reels`) are aliases pointing at them. When a platform changes its
|
|
17
|
+
guidance you repoint one alias instead of rewriting a target — and no committed
|
|
18
|
+
id ever becomes a lie about what it produces.
|
|
19
|
+
|
|
20
|
+
## Why candidates, not a single codec string
|
|
21
|
+
|
|
22
|
+
Format and codec are stored as **ordered candidate tuples**, not single strings.
|
|
23
|
+
`GetRenderFormats()` returns ``{format -> extension}`` and `GetRenderCodecs()`
|
|
24
|
+
returns ``{codec description -> codec name}``, and the exact description strings
|
|
25
|
+
vary across Resolve versions, licenses, and installed IO plugins. A target names
|
|
26
|
+
every spelling it knows and `select_available()` picks the first that exists in
|
|
27
|
+
the live map, so the table survives version drift instead of silently missing.
|
|
28
|
+
|
|
29
|
+
Nothing here is authoritative about what a given machine supports — availability
|
|
30
|
+
is machine, license, and plugin dependent. Resolution happens against the live
|
|
31
|
+
matrix at call time, and an unmatched target fails loudly with the available list
|
|
32
|
+
rather than guessing.
|
|
33
|
+
|
|
34
|
+
## What is deliberately NOT encoded
|
|
35
|
+
|
|
36
|
+
- **Bitrate.** Resolve has no bitrate render-setting key; it exposes only
|
|
37
|
+
`VideoQuality`, whose type and meaning vary per codec (int for some, string
|
|
38
|
+
for others). A portable number is not expressible here, so quality is left at
|
|
39
|
+
the Resolve/format default rather than guessed.
|
|
40
|
+
- **`AudioCodec`.** Left unset so Resolve picks the container default, which is
|
|
41
|
+
correct for every target here.
|
|
42
|
+
|
|
43
|
+
Both omissions follow the module's core rule:
|
|
44
|
+
|
|
45
|
+
**The QC spec asserts only what the render settings actually pin.**
|
|
46
|
+
|
|
47
|
+
Asserting a field the render never controlled produces QC failures that say
|
|
48
|
+
nothing about the deliverable — so unset fields are absent from both projections.
|
|
49
|
+
|
|
50
|
+
## Not every target has a QC projection
|
|
51
|
+
|
|
52
|
+
Image-sequence targets render *many* files and package targets (IMF, DCP) render
|
|
53
|
+
a directory; `deliverable_qc` probes one file. Those targets carry no `qc_codec`
|
|
54
|
+
and instead set `qc_skip_reason`, and `to_qc_spec()` returns None for them rather
|
|
55
|
+
than emitting a spec that would be checked against an arbitrary single frame.
|
|
56
|
+
Every target must have one or the other — a missing QC projection is always
|
|
57
|
+
explained, never silent.
|
|
58
|
+
|
|
59
|
+
## Live-verified against the real matrix
|
|
60
|
+
|
|
61
|
+
Codec candidates were checked against DaVinci Resolve Studio 19.1.3.7 on
|
|
62
|
+
2026-07-27 (20 formats / 271 format-codec pairs). That pass corrected real
|
|
63
|
+
mistakes — plain `"DNxHR HQX"` and `"DNxHR 444"` do not exist (the live labels
|
|
64
|
+
carry a bit depth: `"DNxHR HQX 10-bit"`), DPX/TIFF codecs are spelled
|
|
65
|
+
`"RGB 10 bits"` not `"RGB 10-bit"`, PNG is not a render format at all, and the
|
|
66
|
+
`Wave` format exposes **zero** codecs and rejects every codec value passed to
|
|
67
|
+
`SetCurrentRenderFormatAndCodec`, so an audio-only WAV target is not expressible
|
|
68
|
+
through this API. Availability still varies by version, license, and installed
|
|
69
|
+
IO plugins, so resolution remains live.
|
|
70
|
+
|
|
71
|
+
## ffprobe gotcha encoded here
|
|
72
|
+
|
|
73
|
+
ffprobe reports `format_name=mov,mp4,m4a,3gp,3g2,mj2` for **both** .mov and .mp4,
|
|
74
|
+
and `ffprobe-media.mjs` takes the first token — so `container` is `"mov"` for
|
|
75
|
+
both and cannot discriminate them. `video.codec` is the real discriminator.
|
|
76
|
+
Verified 2026-07-27 against ffmpeg-generated files.
|
|
77
|
+
|
|
78
|
+
Storage: these shipped defaults are a Python literal because the npm `files`
|
|
79
|
+
glob is `src/**/*.py` — a JSON sidecar under `src/` ships in a git checkout and
|
|
80
|
+
silently vanishes on install. User overrides live in `logs/delivery-targets.json`
|
|
81
|
+
(see `user_targets_path`); per-project deliverables belong in the project DB.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
from __future__ import annotations
|
|
85
|
+
|
|
86
|
+
import json
|
|
87
|
+
import logging
|
|
88
|
+
import os
|
|
89
|
+
from dataclasses import dataclass, replace
|
|
90
|
+
from pathlib import Path
|
|
91
|
+
from typing import Any, Dict, Mapping, Optional, Sequence, Tuple
|
|
92
|
+
|
|
93
|
+
logger = logging.getLogger("resolve-mcp.delivery-targets")
|
|
94
|
+
|
|
95
|
+
SCHEMA_VERSION = "2.1"
|
|
96
|
+
|
|
97
|
+
ENV_USER_TARGETS = "DAVINCI_RESOLVE_MCP_DELIVERY_TARGETS"
|
|
98
|
+
|
|
99
|
+
#: Grouping for listings/UI only — no behavior attaches to these.
|
|
100
|
+
TIERS = ("master", "web", "sequence", "broadcast", "package")
|
|
101
|
+
|
|
102
|
+
#: What the shipped candidates were checked against. Availability is still
|
|
103
|
+
#: machine/license/plugin dependent, so resolution happens live regardless.
|
|
104
|
+
VERIFIED_ON = "DaVinci Resolve Studio 19.1.3.7 (2026-07-27, 20 formats / 271 pairs)"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── Target model ────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class DeliveryTarget:
|
|
112
|
+
"""One named render intent. `None` on any optional field means "do not pin"."""
|
|
113
|
+
|
|
114
|
+
id: str
|
|
115
|
+
label: str
|
|
116
|
+
describe: str
|
|
117
|
+
tier: str
|
|
118
|
+
|
|
119
|
+
# Ordered spellings; the first that exists in the live map wins.
|
|
120
|
+
format_candidates: Tuple[str, ...] = ()
|
|
121
|
+
codec_candidates: Tuple[str, ...] = ()
|
|
122
|
+
|
|
123
|
+
# ffprobe side. qc_codec None => no single-file QC projection; qc_skip_reason
|
|
124
|
+
# must then say why, so a missing check is always explained rather than silent.
|
|
125
|
+
qc_container: Optional[str] = None
|
|
126
|
+
qc_codec: Optional[str] = None
|
|
127
|
+
qc_audio_codec: Optional[str] = None
|
|
128
|
+
qc_skip_reason: Optional[str] = None
|
|
129
|
+
|
|
130
|
+
width: Optional[int] = None
|
|
131
|
+
height: Optional[int] = None
|
|
132
|
+
fps: Optional[float] = None # None = inherit the timeline's rate
|
|
133
|
+
audio_channels: Optional[int] = None
|
|
134
|
+
audio_sample_rate: Optional[int] = None
|
|
135
|
+
audio_bit_depth: Optional[int] = None
|
|
136
|
+
export_video: bool = True
|
|
137
|
+
export_audio: bool = True
|
|
138
|
+
export_alpha: bool = False
|
|
139
|
+
|
|
140
|
+
verified: str = ""
|
|
141
|
+
source: str = ""
|
|
142
|
+
notes: Tuple[str, ...] = ()
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def format(self) -> str:
|
|
146
|
+
"""Preferred format spelling, for display and as the resolution seed."""
|
|
147
|
+
return self.format_candidates[0] if self.format_candidates else ""
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def codec(self) -> str:
|
|
151
|
+
"""Preferred codec spelling, for display and as the resolution seed."""
|
|
152
|
+
return self.codec_candidates[0] if self.codec_candidates else ""
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def has_qc_projection(self) -> bool:
|
|
156
|
+
return bool(self.qc_codec or self.qc_audio_codec)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ── Shipped table ───────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
_TIMELINE_NOTE = (
|
|
162
|
+
"Frame size and rate inherit the timeline, so the deliverable matches the cut "
|
|
163
|
+
"rather than a fixed raster."
|
|
164
|
+
)
|
|
165
|
+
_SEQUENCE_SKIP = "renders an image sequence (many files); deliverable_qc probes one"
|
|
166
|
+
_PACKAGE_SKIP = "renders a package directory, not a single file; deliverable_qc probes one"
|
|
167
|
+
_SEQUENCE_NOTE = "Per-frame checks are a separate pass from deliverable_qc."
|
|
168
|
+
_LABELS_NOTE = (
|
|
169
|
+
"Codec description strings vary by Resolve version/license; candidates cover the "
|
|
170
|
+
"known spellings and resolution happens against the live matrix."
|
|
171
|
+
)
|
|
172
|
+
_QT = ("QuickTime", "mov")
|
|
173
|
+
_MP4 = ("MP4", "mp4")
|
|
174
|
+
|
|
175
|
+
_VERIFIED = "2026-07-27"
|
|
176
|
+
_PRORES_SRC = "Live matrix: QuickTime/ProRes render pairs."
|
|
177
|
+
_DNX_SRC = "Live matrix: QuickTime/DNx render pairs; Avid-family mastering codecs."
|
|
178
|
+
_WEB_SRC = "Container/codec/raster shape are long-stable web basics; no bitrate is pinned."
|
|
179
|
+
_SEQ_SRC = "Live matrix: image-sequence render formats for VFX/finishing handoff."
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _prores(key: str, label_bits: str, codecs: Tuple[str, ...], describe: str, **kw) -> DeliveryTarget:
|
|
183
|
+
return DeliveryTarget(
|
|
184
|
+
id=key,
|
|
185
|
+
label=f"ProRes {label_bits} master",
|
|
186
|
+
describe=describe,
|
|
187
|
+
tier="master",
|
|
188
|
+
format_candidates=_QT,
|
|
189
|
+
codec_candidates=codecs,
|
|
190
|
+
qc_container="mov",
|
|
191
|
+
qc_codec="prores",
|
|
192
|
+
audio_channels=2,
|
|
193
|
+
audio_sample_rate=48000,
|
|
194
|
+
audio_bit_depth=24,
|
|
195
|
+
verified=_VERIFIED,
|
|
196
|
+
source=_PRORES_SRC,
|
|
197
|
+
notes=(_TIMELINE_NOTE, _LABELS_NOTE),
|
|
198
|
+
**kw,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _dnxhr(key: str, label_bits: str, codecs: Tuple[str, ...], describe: str, **kw) -> DeliveryTarget:
|
|
203
|
+
return DeliveryTarget(
|
|
204
|
+
id=key,
|
|
205
|
+
label=f"DNxHR {label_bits} master",
|
|
206
|
+
describe=describe,
|
|
207
|
+
tier="master",
|
|
208
|
+
format_candidates=_QT,
|
|
209
|
+
codec_candidates=codecs,
|
|
210
|
+
qc_container="mov",
|
|
211
|
+
qc_codec="dnxhd", # ffprobe reports the DNxHR family as dnxhd
|
|
212
|
+
audio_channels=2,
|
|
213
|
+
audio_sample_rate=48000,
|
|
214
|
+
audio_bit_depth=24,
|
|
215
|
+
verified=_VERIFIED,
|
|
216
|
+
source=_DNX_SRC,
|
|
217
|
+
notes=(_TIMELINE_NOTE, _LABELS_NOTE, "ffprobe reports DNxHR as codec_name=dnxhd."),
|
|
218
|
+
**kw,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _web(key: str, label: str, codecs: Tuple[str, ...], w: int, h: int, qc_codec: str,
|
|
223
|
+
describe: str, notes: Tuple[str, ...] = ()) -> DeliveryTarget:
|
|
224
|
+
return DeliveryTarget(
|
|
225
|
+
id=key,
|
|
226
|
+
label=label,
|
|
227
|
+
describe=describe,
|
|
228
|
+
tier="web",
|
|
229
|
+
format_candidates=_MP4,
|
|
230
|
+
codec_candidates=codecs,
|
|
231
|
+
qc_container="mov", # ffprobe first token is "mov" for mp4 too
|
|
232
|
+
qc_codec=qc_codec,
|
|
233
|
+
width=w,
|
|
234
|
+
height=h,
|
|
235
|
+
audio_channels=2,
|
|
236
|
+
audio_sample_rate=48000,
|
|
237
|
+
verified="2026-07-27",
|
|
238
|
+
source=_WEB_SRC,
|
|
239
|
+
notes=notes,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _sequence(key: str, label: str, formats: Tuple[str, ...], codecs: Tuple[str, ...],
|
|
244
|
+
describe: str, notes: Tuple[str, ...] = ()) -> DeliveryTarget:
|
|
245
|
+
return DeliveryTarget(
|
|
246
|
+
id=key,
|
|
247
|
+
label=label,
|
|
248
|
+
describe=describe,
|
|
249
|
+
tier="sequence",
|
|
250
|
+
format_candidates=formats,
|
|
251
|
+
codec_candidates=codecs,
|
|
252
|
+
export_audio=False,
|
|
253
|
+
qc_skip_reason=_SEQUENCE_SKIP,
|
|
254
|
+
verified=_VERIFIED,
|
|
255
|
+
source=_SEQ_SRC,
|
|
256
|
+
notes=(_SEQUENCE_NOTE, _LABELS_NOTE) + notes,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _broadcast(key: str, label: str, codecs: Tuple[str, ...], describe: str,
|
|
261
|
+
w: Optional[int] = None, h: Optional[int] = None,
|
|
262
|
+
qc_codec: str = "h264", notes: Tuple[str, ...] = ()) -> DeliveryTarget:
|
|
263
|
+
return DeliveryTarget(
|
|
264
|
+
id=key,
|
|
265
|
+
label=label,
|
|
266
|
+
describe=describe,
|
|
267
|
+
tier="broadcast",
|
|
268
|
+
format_candidates=("MXF OP1A", "mxf_op1a"),
|
|
269
|
+
codec_candidates=codecs,
|
|
270
|
+
qc_container="mxf",
|
|
271
|
+
qc_codec=qc_codec,
|
|
272
|
+
width=w,
|
|
273
|
+
height=h,
|
|
274
|
+
audio_channels=2,
|
|
275
|
+
audio_sample_rate=48000,
|
|
276
|
+
audio_bit_depth=24,
|
|
277
|
+
verified=_VERIFIED,
|
|
278
|
+
source="Live matrix: MXF OP1A broadcast codecs.",
|
|
279
|
+
notes=notes,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _package(key: str, label: str, formats: Tuple[str, ...], codecs: Tuple[str, ...],
|
|
284
|
+
describe: str, notes: Tuple[str, ...]) -> DeliveryTarget:
|
|
285
|
+
return DeliveryTarget(
|
|
286
|
+
id=key,
|
|
287
|
+
label=label,
|
|
288
|
+
describe=describe,
|
|
289
|
+
tier="package",
|
|
290
|
+
format_candidates=formats,
|
|
291
|
+
codec_candidates=codecs,
|
|
292
|
+
qc_skip_reason=_PACKAGE_SKIP,
|
|
293
|
+
audio_channels=2,
|
|
294
|
+
audio_sample_rate=48000,
|
|
295
|
+
verified=_VERIFIED,
|
|
296
|
+
source="Live matrix: package render formats.",
|
|
297
|
+
notes=notes,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
_H264 = ("H.264", "H264")
|
|
302
|
+
_H265 = ("H.265", "H265", "HEVC")
|
|
303
|
+
|
|
304
|
+
DELIVERY_TARGETS: Dict[str, DeliveryTarget] = {
|
|
305
|
+
# ── Mastering: ProRes family ────────────────────────────────────────────
|
|
306
|
+
"prores422proxy_master": _prores(
|
|
307
|
+
"prores422proxy_master", "422 Proxy",
|
|
308
|
+
("Apple ProRes 422 Proxy", "ProRes422Proxy"),
|
|
309
|
+
"Lightest ProRes tier. Offline/review copies, not a finishing master.",
|
|
310
|
+
),
|
|
311
|
+
"prores422lt_master": _prores(
|
|
312
|
+
"prores422lt_master", "422 LT",
|
|
313
|
+
("Apple ProRes 422 LT", "ProRes422LT"),
|
|
314
|
+
"Light ProRes tier. Review and lightweight distribution.",
|
|
315
|
+
),
|
|
316
|
+
"prores422_master": _prores(
|
|
317
|
+
"prores422_master", "422",
|
|
318
|
+
("Apple ProRes 422", "ProRes422"),
|
|
319
|
+
"Standard ProRes tier. Common broadcast/agency mezzanine.",
|
|
320
|
+
),
|
|
321
|
+
"prores422hq_master": _prores(
|
|
322
|
+
"prores422hq_master", "422 HQ",
|
|
323
|
+
("Apple ProRes 422 HQ", "ProRes422HQ"),
|
|
324
|
+
"The default finishing master for most work. 48 kHz 24-bit stereo.",
|
|
325
|
+
),
|
|
326
|
+
"prores4444_master": _prores(
|
|
327
|
+
"prores4444_master", "4444",
|
|
328
|
+
("Apple ProRes 4444", "ProRes4444"),
|
|
329
|
+
"Mastering with an alpha channel, for graphics/VFX handoff.",
|
|
330
|
+
export_alpha=True,
|
|
331
|
+
),
|
|
332
|
+
"prores4444xq_master": _prores(
|
|
333
|
+
"prores4444xq_master", "4444 XQ",
|
|
334
|
+
("Apple ProRes 4444 XQ", "ProRes4444XQ"),
|
|
335
|
+
"Highest ProRes tier with alpha. HDR and heavy-grade finishing.",
|
|
336
|
+
export_alpha=True,
|
|
337
|
+
),
|
|
338
|
+
# ── Mastering: DNxHR family ─────────────────────────────────────────────
|
|
339
|
+
"dnxhr_lb_master": _dnxhr(
|
|
340
|
+
"dnxhr_lb_master", "LB",
|
|
341
|
+
("DNxHR LB", "DNxHR_LB"),
|
|
342
|
+
"Low-bandwidth DNxHR. Offline/review, not finishing.",
|
|
343
|
+
),
|
|
344
|
+
"dnxhr_sq_master": _dnxhr(
|
|
345
|
+
"dnxhr_sq_master", "SQ",
|
|
346
|
+
("DNxHR SQ", "DNxHR_SQ"),
|
|
347
|
+
"Standard-quality DNxHR. Avid-family review and distribution.",
|
|
348
|
+
),
|
|
349
|
+
"dnxhr_hq_master": _dnxhr(
|
|
350
|
+
"dnxhr_hq_master", "HQ",
|
|
351
|
+
("DNxHR HQ", "DNxHR_HQ"),
|
|
352
|
+
"High-quality 8-bit DNxHR. Common Avid mezzanine.",
|
|
353
|
+
),
|
|
354
|
+
# Live matrix carries a bit depth in the label — plain "DNxHR HQX" does not exist.
|
|
355
|
+
"dnxhr_hqx_master": _dnxhr(
|
|
356
|
+
"dnxhr_hqx_master", "HQX 10-bit",
|
|
357
|
+
("DNxHR HQX 10-bit", "DNxHRHQX_10", "DNxHR HQX 12-bit", "DNxHRHQX_12", "DNxHR HQX"),
|
|
358
|
+
"10-bit DNxHR. The DNx tier to use for finishing and HDR.",
|
|
359
|
+
),
|
|
360
|
+
"dnxhr_444_master": _dnxhr(
|
|
361
|
+
"dnxhr_444_master", "444 12-bit",
|
|
362
|
+
("DNxHR 444 12-bit", "DNxHR444_12", "DNxHR 444 10-bit", "DNxHR444_10", "DNxHR 444"),
|
|
363
|
+
"4:4:4 12-bit DNxHR. Highest DNx tier for finishing.",
|
|
364
|
+
),
|
|
365
|
+
"dnxhd_1080p220_10_master": _dnxhr(
|
|
366
|
+
"dnxhd_1080p220_10_master", "HD 1080p 220 10-bit",
|
|
367
|
+
("DNxHD 1080p 220/185/175 10-bit", "DNxHD1080p220_10"),
|
|
368
|
+
"HD-era 10-bit DNxHD for Avid finishing at 1920x1080.",
|
|
369
|
+
width=1920,
|
|
370
|
+
height=1080,
|
|
371
|
+
),
|
|
372
|
+
# ── Web / streaming ─────────────────────────────────────────────────────
|
|
373
|
+
"h264_720p_web": _web(
|
|
374
|
+
"h264_720p_web", "H.264 720p web", _H264, 1280, 720, "h264",
|
|
375
|
+
"Small 1280x720 web deliverable for previews and low-bandwidth use.",
|
|
376
|
+
),
|
|
377
|
+
"h264_1080p_web": _web(
|
|
378
|
+
"h264_1080p_web", "H.264 1080p web", _H264, 1920, 1080, "h264",
|
|
379
|
+
"General-purpose 1920x1080 web deliverable. The safe default.",
|
|
380
|
+
),
|
|
381
|
+
"h264_2160p_web": _web(
|
|
382
|
+
"h264_2160p_web", "H.264 2160p web", _H264, 3840, 2160, "h264",
|
|
383
|
+
"UHD 3840x2160 in H.264, for players without reliable H.265 support.",
|
|
384
|
+
),
|
|
385
|
+
"h265_1080p_web": _web(
|
|
386
|
+
"h265_1080p_web", "H.265 1080p web", _H265, 1920, 1080, "hevc",
|
|
387
|
+
"1920x1080 in H.265 for smaller files where the player supports it.",
|
|
388
|
+
notes=("H.265 availability is license/plugin dependent.",),
|
|
389
|
+
),
|
|
390
|
+
"h265_2160p_web": _web(
|
|
391
|
+
"h265_2160p_web", "H.265 2160p web", _H265, 3840, 2160, "hevc",
|
|
392
|
+
"UHD 3840x2160 web deliverable. The usual 4K web choice.",
|
|
393
|
+
notes=("H.265 availability is license/plugin dependent.",),
|
|
394
|
+
),
|
|
395
|
+
"h264_vertical_1080_web": _web(
|
|
396
|
+
"h264_vertical_1080_web", "H.264 1080x1920 vertical", _H264, 1080, 1920, "h264",
|
|
397
|
+
"Full-frame vertical deliverable for short-form/social placements.",
|
|
398
|
+
notes=("Vertical raster only — reframing is an editorial decision, not a render setting.",),
|
|
399
|
+
),
|
|
400
|
+
"h264_square_1080_web": _web(
|
|
401
|
+
"h264_square_1080_web", "H.264 1080x1080 square", _H264, 1080, 1080, "h264",
|
|
402
|
+
"Square deliverable for feed placements.",
|
|
403
|
+
notes=("Square raster only — reframing is an editorial decision, not a render setting.",),
|
|
404
|
+
),
|
|
405
|
+
# ── Image sequences (finishing / VFX handoff) ───────────────────────────
|
|
406
|
+
# Live labels are "RGB 10 bits" (plural, spaced), not "RGB 10-bit".
|
|
407
|
+
# PNG is deliberately absent: it is not a Resolve render format.
|
|
408
|
+
"dpx_sequence": _sequence(
|
|
409
|
+
"dpx_sequence", "DPX image sequence", ("DPX", "dpx"),
|
|
410
|
+
("RGB 10 bits", "RGB10", "RGB 12 bits", "RGB 16 bits"),
|
|
411
|
+
"10-bit DPX frames. The traditional film/finishing interchange.",
|
|
412
|
+
),
|
|
413
|
+
"exr_sequence": _sequence(
|
|
414
|
+
"exr_sequence", "OpenEXR image sequence", ("EXR", "exr"),
|
|
415
|
+
("RGB half", "RGBHalf", "RGB half (ZIP)", "RGB float", "RGBFloat"),
|
|
416
|
+
"OpenEXR frames for VFX handoff; carries linear scene-referred data.",
|
|
417
|
+
notes=("Set the project's color management for a scene-linear export before using this.",),
|
|
418
|
+
),
|
|
419
|
+
"tiff_sequence": _sequence(
|
|
420
|
+
"tiff_sequence", "TIFF image sequence", ("TIFF", "tif"),
|
|
421
|
+
("RGB 16 bits", "RGB16", "RGB 8 bits"),
|
|
422
|
+
"TIFF frames for stills-oriented or archival handoff.",
|
|
423
|
+
),
|
|
424
|
+
# ── Broadcast / Avid handoff ────────────────────────────────────────────
|
|
425
|
+
"dnxhr_hq_mxf_opatom": DeliveryTarget(
|
|
426
|
+
id="dnxhr_hq_mxf_opatom",
|
|
427
|
+
label="DNxHR HQ MXF OP-Atom (Avid)",
|
|
428
|
+
describe="Avid-native handoff: MXF OP-Atom essence, DNxHR HQ.",
|
|
429
|
+
tier="broadcast",
|
|
430
|
+
format_candidates=("MXF OP-Atom", "MXF_OP_Atom", "mxf_op_atom"),
|
|
431
|
+
codec_candidates=("DNxHR HQ", "DNxHR_HQ"),
|
|
432
|
+
qc_container="mxf",
|
|
433
|
+
qc_codec="dnxhd",
|
|
434
|
+
audio_channels=2,
|
|
435
|
+
audio_sample_rate=48000,
|
|
436
|
+
audio_bit_depth=24,
|
|
437
|
+
verified=_VERIFIED,
|
|
438
|
+
source="Live matrix: MXF OP-Atom + DNxHR HQ (Avid handoff).",
|
|
439
|
+
notes=(
|
|
440
|
+
_TIMELINE_NOTE,
|
|
441
|
+
_LABELS_NOTE,
|
|
442
|
+
"OP-Atom writes separate essence files per track; confirm the layout your Avid expects.",
|
|
443
|
+
),
|
|
444
|
+
),
|
|
445
|
+
# No audio-only target: the `Wave` format exposes ZERO codecs and
|
|
446
|
+
# SetCurrentRenderFormatAndCodec('wav', <anything>) is rejected, so an
|
|
447
|
+
# audio-only WAV deliverable is not expressible through this API.
|
|
448
|
+
# Verified against Resolve Studio 19.1.3.7 on 2026-07-27.
|
|
449
|
+
#
|
|
450
|
+
# ── Broadcast (MXF OP1A) ────────────────────────────────────────────────
|
|
451
|
+
"xavc_intra_1080_broadcast": _broadcast(
|
|
452
|
+
"xavc_intra_1080_broadcast", "Sony XAVC Intra 100 1080p",
|
|
453
|
+
("Sony XAVC Intra CBG 100 1920x1080", "SonyXAVCIC100_1920x1080"),
|
|
454
|
+
"Sony XAVC Intra Class 100 at 1920x1080. Common broadcast delivery.",
|
|
455
|
+
w=1920, h=1080,
|
|
456
|
+
),
|
|
457
|
+
"xavc_intra_uhd_broadcast": _broadcast(
|
|
458
|
+
"xavc_intra_uhd_broadcast", "Sony XAVC Intra 300 UHD",
|
|
459
|
+
("Sony XAVC Intra CBG 300 3840x2160", "SonyXAVCIC300_3840x2160"),
|
|
460
|
+
"Sony XAVC Intra Class 300 at 3840x2160 for UHD broadcast delivery.",
|
|
461
|
+
w=3840, h=2160,
|
|
462
|
+
),
|
|
463
|
+
"avcintra_1080_c100_broadcast": _broadcast(
|
|
464
|
+
"avcintra_1080_c100_broadcast", "AVC-Intra 1080 Class 100",
|
|
465
|
+
("AVC Intra 1080 Class 100", "PanasonicAVCIntra_1080_C100"),
|
|
466
|
+
"Panasonic AVC-Intra Class 100 at 1080. Broadcast/ENG delivery.",
|
|
467
|
+
w=1920, h=1080,
|
|
468
|
+
),
|
|
469
|
+
# ── Package formats ─────────────────────────────────────────────────────
|
|
470
|
+
"imf_prores422hq": _package(
|
|
471
|
+
"imf_prores422hq", "IMF package (ProRes 422 HQ)",
|
|
472
|
+
("IMF", "imf"), ("Apple ProRes 422 HQ", "ProRes422HQ"),
|
|
473
|
+
"IMF package with a ProRes 422 HQ video essence.",
|
|
474
|
+
notes=(
|
|
475
|
+
"IMF is a PACKAGE: composition playlists, per-asset constraints, and supplemental "
|
|
476
|
+
"packages are not expressible as a render target. This selects the format/codec only "
|
|
477
|
+
"— the packaging rules still need a human.",
|
|
478
|
+
),
|
|
479
|
+
),
|
|
480
|
+
"dcp_2k_dci": _package(
|
|
481
|
+
"dcp_2k_dci", "DCP 2K DCI",
|
|
482
|
+
("DCP", "dcp"), ("Kakadu JPEG 2000 2K DCI", "KJP2K_2KDCI"),
|
|
483
|
+
"2K DCI Digital Cinema Package with a Kakadu JPEG 2000 essence.",
|
|
484
|
+
notes=(
|
|
485
|
+
"DCP is a PACKAGE: reels, KDMs, encryption, and CPL/PKL structure are not expressible "
|
|
486
|
+
"as a render target. This selects the format/codec only — the packaging rules still "
|
|
487
|
+
"need a human. easyDCP variants exist on this install too.",
|
|
488
|
+
),
|
|
489
|
+
),
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
VALID_TARGETS = frozenset(DELIVERY_TARGETS)
|
|
493
|
+
|
|
494
|
+
#: Platform names are pointers, not targets. Repoint these when a platform
|
|
495
|
+
#: changes its guidance; never bake a platform name into a target id.
|
|
496
|
+
TARGET_ALIASES: Dict[str, str] = {
|
|
497
|
+
"youtube": "h264_1080p_web",
|
|
498
|
+
"youtube_1080p": "h264_1080p_web",
|
|
499
|
+
"youtube_4k": "h265_2160p_web",
|
|
500
|
+
"youtube_2160p": "h265_2160p_web",
|
|
501
|
+
"vimeo": "h264_1080p_web",
|
|
502
|
+
"vimeo_1080p": "h264_1080p_web",
|
|
503
|
+
"vimeo_4k": "h265_2160p_web",
|
|
504
|
+
"tiktok": "h264_vertical_1080_web",
|
|
505
|
+
"reels": "h264_vertical_1080_web",
|
|
506
|
+
"shorts": "h264_vertical_1080_web",
|
|
507
|
+
"instagram_feed": "h264_square_1080_web",
|
|
508
|
+
"instagram_reels": "h264_vertical_1080_web",
|
|
509
|
+
"web": "h264_1080p_web",
|
|
510
|
+
"master": "prores422hq_master",
|
|
511
|
+
"prores": "prores422hq_master",
|
|
512
|
+
"prores_master": "prores422hq_master",
|
|
513
|
+
"dnxhr": "dnxhr_hqx_master",
|
|
514
|
+
"dnxhd": "dnxhd_1080p220_10_master",
|
|
515
|
+
"xavc": "xavc_intra_1080_broadcast",
|
|
516
|
+
"broadcast": "xavc_intra_1080_broadcast",
|
|
517
|
+
"imf": "imf_prores422hq",
|
|
518
|
+
"dcp": "dcp_2k_dci",
|
|
519
|
+
"dnx_master": "dnxhr_hqx_master",
|
|
520
|
+
"avid": "dnxhr_hq_mxf_opatom",
|
|
521
|
+
"vfx": "exr_sequence",
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
#: Fields a caller may override per call. Deliberately excludes id/label/tier/
|
|
525
|
+
#: verified/source — those describe the shipped definition, not the render.
|
|
526
|
+
OVERRIDE_KEYS = frozenset(
|
|
527
|
+
{
|
|
528
|
+
"format_candidates",
|
|
529
|
+
"codec_candidates",
|
|
530
|
+
"width",
|
|
531
|
+
"height",
|
|
532
|
+
"fps",
|
|
533
|
+
"audio_channels",
|
|
534
|
+
"audio_sample_rate",
|
|
535
|
+
"audio_bit_depth",
|
|
536
|
+
"export_video",
|
|
537
|
+
"export_audio",
|
|
538
|
+
"export_alpha",
|
|
539
|
+
"qc_container",
|
|
540
|
+
"qc_codec",
|
|
541
|
+
"qc_audio_codec",
|
|
542
|
+
}
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
_INT_KEYS = frozenset({"width", "height", "audio_channels", "audio_sample_rate", "audio_bit_depth"})
|
|
546
|
+
_BOOL_KEYS = frozenset({"export_video", "export_audio", "export_alpha"})
|
|
547
|
+
_TUPLE_KEYS = frozenset({"format_candidates", "codec_candidates"})
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
# ── Live-matrix resolution ──────────────────────────────────────────────────
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def select_available(candidates: Sequence[str], mapping: Any) -> Optional[str]:
|
|
554
|
+
"""Pick the first candidate present in a live {description: id} map.
|
|
555
|
+
|
|
556
|
+
Returns the resolved **id** (the dict value), or None when no candidate
|
|
557
|
+
matches — which is the signal to fail loudly with the available list rather
|
|
558
|
+
than handing Resolve a name it will reject.
|
|
559
|
+
"""
|
|
560
|
+
if not isinstance(mapping, dict) or not mapping:
|
|
561
|
+
return None
|
|
562
|
+
lowered = {str(k).lower(): str(v) for k, v in mapping.items()}
|
|
563
|
+
ids = {str(v).lower(): str(v) for v in mapping.values()}
|
|
564
|
+
for candidate in candidates or ():
|
|
565
|
+
if not isinstance(candidate, str) or not candidate:
|
|
566
|
+
continue
|
|
567
|
+
needle = candidate.lower()
|
|
568
|
+
if needle in lowered:
|
|
569
|
+
return lowered[needle]
|
|
570
|
+
if needle in ids:
|
|
571
|
+
return ids[needle]
|
|
572
|
+
return None
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# ── Name resolution ─────────────────────────────────────────────────────────
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def normalize_target_name(name: Any) -> Optional[str]:
|
|
579
|
+
"""Fold a user-supplied name to a canonical target id, or None if unknown."""
|
|
580
|
+
if not isinstance(name, str):
|
|
581
|
+
return None
|
|
582
|
+
key = name.strip().lower().replace("-", "_").replace(" ", "_")
|
|
583
|
+
if not key:
|
|
584
|
+
return None
|
|
585
|
+
if key in DELIVERY_TARGETS:
|
|
586
|
+
return key
|
|
587
|
+
return TARGET_ALIASES.get(key)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def resolve_target(
|
|
591
|
+
name: Any,
|
|
592
|
+
overrides: Optional[Mapping[str, Any]] = None,
|
|
593
|
+
*,
|
|
594
|
+
extra_targets: Optional[Mapping[str, DeliveryTarget]] = None,
|
|
595
|
+
) -> Optional[DeliveryTarget]:
|
|
596
|
+
"""Materialize a target by name, with optional per-field overrides.
|
|
597
|
+
|
|
598
|
+
Returns None for an unknown name — unlike the cap/tier resolvers, there is no
|
|
599
|
+
sensible default deliverable to fall back to, and silently rendering the
|
|
600
|
+
wrong format is exactly the failure this feature exists to prevent.
|
|
601
|
+
|
|
602
|
+
Unknown or un-coercible override keys are skipped with a log line; they never
|
|
603
|
+
raise, and they never change a field the caller did not name.
|
|
604
|
+
"""
|
|
605
|
+
canonical = normalize_target_name(name)
|
|
606
|
+
target: Optional[DeliveryTarget] = None
|
|
607
|
+
if extra_targets and isinstance(name, str):
|
|
608
|
+
target = extra_targets.get(name.strip().lower())
|
|
609
|
+
if target is None and canonical:
|
|
610
|
+
target = (extra_targets or {}).get(canonical) or DELIVERY_TARGETS.get(canonical)
|
|
611
|
+
if target is None:
|
|
612
|
+
logger.warning("unknown delivery target: %r", name)
|
|
613
|
+
return None
|
|
614
|
+
|
|
615
|
+
if not overrides:
|
|
616
|
+
return target
|
|
617
|
+
|
|
618
|
+
patch: Dict[str, Any] = {}
|
|
619
|
+
for key, raw in overrides.items():
|
|
620
|
+
if key not in OVERRIDE_KEYS:
|
|
621
|
+
logger.debug("ignoring unknown delivery-target override: %s", key)
|
|
622
|
+
continue
|
|
623
|
+
if raw is None:
|
|
624
|
+
patch[key] = None
|
|
625
|
+
continue
|
|
626
|
+
try:
|
|
627
|
+
if key in _TUPLE_KEYS:
|
|
628
|
+
items = (raw,) if isinstance(raw, str) else tuple(raw)
|
|
629
|
+
patch[key] = tuple(str(item) for item in items)
|
|
630
|
+
elif key in _INT_KEYS:
|
|
631
|
+
patch[key] = int(raw)
|
|
632
|
+
elif key in _BOOL_KEYS:
|
|
633
|
+
patch[key] = bool(raw)
|
|
634
|
+
elif key == "fps":
|
|
635
|
+
patch[key] = float(raw)
|
|
636
|
+
else:
|
|
637
|
+
patch[key] = str(raw)
|
|
638
|
+
except (TypeError, ValueError):
|
|
639
|
+
logger.warning("ignoring un-coercible delivery-target override %s=%r", key, raw)
|
|
640
|
+
return replace(target, **patch) if patch else target
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def list_targets(
|
|
644
|
+
extra_targets: Optional[Mapping[str, DeliveryTarget]] = None,
|
|
645
|
+
*,
|
|
646
|
+
tier: Optional[str] = None,
|
|
647
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
648
|
+
"""Introspection payload for the MCP surface and the control panel."""
|
|
649
|
+
merged = dict(DELIVERY_TARGETS)
|
|
650
|
+
merged.update(extra_targets or {})
|
|
651
|
+
return {
|
|
652
|
+
target_id: {
|
|
653
|
+
"id": target.id,
|
|
654
|
+
"label": target.label,
|
|
655
|
+
"describe": target.describe,
|
|
656
|
+
"tier": target.tier,
|
|
657
|
+
"format": target.format,
|
|
658
|
+
"codec": target.codec,
|
|
659
|
+
"format_candidates": list(target.format_candidates),
|
|
660
|
+
"codec_candidates": list(target.codec_candidates),
|
|
661
|
+
"width": target.width,
|
|
662
|
+
"height": target.height,
|
|
663
|
+
"fps": target.fps,
|
|
664
|
+
"export_video": target.export_video,
|
|
665
|
+
"export_audio": target.export_audio,
|
|
666
|
+
"export_alpha": target.export_alpha,
|
|
667
|
+
"has_qc_projection": target.has_qc_projection,
|
|
668
|
+
"qc_skip_reason": target.qc_skip_reason,
|
|
669
|
+
"verified": target.verified,
|
|
670
|
+
"source": target.source,
|
|
671
|
+
"notes": list(target.notes),
|
|
672
|
+
"aliases": sorted(a for a, t in TARGET_ALIASES.items() if t == target_id),
|
|
673
|
+
"user_defined": target_id not in DELIVERY_TARGETS,
|
|
674
|
+
}
|
|
675
|
+
for target_id, target in sorted(merged.items())
|
|
676
|
+
if tier is None or target.tier == tier
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
# ── Projections ─────────────────────────────────────────────────────────────
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def to_render_settings(target: DeliveryTarget, *, timeline_fps: Optional[float] = None) -> Dict[str, Any]:
|
|
684
|
+
"""Project a target onto Resolve `SetRenderSettings` keys.
|
|
685
|
+
|
|
686
|
+
Only pinned fields are emitted, so unset dimensions keep Resolve's own
|
|
687
|
+
defaults instead of being forced to a guessed value. Every key here is a
|
|
688
|
+
member of `_RENDER_SETTING_KEYS`; format/codec are NOT settings and are
|
|
689
|
+
applied separately via `SetCurrentRenderFormatAndCodec`.
|
|
690
|
+
"""
|
|
691
|
+
settings: Dict[str, Any] = {
|
|
692
|
+
"ExportVideo": target.export_video,
|
|
693
|
+
"ExportAudio": target.export_audio,
|
|
694
|
+
}
|
|
695
|
+
if target.export_video:
|
|
696
|
+
if target.width is not None:
|
|
697
|
+
settings["FormatWidth"] = target.width
|
|
698
|
+
if target.height is not None:
|
|
699
|
+
settings["FormatHeight"] = target.height
|
|
700
|
+
fps = target.fps if target.fps is not None else timeline_fps
|
|
701
|
+
if fps is not None:
|
|
702
|
+
settings["FrameRate"] = fps
|
|
703
|
+
if target.export_alpha:
|
|
704
|
+
settings["ExportAlpha"] = True
|
|
705
|
+
if target.export_audio:
|
|
706
|
+
if target.audio_sample_rate is not None:
|
|
707
|
+
settings["AudioSampleRate"] = target.audio_sample_rate
|
|
708
|
+
if target.audio_bit_depth is not None:
|
|
709
|
+
settings["AudioBitDepth"] = target.audio_bit_depth
|
|
710
|
+
return settings
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def to_qc_spec(
|
|
714
|
+
target: DeliveryTarget, *, timeline_fps: Optional[float] = None
|
|
715
|
+
) -> Optional[Dict[str, Any]]:
|
|
716
|
+
"""Project a target onto the `checkDeliverable` spec vocabulary (ffprobe-shaped).
|
|
717
|
+
|
|
718
|
+
Returns None when the target has no single-file QC projection (image
|
|
719
|
+
sequences render many files; `deliverable_qc` probes one).
|
|
720
|
+
|
|
721
|
+
Asserts only what `to_render_settings` actually pins — a field the render
|
|
722
|
+
never controlled would produce a QC failure that says nothing about the
|
|
723
|
+
deliverable. `container` is the ffprobe first-token value, which is "mov" for
|
|
724
|
+
mp4 and mov alike; `video.codec` is what discriminates them.
|
|
725
|
+
"""
|
|
726
|
+
if not target.has_qc_projection:
|
|
727
|
+
return None
|
|
728
|
+
|
|
729
|
+
spec: Dict[str, Any] = {}
|
|
730
|
+
if target.qc_container:
|
|
731
|
+
spec["container"] = target.qc_container
|
|
732
|
+
|
|
733
|
+
if target.export_video and target.qc_codec:
|
|
734
|
+
video: Dict[str, Any] = {"codec": target.qc_codec}
|
|
735
|
+
if target.width is not None:
|
|
736
|
+
video["width"] = target.width
|
|
737
|
+
if target.height is not None:
|
|
738
|
+
video["height"] = target.height
|
|
739
|
+
fps = target.fps if target.fps is not None else timeline_fps
|
|
740
|
+
if fps is not None:
|
|
741
|
+
video["fps"] = fps
|
|
742
|
+
spec["video"] = video
|
|
743
|
+
|
|
744
|
+
if target.export_audio:
|
|
745
|
+
audio: Dict[str, Any] = {}
|
|
746
|
+
if target.qc_audio_codec:
|
|
747
|
+
audio["codec"] = target.qc_audio_codec
|
|
748
|
+
if target.audio_channels is not None:
|
|
749
|
+
audio["channels"] = target.audio_channels
|
|
750
|
+
if target.audio_sample_rate is not None:
|
|
751
|
+
audio["sampleRate"] = target.audio_sample_rate
|
|
752
|
+
if target.audio_bit_depth is not None:
|
|
753
|
+
audio["bitDepth"] = target.audio_bit_depth
|
|
754
|
+
if audio:
|
|
755
|
+
spec["audio"] = audio
|
|
756
|
+
|
|
757
|
+
return spec or None
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
# ── User overrides ──────────────────────────────────────────────────────────
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def user_targets_path(project_dir: os.PathLike[str] | str, env: Optional[Mapping[str, str]] = None) -> Path:
|
|
764
|
+
"""Path to the user's delivery-target override file."""
|
|
765
|
+
values = os.environ if env is None else env
|
|
766
|
+
override = values.get(ENV_USER_TARGETS)
|
|
767
|
+
if override:
|
|
768
|
+
return Path(override).expanduser()
|
|
769
|
+
return Path(project_dir) / "logs" / "delivery-targets.json"
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _as_candidates(raw: Any) -> Tuple[str, ...]:
|
|
773
|
+
if isinstance(raw, str):
|
|
774
|
+
return (raw,)
|
|
775
|
+
if isinstance(raw, (list, tuple)):
|
|
776
|
+
return tuple(str(item) for item in raw if item)
|
|
777
|
+
return ()
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def load_user_targets(path: os.PathLike[str] | str) -> Dict[str, DeliveryTarget]:
|
|
781
|
+
"""Load user-defined targets, skipping malformed entries rather than failing.
|
|
782
|
+
|
|
783
|
+
A broken entry must not take out the shipped set — the user still needs to
|
|
784
|
+
render. Each skipped entry is logged with its reason. An absent or
|
|
785
|
+
unparseable file yields {}.
|
|
786
|
+
|
|
787
|
+
File shape::
|
|
788
|
+
|
|
789
|
+
{"targets": {"<id>": {"format": "QuickTime" | ["QuickTime", "mov"],
|
|
790
|
+
"codec": "DNxHR HQ", "width": 1920, ...}}}
|
|
791
|
+
"""
|
|
792
|
+
try:
|
|
793
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
794
|
+
payload = json.load(handle)
|
|
795
|
+
except FileNotFoundError:
|
|
796
|
+
return {}
|
|
797
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
798
|
+
logger.warning("could not read delivery targets from %s: %s", path, exc)
|
|
799
|
+
return {}
|
|
800
|
+
|
|
801
|
+
if not isinstance(payload, dict):
|
|
802
|
+
logger.warning("delivery targets file %s is not an object; ignoring", path)
|
|
803
|
+
return {}
|
|
804
|
+
raw_targets = payload.get("targets")
|
|
805
|
+
if not isinstance(raw_targets, dict):
|
|
806
|
+
logger.warning("delivery targets file %s has no 'targets' object; ignoring", path)
|
|
807
|
+
return {}
|
|
808
|
+
|
|
809
|
+
loaded: Dict[str, DeliveryTarget] = {}
|
|
810
|
+
for target_id, body in raw_targets.items():
|
|
811
|
+
if not isinstance(target_id, str) or not target_id.strip():
|
|
812
|
+
logger.warning("skipping delivery target with a non-string id")
|
|
813
|
+
continue
|
|
814
|
+
if not isinstance(body, dict):
|
|
815
|
+
logger.warning("skipping delivery target %r: entry is not an object", target_id)
|
|
816
|
+
continue
|
|
817
|
+
key = target_id.strip().lower()
|
|
818
|
+
formats = _as_candidates(body.get("format_candidates") or body.get("format"))
|
|
819
|
+
codecs = _as_candidates(body.get("codec_candidates") or body.get("codec"))
|
|
820
|
+
missing = [n for n, v in (("format", formats), ("codec", codecs)) if not v]
|
|
821
|
+
if missing:
|
|
822
|
+
logger.warning("skipping delivery target %r: missing %s", target_id, ", ".join(missing))
|
|
823
|
+
continue
|
|
824
|
+
tier = str(body.get("tier") or "master")
|
|
825
|
+
if tier not in TIERS:
|
|
826
|
+
logger.warning("delivery target %r has unknown tier %r; treating as 'master'", target_id, tier)
|
|
827
|
+
tier = "master"
|
|
828
|
+
base = DeliveryTarget(
|
|
829
|
+
id=key,
|
|
830
|
+
label=str(body.get("label") or key),
|
|
831
|
+
describe=str(body.get("describe") or ""),
|
|
832
|
+
tier=tier,
|
|
833
|
+
format_candidates=formats,
|
|
834
|
+
codec_candidates=codecs,
|
|
835
|
+
qc_container=body.get("qc_container") or None,
|
|
836
|
+
qc_codec=body.get("qc_codec") or None,
|
|
837
|
+
qc_audio_codec=body.get("qc_audio_codec") or None,
|
|
838
|
+
verified=str(body.get("verified") or ""),
|
|
839
|
+
source=str(body.get("source") or "user-defined"),
|
|
840
|
+
)
|
|
841
|
+
# Route the numeric/boolean fields through the same coercion the MCP
|
|
842
|
+
# override path uses, so a user file and a per-call override behave
|
|
843
|
+
# identically (e.g. "1080" as a string still lands as an int).
|
|
844
|
+
overrides = {k: v for k, v in body.items() if k in OVERRIDE_KEYS}
|
|
845
|
+
loaded[key] = resolve_target(key, overrides, extra_targets={key: base}) or base
|
|
846
|
+
return loaded
|