davinci-resolve-mcp 2.102.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,71 @@
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
+
5
70
  ## What's New in v2.102.0
6
71
 
7
72
  **A rough mix that reports what it achieved, not what it intended.** The pieces were
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.102.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.102.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.102.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,13 @@ 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
+
555
562
  **Offline audio and image QC on `media_analysis`** — no Resolve connection required:
556
563
  `measure_loudness`, `mix_plan` / `mix_plan_capabilities` (dialogue-anchored rough mix
557
564
  with dialogue-following ducking, rendered and re-measured), and `assess_grade` /
@@ -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.102.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.102.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.102.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.102.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(
@@ -22875,6 +22896,8 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
22875
22896
 
22876
22897
 
22877
22898
  _TIMELINE_ACTIONS = [
22899
+ # Offline authoring — served without a Resolve connection, above the _check() gate.
22900
+ "author_offline", "offline_fallback_capabilities",
22878
22901
  "list", "get_current", "set_current", "get_name", "set_name", "get_start_frame",
22879
22902
  "get_end_frame", "get_start_timecode", "set_start_timecode", "get_track_count",
22880
22903
  "add_track", "delete_track", "get_track_sub_type", "set_track_enable",
@@ -23131,6 +23154,38 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
23131
23154
  # action_help is pull-on-demand metadata; no Resolve connection needed.
23132
23155
  if action == "action_help":
23133
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
+ ))
23134
23189
  pm, proj, err = _check()
23135
23190
  if err:
23136
23191
  return err
