davinci-resolve-mcp 2.66.0 → 2.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/README.md +1 -1
- package/docs/SKILL.md +10 -0
- package/docs/kernels/render-deliver-kernel.md +32 -0
- package/docs/process/release-process.md +8 -1
- package/docs/reference/api-limitations.md +18 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/README.md +4 -1
- package/resolve-advanced/server/deliverable-spec-bridge.mjs +310 -0
- package/resolve-advanced/server/runner-apply-contract.mjs +16 -1
- package/resolve-advanced/server/tools/deliverable.mjs +20 -1
- package/src/granular/common.py +5 -1
- package/src/granular/project.py +22 -7
- package/src/server.py +271 -22
- package/src/utils/api_truth.py +43 -0
- package/src/utils/delivery_targets.py +846 -0
- package/src/utils/render_ids.py +68 -0
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-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:**
|
|
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.
|
|
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
|
@@ -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)
|
|
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) =>
|
|
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
|
};
|