davinci-resolve-mcp 2.101.0 → 2.103.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,130 @@
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.103.0
6
+
7
+ **An unreachable Resolve no longer ends the work.** The interchange authoring that can
8
+ write an importable timeline has been in this repository the whole time, one process
9
+ away, while a connection failure stopped everything. This routes to it.
10
+
11
+ ### Added
12
+
13
+ - **`timeline author_offline`** — write an importable timeline from a file-path clip plan
14
+ with no Resolve connection. Served **above** the connection check, because it exists
15
+ for the case where there is none. Targets in preference order:
16
+ - **`drt`** (default) — Resolve-native, carries track structure. Stamped at project
17
+ version 17 (Resolve 21.0); older builds need the advanced server's
18
+ `drt(action='downgrade')`. Verified map: 18.0.4 -> 11, 19.1.x -> 14, 21.0 -> 17.
19
+ - **`otio`** — round-trips through this repo's own parser and carries gaps, per-clip
20
+ speed, and transitions. The target to pick when the plan has retimes.
21
+ - **`edl`** — CMX3600: video cuts and M2 speed, nothing else.
22
+ - **`timeline offline_fallback_capabilities`** — whether authoring is available here, and
23
+ why not if it is not.
24
+ - **`offline_alternative` on every not-connected error** — naming what could be produced.
25
+
26
+ ### It is an offer, never a substitute
27
+
28
+ A caller who asked to build a timeline *in Resolve* has not succeeded because a file was
29
+ written somewhere. The connection error stays an error, the block says outright that
30
+ authoring does not complete what failed, and nothing is authored unless it is asked for.
31
+ A test asserts both halves, because an offer that reads as success is worse than no offer.
32
+
33
+ ### Two silent failures, now named
34
+
35
+ - `media_tc_origin_assumed` — OTIO source frames are **timecode-absolute**. An event with
36
+ no media timecode origin imports as an *empty* timeline: the file opens, nothing
37
+ appears, and no error is raised. Every event that had to assume an origin is named,
38
+ with the fix (`media_start_tc_frame` per clip).
39
+ - `retimes_flattened` — a `.drt` carries no per-clip speed field, so retimes flatten to
40
+ 100% forward. Every event that lost one is named, with OTIO as the target that keeps it.
41
+
42
+ ### Fixed
43
+
44
+ `_check()` emitted its own flat `NOT_CONNECTED` error asserting Resolve might not be
45
+ running and pointing every reader at a Studio-only preference — the same three wrong
46
+ claims `_not_connected_error` was written to stop making in v2.63, still being made here
47
+ because two producers of one error had drifted apart. It now delegates, so the message
48
+ distinguishes "not running" from "running but refusing scripting" from "the bridge is
49
+ enabled and silent", and free-edition users stop being sent to check a Studio install.
50
+
51
+ ### Design notes
52
+
53
+ - **Frame numbers are at the timeline rate and `end_frame` is EXCLUSIVE**, matching
54
+ `AppendToTimeline`'s half-open range. The two shapes disagreeing would be a one-frame
55
+ error on every clip — exactly the kind that survives review.
56
+ - **Authoring runs in Node** against `resolve-advanced/server/author-interchange.mjs`
57
+ rather than a second Python writer. Two writers to keep in agreement means the one that
58
+ drifts is always the copy nobody runs. Without Node it refuses and says why.
59
+
60
+ ### Validation
61
+
62
+ - Offline suite: 2959 passed, 1 skipped, 719 subtests, 0 failures.
63
+ - All three targets authored and read back: OTIO parsed as a Timeline document with
64
+ timecode-absolute source ranges, DRT and EDL written and inspected.
65
+ - Three deliberate mutations (swallowing the media-origin warning, treating `end_frame`
66
+ as inclusive, and marking the connection error as a success) were each caught.
67
+ - No Resolve behavior changed by the authoring path itself; live import validation of an
68
+ authored file is **not** included in this release.
69
+
70
+ ## What's New in v2.102.0
71
+
72
+ **A rough mix that reports what it achieved, not what it intended.** The pieces were
73
+ already here — `media_analysis` measures EBU R128 loudness and detects silence,
74
+ `delivery_targets` holds the standards, `loudness_qc` grades a finished file. What was
75
+ missing is the step between measuring and grading: deciding the gains.
76
+
77
+ ### Added
78
+
79
+ - **`media_analysis mix_plan`** — dialogue-normalisation gain, a music-bed level relative
80
+ to it, and ducking windows derived from silence detection **on the dialogue stem**, so
81
+ the bed follows the words rather than a hand-placed envelope. `dry_run` defaults to
82
+ true and renders nothing.
83
+ - **The achieved loudness is measured, not derived.** The premix is rendered, then
84
+ re-measured; `achieved` carries integrated LUFS, true peak, loudness range, and the
85
+ delta from target. A plan that hits its target on paper and clips on true peak is a
86
+ failed plan, and only the measurement tells you which one you have.
87
+ - **Dialogue-anchored, then programme-trimmed.** Anchoring dialogue at target is right
88
+ for a dialogue-gated standard and wrong for a full-programme one the moment a bed is
89
+ added. For non-dialogue-gated standards one measured trim is applied to everything
90
+ equally — preserving the dialogue-to-bed relationship — and reported as
91
+ `program_normalize.trim_db`. It never runs on a dialogue-gated standard, where
92
+ dialogue is the figure being graded.
93
+ - **Nothing else is corrected.** `loudness_off_target`, `true_peak_over`, and `clipped`
94
+ come back as flags with remedies, never as a quietly normalised file.
95
+ - Standards come from `delivery_targets` (`web`, `podcast`, `ebu_r128`, `atsc_a85`,
96
+ `ott_dialogue_gated`) — the table the delivery tools already grade against, not a
97
+ second copy.
98
+ - **`media_analysis measure_loudness`** — integrated LUFS, loudness range, and true peak
99
+ per file.
100
+ - **`media_analysis mix_plan_capabilities`** — dependency state, known standards, and the
101
+ defaults, including the music-bed offset, which is the number most likely to be argued
102
+ with and so is named rather than buried.
103
+
104
+ ### Fixed while building it
105
+
106
+ The new loudness parser reads the `Summary:` block **and** drops ebur128's per-frame
107
+ progress lines, which carry their own `I:` and `LRA:` fields. A plain last-match-wins
108
+ parse is correct only because ffmpeg happens to print the summary last, and scoping to
109
+ the summary alone still swallows a progress line printed after it. Both steps are needed;
110
+ a test with a trailing progress line pins it.
111
+
112
+ ### Scope
113
+
114
+ A rough mix: gain staging, a bed, and ducking. No EQ, compression, de-essing, or
115
+ limiting, and the module says so in its capabilities rather than leaving it implied.
116
+
117
+ ### Validation
118
+
119
+ - Offline suite: 2924 passed, 1 skipped, 711 subtests, 0 failures.
120
+ - End-to-end through real ffmpeg on generated tones: target hit from measurement, the
121
+ programme trim landing a hot bed on R128, a dialogue-gated standard refusing the trim,
122
+ and clipping reported rather than normalised away.
123
+ - Three deliberate mutations (silent peak normalisation, trimming a dialogue-gated
124
+ standard, and dropping the parser scoping) were each caught. The parser mutation was
125
+ caught only after the test was strengthened — the first version of it passed against
126
+ both the fix and its absence.
127
+ - No Resolve behavior changed; live test not required.
128
+
5
129
  ## What's New in v2.101.0
