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 +65 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +7 -0
- package/docs/kernels/timeline-conform-interchange-kernel.md +39 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +67 -12
- package/src/utils/offline_fallback.py +307 -0
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v2.
|
|
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.
|
|
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
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "2.
|
|
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.
|
|
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
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
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
|
+
}
|