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 CHANGED
@@ -2,6 +2,89 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.67.0
6
+
7
+ Adds **delivery targets** — named render intents that carry their own QC spec —
8
+ and fixes a silent render-codec bug found while designing them.
9
+
10
+ ### Fixed
11
+
12
+ - **Render codec display names were rejected.** `GetRenderCodecs` returns
13
+ `{description: id}`, but the codec was passed to
14
+ `SetCurrentRenderFormatAndCodec`, `GetRenderResolutions`, and
15
+ `prepare_render_job` **raw** while the format was normalized. Verified live on
16
+ Studio 19.1.3.7: `('mov', 'Apple ProRes 422 HQ')` returns False while
17
+ `('mov', 'ProRes422HQ')` returns True — and the same holds for
18
+ `('mp4', 'H.264')` vs `('mp4', 'H264')`. Any render set by the codec name shown
19
+ in the Deliver page failed, across every codec family. This is the codec half
20
+ of the format-id fix (issue #59).
21
+ - **A rejected format/codec no longer queues a job anyway.**
22
+ `prepare_render_job` previously applied settings and called `AddRenderJob()`
23
+ even when `SetCurrentRenderFormatAndCodec` returned False, reporting
24
+ `success: True` with `format_success: False` buried in the payload — a queued
25
+ job that would render in the *previously set* codec. It now returns
26
+ `RENDER_FORMAT_CODEC_REJECTED` with the machine's available codecs and queues
27
+ nothing. `set_format_and_codec` returns the same structured error instead of a
28
+ bare `success: False`.
29
+ - **The granular server never received the issue #59 fix.**
30
+ `get_render_codecs` passed a raw display name, and
31
+ `set_current_render_format_and_codec` passed both format and codec raw. The
32
+ resolvers now live in `src/utils/render_ids.py` and are shared by both servers,
33
+ so the two cannot drift apart again.
34
+
35
+ ### Added
36
+
37
+ - **Delivery targets** (`src/utils/delivery_targets.py`) — 28 named render
38
+ intents across master / web / sequence / broadcast / package tiers, every one
39
+ resolved live against Resolve Studio 19.1.3.7 (20 formats / 271 pairs). One
40
+ definition projects onto both Resolve `SetRenderSettings` keys and the
41
+ ffprobe-shaped spec `deliverable_qc` consumes, so a render and the check that
42
+ verifies it come from the same source. New `render` actions:
43
+ `list_delivery_targets`, `resolve_delivery_target`, `prepare_delivery_job`.
44
+ - Ids describe the deliverable (`prores422hq_master`, `h264_1080p_web`);
45
+ platform names (`youtube`, `tiktok`, `avid`, `stems`) are **aliases**, so a
46
+ platform changing its guidance repoints an alias instead of rewriting a target.
47
+ - Format and codec are ordered **candidate lists** resolved against the live
48
+ matrix, because codec descriptions vary by Resolve version, license, and
49
+ installed IO plugins. An unavailable target fails with the machine's actual
50
+ available lists; `list_delivery_targets` with `check_availability` reports
51
+ what this install can render.
52
+ - User-defined targets load from `logs/delivery-targets.json` (override with
53
+ `DAVINCI_RESOLVE_MCP_DELIVERY_TARGETS`); a malformed entry is skipped with a
54
+ warning rather than taking out the shipped set.
55
+ - **`deliverable(action="spec_from_authored")`** on the advanced server —
56
+ projects the authored deliverable vocabulary (codec display names,
57
+ `"1920x1080"`, `"-16 LUFS"`, `<SHOW>_<EP>_<YYYYMMDD>.mov` naming templates)
58
+ onto a `deliverable_qc` spec plus a `loudness_qc` target. Anything it cannot
59
+ map is reported in `unmapped[]` rather than dropped.
60
+ - **The `deliver` apply contract now carries QC specs.**
61
+ `APPLY_CONTRACT.deliver` emits `qc[]` alongside each deliverable, so a render
62
+ job travels with the spec that will verify it. Previously a stub.
63
+ - **`tests.test_duplicate_definitions`** — static guard against a module-level
64
+ name being defined twice under `src/`. pyflakes does not catch this (it only
65
+ reports redefinition of an *unused* name), and the dangerous case is exactly
66
+ the one it misses. Added to the release validation set.
67
+
68
+ ### Notes
69
+
70
+ - Bitrate is deliberately not encoded in delivery targets: Resolve exposes no
71
+ bitrate render-setting key, only `VideoQuality`, whose type varies per codec.
72
+ - Image-sequence and package (IMF/DCP) targets return no QC spec —
73
+ `deliverable_qc` probes a single file, and those render many files or a
74
+ directory. Every such target carries an explicit `qc_skip_reason`, so a missing
75
+ check is always explained rather than silent.
76
+ - **There is no audio-only WAV target.** The `Wave` format exposes zero codecs
77
+ and `SetCurrentRenderFormatAndCodec('wav', …)` rejects every value tried,
78
+ including the empty string. Recorded in `src/utils/api_truth.py` and the
79
+ generated `docs/reference/api-limitations.md`.
80
+ - The live pass corrected candidate spellings that were wrong: plain
81
+ `"DNxHR HQX"` / `"DNxHR 444"` do not exist (live labels carry a bit depth),
82
+ DPX/TIFF codecs are `"RGB 10 bits"` not `"RGB 10-bit"`, and PNG is not a
83
+ Resolve render format.
84
+ - `container` is `"mov"` for both `.mov` and `.mp4`: ffprobe reports
85
+ `format_name=mov,mp4,m4a,3gp,3g2,mj2` for each and only the first token is
86
+ kept, so `video.codec` is what discriminates them.
87
+
5
88
  ## What's New in v2.66.0
6
89
 
7
90
  Generalizes the HTTP transcription backend introduced in v2.65.0 into a
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.66.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.67.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-blue.svg)](#server-modes)
package/docs/SKILL.md CHANGED
@@ -1363,6 +1363,16 @@ Render / Deliver kernel actions (v2.9.0+) add planning and safety layers:
1363
1363
  `export_render_boundary_report`. See `docs/kernels/render-deliver-kernel.md` for the
1364
1364
  live-tested format/codec, settings, job, and Quick Export boundary map.
1365
1365
 
1366
+ Delivery targets (v2.67.0+) are named render intents — `list_delivery_targets`,
1367
+ `resolve_delivery_target(target)`, `prepare_delivery_job(target, target_dir)`.
1368
+ Ask for `prores422hq_master`, `dnxhr_hqx_master`, `h264_1080p_web`, or a platform
1369
+ alias like `youtube` / `tiktok`, and one definition supplies both the Resolve
1370
+ render settings and the ffprobe QC spec the advanced server checks the output
1371
+ against. Format/codec resolve against the live matrix, so a target this machine
1372
+ or license cannot render fails with the available lists rather than silently
1373
+ rendering something else. `list_delivery_targets` with `check_availability: true`
1374
+ reports what this install can actually deliver.
1375
+
1366
1376
  ---
1367
1377
 
1368
1378
  ## Common Workflows
@@ -24,6 +24,38 @@ All actions are under `render(action=...)`.
24
24
  | `quick_export_capabilities` | Supported | Lists Quick Export presets and enforced safety guards. |
25
25
  | `safe_quick_export` | Supported dry-run | Validates temp target, forces `EnableUpload=False`, and requires `allow_render=True` before actual Quick Export execution. |
26
26
  | `export_render_boundary_report` | Supported | Combines capabilities, settings snapshot, format matrix, and Quick Export capabilities. |
27
+ | `list_delivery_targets` | Supported | Lists named render intents, optionally filtered by tier and checked against the live format/codec matrix. |
28
+ | `resolve_delivery_target` | Supported | Resolves a named target to live format/codec ids plus its render settings and QC spec. |
29
+ | `prepare_delivery_job` | Supported | Resolves a named target and queues a render job for it, returning the QC spec that verifies the output. |
30
+
31
+ ## Delivery Targets
32
+
33
+ A delivery target is a **named render intent** (`prores422hq_master`,
34
+ `h264_1080p_web`, `dnxhr_hqx_master`, ...) defined once in
35
+ `src/utils/delivery_targets.py` and projected two ways: onto Resolve
36
+ `SetRenderSettings` keys, and onto the ffprobe-shaped spec the advanced server's
37
+ `deliverable_qc` consumes. The render and the check that verifies it therefore
38
+ come from one definition.
39
+
40
+ - **Ids describe the deliverable; platform names are aliases.** `youtube`,
41
+ `tiktok`, `reels` and friends point at spec-descriptive ids. When a platform
42
+ changes its guidance you repoint an alias rather than rewrite a target, so no
43
+ committed id can become a lie about what it produces.
44
+ - **Format and codec are candidate lists**, resolved against the live matrix at
45
+ call time. Codec description strings vary by Resolve version, license, and
46
+ installed IO plugins, so a target names the spellings it knows and the first
47
+ available one wins.
48
+ - **Unavailable targets fail loudly** with `DELIVERY_TARGET_FORMAT_UNAVAILABLE` /
49
+ `DELIVERY_TARGET_CODEC_UNAVAILABLE` and the machine's actual available lists.
50
+ Use `list_delivery_targets` with `check_availability` to see what this install
51
+ can render before planning around it.
52
+ - **The QC spec asserts only what the render settings pin.** Bitrate is not
53
+ encoded (Resolve exposes no bitrate key — only `VideoQuality`, whose type
54
+ varies per codec), and image-sequence targets carry no QC spec at all because
55
+ `deliverable_qc` probes a single file.
56
+ - Users can add targets in `logs/delivery-targets.json` (override the path with
57
+ `DAVINCI_RESOLVE_MCP_DELIVERY_TARGETS`). A malformed entry is skipped with a
58
+ warning rather than taking out the shipped set.
27
59
 
28
60
  ## Supported Boundaries
29
61
 
@@ -59,13 +59,20 @@ venv/bin/python tests/test_import.py
59
59
  venv/bin/python scripts/audit_api_parity.py
60
60
  venv/bin/python scripts/gen_api_limitations.py --check
61
61
  node scripts/agent-rules/generate.mjs --check
62
- venv/bin/python -m unittest tests.test_static_undefined_names tests.test_action_list_drift tests.test_panel_docs_drift tests.test_doc_tool_counts tests.test_agent_rules_drift
62
+ venv/bin/python -m unittest tests.test_static_undefined_names tests.test_duplicate_definitions tests.test_action_list_drift tests.test_panel_docs_drift tests.test_doc_tool_counts tests.test_agent_rules_drift
63
63
  node bin/davinci-resolve-mcp.mjs --help
64
64
  node bin/davinci-resolve-mcp.mjs --version
65
65
  npm pack --dry-run
66
66
  git diff --check
67
67
  ```
68
68
 
69
+ `test_duplicate_definitions` asserts no module-level name is defined twice under
70
+ `src/`. A second `def foo` silently replaces the first, and in a module the size
71
+ of `src/server.py` the two can be thousands of lines apart with different
72
+ signatures. pyflakes does **not** catch this — it only reports redefinition of an
73
+ *unused* name, so the dangerous case (first definition used, then shadowed)
74
+ passes clean.
75
+
69
76
  The drift guards (undefined names in `src/`, tool action lists vs dispatch,
70
77
  control-panel guide vs panel navigation/screenshots, api-limitations vs the
71
78
  api_truth ledger, and **tool counts across the docs vs reality** —
@@ -12,7 +12,7 @@ that none exists).
12
12
 
13
13
  **Verified on:** DaVinci Resolve Studio 21.0.0
14
14
 
15
- **Totals:** 19 missing capabilities, 12 bugs / unreliable behaviors.
15
+ **Totals:** 20 missing capabilities, 13 bugs / unreliable behaviors.
16
16
 
17
17
  The authoritative source is the runtime-queryable `api_truth` ledger
18
18
  (`resolve_control api_truth "<query>"`); this document is generated from
@@ -177,6 +177,14 @@ equivalent, blocking full automation.
177
177
  - **Workaround / current handling:** Delete and recreate the folder with the desired name, or rename in the Resolve UI.
178
178
  - **Tags:** missing-method, media-pool, folder
179
179
 
180
+ ### Project.SetCurrentRenderFormatAndCodec
181
+
182
+ - **Object:** `Project`
183
+ - **Signature:** `(format, codec) -> bool`
184
+ - **Behavior:** Some render formats expose NO codecs at all — GetRenderCodecs('wav') and GetRenderCodecs('gif') both return {} on Studio 19.1.3.7 — and the call then rejects every codec value, including the empty string, the format id itself, and any plausible name ('Linear PCM'). There is no documented way to select such a format through this API, so an audio-only WAV deliverable is not expressible in scripting.
185
+ - **Workaround / current handling:** Check GetRenderCodecs(format) first; when it is empty, treat the format as unreachable through this API rather than guessing a codec value. Render audio-only via ExportVideo=False on a format that does expose codecs, or drive it from a saved render preset.
186
+ - **Tags:** render, deliver, audio, unsupported
187
+
180
188
  ## Bugs / Unreliable Behavior (please fix)
181
189
 
182
190
  Methods that exist but misbehave — silent failures, unreliable return
@@ -274,3 +282,12 @@ values, or automation-hostile modal prompts.
274
282
  - **Workaround / current handling:** On a False return, locate the LUT, copy it into a namespaced subfolder of the master LUT dir (MCP/, so it does not clobber stock LUTs by basename), call RefreshLUTList(), and retry with the master-relative path. graph.set_lut and the granular graph_set_lut now do this automatically via src.utils.lut_paths.ensure_lut_in_master.
275
283
  - **Reference:** [issue #90](https://github.com/samuelgursky/davinci-resolve-mcp/issues/90)
276
284
  - **Tags:** color, lut, path-resolution, silent-failure
285
+
286
+ ### Project.GetRenderCodecs
287
+
288
+ - **Object:** `Project`
289
+ - **Signature:** `(renderFormat) -> {codec description: codec name}`
290
+ - **Behavior:** Returns {description: id} — the human-readable description is the KEY and the id Resolve actually accepts is the VALUE. SetCurrentRenderFormatAndCodec, GetRenderCodecs and GetRenderResolutions all require the id, so passing the description a user sees in the Deliver page is rejected. Verified live on Studio 19.1.3.7: ('mov', 'Apple ProRes 422 HQ') -> False while ('mov', 'ProRes422HQ') -> True, and ('mp4', 'H.264') -> False while ('mp4', 'H264') -> True. It affects every family, not only the ones whose id differs obviously. Mirrors the same trap in GetRenderFormats, which returns {format: extension}.
291
+ - **Workaround / current handling:** Normalize both arguments through the live maps before calling: src.utils.render_ids.render_format_id_from_formats and render_codec_id_from_codecs accept a description or an id and return the id.
292
+ - **Reference:** [issue #59](https://github.com/samuelgursky/davinci-resolve-mcp/issues/59)
293
+ - **Tags:** render, deliver, silent-failure, id-vs-label
package/install.py CHANGED
@@ -36,7 +36,7 @@ from src.utils.update_check import (
36
36
 
37
37
  # ─── Version ──────────────────────────────────────────────────────────────────
38
38
 
39
- VERSION = "2.66.0"
39
+ VERSION = "2.67.0"
40
40
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
41
41
  # Resolve's scripting bridge loads into newer interpreters on recent builds
42
42
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.66.0",
3
+ "version": "2.67.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,10 @@ Each dispatches on an `action`. Highlights:
87
87
  - **`deliverable`** — deliverable QC / compliance: `deliverable_qc` (ffprobe a render vs its spec →
88
88
  pass/fail per field), `loudness_qc` (ebur128 LUFS/true-peak/LRA), `reframe_blanking_check`,
89
89
  `conform_completeness`, `re_delivery_diff`, `render_manifest` (build/reconcile), `expand_deliverable`
90
- (texted/textless/stems/slate/leader entities). Report-only (`gate: review` never auto-pass-clear).
90
+ (texted/textless/stems/slate/leader entities), `spec_from_authored` (authored deliverable vocabulary
91
+ codec display names, `"1920x1080"`, `"-16 LUFS"`, naming templates — into a `deliverable_qc` spec plus a
92
+ `loudness_qc` target, reporting anything it could not map). Report-only (`gate: review` — never
93
+ auto-pass-clear).
91
94
  - **`media`** — media front-end / AE: `ingest_verify` (hash seal/verify/dupes-by-hash), `media_inventory`
92
95
  (fps/codec/colorspace/TC + card gaps), `sync` (TC picture↔sound + drift/MOS), `relink_manifest`,
93
96
  `rename_plan` (refuses camera originals) / `reel_normalize`, `turnover_package`, `project_hygiene`.
@@ -0,0 +1,310 @@
1
+ /**
2
+ * deliverable spec BRIDGE — authored deliverable vocabulary → ffprobe-shaped QC spec.
3
+ *
4
+ * The two vocabularies in this repo do not match, and that mismatch is why the
5
+ * `deliver` stage has never actually QC'd anything:
6
+ *
7
+ * authored (spec-compile / YAML) ffprobe (checkDeliverable / deliverable-qc)
8
+ * ------------------------------ ------------------------------------------
9
+ * video.codec "ProRes 422 HQ" video.codec "prores"
10
+ * video.res "1920x1080" video.width 1920 + video.height 1080
11
+ * video.color "Rec.709 Gamma 2.4" video.colorPrimaries/colorMatrix "bt709"
12
+ * audio.config "stereo" audio.channels 2
13
+ * audio.loudness "-16 LUFS" (not a QC-spec field — a loudnessQc target)
14
+ * naming "<SHOW>_<EP>_M_<YYYYMMDD>" filenameRegex "^..._\\d{8}\\.mov$"
15
+ * (implied by the naming extension) container "mov"
16
+ *
17
+ * PURE — no I/O, no Resolve, no ffprobe. Mirrors the live Python server's
18
+ * `src/utils/delivery_targets.py`, which performs the same projection for named
19
+ * render intents; this side handles the *authored* vocabulary, which only exists
20
+ * here. Neither table is duplicated.
21
+ *
22
+ * Two rules carried over from the Python side, for the same reason:
23
+ *
24
+ * 1. Assert only what is actually known. A QC field the spec never pinned
25
+ * produces a failure that says nothing about the deliverable.
26
+ * 2. Never drop silently. Anything that cannot be mapped is reported in
27
+ * `unmapped[]` so an unrecognized codec name surfaces instead of quietly
28
+ * producing a spec with no codec check in it.
29
+ *
30
+ * ffprobe gotcha: `format_name` is "mov,mp4,m4a,3gp,3g2,mj2" for BOTH .mov and
31
+ * .mp4, and ffprobe-media.mjs takes the first token — so `container` is "mov"
32
+ * for both and cannot discriminate them. `video.codec` is the discriminator.
33
+ */
34
+
35
+ /** Authored codec display name → ffprobe codec_name. Matched case-insensitively by prefix family. */
36
+ const CODEC_FAMILIES = [
37
+ { test: /prores/i, codec: 'prores' },
38
+ { test: /dnx(hr|hd)/i, codec: 'dnxhd' },
39
+ { test: /^h\.?264|avc(?!.*intra)|x264|xavc/i, codec: 'h264' },
40
+ { test: /^h\.?265|hevc|x265/i, codec: 'hevc' },
41
+ { test: /avc.?intra/i, codec: 'h264' },
42
+ { test: /jpe?g.?2000|j2k/i, codec: 'jpeg2000' },
43
+ { test: /cineform/i, codec: 'cfhd' },
44
+ { test: /^dv|dvcpro/i, codec: 'dvvideo' },
45
+ { test: /mpeg.?2|xdcam/i, codec: 'mpeg2video' },
46
+ { test: /uncompressed|v210/i, codec: 'v210' },
47
+ ];
48
+
49
+ /** Named rasters that appear in authored specs instead of an explicit WxH. */
50
+ const NAMED_RASTERS = {
51
+ hd: [1920, 1080],
52
+ '1080p': [1920, 1080],
53
+ fhd: [1920, 1080],
54
+ '720p': [1280, 720],
55
+ uhd: [3840, 2160],
56
+ '2160p': [3840, 2160],
57
+ '4k': [4096, 2160],
58
+ '4kdci': [4096, 2160],
59
+ '2k': [2048, 1080],
60
+ };
61
+
62
+ /** Authored audio layout word → channel count. */
63
+ const AUDIO_LAYOUTS = {
64
+ mono: 1,
65
+ stereo: 2,
66
+ '2.0': 2,
67
+ lcr: 3,
68
+ quad: 4,
69
+ '5.1': 6,
70
+ '7.1': 8,
71
+ };
72
+
73
+ /** Container inferred from a filename/naming extension. */
74
+ const EXT_CONTAINERS = {
75
+ mov: 'mov',
76
+ mp4: 'mov', // ffprobe first token is "mov" for mp4 too
77
+ m4v: 'mov',
78
+ mxf: 'mxf',
79
+ wav: 'wav',
80
+ mka: 'matroska',
81
+ mkv: 'matroska',
82
+ };
83
+
84
+ /** Date-ish naming tokens with a known shape; everything else is permissive. */
85
+ const TOKEN_PATTERNS = {
86
+ YYYYMMDD: '\\d{8}',
87
+ YYYY_MM_DD: '\\d{4}_\\d{2}_\\d{2}',
88
+ 'YYYY-MM-DD': '\\d{4}-\\d{2}-\\d{2}',
89
+ YYYY: '\\d{4}',
90
+ YY: '\\d{2}',
91
+ MM: '\\d{2}',
92
+ DD: '\\d{2}',
93
+ VER: 'v?\\d+',
94
+ V: 'v?\\d+',
95
+ };
96
+
97
+ const DEFAULT_TOKEN = '[A-Za-z0-9.-]+';
98
+
99
+ /** Map an authored codec name to an ffprobe codec_name, or null if unrecognized. */
100
+ export function codecToFfprobe(name) {
101
+ if (typeof name !== 'string' || !name.trim()) return null;
102
+ for (const { test, codec } of CODEC_FAMILIES) {
103
+ if (test.test(name)) return codec;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /** Parse "1920x1080" / "1920 X 1080" / "UHD" / "HD" → {width, height}, or null. */
109
+ export function parseRaster(res) {
110
+ if (res == null) return null;
111
+ if (typeof res === 'object' && res.width && res.height) {
112
+ return { width: Number(res.width), height: Number(res.height) };
113
+ }
114
+ if (typeof res !== 'string') return null;
115
+ const explicit = res.trim().match(/^(\d{2,5})\s*[x×]\s*(\d{2,5})$/i);
116
+ if (explicit) return { width: Number(explicit[1]), height: Number(explicit[2]) };
117
+ const named = NAMED_RASTERS[res.trim().toLowerCase().replace(/[\s_]/g, '')];
118
+ return named ? { width: named[0], height: named[1] } : null;
119
+ }
120
+
121
+ /** Parse an authored fps ("23.976", 23.98, "24000/1001") → number, or null. */
122
+ export function parseFps(fps) {
123
+ if (typeof fps === 'number' && Number.isFinite(fps)) return fps;
124
+ if (typeof fps !== 'string' || !fps.trim()) return null;
125
+ const ratio = fps.trim().match(/^(\d+)\s*\/\s*(\d+)$/);
126
+ if (ratio) return Number(ratio[1]) / Number(ratio[2]);
127
+ const n = Number(fps);
128
+ return Number.isFinite(n) ? n : null;
129
+ }
130
+
131
+ /** Parse "-16 LUFS" / -16 / "-16LUFS" → number, or null. */
132
+ export function parseLoudness(value) {
133
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
134
+ if (typeof value !== 'string') return null;
135
+ const m = value.trim().match(/^(-?\d+(?:\.\d+)?)\s*(?:lufs)?$/i);
136
+ return m ? Number(m[1]) : null;
137
+ }
138
+
139
+ /**
140
+ * Map an authored color description to the ffprobe tags that are UNAMBIGUOUS.
141
+ *
142
+ * Primaries and matrix follow from the colorimetry name. The transfer does NOT:
143
+ * "Rec.709 Gamma 2.4" names a display gamma, which is not the same thing as the
144
+ * BT.709 OETF that ffprobe would report as color_transfer=bt709. Asserting one
145
+ * for the other produces a QC failure that is about vocabulary, not the file, so
146
+ * the transfer is deliberately left unmapped and reported.
147
+ *
148
+ * @returns {{tags: object, unmapped: string[]}}
149
+ */
150
+ export function parseColor(color) {
151
+ if (typeof color !== 'string' || !color.trim()) return { tags: {}, unmapped: [] };
152
+ const text = color.toLowerCase();
153
+ const tags = {};
154
+ const unmapped = [];
155
+
156
+ if (/rec\.?\s*709|bt\.?\s*709/.test(text)) {
157
+ tags.colorPrimaries = 'bt709';
158
+ tags.colorMatrix = 'bt709';
159
+ } else if (/rec\.?\s*2020|bt\.?\s*2020/.test(text)) {
160
+ tags.colorPrimaries = 'bt2020';
161
+ tags.colorMatrix = /ncl/.test(text) ? 'bt2020nc' : 'bt2020nc';
162
+ } else if (/p3/.test(text)) {
163
+ tags.colorPrimaries = 'smpte432';
164
+ } else if (/rec\.?\s*601|bt\.?\s*601/.test(text)) {
165
+ tags.colorPrimaries = 'bt470bg';
166
+ tags.colorMatrix = 'bt470bg';
167
+ } else if (text.trim()) {
168
+ unmapped.push(`color '${color}': unrecognized colorimetry`);
169
+ }
170
+
171
+ if (/pq|st\.?\s*2084/.test(text)) tags.colorTransfer = 'smpte2084';
172
+ else if (/hlg|arib/.test(text)) tags.colorTransfer = 'arib-std-b67';
173
+ else if (/gamma/.test(text)) {
174
+ unmapped.push(
175
+ `color '${color}': display gamma is not an ffprobe color_transfer value; transfer left unasserted`,
176
+ );
177
+ }
178
+
179
+ return { tags, unmapped };
180
+ }
181
+
182
+ /**
183
+ * Turn a naming template into an anchored filename regex.
184
+ * `<SHOW>_<EP>_MASTER_<YYYYMMDD>.mov` → `^[A-Za-z0-9.-]+_[A-Za-z0-9.-]+_MASTER_\d{8}\.mov$`
185
+ */
186
+ export function namingToRegex(naming) {
187
+ if (typeof naming !== 'string' || !naming.trim()) return null;
188
+ let out = '';
189
+ let rest = naming.trim();
190
+ const tokenRe = /<([^<>]+)>/;
191
+ for (;;) {
192
+ const m = rest.match(tokenRe);
193
+ if (!m) {
194
+ out += escapeRegex(rest);
195
+ break;
196
+ }
197
+ out += escapeRegex(rest.slice(0, m.index));
198
+ const key = m[1].trim().toUpperCase().replace(/[\s]/g, '');
199
+ out += TOKEN_PATTERNS[key] ?? DEFAULT_TOKEN;
200
+ rest = rest.slice(m.index + m[0].length);
201
+ }
202
+ return `^${out}$`;
203
+ }
204
+
205
+ function escapeRegex(s) {
206
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
207
+ }
208
+
209
+ /** Container from an explicit value, else inferred from a naming/filename extension. */
210
+ export function inferContainer(explicit, naming) {
211
+ if (typeof explicit === 'string' && explicit.trim()) {
212
+ const key = explicit.trim().toLowerCase().replace(/^\./, '');
213
+ return EXT_CONTAINERS[key] ?? key;
214
+ }
215
+ if (typeof naming !== 'string') return null;
216
+ const m = naming.trim().match(/\.([A-Za-z0-9]+)\s*$/);
217
+ if (!m) return null;
218
+ return EXT_CONTAINERS[m[1].toLowerCase()] ?? m[1].toLowerCase();
219
+ }
220
+
221
+ /**
222
+ * Project ONE authored deliverable onto a checkDeliverable spec + a loudness target.
223
+ *
224
+ * @param {object} deliverable Authored shape: { id?, video:{codec,res,fps,color,scan?},
225
+ * audio:{config,loudness,sampleRate?,bitDepth?}, container?, naming?, durationSeconds? }
226
+ * @returns {{id, spec, loudnessTarget, unmapped, note}}
227
+ * `spec` feeds deliverable_qc; `loudnessTarget` feeds loudness_qc (null when not authored).
228
+ */
229
+ export function deliverableSpecFromAuthored(deliverable) {
230
+ const d = deliverable || {};
231
+ const v = d.video || {};
232
+ const a = d.audio || {};
233
+ const unmapped = [];
234
+
235
+ const video = {};
236
+ const ffCodec = codecToFfprobe(v.codec);
237
+ if (ffCodec) video.codec = ffCodec;
238
+ else if (v.codec) unmapped.push(`video.codec '${v.codec}': no known ffprobe codec_name`);
239
+
240
+ const raster = parseRaster(v.res ?? v.resolution);
241
+ if (raster) {
242
+ video.width = raster.width;
243
+ video.height = raster.height;
244
+ } else if (v.res ?? v.resolution) {
245
+ unmapped.push(`video.res '${v.res ?? v.resolution}': unparseable raster`);
246
+ }
247
+
248
+ const fps = parseFps(v.fps);
249
+ if (fps != null) video.fps = fps;
250
+ else if (v.fps != null) unmapped.push(`video.fps '${v.fps}': unparseable frame rate`);
251
+
252
+ if (typeof v.scan === 'string' && v.scan.trim()) video.scan = v.scan.trim().toLowerCase();
253
+ if (v.bitDepth != null) video.bitDepth = Number(v.bitDepth);
254
+
255
+ const color = parseColor(v.color);
256
+ Object.assign(video, color.tags);
257
+ unmapped.push(...color.unmapped);
258
+
259
+ const audio = {};
260
+ if (a.channels != null) audio.channels = Number(a.channels);
261
+ else if (typeof a.config === 'string') {
262
+ const channels = AUDIO_LAYOUTS[a.config.trim().toLowerCase()];
263
+ if (channels) audio.channels = channels;
264
+ else unmapped.push(`audio.config '${a.config}': unknown layout`);
265
+ }
266
+ if (a.sampleRate != null) audio.sampleRate = Number(a.sampleRate);
267
+ if (a.bitDepth != null) audio.bitDepth = Number(a.bitDepth);
268
+ if (typeof a.channelLayout === 'string') audio.channelLayout = a.channelLayout;
269
+
270
+ const spec = {};
271
+ if (Object.keys(video).length) spec.video = video;
272
+ if (Object.keys(audio).length) spec.audio = audio;
273
+
274
+ const container = inferContainer(d.container, d.naming);
275
+ if (container) spec.container = container;
276
+
277
+ const filenameRegex = namingToRegex(d.naming);
278
+ if (filenameRegex) spec.filenameRegex = filenameRegex;
279
+
280
+ if (d.durationSeconds != null) spec.durationSeconds = Number(d.durationSeconds);
281
+ if (d.durationTolSeconds != null) spec.durationTolSeconds = Number(d.durationTolSeconds);
282
+
283
+ const integrated = parseLoudness(a.loudness);
284
+ const loudnessTarget = integrated == null ? null : { integrated };
285
+ if (a.loudness != null && integrated == null) {
286
+ unmapped.push(`audio.loudness '${a.loudness}': unparseable target`);
287
+ }
288
+ if (a.truePeakMax != null && loudnessTarget) loudnessTarget.truePeakMax = Number(a.truePeakMax);
289
+ if (a.lraMax != null && loudnessTarget) loudnessTarget.lraMax = Number(a.lraMax);
290
+ if (a.loudnessTol != null && loudnessTarget) loudnessTarget.integratedTol = Number(a.loudnessTol);
291
+
292
+ return {
293
+ id: d.id ?? null,
294
+ spec,
295
+ loudnessTarget,
296
+ unmapped,
297
+ note:
298
+ 'Loudness is NOT part of the deliverable_qc spec — run loudness_qc separately with loudnessTarget.',
299
+ };
300
+ }
301
+
302
+ /** Project an authored `deliverables:` list. Non-object entries are reported, not dropped. */
303
+ export function deliverableSpecsFromAuthored(deliverables) {
304
+ if (!Array.isArray(deliverables)) return [];
305
+ return deliverables.map((d, i) =>
306
+ d && typeof d === 'object'
307
+ ? deliverableSpecFromAuthored(d)
308
+ : { id: null, spec: {}, loudnessTarget: null, unmapped: [`deliverables[${i}] is not an object`], note: null },
309
+ );
310
+ }
@@ -10,6 +10,7 @@
10
10
  *
11
11
  * VERIFY against the live server's real action names/args before finalizing (first real-footage session).
12
12
  */
13
+ import { deliverableSpecsFromAuthored } from './deliverable-spec-bridge.mjs';
13
14
 
14
15
  // stage → the Python action(s) + how each action's args derive from the resolved config.
15
16
  export const APPLY_CONTRACT = {
@@ -43,8 +44,22 @@ export const APPLY_CONTRACT = {
43
44
  qc: [{ action: 'noop', argsFrom: () => ({ note: 'qc is deterministic in Node (gamut_legal/deliverable_qc/verify_grade) — no live apply' }) }],
44
45
  deliver: [
45
46
  {
47
+ // The emitted job carries the QC spec that will later verify it, so the
48
+ // render and its check come from one authored definition instead of being
49
+ // re-derived by hand after the fact. Loudness is deliberately separate —
50
+ // it is not a deliverable_qc field (see deliverable-spec-bridge.mjs).
46
51
  action: 'render.add_job',
47
- argsFrom: (c) => ({ deliverables: c?.deliverables ?? c ?? null, note: 'expand deliverable entities → render preset + naming' }),
52
+ argsFrom: (c) => {
53
+ const deliverables = c?.deliverables ?? (Array.isArray(c) ? c : null);
54
+ const projected = deliverableSpecsFromAuthored(deliverables ?? []);
55
+ const unmapped = projected.flatMap((p) => (p.unmapped || []).map((u) => (p.id ? `${p.id}: ${u}` : u)));
56
+ return {
57
+ deliverables: deliverables ?? c ?? null,
58
+ qc: projected.map((p) => ({ id: p.id, spec: p.spec, loudnessTarget: p.loudnessTarget })),
59
+ ...(unmapped.length ? { unmapped } : {}),
60
+ note: 'render per deliverable, then deliverable_qc each output against its qc[].spec (and loudness_qc against qc[].loudnessTarget)',
61
+ };
62
+ },
48
63
  },
49
64
  ],
50
65
  };
@@ -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
  };