6
130
 
7
131
  **A grade can now reject itself.** `assess_grade` has measured grade damage since
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.101.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.103.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.101.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.103.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.101.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.103.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/docs/SKILL.md CHANGED
@@ -552,6 +552,20 @@ Key actions:
552
552
  `load_user_preferences_preset`
553
553
  - `quit` — terminates Resolve (destructive; confirm with user first)
554
554
 
555
+ **Offline timeline authoring on `timeline`** — served above the connection check:
556
+ `author_offline` writes an importable `.drt` / `.otio` / `.edl` from a clip plan when
557
+ Resolve is unreachable, and `offline_fallback_capabilities` reports whether it can. Every
558
+ not-connected error carries an `offline_alternative` block naming it. Authoring a file
559
+ does not complete a failed live operation — the timeline is not in a project until it is
560
+ imported. See `docs/kernels/timeline-conform-interchange-kernel.md`.
561
+
562
+ **Offline audio and image QC on `media_analysis`** — no Resolve connection required:
563
+ `measure_loudness`, `mix_plan` / `mix_plan_capabilities` (dialogue-anchored rough mix
564
+ with dialogue-following ducking, rendered and re-measured), and `assess_grade` /
565
+ `grade_loop` / `grade_loop_capabilities` (numeric grade-damage QC and the retry ladder
566
+ that backs a look off until it stops damaging the picture). See
567
+ `docs/kernels/audio-fairlight-kernel.md` and `docs/kernels/color-grade-kernel.md`.
568
+
555
569
  **`layout_presets`** — Save, load, export, import, delete UI layout presets.
