davinci-resolve-mcp 2.78.0 → 2.79.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 +122 -0
- package/README.md +2 -1
- package/docs/guides/conforming-an-avid-aaf.md +91 -0
- package/docs/reference/api-limitations.md +40 -12
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/README.md +3 -1
- package/resolve-advanced/server/author-interchange.mjs +154 -16
- package/resolve-advanced/server/tools/editorial.mjs +5 -1
- package/resolve-advanced/vendor/conform-qc/packaging/emit-fcp7.js +155 -3
- package/resolve-advanced/vendor/conform-qc/packaging/index.js +5 -1
- package/resolve-advanced/vendor/conform-qc/test/packaging.test.js +152 -13
- package/src/granular/common.py +1 -1
- package/src/server.py +145 -37
- package/src/utils/api_truth.py +219 -29
- package/src/utils/page_lock.py +50 -0
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.79.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -50,6 +50,7 @@ from src.utils.contracts import validate as _validate_params
|
|
|
50
50
|
from src.utils.cut_ir import build_cut_list as _build_cut_list
|
|
51
51
|
from src.utils.page_lock import (
|
|
52
52
|
color_page_for_thumbnails as _color_page_for_thumbnails,
|
|
53
|
+
edit_page_for_timeline_edits as _edit_page_for_timeline_edits,
|
|
53
54
|
open_page_serialized as _open_page_serialized,
|
|
54
55
|
page_lock as _page_lock,
|
|
55
56
|
)
|
|
@@ -3918,12 +3919,31 @@ def _timeline_items_presence(tl, items):
|
|
|
3918
3919
|
return "absent"
|
|
3919
3920
|
|
|
3920
3921
|
|
|
3921
|
-
def _timeline_delete_clips_verified(tl, items, ripple):
|
|
3922
|
-
"""Timeline.DeleteClips with readback-and-retry.
|
|
3922
|
+
def _timeline_delete_clips_verified(tl, items, ripple, *, resolve=None):
|
|
3923
|
+
"""Timeline.DeleteClips with a page guard and readback-and-retry.
|
|
3923
3924
|
|
|
3924
|
-
api_truth 'Timeline.DeleteClips (
|
|
3925
|
-
|
|
3926
|
-
|
|
3925
|
+
api_truth 'Timeline.DeleteClips (requires the Edit page; flaky first
|
|
3926
|
+
attempt)' records two distinct failures behind this one call.
|
|
3927
|
+
|
|
3928
|
+
Wrong page (deterministic): with the UI on some pages (verified:
|
|
3929
|
+
Fairlight) the call returns False and deletes nothing, retries included —
|
|
3930
|
+
retrying cannot help. When a `resolve` handle is supplied, the guard
|
|
3931
|
+
switches to the Edit page for the call and restores the caller's page
|
|
3932
|
+
after. Guard failures are swallowed: the delete attempt itself stays the
|
|
3933
|
+
source of truth. `resolve` is an explicit parameter, not an internal
|
|
3934
|
+
get_resolve(), so offline tests calling this helper directly can never
|
|
3935
|
+
touch a live Resolve UI.
|
|
3936
|
+
|
|
3937
|
+
The guard is page_lock's edit_page_for_timeline_edits, not a local
|
|
3938
|
+
OpenPage pair, because the switch has to be serialized against every other
|
|
3939
|
+
page-switching operation (thumbnail capture takes the Color page the same
|
|
3940
|
+
way). Unlocked, a concurrent switch lands this delete on some other page —
|
|
3941
|
+
reintroducing the very failure the guard prevents. Callers deleting in a
|
|
3942
|
+
loop should hold that guard around the loop; nesting it here is free.
|
|
3943
|
+
|
|
3944
|
+
Flaky first attempt: the call can return False while every item is still
|
|
3945
|
+
present, and an identical retry then succeeds. On a False, read the
|
|
3946
|
+
tracks back:
|
|
3927
3947
|
|
|
3928
3948
|
absent -> the delete landed despite the False; report success.
|
|
3929
3949
|
present -> retry the identical call once, then read back again.
|
|
@@ -3938,14 +3958,20 @@ def _timeline_delete_clips_verified(tl, items, ripple):
|
|
|
3938
3958
|
handles to already-deleted items included. That could not be made to
|
|
3939
3959
|
misbehave against a fake; it is recorded, not resolved.
|
|
3940
3960
|
"""
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3961
|
+
def _delete_with_readback():
|
|
3962
|
+
if bool(tl.DeleteClips(items, ripple)):
|
|
3963
|
+
return True
|
|
3964
|
+
presence = _timeline_items_presence(tl, items)
|
|
3965
|
+
if presence != "present":
|
|
3966
|
+
return presence == "absent"
|
|
3967
|
+
if bool(tl.DeleteClips(items, ripple)):
|
|
3968
|
+
return True
|
|
3969
|
+
return _timeline_items_presence(tl, items) == "absent"
|
|
3970
|
+
|
|
3971
|
+
if resolve is None:
|
|
3972
|
+
return _delete_with_readback()
|
|
3973
|
+
with _edit_page_for_timeline_edits(resolve):
|
|
3974
|
+
return _delete_with_readback()
|
|
3949
3975
|
|
|
3950
3976
|
|
|
3951
3977
|
def _timeline_items_by_ids(tl, ids, track_types=("video", "audio", "subtitle")):
|
|
@@ -4110,7 +4136,7 @@ def _append_and_recover_timeline_item(
|
|
|
4110
4136
|
return result, duplicate_item, None
|
|
4111
4137
|
|
|
4112
4138
|
|
|
4113
|
-
def _timeline_duplicate_clips_impl(proj, tl, p: Dict[str, Any], *, delete_sources: bool = False):
|
|
4139
|
+
def _timeline_duplicate_clips_impl(proj, tl, p: Dict[str, Any], *, delete_sources: bool = False, resolve=None):
|
|
4114
4140
|
ids = p.get("clip_ids") or p.get("ids")
|
|
4115
4141
|
selected = bool(p.get("selected", False))
|
|
4116
4142
|
if ids is not None and not isinstance(ids, list):
|
|
@@ -4321,7 +4347,7 @@ def _timeline_duplicate_clips_impl(proj, tl, p: Dict[str, Any], *, delete_source
|
|
|
4321
4347
|
seen_delete_ids.add(item_id)
|
|
4322
4348
|
if delete_items:
|
|
4323
4349
|
try:
|
|
4324
|
-
out["deleted_sources"] = _timeline_delete_clips_verified(tl, delete_items, bool(p.get("ripple", False)))
|
|
4350
|
+
out["deleted_sources"] = _timeline_delete_clips_verified(tl, delete_items, bool(p.get("ripple", False)), resolve=resolve)
|
|
4325
4351
|
out["deleted_source_ids"] = _timeline_item_ids(delete_items)
|
|
4326
4352
|
except Exception as exc:
|
|
4327
4353
|
out["deleted_sources"] = False
|
|
@@ -4399,7 +4425,7 @@ def _collect_timeline_items_in_range(tl, p: Dict[str, Any]):
|
|
|
4399
4425
|
return start, end, items, None
|
|
4400
4426
|
|
|
4401
4427
|
|
|
4402
|
-
def _timeline_copy_range_impl(proj, tl, p: Dict[str, Any], *, overwrite: bool = False):
|
|
4428
|
+
def _timeline_copy_range_impl(proj, tl, p: Dict[str, Any], *, overwrite: bool = False, resolve=None):
|
|
4403
4429
|
start, end, items, err = _collect_timeline_items_in_range(tl, p)
|
|
4404
4430
|
if err:
|
|
4405
4431
|
return err
|
|
@@ -4436,7 +4462,7 @@ def _timeline_copy_range_impl(proj, tl, p: Dict[str, Any], *, overwrite: bool =
|
|
|
4436
4462
|
if existing_start < dest_end and existing_end > dest_start:
|
|
4437
4463
|
delete_targets.append(existing)
|
|
4438
4464
|
if delete_targets:
|
|
4439
|
-
deleted = _timeline_delete_clips_verified(tl, delete_targets, False)
|
|
4465
|
+
deleted = _timeline_delete_clips_verified(tl, delete_targets, False, resolve=resolve)
|
|
4440
4466
|
|
|
4441
4467
|
results = []
|
|
4442
4468
|
for track_type, source_track, item, overlap_start, overlap_end in items:
|
|
@@ -4499,7 +4525,7 @@ def _apply_cuts_skip_reason(cut):
|
|
|
4499
4525
|
return None
|
|
4500
4526
|
|
|
4501
4527
|
|
|
4502
|
-
def _timeline_lift_range_impl(tl, p: Dict[str, Any]):
|
|
4528
|
+
def _timeline_lift_range_impl(tl, p: Dict[str, Any], *, resolve=None):
|
|
4503
4529
|
start, end, items, err = _collect_timeline_items_in_range(tl, p)
|
|
4504
4530
|
if err:
|
|
4505
4531
|
return err
|
|
@@ -4529,7 +4555,7 @@ def _timeline_lift_range_impl(tl, p: Dict[str, Any]):
|
|
|
4529
4555
|
return {"success": True, "deleted": 0, "range": {"start": start, "end": end}}
|
|
4530
4556
|
deleted_ids = _timeline_item_ids(delete_items)
|
|
4531
4557
|
return {
|
|
4532
|
-
"success": _timeline_delete_clips_verified(tl, delete_items, bool(p.get("ripple", False))),
|
|
4558
|
+
"success": _timeline_delete_clips_verified(tl, delete_items, bool(p.get("ripple", False)), resolve=resolve),
|
|
4533
4559
|
"deleted": len(delete_items),
|
|
4534
4560
|
"deleted_ids": deleted_ids,
|
|
4535
4561
|
"range": {"start": start, "end": end},
|
|
@@ -5973,7 +5999,10 @@ _PRPROJ_REFUSAL = (
|
|
|
5973
5999
|
"advanced MCP — editorial.list_sequences / editorial.parse_interchange (format 'prproj'); "
|
|
5974
6000
|
"(2) convert it to an importable interchange — editorial.convert_to_interchange "
|
|
5975
6001
|
"(target 'otio'|'edl'|'drt') — then import that here with import_timeline_checked. "
|
|
5976
|
-
"Editorial timing/cuts/transitions
|
|
6002
|
+
"Editorial timing/cuts/transitions carry over; per-clip effects/Lumetri color do not. "
|
|
6003
|
+
"Speed/reverse carry on 'otio' and 'edl' ONLY — the DRT clip schema has no per-clip "
|
|
6004
|
+
"speed field, so 'drt' flattens every retime to 100% forward and reports them in "
|
|
6005
|
+
"`flattened`. Prefer 'otio' or 'edl' for a cut that carries retimes. "
|
|
5977
6006
|
"Alternatively export FCP7 XML / AAF / FCPXML from Premiere and conform that."
|
|
5978
6007
|
)
|
|
5979
6008
|
|
|
@@ -5982,6 +6011,12 @@ _PRPROJ_REFUSAL = (
|
|
|
5982
6011
|
# pool after import, not by rewriting the file.
|
|
5983
6012
|
_BINARY_INTERCHANGE_EXTS = {".aaf"}
|
|
5984
6013
|
|
|
6014
|
+
# Interchange that is not XML at all. `.otio` is JSON, so the sanitize/relink pass —
|
|
6015
|
+
# which parses the file as XML and rewrites <pathurl> elements — cannot run on it, and
|
|
6016
|
+
# the missing-media / generator-clip advice it exists to give is not the diagnosis for
|
|
6017
|
+
# an .otio that fails to import.
|
|
6018
|
+
_JSON_INTERCHANGE_EXTS = {".otio"}
|
|
6019
|
+
|
|
5985
6020
|
|
|
5986
6021
|
def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
|
|
5987
6022
|
path = p.get("path")
|
|
@@ -5992,12 +6027,50 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
|
|
|
5992
6027
|
ext = os.path.splitext(path)[1].lower()
|
|
5993
6028
|
if ext == ".prproj":
|
|
5994
6029
|
return _err(_PRPROJ_REFUSAL, category="invalid_input")
|
|
6030
|
+
# ImportTimelineFromFile silently no-ops on the never-saved default project: it returns
|
|
6031
|
+
# nothing, creates no timeline, and reports no cause. The generic "Resolve created no
|
|
6032
|
+
# timeline" error that came back instead sent people to source-clip resolution and
|
|
6033
|
+
# sanitize_media — the wrong road entirely, because the file is fine.
|
|
6034
|
+
#
|
|
6035
|
+
# This is the last unrecorded member of a family the repo already documents: SaveProject
|
|
6036
|
+
# returns False here (and blocks forever headless), CreateProject fails against a dirty
|
|
6037
|
+
# untitled project, and DeleteProject's workaround warns against leaving the session on
|
|
6038
|
+
# this fallback. Refusing early is what makes the cause visible.
|
|
6039
|
+
#
|
|
6040
|
+
# The refusal is HARD — no override flag. An override would reintroduce exactly the
|
|
6041
|
+
# silent no-op this exists to kill, since the call cannot succeed either way.
|
|
6042
|
+
#
|
|
6043
|
+
# Resolve exposes no "is this project unsaved" predicate (no IsModified / IsSaved), so
|
|
6044
|
+
# the test is the literal fallback name. Known hole, stated rather than hidden: a
|
|
6045
|
+
# localized Resolve may not name it "Untitled Project", and this guard will miss it —
|
|
6046
|
+
# such a session gets the old generic error, not a wrong answer.
|
|
6047
|
+
from src.utils.project_cleanup import UNSAVED_DEFAULT_PROJECT
|
|
6048
|
+
try:
|
|
6049
|
+
_current_name = proj.GetName()
|
|
6050
|
+
except Exception:
|
|
6051
|
+
_current_name = None
|
|
6052
|
+
if _current_name == UNSAVED_DEFAULT_PROJECT:
|
|
6053
|
+
return _err(
|
|
6054
|
+
f"Cannot import a timeline into the never-saved default project "
|
|
6055
|
+
f"('{UNSAVED_DEFAULT_PROJECT}') — Resolve accepts the call and creates no "
|
|
6056
|
+
f"timeline, with no error naming the cause.",
|
|
6057
|
+
category="invalid_input",
|
|
6058
|
+
remediation=(
|
|
6059
|
+
"The project state is the problem, not the file. Save this project under a "
|
|
6060
|
+
"name, or load an existing named project (project_manager.load), then retry "
|
|
6061
|
+
"the import unchanged."
|
|
6062
|
+
),
|
|
6063
|
+
)
|
|
5995
6064
|
# AAF (and any binary interchange) is read natively by Resolve. The XML
|
|
5996
6065
|
# sanitize/relink path parses the file as text, so it must be SKIPPED for AAF
|
|
5997
6066
|
# even when sanitize_media / relink_search_roots is passed; fuzzy XML relink is
|
|
5998
6067
|
# N/A (media links through the media pool). We still detect the created
|
|
5999
6068
|
# (possibly offline) timeline via the before/after id diff.
|
|
6000
6069
|
is_binary = ext in _BINARY_INTERCHANGE_EXTS
|
|
6070
|
+
# .otio is JSON. The sanitize/relink pass parses the file as XML, so it cannot run here
|
|
6071
|
+
# any more than it can on an AAF — and its advice (missing media, generator clips) is
|
|
6072
|
+
# not the diagnosis for an .otio that fails to import.
|
|
6073
|
+
is_json = ext in _JSON_INTERCHANGE_EXTS
|
|
6001
6074
|
# sanitize_media (alias: relink_media) rewrites the XML to drop clipitems that
|
|
6002
6075
|
# reference missing media or are generators (slug/solid w/ no pathurl) — both
|
|
6003
6076
|
# abort Resolve's scripting-API import and leave the timeline fully offline.
|
|
@@ -6017,7 +6090,13 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
|
|
|
6017
6090
|
"relinks via the media pool. Imported the file as-is; pass relink_search_roots to "
|
|
6018
6091
|
"auto-relink after import."
|
|
6019
6092
|
)
|
|
6020
|
-
|
|
6093
|
+
elif is_json and (sanitize_requested or _binary_has_roots):
|
|
6094
|
+
binary_relink_note = (
|
|
6095
|
+
f"sanitize_media / XML path-rewrite relink are N/A for {ext} (JSON, not XML) — "
|
|
6096
|
+
"imported as-is. Media links by the target_url recorded in the file; relink via "
|
|
6097
|
+
"the media pool afterward (see `relink`)."
|
|
6098
|
+
)
|
|
6099
|
+
sanitize = sanitize_requested and not is_binary and not is_json
|
|
6021
6100
|
if not sanitize and p.get("require_temp_path", True) and not _render_temp_path_ok(path):
|
|
6022
6101
|
return _err(
|
|
6023
6102
|
"path must be under the system temp directory unless require_temp_path=False",
|
|
@@ -6114,6 +6193,26 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
|
|
|
6114
6193
|
"Otherwise verify it exports/opens in Resolve directly, or convert to "
|
|
6115
6194
|
"FCP7 XML / FCPXML upstream and import that."
|
|
6116
6195
|
)
|
|
6196
|
+
elif is_json:
|
|
6197
|
+
# Measured on 19.1.3: Resolve DOES import OTIO through the scripting API —
|
|
6198
|
+
# its own EXPORT_OTIO output re-imports cleanly. What it refuses is a
|
|
6199
|
+
# document that is valid OTIO but not Resolve-shaped, and the shape it cares
|
|
6200
|
+
# about most is the source frame ORIGIN: a clip's source_range must sit
|
|
6201
|
+
# inside the media's real timecode range, so 0-based source offsets against
|
|
6202
|
+
# media that starts at 01:00:00:00 produce no timeline at all. Missing media
|
|
6203
|
+
# and sanitize_media are not the diagnosis here and pointing at them wastes
|
|
6204
|
+
# the caller's time on a file whose media is online.
|
|
6205
|
+
remediation = (
|
|
6206
|
+
f"Resolve created no timeline from this {ext}. This is usually the "
|
|
6207
|
+
"document's shape, not its media: Resolve expects Clip.2 with a "
|
|
6208
|
+
"media_references map, an available_range on each reference, and "
|
|
6209
|
+
"source_range frames expressed against the media's own timecode origin "
|
|
6210
|
+
"(0-based source offsets fail when the media does not start at "
|
|
6211
|
+
"00:00:00:00). Author it with editorial.convert_to_interchange "
|
|
6212
|
+
"(target 'otio'), supplying each event's media start timecode, and check "
|
|
6213
|
+
"the returned mediaOriginAssumed list. To compare against a known-good "
|
|
6214
|
+
"file, export any timeline with timeline.export EXPORT_OTIO."
|
|
6215
|
+
)
|
|
6117
6216
|
elif sanitize:
|
|
6118
6217
|
remediation = None
|
|
6119
6218
|
else:
|
|
@@ -6154,6 +6253,9 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
|
|
|
6154
6253
|
if is_binary:
|
|
6155
6254
|
msg += (" Relink via the media pool (right-click → Relink Clips) or point Resolve "
|
|
6156
6255
|
"at the media roots — AAF media links there, not by rewriting the file.")
|
|
6256
|
+
elif is_json:
|
|
6257
|
+
msg += (" Relink via the media pool — .otio is JSON, so sanitize_media cannot "
|
|
6258
|
+
"rewrite its paths; check each clip's target_url instead.")
|
|
6157
6259
|
elif not sanitize:
|
|
6158
6260
|
msg += (" Retry with sanitize_media=True to drop missing-media/generator "
|
|
6159
6261
|
"clips so the remaining media links automatically.")
|
|
@@ -20499,7 +20601,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
20499
20601
|
"track_indices": [target_track],
|
|
20500
20602
|
"allow_partial_item_delete": True,
|
|
20501
20603
|
"ripple": False,
|
|
20502
|
-
})
|
|
20604
|
+
}, resolve=_r)
|
|
20503
20605
|
if not lift.get("success"):
|
|
20504
20606
|
return {"success": False, "error": f"lift failed: {lift.get('error')}", "lift": lift}
|
|
20505
20607
|
audio_lift = None
|
|
@@ -20511,7 +20613,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
20511
20613
|
"track_indices": linked_audio_indices,
|
|
20512
20614
|
"allow_partial_item_delete": True,
|
|
20513
20615
|
"ripple": False,
|
|
20514
|
-
})
|
|
20616
|
+
}, resolve=_r)
|
|
20515
20617
|
if not audio_lift.get("success"):
|
|
20516
20618
|
return {
|
|
20517
20619
|
"success": False,
|
|
@@ -21005,7 +21107,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
21005
21107
|
blocked = _consume_confirm_token(action="timeline.delete_clips_ripple", params=p)
|
|
21006
21108
|
if blocked:
|
|
21007
21109
|
return blocked
|
|
21008
|
-
return {"success": _timeline_delete_clips_verified(tl, found, ripple)}
|
|
21110
|
+
return {"success": _timeline_delete_clips_verified(tl, found, ripple, resolve=get_resolve())}
|
|
21009
21111
|
elif action == "set_clips_linked":
|
|
21010
21112
|
ids_set = set(p["clip_ids"])
|
|
21011
21113
|
found = []
|
|
@@ -21023,13 +21125,13 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
21023
21125
|
elif action == "copy_clips":
|
|
21024
21126
|
return _timeline_duplicate_clips_impl(proj, tl, p)
|
|
21025
21127
|
elif action == "move_clips":
|
|
21026
|
-
return _timeline_duplicate_clips_impl(proj, tl, p, delete_sources=True)
|
|
21128
|
+
return _timeline_duplicate_clips_impl(proj, tl, p, delete_sources=True, resolve=get_resolve())
|
|
21027
21129
|
elif action in {"copy_range", "duplicate_range"}:
|
|
21028
21130
|
return _timeline_copy_range_impl(proj, tl, p)
|
|
21029
21131
|
elif action == "overwrite_range":
|
|
21030
|
-
return _timeline_copy_range_impl(proj, tl, p, overwrite=True)
|
|
21132
|
+
return _timeline_copy_range_impl(proj, tl, p, overwrite=True, resolve=get_resolve())
|
|
21031
21133
|
elif action == "lift_range":
|
|
21032
|
-
return _timeline_lift_range_impl(tl, p)
|
|
21134
|
+
return _timeline_lift_range_impl(tl, p, resolve=get_resolve())
|
|
21033
21135
|
elif action == "story_spine_report":
|
|
21034
21136
|
return _timeline_story_spine_report(tl, p)
|
|
21035
21137
|
elif action == "create_variant_from_ranges":
|
|
@@ -21184,15 +21286,21 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
21184
21286
|
|
|
21185
21287
|
allow_partial = bool(p.get("allow_partial_item_delete", True))
|
|
21186
21288
|
results = []
|
|
21187
|
-
|
|
21188
|
-
|
|
21189
|
-
|
|
21190
|
-
|
|
21191
|
-
|
|
21192
|
-
|
|
21193
|
-
|
|
21194
|
-
|
|
21195
|
-
|
|
21289
|
+
resolve_obj = get_resolve()
|
|
21290
|
+
# Hold the Edit page once for the whole run. The per-delete guard nests
|
|
21291
|
+
# harmlessly inside (it finds the page already on edit), but without this
|
|
21292
|
+
# each cut would switch and restore on its own: from Fairlight, N cuts
|
|
21293
|
+
# cost 2N page flips instead of 2.
|
|
21294
|
+
with _edit_page_for_timeline_edits(resolve_obj):
|
|
21295
|
+
for c in applicable:
|
|
21296
|
+
sp = c["span"]
|
|
21297
|
+
res = _timeline_lift_range_impl(tl, {
|
|
21298
|
+
"start_frame": sp["start"],
|
|
21299
|
+
"end_frame": sp["end"],
|
|
21300
|
+
"ripple": c["action"] == "ripple_delete",
|
|
21301
|
+
"allow_partial_item_delete": allow_partial,
|
|
21302
|
+
}, resolve=resolve_obj)
|
|
21303
|
+
results.append({"action": c["action"], "span": sp, "result": res})
|
|
21196
21304
|
applied = sum(1 for r in results
|
|
21197
21305
|
if isinstance(r["result"], dict) and r["result"].get("success"))
|
|
21198
21306
|
return {"success": True, "applied": applied, "total": len(applicable),
|
package/src/utils/api_truth.py
CHANGED
|
@@ -181,7 +181,14 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
181
181
|
"five consecutive imports with no media duplicated. Use DRT "
|
|
182
182
|
"for one-shot hand-offs only. For OTIO, pass "
|
|
183
183
|
"importSourceClips=True with a sourceClipsPath. See "
|
|
184
|
-
"docs/guides/headless-edit-loop.md."
|
|
184
|
+
"docs/guides/headless-edit-loop.md. For a consolidated Avid "
|
|
185
|
+
"AAF specifically, do NOT expect this call (or Reconform "
|
|
186
|
+
"from Bins, or the UI's 'Link to source camera files') to "
|
|
187
|
+
"conform it to camera originals — all three were measured "
|
|
188
|
+
"against a real turnover and all three fail, the UI option "
|
|
189
|
+
"linking 878 of 882 items with only 144 correct while "
|
|
190
|
+
"rendering as a fully conformed timeline. See "
|
|
191
|
+
"docs/guides/conforming-an-avid-aaf.md.",
|
|
185
192
|
"tags": ["timeline", "import", "interchange", "silent-failure", "conform"],
|
|
186
193
|
"submit": "bug",
|
|
187
194
|
},
|
|
@@ -274,11 +281,119 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
274
281
|
"— naming nothing actionable — when the Media Pool's CURRENT "
|
|
275
282
|
"folder is not the folder holding the clips, even though every "
|
|
276
283
|
"clip_id passed is valid and resolvable. Reported against "
|
|
277
|
-
"Resolve Studio in PR #99."
|
|
284
|
+
"Resolve Studio in PR #99. "
|
|
285
|
+
"SEPARATELY, its clipInfo HAS NO TRACK FIELD: there is no way "
|
|
286
|
+
"to say which video or audio track a clip should land on, so "
|
|
287
|
+
"this call cannot build a multi-track timeline. The asymmetry "
|
|
288
|
+
"is the surprise — MediaPool.AppendToTimeline's clipInfo DOES "
|
|
289
|
+
"take `trackIndex`, so the two clipInfo shapes are not the same "
|
|
290
|
+
"shape, and code that works against one silently loses track "
|
|
291
|
+
"assignment against the other.",
|
|
278
292
|
"recommended": "Call MediaPool.SetCurrentFolder() to the clips' bin "
|
|
279
293
|
"before creating the timeline (media_pool "
|
|
280
|
-
"set_current_folder). Valid ids are not sufficient."
|
|
281
|
-
|
|
294
|
+
"set_current_folder). Valid ids are not sufficient. "
|
|
295
|
+
"For multi-track placement do not use this call at all: "
|
|
296
|
+
"create an EMPTY timeline (media_pool create_timeline), add "
|
|
297
|
+
"the tracks you need (timeline add_track), then place each "
|
|
298
|
+
"clip with MediaPool.AppendToTimeline passing clipInfo "
|
|
299
|
+
"`trackIndex` (media_pool append_to_timeline with "
|
|
300
|
+
"track_index).",
|
|
301
|
+
"tags": ["media-pool", "timeline", "unhelpful-error", "missing-method", "conform"],
|
|
302
|
+
"submit": "bug",
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
"symbol": "MediaPool.AppendToTimeline (overlapping records — earlier item wins)",
|
|
306
|
+
"object": "MediaPool",
|
|
307
|
+
"signature": "([{mediaPoolItem, startFrame, endFrame, recordFrame, "
|
|
308
|
+
"trackIndex, mediaType}]) -> [TimelineItem]",
|
|
309
|
+
"reality": "AppendToTimeline does NOT overwrite an overlapping record. "
|
|
310
|
+
"When two clipInfos resolve to record ranges that overlap on "
|
|
311
|
+
"the same track, the EARLIER item wins and the later append is "
|
|
312
|
+
"dropped — measured while placing a real turnover. There is no "
|
|
313
|
+
"overwrite edit mode to reach for either: AppendToTimeline is "
|
|
314
|
+
"the only programmatic placement the API offers, so a consumer "
|
|
315
|
+
"that assumes overwrite semantics gets a timeline that is "
|
|
316
|
+
"silently SHORT by the number of colliding events, with no "
|
|
317
|
+
"error to say which ones lost.",
|
|
318
|
+
"recommended": "Resolve collisions BEFORE appending — the API will not do "
|
|
319
|
+
"it for you. Cap each event's placed duration at the next "
|
|
320
|
+
"event's recordFrame on the same track, or place the "
|
|
321
|
+
"colliding events on separate tracks. Verify by comparing "
|
|
322
|
+
"the timeline's item count against the number of clipInfos "
|
|
323
|
+
"you sent, per track, and finish with detect_gaps_overlaps; "
|
|
324
|
+
"a count that matches is the only evidence nothing was "
|
|
325
|
+
"dropped.",
|
|
326
|
+
"tags": ["timeline", "edit", "silent-failure", "conform", "media-pool"],
|
|
327
|
+
"submit": "bug",
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
"symbol": "MediaPool.AppendToTimeline (errored-chunk placements are not durable across a save)",
|
|
331
|
+
"object": "MediaPool",
|
|
332
|
+
"signature": "([clipInfos]) -> [TimelineItem]",
|
|
333
|
+
"reality": "Placements made by an append call whose response ERRORED are "
|
|
334
|
+
"not durable: they appear in the timeline, every in-session "
|
|
335
|
+
"read agrees they are there, and THE SAVE DISCARDS THEM. "
|
|
336
|
+
"Measured on a real turnover — a timeline verified at 573 items "
|
|
337
|
+
"immediately after construction held 500 after the save, a loss "
|
|
338
|
+
"of exactly one errored append chunk's worth. The API reported "
|
|
339
|
+
"the failure, the items appeared anyway, and nothing between "
|
|
340
|
+
"construction and the save disagreed with the wrong number. "
|
|
341
|
+
"This is the dangerous shape: the witness is derived from the "
|
|
342
|
+
"same unsaved state as the thing it is checking, so it cannot "
|
|
343
|
+
"contradict it. Only a POST-SAVE read can.",
|
|
344
|
+
"recommended": "Treat the save as the verification boundary, not the end "
|
|
345
|
+
"of the job. Save, RE-READ the timeline's item count from "
|
|
346
|
+
"the saved project, and re-append whatever is missing "
|
|
347
|
+
"(bounded — two rounds is enough in practice); only then "
|
|
348
|
+
"report success. A timeline that is still short after that, "
|
|
349
|
+
"or that will not save, is a FAILURE — report it as one "
|
|
350
|
+
"rather than returning the in-session count. Never claim a "
|
|
351
|
+
"construction succeeded on a pre-save read alone, and treat "
|
|
352
|
+
"any errored append chunk as suspect even when its items "
|
|
353
|
+
"are visibly present.",
|
|
354
|
+
"tags": ["timeline", "edit", "silent-failure", "data-loss", "conform",
|
|
355
|
+
"unreliable-return", "media-pool"],
|
|
356
|
+
"submit": "bug",
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
"symbol": "MediaPool.ImportTimelineFromFile (.otio document shape)",
|
|
360
|
+
"object": "MediaPool",
|
|
361
|
+
"signature": "(filePath, {importOptions}) -> Timeline",
|
|
362
|
+
"reality": "Resolve DOES import OTIO through the scripting API — but only "
|
|
363
|
+
"a Resolve-shaped document, and it rejects anything else by "
|
|
364
|
+
"creating NO timeline and returning None, with no error naming "
|
|
365
|
+
"a cause. Established on 19.1.3.7 by exporting a timeline with "
|
|
366
|
+
"Timeline.Export(..., EXPORT_OTIO) and feeding Resolve's own "
|
|
367
|
+
"file straight back: it re-imports cleanly (3 items, 3 linked), "
|
|
368
|
+
"while a valid hand-authored OTIO of the same cut, same project, "
|
|
369
|
+
"same session, same three online media files, produced nothing. "
|
|
370
|
+
"So a refusal is a SHAPE problem, not a media problem. "
|
|
371
|
+
"The requirement that actually decides it is the SOURCE FRAME "
|
|
372
|
+
"ORIGIN: a clip's source_range must be expressed against the "
|
|
373
|
+
"media's own timecode range. Media carrying an embedded start "
|
|
374
|
+
"TC of 01:00:00:00 gets an available_range starting at frame "
|
|
375
|
+
"86400, and the clip's source_range must start there too — "
|
|
376
|
+
"0-based source offsets, the natural reading of 'source in "
|
|
377
|
+
"point', put the clip outside the media's real range and the "
|
|
378
|
+
"import dies. Isolated by bisection: 0-based fails and absolute "
|
|
379
|
+
"succeeds whether or not the stack/track source_range is null. "
|
|
380
|
+
"Resolve also writes (and expects) Clip.2 with a "
|
|
381
|
+
"`media_references` MAP plus `active_media_reference_key`, not "
|
|
382
|
+
"Clip.1 with a singular `media_reference`; an `available_range` "
|
|
383
|
+
"on each reference; a BARE path in `target_url`, not a file:// "
|
|
384
|
+
"URL; `enabled` and `metadata` throughout; `global_start_time`; "
|
|
385
|
+
"and track names in its own form ('Video 1').",
|
|
386
|
+
"recommended": "Author OTIO for Resolve by mirroring what Resolve itself "
|
|
387
|
+
"exports, and give every event its media timecode origin. "
|
|
388
|
+
"editorial.convert_to_interchange (target 'otio') does this "
|
|
389
|
+
"and reports any event whose origin had to be assumed in "
|
|
390
|
+
"`mediaOriginAssumed` — a non-empty list means the file will "
|
|
391
|
+
"only import if that media really starts at 00:00:00:00. To "
|
|
392
|
+
"debug a refusal, export any timeline with EXPORT_OTIO and "
|
|
393
|
+
"diff your document against it; do NOT chase missing media "
|
|
394
|
+
"or reach for sanitize_media, which cannot even parse a "
|
|
395
|
+
".otio (it is JSON, not XML).",
|
|
396
|
+
"tags": ["timeline", "import", "interchange", "otio", "silent-failure", "conform"],
|
|
282
397
|
"submit": "bug",
|
|
283
398
|
},
|
|
284
399
|
{
|
|
@@ -554,10 +669,62 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
554
669
|
"against the documented SetProperty key list AND by live "
|
|
555
670
|
"mutating attempt on 21.0.0: SetProperty('Speed'|'PlaybackSpeed'"
|
|
556
671
|
"|'RetimeSpeed'|'ClipSpeed', 50) all return False, while "
|
|
557
|
-
"SetProperty('RetimeProcess', 1) returns True."
|
|
672
|
+
"SetProperty('RetimeProcess', 1) returns True. "
|
|
673
|
+
"THE READ SIDE IS AS DEAD AS THE WRITE SIDE, which is easy to "
|
|
674
|
+
"miss: re-measured on Studio 19.1.3.7 against a placed item, "
|
|
675
|
+
"GetProperty('Speed'), GetProperty('PlaybackSpeed'), "
|
|
676
|
+
"GetProperty('RetimeSpeed') and GetProperty('ClipSpeed') ALL "
|
|
677
|
+
"return None, and the keyless GetProperty() dict (26 keys on "
|
|
678
|
+
"that item) carries no speed value at all — its only retime key "
|
|
679
|
+
"is RetimeProcess, which is quality, not ratio. SetProperty("
|
|
680
|
+
"'Speed', 1.75) returned False on 19.1.3.7 too, so the write "
|
|
681
|
+
"refusal is not specific to 21.0.0. Note the 21.0.0 stamp above "
|
|
682
|
+
"covers the SetProperty measurements only. "
|
|
683
|
+
"THE INTERCHANGE ROUTE IS ALSO CLOSED — carrying a retime in "
|
|
684
|
+
"through FCP7 XML does not work either, measured on 19.1.3 with "
|
|
685
|
+
"three real files, clips at 100/200/50% on V1, each variant "
|
|
686
|
+
"imported as its own timeline: (a) the importer IGNORES the "
|
|
687
|
+
"scalar Time Remap speed filter and the clips arrive at 100%; "
|
|
688
|
+
"(b) `graphdict` is dead in FOUR separate shapes — the full "
|
|
689
|
+
"Premiere form (variablespeed=0 + speed + reverse=FALSE + "
|
|
690
|
+
"frameblending + 4-keyframe graphdict + FCPCurve), graphdict "
|
|
691
|
+
"alone with no <speed> param, a 100% REVERSE (reverse=TRUE plus "
|
|
692
|
+
"a descending graphdict, the exact shape a real Premiere export "
|
|
693
|
+
"carries), and variablespeed=1 + graphdict — 0 of 2 retimes "
|
|
694
|
+
"landed in every one; (c) `reverse` did not survive either; "
|
|
695
|
+
"(d) any <in>/<pproTicksIn> inconsistency is silently REJECTED, "
|
|
696
|
+
"measured in BOTH orientations. The emitted maps were verified "
|
|
697
|
+
"constant-slope against real Premiere exports before importing, "
|
|
698
|
+
"and every imported clip came back consuming exactly its record "
|
|
699
|
+
"span of source (a 200% clip emitted in 200 / out 296 was "
|
|
700
|
+
"clamped to out 248 — 48 source frames over a 48-frame record "
|
|
701
|
+
"span). Placement is NOT the problem: the same route imported "
|
|
702
|
+
"573 clips with 572 of 573 matching by track and record position "
|
|
703
|
+
"with source frames exact, and the importer BUILT a 59-frame "
|
|
704
|
+
"dissolve. The retime gap is specific, not general. "
|
|
705
|
+
"TRAP: Resolve's own FCP7 export cannot witness a speed. It "
|
|
706
|
+
"writes a DEGENERATE Time Remap on every clip — `speed` value 0 "
|
|
707
|
+
"(not 100) and a graphdict whose keyframe `value`s are all 0 "
|
|
708
|
+
"while its `when`s carry the clip's source in/out — so anyone "
|
|
709
|
+
"verifying a retime by round-tripping through EXPORT_FCP_7_XML "
|
|
710
|
+
"is reading furniture, and the identity Time Remap blocks "
|
|
711
|
+
"present on every clip are what make the route look like it "
|
|
712
|
+
"should work.",
|
|
558
713
|
"recommended": "Set clip speed/retime in the Resolve UI; no scripted "
|
|
559
|
-
"equivalent exists
|
|
560
|
-
|
|
714
|
+
"equivalent exists, and no interchange route carries one "
|
|
715
|
+
"in. Do NOT read speed back with GetProperty (None) or "
|
|
716
|
+
"witness it via EXPORT_FCP_7_XML (degenerate). Read the "
|
|
717
|
+
"clip's GEOMETRY instead — GetLeftOffset / GetRightOffset, "
|
|
718
|
+
"which on the 200% clip read 200 / 248 and agreed with the "
|
|
719
|
+
"export's in/out. Caveat worth stating: no positive control "
|
|
720
|
+
"has been run — no clip KNOWN to be retimed has been read "
|
|
721
|
+
"back through those two witnesses, because there is no "
|
|
722
|
+
"scripting path to create one. So the geometry witness is "
|
|
723
|
+
"the best available, not a proven one. Either way the "
|
|
724
|
+
"interchange route is rejected: no retime was built, and "
|
|
725
|
+
"none could have been verified.",
|
|
726
|
+
"tags": ["missing-method", "timeline", "retime", "speed", "interchange",
|
|
727
|
+
"silent-failure", "unreliable-return"],
|
|
561
728
|
"submit": "missing",
|
|
562
729
|
},
|
|
563
730
|
{
|
|
@@ -951,30 +1118,53 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
951
1118
|
"tags": ["timeline", "edit", "off-by-one", "readback"],
|
|
952
1119
|
},
|
|
953
1120
|
{
|
|
954
|
-
"symbol": "Timeline.DeleteClips (flaky first attempt)",
|
|
1121
|
+
"symbol": "Timeline.DeleteClips (requires the Edit page; flaky first attempt)",
|
|
955
1122
|
"object": "Timeline",
|
|
956
1123
|
"signature": "([TimelineItem], ripple) -> bool",
|
|
957
|
-
"reality": "
|
|
958
|
-
"
|
|
959
|
-
"
|
|
960
|
-
"
|
|
961
|
-
"
|
|
962
|
-
"
|
|
963
|
-
"
|
|
964
|
-
"
|
|
965
|
-
"
|
|
966
|
-
"
|
|
967
|
-
"
|
|
968
|
-
"
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1124
|
+
"reality": "Two distinct failures share this call.\n"
|
|
1125
|
+
"\n"
|
|
1126
|
+
" **1. Wrong page (deterministic, has a mechanism).** With "
|
|
1127
|
+
"the UI on the Fairlight page, DeleteClips returns False and "
|
|
1128
|
+
"deletes nothing, no matter how many times it is retried. "
|
|
1129
|
+
"Verified 2026-08-04 on Studio 21.0: three identical retries "
|
|
1130
|
+
"against 132 valid, unlocked, present TimelineItems all "
|
|
1131
|
+
"returned False with all 132 still on the track; a single "
|
|
1132
|
+
"`Resolve.OpenPage(\"edit\")` followed by the same call "
|
|
1133
|
+
"returned True and left 0 items. Track lock and enable state "
|
|
1134
|
+
"were confirmed clear before and after, so this is a page "
|
|
1135
|
+
"gate, not a lock. This is the case the previous entry's "
|
|
1136
|
+
"falsification condition anticipated — a retry seen to fail "
|
|
1137
|
+
"repeatedly — and revisiting it found the mechanism.\n"
|
|
1138
|
+
"\n"
|
|
1139
|
+
" **2. Flaky first attempt (one observation, no "
|
|
1140
|
+
"mechanism).** Independently of the page, the call has been "
|
|
1141
|
+
"seen once to return False with every item still present, "
|
|
1142
|
+
"where an identical immediate retry succeeded (Studio 21.0, "
|
|
1143
|
+
"cut-video edit session). Whether the UI was on the Edit "
|
|
1144
|
+
"page at the time was not recorded, so it cannot be ruled "
|
|
1145
|
+
"in or out as failure 1 in disguise. Do NOT read this as "
|
|
1146
|
+
"the ProjectManager.DeleteProject shape: that one has an "
|
|
1147
|
+
"identified mechanism (the project being, or recently "
|
|
1148
|
+
"having been, current) that retrying does not clear. One "
|
|
1149
|
+
"observation is still not a mechanism; if an immediate "
|
|
1150
|
+
"retry is ever seen to fail repeatedly while the UI is "
|
|
1151
|
+
"confirmed on the Edit page, this sub-entry needs "
|
|
1152
|
+
"revisiting.",
|
|
1153
|
+
"recommended": "Open the Edit page first (`Resolve.OpenPage(\"edit\")`) "
|
|
1154
|
+
"— a caller that deletes clips from a script must not "
|
|
1155
|
+
"assume the user left the UI on Edit, and a Fairlight or "
|
|
1156
|
+
"Color session is a completely ordinary place for them "
|
|
1157
|
+
"to be. Then treat a False return as advisory: re-list "
|
|
1158
|
+
"the track and check whether the items are actually "
|
|
1159
|
+
"gone; if still present, retry the identical call once "
|
|
1160
|
+
"before failing. A readback that raised, enumerated "
|
|
1161
|
+
"nothing, or covered items whose unique ID cannot be "
|
|
1162
|
+
"read is UNKNOWN, not gone — never report an "
|
|
1163
|
+
"unverifiable delete as success, and do not spend a "
|
|
1164
|
+
"second destructive call on an outcome you equally "
|
|
1165
|
+
"cannot read.",
|
|
1166
|
+
"tags": ["unreliable-return", "flaky", "silent-failure",
|
|
1167
|
+
"page-dependent", "timeline", "edit"],
|
|
978
1168
|
"submit": "bug",
|
|
979
1169
|
"mitigation": ["_timeline_delete_clips_verified", "_timeline_items_presence"],
|
|
980
1170
|
},
|