davinci-resolve-mcp 2.66.0 → 2.67.1

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.
@@ -10,11 +10,13 @@
10
10
  * re_delivery_diff — old vs new render: frame-count/duration Δ + spec drift
11
11
  * render_manifest — build (checksums + frame counts) / reconcile actual outputs
12
12
  * expand_deliverable — model texted/textless/stems/slate/leader entities + their rules
13
+ * spec_from_authored — authored deliverable vocabulary → deliverable_qc spec + loudness target
13
14
  */
14
15
  import { z } from 'zod';
15
16
  import { deliverableQc, loudnessQc, reframeBlankingCheck, conformCompleteness, reDeliveryDiff } from '../deliverable-qc.mjs';
16
17
  import { buildManifest, reconcileManifest } from '../render-manifest.mjs';
17
18
  import { expandDeliverable } from '../deliverable-entities.mjs';
19
+ import { deliverableSpecFromAuthored, deliverableSpecsFromAuthored } from '../deliverable-spec-bridge.mjs';
18
20
 
19
21
  const specSchema = z.object({}).passthrough();
20
22
 
@@ -64,10 +66,21 @@ const expandSchema = z.object({
64
66
  entities: z.array(z.enum(['texted', 'textless', 'stems_ME', 'slate', 'leader'])).optional(),
65
67
  });
66
68
 