556
570
  `list` (Resolve 21.0.4+) enumerates the saved preset names the other actions
557
571
  take.
@@ -53,6 +53,39 @@ All kernel actions are exposed through `timeline`.
53
53
  - Subtitle generation from the generated timeline returned true.
54
54
  - Fairlight preset listing and the full boundary report worked.
55
55
 
56
+ ## Rough mix (`media_analysis`)
57
+
58
+ Offline and file-based — no Resolve connection — sitting between measuring loudness and
59
+ grading it against a spec. The step nothing else covered was deciding the gains.
60
+
61
+ - `measure_loudness(path|paths)` — integrated LUFS, loudness range, and true peak per
62
+ file via ffmpeg `ebur128`.
63
+ - `mix_plan(dialogue[], music?, sfx?, standard?, target_lufs?, bed_offset_lu?, duck_db?,
64
+ attack_s?, release_s?, hold_s?, dry_run?, output_path?, program_normalize?)` —
65
+ dialogue-normalisation gain, a bed level relative to it, and ducking windows derived
66
+ from silence detection **on the dialogue stem**, so the bed follows the words rather
67
+ than a hand-placed envelope. `dry_run` defaults to true and renders nothing.
68
+ - `mix_plan_capabilities()` — dependency state, the standards it knows, and the defaults.
69
+
70
+ Three things worth knowing before using it:
71
+
72
+ - **The achieved loudness is measured, not derived.** The premix is rendered, then
73
+ re-measured, and `achieved` carries what came back. A plan that hits its target on
74
+ paper and clips on true peak is a failed plan; only the measurement distinguishes them.
75
+ - **Dialogue-anchored, then programme-trimmed.** Anchoring dialogue at target is right
76
+ for a dialogue-gated standard and wrong for a full-programme one the moment a bed is
77
+ added. For non-dialogue-gated standards a single measured trim is applied to everything
78
+ equally — preserving the dialogue-to-bed relationship — and reported as
79
+ `program_normalize.trim_db`. It never runs on a dialogue-gated standard.
80
+ - **Nothing else is corrected.** Over true peak or clipped comes back as a flag with a
81
+ remedy, never as a quietly normalised file. Pulling the mix down to fix a peak would
82
+ move the loudness off the target it just hit.
83
+
84
+ Standards come from `src/utils/delivery_targets.py` (`web`, `podcast`, `ebu_r128`,
85
+ `atsc_a85`, `ott_dialogue_gated`) — the same table the delivery tools grade against, not
86
+ a second copy. It is a rough mix: gain staging, a bed, and ducking. No EQ, compression,
87
+ de-essing, or limiting.
88
+
56
89
  ## Boundaries
57
90
 
58
91
  - Timeline item audio properties may be readable as `None` and can reject writes
@@ -67,6 +67,45 @@ Supported aliases include `aaf`, `drt`, `edl`, `edl_cdl`, `edl_sdl`,
67
67
  - Synthetic-only unlink, missing-media detection, relink candidate planning,
68
68
  and safe relink all worked through generated media.
69
69
 