@@ -0,0 +1,307 @@
1
+ """When Resolve cannot be reached, produce something importable instead of stopping.
2
+
3
+ An unreachable Resolve currently ends the work. The error explaining *why* is a good
4
+ one — it reads the situation and names the fix that applies — but the timeline the caller
5
+ wanted still does not exist, and the interchange authoring that could produce it has been
6
+ sitting in this repository the whole time, one process away.
7
+
8
+ This routes to it. A clip plan the live path would have built in the app becomes an
9
+ OTIO, EDL, or DRT file the user imports in one action.
10
+
11
+ ## It is an offer, never a substitute
12
+
13
+ A caller who asked to build a timeline *in Resolve* has not succeeded because a file was
14
+ written somewhere. The connection error stays an error and gains an `offline_alternative`
15
+ block naming what could be produced; authoring only happens when someone asks for it.
16
+ Silently rerouting would turn "your project now has this timeline" into a claim that is
17
+ false in the only sense that matters.
18
+
19
+ ## Target order: DRT, then OTIO, then EDL
20
+
21
+ DRT is Resolve-native and carries track structure. OTIO round-trips through this repo's
22
+ own parser and carries gaps, per-clip speed, and transitions. EDL is CMX3600 — video
23
+ cuts and M2 speed, nothing else — and is the fallback when the other two are refused.
24
+
25
+ ## Two traps that have already cost time once
26
+
27
+ - **OTIO source frames are timecode-absolute.** An event with no media timecode origin
28
+ imports as an *empty* timeline: the file opens, nothing appears, and no error is
29
+ raised. Every event that had to assume an origin comes back as a warning naming the
30
+ clip, rather than being discovered later as a silent no-op.
31
+ - **A `.drt` carries no per-clip speed.** Retimes flatten to 100% forward. Events that
32
+ lost one are named; OTIO is the target that keeps them.
33
+
34
+ ## Authoring runs in Node, deliberately
35
+
36
+ `resolve-advanced/server/author-interchange.mjs` already writes all three formats and is
37
+ covered by that server's own suite. A second implementation in Python would be two
38
+ writers to keep in agreement, and the one that drifts is always the copy nobody runs. If
39
+ Node is unavailable, this says so and refuses — it does not fall back to a half-format.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import os
46
+ import shutil
47
+ import subprocess
48
+ from typing import Any, Dict, List, Optional, Sequence
49
+
50
+ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
51
+ BRIDGE_SCRIPT = os.path.join(REPO_ROOT, "scripts", "author_interchange.mjs")
52
+ AUTHORING_MODULE = os.path.join(
53
+ REPO_ROOT, "resolve-advanced", "server", "author-interchange.mjs"
54
+ )
55
+
56
+ TARGETS = ("drt", "otio", "edl")
57
+ DEFAULT_TARGET = "drt"
58
+ DEFAULT_FPS = 24.0
59
+
60
+ #: `drt.downgrade`'s verified map, quoted so the response can say which Resolve a file
61
+ #: targets instead of leaving the caller to find out on import.
62
+ DRT_PROJECT_VERSIONS = {"18.0.4": 11, "19.1": 14, "21.0": 17}
63
+
64
+
65
+ class OfflineFallbackError(Exception):
66
+ """Bad plan, missing tool, or a target that cannot carry the plan honestly."""
67
+
68
+
69
+ def capabilities() -> Dict[str, Any]:
70
+ node = shutil.which("node")
71
+ return {
72
+ "available": bool(node) and os.path.isfile(BRIDGE_SCRIPT) and os.path.isfile(AUTHORING_MODULE),
73
+ "node_path": node,
74
+ "bridge_script": BRIDGE_SCRIPT if os.path.isfile(BRIDGE_SCRIPT) else None,
75
+ "authoring_module": AUTHORING_MODULE if os.path.isfile(AUTHORING_MODULE) else None,
76
+ "targets": list(TARGETS),
77
+ "default_target": DEFAULT_TARGET,
78
+ "drt_project_versions": DRT_PROJECT_VERSIONS,
79
+ "note": (
80
+ "Writes an importable timeline when Resolve is unreachable. It does not make "
81
+ "a failed live operation succeed — the timeline exists as a file, not in a "
82
+ "project, until someone imports it."
83
+ ),
84
+ }
85
+
86
+
87
+ def _require() -> None:
88
+ report = capabilities()
89
+ if report["available"]:
90
+ return
91
+ missing = []
92
+ if not report["node_path"]:
93
+ missing.append("node is not on PATH")
94
+ if not report["bridge_script"]:
95
+ missing.append(f"{BRIDGE_SCRIPT} is missing")
96
+ if not report["authoring_module"]:
97
+ missing.append("the resolve-advanced authoring module is missing from this install")
98
+ raise OfflineFallbackError(
99
+ "offline interchange authoring is unavailable: " + "; ".join(missing)
100
+ )
101
+
102
+
103
+ # ── plan → events ────────────────────────────────────────────────────────────
104
+
105
+
106
+ def _number(value: Any, field: str, clip_index: int) -> Optional[float]:
107
+ if value is None:
108
+ return None
109
+ try:
110
+ return float(value)
111
+ except (TypeError, ValueError):
112
+ raise OfflineFallbackError(f"clip {clip_index}: '{field}' is not a number ({value!r})")
113
+
114
+
115
+ def plan_to_events(
116
+ clips: Sequence[Dict[str, Any]], *, fps: float = DEFAULT_FPS
117
+ ) -> List[Dict[str, Any]]:
118
+ """Turn a file-path clip plan into the normalized event list the writers take.
119
+
120
+ Everything is in FRAMES at the timeline rate. `end_frame` is EXCLUSIVE, matching
121
+ `AppendToTimeline`'s half-open range — the two shapes disagreeing would be a
122
+ one-frame error on every clip, which is exactly the kind that survives review.
123
+
124
+ Clips without `record_frame` are laid end to end per track, in the order given.
125
+ """
126
+ if not clips:
127
+ raise OfflineFallbackError("supply at least one clip")
128
+
129
+ cursor: Dict[str, float] = {"V": 0.0, "A": 0.0}
130
+ events: List[Dict[str, Any]] = []
131
+ for index, clip in enumerate(clips):
132
+ if not isinstance(clip, dict) or not clip.get("path"):
133
+ raise OfflineFallbackError(f"clip {index}: needs a 'path'")
134
+ path = str(clip["path"])
135
+ media_type = str(clip.get("media_type") or clip.get("mediaType") or "video").lower()
136
+ track = "A" if media_type.startswith("audio") else "V"
137
+
138
+ start = _number(clip.get("start_frame", clip.get("startFrame", 0)), "start_frame", index) or 0.0
139
+ end = _number(clip.get("end_frame", clip.get("endFrame")), "end_frame", index)
140
+ duration = _number(clip.get("duration_frames", clip.get("durationFrames")), "duration_frames", index)
141
+ if end is None and duration is None:
142
+ raise OfflineFallbackError(
143
+ f"clip {index}: supply end_frame (exclusive) or duration_frames"
144
+ )
145
+ if end is None:
146
+ end = start + float(duration)
147
+ length = float(end) - float(start)
148
+ if length <= 0:
149
+ raise OfflineFallbackError(
150
+ f"clip {index}: end_frame ({end}) must be greater than start_frame ({start}); "
151
+ "end_frame is exclusive"
152
+ )
153
+
154
+ record = _number(clip.get("record_frame", clip.get("recordFrame")), "record_frame", index)
155
+ if record is None:
156
+ record = cursor[track]
157
+ cursor[track] = float(record) + length
158
+
159
+ event: Dict[str, Any] = {
160
+ "source": path,
161
+ "track": track,
162
+ "recIn": int(round(float(record))),
163
+ "recOut": int(round(float(record) + length)),
164
+ "srcIn": int(round(float(start))),
165
+ "srcOut": int(round(float(end))),
166
+ "fps": float(clip.get("fps") or fps),
167
+ }
168
+ origin = clip.get("media_start_tc_frame", clip.get("mediaStartTcFrame"))
169
+ if origin is not None:
170
+ event["mediaStartTcFrame"] = int(round(float(origin)))
171
+ absolute = clip.get("src_tc_frame", clip.get("srcTcFrame"))
172
+ if absolute is not None:
173
+ event["srcTcFrame"] = int(round(float(absolute)))
174
+ if clip.get("speed") is not None:
175
+ event["speed"] = float(clip["speed"])
176
+ if clip.get("reverse"):
177
+ event["reverse"] = True
178
+ if clip.get("name"):
179
+ event["name"] = str(clip["name"])
180
+ events.append(event)
181
+ return events
182
+
183
+
184
+ # ── authoring ────────────────────────────────────────────────────────────────
185
+
186
+
187
+ def author(
188
+ clips: Sequence[Dict[str, Any]],
189
+ output_path: str,
190
+ *,
191
+ target: str = DEFAULT_TARGET,
192
+ name: str = "Offline Conform",
193
+ fps: float = DEFAULT_FPS,
194
+ start_timecode: str = "01:00:00:00",
195
+ resolution: str = "1920x1080",
196
+ ) -> Dict[str, Any]:
197
+ """Author an importable timeline file from a clip plan."""
198
+ normalized = str(target or DEFAULT_TARGET).lower()
199
+ if normalized not in TARGETS:
200
+ raise OfflineFallbackError(
201
+ f"unknown target '{target}'. Valid targets: {', '.join(TARGETS)}"
202
+ )
203
+ _require()
204
+ events = plan_to_events(clips, fps=fps)
205
+
206
+ request = {
207
+ "events": events,
208
+ "target": normalized,
209
+ "outputPath": os.path.abspath(output_path),
210
+ "opts": {
211
+ "name": name,
212
+ "fps": float(fps),
213
+ "startTimecode": start_timecode,
214
+ "resolution": resolution,
215
+ },
216
+ }
217
+ process = subprocess.run(
218
+ [shutil.which("node"), BRIDGE_SCRIPT],
219
+ input=json.dumps(request).encode("utf-8"),
220
+ capture_output=True,
221
+ check=False,
222
+ timeout=120,
223
+ )
224
+ raw = process.stdout.decode("utf-8", "replace").strip()
225
+ try:
226
+ result = json.loads(raw) if raw else {}
227
+ except json.JSONDecodeError:
228
+ raise OfflineFallbackError(
229
+ f"the authoring bridge produced no usable output "
230
+ f"(exit {process.returncode}): {raw[:300] or process.stderr.decode('utf-8', 'replace')[-300:]}"
231
+ )
232
+ if not result.get("ok"):
233
+ raise OfflineFallbackError(result.get("error") or "authoring failed")
234
+
235
+ return {
236
+ "target": result["target"],
237
+ "output_path": result["outputPath"],
238
+ "bytes": result["bytes"],
239
+ "event_count": result["eventCount"],
240
+ "warnings": result.get("warnings", []),
241
+ "import_with": _import_instruction(result["target"]),
242
+ "resolve_version": _version_note(result["target"]),
243
+ "note": (
244
+ "The timeline exists as a file. It is not in any project until it is "
245
+ "imported."
246
+ ),
247
+ }
248
+
249
+
250
+ def _import_instruction(target: str) -> str:
251
+ return {
252
+ "drt": "Resolve: right-click a Media Pool bin > Timelines > Import > Timeline, "
253
+ "or File > Import > Timeline.",
254
+ "otio": "Resolve: File > Import > Timeline, and pick the .otio.",
255
+ "edl": "Resolve: right-click a bin > Timelines > Import > Pre-conformed EDL. "
256
+ "Media must already be in the Media Pool for it to link.",
257
+ }[target]
258
+
259
+
260
+ def _version_note(target: str) -> Dict[str, Any]:
261
+ if target != "drt":
262
+ return {
263
+ "targets": "any Resolve that reads this format",
264
+ "note": "No project-version gate applies to OTIO or EDL.",
265
+ }
266
+ return {
267
+ "targets": "Resolve 21.0 (project version 17)",
268
+ "older_builds": (
269
+ "An older Resolve refuses a newer project version. Use the advanced server's "
270
+ "`drt(action='downgrade')` to stamp it down — verified map: 18.0.4 -> 11, "
271
+ "19.1.x -> 14, 21.0 -> 17."
272
+ ),
273
+ "project_versions": DRT_PROJECT_VERSIONS,
274
+ }
275
+
276
+
277
+ # ── the offer attached to a connection failure ───────────────────────────────
278
+
279
+
280
+ def offline_alternative(*, action: Optional[str] = None) -> Dict[str, Any]:
281
+ """The block a not-connected error carries. Describes an option; performs nothing."""
282
+ report = capabilities()
283
+ if not report["available"]:
284
+ return {
285
+ "available": False,
286
+ "reason": (
287
+ "Offline interchange authoring needs Node and the bundled authoring "
288
+ "module; one of them is missing from this install."
289
+ ),
290
+ }
291
+ return {
292
+ "available": True,
293
+ "what": (
294
+ "Resolve is unreachable, but a timeline can still be written as a file you "
295
+ "import in one action."
296
+ ),
297
+ "call": (
298
+ "timeline(action='author_offline', params={'clips': [{'path': ..., "
299
+ "'start_frame': ..., 'end_frame': ...}], 'output_path': ..., 'target': 'drt'})"
300
+ ),
301
+ "targets": list(TARGETS),
302
+ "does_not": (
303
+ "This does not complete the operation that just failed"
304
+ + (f" ({action})" if action else "")
305
+ + ". Nothing is added to a Resolve project until the file is imported."
306
+ ),
307
+ }