69
+ const specFromAuthoredSchema = z
70
+ .object({
71
+ deliverable: specSchema
72
+ .optional()
73
+ .describe('One authored deliverable: { id?, video:{codec,res,fps,color}, audio:{config,loudness}, container?, naming? }'),
74
+ deliverables: z.array(specSchema).optional().describe('An authored deliverables: list'),
75
+ })
76
+ .refine((a) => !!a.deliverable !== !!a.deliverables, {
77
+ message: 'pass exactly one of deliverable or deliverables',
78
+ });
79
+
67
80
  export const deliverableTool = {
68
81
  name: 'deliverable',
69
82
  description:
70
- 'Deliverable QC / compliance (Cluster D) — the #1 reject-preventer. MEASURE/report-only (gate: review — never auto-pass-clear). Actions: deliverable_qc (ffprobe a render vs its spec → pass/fail per field: codec/raster/fps/scan/color tags/audio layout/naming/duration), loudness_qc (ebur128 integrated LUFS + true-peak dBTP + LRA vs target), reframe_blanking_check (letterbox/pillarbox + active-picture bounds + illegal edge pixels), conform_completeness (all online, handles, duration == reference frame-exact), re_delivery_diff (old vs new render: frame/duration Δ + spec drift), render_manifest (build checksums+frame-counts / reconcile actual outputs), expand_deliverable (model texted/textless/stems/slate/leader entities + rules). Needs ffmpeg/ffprobe on PATH for the file actions.',
83
+ 'Deliverable QC / compliance (Cluster D) — the #1 reject-preventer. MEASURE/report-only (gate: review — never auto-pass-clear). Actions: deliverable_qc (ffprobe a render vs its spec → pass/fail per field: codec/raster/fps/scan/color tags/audio layout/naming/duration), loudness_qc (ebur128 integrated LUFS + true-peak dBTP + LRA vs target), reframe_blanking_check (letterbox/pillarbox + active-picture bounds + illegal edge pixels), conform_completeness (all online, handles, duration == reference frame-exact), re_delivery_diff (old vs new render: frame/duration Δ + spec drift), render_manifest (build checksums+frame-counts / reconcile actual outputs), expand_deliverable (model texted/textless/stems/slate/leader entities + rules), spec_from_authored (authored deliverable vocabulary — codec display names, "1920x1080", "-16 LUFS", naming templates — into a deliverable_qc spec + loudness_qc target, reporting anything it could not map). Needs ffmpeg/ffprobe on PATH for the file actions.',
71
84
  async handler({ action, args }) {
72
85
  if (action === 'deliverable_qc') {
73
86
  const p = deliverableQcSchema.parse(args);
@@ -100,6 +113,12 @@ export const deliverableTool = {
100
113
  const p = expandSchema.parse(args);
101
114
  return expandDeliverable({ name: p.name, spec: p.spec, entities: p.entities });
102
115
  }
116
+ if (action === 'spec_from_authored') {
117
+ const p = specFromAuthoredSchema.parse(args);
118
+ return p.deliverables
119
+ ? { deliverables: deliverableSpecsFromAuthored(p.deliverables) }
120
+ : deliverableSpecFromAuthored(p.deliverable);
121
+ }
103
122
  throw new Error(`Unknown deliverable action: ${action}`);
104
123
  },
105
124
  };
@@ -44,6 +44,10 @@ from src.utils.layout_presets import (
44
44
  )
45
45
  from src.utils.object_inspection import inspect_object, print_object_help
46
46
  from src.utils.platform import get_platform, get_resolve_paths
47
+ from src.utils.render_ids import (
48
+ render_codec_id_from_codecs,
49
+ render_format_id_from_formats,
50
+ )
47
51
  from src.utils.resolve_connection import connect_resolve
48
52
  from src.utils.project_properties import (
49
53
  get_all_project_properties,
@@ -81,7 +85,7 @@ if not logging.getLogger().handlers:
81
85
  handlers=[logging.StreamHandler()],
82
86
  )
83
87
 
84
- VERSION = "2.66.0"
88
+ VERSION = "2.67.1"
85
89
  logger = logging.getLogger("davinci-resolve-mcp")
86
90
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
87
91
  logger.info(f"Detected platform: {get_platform()}")
@@ -1225,7 +1225,7 @@ def get_render_codecs(format_name: str) -> Dict[str, Any]:
1225
1225
  """Get available codecs for a given render format.
1226
1226
 
1227
1227
  Args:
1228
- format_name: Render format name (e.g. 'mp4', 'mov', 'avi').
1228
+ format_name: Render format id or display name (e.g. 'mov', 'QuickTime').
1229
1229
  """
1230
1230
  resolve = get_resolve()
1231
1231
  if resolve is None:
@@ -1233,8 +1233,11 @@ def get_render_codecs(format_name: str) -> Dict[str, Any]:
1233
1233
  project = resolve.GetProjectManager().GetCurrentProject()
1234
1234
  if not project:
1235
1235
  return {"error": "No project currently open"}
1236
- codecs = project.GetRenderCodecs(format_name)
1237
- return {"format": format_name, "codecs": codecs if codecs else {}}
1236
+ # GetRenderCodecs needs the format *id*; GetRenderFormats returns
1237
+ # {display_name: id}, so an unnormalized display name yields {} (issue #59).
1238
+ format_id = render_format_id_from_formats(project.GetRenderFormats() or {}, format_name)
1239
+ codecs = project.GetRenderCodecs(format_id)
1240
+ return {"format": format_name, "format_id": format_id, "codecs": codecs if codecs else {}}
1238
1241
 
1239
1242
 
1240
1243
  @mcp.tool()
@@ -1255,8 +1258,9 @@ def set_current_render_format_and_codec(format_name: str, codec_name: str) -> Di
1255
1258
  """Set the render format and codec.
1256
1259
 
1257
1260
  Args:
1258
- format_name: Render format (e.g. 'mp4', 'mov').
1259
- codec_name: Codec name (e.g. 'H264', 'H265', 'ProRes422HQ').
1261
+ format_name: Render format id or display name (e.g. 'mov', 'QuickTime').
1262
+ codec_name: Codec id or display name (e.g. 'ProRes422HQ',
1263
+ 'Apple ProRes 422 HQ', 'H.264').
1260
1264
  """
1261
1265
  resolve = get_resolve()
1262
1266
  if resolve is None:
@@ -1264,8 +1268,19 @@ def set_current_render_format_and_codec(format_name: str, codec_name: str) -> Di
1264
1268
  project = resolve.GetProjectManager().GetCurrentProject()
1265
1269
  if not project:
1266
1270
  return {"error": "No project currently open"}
1267
- result = project.SetCurrentRenderFormatAndCodec(format_name, codec_name)
1268
- return {"success": bool(result), "format": format_name, "codec": codec_name}
1271
+ # Both arguments must be *ids*. The display names Resolve shows in the UI are
1272
+ # the dict keys, not the values, so passing them through raw is silently
1273
+ # rejected for every format where label != id (QuickTime/ProRes, not mp4).
1274
+ format_id = render_format_id_from_formats(project.GetRenderFormats() or {}, format_name)
1275
+ codec_id = render_codec_id_from_codecs(project.GetRenderCodecs(format_id) or {}, codec_name)
1276
+ result = project.SetCurrentRenderFormatAndCodec(format_id, codec_id)
1277
+ return {
1278
+ "success": bool(result),
1279
+ "format": format_name,
1280
+ "codec": codec_name,
1281
+ "format_id": format_id,
1282
+ "codec_id": codec_id,
1283
+ }
1269
1284
 
1270
1285
 
1271
1286
  @mcp.tool()
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.66.0"
14
+ VERSION = "2.67.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -49,6 +49,11 @@ from src.utils.cut_ir import build_cut_list as _build_cut_list
49
49
  from src.utils.page_lock import open_page_serialized as _open_page_serialized
50
50
  from src.utils.proc import safe_run
51
51
  from src.utils.readback import verify_by_readback, verification_stats as _verification_stats
52
+ from src.utils.render_ids import (
53
+ render_codec_id_from_codecs as _render_codec_id_from_codecs,
54
+ render_format_id_from_formats as _render_format_id_from_formats,
55
+ )
56
+ from src.utils import delivery_targets as _delivery_targets
52
57
  from src.utils.update_check import (
53
58
  check_for_updates,
54
59
  clear_update_prompt_preferences,
@@ -15233,6 +15238,9 @@ _RENDER_KERNEL_ACTIONS = [
15233
15238
  "quick_export_capabilities",
15234
15239
  "safe_quick_export",
15235
15240
  "export_render_boundary_report",
15241
+ "list_delivery_targets",
15242
+ "resolve_delivery_target",
15243
+ "prepare_delivery_job",
15236
15244
  ]
15237
15245
 
15238
15246
 
@@ -15256,24 +15264,6 @@ def _render_formats(proj):
15256
15264
  return _ser(formats)
15257
15265
 
15258
15266
 
15259
- def _render_format_id_from_formats(formats: Any, fmt: str) -> str:
15260
- if not isinstance(fmt, str) or not fmt or not isinstance(formats, dict):
15261
- return fmt
15262
- if fmt in formats:
15263
- return formats.get(fmt) or fmt
15264
- if fmt in formats.values():
15265
- return fmt
15266
- needle = fmt.lower()
15267
- for label, format_id in formats.items():
15268
- label_text = str(label)
15269
- id_text = str(format_id)
15270
- if label_text.lower() == needle:
15271
- return id_text or fmt
15272
- if id_text.lower() == needle:
15273
- return id_text
15274
- return fmt
15275
-
15276
-
15277
15267
  def _render_format_id(proj, fmt: str, formats: Optional[Dict[str, Any]] = None) -> str:
15278
15268
  if formats is None:
15279
15269
  formats = _render_formats(proj)
@@ -15294,6 +15284,15 @@ def _render_format_requested(requested: Optional[List[Any]], label: str, extensi
15294
15284
  return False
15295
15285
 
15296
15286
 
15287
+ def _render_codec_id(proj, fmt: str, codec: str, formats: Optional[Dict[str, Any]] = None) -> str:
15288
+ """Resolve a codec display name to its id for `fmt` (itself label-or-id tolerant)."""
15289
+ try:
15290
+ codecs = proj.GetRenderCodecs(_render_format_id(proj, fmt, formats)) or {}
15291
+ except Exception:
15292
+ return codec
15293
+ return _render_codec_id_from_codecs(codecs, codec)
15294
+
15295
+
15297
15296
  def _render_codecs(proj, fmt: str, formats: Optional[Dict[str, Any]] = None):
15298
15297
  try:
15299
15298
  return _ser(proj.GetRenderCodecs(_render_format_id(proj, fmt, formats)) or {})
@@ -15490,7 +15489,33 @@ def _prepare_render_job(proj, p: Dict[str, Any]):
15490
15489
  before = _render_settings_snapshot(proj)
15491
15490
  format_success = None
15492
15491
  if p.get("format") and p.get("codec"):
15493
- format_success = bool(proj.SetCurrentRenderFormatAndCodec(_render_format_id(proj, p["format"]), p["codec"]))
15492
+ formats = _render_formats(proj)
15493
+ format_id = _render_format_id_from_formats(formats, p["format"])
15494
+ codec_id = _render_codec_id(proj, format_id, p["codec"], formats)
15495
+ format_success = bool(proj.SetCurrentRenderFormatAndCodec(format_id, codec_id))
15496
+ if not format_success:
15497
+ # Refuse to queue. Continuing here would apply settings, return a real
15498
+ # job id, and report success=True for a job that renders in the
15499
+ # PREVIOUSLY set format/codec — with format_success=False the only
15500
+ # signal, buried in the payload.
15501
+ return _err(
15502
+ f"Could not set render format/codec: {p['format']} / {p['codec']}",
15503
+ code="RENDER_FORMAT_CODEC_REJECTED",
15504
+ category="invalid_input",
15505
+ reason="Resolve rejected the format/codec pair; no render job was queued.",
15506
+ remediation=(
15507
+ "Check render(action='get_formats') and "
15508
+ "render(action='get_codecs', params={'format': ...}) for what this "
15509
+ "machine, license, and plugin set actually support."
15510
+ ),
15511
+ state={
15512
+ "requested_format": p["format"],
15513
+ "requested_codec": p["codec"],
15514
+ "resolved_format_id": format_id,
15515
+ "resolved_codec_id": codec_id,
15516
+ "available_codecs": _render_codecs(proj, format_id, formats),
15517
+ },
15518
+ )
15494
15519
  settings_success = bool(proj.SetRenderSettings(settings))
15495
15520
  job_id = proj.AddRenderJob() if settings_success else None
15496
15521
  return {
@@ -15503,6 +15528,186 @@ def _prepare_render_job(proj, p: Dict[str, Any]):
15503
15528
  }
15504
15529
 
15505
15530
 
15531
+ # ── Delivery targets ────────────────────────────────────────────────────────
15532
+ # Named render intents. One definition emits both the Resolve render settings
15533
+ # and the ffprobe-shaped QC spec, so the advanced server can verify a rendered
15534
+ # file against the same intent that produced it. See src/utils/delivery_targets.py.
15535
+
15536
+
15537
+ def _delivery_target_extras() -> Dict[str, Any]:
15538
+ """User-defined targets from logs/delivery-targets.json; {} if absent/broken."""
15539
+ try:
15540
+ return _delivery_targets.load_user_targets(
15541
+ _delivery_targets.user_targets_path(project_dir)
15542
+ )
15543
+ except Exception as exc: # pragma: no cover - defensive; loader already tolerates
15544
+ logger.warning("could not load user delivery targets: %s", exc)
15545
+ return {}
15546
+
15547
+
15548
+ def _delivery_timeline_fps(proj) -> Optional[float]:
15549
+ """Timeline frame rate for targets that inherit it. None when unavailable.
15550
+
15551
+ Distinct from `_timeline_fps(tl)` above, which takes a timeline and returns a
15552
+ (fps, err) tuple for timecode math.
15553
+ """
15554
+ try:
15555
+ timeline = proj.GetCurrentTimeline()
15556
+ if not timeline:
15557
+ return None
15558
+ value = timeline.GetSetting("timelineFrameRate")
15559
+ return float(value) if value else None
15560
+ except Exception:
15561
+ return None
15562
+
15563
+
15564
+ def _resolve_delivery_target_live(proj, p: Dict[str, Any]):
15565
+ """Resolve a named target against the LIVE format/codec matrix.
15566
+
15567
+ Returns (payload, error). Availability is machine/license/plugin dependent,
15568
+ so a target that cannot be satisfied here fails with the actual available
15569
+ lists rather than handing Resolve a name it will reject.
15570
+ """
15571
+ name = p.get("target")
15572
+ if not name:
15573
+ return None, _err(
15574
+ "target is required",
15575
+ code="MISSING_TARGET",
15576
+ category="invalid_input",
15577
+ remediation="Call render(action='list_delivery_targets') to see the available names.",
15578
+ )
15579
+ extras = _delivery_target_extras()
15580
+ target = _delivery_targets.resolve_target(name, p.get("overrides"), extra_targets=extras)
15581
+ if target is None:
15582
+ return None, _err(
15583
+ f"Unknown delivery target: {name}",
15584
+ code="UNKNOWN_DELIVERY_TARGET",
15585
+ category="invalid_input",
15586
+ remediation="Call render(action='list_delivery_targets') to see the available names.",
15587
+ state={"known_targets": sorted(_delivery_targets.list_targets(extras))},
15588
+ )
15589
+
15590
+ formats = _render_formats(proj)
15591
+ format_id = _delivery_targets.select_available(target.format_candidates, formats)
15592
+ if not format_id:
15593
+ return None, _err(
15594
+ f"Delivery target '{target.id}' needs a render format this machine does not expose",
15595
+ code="DELIVERY_TARGET_FORMAT_UNAVAILABLE",
15596
+ category="unsupported",
15597
+ reason="Render format availability depends on the Resolve version, license, and installed IO plugins.",
15598
+ remediation="Check render(action='get_formats'); a codec plugin may need installing, or Resolve restarting.",
15599
+ state={"target": target.id, "tried": list(target.format_candidates), "available_formats": formats},
15600
+ )
15601
+
15602
+ codecs = _render_codecs(proj, format_id, formats)
15603
+ codec_id = _delivery_targets.select_available(target.codec_candidates, codecs)
15604
+ if not codec_id:
15605
+ return None, _err(
15606
+ f"Delivery target '{target.id}' needs a codec this machine does not expose for {format_id}",
15607
+ code="DELIVERY_TARGET_CODEC_UNAVAILABLE",
15608
+ category="unsupported",
15609
+ reason="Codec availability depends on the Resolve version, license, and installed IO plugins.",
15610
+ remediation="Check render(action='get_codecs', params={'format': ...}); a codec plugin may need installing, or Resolve restarting.",
15611
+ state={
15612
+ "target": target.id,
15613
+ "format_id": format_id,
15614
+ "tried": list(target.codec_candidates),
15615
+ "available_codecs": codecs,
15616
+ },
15617
+ )
15618
+
15619
+ fps = _delivery_timeline_fps(proj)
15620
+ qc_spec = _delivery_targets.to_qc_spec(target, timeline_fps=fps)
15621
+ return (
15622
+ {
15623
+ "target": target.id,
15624
+ "label": target.label,
15625
+ "tier": target.tier,
15626
+ "format_id": format_id,
15627
+ "codec_id": codec_id,
15628
+ "timeline_fps": fps,
15629
+ "settings": _delivery_targets.to_render_settings(target, timeline_fps=fps),
15630
+ "qc_spec": qc_spec,
15631
+ "qc_note": (
15632
+ None
15633
+ if qc_spec
15634
+ else "This target renders an image sequence; deliverable_qc probes a single file, so it has no QC spec."
15635
+ ),
15636
+ "notes": list(target.notes),
15637
+ },
15638
+ None,
15639
+ )
15640
+
15641
+
15642
+ def _list_delivery_targets(proj, p: Dict[str, Any]):
15643
+ tier = p.get("tier")
15644
+ if tier is not None and tier not in _delivery_targets.TIERS:
15645
+ return _err(
15646
+ f"Unknown tier: {tier}",
15647
+ code="UNKNOWN_DELIVERY_TIER",
15648
+ category="invalid_input",
15649
+ state={"known_tiers": list(_delivery_targets.TIERS)},
15650
+ )
15651
+ extras = _delivery_target_extras()
15652
+ listing = _delivery_targets.list_targets(extras, tier=tier)
15653
+
15654
+ if p.get("check_availability"):
15655
+ # Resolve every target against the live matrix so the caller sees what
15656
+ # this machine/license can actually render, not just what ships.
15657
+ formats = _render_formats(proj)
15658
+ codec_cache: Dict[str, Any] = {}
15659
+ for target_id, entry in listing.items():
15660
+ target = _delivery_targets.resolve_target(target_id, extra_targets=extras)
15661
+ format_id = _delivery_targets.select_available(target.format_candidates, formats)
15662
+ entry["available"] = False
15663
+ entry["format_id"] = format_id
15664
+ entry["codec_id"] = None
15665
+ if not format_id:
15666
+ entry["unavailable_reason"] = "format not exposed by this Resolve install"
15667
+ continue
15668
+ if format_id not in codec_cache:
15669
+ codec_cache[format_id] = _render_codecs(proj, format_id, formats)
15670
+ codec_id = _delivery_targets.select_available(target.codec_candidates, codec_cache[format_id])
15671
+ entry["codec_id"] = codec_id
15672
+ if not codec_id:
15673
+ entry["unavailable_reason"] = f"no matching codec for format '{format_id}'"
15674
+ continue
15675
+ entry["available"] = True
15676
+
15677
+ return _ok(
15678
+ targets=listing,
15679
+ schema_version=_delivery_targets.SCHEMA_VERSION,
15680
+ tiers=list(_delivery_targets.TIERS),
15681
+ availability_checked=bool(p.get("check_availability")),
15682
+ )
15683
+
15684
+
15685
+ def _prepare_delivery_job(proj, p: Dict[str, Any]):
15686
+ """Resolve a named target, then queue a render job for it.
15687
+
15688
+ Target settings are the base; anything in `settings` wins, so a caller can
15689
+ pin TargetDir/CustomName (or override a dimension) without restating the target.
15690
+ """
15691
+ resolved, err = _resolve_delivery_target_live(proj, p)
15692
+ if err:
15693
+ return err
15694
+ settings = dict(resolved["settings"])
15695
+ settings.update(p.get("settings") or {})
15696
+ prepared = _prepare_render_job(
15697
+ proj,
15698
+ {
15699
+ **{k: v for k, v in p.items() if k not in ("target", "overrides", "settings")},
15700
+ "settings": settings,
15701
+ "format": resolved["format_id"],
15702
+ "codec": resolved["codec_id"],
15703
+ },
15704
+ )
15705
+ if isinstance(prepared, dict) and not prepared.get("error"):
15706
+ prepared["delivery_target"] = resolved["target"]
15707
+ prepared["qc_spec"] = resolved["qc_spec"]
15708
+ return prepared
15709
+
15710
+
15506
15711
  def _render_job_lifecycle_probe(proj, p: Dict[str, Any]):
15507
15712
  prepared = _prepare_render_job(proj, {**p, "dry_run": False, "require_temp_target": True})
15508
15713
  if prepared.get("error") or not prepared.get("success"):
@@ -15610,6 +15815,16 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
15610
15815
  quick_export_capabilities() -> {presets, safe_params, guards}
15611
15816
  safe_quick_export(preset, target_dir?|params?, custom_name?, dry_run?, allow_render?) -> {success, status}
15612
15817
  export_render_boundary_report(include_matrix?, max_pairs?, include_quick_export?) -> {capabilities, settings, matrix?}
15818
+ list_delivery_targets(tier?, check_availability?) -> {targets, tiers, schema_version}
15819
+ resolve_delivery_target(target, overrides?) -> {format_id, codec_id, settings, qc_spec}
15820
+ prepare_delivery_job(target, target_dir, overrides?, settings?, custom_name?, dry_run?) -> {success, job_id, qc_spec}
15821
+
15822
+ Delivery targets are named render intents (`prores422hq_master`, `youtube`,
15823
+ `tiktok`, ...). One definition emits both the Resolve render settings and an
15824
+ ffprobe-shaped QC spec, so the advanced server's `deliverable_qc` can verify a
15825
+ rendered file against the same intent that produced it. Format/codec are
15826
+ resolved against the live matrix, so an intent unavailable on this
15827
+ machine/license fails with the actual available lists.
15613
15828
  """
15614
15829
  p = params or {}
15615
15830
  _, proj, err = _check()
@@ -15644,14 +15859,48 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
15644
15859
  return {"codecs": _render_codecs(proj, p["format"])}
15645
15860
  elif action == "get_format_and_codec":
15646
15861
  return _ser(proj.GetCurrentRenderFormatAndCodec())
15862
+ elif action == "list_delivery_targets":
15863
+ return _list_delivery_targets(proj, p)
15864
+ elif action == "resolve_delivery_target":
15865
+ _resolved, _target_err = _resolve_delivery_target_live(proj, p)
15866
+ return _target_err if _target_err else _ok(**_resolved)
15867
+ elif action == "prepare_delivery_job":
15868
+ return _prepare_delivery_job(proj, p)
15647
15869
  elif action == "set_format_and_codec":
15648
- return {"success": bool(proj.SetCurrentRenderFormatAndCodec(_render_format_id(proj, p["format"]), p["codec"]))}
15870
+ _formats = _render_formats(proj)
15871
+ _format_id = _render_format_id_from_formats(_formats, p["format"])
15872
+ _codec_id = _render_codec_id(proj, _format_id, p["codec"], _formats)
15873
+ if not proj.SetCurrentRenderFormatAndCodec(_format_id, _codec_id):
15874
+ # Availability is machine/license/plugin dependent, so name what this
15875
+ # install actually exposes instead of just reporting False.
15876
+ return _err(
15877
+ f"Could not set render format/codec: {p['format']} / {p['codec']}",
15878
+ code="RENDER_FORMAT_CODEC_REJECTED",
15879
+ category="invalid_input",
15880
+ reason="Resolve rejected the format/codec pair.",
15881
+ remediation=(
15882
+ "Check render(action='get_formats') and "
15883
+ "render(action='get_codecs', params={'format': ...}). If the codec comes "
15884
+ "from an IO plugin, confirm it is installed and Resolve has been restarted."
15885
+ ),
15886
+ state={
15887
+ "requested_format": p["format"],
15888
+ "requested_codec": p["codec"],
15889
+ "resolved_format_id": _format_id,
15890
+ "resolved_codec_id": _codec_id,
15891
+ "available_codecs": _render_codecs(proj, _format_id, _formats),
15892
+ },
15893
+ )
15894
+ return {"success": True, "format_id": _format_id, "codec_id": _codec_id}
15649
15895
  elif action == "get_mode":
15650
15896
  return {"mode": proj.GetCurrentRenderMode()}
15651
15897
  elif action == "set_mode":
15652
15898
  return {"success": bool(proj.SetCurrentRenderMode(p["mode"]))}
15653
15899
  elif action == "get_resolutions":
15654
- return {"resolutions": _ser(proj.GetRenderResolutions(_render_format_id(proj, p["format"]), p["codec"]))}
15900
+ _formats = _render_formats(proj)
15901
+ _format_id = _render_format_id_from_formats(_formats, p["format"])
15902
+ _codec_id = _render_codec_id(proj, _format_id, p["codec"], _formats)
15903
+ return {"resolutions": _ser(proj.GetRenderResolutions(_format_id, _codec_id))}
15655
15904
  elif action == "get_settings":
15656
15905
  missing = _requires_method(proj, "GetRenderSettings", "unknown")
15657
15906
  if missing:
@@ -593,6 +593,49 @@ API_TRUTH: List[Dict[str, Any]] = [
593
593
  "issue": 90,
594
594
  "mitigation": ["_ensure_lut_in_master"],
595
595
  },
596
+ {
597
+ "symbol": "Project.GetRenderCodecs",
598
+ "object": "Project",
599
+ "signature": "(renderFormat) -> {codec description: codec name}",
600
+ "reality": "Returns {description: id} — the human-readable description is "
601
+ "the KEY and the id Resolve actually accepts is the VALUE. "
602
+ "SetCurrentRenderFormatAndCodec, GetRenderCodecs and "
603
+ "GetRenderResolutions all require the id, so passing the "
604
+ "description a user sees in the Deliver page is rejected. "
605
+ "Verified live on Studio 19.1.3.7: ('mov', 'Apple ProRes 422 "
606
+ "HQ') -> False while ('mov', 'ProRes422HQ') -> True, and "
607
+ "('mp4', 'H.264') -> False while ('mp4', 'H264') -> True. It "
608
+ "affects every family, not only the ones whose id differs "
609
+ "obviously. Mirrors the same trap in GetRenderFormats, which "
610
+ "returns {format: extension}.",
611
+ "recommended": "Normalize both arguments through the live maps before "
612
+ "calling: src.utils.render_ids.render_format_id_from_formats "
613
+ "and render_codec_id_from_codecs accept a description or an "
614
+ "id and return the id.",
615
+ "tags": ["render", "deliver", "silent-failure", "id-vs-label"],
616
+ "submit": "bug",
617
+ "issue": 59,
618
+ "mitigation": ["_render_format_id", "_render_codec_id"],
619
+ },
620
+ {
621
+ "symbol": "Project.SetCurrentRenderFormatAndCodec",
622
+ "object": "Project",
623
+ "signature": "(format, codec) -> bool",
624
+ "reality": "Some render formats expose NO codecs at all — "
625
+ "GetRenderCodecs('wav') and GetRenderCodecs('gif') both return "
626
+ "{} on Studio 19.1.3.7 — and the call then rejects every codec "
627
+ "value, including the empty string, the format id itself, and "
628
+ "any plausible name ('Linear PCM'). There is no documented way "
629
+ "to select such a format through this API, so an audio-only WAV "
630
+ "deliverable is not expressible in scripting.",
631
+ "recommended": "Check GetRenderCodecs(format) first; when it is empty, "
632
+ "treat the format as unreachable through this API rather "
633
+ "than guessing a codec value. Render audio-only via "
634
+ "ExportVideo=False on a format that does expose codecs, or "
635
+ "drive it from a saved render preset.",
636
+ "tags": ["render", "deliver", "audio", "unsupported"],
637
+ "submit": "missing",
638
+ },
596
639
  ]
597
640
 
598
641