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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,107 @@
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.1
6
+
7
+ Documentation correction. No code changes — v2.67.0 already ships the correct
8
+ behavior.
9
+
10
+ ### Changed
11
+
12
+ - **`docs/kernels/render-deliver-kernel.md` no longer quotes a format/codec count
13
+ as portable.** It presented "23 formats and 99 format/codec pairs" as the
14
+ expected probe result; a live probe on Studio 19.1.3.7 found 20 formats and 271
15
+ pairs. Both mentions now carry the build they came from and point at probing
16
+ the machine in hand (`probe_render_matrix`, or `list_delivery_targets` with
17
+ `check_availability`) instead of comparing against a fixed number.
18
+ - The kernel's boundary list now records the two traps the delivery-target work
19
+ verified live: codec **descriptions are not codec ids** (`H.264` vs `H264`, not
20
+ only the ProRes family), and some formats expose **no codecs at all** (`Wave`,
21
+ `GIF` on 19.1.3.7) and cannot be selected through this API.
22
+
23
+ ## What's New in v2.67.0
24
+
25
+ Adds **delivery targets** — named render intents that carry their own QC spec —
26
+ and fixes a silent render-codec bug found while designing them.
27
+
28
+ ### Fixed
29
+
30
+ - **Render codec display names were rejected.** `GetRenderCodecs` returns
31
+ `{description: id}`, but the codec was passed to
32
+ `SetCurrentRenderFormatAndCodec`, `GetRenderResolutions`, and
33
+ `prepare_render_job` **raw** while the format was normalized. Verified live on
34
+ Studio 19.1.3.7: `('mov', 'Apple ProRes 422 HQ')` returns False while
35
+ `('mov', 'ProRes422HQ')` returns True — and the same holds for
36
+ `('mp4', 'H.264')` vs `('mp4', 'H264')`. Any render set by the codec name shown
37
+ in the Deliver page failed, across every codec family. This is the codec half
38
+ of the format-id fix (issue #59).
39
+ - **A rejected format/codec no longer queues a job anyway.**
40
+ `prepare_render_job` previously applied settings and called `AddRenderJob()`
41
+ even when `SetCurrentRenderFormatAndCodec` returned False, reporting
42
+ `success: True` with `format_success: False` buried in the payload — a queued
43
+ job that would render in the *previously set* codec. It now returns
44
+ `RENDER_FORMAT_CODEC_REJECTED` with the machine's available codecs and queues
45
+ nothing. `set_format_and_codec` returns the same structured error instead of a
46
+ bare `success: False`.
47
+ - **The granular server never received the issue #59 fix.**
48
+ `get_render_codecs` passed a raw display name, and
49
+ `set_current_render_format_and_codec` passed both format and codec raw. The
50
+ resolvers now live in `src/utils/render_ids.py` and are shared by both servers,
51
+ so the two cannot drift apart again.
52
+
53
+ ### Added
54
+
55
+ - **Delivery targets** (`src/utils/delivery_targets.py`) — 28 named render
56
+ intents across master / web / sequence / broadcast / package tiers, every one
57
+ resolved live against Resolve Studio 19.1.3.7 (20 formats / 271 pairs). One
58
+ definition projects onto both Resolve `SetRenderSettings` keys and the
59
+ ffprobe-shaped spec `deliverable_qc` consumes, so a render and the check that
60
+ verifies it come from the same source. New `render` actions:
61
+ `list_delivery_targets`, `resolve_delivery_target`, `prepare_delivery_job`.
62
+ - Ids describe the deliverable (`prores422hq_master`, `h264_1080p_web`);
63
+ platform names (`youtube`, `tiktok`, `avid`, `stems`) are **aliases**, so a
64
+ platform changing its guidance repoints an alias instead of rewriting a target.
65
+ - Format and codec are ordered **candidate lists** resolved against the live
66
+ matrix, because codec descriptions vary by Resolve version, license, and
67
+ installed IO plugins. An unavailable target fails with the machine's actual
68
+ available lists; `list_delivery_targets` with `check_availability` reports
69
+ what this install can render.
70
+ - User-defined targets load from `logs/delivery-targets.json` (override with
71
+ `DAVINCI_RESOLVE_MCP_DELIVERY_TARGETS`); a malformed entry is skipped with a
72
+ warning rather than taking out the shipped set.
73
+ - **`deliverable(action="spec_from_authored")`** on the advanced server —
74
+ projects the authored deliverable vocabulary (codec display names,
75
+ `"1920x1080"`, `"-16 LUFS"`, `<SHOW>_<EP>_<YYYYMMDD>.mov` naming templates)
76
+ onto a `deliverable_qc` spec plus a `loudness_qc` target. Anything it cannot
77
+ map is reported in `unmapped[]` rather than dropped.
78
+ - **The `deliver` apply contract now carries QC specs.**
79
+ `APPLY_CONTRACT.deliver` emits `qc[]` alongside each deliverable, so a render
80
+ job travels with the spec that will verify it. Previously a stub.
81
+ - **`tests.test_duplicate_definitions`** — static guard against a module-level
82
+ name being defined twice under `src/`. pyflakes does not catch this (it only
83
+ reports redefinition of an *unused* name), and the dangerous case is exactly
84
+ the one it misses. Added to the release validation set.
85
+
86
+ ### Notes
87
+
88
+ - Bitrate is deliberately not encoded in delivery targets: Resolve exposes no
89
+ bitrate render-setting key, only `VideoQuality`, whose type varies per codec.
90
+ - Image-sequence and package (IMF/DCP) targets return no QC spec —
91
+ `deliverable_qc` probes a single file, and those render many files or a
92
+ directory. Every such target carries an explicit `qc_skip_reason`, so a missing
93
+ check is always explained rather than silent.
94
+ - **There is no audio-only WAV target.** The `Wave` format exposes zero codecs
95
+ and `SetCurrentRenderFormatAndCodec('wav', …)` rejects every value tried,
96
+ including the empty string. Recorded in `src/utils/api_truth.py` and the
97
+ generated `docs/reference/api-limitations.md`.
98
+ - The live pass corrected candidate spellings that were wrong: plain
99
+ `"DNxHR HQX"` / `"DNxHR 444"` do not exist (live labels carry a bit depth),
100
+ DPX/TIFF codecs are `"RGB 10 bits"` not `"RGB 10-bit"`, and PNG is not a
101
+ Resolve render format.
102
+ - `container` is `"mov"` for both `.mov` and `.mp4`: ffprobe reports
103
+ `format_name=mov,mp4,m4a,3gp,3g2,mj2` for each and only the first token is
104
+ kept, so `video.codec` is what discriminates them.
105
+
5
106
  ## What's New in v2.66.0
6
107
 
7
108
  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.1-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
 
@@ -47,7 +79,17 @@ All actions are under `render(action=...)`.
47
79
  readback as version/page dependent and still validates and applies settings
48
80
  through `SetRenderSettings`.
49
81
  - Render format and codec availability is machine, OS, license, and plugin
50
- dependent. The final live probe found 23 formats and 99 format/codec pairs.
82
+ dependent, and it varies a lot: a live probe found 23 formats / 99 pairs on one
83
+ build, and 20 formats / 271 pairs on Studio 19.1.3.7. Treat neither figure as
84
+ the expected count — probe the machine you are on
85
+ (`probe_render_matrix`, or `list_delivery_targets` with `check_availability`).
86
+ - Codec **descriptions are not codec ids**. `GetRenderCodecs` returns
87
+ `{description: id}` and every setter wants the id, so the name shown in the
88
+ Deliver page is rejected — for `H.264` (`H264`) just as much as for
89
+ `Apple ProRes 422 HQ` (`ProRes422HQ`). `src/utils/render_ids.py` normalizes
90
+ either form; see `docs/reference/api-limitations.md`.
91
+ - Some formats expose **no codecs at all** (`Wave`, `GIF` on 19.1.3.7) and then
92
+ reject every codec value, so they cannot be selected through this API.
51
93
  - Some settings may be accepted but not readable for coercion checks on builds
52
94
  where `GetRenderSettings` is unavailable.
53
95
  - Quick Export actual execution is intentionally gated behind
@@ -119,6 +161,8 @@ Result:
119
161
 
120
162
  The live harness created and deleted a disposable project named
121
163
  `_mcp_render_deliver_probe_1778342107`, generated synthetic media, probed 23
122
- formats and 99 format/codec pairs, rendered one tiny synthetic output, wrote
164
+ formats and 99 format/codec pairs **on that build** (a later probe on Studio
165
+ 19.1.3.7 saw 20 formats / 271 pairs — the counts are not portable), rendered one
166
+ tiny synthetic output, wrote
123
167
  JSON and Markdown reports, and removed the generated media and render output
124
168
  directories after the report was written.
@@ -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.1"
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.1",
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
  };