70
+ ## When Resolve is unreachable
71
+
72
+ An unreachable Resolve used to end the work. It no longer has to: `timeline` serves two
73
+ actions **above** the connection check, and every not-connected error carries an
74
+ `offline_alternative` block naming them.
75
+
76
+ - `author_offline(clips[], output_path, target?, name?, fps?, start_timecode?,
77
+ resolution?)` — write an importable timeline from a file-path clip plan.
78
+ Targets, in preference order:
79
+ - **`drt`** (default) — Resolve-native, carries track structure. Stamped at project
80
+ version 17 (Resolve 21.0); older builds need the advanced server's
81
+ `drt(action='downgrade')`. Verified map: 18.0.4 → 11, 19.1.x → 14, 21.0 → 17.
82
+ - **`otio`** — round-trips through this repo's own parser and carries gaps, per-clip
83
+ speed, and transitions. The target to pick when the plan has retimes.
84
+ - **`edl`** — CMX3600: video cuts and M2 speed, nothing else.
85
+ - `offline_fallback_capabilities()` — whether authoring is available here, and why not
86
+ if it is not.
87
+
88
+ Frame numbers are at the timeline rate and `end_frame` is **EXCLUSIVE**, matching
89
+ `AppendToTimeline`'s half-open range.
90
+
91
+ **It is an offer, not a substitute.** A caller who asked to build a timeline *in Resolve*
92
+ has not succeeded because a file was written. The connection error stays an error, the
93
+ block says outright that authoring does not complete what failed, and nothing is authored
94
+ unless someone asks for it.
95
+
96
+ Two warnings the result can carry, both for failures that are otherwise silent:
97
+
98
+ - `media_tc_origin_assumed` — OTIO source frames are **timecode-absolute**. An event with
99
+ no media timecode origin imports as an *empty* timeline: the file opens, nothing
100
+ appears, no error is raised. Pass `media_start_tc_frame` (or an absolute
101
+ `src_tc_frame`) per clip. Every event that had to assume is named.
102
+ - `retimes_flattened` — a `.drt` has no per-clip speed field, so retimes flatten to 100%
103
+ forward. Every event that lost one is named; author OTIO to keep them.
104
+
105
+ Authoring runs in Node against `resolve-advanced/server/author-interchange.mjs` rather
106
+ than a second Python writer — two writers to keep in agreement means the one that drifts
107
+ is always the copy nobody runs. Without Node it refuses and says so.
108
+
70
109
  ## Boundaries
71
110
 
72
111
  - Interchange formats are not semantically equivalent. DRT is the strongest
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.101.0"
40
+ VERSION = "2.103.0"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (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.101.0",
3
+ "version": "2.103.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,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.101.0"
90
+ VERSION = "2.103.0"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.101.0"
14
+ VERSION = "2.103.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -970,7 +970,7 @@ def _not_connected_error():
970
970
  running = resolve_is_running()
971
971
  bridge_on = _bridge_requested()
972
972
  if bridge_on:
973
- return _err(
973
+ return _with_offline_alternative(_err(
974
974
  "The in-app bridge is enabled but not answering.",
975
975
  code="BRIDGE_UNAVAILABLE", category="not_connected",
976
976
  # `not_connected` defaults to retryable because auto-launch may
@@ -986,9 +986,9 @@ def _not_connected_error():
986
986
  "run `launchctl setenv PYTHON3HOME \"$(python3 -c 'import sys; "
987
987
  "print(sys.prefix)')\"` (launchctl, not export) and restart Resolve.",
988
988
  state={"resolve_running": running, "bridge_enabled": True},
989
- )
989
+ ))
990
990
  if running:
991
- return _err(
991
+ return _with_offline_alternative(_err(
992
992
  "DaVinci Resolve is running but is not answering the scripting API.",
993
993
  code="SCRIPTING_UNAVAILABLE", category="not_connected",
994
994
  # Not retryable: a preference has to change, or the bridge has to be
@@ -1006,13 +1006,33 @@ def _not_connected_error():
1006
1006
  # responses, and the in-app bridge is not an option for the former.
1007
1007
  state={"resolve_running": True, "bridge_enabled": False,
1008
1008
  "headless": _resolve_runtime.is_headless()},
1009
- )
1010
- return _err(
1009
+ ))
1010
+ return _with_offline_alternative(_err(
1011
1011
  "DaVinci Resolve is not running and could not be started.",
1012
1012
  code="RESOLVE_NOT_RUNNING", category="not_connected",
1013
1013
  remediation="Start DaVinci Resolve and open a project, then retry.",
1014
1014
  state={"resolve_running": bool(running), "bridge_enabled": False},
1015
- )
1015
+ ))
1016
+
1017
+
1018
+ def _with_offline_alternative(error: Dict[str, Any]) -> Dict[str, Any]:
1019
+ """Attach the offline-authoring offer to a not-connected error.
1020
+
1021
+ An offer, not a substitute: the error stays an error, and the block says outright
1022
+ that authoring a file does not complete the operation that just failed. Rerouting
1023
+ silently would turn "your project now has this timeline" into a claim that is false
1024
+ in the only sense that matters.
1025
+ """
1026
+ try:
1027
+ from src.utils import offline_fallback as _offline_mod
1028
+
1029
+ alternative = _offline_mod.offline_alternative()
1030
+ if alternative.get("available"):
1031
+ error.setdefault("error", {})["offline_alternative"] = alternative
1032
+ except Exception:
1033
+ # A connection error must survive anything wrong with the fallback path.
1034
+ pass
1035
+ return error
1016
1036
 
1017
1037
 
1018
1038
  def _destructive_versioning_provider() -> Optional[Tuple[Any, Any, str, Optional[str]]]:
@@ -2489,11 +2509,12 @@ def _check():
2489
2509
  )
