@motionharness/bridge-davinci 0.0.0-stage → 0.0.23

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.
@@ -0,0 +1,469 @@
1
+ """Detach one ordinary video instance through documented native Take APIs.
2
+
3
+ This is a journaled sequence, not an atomic Resolve transaction. An interrupted
4
+ host call without a matching persisted checkpoint requires inspection; recovery
5
+ never assumes that a new native state belongs to this request.
6
+ """
7
+
8
+ import copy
9
+ import json
10
+ import math
11
+ import os
12
+ import tempfile
13
+ import uuid
14
+
15
+ from runtime_io import atomic_json, file_digest
16
+
17
+
18
+ _INTERPRETATION = ("Alpha mode", "Input Color Space", "Input Gamma", "Data Level", "PAR", "Field Dominance")
19
+ _TARGET_VARIABLE = {"mediaId", "name", "speed", "sourceStart", "sourceEnd", "sourceStartTime", "sourceEndTime",
20
+ "leftOffset", "rightOffset", "takes", "selectedTake"}
21
+
22
+
23
+ def _native(item):
24
+ media = item.GetMediaPoolItem()
25
+ media_id = media.GetUniqueId() if media else None
26
+ takes = []
27
+ for index in range(1, item.GetTakesCount() + 1):
28
+ take = item.GetTakeByIndex(index)
29
+ takes.append({"mediaId": take["mediaPoolItem"].GetUniqueId(),
30
+ "startFrame": take["startFrame"], "endFrame": take["endFrame"]})
31
+ selected = item.GetSelectedTakeIndex()
32
+ # Resolve 21.1 returns zero after leaving and returning to a timeline even
33
+ # while the take list and its chosen media remain intact. A durable snapshot
34
+ # uses that unique media identity; ambiguous lists never get an inferred index.
35
+ matching_takes = [index + 1 for index, take in enumerate(takes) if take["mediaId"] == media_id]
36
+ if selected == 0 and len(matching_takes) == 1:
37
+ selected = matching_takes[0]
38
+ return {"id": item.GetUniqueId(), "kind": item.GetType(), "name": item.GetName(),
39
+ "mediaId": media_id,
40
+ "start": item.GetStart(True), "end": item.GetEnd(True), "duration": item.GetDuration(True),
41
+ "sourceStart": item.GetSourceStartFrame(), "sourceEnd": item.GetSourceEndFrame(),
42
+ "sourceStartTime": item.GetSourceStartTime(), "sourceEndTime": item.GetSourceEndTime(),
43
+ "speed": item.GetSpeed(), "properties": item.GetProperties(), "fades": item.GetFades(),
44
+ "leftOffset": item.GetLeftOffset(True), "rightOffset": item.GetRightOffset(True),
45
+ "markers": item.GetMarkers(), "enabled": item.GetClipEnabled(),
46
+ "fusionCount": item.GetFusionCompCount(), "linkedIds": sorted(link.GetUniqueId() for link in item.GetLinkedItems() or []),
47
+ "takes": takes, "selectedTake": selected}
48
+
49
+
50
+ def _media_native(media):
51
+ return {"id": media.GetUniqueId(), "properties": media.GetClipProperty() or {},
52
+ "metadata": media.GetThirdPartyMetadata() or {}}
53
+
54
+
55
+ def _all_media(folder):
56
+ yield from folder.GetClipList() or []
57
+ for child in folder.GetSubFolderList() or []:
58
+ yield from _all_media(child)
59
+
60
+
61
+ def _file_state(path):
62
+ stat = os.stat(path)
63
+ return {"device": stat.st_dev, "inode": stat.st_ino, "bytes": stat.st_size, "modifiedNs": stat.st_mtime_ns}
64
+
65
+
66
+ class _Transaction:
67
+ def __init__(self, resolve, project, scope, params, api):
68
+ self.resolve, self.project, self.scope, self.params, self.api = resolve, project, scope, params, api
69
+ self.require, self.failure = api["require"], api["BridgeFailure"]
70
+ self.fingerprint = api["fingerprint"]
71
+ self.pool = project.GetMediaPool()
72
+ self.timeline = api["timeline_for"](project, params["timelineId"])
73
+ found = [(item, track) for track in range(1, self.timeline.GetTrackCount("video") + 1)
74
+ for item in self.timeline.GetItemListInTrack("video", track) or [] if item.GetUniqueId() == params["itemId"]]
75
+ self.require(len(found) == 1, "ITEM_MISSING", "没有找到指定的视频实例,未按名称猜测。")
76
+ self.item, self.track = found[0]
77
+ self.old_media = api["media_for"](project, params["oldMediaPoolItemId"])
78
+ self.new_media = None
79
+ self.path = os.path.realpath(params["newFilePath"])
80
+ scratch = os.environ.get("MH_BRIDGE_SCRATCH", "")
81
+ self.require(os.path.isabs(scratch), "INVALID_REQUEST", "缺少桥接原生操作工作目录。")
82
+ self.scratch = os.path.realpath(scratch)
83
+ self.journal_path = params["nativeJournal"]
84
+ self.require(os.path.isabs(self.journal_path) and not os.path.islink(self.journal_path)
85
+ and os.path.dirname(os.path.realpath(self.journal_path)) == self.scratch
86
+ and os.path.basename(self.journal_path).startswith("native-instance-"),
87
+ "INVALID_REQUEST", "独立实例记录必须位于桥接工作目录。")
88
+ try:
89
+ token = uuid.UUID(params["nativeToken"])
90
+ except (ValueError, TypeError, AttributeError):
91
+ token = None
92
+ self.require(token and token.version == 4, "INVALID_REQUEST", "原生操作令牌无效。")
93
+ self.identity = {"token": str(token), "scope": scope, "timelineId": params["timelineId"], "itemId": params["itemId"],
94
+ "oldMediaPoolItemId": params["oldMediaPoolItemId"], "newFilePath": self.path,
95
+ "newMetadata": params["newMetadata"], "sourceName": params["sourceName"], "outputSpec": params["outputSpec"],
96
+ "expectedTimelineFingerprint": params["expectedTimelineFingerprint"],
97
+ "expectedMediaFingerprint": params["expectedMediaFingerprint"]}
98
+ self.record = None
99
+
100
+ def _active(self):
101
+ self.api["require_active_project"](self.resolve, self.project)
102
+ current = self.project.GetCurrentTimeline()
103
+ self.require(current and current.GetUniqueId() == self.timeline.GetUniqueId(), "VERSION_CONFLICT", "当前时间线已改变,未继续修改实例。")
104
+ self.require(not self.project.IsRenderingInProgress(), "RESOLVE_BUSY", "达芬奇正在导出,未修改实例。")
105
+
106
+ def _otio(self, target):
107
+ with tempfile.TemporaryDirectory(prefix="native-instance-otio-", dir=self.scratch) as directory:
108
+ path = os.path.join(directory, "timeline.otio")
109
+ self.require(self.timeline.Export(path, self.resolve.EXPORT_OTIO), "CAPABILITY_UNAVAILABLE", "无法完整检查时间线效果。")
110
+ with open(path, encoding="utf-8") as stream:
111
+ value = json.load(stream)
112
+ shadow = copy.deepcopy(value)
113
+ matches, video_track = [], 0
114
+ for track in shadow.get("tracks", {}).get("children", []):
115
+ if track.get("kind") != "Video":
116
+ continue
117
+ video_track += 1
118
+ if video_track != self.track:
119
+ continue
120
+ cursor = 0.0
121
+ for index, child in enumerate(track.get("children", [])):
122
+ duration = (child.get("source_range") or {}).get("duration")
123
+ frames = duration["value"] / duration["rate"] * 30 if duration and duration.get("rate") else None
124
+ kind = str(child.get("OTIO_SCHEMA", "")).split(".")[0]
125
+ if (kind not in ("Gap", "Transition") and frames is not None
126
+ and abs(cursor + self.timeline.GetStartFrame() - target["start"]) < .001
127
+ and abs(frames - target["duration"]) < .001):
128
+ matches.append(copy.deepcopy(child))
129
+ track["children"][index] = {"HotionHarnessOwnedTarget": self.item.GetUniqueId()}
130
+ if kind != "Transition":
131
+ self.require(frames is not None and math.isfinite(frames), "CAPABILITY_UNAVAILABLE", "时间线区间无法完整读回。")
132
+ cursor += frames
133
+ self.require(len(matches) == 1, "CAPABILITY_UNAVAILABLE", "不能将原生实例唯一对应到时间线结构。")
134
+ return {"others": shadow, "target": matches[0]}
135
+
136
+ def capture(self):
137
+ self._active()
138
+ native = _native(self.item)
139
+ result = {"timeline": self.api["timeline_snapshot"](self.project, self.scope, self.timeline, self.resolve),
140
+ "oldMedia": self.api["media_snapshot"](self.project, self.scope, self.old_media, self.resolve),
141
+ "oldMediaNative": _media_native(self.old_media),
142
+ "target": native, "otio": self._otio(native), "playhead": self.timeline.GetCurrentTimecode(),
143
+ "newFile": _file_state(self.path), "oldFile": _file_state(self.old_media.GetClipProperty("File Path")),
144
+ "fileCandidates": sorted(media.GetUniqueId() for media in _all_media(self.pool.GetRootFolder())
145
+ if os.path.realpath(media.GetClipProperty("File Path") or "") == self.path),
146
+ "newMedia": _media_native(self.new_media) if self.new_media else None}
147
+ self._active()
148
+ after_native = _native(self.item)
149
+ self.require(after_native == native, "VERSION_CONFLICT", "读取期间实例发生变化。",
150
+ beforeNative=native, afterNative=after_native)
151
+ # JSON normalizes numeric marker keys for persisted recovery comparisons.
152
+ return json.loads(json.dumps(result))
153
+
154
+ def save(self):
155
+ atomic_json(self.journal_path, self.record)
156
+
157
+ def guard(self):
158
+ actual = self.capture()
159
+ self.require(actual == self.record["checkpoint"], "RECOVERY_CONFLICT", "原生状态与本次操作记录不一致,未覆盖其他修改。")
160
+ return actual
161
+
162
+ def invariants(self, state):
163
+ original = self.record["original"]
164
+ self.require({k: v for k, v in state["target"].items() if k not in _TARGET_VARIABLE}
165
+ == {k: v for k, v in original["target"].items() if k not in _TARGET_VARIABLE},
166
+ "APPLY_OUTCOME_UNKNOWN", "实例的时长、位置、属性或标记发生了意外改变。")
167
+ self.require(state["otio"]["others"] == original["otio"]["others"], "APPLY_OUTCOME_UNKNOWN", "其他时间线片段或效果发生变化,未继续操作。")
168
+ ignored = {"items", "sources", "complete", "unsupported", "fingerprint"}
169
+ self.require({k: v for k, v in state["timeline"].items() if k not in ignored}
170
+ == {k: v for k, v in original["timeline"].items() if k not in ignored},
171
+ "APPLY_OUTCOME_UNKNOWN", "时间线结构或项目设置发生变化。")
172
+ def other_items(value):
173
+ return [{k: v for k, v in item.items() if k != "otioHash"} for item in value["timeline"]["items"] if item["id"] != self.item.GetUniqueId()]
174
+ self.require(other_items(state) == other_items(original), "APPLY_OUTCOME_UNKNOWN", "其他实例发生变化。")
175
+ before_target = next(item for item in original["timeline"]["items"] if item["id"] == self.item.GetUniqueId())
176
+ after_target = next(item for item in state["timeline"]["items"] if item["id"] == self.item.GetUniqueId())
177
+ changing = {"mediaPoolItemId", "sourceIn", "sourceOut", "sourceStartTime", "sourceEndTime", "leftOffset", "rightOffset", "speed", "otioHash", "unsupported"}
178
+ self.require({key: value for key, value in before_target.items() if key not in changing}
179
+ == {key: value for key, value in after_target.items() if key not in changing},
180
+ "RECOVERY_CONFLICT", "实例的调色或其他固定原生状态被改动。")
181
+ allowed_reasons = {"take-selector-or-multicam"} if state["target"]["takes"] else set()
182
+ self.require(set(after_target["unsupported"]) - set(before_target["unsupported"]) <= allowed_reasons,
183
+ "RECOVERY_CONFLICT", "实例出现了不属于本次操作的效果或结构,未继续覆盖。")
184
+ media_ignored = {"fingerprint", "uses", "usageScanComplete"}
185
+ self.require({k: v for k, v in state["oldMedia"].items() if k not in media_ignored}
186
+ == {k: v for k, v in original["oldMedia"].items() if k not in media_ignored},
187
+ "APPLY_OUTCOME_UNKNOWN", "原媒体或其关联信息发生变化。")
188
+ def old_native(value):
189
+ media = value["oldMediaNative"]
190
+ return {**media, "properties": {key: entry for key, entry in media["properties"].items() if key != "Usage"}}
191
+ self.require(old_native(state) == old_native(original), "APPLY_OUTCOME_UNKNOWN", "原媒体的完整解释或元数据发生变化。")
192
+ def other_uses(value):
193
+ return [{k: v for k, v in use.items() if not (k == "otioHash" and use["timelineId"] == self.timeline.GetUniqueId())}
194
+ for use in value["oldMedia"]["uses"] if use["id"] != self.item.GetUniqueId()]
195
+ self.require(other_uses(state) == other_uses(original), "APPLY_OUTCOME_UNKNOWN", "共享媒体的其他使用位置发生变化。")
196
+ old_id = self.old_media.GetUniqueId()
197
+ new_id = self.new_media.GetUniqueId() if self.new_media else None
198
+ def unrelated_sources(value):
199
+ return [source for source in value["timeline"]["sources"] if source["mediaPoolItemId"] not in (old_id, new_id)]
200
+ self.require(unrelated_sources(state) == unrelated_sources(original), "APPLY_OUTCOME_UNKNOWN", "其他素材解释发生变化。")
201
+ self.require(state["newFile"] == original["newFile"] and state["oldFile"] == original["oldFile"], "ARTIFACT_CHANGED", "操作期间视频文件发生改变。")
202
+ expected_candidates = [self.new_media.GetUniqueId()] if self.new_media else original["fileCandidates"]
203
+ self.require(state["fileCandidates"] == expected_candidates, "RECOVERY_CONFLICT", "同一副本文件出现了未确认的媒体对象,未猜测归属。")
204
+
205
+ def stage(self, name, mutate, expected, allow_playhead=False):
206
+ before = self.guard()
207
+ self.record["intent"] = name
208
+ self.save()
209
+ error, accepted = None, False
210
+ try:
211
+ accepted = bool(mutate())
212
+ except Exception as caught:
213
+ error = caught
214
+ after = self.capture()
215
+ self.invariants(after)
216
+ if before["newMedia"] and after["newMedia"]:
217
+ allowed_properties = {"Clip Name", "EDL Clip Name"} if name == "named" else {name[len("interpretation-"):]} if name.startswith("interpretation-") else set()
218
+ allowed_metadata = self.params["newMetadata"].keys() if name in ("identified", "rollback-identify-import") else ()
219
+ def stable_new(value):
220
+ media = value["newMedia"]
221
+ return {"id": media["id"], "properties": {key: entry for key, entry in media["properties"].items() if key != "Usage" and key not in allowed_properties},
222
+ "metadata": {key: entry for key, entry in media["metadata"].items() if key not in allowed_metadata}}
223
+ self.require(stable_new(before) == stable_new(after), "RECOVERY_CONFLICT", "新副本媒体的其他属性被改动,未继续覆盖。")
224
+ self.require(allow_playhead or after["playhead"] == before["playhead"], "RECOVERY_CONFLICT", "播放头在操作期间改变,未覆盖新的选择。")
225
+ applied = expected(after)
226
+ self.require(applied or after == before, "APPLY_OUTCOME_UNKNOWN", "原生调用后的状态无法对应本次操作,停止自动恢复。", stage=name)
227
+ self.record.update(checkpoint=after, stage=name if applied else self.record["stage"], intent=None,
228
+ newMediaPoolItemId=self.new_media.GetUniqueId() if self.new_media else None)
229
+ self.save()
230
+ if error or not accepted or not applied:
231
+ raise self.failure("NATIVE_INSTANCE_STEP_FAILED", "原生实例操作未完成。", stage=name, diagnostic=str(error) if error else None)
232
+ return after
233
+
234
+ def speed(self, percentage, stage):
235
+ return self.stage(stage, lambda: self.item.SetSpeed({"Percentage": percentage, "RippleTimeline": False}),
236
+ lambda state: state["target"]["speed"].get("Percentage") == percentage and not state["target"]["takes"])
237
+
238
+ def seek(self, timecode, stage):
239
+ def mutate():
240
+ self.api["seek_timecode"](self.project, self.timeline, timecode, resolve=self.resolve)
241
+ return True
242
+ return self.stage(stage, mutate, lambda state: state["playhead"].replace(";", ":") == timecode.replace(";", ":"), True)
243
+
244
+ def extent(self):
245
+ state = self.record["checkpoint"]["target"]
246
+ start, end = state["sourceStartTime"] * 30, state["sourceEndTime"] * 30
247
+ self.require(abs(start - round(start)) < .001 and abs(end - round(end)) < .001 and 0 <= round(start) < round(end),
248
+ "UNSUPPORTED_TIMELINE_ITEM", "实例源区间不能无损转换为原生 Take 帧边界。")
249
+ return round(start), round(end) - 1
250
+
251
+ def add_take(self, media, label):
252
+ start, end = self.extent()
253
+ expected_ids = [self.item.GetMediaPoolItem().GetUniqueId(), media.GetUniqueId()]
254
+ return self.stage(label, lambda: self.item.AddTake(media, start, end),
255
+ lambda state: [take["mediaId"] for take in state["target"]["takes"]] == expected_ids)
256
+
257
+ def select(self, index, media, label):
258
+ return self.stage(label, lambda: self.item.SelectTakeByIndex(index),
259
+ lambda state: state["target"]["selectedTake"] == index and state["target"]["mediaId"] == media.GetUniqueId())
260
+
261
+ def finalize(self, media, label):
262
+ return self.stage(label, self.item.FinalizeTake,
263
+ lambda state: not state["target"]["takes"] and state["target"]["selectedTake"] == 0 and state["target"]["mediaId"] == media.GetUniqueId())
264
+
265
+ def restore_speed(self, label):
266
+ original = self.record["original"]["target"]
267
+ percentage = original["speed"]["Percentage"]
268
+ if percentage == 0:
269
+ timecode = self.api["frame_timecode"](int(original["start"]), {"num": 30, "den": 1}, False)
270
+ self.seek(timecode, label + "-seek-freeze")
271
+ self.record["playheadOwned"] = True
272
+ self.save()
273
+ if self.item.GetSpeed().get("Percentage") != percentage:
274
+ self.speed(percentage, label)
275
+ if self.item.GetName() != original["name"]:
276
+ self.stage(label + "-name", lambda: self.item.SetName(original["name"]), lambda state: state["target"]["name"] == original["name"])
277
+ if self.record.get("playheadOwned"):
278
+ self.seek(self.record["original"]["playhead"], label + "-playhead")
279
+
280
+ def verify_final(self, state, media_id):
281
+ self.invariants(state)
282
+ expected = copy.deepcopy(self.record["original"]["target"])
283
+ expected["mediaId"] = media_id
284
+ self.require(state["target"] == expected, "APPLY_OUTCOME_UNKNOWN", "独立实例的原生时间映射或属性没有完整保留。")
285
+ def clip(value):
286
+ return {k: v for k, v in value["otio"]["target"].items() if k not in ("media_references", "media_reference")}
287
+ self.require(clip(state) == clip(self.record["original"]), "APPLY_OUTCOME_UNKNOWN", "实例的时间线效果没有完整保留。")
288
+ self.require(state["oldMedia"]["usageScanComplete"], "APPLY_OUTCOME_UNKNOWN", "原媒体的使用位置未能完整读回。")
289
+ before = next(item for item in self.record["original"]["timeline"]["items"] if item["id"] == self.item.GetUniqueId())
290
+ after = next(item for item in state["timeline"]["items"] if item["id"] == self.item.GetUniqueId())
291
+ expected_item = {**before, "mediaPoolItemId": media_id}
292
+ self.require(after == expected_item, "APPLY_OUTCOME_UNKNOWN", "完整原生读回与原实例不一致。")
293
+
294
+ def rollback(self):
295
+ self.guard()
296
+ target = self.record["checkpoint"]["target"]
297
+ old_id = self.old_media.GetUniqueId()
298
+ new_id = self.new_media.GetUniqueId() if self.new_media else None
299
+ if target["takes"]:
300
+ self.require(len(target["takes"]) == 2 and {take["mediaId"] for take in target["takes"]} == {old_id, new_id},
301
+ "RECOVERY_CONFLICT", "Take 列表不再属于本次操作,未删除任何 Take。")
302
+ old_index = next(index + 1 for index, take in enumerate(target["takes"]) if take["mediaId"] == old_id)
303
+ new_index = 3 - old_index
304
+ self.select(old_index, self.old_media, "rollback-select-original")
305
+ self.stage("rollback-delete-copy-take", lambda: self.item.DeleteTakeByIndex(new_index),
306
+ lambda state: len(state["target"]["takes"]) == 1 and state["target"]["takes"][0]["mediaId"] == old_id)
307
+ self.finalize(self.old_media, "rollback-finalize-original")
308
+ elif target["mediaId"] != old_id:
309
+ self.require(target["mediaId"] == new_id, "RECOVERY_CONFLICT", "实例已引用其他媒体,未覆盖。")
310
+ if target["speed"].get("Percentage") != 100:
311
+ self.speed(100, "rollback-normalize-speed")
312
+ self.add_take(self.old_media, "rollback-add-original")
313
+ self.select(2, self.old_media, "rollback-select-original")
314
+ self.finalize(self.old_media, "rollback-finalize-original")
315
+ self.restore_speed("rollback-restore-speed")
316
+ final = self.guard()
317
+ self.verify_final(final, old_id)
318
+ self.require(final["oldMedia"] == self.record["original"]["oldMedia"], "APPLY_OUTCOME_UNKNOWN", "原共享素材没有完整恢复。")
319
+ if self.new_media and self.record["original"]["newMedia"] is None:
320
+ retained = self.api["media_snapshot"](self.project, self.scope, self.new_media, self.resolve)
321
+ wanted = self.params["newMetadata"]
322
+ self.require(retained["usageScanComplete"] and not retained["uses"]
323
+ and os.path.realpath(retained["path"]) == self.path
324
+ and all(key not in retained["metadata"] or retained["metadata"][key] == value for key, value in wanted.items()),
325
+ "RECOVERY_CONFLICT", "保留副本的使用位置或归属已改变,未补写关联。")
326
+ combined = {**retained["metadata"], **wanted}
327
+ if retained["metadata"] != combined:
328
+ self.stage("rollback-identify-import", lambda: self.new_media.SetThirdPartyMetadata(combined),
329
+ lambda state: state["newMedia"]["metadata"] == combined)
330
+ self.record.update(stage="rolled-back", intent=None)
331
+ self.save()
332
+ return {"rolledBack": True, "stage": "rolled-back", "retainedMediaPoolItemId": new_id}
333
+
334
+ def run(self):
335
+ self._active()
336
+ self.require(os.path.isfile(self.path), "ARTIFACT_MISSING", "独立副本视频不存在。")
337
+ self.require(os.path.isfile(self.old_media.GetClipProperty("File Path") or ""), "ARTIFACT_MISSING", "原视频离线,未复制实例。")
338
+ os.makedirs(self.scratch, mode=0o700, exist_ok=True)
339
+ if os.path.exists(self.journal_path):
340
+ with open(self.journal_path, encoding="utf-8") as stream:
341
+ self.record = json.load(stream)
342
+ self.require(self.record.get("schemaVersion") == 1 and self.record.get("identity") == self.identity, "RECOVERY_CONFLICT", "原生记录与当前请求身份不同。")
343
+ if self.record.get("newMediaPoolItemId"):
344
+ self.new_media = self.api["media_for"](self.project, self.record["newMediaPoolItemId"])
345
+ mode = self.params.get("recover")
346
+ self.require(mode in ("check", "rollback"), "RECOVERY_REQUIRED", "已有原生操作记录,请先检查或恢复,不能重复复制实例。")
347
+ current = self.capture()
348
+ matched = current == self.record["checkpoint"]
349
+ if mode == "check":
350
+ answer = {"stage": self.record["stage"], "canRollback": matched, "committed": matched and self.record["stage"] == "committed",
351
+ "rolledBack": matched and self.record["stage"] == "rolled-back", "checkpointMatches": matched}
352
+ if answer["committed"]:
353
+ answer.update(self.result())
354
+ return answer
355
+ return self.rollback()
356
+ self.require(not self.params.get("recover"), "RECOVERY_MISSING", "没有找到该操作的原生记录。")
357
+ original = self.capture()
358
+ self.require(original["timeline"]["fingerprint"] == self.params["expectedTimelineFingerprint"]
359
+ and original["oldMedia"]["fingerprint"] == self.params["expectedMediaFingerprint"],
360
+ "VERSION_CONFLICT", "时间线或原媒体在复制前已改变。")
361
+ target = next(item for item in original["timeline"]["items"] if item["id"] == self.item.GetUniqueId())
362
+ properties = self.old_media.GetClipProperty()
363
+ percentage = original["target"]["speed"].get("Percentage")
364
+ spec = self.params["outputSpec"]
365
+ self.require(original["timeline"].get("validatedHost") and original["timeline"]["fps"] == {"num": 30, "den": 1}
366
+ and not original["timeline"].get("dropFrame") and float(properties.get("FPS", 0)) == 30
367
+ and spec.get("fps") == {"num": 30, "den": 1}, "UNSUPPORTED_FPS", "独立实例原生操作当前仅验收了素材与时间线均为 30 fps。")
368
+ self.require(target.get("kind") == "video" and not target["unsupported"] and not target.get("trackLocked")
369
+ and target["mediaPoolItemId"] == self.old_media.GetUniqueId() and original["oldMedia"]["usageScanComplete"]
370
+ and not original["target"]["takes"] and not self.item.GetLinkedItems()
371
+ and not properties.get("Proxy Media Path")
372
+ and str(properties.get("Audio Ch", "0")) in ("0", "")
373
+ and isinstance(percentage, (int, float)) and math.isfinite(percentage),
374
+ "UNSUPPORTED_TIMELINE_ITEM", "此操作仅支持无联动音频、无已有 Take、可完整检查的普通恒速或冻结视频实例。")
375
+ self.require(all(isinstance(original["target"][key], (int, float)) and math.isfinite(original["target"][key])
376
+ and original["target"][key] == int(original["target"][key]) for key in ("start", "end", "duration")),
377
+ "UNSUPPORTED_TIMELINE_ITEM", "独立实例操作尚未验收亚帧边界。")
378
+ normalized_start = original["target"]["sourceStartTime"] * 30
379
+ self.require(math.isfinite(normalized_start) and abs(normalized_start - round(normalized_start)) < .001
380
+ and 0 <= round(normalized_start) and round(normalized_start) + original["target"]["duration"] <= int(properties["Frames"]),
381
+ "UNSUPPORTED_TIMELINE_ITEM", "该慢速或长冻结片段无法在临时归一速度时保留原时长,未修改实例。")
382
+ self.require(isinstance(self.params["sourceName"], str) and self.params["sourceName"].strip(), "INVALID_REQUEST", "副本工程名称不能为空。")
383
+ metadata = self.params["newMetadata"]
384
+ try:
385
+ link = json.loads(metadata["motionharness.bridge"])
386
+ except (ValueError, TypeError, KeyError):
387
+ link = None
388
+ self.require(isinstance(metadata, dict) and all(isinstance(k, str) and isinstance(v, str) for k, v in metadata.items())
389
+ and isinstance(link, dict) and link.get("schemaVersion") == 1
390
+ and all(isinstance(link.get(k), str) and link[k] for k in ("sourceId", "bindingId", "artifactId")),
391
+ "INVALID_REQUEST", "新副本关联身份无效。")
392
+ try:
393
+ old_link = json.loads(original["oldMedia"]["metadata"].get("motionharness.bridge", "null"))
394
+ except (ValueError, TypeError):
395
+ old_link = None
396
+ self.require(isinstance(old_link, dict) and all(link[key] != old_link.get(key) for key in ("sourceId", "bindingId", "artifactId")),
397
+ "INVALID_REQUEST", "副本必须使用独立的源工程、素材和绑定身份。")
398
+ matches = [media for media in _all_media(self.pool.GetRootFolder()) if os.path.realpath(media.GetClipProperty("File Path") or "") == self.path]
399
+ self.require(os.path.realpath(original["oldMedia"]["path"]) != self.path and len(matches) <= 1,
400
+ "TARGET_OCCUPIED", "副本视频存在多个媒体对象或仍指向原文件,请先核对操作记录。")
401
+ if matches:
402
+ existing = self.api["media_snapshot"](self.project, self.scope, matches[0], self.resolve)
403
+ self.require(existing["metadata"] == metadata and existing["usageScanComplete"] and not existing["uses"],
404
+ "TARGET_OCCUPIED", "已有副本媒体的归属或使用位置不符合恢复条件。")
405
+ self.new_media = matches[0]
406
+ original = self.capture()
407
+ self.require(original["timeline"]["fingerprint"] == self.params["expectedTimelineFingerprint"]
408
+ and original["oldMedia"]["fingerprint"] == self.params["expectedMediaFingerprint"],
409
+ "VERSION_CONFLICT", "复用副本媒体前原实例已改变。")
410
+ self.require(int(properties["Frames"]) == spec["frameCount"] and properties["Resolution"] == "%sx%s" % (spec["width"], spec["height"])
411
+ and file_digest(original["oldMedia"]["path"]) == file_digest(self.path), "ARTIFACT_CHANGED", "独立副本必须先使用与原实例相同的完整视频。")
412
+ self.record = {"schemaVersion": 1, "identity": self.identity, "stage": "prepared", "intent": None,
413
+ "original": original, "checkpoint": original, "newMediaPoolItemId": self.new_media.GetUniqueId() if self.new_media else None, "playheadOwned": False}
414
+ self.save()
415
+ try:
416
+ def import_copy():
417
+ imported = self.pool.ImportMedia([self.path])
418
+ if imported and len(imported) == 1:
419
+ self.new_media = imported[0]
420
+ return True
421
+ return False
422
+ if not self.new_media:
423
+ self.stage("imported", import_copy, lambda state: bool(state["newMedia"]) and os.path.realpath(state["newMedia"]["properties"].get("File Path", "")) == self.path)
424
+ if self.new_media.GetThirdPartyMetadata() != metadata:
425
+ self.stage("identified", lambda: self.new_media.SetThirdPartyMetadata(metadata),
426
+ lambda state: all(state["newMedia"]["metadata"].get(k) == v for k, v in metadata.items()))
427
+ for key in ("FPS", "Frames", "Resolution", "Audio Ch"):
428
+ self.require(self.new_media.GetClipProperty().get(key) == properties.get(key), "ARTIFACT_CHANGED", "新媒体规格读回不同。", property=key)
429
+ for key in _INTERPRETATION:
430
+ expected = properties.get(key)
431
+ if expected is not None and self.new_media.GetClipProperty().get(key) != expected:
432
+ self.stage("interpretation-" + key, lambda key=key, value=expected: self.new_media.SetClipProperty(key, value),
433
+ lambda state, key=key, value=expected: state["newMedia"]["properties"].get(key) == value)
434
+ if self.new_media.GetName() != self.params["sourceName"]:
435
+ self.stage("named", lambda: self.new_media.SetName(self.params["sourceName"]),
436
+ lambda state: state["newMedia"]["properties"].get("Clip Name") == self.params["sourceName"]
437
+ and ("EDL Clip Name" not in state["newMedia"]["properties"] or state["newMedia"]["properties"]["EDL Clip Name"] == self.params["sourceName"]))
438
+ if percentage != 100:
439
+ self.speed(100, "normalized-speed")
440
+ self.add_take(self.new_media, "take-added")
441
+ self.select(2, self.new_media, "take-selected")
442
+ self.finalize(self.new_media, "take-finalized")
443
+ self.restore_speed("restored-speed")
444
+ final = self.guard()
445
+ self.verify_final(final, self.new_media.GetUniqueId())
446
+ self.record.update(stage="committed", intent=None)
447
+ self.save()
448
+ return self.result()
449
+ except Exception as error:
450
+ try:
451
+ rollback = self.rollback()
452
+ except Exception as recovery_error:
453
+ raise self.failure("APPLY_OUTCOME_UNKNOWN", "独立实例操作可能部分完成,原生状态不再允许安全自动恢复。",
454
+ rolledBack=False, nativeJournal=self.journal_path, diagnostic=str(error),
455
+ diagnosticDetails=getattr(error, "details", {}),
456
+ recoveryDiagnostic=str(recovery_error)) from error
457
+ raise self.failure(getattr(error, "code", "NATIVE_INSTANCE_STEP_FAILED"), "独立实例操作未完成,原时间线实例已恢复。",
458
+ **rollback, nativeJournal=self.journal_path, diagnostic=str(error)) from error
459
+
460
+ def result(self):
461
+ current = self.guard()
462
+ self.verify_final(current, self.new_media.GetUniqueId())
463
+ return {"media": self.api["media_snapshot"](self.project, self.scope, self.new_media, self.resolve),
464
+ "item": next(item for item in current["timeline"]["items"] if item["id"] == self.item.GetUniqueId()),
465
+ "oldMedia": current["oldMedia"], "nativeJournal": self.journal_path, "unchangedOtherUses": True}
466
+
467
+
468
+ def duplicate_instance(resolve, project, scope, params, api):
469
+ return _Transaction(resolve, project, scope, params, api).run()
@@ -0,0 +1,125 @@
1
+ """Recognize and refresh owned legacy generators for existing projects.
2
+
3
+ New clips use ordinary video; generator creation is no longer supported.
4
+ """
5
+ import copy
6
+ import json
7
+
8
+ from inspector_runtime import callbacks, settings, signature, tool_id, tool_name, connected_to, canonical
9
+
10
+ DATA_KEY = 'HotionHarness.Intrinsic.v1'
11
+ TIME_INPUTS = ('GlobalIn', 'GlobalOut', 'ClipTimeStart', 'ClipTimeEnd')
12
+ FIELDS = {'MHSchema': 'schemaVersion', 'MHWorkspaceKey': 'workspaceKey', 'MHSourceId': 'sourceId',
13
+ 'MHBindingId': 'bindingId', 'MHMediaPoolItemId': 'mediaPoolItemId',
14
+ 'MHDatabaseId': 'databaseId', 'MHProjectId': 'projectId'}
15
+
16
+
17
+ def stable(value):
18
+ # Fusion reloads Lua numbers as floats and adds table flags. Neither changes
19
+ # the saved graph's meaning; preserve every substantive setting and value.
20
+ if isinstance(value, dict): return {k: stable(v) for k, v in canonical(value).items()}
21
+ if isinstance(value, list): return [stable(v) for v in value]
22
+ if isinstance(value, float) and value.is_integer(): return int(value)
23
+ return value
24
+
25
+
26
+ def tools_for(comp):
27
+ tools = list((comp.GetToolList(False) or {}).values())
28
+ expected = {'MHBridge': 'MacroOperator', 'HotionHarnessBackground': 'Background',
29
+ 'MediaOut1': 'MediaOut', 'HotionHarnessMedia': 'MediaIn'}
30
+ actual = {tool_name(t): tool_id(t) for t in tools}
31
+ if len(tools) != len(actual) or actual != expected:
32
+ raise ValueError('native generator graph contains unknown or missing tools')
33
+ by_name = {tool_name(t): t for t in tools}
34
+ macro = by_name['MHBridge']
35
+ children = list((macro.GetChildrenList(False) or {}).values())
36
+ if len(children) != 1 or tool_name(children[0]) != 'HotionHarnessBackground' or tool_id(children[0]) != 'Background':
37
+ raise ValueError('native generator controller children changed')
38
+ own = settings(macro)
39
+ if set(own.get('UserControls', {})) - {'__flags'} != set(FIELDS) | {'MHProjectName', 'MHEdit', 'MHMore'}:
40
+ raise ValueError('native generator controls changed')
41
+ for key, code in callbacks().items():
42
+ if own['UserControls'][key].get('BTNCS_Execute') != code:
43
+ raise ValueError('native generator button callback changed')
44
+ if own.get('Outputs', {}).get('MainOutput1', {}).get('SourceOp') != 'HotionHarnessBackground':
45
+ raise ValueError('native generator interface changed')
46
+ return by_name
47
+
48
+
49
+ def graph(comp, identity, media):
50
+ by_name = tools_for(comp)
51
+ macro, image = by_name['MHBridge'], by_name['HotionHarnessMedia']
52
+ if any(macro.GetInput(key) != identity[field] for key, field in FIELDS.items()):
53
+ raise ValueError('native generator binding identity changed')
54
+ if image.GetInput('MediaSource') != 'MediaPool' or image.GetInput('MediaID') != media.GetMediaId():
55
+ raise ValueError('native generator no longer reads its bound media pool item')
56
+ if not connected_to(by_name['MediaOut1'], 'HotionHarnessMedia'):
57
+ raise ValueError('native generator image route changed')
58
+ values = {tool_name(t): copy.deepcopy(settings(t)) for t in by_name.values()}
59
+ # ReplaceClip updates Resolve's generated media cache. The referenced native
60
+ # MediaID and the independent media-pool readback remain mandatory above.
61
+ cache = values['HotionHarnessMedia'].get('CustomData', {})
62
+ cache.pop('MediaProps', None)
63
+ cache.pop('MEDIA_ID', None)
64
+ for name in ('HotionHarnessBackground', 'HotionHarnessMedia'):
65
+ time_keys = TIME_INPUTS if name == 'HotionHarnessMedia' else TIME_INPUTS[:2]
66
+ for key in time_keys:
67
+ value = values[name].get('Inputs', {}).pop(key, None)
68
+ if value and set(value) - {'__ctor', '__flags', 'Value'}:
69
+ raise ValueError('native generator has an animated or expressed time mapping')
70
+ child = values['MHBridge'].get('Tools', {}).get('HotionHarnessBackground', {})
71
+ for key in TIME_INPUTS[:2]:
72
+ child.get('Inputs', {}).pop(key, None)
73
+ return signature(stable(values))
74
+
75
+
76
+ def read_entry(item, media_index):
77
+ try:
78
+ if item.GetMediaPoolItem() or item.GetFusionCompCount() != 1:
79
+ return None
80
+ comp = item.GetFusionCompByIndex(1)
81
+ receipt = stable(comp.GetData(DATA_KEY))
82
+ if not isinstance(receipt, dict) or receipt.get('schemaVersion') != 1:
83
+ return None
84
+ identity = receipt['identity']
85
+ media = media_index.get(identity['mediaPoolItemId'])
86
+ if not media:
87
+ return None
88
+ link = json.loads((media.GetThirdPartyMetadata() or {}).get('motionharness.bridge', 'null'))
89
+ if not isinstance(link, dict) or any(link.get(key) != identity[key] for key in ('sourceId', 'bindingId')):
90
+ return None
91
+ current = graph(comp, identity, media)
92
+ if current != receipt.get('graphFingerprint'):
93
+ return None
94
+ image = comp.FindTool('HotionHarnessMedia')
95
+ props, attrs = media.GetClipProperty(), comp.GetAttrs()
96
+ width, height = (int(v.strip()) for v in props['Resolution'].lower().split('x'))
97
+ fmt = comp.GetPrefs('Comp.FrameFormat')
98
+ if (fmt['Width'] != width or fmt['Height'] != height or abs(fmt['Rate'] - float(props['FPS'])) > 0.0001
99
+ or any(fmt[key] != 3 for key in ('DepthFull', 'DepthPreview', 'DepthInteractive'))
100
+ or fmt['AspectX'] != 1 or fmt['AspectY'] != 1):
101
+ return None
102
+ left, right = attrs['COMPN_GlobalStart'], attrs['COMPN_GlobalEnd'] + 1
103
+ a, b = image.GetInput('GlobalIn'), image.GetInput('GlobalOut')
104
+ if (a != image.GetInput('ClipTimeStart') or b != image.GetInput('ClipTimeEnd')
105
+ or not 0 <= a <= left < right <= b + 1 <= int(props['Frames'])
106
+ or left != item.GetLeftOffset() or right - left != item.GetEnd() - item.GetStart()):
107
+ return None
108
+ return {**receipt, 'graphFingerprint': current}
109
+ except (AttributeError, TypeError, KeyError, ValueError, RuntimeError):
110
+ return None
111
+
112
+
113
+ def refresh_media(item, media):
114
+ """Refresh only our MediaIn reference, never the controller/macro."""
115
+ comp = item.GetFusionCompByIndex(1)
116
+ image = comp.FindTool('HotionHarnessMedia')
117
+ mapping = {key: image.GetInput(key) for key in TIME_INPUTS}
118
+ loop = image.GetInput('Loop')
119
+ image.SetInput('MediaID', '')
120
+ image.SetInput('MediaID', media.GetMediaId())
121
+ # Rebinding also restores Resolve's one-frame Loop default. Restore the
122
+ # proved loop state before restoring its range.
123
+ image.SetInput('Loop', loop)
124
+ for key, value in mapping.items():
125
+ image.SetInput(key, value)