@motionharness/bridge-davinci 0.0.0-stage → 0.0.22
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/LICENSE +201 -0
- package/NOTICE +13 -0
- package/OPENFX_LICENSE +24 -0
- package/README.md +114 -2
- package/THIRD_PARTY_NOTICES.md +196 -0
- package/bridge-manifest.json +107 -0
- package/dist/inspector_runtime.py +113 -0
- package/dist/instance_runtime.py +469 -0
- package/dist/intrinsic_runtime.py +125 -0
- package/dist/legacy_conversion.py +619 -0
- package/dist/resolve_rpc.py +1508 -0
- package/dist/runner.mjs +22203 -0
- package/dist/runtime_io.py +31 -0
- package/package.json +35 -4
- package/resources/HotionHarness.ofx.bundle/Contents/Info.plist +9 -0
- package/resources/HotionHarness.ofx.bundle/Contents/MacOS/HotionHarness.ofx +0 -0
- package/resources/HotionHarnessInstall +0 -0
- package/resources/HotionHarnessStatus.app/Contents/Info.plist +12 -0
- package/resources/HotionHarnessStatus.app/Contents/MacOS/HotionHarnessStatus +0 -0
- package/resources/legacy-forwarder.lua +55 -0
- package/resources/legacy-inspector-v1.lua +31 -0
- package/resources/menu-entry.py +150 -0
- package/resources/sidebar-help/index.html +35 -0
- package/resources/sidebar-help/resolve-sidebar.png +0 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
"""Convert recognized legacy generators only inside a newly duplicated timeline.
|
|
2
|
+
|
|
3
|
+
The original timeline and original media objects are never mutation targets.
|
|
4
|
+
Unknown Fusion processing remains on the original timeline; a copy is refused
|
|
5
|
+
when it would retain an unconverted Fusion item or unverified nonzero fades.
|
|
6
|
+
A native failure leaves an owned copy and a durable journal for inspection;
|
|
7
|
+
it never retries a destructive step.
|
|
8
|
+
"""
|
|
9
|
+
import copy
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
import tempfile
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
import intrinsic_runtime as intrinsic
|
|
17
|
+
from runtime_io import atomic_json, file_digest
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_MEDIA_PROPERTIES = ("File Path", "FPS", "Frames", "Resolution", "Start", "Audio Ch", "Proxy Media Path",
|
|
21
|
+
"Alpha mode", "Input Color Space", "Input Gamma", "Data Level", "PAR", "Field Dominance", "Clip Name")
|
|
22
|
+
_INTERPRETATION = ("Alpha mode", "Input Color Space", "Input Gamma", "Data Level", "PAR", "Field Dominance")
|
|
23
|
+
_STATIC_EFFECTS = {"Transform", "Cropping", "Composite", "Lens Correction", "Retime and Scaling", "Video Faders"}
|
|
24
|
+
_EMPTY_EFFECTS = {"Source Transform", "Source Cropping", "Source Looks", "Immersive Transform", "Immersive Output Transform"}
|
|
25
|
+
# Ordinary video exposes these default sections; generators do not. Only their
|
|
26
|
+
# exact no-op state is allowed to appear during conversion.
|
|
27
|
+
_VIDEO_DEFAULTS = {"Distortion": 0, "RetimeProcess": 0, "MotionEstimation": 0, "Scaling": 0, "ResizeFilter": 0,
|
|
28
|
+
"LensCorrectionEnabled": True, "RetimeAndScalingEnabled": True}
|
|
29
|
+
_LIMITS = ["仅转换完整识别的自有旧生成器、原速、同帧率、整数帧裁切。",
|
|
30
|
+
"任何 Fusion 片段无法完整转换或未在本次范围内时,保留原时间线,不创建转换副本。",
|
|
31
|
+
"时间线含非零视频淡入淡出时,不自动转换:当前宿主的 SetFades 保存重开尚不能保证保留。",
|
|
32
|
+
"转换仅写入 DuplicateTimeline 副本;新视频使用独立媒体对象与关联。",
|
|
33
|
+
"结构读回不等于画面或宿主稳定性验收;当前旧生成器转换仍为实验性功能。"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _json(value):
|
|
37
|
+
return json.loads(json.dumps(value, ensure_ascii=False, allow_nan=False))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _scratch(api):
|
|
41
|
+
path = os.environ.get("MH_BRIDGE_SCRATCH", "")
|
|
42
|
+
api["require"](os.path.isabs(path), "INVALID_REQUEST", "缺少桥接原生操作目录。")
|
|
43
|
+
os.makedirs(path, mode=0o700, exist_ok=True)
|
|
44
|
+
return os.path.realpath(path)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _media(media):
|
|
48
|
+
properties = media.GetClipProperty() or {}
|
|
49
|
+
return {"mediaPoolItemId": media.GetUniqueId(), "path": properties.get("File Path", ""),
|
|
50
|
+
"properties": {key: properties.get(key) for key in _MEDIA_PROPERTIES},
|
|
51
|
+
"metadata": media.GetThirdPartyMetadata() or {}}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _native(item, kind, track):
|
|
55
|
+
media = item.GetMediaPoolItem()
|
|
56
|
+
video = kind == "video"
|
|
57
|
+
result = {"id": item.GetUniqueId(), "trackType": kind, "track": track, "kind": item.GetType(), "name": item.GetName(),
|
|
58
|
+
"mediaPoolItemId": media.GetUniqueId() if media else None,
|
|
59
|
+
"startFrame": item.GetStart(True), "endFrame": item.GetEnd(True), "duration": item.GetDuration(True),
|
|
60
|
+
"sourceStart": item.GetSourceStartFrame(), "sourceEnd": item.GetSourceEndFrame(),
|
|
61
|
+
"sourceStartTime": item.GetSourceStartTime(), "sourceEndTime": item.GetSourceEndTime(),
|
|
62
|
+
"properties": item.GetProperties(), "speed": item.GetSpeed(), "fades": item.GetFades(),
|
|
63
|
+
"enabled": item.GetClipEnabled(), "markers": item.GetMarkers(),
|
|
64
|
+
"flags": sorted(item.GetFlagList() or []), "clipColor": item.GetClipColor(),
|
|
65
|
+
"fusionCount": item.GetFusionCompCount(), "takes": item.GetTakesCount(),
|
|
66
|
+
"linked": [other.GetUniqueId() for other in item.GetLinkedItems() or []]}
|
|
67
|
+
if video and result["kind"] != "transition":
|
|
68
|
+
graph = item.GetNodeGraph()
|
|
69
|
+
group = item.GetColorGroup()
|
|
70
|
+
result.update(gradeTools=[graph.GetToolsInNode(index) or [] for index in range(1, graph.GetNumNodes() + 1)] if graph else [],
|
|
71
|
+
colorGroup=group.GetName() if group else None, version=item.GetCurrentVersion(),
|
|
72
|
+
versions={"local": item.GetVersionNameList(0), "remote": item.GetVersionNameList(1)},
|
|
73
|
+
outputBlanking=item.GetOutputBlanking(), timelineBlanking=item.GetUseTimelineForOutputBlanking(),
|
|
74
|
+
stereo=[item.GetStereoConvergenceValues(), item.GetStereoLeftFloatingWindowParams(), item.GetStereoRightFloatingWindowParams()])
|
|
75
|
+
return _json(result)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _otio(resolve, timeline, api):
|
|
79
|
+
with tempfile.TemporaryDirectory(prefix="legacy-otio-", dir=_scratch(api)) as directory:
|
|
80
|
+
path = os.path.join(directory, "timeline.otio")
|
|
81
|
+
api["require"](timeline.Export(path, resolve.EXPORT_OTIO), "CAPABILITY_UNAVAILABLE", "无法读取完整时间线效果,未转换旧片段。")
|
|
82
|
+
with open(path, encoding="utf-8") as stream:
|
|
83
|
+
document = json.load(stream)
|
|
84
|
+
api["require"](str(document.get("OTIO_SCHEMA", "")).startswith("Timeline.")
|
|
85
|
+
and str(document.get("tracks", {}).get("OTIO_SCHEMA", "")).startswith("Stack."),
|
|
86
|
+
"CAPABILITY_UNAVAILABLE", "时间线交换结构不完整,未转换旧片段。")
|
|
87
|
+
# DuplicateTimeline legitimately changes the timeline name, not its contents.
|
|
88
|
+
document.pop("name", None)
|
|
89
|
+
return document
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _capture(resolve, project, scope, timeline, api):
|
|
93
|
+
api["require_active_project"](resolve, project)
|
|
94
|
+
api["require"](not project.IsRenderingInProgress(), "RESOLVE_BUSY", "达芬奇正在渲染,未读取或转换生成器。")
|
|
95
|
+
with api["timeline_context"](resolve, timeline):
|
|
96
|
+
native, objects, tracks = [], {}, []
|
|
97
|
+
for kind in ("video", "audio", "subtitle"):
|
|
98
|
+
for track in range(1, timeline.GetTrackCount(kind) + 1):
|
|
99
|
+
tracks.append({"kind": kind, "index": track, "name": timeline.GetTrackName(kind, track),
|
|
100
|
+
"enabled": timeline.GetIsTrackEnabled(kind, track), "locked": timeline.GetIsTrackLocked(kind, track)})
|
|
101
|
+
items = sorted(timeline.GetItemListInTrack(kind, track) or [], key=lambda item: (item.GetStart(True), item.GetEnd(True), item.GetType(), item.GetUniqueId()))
|
|
102
|
+
for item in items:
|
|
103
|
+
value = _native(item, kind, track)
|
|
104
|
+
value["slot"] = len(native)
|
|
105
|
+
native.append(value)
|
|
106
|
+
objects[value["id"]] = item
|
|
107
|
+
media_index = {item.GetUniqueId(): item for item in api["all_media"](project.GetMediaPool().GetRootFolder())}
|
|
108
|
+
entries, sources = {}, {}
|
|
109
|
+
for value in native:
|
|
110
|
+
item = objects[value["id"]]
|
|
111
|
+
if value["trackType"] == "video" and not value["mediaPoolItemId"] and value["fusionCount"]:
|
|
112
|
+
entry = intrinsic.read_entry(item, media_index)
|
|
113
|
+
if entry:
|
|
114
|
+
entries[value["id"]] = entry
|
|
115
|
+
media_id = entry["identity"]["mediaPoolItemId"]
|
|
116
|
+
sources[media_id] = _media(media_index[media_id])
|
|
117
|
+
elif value["mediaPoolItemId"]:
|
|
118
|
+
sources[value["mediaPoolItemId"]] = _media(media_index[value["mediaPoolItemId"]])
|
|
119
|
+
settings = project.GetSettings() or {}
|
|
120
|
+
result = {"scope": scope, "timelineId": timeline.GetUniqueId(), "startFrame": timeline.GetStartFrame(),
|
|
121
|
+
"endFrame": timeline.GetEndFrame(), "settings": timeline.GetSettings(), "markers": timeline.GetMarkers(),
|
|
122
|
+
"outputBlanking": timeline.GetOutputBlanking(), "tracks": tracks, "items": native,
|
|
123
|
+
"entries": entries, "sources": sources, "otio": _otio(resolve, timeline, api),
|
|
124
|
+
"grades": api["grade_evidence"](resolve, timeline, list(objects.values()), set(entries)),
|
|
125
|
+
"projectSettings": {key: value for key, value in settings.items() if key.startswith(("color", "timeline", "node", "perf")) and not key.startswith("colorGallery")}}
|
|
126
|
+
api["require_active_project"](resolve, project)
|
|
127
|
+
api["require"](all(_native(objects[value["id"]], value["trackType"], value["track"]) == {key: val for key, val in value.items() if key != "slot"} for value in native),
|
|
128
|
+
"VERSION_CONFLICT", "读取过程中时间线被另一控制端改变。")
|
|
129
|
+
return _json(result), objects, media_index
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _semantic(state):
|
|
133
|
+
value = copy.deepcopy(state)
|
|
134
|
+
value.pop("timelineId")
|
|
135
|
+
slots = {item["id"]: item["slot"] for item in value["items"]}
|
|
136
|
+
value["entries"] = {str(slots[key]): entry for key, entry in value["entries"].items()}
|
|
137
|
+
value["grades"] = {str(slots[key]): grade for key, grade in value["grades"].items()}
|
|
138
|
+
for item in value["items"]:
|
|
139
|
+
item.pop("id")
|
|
140
|
+
item["linked"] = sorted((slots.get(link, "outside:" + link) for link in item["linked"]), key=str)
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _clip(state, native, api):
|
|
145
|
+
rate = api["fps_value"](state["settings"]["timelineFrameRate"])
|
|
146
|
+
fps = rate["num"] / rate["den"]
|
|
147
|
+
video_track, found = 0, []
|
|
148
|
+
for track in state["otio"]["tracks"].get("children", []):
|
|
149
|
+
if track.get("kind") != "Video":
|
|
150
|
+
continue
|
|
151
|
+
video_track += 1
|
|
152
|
+
if video_track != native["track"]:
|
|
153
|
+
continue
|
|
154
|
+
cursor, children = state["startFrame"], track.get("children", [])
|
|
155
|
+
for index, child in enumerate(children):
|
|
156
|
+
kind = str(child.get("OTIO_SCHEMA", "")).split(".")[0]
|
|
157
|
+
duration = (child.get("source_range") or {}).get("duration")
|
|
158
|
+
frames = duration["value"] / duration["rate"] * fps if duration and duration.get("rate") else None
|
|
159
|
+
if kind == "Clip" and frames is not None and abs(cursor - native["startFrame"]) < .001 and abs(frames - native["duration"]) < .001:
|
|
160
|
+
adjacent = any(str(children[neighbor].get("OTIO_SCHEMA", "")).startswith("Transition.")
|
|
161
|
+
for neighbor in (index - 1, index + 1) if 0 <= neighbor < len(children))
|
|
162
|
+
found.append({"value": child, "track": track, "index": index, "adjacentTransition": adjacent})
|
|
163
|
+
if kind != "Transition":
|
|
164
|
+
if frames is None or not math.isfinite(frames):
|
|
165
|
+
return None
|
|
166
|
+
cursor += frames
|
|
167
|
+
return found[0] if len(found) == 1 else None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _effects_reasons(clip, source_in=0):
|
|
171
|
+
reasons = []
|
|
172
|
+
for effect in clip.get("effects", []):
|
|
173
|
+
data = effect.get("metadata", {}).get("Resolve_OTIO", {})
|
|
174
|
+
name, parameters = data.get("Effect Name"), data.get("Parameters", [])
|
|
175
|
+
if not str(effect.get("OTIO_SCHEMA", "")).startswith("Effect.") or effect.get("effect_name") != "Resolve Effect":
|
|
176
|
+
reasons.append(("legacy-retime-or-effect", "存在不能无损复制的时间效果或额外效果。")); continue
|
|
177
|
+
if name == "Dynamic Zoom":
|
|
178
|
+
if data.get("Enabled") is not False:
|
|
179
|
+
reasons.append(("legacy-dynamic-zoom", "动态缩放含原生曲线,当前保留旧结构。"))
|
|
180
|
+
else:
|
|
181
|
+
# The host exports default curve offsets relative to the trim.
|
|
182
|
+
left, right = str(-int(source_in)), str(1000 - int(source_in))
|
|
183
|
+
defaults = {"dynamicZoomCenter": {left: [0.0, 0.0], right: [0.0, 0.0]}, "dynamicZoomScale": {left: 0.8, right: 1.0}}
|
|
184
|
+
if any(parameter.get("Parameter ID") not in defaults or
|
|
185
|
+
{str(key): frame.get("Value") for key, frame in parameter.get("Key Frames", {}).items()} != defaults[parameter["Parameter ID"]]
|
|
186
|
+
for parameter in parameters):
|
|
187
|
+
reasons.append(("legacy-disabled-animation", "已停用的动态缩放仍有自定义曲线,不能丢弃。"))
|
|
188
|
+
continue
|
|
189
|
+
if name in _EMPTY_EFFECTS and parameters:
|
|
190
|
+
reasons.append(("legacy-hidden-parameters", "源或沉浸式效果包含公开属性无法复制的参数。"))
|
|
191
|
+
elif name not in _STATIC_EFFECTS | _EMPTY_EFFECTS:
|
|
192
|
+
reasons.append(("legacy-extra-effect", "存在未支持的效果:" + str(name)))
|
|
193
|
+
if any(parameter.get("Key Frames") for parameter in parameters):
|
|
194
|
+
reasons.append(("legacy-inspector-keyframes", "检查器包含关键帧,公开静态属性不能完整迁移。"))
|
|
195
|
+
return reasons
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _properties_match(before, after):
|
|
199
|
+
return (all(key in after and after[key] == value for key, value in before.items())
|
|
200
|
+
and all(key in _VIDEO_DEFAULTS and value == _VIDEO_DEFAULTS[key] for key, value in after.items() if key not in before))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _default_versions(item):
|
|
204
|
+
# 21.1 reports one default remote-version placeholder even for generators
|
|
205
|
+
# with no MediaPoolItem. The exported seed retains its English placeholder.
|
|
206
|
+
defaults = {"Version1", "Version 1", "版本1", "版本 1"}
|
|
207
|
+
current, versions = item.get("version") or {}, item.get("versions") or {}
|
|
208
|
+
local, remote = versions.get("local") or [], versions.get("remote") or []
|
|
209
|
+
return (current.get("versionType") == 0 and current.get("versionName") in defaults
|
|
210
|
+
and local == [current["versionName"]] and len(remote) <= 1 and all(name in defaults for name in remote))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _default_stereo(values, source_in):
|
|
214
|
+
if not isinstance(values, list) or len(values) != 3:
|
|
215
|
+
return False
|
|
216
|
+
offset = str(int(source_in))
|
|
217
|
+
window = {"left": 0, "right": 0, "top": 0, "bottom": 0}
|
|
218
|
+
return (values[0] in ({}, {offset: 0}) and values[1] in ({}, {offset: window})
|
|
219
|
+
and values[2] in ({}, {offset: window}))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _shifted_markers(markers, source_in):
|
|
223
|
+
# On the verified host generator marker keys are relative to its trim,
|
|
224
|
+
# whereas ordinary media marker keys are source-frame coordinates. OTIO
|
|
225
|
+
# uses the latter for both and independently verifies the conversion.
|
|
226
|
+
result = {}
|
|
227
|
+
for frame, marker in markers.items():
|
|
228
|
+
value = float(frame) + source_in
|
|
229
|
+
result[str(int(value)) if value.is_integer() else str(value)] = marker
|
|
230
|
+
return result
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _clip_processing(value, original=None):
|
|
234
|
+
result = copy.deepcopy(value)
|
|
235
|
+
for key in ("media_reference", "media_references", "active_media_reference_key"):
|
|
236
|
+
result.pop(key, None)
|
|
237
|
+
if original is not None:
|
|
238
|
+
original_names = {effect.get("metadata", {}).get("Resolve_OTIO", {}).get("Effect Name") for effect in original.get("effects", [])}
|
|
239
|
+
def newly_exposed_default(effect):
|
|
240
|
+
metadata = effect.get("metadata", {}).get("Resolve_OTIO", {})
|
|
241
|
+
return (str(effect.get("OTIO_SCHEMA", "")).startswith("Effect.") and effect.get("effect_name") == "Resolve Effect"
|
|
242
|
+
and metadata.get("Effect Name") in {"Lens Correction", "Retime and Scaling"} - original_names
|
|
243
|
+
and metadata.get("Enabled") is True and metadata.get("Parameters") == [])
|
|
244
|
+
result["effects"] = [effect for effect in result.get("effects", []) if not newly_exposed_default(effect)]
|
|
245
|
+
return result
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _copy_safety(state, convert_ids):
|
|
249
|
+
"""A whole-timeline duplicate must preserve every item, including unselected ones."""
|
|
250
|
+
fusion_items, blockers = [], []
|
|
251
|
+
for item in state["items"]:
|
|
252
|
+
identity = {"itemId": item["id"], "name": item["name"], "trackType": item["trackType"], "track": item["track"]}
|
|
253
|
+
if item.get("fusionCount"):
|
|
254
|
+
fusion_items.append(identity)
|
|
255
|
+
if item["id"] not in convert_ids:
|
|
256
|
+
blockers.append({**identity, "code": "legacy-unconverted-fusion",
|
|
257
|
+
"message": "此 Fusion 片段不会在本次转换;尚不能保证副本保存重开后保留其时间映射,原时间线保持,不创建副本。"})
|
|
258
|
+
fades = item.get("fades") or {}
|
|
259
|
+
if item["trackType"] == "video" and any(fades.get(key, 0) != 0 for key in ("FadeIn", "FadeOut")):
|
|
260
|
+
blockers.append({**identity, "code": "legacy-fades-persistence-unverified", "fades": fades,
|
|
261
|
+
"message": "此片段含非零视频淡入淡出;当前宿主 SetFades 的保存重开行为未通过验证,不自动复制转换或补写。"})
|
|
262
|
+
return {"supported": not blockers, "fusionItems": fusion_items, "blockers": blockers}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _plan(resolve, project, scope, params, api):
|
|
266
|
+
timeline = api["timeline_for"](project, params["timelineId"])
|
|
267
|
+
state, objects, media_index = _capture(resolve, project, scope, timeline, api)
|
|
268
|
+
selected = set(params.get("itemIds") or [])
|
|
269
|
+
api["require"](not selected or selected <= set(objects), "ITEM_MISSING", "预览指定的实例不存在,未按名称猜测。")
|
|
270
|
+
candidates = []
|
|
271
|
+
for item in state["items"]:
|
|
272
|
+
if item["trackType"] != "video" or (selected and item["id"] not in selected):
|
|
273
|
+
continue
|
|
274
|
+
entry = state["entries"].get(item["id"])
|
|
275
|
+
if not selected and (item["mediaPoolItemId"] or not item["fusionCount"]):
|
|
276
|
+
continue
|
|
277
|
+
reasons = []
|
|
278
|
+
if not entry:
|
|
279
|
+
reasons.append(("legacy-identity-unrecognized", "不是完整识别的 Hotion Harness 旧生成器,保持原结构。"))
|
|
280
|
+
candidate = {"itemId": item["id"], "name": item["name"], "track": item["track"], "startFrame": item["startFrame"], "endFrame": item["endFrame"]}
|
|
281
|
+
if entry:
|
|
282
|
+
identity = entry["identity"]
|
|
283
|
+
media = state["sources"][identity["mediaPoolItemId"]]
|
|
284
|
+
candidate.update(identity=identity, media={**media, "fingerprint": api["fingerprint"](media)})
|
|
285
|
+
comp = objects[item["id"]].GetFusionCompByIndex(1)
|
|
286
|
+
attrs = comp.GetAttrs()
|
|
287
|
+
start, end = attrs["COMPN_GlobalStart"], attrs["COMPN_GlobalEnd"] + 1
|
|
288
|
+
candidate.update(sourceIn=start, sourceOut=end)
|
|
289
|
+
if any(identity.get(key) != value for key, value in scope.items()):
|
|
290
|
+
reasons.append(("legacy-scope-conflict", "生成器身份不属于当前数据库和工程。"))
|
|
291
|
+
if not api["validated_host"](resolve):
|
|
292
|
+
reasons.append(("legacy-host-unverified", "当前宿主版本或平台未验证生成器帧边界。"))
|
|
293
|
+
props = media["properties"]
|
|
294
|
+
if (not all(isinstance(item.get(key), dict) for key in ("properties", "speed", "fades", "markers", "version"))
|
|
295
|
+
or not isinstance(item.get("enabled"), bool) or not isinstance(item.get("timelineBlanking"), bool)):
|
|
296
|
+
reasons.append(("legacy-native-properties-unavailable", "生成器的静态属性或状态不能完整读取,未尝试删除替换。"))
|
|
297
|
+
if (item.get("speed") or {}).get("Percentage") != 100 or end - start != item["duration"]:
|
|
298
|
+
reasons.append(("legacy-time-map", "只支持原速且源区间与时间线时长一致的生成器。"))
|
|
299
|
+
if not all(isinstance(value, (int, float)) and math.isfinite(value) and int(value) == value for value in (start, end, item["startFrame"], item["endFrame"])):
|
|
300
|
+
reasons.append(("legacy-subframe", "包含尚未验证的亚帧边界。"))
|
|
301
|
+
if api["fps_value"](props["FPS"]) != api["fps_value"](state["settings"]["timelineFrameRate"]):
|
|
302
|
+
reasons.append(("legacy-cross-fps", "旧生成器与时间线帧率不同,保持原结构。"))
|
|
303
|
+
if str(props.get("Audio Ch", "0")) not in ("", "0") or item["linked"]:
|
|
304
|
+
reasons.append(("legacy-linked-audio", "存在源音频或联动实例,不能只替换视频。"))
|
|
305
|
+
if not os.path.isfile(media["path"]) or props.get("Proxy Media Path"):
|
|
306
|
+
reasons.append(("legacy-media-unavailable", "原视频离线或附带代理,无法核对真实源。"))
|
|
307
|
+
if item["takes"]:
|
|
308
|
+
reasons.append(("legacy-take-selector", "已有 Take 选择器,保持原结构。"))
|
|
309
|
+
if any(track["locked"] for track in state["tracks"] if track["kind"] == "video" and track["index"] == item["track"]):
|
|
310
|
+
reasons.append(("legacy-track-locked", "轨道已锁定,复制时间线也保持锁定。"))
|
|
311
|
+
if any(item.get("gradeTools", [])) or item.get("colorGroup") or str(state["projectSettings"].get("nodeStackLayers", "1")) != "1":
|
|
312
|
+
reasons.append(("legacy-color-processing", "存在调色节点、组或多层节点,无法完整核对其关键帧。"))
|
|
313
|
+
if not state["grades"].get(item["id"], {}).get("identity"):
|
|
314
|
+
reasons.append(("legacy-nonidentity-grade", "调色 LUT 不是恒等变换;当前保留旧结构,避免丢失主色轮或未可完整读取的调色。"))
|
|
315
|
+
if state["grades"].get(item["id"], {}).get("unsupported"):
|
|
316
|
+
reasons.append(("legacy-grade-unverified", "当前调色不能完整核对,保持旧结构。"))
|
|
317
|
+
if not _default_versions(item):
|
|
318
|
+
reasons.append(("legacy-color-versions", "包含多个或远程调色版本,不能丢弃版本。"))
|
|
319
|
+
if not _default_stereo(item.get("stereo"), start):
|
|
320
|
+
reasons.append(("legacy-stereo-animation", "包含立体窗口或汇聚设置,保持原结构。"))
|
|
321
|
+
if (item.get("properties") or {}).get("Distortion"):
|
|
322
|
+
reasons.append(("legacy-lens-correction", "非默认镜头校正尚未验证。"))
|
|
323
|
+
clip = _clip(state, item, api)
|
|
324
|
+
if not clip:
|
|
325
|
+
reasons.append(("legacy-otio-coverage", "无法将实例唯一对应到公开时间线结构。"))
|
|
326
|
+
else:
|
|
327
|
+
if clip["adjacentTransition"]:
|
|
328
|
+
reasons.append(("legacy-adjacent-transition", "相邻转场不能通过当前替换方式完整保留,生成器与转场一起保留。"))
|
|
329
|
+
reasons.extend(_effects_reasons(clip["value"], start))
|
|
330
|
+
candidate["reasons"] = [{"code": code, "message": message} for code, message in sorted(set(reasons))]
|
|
331
|
+
candidate["status"] = "keep" if reasons else "convert"
|
|
332
|
+
candidates.append(candidate)
|
|
333
|
+
copy_safety = _copy_safety(state, {item["itemId"] for item in candidates if item["status"] == "convert"})
|
|
334
|
+
return {"scope": scope, "timelineId": timeline.GetUniqueId(), "name": timeline.GetName(),
|
|
335
|
+
"fingerprint": api["fingerprint"](state), "candidates": candidates, "copySafety": copy_safety, "limits": _LIMITS,
|
|
336
|
+
"originalPreserved": True}, state, objects, media_index
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def preview(resolve, project, scope, params, api):
|
|
340
|
+
return _plan(resolve, project, scope, params, api)[0]
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _journal_path(params, api):
|
|
344
|
+
scratch = _scratch(api)
|
|
345
|
+
path = os.path.realpath(params.get("nativeJournal", ""))
|
|
346
|
+
api["require"](os.path.dirname(path) == scratch and isinstance(params.get("nativeToken"), str) and params["nativeToken"],
|
|
347
|
+
"INVALID_REQUEST", "原生迁移记录必须位于当前桥接工作目录。")
|
|
348
|
+
return path
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def check(resolve, project, scope, params, api):
|
|
352
|
+
path = _journal_path(params, api)
|
|
353
|
+
if not os.path.exists(path):
|
|
354
|
+
current = preview(resolve, project, scope, params, api)
|
|
355
|
+
unchanged = current["fingerprint"] == params["expectedFingerprint"]
|
|
356
|
+
return {"completed": False, "canResume": unchanged, "originalPreserved": unchanged, "stage": "not-started", "imports": [], "mappings": []}
|
|
357
|
+
with open(path, encoding="utf-8") as stream:
|
|
358
|
+
record = json.load(stream)
|
|
359
|
+
api["require"](record.get("token") == params["nativeToken"] and record.get("scope") == scope and record.get("originalTimelineId") == params["timelineId"],
|
|
360
|
+
"RECOVERY_CONFLICT", "迁移记录身份与当前任务不一致。")
|
|
361
|
+
original = api["timeline_for"](project, params["timelineId"])
|
|
362
|
+
original_state = _capture(resolve, project, scope, original, api)[0]
|
|
363
|
+
original_matches = api["fingerprint"](original_state) == record["originalFingerprint"]
|
|
364
|
+
copies = [timeline for timeline in api["timelines"](project) if timeline and timeline.GetUniqueId() == record.get("timelineId")]
|
|
365
|
+
copied = copies[0] if len(copies) == 1 else None
|
|
366
|
+
copy_matches = bool(copied and record.get("checkpoint") and _capture(resolve, project, scope, copied, api)[0] == record["checkpoint"])
|
|
367
|
+
completed = record.get("stage") == "completed" and original_matches and copy_matches
|
|
368
|
+
return {"completed": completed, "canResume": completed, "stage": record.get("stage"), "originalPreserved": original_matches,
|
|
369
|
+
"checkpointMatches": copy_matches, "timelineId": record.get("timelineId"), "result": record.get("result") if completed else None,
|
|
370
|
+
"imports": record.get("imports", []), "mappings": record.get("mappings", []),
|
|
371
|
+
"copyMissing": bool(record.get("timelineId") and not copied),
|
|
372
|
+
"unattributedTimelineIds": [timeline.GetUniqueId() for timeline in api["timelines"](project)
|
|
373
|
+
if timeline and timeline.GetUniqueId() not in record.get("previousTimelineIds", [])] if not record.get("timelineId") else [],
|
|
374
|
+
"nextStep": "原时间线保留;未完整结束的隔离副本需核对,不自动重放删除或插入。"}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def convert(resolve, project, scope, params, api):
|
|
378
|
+
require, failure = api["require"], api["BridgeFailure"]
|
|
379
|
+
path = _journal_path(params, api)
|
|
380
|
+
if os.path.exists(path):
|
|
381
|
+
existing = check(resolve, project, scope, params, api)
|
|
382
|
+
require(existing["completed"], "RECOVERY_REQUIRED", "已有迁移记录,未创建第二个副本;请先检查原任务。", native=existing)
|
|
383
|
+
return existing["result"]
|
|
384
|
+
plan, original_state, _original_objects, media_index = _plan(resolve, project, scope, params, api)
|
|
385
|
+
require(plan["fingerprint"] == params["expectedFingerprint"], "VERSION_CONFLICT", "预览后的原时间线已改变,未创建副本。")
|
|
386
|
+
wanted = set(params.get("convertItemIds") or [])
|
|
387
|
+
candidates = {item["itemId"]: item for item in plan["candidates"] if item["itemId"] in wanted}
|
|
388
|
+
require(0 < len(wanted) <= 20 and len(candidates) == len(wanted) and all(item["status"] == "convert" for item in candidates.values()),
|
|
389
|
+
"UNSUPPORTED_TIMELINE_ITEM", "请求包含无法完整迁移的生成器,未创建副本。")
|
|
390
|
+
# Recheck the final request, not only the preview's eligible candidates:
|
|
391
|
+
# missing catalog records can remove an otherwise eligible Fusion item.
|
|
392
|
+
copy_safety = _copy_safety(original_state, wanted)
|
|
393
|
+
require(copy_safety["supported"], "UNSUPPORTED_TIMELINE_ITEM",
|
|
394
|
+
"整条时间线的副本无法保证保留,原时间线保持;未复制时间线或导入媒体。", copySafety=copy_safety)
|
|
395
|
+
replacements = {item["oldBindingId"]: item for item in params["replacements"]}
|
|
396
|
+
require(set(replacements) == {item["identity"]["bindingId"] for item in candidates.values()}, "INVALID_REQUEST", "新媒体清单与预览范围不一致。")
|
|
397
|
+
for replacement in replacements.values():
|
|
398
|
+
require(all(candidate["identity"]["mediaPoolItemId"] == replacement["oldMediaPoolItemId"]
|
|
399
|
+
and candidate["identity"]["sourceId"] == replacement["sourceId"]
|
|
400
|
+
for candidate in candidates.values() if candidate["identity"]["bindingId"] == replacement["oldBindingId"]),
|
|
401
|
+
"INVALID_REQUEST", "新媒体清单与旧生成器的源身份不一致。")
|
|
402
|
+
old = media_index[replacement["oldMediaPoolItemId"]]
|
|
403
|
+
props = old.GetClipProperty()
|
|
404
|
+
require(os.path.isabs(replacement["filePath"]) and os.path.realpath(props["File Path"]) != os.path.realpath(replacement["filePath"])
|
|
405
|
+
and file_digest(props["File Path"]) == replacement["sha256"] == file_digest(replacement["filePath"]),
|
|
406
|
+
"ARTIFACT_CHANGED", "新旧视频不是独立且相同的不可变内容,未转换。")
|
|
407
|
+
link = json.loads(replacement["metadata"]["motionharness.bridge"])
|
|
408
|
+
require(link == {"schemaVersion": 1, "sourceId": replacement["sourceId"], "bindingId": replacement["bindingId"], "artifactId": replacement["artifactId"]}
|
|
409
|
+
and replacement["bindingId"] != replacement["oldBindingId"], "INVALID_REQUEST", "新关联身份无效。")
|
|
410
|
+
require(not any(os.path.realpath(media.GetClipProperty("File Path") or "") == os.path.realpath(replacement["filePath"]) for media in media_index.values()),
|
|
411
|
+
"TARGET_OCCUPIED", "新媒体文件已有原生对象,未重复导入。")
|
|
412
|
+
original = api["timeline_for"](project, params["timelineId"])
|
|
413
|
+
names = {timeline.GetName() for timeline in api["timelines"](project) if timeline}
|
|
414
|
+
name = params.get("name")
|
|
415
|
+
if name:
|
|
416
|
+
require(name not in names, "TARGET_OCCUPIED", "隔离副本名称已经存在,请选择新名称;未调用 DuplicateTimeline。")
|
|
417
|
+
else:
|
|
418
|
+
base = plan["name"] + " · Hotion Harness 转换"
|
|
419
|
+
name, suffix = base, 2
|
|
420
|
+
while name in names:
|
|
421
|
+
name = base + " " + str(suffix)
|
|
422
|
+
suffix += 1
|
|
423
|
+
previous = project.GetCurrentTimeline()
|
|
424
|
+
record = {"schemaVersion": 1, "token": params["nativeToken"], "scope": scope, "originalTimelineId": original.GetUniqueId(),
|
|
425
|
+
"originalFingerprint": plan["fingerprint"], "stage": "duplicate-intent", "mappings": [], "imports": [],
|
|
426
|
+
"previousTimelineIds": [timeline.GetUniqueId() for timeline in api["timelines"](project) if timeline]}
|
|
427
|
+
atomic_json(path, record, allow_nan=False)
|
|
428
|
+
copied = None
|
|
429
|
+
try:
|
|
430
|
+
copied = original.DuplicateTimeline(name)
|
|
431
|
+
require(copied and copied.GetUniqueId() != original.GetUniqueId(), "APPLY_OUTCOME_UNKNOWN", "DuplicateTimeline 没有返回独立副本;原时间线未作为修改目标。")
|
|
432
|
+
record.update(timelineId=copied.GetUniqueId(), stage="duplicated")
|
|
433
|
+
atomic_json(path, record, allow_nan=False)
|
|
434
|
+
state, objects, _ = _capture(resolve, project, scope, copied, api)
|
|
435
|
+
require(_semantic(state) == _semantic(original_state), "APPLY_OUTCOME_UNKNOWN", "复制时间线的结构与原时间线不一致,未删除任何片段。")
|
|
436
|
+
copied_ids = {old["id"]: new["id"] for old, new in zip(original_state["items"], state["items"])}
|
|
437
|
+
record.update(checkpoint=state, copiedItemIds=copied_ids)
|
|
438
|
+
atomic_json(path, record, allow_nan=False)
|
|
439
|
+
require(project.SetCurrentTimeline(copied), "CAPABILITY_UNAVAILABLE", "无法选择隔离副本。")
|
|
440
|
+
api["await_timeline"](project, copied, resolve=resolve)
|
|
441
|
+
pool, imported = project.GetMediaPool(), {}
|
|
442
|
+
|
|
443
|
+
def guard():
|
|
444
|
+
api["require_active_project"](resolve, project)
|
|
445
|
+
require(project.GetCurrentTimeline().GetUniqueId() == copied.GetUniqueId() and not project.IsRenderingInProgress(),
|
|
446
|
+
"VERSION_CONFLICT", "转换期间当前时间线或渲染状态改变,未继续修改。")
|
|
447
|
+
require(_capture(resolve, project, scope, original, api)[0] == original_state, "VERSION_CONFLICT", "原时间线被另一控制端改变,已停止转换。")
|
|
448
|
+
current = _capture(resolve, project, scope, copied, api)[0]
|
|
449
|
+
require(current == record["checkpoint"], "RECOVERY_CONFLICT", "隔离副本与操作记录不同,未覆盖其他修改。")
|
|
450
|
+
|
|
451
|
+
def step(label, action):
|
|
452
|
+
guard()
|
|
453
|
+
record.update(stage=label + "-intent")
|
|
454
|
+
atomic_json(path, record, allow_nan=False)
|
|
455
|
+
result = action()
|
|
456
|
+
record.update(stage=label, checkpoint=_capture(resolve, project, scope, copied, api)[0])
|
|
457
|
+
atomic_json(path, record, allow_nan=False)
|
|
458
|
+
require(result, "APPLY_OUTCOME_UNKNOWN", "原生转换步骤未确认成功,已保留原时间线及隔离副本。", stage=label, timelineId=copied.GetUniqueId())
|
|
459
|
+
return result
|
|
460
|
+
|
|
461
|
+
for old_binding, replacement in replacements.items():
|
|
462
|
+
result = step("import-" + replacement["bindingId"], lambda: pool.ImportMedia([replacement["filePath"]]))
|
|
463
|
+
require(isinstance(result, list) and len(result) == 1 and result[0].GetUniqueId() not in media_index,
|
|
464
|
+
"APPLY_OUTCOME_UNKNOWN", "新视频没有得到独立媒体池身份。")
|
|
465
|
+
media = result[0]
|
|
466
|
+
imported[old_binding] = media
|
|
467
|
+
record["imports"].append({"bindingId": replacement["bindingId"], "mediaPoolItemId": media.GetUniqueId()})
|
|
468
|
+
atomic_json(path, record, allow_nan=False)
|
|
469
|
+
old_props = media_index[replacement["oldMediaPoolItemId"]].GetClipProperty()
|
|
470
|
+
def identify():
|
|
471
|
+
for key in _INTERPRETATION:
|
|
472
|
+
value = old_props.get(key)
|
|
473
|
+
# The individual Field Dominance getter reports resolved
|
|
474
|
+
# "Progressive" while the full table preserves "Auto".
|
|
475
|
+
if value is not None and (media.GetClipProperty() or {}).get(key) != value:
|
|
476
|
+
require(media.SetClipProperty(key, value) and (media.GetClipProperty() or {}).get(key) == value,
|
|
477
|
+
"APPLY_OUTCOME_UNKNOWN", "新视频解释属性没有完整保留。", property=key)
|
|
478
|
+
return media.SetThirdPartyMetadata(replacement["metadata"])
|
|
479
|
+
step("identify-" + replacement["bindingId"], identify)
|
|
480
|
+
require(media.GetThirdPartyMetadata() == replacement["metadata"], "APPLY_OUTCOME_UNKNOWN", "新媒体关联读回失败。")
|
|
481
|
+
|
|
482
|
+
for old_id, candidate in candidates.items():
|
|
483
|
+
original_item = next(item for item in original_state["items"] if item["id"] == old_id)
|
|
484
|
+
copied_id = copied_ids[old_id]
|
|
485
|
+
old_item = objects[copied_id]
|
|
486
|
+
replacement = replacements[candidate["identity"]["bindingId"]]
|
|
487
|
+
media = imported[replacement["oldBindingId"]]
|
|
488
|
+
step("delete-" + old_id, lambda: copied.DeleteClips([old_item], False))
|
|
489
|
+
require(copied.GetUniqueId() != original.GetUniqueId(), "APPLY_OUTCOME_UNKNOWN", "原时间线不能作为插入目标。")
|
|
490
|
+
inserted = step("append-" + old_id, lambda: pool.AppendToTimeline([{"mediaPoolItem": media,
|
|
491
|
+
# The verified 21.1 AppendToTimeline convention is half open;
|
|
492
|
+
# it differs from AddTake's inclusive end on the same host.
|
|
493
|
+
"startFrame": candidate["sourceIn"], "endFrame": candidate["sourceOut"],
|
|
494
|
+
"mediaType": 1, "trackIndex": candidate["track"], "recordFrame": candidate["startFrame"]}]))
|
|
495
|
+
require(isinstance(inserted, list) and len(inserted) == 1, "APPLY_OUTCOME_UNKNOWN", "原生插入没有返回唯一普通视频。")
|
|
496
|
+
new_item = inserted[0]
|
|
497
|
+
require(new_item.GetMediaPoolItem() and new_item.GetMediaPoolItem().GetUniqueId() == media.GetUniqueId()
|
|
498
|
+
and new_item.GetFusionCompCount() == 0, "APPLY_OUTCOME_UNKNOWN", "转换结果不是普通媒体片段。")
|
|
499
|
+
mapping = {"oldItemId": old_id, "copiedItemId": copied_id, "newItemId": new_item.GetUniqueId(),
|
|
500
|
+
"oldBindingId": replacement["oldBindingId"], "bindingId": replacement["bindingId"],
|
|
501
|
+
"mediaPoolItemId": media.GetUniqueId(), "verified": False}
|
|
502
|
+
record["mappings"].append(mapping)
|
|
503
|
+
atomic_json(path, record, allow_nan=False)
|
|
504
|
+
def configure():
|
|
505
|
+
checkpoint = _native(new_item, "video", candidate["track"])
|
|
506
|
+
def write(label, action):
|
|
507
|
+
nonlocal checkpoint
|
|
508
|
+
api["require_active_project"](resolve, project)
|
|
509
|
+
require(project.GetCurrentTimeline().GetUniqueId() == copied.GetUniqueId()
|
|
510
|
+
and _native(new_item, "video", candidate["track"]) == checkpoint,
|
|
511
|
+
"RECOVERY_CONFLICT", "配置普通片段期间实例或上下文被修改。")
|
|
512
|
+
require(action(), "APPLY_OUTCOME_UNKNOWN", "普通片段属性写入没有确认。", property=label)
|
|
513
|
+
checkpoint = _native(new_item, "video", candidate["track"])
|
|
514
|
+
if any(checkpoint["properties"].get(key) != value for key, value in original_item["properties"].items()):
|
|
515
|
+
write("properties", lambda: new_item.SetProperties(original_item["properties"]))
|
|
516
|
+
if checkpoint["fades"] != original_item["fades"]:
|
|
517
|
+
write("fades", lambda: new_item.SetFades(original_item["fades"]))
|
|
518
|
+
if checkpoint["name"] != original_item["name"]:
|
|
519
|
+
write("name", lambda: new_item.SetName(original_item["name"]))
|
|
520
|
+
if checkpoint["enabled"] != original_item["enabled"]:
|
|
521
|
+
write("enabled", lambda: new_item.SetClipEnabled(original_item["enabled"]))
|
|
522
|
+
if original_item.get("timelineBlanking") is not None:
|
|
523
|
+
if checkpoint["timelineBlanking"] != original_item["timelineBlanking"]:
|
|
524
|
+
write("blanking-mode", lambda: new_item.SetUseTimelineForOutputBlanking(original_item["timelineBlanking"]))
|
|
525
|
+
if not original_item["timelineBlanking"] and checkpoint["outputBlanking"] != original_item["outputBlanking"]:
|
|
526
|
+
write("blanking", lambda: new_item.SetOutputBlanking(original_item["outputBlanking"]))
|
|
527
|
+
for frame, marker in original_item["markers"].items():
|
|
528
|
+
write("marker", lambda f=frame, m=marker: new_item.AddMarker(float(f) + candidate["sourceIn"], m["color"], m["name"], m.get("note", ""), m["duration"], m.get("customData", "")))
|
|
529
|
+
for flag in original_item["flags"]:
|
|
530
|
+
write("flag", lambda value=flag: new_item.AddFlag(value))
|
|
531
|
+
if original_item["clipColor"]:
|
|
532
|
+
write("clip-color", lambda: new_item.SetClipColor(original_item["clipColor"]))
|
|
533
|
+
return True
|
|
534
|
+
step("configure-" + old_id, configure)
|
|
535
|
+
actual = _native(new_item, "video", candidate["track"])
|
|
536
|
+
fixed = ("name", "startFrame", "endFrame", "duration", "fades", "enabled", "flags", "clipColor", "gradeTools", "colorGroup", "version", "outputBlanking", "timelineBlanking")
|
|
537
|
+
require(all(actual.get(key) == original_item.get(key) for key in fixed) and _properties_match(original_item["properties"], actual["properties"])
|
|
538
|
+
and _default_versions(actual) and _default_stereo(actual["stereo"], candidate["sourceIn"])
|
|
539
|
+
and actual["markers"] == _shifted_markers(original_item["markers"], candidate["sourceIn"]),
|
|
540
|
+
"APPLY_OUTCOME_UNKNOWN", "普通视频的区间或支持属性没有完整保留。", itemId=old_id)
|
|
541
|
+
rate = api["fps_value"](media.GetClipProperty("FPS"))
|
|
542
|
+
fps = rate["num"] / rate["den"]
|
|
543
|
+
require(actual["speed"].get("Percentage") == 100 and not actual["linked"] and actual["takes"] == 0
|
|
544
|
+
and abs(actual["sourceStartTime"] * fps - candidate["sourceIn"]) < .001
|
|
545
|
+
and abs(actual["sourceEndTime"] * fps - candidate["sourceOut"]) < .001,
|
|
546
|
+
"APPLY_OUTCOME_UNKNOWN", "普通视频源区间或采样读回不一致。", itemId=old_id)
|
|
547
|
+
mapping["verified"] = True
|
|
548
|
+
atomic_json(path, record, allow_nan=False)
|
|
549
|
+
|
|
550
|
+
final, final_objects, _ = _capture(resolve, project, scope, copied, api)
|
|
551
|
+
require(_capture(resolve, project, scope, original, api)[0] == original_state, "APPLY_OUTCOME_UNKNOWN", "原时间线的读回发生改变,已停止验收。")
|
|
552
|
+
expected = _semantic(original_state)
|
|
553
|
+
normalized = _semantic(final)
|
|
554
|
+
require(len(final["items"]) == len(original_state["items"]), "APPLY_OUTCOME_UNKNOWN", "转换后实例数量不一致。")
|
|
555
|
+
replaced_ids = {mapping["copiedItemId"] for mapping in record["mappings"]}
|
|
556
|
+
new_ids = {mapping["newItemId"] for mapping in record["mappings"]}
|
|
557
|
+
require({item["id"] for item in final["items"]} - new_ids == set(copied_ids.values()) - replaced_ids,
|
|
558
|
+
"APPLY_OUTCOME_UNKNOWN", "未转换的相邻实例身份发生变化。")
|
|
559
|
+
for mapping in record["mappings"]:
|
|
560
|
+
old_native = next(item for item in original_state["items"] if item["id"] == mapping["oldItemId"])
|
|
561
|
+
new_native = next(item for item in final["items"] if item["id"] == mapping["newItemId"])
|
|
562
|
+
require(new_native["slot"] == old_native["slot"], "APPLY_OUTCOME_UNKNOWN", "相邻实例顺序发生变化。")
|
|
563
|
+
slot = old_native["slot"]
|
|
564
|
+
normalized["items"][slot] = expected["items"][slot]
|
|
565
|
+
normalized["entries"][str(slot)] = expected["entries"][str(slot)]
|
|
566
|
+
before_clip, after_clip = _clip(original_state, old_native, api), _clip(final, new_native, api)
|
|
567
|
+
require(before_clip and after_clip, "APPLY_OUTCOME_UNKNOWN", "转换后无法唯一核对公开时间线结构。")
|
|
568
|
+
before_value, after_value = _clip_processing(before_clip["value"]), _clip_processing(after_clip["value"], before_clip["value"])
|
|
569
|
+
require(before_value == after_value, "APPLY_OUTCOME_UNKNOWN", "转换后的关键帧、效果、标记或公开源区间不一致。", itemId=mapping["oldItemId"])
|
|
570
|
+
# Compare every untouched track, clip, transition and source; only the
|
|
571
|
+
# acknowledged generator nodes and media references may differ.
|
|
572
|
+
normalized["sources"] = expected["sources"]
|
|
573
|
+
normalized["otio"] = copy.deepcopy(final["otio"])
|
|
574
|
+
normalized_state = {**final, "otio": normalized["otio"]}
|
|
575
|
+
for mapping in record["mappings"]:
|
|
576
|
+
old_native = next(item for item in original_state["items"] if item["id"] == mapping["oldItemId"])
|
|
577
|
+
new_native = next(item for item in final["items"] if item["id"] == mapping["newItemId"])
|
|
578
|
+
old_clip, new_clip = _clip(original_state, old_native, api), _clip(normalized_state, new_native, api)
|
|
579
|
+
new_clip["track"]["children"][new_clip["index"]] = copy.deepcopy(old_clip["value"])
|
|
580
|
+
require(normalized == expected, "APPLY_OUTCOME_UNKNOWN", "其他片段、轨道、转场或时间线设置没有完整保留。")
|
|
581
|
+
kept = [{"oldItemId": item["itemId"], "copiedItemId": copied_ids[item["itemId"]], "reasons": item["reasons"]}
|
|
582
|
+
for item in plan["candidates"] if item["itemId"] not in wanted]
|
|
583
|
+
native_media = []
|
|
584
|
+
for old_binding, media in imported.items():
|
|
585
|
+
props = media.GetClipProperty()
|
|
586
|
+
replacement = replacements[old_binding]
|
|
587
|
+
old_props = media_index[replacement["oldMediaPoolItemId"]].GetClipProperty()
|
|
588
|
+
require(all(props.get(key) == old_props.get(key) for key in _MEDIA_PROPERTIES if key not in ("File Path", "Clip Name"))
|
|
589
|
+
and os.path.realpath(props["File Path"]) == os.path.realpath(replacement["filePath"])
|
|
590
|
+
and file_digest(props["File Path"]) == replacement["sha256"] and not media.GetTimeline(),
|
|
591
|
+
"APPLY_OUTCOME_UNKNOWN", "新视频的文件内容、规格或媒体解释不一致。")
|
|
592
|
+
uses = [api["item_snapshot"](final_objects[mapping["newItemId"]], copied.GetUniqueId(), candidates[mapping["oldItemId"]]["track"], True)
|
|
593
|
+
for mapping in record["mappings"] if mapping["oldBindingId"] == old_binding]
|
|
594
|
+
value = {"scope": scope, "projectName": project.GetName(), "mediaPoolItemId": media.GetUniqueId(), "path": props["File Path"],
|
|
595
|
+
"metadata": media.GetThirdPartyMetadata(), "uses": uses, "usageScanComplete": False,
|
|
596
|
+
"clipProperties": {key: props.get(key) for key in _MEDIA_PROPERTIES}}
|
|
597
|
+
value["fingerprint"] = api["fingerprint"](value)
|
|
598
|
+
native_media.append(value)
|
|
599
|
+
result = {"originalTimelineId": original.GetUniqueId(), "timelineId": copied.GetUniqueId(), "name": copied.GetName(),
|
|
600
|
+
"originalPreserved": True, "structuralVerified": True, "pictureValidation": "review-required",
|
|
601
|
+
"sampleFrames": sorted({int(frame) for item in candidates.values() for frame in (item["startFrame"], (item["startFrame"] + item["endFrame"] - 1) // 2, item["endFrame"] - 1)}),
|
|
602
|
+
"mappings": record["mappings"], "kept": kept, "media": native_media,
|
|
603
|
+
"rollback": {"timelineId": original.GetUniqueId(), "method": "use-preserved-original", "overwritesLaterEdits": False}}
|
|
604
|
+
result = _json(result)
|
|
605
|
+
record.update(stage="completed", result=result, checkpoint=final)
|
|
606
|
+
atomic_json(path, record, allow_nan=False)
|
|
607
|
+
return result
|
|
608
|
+
except Exception as error:
|
|
609
|
+
if copied:
|
|
610
|
+
raise failure("APPLY_OUTCOME_UNKNOWN", "转换在隔离副本中停止;原时间线未作为修改目标,请核对原任务,勿重复创建。",
|
|
611
|
+
originalTimelineId=original.GetUniqueId(), timelineId=copied.GetUniqueId(), stage=record.get("stage"),
|
|
612
|
+
diagnostic=str(error), nativeDetails=getattr(error, "details", {})) from error
|
|
613
|
+
raise
|
|
614
|
+
finally:
|
|
615
|
+
active = resolve.GetProjectManager().GetCurrentProject()
|
|
616
|
+
current = project.GetCurrentTimeline() if active and active.GetUniqueId() == project.GetUniqueId() else None
|
|
617
|
+
if copied and current and current.GetUniqueId() == copied.GetUniqueId() and previous:
|
|
618
|
+
project.SetCurrentTimeline(previous)
|
|
619
|
+
api["await_timeline"](project, previous, resolve=resolve)
|