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