2490
2510
  resolve = get_resolve()
2491
2511
  if resolve is None:
2492
- return None, None, _err(
2493
- "Not connected to DaVinci Resolve. Is Resolve running?",
2494
- code="NOT_CONNECTED", category="not_connected", retryable=True,
2495
- remediation="Open DaVinci Resolve Studio and set Preferences > General > 'External scripting using' to Local.",
2496
- )
2512
+ # Delegate rather than assert. This branch used to claim Resolve might not be
2513
+ # running and point every reader at a Studio-only preference — the same three
2514
+ # wrong claims `_not_connected_error` was written to stop making, still being
2515
+ # made here because two producers of the same error drifted apart. It also
2516
+ # carries the offline-authoring offer, which this branch never had.
2517
+ return None, None, _not_connected_error()
2497
2518
  pm = resolve.GetProjectManager()
2498
2519
  if pm is None:
2499
2520
  return None, None, _err(
@@ -20128,6 +20149,52 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
20128
20149
  from src.utils import grade_loop as _grade_loop_mod
20129
20150
 
20130
20151
  return _ok(**_grade_loop_mod.capabilities())
20152
+ if action in {"mix_plan", "measure_loudness", "mix_plan_capabilities"}:
20153
+ # Gain staging between measuring loudness and grading it: dialogue-norm gain,
20154
+ # a bed level relative to it, and ducking windows the dialogue itself implies.
20155
+ # The render is measured afterwards, so what comes back is the loudness
20156
+ # achieved rather than the arithmetic meant to produce it.
20157
+ from src.utils import mix_plan as _mix_plan_mod
20158
+
20159
+ if action == "mix_plan_capabilities":
20160
+ return _ok(**_mix_plan_mod.capabilities())
20161
+ try:
20162
+ if action == "measure_loudness":
20163
+ paths = p.get("paths") or ([p["path"]] if p.get("path") else [])
20164
+ if not paths:
20165
+ return _err("measure_loudness requires path or paths")
20166
+ return _ok(measurements=[_mix_plan_mod.measure(str(item)) for item in paths])
20167
+
20168
+ dialogue = p.get("dialogue") or ([p["path"]] if p.get("path") else [])
20169
+ if isinstance(dialogue, str):
20170
+ dialogue = [dialogue]
20171
+ kwargs = dict(
20172
+ music=p.get("music") or [],
20173
+ sfx=p.get("sfx") or [],
20174
+ standard=str(p.get("standard") or _mix_plan_mod.DEFAULT_STANDARD),
20175
+ target_lufs=(
20176
+ float(p["target_lufs"]) if p.get("target_lufs") is not None else None
20177
+ ),
20178
+ bed_offset_lu=float(p.get("bed_offset_lu", _mix_plan_mod.DEFAULT_BED_OFFSET_LU)),
20179
+ duck_db=float(p.get("duck_db", _mix_plan_mod.DEFAULT_DUCK_DB)),
20180
+ attack_s=float(p.get("attack_s", _mix_plan_mod.DEFAULT_ATTACK_S)),
20181
+ release_s=float(p.get("release_s", _mix_plan_mod.DEFAULT_RELEASE_S)),
20182
+ hold_s=float(p.get("hold_s", _mix_plan_mod.DEFAULT_HOLD_S)),
20183
+ )
20184
+ if p.get("dry_run", True):
20185
+ return _ok(**_mix_plan_mod.plan(dialogue, **kwargs))
20186
+ return _ok(**_mix_plan_mod.render(
20187
+ dialogue,
20188
+ output_path=(p.get("output_path") or p.get("outputPath")) or None,
20189
+ program_normalize=p.get("program_normalize"),
20190
+ **kwargs,
20191
+ ))
20192
+ except _mix_plan_mod.MixPlanError as exc:
20193
+ return _err(str(exc), code="MIX_PLAN_REFUSED", category="invalid_input",
20194
+ remediation=(
20195
+ "Supply dialogue=[...] stems that carry audio. The mix is "
20196
+ "anchored to dialogue, so it cannot be planned without one."
20197
+ ))
20131
20198
  if action == "image_qc_capabilities":
20132
20199
  from src.utils import image_qc as _image_qc_mod
20133
20200
 
@@ -21082,6 +21149,9 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
21082
21149
  "assess_grade",
21083
21150
  "grade_loop",
21084
21151
  "grade_loop_capabilities",
21152
+ "mix_plan",
21153
+ "mix_plan_capabilities",
21154
+ "measure_loudness",
21085
21155
  "image_qc_capabilities",
21086
21156
  "install_guidance",
21087
21157
  "resolve_output_root",
@@ -22826,6 +22896,8 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
22826
22896
 
22827
22897
 
22828
22898
  _TIMELINE_ACTIONS = [
22899
+ # Offline authoring — served without a Resolve connection, above the _check() gate.
22900
+ "author_offline", "offline_fallback_capabilities",
22829
22901
  "list", "get_current", "set_current", "get_name", "set_name", "get_start_frame",
22830
22902
  "get_end_frame", "get_start_timecode", "set_start_timecode", "get_track_count",
22831
22903
  "add_track", "delete_track", "get_track_sub_type", "set_track_enable",
@@ -23082,6 +23154,38 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
23082
23154
  # action_help is pull-on-demand metadata; no Resolve connection needed.
23083
23155
  if action == "action_help":
23084
23156
  return _action_help("timeline", p)
23157
+ if action in {"author_offline", "offline_fallback_capabilities"}:
23158
+ # Deliberately above the connection check: these exist FOR the case where there
23159
+ # is no connection. Authoring writes a file the user imports; it does not make
23160
+ # a failed live operation succeed, and the response says so.
23161
+ from src.utils import offline_fallback as _offline_mod
23162
+
23163
+ if action == "offline_fallback_capabilities":
23164
+ return _ok(**_offline_mod.capabilities())
23165
+ err, _clean = _validate_params(p, {
23166
+ "output_path": {"type": str, "required": True, "non_empty": True},
23167
+ })
23168
+ if err:
23169
+ return _err(err)
23170
+ try:
23171
+ return _ok(**_offline_mod.author(
23172
+ p.get("clips") or [],
23173
+ str(p["output_path"]),
23174
+ target=str(p.get("target") or _offline_mod.DEFAULT_TARGET),
23175
+ name=str(p.get("name") or "Offline Conform"),
23176
+ fps=float(p.get("fps") or _offline_mod.DEFAULT_FPS),
23177
+ start_timecode=str(p.get("start_timecode") or "01:00:00:00"),
23178
+ resolution=str(p.get("resolution") or "1920x1080"),
23179
+ ))
23180
+ except _offline_mod.OfflineFallbackError as exc:
23181
+ return _err(str(exc), code="OFFLINE_AUTHORING_REFUSED",
23182
+ category="invalid_input", retryable=False,
23183
+ remediation=(
23184
+ "Supply clips=[{path, start_frame, end_frame}] with frame "
23185
+ "numbers at the timeline rate; end_frame is EXCLUSIVE. Add "
23186
+ "media_start_tc_frame per clip so source frames are "
23187
+ "timecode-absolute, or the import can produce an empty timeline."
23188
+ ))
23085
23189
  pm, proj, err = _check()
23086
23190
  if err:
23087
23191
  return err