davinci-resolve-mcp 2.104.0 → 2.104.2
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 +77 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/guides/headless-edit-loop.md +15 -0
- package/install.py +33 -12
- package/package.json +1 -1
- package/resolve-advanced/server/db-patch.mjs +15 -3
- package/resolve-advanced/server/offline-ref-db.mjs +8 -2
- package/resolve-advanced/vendor/drp-format/__tests__/framerate-encoding.test.js +47 -0
- package/resolve-advanced/vendor/drp-format/drp-packager.js +1 -0
- package/resolve-advanced/vendor/drp-format/seq-container-builder.js +11 -3
- package/resolve-advanced/vendor/drp-format/xml-builder.js +23 -19
- package/scripts/doctor.py +85 -39
- package/src/granular/common.py +1 -1
- package/src/granular/media_pool.py +15 -7
- package/src/granular/timeline.py +10 -5
- package/src/granular/timeline_item.py +21 -7
- package/src/server.py +497 -93
- package/src/utils/api_truth.py +17 -5
- package/src/utils/app_control.py +11 -1
- package/src/utils/audio_fairlight_live_probe.py +8 -1
- package/src/utils/color_grade_live_probe.py +8 -1
- package/src/utils/fusion_composition_live_probe.py +8 -1
- package/src/utils/probe_catalogue.py +15 -3
- package/src/utils/project_cleanup.py +17 -3
- package/src/utils/render_deliver_live_probe.py +8 -1
- package/src/utils/resolve_bridge_client.py +10 -1
- package/src/utils/resolve_writes.py +79 -0
- package/src/utils/review_annotation_live_probe.py +8 -1
- package/src/utils/timeline_conform_live_probe.py +8 -1
- package/src/utils/timeline_kernel_live_probe.py +7 -1
- package/src/utils/timeline_versioning.py +15 -2
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.104.
|
|
14
|
+
VERSION = "2.104.2"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -44,6 +44,7 @@ for p in [current_dir, project_dir]:
|
|
|
44
44
|
|
|
45
45
|
# Platform-specific Resolve paths
|
|
46
46
|
from src.utils.cdl import normalize_cdl_payload
|
|
47
|
+
from src.utils import resolve_writes as _resolve_writes
|
|
47
48
|
from src.utils.mcp_stdio import run_fastmcp_stdio
|
|
48
49
|
from src.utils.api_truth import lookup_api_truth, VERIFIED_ON as _API_TRUTH_VERIFIED_ON
|
|
49
50
|
from src.utils import clip_colors as _clip_colors
|
|
@@ -6421,12 +6422,28 @@ def _timeline_create_variant_from_ranges(proj, source_tl, p: Dict[str, Any]) ->
|
|
|
6421
6422
|
new_tl = mp.CreateEmptyTimeline(name)
|
|
6422
6423
|
if not new_tl:
|
|
6423
6424
|
return _err(f"Failed to create timeline: {name}")
|
|
6424
|
-
proj
|
|
6425
|
+
switch_err = _set_current_timeline_checked(proj, new_tl, what="building the variant")
|
|
6426
|
+
if switch_err:
|
|
6427
|
+
return switch_err
|
|
6425
6428
|
if p.get("start_timecode"):
|
|
6429
|
+
# A refused start timecode leaves the variant at the default hour while
|
|
6430
|
+
# every record frame below was computed against the requested one, so
|
|
6431
|
+
# the whole build lands at the wrong absolute time.
|
|
6426
6432
|
try:
|
|
6427
|
-
new_tl.SetStartTimecode(p["start_timecode"])
|
|
6428
|
-
|
|
6429
|
-
|
|
6433
|
+
start_tc_set = bool(new_tl.SetStartTimecode(p["start_timecode"]))
|
|
6434
|
+
start_tc_error = None
|
|
6435
|
+
except Exception as exc:
|
|
6436
|
+
start_tc_set, start_tc_error = False, str(exc)
|
|
6437
|
+
if not start_tc_set:
|
|
6438
|
+
return _err(
|
|
6439
|
+
f"SetStartTimecode({p['start_timecode']!r}) was refused on '{name}'",
|
|
6440
|
+
code="START_TIMECODE_REFUSED", category="api_error", retryable=False,
|
|
6441
|
+
reason=(start_tc_error or
|
|
6442
|
+
"Resolve returned False. The variant would sit at the default "
|
|
6443
|
+
"start timecode while its record frames assume the requested one."),
|
|
6444
|
+
remediation="Check the timecode is valid for the timeline's frame rate, "
|
|
6445
|
+
"or omit start_timecode to build at the default start.",
|
|
6446
|
+
)
|
|
6430
6447
|
for track_type, needed in max_tracks.items():
|
|
6431
6448
|
while int(new_tl.GetTrackCount(track_type) or 0) < needed:
|
|
6432
6449
|
if not new_tl.AddTrack(track_type):
|
|
@@ -6673,7 +6690,16 @@ def _timeline_thumbnail_contact_sheet(proj, tl, p: Dict[str, Any]) -> Dict[str,
|
|
|
6673
6690
|
sampled.append(sample)
|
|
6674
6691
|
continue
|
|
6675
6692
|
try:
|
|
6676
|
-
|
|
6693
|
+
# A refused playhead move means the thumbnail below is the
|
|
6694
|
+
# frame the playhead was ALREADY on -- a sheet of the wrong
|
|
6695
|
+
# frames, labelled with the frames that were asked for.
|
|
6696
|
+
if not tl.SetCurrentTimecode(timecode):
|
|
6697
|
+
sample["error"] = (
|
|
6698
|
+
f"Playhead would not move to {timecode}; no thumbnail was "
|
|
6699
|
+
"sampled here rather than sampling the wrong frame"
|
|
6700
|
+
)
|
|
6701
|
+
sampled.append(sample)
|
|
6702
|
+
continue
|
|
6677
6703
|
thumbnail = tl.GetCurrentClipThumbnailImage()
|
|
6678
6704
|
if not thumbnail:
|
|
6679
6705
|
sample["error"] = (
|
|
@@ -6690,11 +6716,7 @@ def _timeline_thumbnail_contact_sheet(proj, tl, p: Dict[str, Any]) -> Dict[str,
|
|
|
6690
6716
|
sample["error"] = str(exc)
|
|
6691
6717
|
sampled.append(sample)
|
|
6692
6718
|
finally:
|
|
6693
|
-
|
|
6694
|
-
try:
|
|
6695
|
-
tl.SetCurrentTimecode(original_timecode)
|
|
6696
|
-
except Exception:
|
|
6697
|
-
pass
|
|
6719
|
+
_restore_playhead(tl, original_timecode, what="the contact sheet")
|
|
6698
6720
|
sheet_samples = [sample for sample in sampled if sample.get("thumbnail_rgb")]
|
|
6699
6721
|
if not sheet_samples:
|
|
6700
6722
|
return {"success": False, "samples": sampled, "error": "No thumbnails could be sampled"}
|
|
@@ -6982,6 +7004,41 @@ _BINARY_INTERCHANGE_EXTS = {".aaf"}
|
|
|
6982
7004
|
_JSON_INTERCHANGE_EXTS = {".otio"}
|
|
6983
7005
|
|
|
6984
7006
|
|
|
7007
|
+
_FCP7_SEQUENCE_NAME_RE = re.compile(
|
|
7008
|
+
r"(<sequence\b[^>]*>(?:(?!<name>|</sequence>)[\s\S])*?<name>)([\s\S]*?)(</name>)"
|
|
7009
|
+
)
|
|
7010
|
+
|
|
7011
|
+
|
|
7012
|
+
def _rewrite_fcp7_sequence_name(xml_path: str, new_name: str):
|
|
7013
|
+
"""Copy an FCP7 XML with its first <sequence><name> replaced; (path, changed, error).
|
|
7014
|
+
|
|
7015
|
+
Resolve honours the sequence name INSIDE the file, not the timelineName
|
|
7016
|
+
import option: when the internal name matches an existing timeline, the
|
|
7017
|
+
import silently hands back that existing timeline (issue #171). Rewriting
|
|
7018
|
+
the internal name is the only way to make the option mean what it says.
|
|
7019
|
+
Surgical text replacement on a temp copy — the original is never touched,
|
|
7020
|
+
and everything but the one name survives byte-for-byte (an XML re-serialize
|
|
7021
|
+
would drop the xmeml DOCTYPE).
|
|
7022
|
+
"""
|
|
7023
|
+
try:
|
|
7024
|
+
with open(xml_path, encoding="utf-8", errors="replace") as fh:
|
|
7025
|
+
text = fh.read()
|
|
7026
|
+
except OSError as exc:
|
|
7027
|
+
return None, False, f"could not read {xml_path}: {exc}"
|
|
7028
|
+
escaped = (
|
|
7029
|
+
new_name.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
7030
|
+
)
|
|
7031
|
+
text, count = _FCP7_SEQUENCE_NAME_RE.subn(
|
|
7032
|
+
lambda m: m.group(1) + escaped + m.group(3), text, count=1
|
|
7033
|
+
)
|
|
7034
|
+
if not count:
|
|
7035
|
+
return None, False, None
|
|
7036
|
+
fd, out_path = tempfile.mkstemp(prefix="timeline_name_", suffix=".xml")
|
|
7037
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
7038
|
+
fh.write(text)
|
|
7039
|
+
return out_path, True, None
|
|
7040
|
+
|
|
7041
|
+
|
|
6985
7042
|
def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
|
|
6986
7043
|
path = p.get("path")
|
|
6987
7044
|
if not path:
|
|
@@ -7112,6 +7169,22 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
|
|
|
7112
7169
|
) if k in sres
|
|
7113
7170
|
}
|
|
7114
7171
|
|
|
7172
|
+
# Resolve honours the interchange file's internal sequence name over the
|
|
7173
|
+
# timelineName option (issue #171): with a stale internal name that matches
|
|
7174
|
+
# an existing timeline, the import "succeeds" by returning the existing
|
|
7175
|
+
# timeline. For FCP7 XML the name is rewritable in place; for formats we
|
|
7176
|
+
# cannot rewrite, the created_new gate below turns the case into an error.
|
|
7177
|
+
requested_name = options.get("timelineName")
|
|
7178
|
+
sequence_name_rewritten = False
|
|
7179
|
+
if requested_name and not is_binary and not is_json:
|
|
7180
|
+
rewritten_path, sequence_name_rewritten, rewrite_err = _rewrite_fcp7_sequence_name(
|
|
7181
|
+
import_path, str(requested_name)
|
|
7182
|
+
)
|
|
7183
|
+
if rewrite_err:
|
|
7184
|
+
return _err(f"Could not rewrite the sequence name: {rewrite_err}")
|
|
7185
|
+
if sequence_name_rewritten:
|
|
7186
|
+
import_path = rewritten_path
|
|
7187
|
+
|
|
7115
7188
|
if p.get("dry_run"):
|
|
7116
7189
|
out = _ok(path=path, import_path=import_path, options=options, would_import=True)
|
|
7117
7190
|
if sanitize_report is not None:
|
|
@@ -7198,14 +7271,38 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
|
|
|
7198
7271
|
relink_result = _binary_post_import_relink(imported, mp, search_roots)
|
|
7199
7272
|
if relink_result.get("after"):
|
|
7200
7273
|
media = relink_result["after"]
|
|
7274
|
+
created_new = imported_id not in before_ids
|
|
7275
|
+
if requested_name and not created_new:
|
|
7276
|
+
# The caller asked for a NEW timeline by name and got an existing
|
|
7277
|
+
# one back — the interchange file's internal sequence name matched
|
|
7278
|
+
# it and Resolve ignored the option (issue #171). success:true with
|
|
7279
|
+
# created_new:false read as an import; an iterating loop would
|
|
7280
|
+
# operate on the same timeline forever.
|
|
7281
|
+
return _err(
|
|
7282
|
+
f"Import returned the EXISTING timeline {imported.GetName()!r} "
|
|
7283
|
+
f"({imported_id}) instead of creating {requested_name!r}. Resolve "
|
|
7284
|
+
"honours the sequence name inside the file over the timelineName "
|
|
7285
|
+
"option, and this file's internal name matched an existing "
|
|
7286
|
+
"timeline.",
|
|
7287
|
+
category="api_error",
|
|
7288
|
+
remediation=(
|
|
7289
|
+
"Rewrite the sequence name inside the interchange file to the "
|
|
7290
|
+
"new name before importing (for FCP7 XML this wrapper does it "
|
|
7291
|
+
f"automatically; it could not for this file{' — the <sequence><name> pattern was not found' if not sequence_name_rewritten else ''}), "
|
|
7292
|
+
"or delete/rename the existing timeline first."
|
|
7293
|
+
),
|
|
7294
|
+
state={"existing_name": imported.GetName(), "existing_id": imported_id},
|
|
7295
|
+
)
|
|
7201
7296
|
out = _ok(
|
|
7202
7297
|
name=imported.GetName(),
|
|
7203
7298
|
id=imported_id,
|
|
7204
|
-
created_new=
|
|
7299
|
+
created_new=created_new,
|
|
7205
7300
|
timeline_count=proj.GetTimelineCount(),
|
|
7206
7301
|
api_returned_object=api_returned_object,
|
|
7207
7302
|
media=media,
|
|
7208
7303
|
)
|
|
7304
|
+
if sequence_name_rewritten:
|
|
7305
|
+
out["sequence_name_rewritten"] = True
|
|
7209
7306
|
if sanitize_report is not None:
|
|
7210
7307
|
out["sanitize"] = sanitize_report
|
|
7211
7308
|
if relink_result is not None:
|
|
@@ -8781,15 +8878,28 @@ def _setup_multicam_timeline(proj, mp, p: Dict[str, Any]):
|
|
|
8781
8878
|
new_tl = mp.CreateEmptyTimeline(plan["name"])
|
|
8782
8879
|
if not new_tl:
|
|
8783
8880
|
return _err(f"Failed to create multicam setup timeline: {plan['name']}")
|
|
8784
|
-
|
|
8785
|
-
|
|
8786
|
-
|
|
8787
|
-
pass
|
|
8881
|
+
switch_err = _set_current_timeline_checked(proj, new_tl, what="stacking the angles")
|
|
8882
|
+
if switch_err:
|
|
8883
|
+
return switch_err
|
|
8788
8884
|
if plan.get("start_timecode"):
|
|
8885
|
+
# Same as the variant builder: the angle stack's record frames assume
|
|
8886
|
+
# this start, so a refusal puts every angle at the wrong absolute time.
|
|
8789
8887
|
try:
|
|
8790
|
-
new_tl.SetStartTimecode(plan["start_timecode"])
|
|
8791
|
-
|
|
8792
|
-
|
|
8888
|
+
start_tc_set = bool(new_tl.SetStartTimecode(plan["start_timecode"]))
|
|
8889
|
+
start_tc_error = None
|
|
8890
|
+
except Exception as exc:
|
|
8891
|
+
start_tc_set, start_tc_error = False, str(exc)
|
|
8892
|
+
if not start_tc_set:
|
|
8893
|
+
return _err(
|
|
8894
|
+
f"SetStartTimecode({plan['start_timecode']!r}) was refused on "
|
|
8895
|
+
f"'{plan['name']}'",
|
|
8896
|
+
code="START_TIMECODE_REFUSED", category="api_error", retryable=False,
|
|
8897
|
+
reason=(start_tc_error or
|
|
8898
|
+
"Resolve returned False. The angle stack would land at the "
|
|
8899
|
+
"wrong absolute time."),
|
|
8900
|
+
remediation="Check the timecode is valid for the timeline's frame rate, "
|
|
8901
|
+
"or omit start_timecode.",
|
|
8902
|
+
)
|
|
8793
8903
|
|
|
8794
8904
|
audio_type = str(p.get("audio_type", p.get("audioType", "stereo")) or "stereo")
|
|
8795
8905
|
video_tracks = _ensure_timeline_tracks(new_tl, "video", plan.get("max_video_track", 0))
|
|
@@ -10502,7 +10612,18 @@ def _apply_sync_event_markers(proj, detection: Dict[str, Any], p: Dict[str, Any]
|
|
|
10502
10612
|
skipped.append({"clip_id": clip_id, "frame": marker["frame"], "reason": "Marker already exists", "custom_data": custom_data})
|
|
10503
10613
|
continue
|
|
10504
10614
|
if existing and replace_existing and _has_method(clip, "DeleteMarkerByCustomData"):
|
|
10505
|
-
|
|
10615
|
+
# A discarded False leaves the old marker in place, and AddMarker
|
|
10616
|
+
# then refuses the occupied frame -- reported as "add failed"
|
|
10617
|
+
# when the real failure was the delete.
|
|
10618
|
+
if not clip.DeleteMarkerByCustomData(custom_data):
|
|
10619
|
+
failed.append({
|
|
10620
|
+
"clip_id": clip_id, "frame": marker["frame"],
|
|
10621
|
+
"custom_data": custom_data,
|
|
10622
|
+
"reason": "replace_existing was requested but "
|
|
10623
|
+
"DeleteMarkerByCustomData refused; the existing "
|
|
10624
|
+
"marker is still there and nothing was replaced",
|
|
10625
|
+
})
|
|
10626
|
+
continue
|
|
10506
10627
|
result = _add_marker(clip, marker)
|
|
10507
10628
|
if result.get("success"):
|
|
10508
10629
|
added.append({
|
|
@@ -10899,7 +11020,20 @@ def _apply_media_analysis_clip_markers(clip, markers: List[Dict[str, Any]], p: D
|
|
|
10899
11020
|
skipped.append({"frame": marker.get("frame"), "name": marker.get("name"), "reason": "Marker already exists", "custom_data": custom_data})
|
|
10900
11021
|
continue
|
|
10901
11022
|
if existing and replace_existing and _has_method(clip, "DeleteMarkerByCustomData"):
|
|
10902
|
-
|
|
11023
|
+
# A discarded False leaves the old marker in place, and AddMarker
|
|
11024
|
+
# then refuses the occupied frame -- reported as "add failed"
|
|
11025
|
+
# when the real failure was the delete.
|
|
11026
|
+
if not clip.DeleteMarkerByCustomData(custom_data):
|
|
11027
|
+
failed.append({
|
|
11028
|
+
"marker": marker,
|
|
11029
|
+
"result": {
|
|
11030
|
+
"success": False,
|
|
11031
|
+
"reason": "replace_existing was requested but "
|
|
11032
|
+
"DeleteMarkerByCustomData refused; the existing "
|
|
11033
|
+
"marker is still there and nothing was replaced",
|
|
11034
|
+
},
|
|
11035
|
+
})
|
|
11036
|
+
continue
|
|
10903
11037
|
result = _add_marker(clip, marker)
|
|
10904
11038
|
if result.get("success"):
|
|
10905
11039
|
added.append({"frame": result.get("frame"), "name": marker.get("name"), "custom_data": custom_data})
|
|
@@ -12506,11 +12640,24 @@ def _set_current_folder_temporarily(mp, target_path: Optional[str]):
|
|
|
12506
12640
|
|
|
12507
12641
|
|
|
12508
12642
|
def _restore_current_folder(mp, previous):
|
|
12509
|
-
|
|
12510
|
-
|
|
12511
|
-
|
|
12512
|
-
|
|
12513
|
-
|
|
12643
|
+
"""Put the Media Pool's current folder back. Logged, never raised.
|
|
12644
|
+
|
|
12645
|
+
Fire-and-forget by design: this runs after the real work, the current
|
|
12646
|
+
folder is UI state that nothing downstream reads, and turning a failed
|
|
12647
|
+
teardown into a failed operation would report a successful import as a
|
|
12648
|
+
failure. Logged rather than swallowed, because a bin left open somewhere
|
|
12649
|
+
unexpected is otherwise unexplained.
|
|
12650
|
+
"""
|
|
12651
|
+
if not previous:
|
|
12652
|
+
return
|
|
12653
|
+
try:
|
|
12654
|
+
restored = mp.SetCurrentFolder(previous)
|
|
12655
|
+
except Exception as exc:
|
|
12656
|
+
logger.debug("could not restore the current Media Pool folder: %s", exc)
|
|
12657
|
+
return
|
|
12658
|
+
if not restored:
|
|
12659
|
+
logger.debug("could not restore the current Media Pool folder: "
|
|
12660
|
+
"SetCurrentFolder returned %r", restored)
|
|
12514
12661
|
|
|
12515
12662
|
|
|
12516
12663
|
def _ensure_folder_path(mp, path: str):
|
|
@@ -13812,11 +13959,7 @@ def _playhead_frame_preview(tl, p: Dict[str, Any]):
|
|
|
13812
13959
|
width, height, raw = _box_downscale_rgb(width, height, raw, int(max_width))
|
|
13813
13960
|
return Image(data=_rgb_to_png_bytes(width, height, raw), format="png")
|
|
13814
13961
|
finally:
|
|
13815
|
-
|
|
13816
|
-
try:
|
|
13817
|
-
tl.SetCurrentTimecode(original_tc)
|
|
13818
|
-
except Exception:
|
|
13819
|
-
pass
|
|
13962
|
+
_restore_playhead(tl, original_tc, what="the thumbnail capture")
|
|
13820
13963
|
|
|
13821
13964
|
|
|
13822
13965
|
def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
|
|
@@ -13929,7 +14072,11 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
|
|
|
13929
14072
|
if not job:
|
|
13930
14073
|
return _err("AddRenderJob returned nothing", code="RENDER_JOB_FAILED", category="api_error")
|
|
13931
14074
|
before = set(os.listdir(folder))
|
|
13932
|
-
|
|
14075
|
+
# Positional on purpose: the free-edition bridge proxies method calls
|
|
14076
|
+
# positionally, and a keyword argument dies inside _BoundMethod with
|
|
14077
|
+
# "unexpected keyword argument 'isInteractiveMode'" before reaching
|
|
14078
|
+
# Resolve (reported on 21.0.4.5 free/Windows, PR #165).
|
|
14079
|
+
if not proj.StartRendering([job], False):
|
|
13933
14080
|
return _err("StartRendering refused the job", code="RENDER_START_FAILED", category="api_error")
|
|
13934
14081
|
waited = 0.0
|
|
13935
14082
|
while proj.IsRenderingInProgress() and waited < 120:
|
|
@@ -13967,11 +14114,22 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
|
|
|
13967
14114
|
except Exception:
|
|
13968
14115
|
pass
|
|
13969
14116
|
if original_fc:
|
|
14117
|
+
# A failed restore leaves the Deliver page on the capture's format
|
|
14118
|
+
# and codec, which the user's next render would silently inherit.
|
|
14119
|
+
# This runs in a teardown path, so it is logged rather than raised.
|
|
13970
14120
|
try:
|
|
13971
|
-
proj.SetCurrentRenderFormatAndCodec(
|
|
14121
|
+
restored_fc = proj.SetCurrentRenderFormatAndCodec(
|
|
13972
14122
|
original_fc.get("format"), original_fc.get("codec"))
|
|
13973
|
-
except Exception:
|
|
13974
|
-
|
|
14123
|
+
except Exception as exc:
|
|
14124
|
+
restored_fc, restore_exc = False, exc
|
|
14125
|
+
else:
|
|
14126
|
+
restore_exc = None
|
|
14127
|
+
if not restored_fc:
|
|
14128
|
+
logger.warning(
|
|
14129
|
+
"frame capture could not restore the render format/codec to %s/%s: %s",
|
|
14130
|
+
original_fc.get("format"), original_fc.get("codec"),
|
|
14131
|
+
restore_exc or "SetCurrentRenderFormatAndCodec returned False",
|
|
14132
|
+
)
|
|
13975
14133
|
# Best-effort, not a restore: without GetRenderSettings there is nothing
|
|
13976
14134
|
# to restore FROM, so put the mark range back to the whole timeline
|
|
13977
14135
|
# rather than leaving it pinned to the captured frame.
|
|
@@ -13995,11 +14153,7 @@ def _playhead_frame_render(proj, tl, p: Dict[str, Any]):
|
|
|
13995
14153
|
os.rmdir(folder)
|
|
13996
14154
|
except OSError:
|
|
13997
14155
|
pass
|
|
13998
|
-
|
|
13999
|
-
try:
|
|
14000
|
-
tl.SetCurrentTimecode(original_tc)
|
|
14001
|
-
except Exception:
|
|
14002
|
-
pass
|
|
14156
|
+
_restore_playhead(tl, original_tc, what="the render capture")
|
|
14003
14157
|
if original_page and original_page != "deliver":
|
|
14004
14158
|
try:
|
|
14005
14159
|
_open_page_serialized(resolve, original_page)
|
|
@@ -14110,11 +14264,7 @@ def _playhead_frame_full(proj, tl, p: Dict[str, Any]):
|
|
|
14110
14264
|
album.DeleteStills([still])
|
|
14111
14265
|
except Exception:
|
|
14112
14266
|
pass
|
|
14113
|
-
|
|
14114
|
-
try:
|
|
14115
|
-
tl.SetCurrentTimecode(original_tc)
|
|
14116
|
-
except Exception:
|
|
14117
|
-
pass
|
|
14267
|
+
_restore_playhead(tl, original_tc, what="the still capture")
|
|
14118
14268
|
try:
|
|
14119
14269
|
for name in os.listdir(folder):
|
|
14120
14270
|
if name.startswith(prefix):
|
|
@@ -14188,10 +14338,71 @@ def _playhead_frame_capture(p: Dict[str, Any]):
|
|
|
14188
14338
|
return _playhead_frame_render(proj, tl, p)
|
|
14189
14339
|
finally:
|
|
14190
14340
|
if original_tl is not None:
|
|
14191
|
-
|
|
14192
|
-
|
|
14193
|
-
|
|
14194
|
-
|
|
14341
|
+
# Best-effort by necessity: this runs in a finally, so raising or
|
|
14342
|
+
# returning here would replace the caller's real result (or its real
|
|
14343
|
+
# exception) with a restore failure. It is still not silent -- a
|
|
14344
|
+
# failed restore leaves the editor on a different timeline, which is
|
|
14345
|
+
# visible immediately, and it is logged.
|
|
14346
|
+
restore_err = _set_current_timeline_checked(
|
|
14347
|
+
proj, original_tl, what="restoring the timeline after the capture")
|
|
14348
|
+
if restore_err:
|
|
14349
|
+
logger.warning(
|
|
14350
|
+
"frame capture could not restore the current timeline: %s",
|
|
14351
|
+
restore_err["error"]["message"],
|
|
14352
|
+
)
|
|
14353
|
+
|
|
14354
|
+
|
|
14355
|
+
def _restore_playhead(tl, timecode, *, what: str) -> None:
|
|
14356
|
+
"""Put the playhead back after a capture. Logged, never raised.
|
|
14357
|
+
|
|
14358
|
+
Deliberately fire-and-forget on the CALLER's behalf: every use of this runs
|
|
14359
|
+
in a `finally`, so raising or returning an error would replace the caller's
|
|
14360
|
+
real result -- or its real exception -- with a teardown failure. What it is
|
|
14361
|
+
not is silent. A refused restore leaves the editor's playhead somewhere
|
|
14362
|
+
else, and a support log that says so is the difference between a two-minute
|
|
14363
|
+
diagnosis and an afternoon.
|
|
14364
|
+
"""
|
|
14365
|
+
if not timecode:
|
|
14366
|
+
return
|
|
14367
|
+
try:
|
|
14368
|
+
moved = tl.SetCurrentTimecode(timecode)
|
|
14369
|
+
except Exception as exc:
|
|
14370
|
+
logger.warning("could not restore the playhead to %s after %s: %s",
|
|
14371
|
+
timecode, what, exc)
|
|
14372
|
+
return
|
|
14373
|
+
if not moved:
|
|
14374
|
+
logger.warning("could not restore the playhead to %s after %s: "
|
|
14375
|
+
"SetCurrentTimecode returned %r", timecode, what, moved)
|
|
14376
|
+
|
|
14377
|
+
|
|
14378
|
+
def _set_current_timeline_checked(proj, tl, *, what: str):
|
|
14379
|
+
"""Switch the project's current timeline and PROVE it switched.
|
|
14380
|
+
|
|
14381
|
+
Returns None on success, or an error envelope naming what did not happen.
|
|
14382
|
+
|
|
14383
|
+
This is not UI polish. `AppendToTimeline`, `GetCurrentTimeline` and every
|
|
14384
|
+
other "current timeline" call writes to whatever the PROJECT believes is
|
|
14385
|
+
current, not to the handle in scope. A discarded False here sends the
|
|
14386
|
+
assembly to the timeline the editor was looking at while the tool reports
|
|
14387
|
+
the new timeline's name and id, which is the worst possible shape of
|
|
14388
|
+
failure: the work happened, on the wrong timeline, under a success.
|
|
14389
|
+
|
|
14390
|
+
The bare bool is not trusted on its own. A Resolve attached to no database
|
|
14391
|
+
answers version queries normally while every project mutation returns False
|
|
14392
|
+
or None, and a True that did not take has been observed there, so the
|
|
14393
|
+
switch is confirmed by reading the current timeline back.
|
|
14394
|
+
"""
|
|
14395
|
+
ok, detail = _resolve_writes.set_current_timeline(proj, tl)
|
|
14396
|
+
if ok:
|
|
14397
|
+
return None
|
|
14398
|
+
return _err(
|
|
14399
|
+
_resolve_writes.describe_switch_failure(detail, what),
|
|
14400
|
+
code="TIMELINE_SWITCH_FAILED", category="api_error", retryable=False,
|
|
14401
|
+
reason="A discarded False here writes the work to the wrong timeline.",
|
|
14402
|
+
remediation="Quit and restart Resolve if it came up with no database attached "
|
|
14403
|
+
"(resolve_control runtime_mode reports that), then retry.",
|
|
14404
|
+
state=detail,
|
|
14405
|
+
)
|
|
14195
14406
|
|
|
14196
14407
|
|
|
14197
14408
|
def _unknown(action, valid):
|
|
@@ -16271,14 +16482,31 @@ def _resolve_restore_state(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
16271
16482
|
for i in range(1, count + 1):
|
|
16272
16483
|
tl = proj.GetTimelineByIndex(i)
|
|
16273
16484
|
if tl and tl.GetUniqueId() == state["current_timeline_id"]:
|
|
16274
|
-
|
|
16485
|
+
# The one function whose whole job is putting things back
|
|
16486
|
+
# must not claim it did. restored[] is written only on a
|
|
16487
|
+
# verified switch; a failure is reported, not swallowed.
|
|
16488
|
+
switch_err = _set_current_timeline_checked(
|
|
16489
|
+
proj, tl, what="restoring the saved state")
|
|
16490
|
+
if switch_err:
|
|
16491
|
+
restored["current_timeline_error"] = switch_err["error"]["message"]
|
|
16492
|
+
break
|
|
16275
16493
|
restored["current_timeline_id"] = state["current_timeline_id"]
|
|
16276
16494
|
if state.get("current_timecode"):
|
|
16495
|
+
# restored[] is a claim about what was put back. Writing
|
|
16496
|
+
# it before checking made restore_state report a playhead
|
|
16497
|
+
# position it had not reached.
|
|
16277
16498
|
try:
|
|
16278
|
-
tl.SetCurrentTimecode(state["current_timecode"])
|
|
16499
|
+
moved = bool(tl.SetCurrentTimecode(state["current_timecode"]))
|
|
16500
|
+
except Exception as exc:
|
|
16501
|
+
moved, restored["current_timecode_error"] = False, str(exc)
|
|
16502
|
+
if moved:
|
|
16279
16503
|
restored["current_timecode"] = state["current_timecode"]
|
|
16280
|
-
|
|
16281
|
-
|
|
16504
|
+
else:
|
|
16505
|
+
restored.setdefault(
|
|
16506
|
+
"current_timecode_error",
|
|
16507
|
+
f"SetCurrentTimecode({state['current_timecode']!r}) "
|
|
16508
|
+
"returned False; the playhead is elsewhere",
|
|
16509
|
+
)
|
|
16282
16510
|
break
|
|
16283
16511
|
except Exception as exc:
|
|
16284
16512
|
restored["timeline_error"] = str(exc)
|
|
@@ -16292,9 +16520,20 @@ def _resolve_restore_state(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
16292
16520
|
for cid in state["selected_clip_ids"]:
|
|
16293
16521
|
found, parent = _find_clip_with_parent(root, cid)
|
|
16294
16522
|
if found and parent is not None:
|
|
16295
|
-
|
|
16296
|
-
|
|
16297
|
-
|
|
16523
|
+
# Both report with a bare bool. Claiming the selection
|
|
16524
|
+
# was restored when SetSelectedClip refused is the same
|
|
16525
|
+
# lie as the timeline and timecode above.
|
|
16526
|
+
folder_ok = bool(mp.SetCurrentFolder(parent))
|
|
16527
|
+
selected_ok = bool(mp.SetSelectedClip(found))
|
|
16528
|
+
if selected_ok:
|
|
16529
|
+
restored["selected_clip_id"] = cid
|
|
16530
|
+
else:
|
|
16531
|
+
restored["selection_error"] = (
|
|
16532
|
+
f"SetSelectedClip({cid!r}) returned False"
|
|
16533
|
+
+ ("" if folder_ok else
|
|
16534
|
+
" (SetCurrentFolder also refused, so the bin was "
|
|
16535
|
+
"never opened)")
|
|
16536
|
+
)
|
|
16298
16537
|
break # SetSelectedClip is singular; pick the first
|
|
16299
16538
|
except Exception as exc:
|
|
16300
16539
|
restored["selection_error"] = str(exc)
|
|
@@ -17155,10 +17394,16 @@ class _SpecLiveExecutor:
|
|
|
17155
17394
|
if tl is None:
|
|
17156
17395
|
return False
|
|
17157
17396
|
if fps is not None:
|
|
17397
|
+
# A refused rate is the silent wrong-fps timeline: everything built
|
|
17398
|
+
# on it is laid at a rate nobody asked for and the durations read
|
|
17399
|
+
# back wrong. Resolve only accepts this on an EMPTY project, so the
|
|
17400
|
+
# refusal is common and must not be reported as success.
|
|
17158
17401
|
try:
|
|
17159
|
-
tl.SetSetting("timelineFrameRate", str(fps))
|
|
17402
|
+
rate_set = bool(tl.SetSetting("timelineFrameRate", str(fps)))
|
|
17160
17403
|
except Exception:
|
|
17161
|
-
|
|
17404
|
+
rate_set = False
|
|
17405
|
+
if not rate_set:
|
|
17406
|
+
return False
|
|
17162
17407
|
return True
|
|
17163
17408
|
|
|
17164
17409
|
def set_timeline_setting(self, tl_name: str, key: str, value: Any) -> bool:
|
|
@@ -18580,11 +18825,16 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
18580
18825
|
delete_all_jobs() -> {success}
|
|
18581
18826
|
list_jobs() -> {jobs}
|
|
18582
18827
|
get_job_status(job_id) -> {status}
|
|
18583
|
-
verify_output(job_id) -> {status, output_path, output_exists, output_size_bytes, output_probe, expected_duration_seconds, duration_ratio, warnings, verified}
|
|
18828
|
+
verify_output(job_id, expected_frames?, expected_duration_seconds?) -> {status, output_path, output_exists, output_size_bytes, output_probe, mark_range_frames, timeline_item_extent_frames, expected_duration_seconds, duration_ratio, warnings, verified}
|
|
18584
18829
|
JobStatus reports Complete even when the render produced a near-empty
|
|
18585
|
-
stub (content before the timeline start, issue #164)
|
|
18586
|
-
|
|
18587
|
-
the job
|
|
18830
|
+
stub (content before the timeline start, issue #164), and Resolve
|
|
18831
|
+
rewrites the job's own MarkIn/MarkOut down to the collapsed extent, so
|
|
18832
|
+
the job metadata lies too. This checks the actual output file, and
|
|
18833
|
+
cross-checks the job's timeline items (which read back their real
|
|
18834
|
+
positions): items before the timeline start and a mark range far
|
|
18835
|
+
smaller than the item extent both warn. Pass expected_frames or
|
|
18836
|
+
expected_duration_seconds when you know what the render should hold.
|
|
18837
|
+
Verify BEFORE deleting the job; deleted jobs carry no TargetDir.
|
|
18588
18838
|
start(job_ids?, interactive?) -> {success}
|
|
18589
18839
|
stop() -> {success}
|
|
18590
18840
|
is_rendering() -> {rendering}
|
|
@@ -18675,6 +18925,71 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
18675
18925
|
else:
|
|
18676
18926
|
warnings.append("Job carries no TargetDir/OutputFilename to verify against.")
|
|
18677
18927
|
result["output_path"] = output_path
|
|
18928
|
+
# The job's own MarkIn/MarkOut cannot be the only truth source: when
|
|
18929
|
+
# timeline content sits before the timeline's start frame (issue #164)
|
|
18930
|
+
# Resolve rewrites the job's mark range down to the collapsed extent,
|
|
18931
|
+
# so the job metadata lies right along with JobStatus (measured live on
|
|
18932
|
+
# Studio 19.1.3.7: 96 frames of content before start -> job range of 1
|
|
18933
|
+
# frame, Complete at 100%, a 1-frame black stub). Cross-check the
|
|
18934
|
+
# job's timeline items, which DO read back their real positions, and
|
|
18935
|
+
# let the caller override expected duration outright.
|
|
18936
|
+
try:
|
|
18937
|
+
fps = float(str(job.get("FrameRate")))
|
|
18938
|
+
except (TypeError, ValueError):
|
|
18939
|
+
fps = None
|
|
18940
|
+
try:
|
|
18941
|
+
mark_frames = int(job.get("MarkOut")) - int(job.get("MarkIn")) + 1
|
|
18942
|
+
except (TypeError, ValueError):
|
|
18943
|
+
mark_frames = None
|
|
18944
|
+
result["mark_range_frames"] = mark_frames
|
|
18945
|
+
tl_for_job = None
|
|
18946
|
+
try:
|
|
18947
|
+
for i in range(1, int(proj.GetTimelineCount() or 0) + 1):
|
|
18948
|
+
cand = proj.GetTimelineByIndex(i)
|
|
18949
|
+
if cand and cand.GetName() == job.get("TimelineName"):
|
|
18950
|
+
tl_for_job = cand
|
|
18951
|
+
break
|
|
18952
|
+
except Exception:
|
|
18953
|
+
tl_for_job = None
|
|
18954
|
+
if tl_for_job is not None:
|
|
18955
|
+
try:
|
|
18956
|
+
tl_start = int(tl_for_job.GetStartFrame())
|
|
18957
|
+
extent_lo, extent_hi, before_start = None, None, 0
|
|
18958
|
+
for ti in range(1, int(tl_for_job.GetTrackCount("video") or 0) + 1):
|
|
18959
|
+
for it in tl_for_job.GetItemListInTrack("video", ti) or []:
|
|
18960
|
+
it_start, it_end = int(it.GetStart()), int(it.GetEnd())
|
|
18961
|
+
extent_lo = it_start if extent_lo is None else min(extent_lo, it_start)
|
|
18962
|
+
extent_hi = it_end if extent_hi is None else max(extent_hi, it_end)
|
|
18963
|
+
if it_start < tl_start:
|
|
18964
|
+
before_start += 1
|
|
18965
|
+
if before_start:
|
|
18966
|
+
warnings.append(
|
|
18967
|
+
f"{before_start} video item(s) on timeline "
|
|
18968
|
+
f"{job.get('TimelineName')!r} start before the timeline's "
|
|
18969
|
+
f"start frame {tl_start} — the render engine never visits "
|
|
18970
|
+
"that content (recordFrame counts from absolute frame "
|
|
18971
|
+
"zero, issue #164). The items read back as placed; the "
|
|
18972
|
+
"render is a stub."
|
|
18973
|
+
)
|
|
18974
|
+
if (extent_lo is not None and mark_frames is not None
|
|
18975
|
+
and mark_frames < (extent_hi - extent_lo) * 0.5):
|
|
18976
|
+
warnings.append(
|
|
18977
|
+
f"The job's mark range ({mark_frames} frame(s)) is under "
|
|
18978
|
+
f"half the timeline's item extent "
|
|
18979
|
+
f"({extent_hi - extent_lo} frames) — Resolve rewrote the "
|
|
18980
|
+
"range down to a collapsed extent, which is how a stub "
|
|
18981
|
+
"render reports a clean duration ratio."
|
|
18982
|
+
)
|
|
18983
|
+
result["timeline_item_extent_frames"] = (
|
|
18984
|
+
None if extent_lo is None else extent_hi - extent_lo
|
|
18985
|
+
)
|
|
18986
|
+
except Exception:
|
|
18987
|
+
pass
|
|
18988
|
+
else:
|
|
18989
|
+
warnings.append(
|
|
18990
|
+
f"Timeline {job.get('TimelineName')!r} was not found in the "
|
|
18991
|
+
"project, so item-extent cross-checks were skipped."
|
|
18992
|
+
)
|
|
18678
18993
|
if output_path:
|
|
18679
18994
|
exists = os.path.exists(output_path)
|
|
18680
18995
|
result["output_exists"] = exists
|
|
@@ -18684,13 +18999,21 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
18684
18999
|
if probe:
|
|
18685
19000
|
result["output_probe"] = probe
|
|
18686
19001
|
duration = probe.get("duration_seconds")
|
|
18687
|
-
# Expected duration
|
|
18688
|
-
|
|
18689
|
-
|
|
18690
|
-
|
|
18691
|
-
|
|
18692
|
-
|
|
18693
|
-
|
|
19002
|
+
# Expected duration: caller-supplied truth wins; the job's
|
|
19003
|
+
# mark range is the fallback (see collapse caveat above).
|
|
19004
|
+
expected = None
|
|
19005
|
+
if p.get("expected_duration_seconds") is not None:
|
|
19006
|
+
try:
|
|
19007
|
+
expected = float(p["expected_duration_seconds"])
|
|
19008
|
+
except (TypeError, ValueError):
|
|
19009
|
+
expected = None
|
|
19010
|
+
elif p.get("expected_frames") is not None and fps:
|
|
19011
|
+
try:
|
|
19012
|
+
expected = int(p["expected_frames"]) / fps
|
|
19013
|
+
except (TypeError, ValueError):
|
|
19014
|
+
expected = None
|
|
19015
|
+
elif mark_frames is not None and fps:
|
|
19016
|
+
expected = mark_frames / fps
|
|
18694
19017
|
result["expected_duration_seconds"] = expected
|
|
18695
19018
|
if duration is not None and expected and expected > 0:
|
|
18696
19019
|
result["duration_ratio"] = round(duration / expected, 4)
|
|
@@ -19087,10 +19410,10 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
19087
19410
|
tl = mp.CreateEmptyTimeline(create_name)
|
|
19088
19411
|
if not tl:
|
|
19089
19412
|
return _err("Failed to create timeline from clip_infos")
|
|
19090
|
-
|
|
19091
|
-
proj
|
|
19092
|
-
|
|
19093
|
-
|
|
19413
|
+
switch_err = _set_current_timeline_checked(
|
|
19414
|
+
proj, tl, what="appending the clip_infos")
|
|
19415
|
+
if switch_err:
|
|
19416
|
+
return switch_err
|
|
19094
19417
|
timeline_start = _timeline_start_frame(tl)
|
|
19095
19418
|
built = []
|
|
19096
19419
|
for i, ci in enumerate(raw):
|
|
@@ -19703,11 +20026,19 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
19703
20026
|
mark_set = {"applied": False, "error": str(exc)}
|
|
19704
20027
|
|
|
19705
20028
|
# Navigate the bin to the clip's folder so the clip is visible to select.
|
|
20029
|
+
# Not fatal on its own (SetSelectedClip may still work), but a discarded
|
|
20030
|
+
# False meant the caller could not tell "the bin did not move" from
|
|
20031
|
+
# "the bin moved and the clip is right there", which is the whole point
|
|
20032
|
+
# of the action.
|
|
20033
|
+
folder_ok: Optional[bool] = None
|
|
20034
|
+
folder_error: Optional[str] = None
|
|
19706
20035
|
if parent is not None:
|
|
19707
20036
|
try:
|
|
19708
|
-
mp.SetCurrentFolder(parent)
|
|
19709
|
-
except Exception:
|
|
19710
|
-
|
|
20037
|
+
folder_ok = bool(mp.SetCurrentFolder(parent))
|
|
20038
|
+
except Exception as exc:
|
|
20039
|
+
folder_ok, folder_error = False, str(exc)
|
|
20040
|
+
if not folder_ok:
|
|
20041
|
+
folder_error = folder_error or "SetCurrentFolder returned False"
|
|
19711
20042
|
select_ok = bool(mp.SetSelectedClip(clip))
|
|
19712
20043
|
|
|
19713
20044
|
# Bring Resolve to the foreground so the editor doesn't have to
|
|
@@ -19732,6 +20063,8 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
19732
20063
|
"clip_id": clip.GetUniqueId(),
|
|
19733
20064
|
"clip_name": clip.GetName(),
|
|
19734
20065
|
"folder_name": parent.GetName() if parent else None,
|
|
20066
|
+
"bin_opened": folder_ok,
|
|
20067
|
+
"bin_error": folder_error,
|
|
19735
20068
|
"page": page,
|
|
19736
20069
|
"mark_set": mark_set,
|
|
19737
20070
|
"focus": focus_result,
|
|
@@ -22573,10 +22906,10 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
22573
22906
|
tl = mp.CreateEmptyTimeline(name)
|
|
22574
22907
|
if not tl:
|
|
22575
22908
|
return _err("Failed to create selects timeline")
|
|
22576
|
-
|
|
22577
|
-
proj
|
|
22578
|
-
|
|
22579
|
-
|
|
22909
|
+
switch_err = _set_current_timeline_checked(
|
|
22910
|
+
proj, tl, what="assembling the selects")
|
|
22911
|
+
if switch_err:
|
|
22912
|
+
return switch_err
|
|
22580
22913
|
timeline_start = _timeline_start_frame(tl)
|
|
22581
22914
|
built = []
|
|
22582
22915
|
build_errors = []
|
|
@@ -22960,10 +23293,14 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
22960
23293
|
tl, _tl_index = _find_timeline_by_name(proj, plan.get("timeline_name"))
|
|
22961
23294
|
if not tl:
|
|
22962
23295
|
return _err(f"Timeline '{plan.get('timeline_name')}' not found")
|
|
22963
|
-
|
|
22964
|
-
|
|
22965
|
-
|
|
22966
|
-
|
|
23296
|
+
# The lift is handle-based and hits the right timeline; the append that
|
|
23297
|
+
# puts the replacement back is current-timeline-based. A failed switch
|
|
23298
|
+
# here punches a hole in the target and drops the alternate on whatever
|
|
23299
|
+
# the editor had open, under a reported success.
|
|
23300
|
+
switch_err = _set_current_timeline_checked(
|
|
23301
|
+
proj, tl, what="swapping the item")
|
|
23302
|
+
if switch_err:
|
|
23303
|
+
return switch_err
|
|
22967
23304
|
before = _edit_engine_capture(tl)
|
|
22968
23305
|
before["track_counts"] = _edit_engine_track_counts(tl)
|
|
22969
23306
|
slot_start = int(item_block.get("timeline_start_frame"))
|
|
@@ -26513,9 +26850,17 @@ def gallery_stills(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
|
|
|
26513
26850
|
used_format = try_fmt
|
|
26514
26851
|
break
|
|
26515
26852
|
time.sleep(0.3)
|
|
26516
|
-
# Clean up still from gallery
|
|
26853
|
+
# Clean up still from gallery. Cleanup, not the operation: the export
|
|
26854
|
+
# above is what the caller asked for and it has already been verified by
|
|
26855
|
+
# the file listing below. A leftover still is visible in the Gallery
|
|
26856
|
+
# rather than silent, so this is logged, not raised.
|
|
26517
26857
|
if delete_after:
|
|
26518
|
-
|
|
26858
|
+
try:
|
|
26859
|
+
if not album.DeleteStills([still]):
|
|
26860
|
+
logger.warning("DeleteStills returned False; a still is left in "
|
|
26861
|
+
"the Gallery album after the export")
|
|
26862
|
+
except Exception as exc:
|
|
26863
|
+
logger.warning("DeleteStills raised after the export: %s", exc)
|
|
26519
26864
|
if not export_ok:
|
|
26520
26865
|
_discard_still_staging(staging)
|
|
26521
26866
|
return _err("ExportStills failed — ensure the Gallery panel is open on the Color page (Workspace > Gallery)")
|
|
@@ -26668,12 +27013,20 @@ def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
26668
27013
|
# not the per-user dir that dctl install writes to. Relocate and retry.
|
|
26669
27014
|
relocated = _ensure_lut_in_master(lut_path)
|
|
26670
27015
|
if relocated:
|
|
27016
|
+
# The retry below only works if Resolve re-scanned the master
|
|
27017
|
+
# LUT dir. A discarded False here turns the retry into a second
|
|
27018
|
+
# identical failure with no explanation.
|
|
27019
|
+
refreshed = None
|
|
26671
27020
|
try:
|
|
26672
27021
|
_, proj, _lut_err = _check()
|
|
26673
27022
|
if proj:
|
|
26674
|
-
proj.RefreshLUTList()
|
|
26675
|
-
except Exception:
|
|
26676
|
-
|
|
27023
|
+
refreshed = bool(proj.RefreshLUTList())
|
|
27024
|
+
except Exception as exc:
|
|
27025
|
+
logger.warning("RefreshLUTList raised before the SetLUT retry: %s", exc)
|
|
27026
|
+
if refreshed is False:
|
|
27027
|
+
logger.warning(
|
|
27028
|
+
"RefreshLUTList returned False; the relocated LUT may not be "
|
|
27029
|
+
"visible to SetLUT yet")
|
|
26677
27030
|
ok = bool(g.SetLUT(node_index, relocated))
|
|
26678
27031
|
if ok:
|
|
26679
27032
|
return {"success": True, "resolved_lut": relocated,
|
|
@@ -26941,6 +27294,19 @@ def _fusion_group_settings_load(comp, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
26941
27294
|
group.SaveSettings(backup_path)
|
|
26942
27295
|
except Exception as exc:
|
|
26943
27296
|
return _err(f"backup SaveSettings failed: {exc}")
|
|
27297
|
+
# SaveSettings reports through the Lua bridge and its return is not
|
|
27298
|
+
# reliable, but the file it was asked to write is. This backup is the only
|
|
27299
|
+
# way back from the LoadSettings below, so loading without it would be an
|
|
27300
|
+
# irreversible replacement of the group's graph presented as a reversible one.
|
|
27301
|
+
if not os.path.isfile(backup_path):
|
|
27302
|
+
return _err(
|
|
27303
|
+
f"backup SaveSettings did not create a file at {backup_path!r}",
|
|
27304
|
+
code="FUSION_BACKUP_NOT_WRITTEN", category="api_error", retryable=False,
|
|
27305
|
+
reason="LoadSettings replaces the group's graph. Without the backup on "
|
|
27306
|
+
"disk there is no way back, so nothing was loaded.",
|
|
27307
|
+
remediation="Check the backup_path directory is writable, or pass an "
|
|
27308
|
+
"explicit backup_path somewhere that is.",
|
|
27309
|
+
)
|
|
26944
27310
|
|
|
26945
27311
|
undo_name = p.get("undo_name", f"MCP group_settings_load {group_name}")
|
|
26946
27312
|
undo_started = False
|
|
@@ -28086,7 +28452,31 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
28086
28452
|
except Exception:
|
|
28087
28453
|
_already_animated = False
|
|
28088
28454
|
if not _already_animated:
|
|
28455
|
+
# AddModifier reports through the Lua bridge, which resolves an
|
|
28456
|
+
# unknown attribute to None rather than raising, so the return is
|
|
28457
|
+
# not reliable evidence on its own. The readback below is: if the
|
|
28458
|
+
# input still has no connected output, the assignment on the next
|
|
28459
|
+
# line would set a STATIC value and _ok() would report a keyframe
|
|
28460
|
+
# that does not exist.
|
|
28089
28461
|
tool.AddModifier(p["input_name"], p.get("modifier", "BezierSpline"))
|
|
28462
|
+
try:
|
|
28463
|
+
attached = tool[p["input_name"]].GetConnectedOutput() is not None
|
|
28464
|
+
except Exception:
|
|
28465
|
+
attached = False
|
|
28466
|
+
if not attached:
|
|
28467
|
+
return _err(
|
|
28468
|
+
f"Could not animate '{p['input_name']}' on "
|
|
28469
|
+
f"'{p['tool_name']}': no spline modifier attached.",
|
|
28470
|
+
code="FUSION_ADD_MODIFIER_FAILED", category="api_error",
|
|
28471
|
+
retryable=False,
|
|
28472
|
+
reason="Without a spline, assigning at a time sets a STATIC "
|
|
28473
|
+
"value (last write wins) and creates no keyframe. "
|
|
28474
|
+
"Reporting success here would claim a keyframe that "
|
|
28475
|
+
"does not exist.",
|
|
28476
|
+
remediation="Check the input accepts the modifier type "
|
|
28477
|
+
f"({p.get('modifier', 'BezierSpline')!r}); Point "
|
|
28478
|
+
"inputs take 'Path'.",
|
|
28479
|
+
)
|
|
28090
28480
|
tool[p["input_name"]][p["time"]] = p["value"]
|
|
28091
28481
|
return _ok()
|
|
28092
28482
|
finally:
|
|
@@ -29060,10 +29450,24 @@ def _run_inline_lua(source: str) -> Dict[str, Any]:
|
|
|
29060
29450
|
)
|
|
29061
29451
|
|
|
29062
29452
|
# Clear prior slots so we can detect if RunScript silently did nothing.
|
|
29063
|
-
|
|
29064
|
-
|
|
29065
|
-
|
|
29066
|
-
|
|
29453
|
+
# SetData goes through the Lua bridge and returns nil whether or not it
|
|
29454
|
+
# took, so the return is not evidence -- but GetData is. The __mcp_done__
|
|
29455
|
+
# slot is the one that matters: a stale "1" left by the previous run makes
|
|
29456
|
+
# the poll below exit immediately and return the PREVIOUS run's stdout,
|
|
29457
|
+
# result and error as this run's.
|
|
29458
|
+
for slot in ("__mcp_done__", "__mcp_stdout__", "__mcp_result__", "__mcp_error__"):
|
|
29459
|
+
fusion.SetData(slot, "")
|
|
29460
|
+
stale = fusion.GetData("__mcp_done__")
|
|
29461
|
+
if stale not in ("", None):
|
|
29462
|
+
return _err(
|
|
29463
|
+
"Could not clear the Fusion completion sentinel before running.",
|
|
29464
|
+
code="FUSION_SENTINEL_NOT_CLEARED", category="api_error", retryable=True,
|
|
29465
|
+
reason=f"__mcp_done__ still reads {stale!r} after SetData. The poll would "
|
|
29466
|
+
"exit immediately and hand back the previous run's output as this "
|
|
29467
|
+
"run's.",
|
|
29468
|
+
remediation="Retry; if it persists, restart Resolve to clear the Fusion "
|
|
29469
|
+
"app's data slots.",
|
|
29470
|
+
)
|
|
29067
29471
|
|
|
29068
29472
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.lua',
|
|
29069
29473
|
prefix='mcp-lua-inline-',
|