@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,1451 @@
1
+ #!/usr/bin/env python3
2
+ """One JSON request over stdin. Only documented Resolve scripting APIs are used.
3
+
4
+ Only explicit targets and owned render IDs can be modified. Resolve's database
5
+ is never edited directly. Updating a bound media-pool item leaves its timeline
6
+ instances and effects under Resolve ownership.
7
+ """
8
+ import hashlib
9
+ import importlib
10
+ import json
11
+ import math
12
+ import os
13
+ import platform
14
+ import shutil
15
+ import sys
16
+ import tempfile
17
+ import time
18
+ import uuid
19
+ import xml.etree.ElementTree as ET
20
+ from collections import OrderedDict # Fusion's Python table decoder evaluates ordered() tables in __main__.
21
+ from contextlib import contextmanager
22
+ from urllib.parse import unquote, urlparse
23
+
24
+ # Runtime helpers are shipped beside this adapter; importing them is offline.
25
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
+ import inspector_runtime as inspector
27
+ import intrinsic_runtime as intrinsic
28
+ import instance_runtime as instances
29
+ import legacy_conversion as legacy
30
+ from runtime_io import atomic_json, file_digest
31
+
32
+ # Frame boundaries were read back on this exact build; do not extrapolate to unknown builds.
33
+ VALIDATED_BUILD = "21.1.0.14"
34
+ SUPPORTED_INSPECTOR_EFFECTS = {
35
+ "Transform", "Source Transform", "Immersive Transform", "Cropping", "Source Cropping",
36
+ "Source Looks", "Dynamic Zoom", "Composite", "Lens Correction", "Retime and Scaling",
37
+ "Video Faders", "Immersive Output Transform",
38
+ }
39
+ ORIGINAL_PLAYBACK = {"perfProxyMediaMode": "0", "perfOptimisedMediaOn": "0", "perfRenderCacheMode": "none"}
40
+
41
+
42
+ def validated_host(resolve):
43
+ return resolve.GetVersionString() == VALIDATED_BUILD and sys.platform == "darwin" and platform.machine() == "arm64"
44
+
45
+
46
+ class BridgeFailure(Exception):
47
+ def __init__(self, code, message, **details):
48
+ super().__init__(message)
49
+ self.code, self.message, self.details = code, message, details
50
+
51
+
52
+ def require(value, code, message, **details):
53
+ if not value:
54
+ raise BridgeFailure(code, message, **details)
55
+
56
+
57
+ def fingerprint(value):
58
+ return "sha256:" + hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
59
+
60
+
61
+ def connect():
62
+ api = os.environ.get("RESOLVE_SCRIPT_API", "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting")
63
+ sys.path.insert(0, os.path.join(api, "Modules"))
64
+ try:
65
+ module = importlib.import_module("DaVinciResolveScript")
66
+ resolve = module.scriptapp("Resolve")
67
+ except Exception as error:
68
+ raise BridgeFailure("CAPABILITY_UNAVAILABLE", "无法加载 Resolve 脚本模块。", diagnostic=str(error))
69
+ require(resolve, "RESOLVE_UNAVAILABLE", "请启动 Resolve Studio,并启用本机外部脚本访问。")
70
+ return resolve
71
+
72
+
73
+ def scope_for(manager, project):
74
+ database = manager.GetCurrentDatabase()
75
+ deadline = time.monotonic() + 5
76
+ # During StartRendering the UI can briefly return no database through the scripting bridge.
77
+ while not database and time.monotonic() < deadline:
78
+ time.sleep(0.05)
79
+ database = manager.GetCurrentDatabase()
80
+ require(isinstance(database, dict) and database, "CAPABILITY_UNAVAILABLE", "无法读取 Resolve 数据库作用域。")
81
+ return {"databaseId": fingerprint(database), "projectId": project.GetUniqueId()}
82
+
83
+
84
+ def project_for(resolve, requested=None, render_proof=None):
85
+ manager = resolve.GetProjectManager()
86
+ project = manager.GetCurrentProject()
87
+ require(project, "PROJECT_MISSING", "Resolve 当前没有打开工程。")
88
+ # GetCurrentDatabase returns None throughout some renders on 21.1.0.14.
89
+ # Monitoring/cancelling an already-started job may use its previously
90
+ # verified project + complete queue fingerprint; starting still requires DB.
91
+ if (render_proof and requested and not manager.GetCurrentDatabase()
92
+ and validated_host(resolve) and project.IsRenderingInProgress()):
93
+ require(project.GetUniqueId() == requested["projectId"], "VERSION_CONFLICT", "运行中的任务已不属于原工程。")
94
+ job = render_job(project, render_proof["jobId"])
95
+ require(render_proof.get("expectedJobFingerprint") and fingerprint(job) == render_proof["expectedJobFingerprint"],
96
+ "VERSION_CONFLICT", "运行中的队列 ID 或设置与启动前记录不一致。")
97
+ scope = requested
98
+ else:
99
+ scope = scope_for(manager, project)
100
+ if requested is not None:
101
+ require(scope == requested, "VERSION_CONFLICT", "Resolve 当前工程与明确指定的作用域不一致。", actual=scope)
102
+ require_active_project(resolve, project)
103
+ return project, scope
104
+
105
+
106
+ def timelines(project):
107
+ return [project.GetTimelineByIndex(i) for i in range(1, project.GetTimelineCount() + 1)]
108
+
109
+
110
+ def timeline_for(project, timeline_id):
111
+ found = [t for t in timelines(project) if t and t.GetUniqueId() == timeline_id]
112
+ require(len(found) == 1, "TIMELINE_MISSING", "指定时间线不存在,未按名称猜测。")
113
+ return found[0]
114
+
115
+
116
+ def all_media(folder):
117
+ result = list(folder.GetClipList() or [])
118
+ for child in folder.GetSubFolderList() or []:
119
+ result.extend(all_media(child))
120
+ return result
121
+
122
+
123
+ def media_for(project, media_id):
124
+ found = [m for m in all_media(project.GetMediaPool().GetRootFolder()) if m.GetUniqueId() == media_id]
125
+ require(len(found) == 1, "MEDIA_MISSING", "指定媒体池对象不存在,未按文件名猜测。")
126
+ return found[0]
127
+
128
+
129
+ def ofx_source_target(resolve, params):
130
+ """Match the actual input file to its registered media-pool identity.
131
+
132
+ Read file paths once to reject ambiguous imports. Only the target's bridge
133
+ metadata matters; unrelated subtitles, generators and metadata are ignored.
134
+ """
135
+ requested = params.get("scope")
136
+ media_id, raw_path = params.get("mediaPoolItemId"), params.get("path")
137
+ valid = lambda value: isinstance(value, str) and bool(value.strip()) and "\0" not in value
138
+ require(isinstance(requested, dict) and set(requested) == {"databaseId", "projectId"}
139
+ and all(valid(value) for value in requested.values()) and valid(media_id),
140
+ "INVALID_ARGUMENT", "侧栏入口需要明确的数据库、工程和素材池对象 ID。")
141
+ require(valid(raw_path) and os.path.isabs(raw_path), "INVALID_ARGUMENT", "侧栏源路径必须是非空绝对路径。")
142
+ path = os.path.realpath(raw_path)
143
+ project, scope = project_for(resolve, requested)
144
+ matches = []
145
+ for media in all_media(project.GetMediaPool().GetRootFolder()):
146
+ try:
147
+ properties = media.GetClipProperty() or {}
148
+ source_path = properties.get("File Path")
149
+ except Exception:
150
+ require(media.GetUniqueId() != media_id, "CAPABILITY_UNAVAILABLE", "无法读取目标素材的文件路径。")
151
+ continue
152
+ if valid(source_path) and os.path.isabs(source_path) and os.path.realpath(source_path) == path:
153
+ matches.append(media)
154
+ require(len(matches) == 1 and matches[0].GetUniqueId() == media_id,
155
+ "VERSION_CONFLICT", "侧栏源路径没有对应唯一且匹配的素材池对象;请从固定菜单管理片段。")
156
+ result = media_identity(project, scope, matches[0])
157
+ require(os.path.realpath(result["path"]) == path, "VERSION_CONFLICT", "目标素材已被重新链接。")
158
+ project_for(resolve, requested)
159
+ return {**result, "path": path, "unique": True}
160
+
161
+
162
+ def fps_value(value):
163
+ text = str(value).replace(" DF", "").strip()
164
+ if text in ("29.97", "29.970", "29.97002997002997"):
165
+ return {"num": 30000, "den": 1001}
166
+ number = float(text)
167
+ require(number in (25.0, 30.0), "UNSUPPORTED_FPS", "首版只接受 25、30 和 30000/1001 fps。", actual=text)
168
+ return {"num": int(number), "den": 1}
169
+
170
+
171
+ def native_timeline_frames(count, source_fps, timeline_fps):
172
+ frames = count * timeline_fps["num"] * source_fps["den"] // (timeline_fps["den"] * source_fps["num"])
173
+ require(frames > 0, "INVALID_RANGE", "源区间不足一个目标时间线帧。")
174
+ return frames
175
+
176
+
177
+ def sample_end_time(start, timeline_frames, source_fps, timeline_fps, source_frames):
178
+ source_rate, timeline_rate = source_fps["num"] / source_fps["den"], timeline_fps["num"] / timeline_fps["den"]
179
+ return min(source_frames / source_rate, start / source_rate + (timeline_frames - 1) / timeline_rate + 1 / source_rate)
180
+
181
+
182
+ def item_snapshot(item, timeline_id, track, validated=False, media_index=None, timeline_fps=None):
183
+ media = item.GetMediaPoolItem()
184
+ intrinsic_entry = intrinsic.read_entry(item, media_index or {}) if validated and not media else None
185
+ if intrinsic_entry:
186
+ media = media_index[intrinsic_entry['identity']['mediaPoolItemId']]
187
+ unsupported = [] if validated else ["host-build-and-frame-conventions-unverified"]
188
+ kind = item.GetType() if validated else "unknown"
189
+ if not media and kind != "transition":
190
+ unsupported.append("no-plain-media-source")
191
+ fusion_count = item.GetFusionCompCount() if media else 0
192
+ entry = inspector.read_entry(item) if fusion_count > 0 and not intrinsic_entry else None
193
+ if fusion_count > 0 and not intrinsic_entry:
194
+ if entry:
195
+ if not inspector.experimental_enabled(): unsupported.append("inspector-entry-unverified")
196
+ else:
197
+ unsupported.append("fusion-composition")
198
+ if media and item.GetTakesCount() > 0:
199
+ unsupported.append("take-selector-or-multicam")
200
+ props = media.GetClipProperty() if media else {}
201
+ speed, properties, fades = None, None, None
202
+ if validated and media:
203
+ if media.GetTimeline():
204
+ unsupported.append("nested-or-compound-timeline")
205
+ if kind != "video":
206
+ unsupported.append("not-plain-video-item")
207
+ speed, properties, fades = item.GetSpeed(), item.GetProperties(), item.GetFades()
208
+ if properties.get("Distortion"):
209
+ unsupported.append("lens-correction-unverified")
210
+ percentage = speed.get("Percentage") if isinstance(speed, dict) else None
211
+ if not isinstance(percentage, (int, float)) or not math.isfinite(percentage):
212
+ unsupported.append("speed-state-unverified")
213
+ if str(props.get("Audio Ch", "0")) not in ("", "0"):
214
+ unsupported.append("source-audio-unsupported")
215
+ if intrinsic_entry:
216
+ attrs = item.GetFusionCompByIndex(1).GetAttrs()
217
+ source_in, source_out = int(attrs['COMPN_GlobalStart']), int(attrs['COMPN_GlobalEnd']) + 1
218
+ else:
219
+ source_in, source_out = item.GetSourceStartFrame(), item.GetSourceEndFrame()
220
+ source_fps = None
221
+ try:
222
+ source_fps = fps_value(props.get("FPS"))
223
+ except (BridgeFailure, TypeError, ValueError):
224
+ unsupported.append("source-fps-unverified")
225
+ cross_fps = source_fps and timeline_fps and source_fps != timeline_fps
226
+ # 21.1.0.14 clamps GetSourceEndFrame to the last inclusive media frame at
227
+ # EOF, although AppendToTimeline and GetSourceEndTime stay half open.
228
+ # Normalize only that observed case, with independent time/offset proof.
229
+ if percentage == 100 and not cross_fps and source_out - source_in + 1 == item.GetEnd() - item.GetStart():
230
+ try:
231
+ rate = fps_value(props.get("FPS"))
232
+ timed_end = item.GetSourceEndTime() * rate["num"] / rate["den"]
233
+ if (int(props.get("Start", 0)) == 0 and source_out + 1 == int(props["Frames"])
234
+ and item.GetRightOffset() == 0 and abs(timed_end - (source_out + 1)) < 0.001):
235
+ source_out += 1
236
+ except (BridgeFailure, KeyError, TypeError, ValueError, AttributeError):
237
+ pass
238
+ if cross_fps:
239
+ if percentage != 100:
240
+ unsupported.append("cross-fps-retime-unverified")
241
+ else:
242
+ source_rate = source_fps["num"] / source_fps["den"]
243
+ sample_start, sample_end = item.GetSourceStartTime(), item.GetSourceEndTime()
244
+ expected_end = sample_end_time(source_in, item.GetEnd() - item.GetStart(), source_fps, timeline_fps, int(props["Frames"]))
245
+ if not all(isinstance(value, (int, float)) and math.isfinite(value) for value in (sample_start, sample_end)) or abs(sample_end - expected_end) > 1e-6:
246
+ unsupported.append("source-sampling-or-variable-retime-unverified")
247
+ else:
248
+ # Source frames may be fractional on a different-rate timeline.
249
+ # These half-open sample bounds come from the native time API.
250
+ source_in, source_out = sample_start * source_rate, sample_end * source_rate
251
+ elif percentage == 100 and source_out - source_in != item.GetEnd() - item.GetStart():
252
+ unsupported.append("source-sampling-or-variable-retime-unverified")
253
+ else:
254
+ source_in, source_out = item.GetSourceStartFrame(), item.GetSourceEndFrame()
255
+ return {
256
+ "id": item.GetUniqueId(), "timelineId": timeline_id, "track": track,
257
+ "startFrame": item.GetStart(), "endFrame": item.GetEnd(),
258
+ "sourceIn": source_in, "sourceOut": source_out,
259
+ "mediaPoolItemId": media.GetUniqueId() if media else None, "unsupported": unsupported,
260
+ "kind": kind, "speed": speed, "properties": properties, "fades": fades,
261
+ "enabled": item.GetClipEnabled() if validated and media else None,
262
+ "sourceStartTime": item.GetSourceStartTime() if validated and media else None,
263
+ "sourceEndTime": item.GetSourceEndTime() if validated and media else None,
264
+ "leftOffset": item.GetLeftOffset() if validated and media else None,
265
+ "rightOffset": item.GetRightOffset() if validated and media else None,
266
+ "markers": item.GetMarkers() if validated and media else None,
267
+ **({"inspectorEntry": entry} if entry else {}),
268
+ **({"intrinsicEntry": intrinsic_entry} if intrinsic_entry else {}),
269
+ }
270
+
271
+
272
+ def otio_seconds(value):
273
+ try:
274
+ rate, number = float(value["rate"]), float(value["value"])
275
+ return number / rate if rate > 0 and math.isfinite(rate) and math.isfinite(number) else None
276
+ except (KeyError, TypeError, ValueError):
277
+ return None
278
+
279
+
280
+ def otio_effect_reasons(effects):
281
+ reasons = []
282
+ for effect in effects:
283
+ kind, scalar = effect.get("OTIO_SCHEMA"), effect.get("time_scalar")
284
+ if (kind == "FreezeFrame.1" and scalar == 0
285
+ or kind == "LinearTimeWarp.1" and isinstance(scalar, (int, float)) and math.isfinite(scalar) and scalar != 0):
286
+ continue
287
+ metadata = effect.get("metadata", {}).get("Resolve_OTIO", {})
288
+ name = metadata.get("Effect Name")
289
+ if (metadata.get("Enabled") is False and str(effect.get("OTIO_SCHEMA", "")).startswith("Effect.")
290
+ and effect.get("effect_name") == "Resolve Effect"):
291
+ continue
292
+ if (not str(effect.get("OTIO_SCHEMA", "")).startswith("Effect.")
293
+ or effect.get("effect_name") != "Resolve Effect" or name not in SUPPORTED_INSPECTOR_EFFECTS):
294
+ reasons.append("unsupported-otio-effect-or-retime")
295
+ if name == "Retime and Scaling" and metadata.get("Parameters"):
296
+ reasons.append("retime-parameters-unverified")
297
+ if name not in ("Video Faders", "Dynamic Zoom") and any(len(parameter.get("Key Frames", {})) > 1 for parameter in metadata.get("Parameters", []) if isinstance(parameter, dict)):
298
+ reasons.append("animated-inspector-unverified")
299
+ return sorted(set(reasons))
300
+
301
+
302
+ def inspect_otio(document):
303
+ """Conservative semantic digest, independent of the artifact's new filename/path."""
304
+ reasons, clips, coverage, global_reasons = [], [], [], []
305
+ tracks = document.get("tracks", {})
306
+ if not str(document.get("OTIO_SCHEMA", "")).startswith("Timeline.") or not str(tracks.get("OTIO_SCHEMA", "")).startswith("Stack."):
307
+ return {"complete": False, "reasons": ["otio-root-unrecognized"], "hash": None}
308
+ semantics = []
309
+ if tracks.get("effects"):
310
+ global_reasons.append("otio-stack-effects-unverified")
311
+ for track in tracks.get("children", []):
312
+ if track.get("kind") != "Video":
313
+ continue
314
+ if not str(track.get("OTIO_SCHEMA", "")).startswith("Track."):
315
+ global_reasons.append("nested-otio-track")
316
+ children, track_clips, track_reasons, cursor = [], [], [], 0.0
317
+ if track.get("effects") or track.get("source_range") is not None:
318
+ track_reasons.append("otio-track-effects-or-trim-unverified")
319
+ for child in track.get("children", []):
320
+ kind = str(child.get("OTIO_SCHEMA", "")).split(".")[0]
321
+ child_reasons = otio_effect_reasons(child.get("effects", []))
322
+ if kind == "Transition":
323
+ transition = child.get("metadata", {}).get("Resolve_OTIO", {})
324
+ if child.get("transition_type") != "SMPTE_Dissolve" or transition.get("Transition Type") != "Cross Dissolve":
325
+ track_reasons.append("unsupported-transition")
326
+ elif kind not in ("Clip", "Gap"):
327
+ global_reasons.append("nested-or-unknown-otio-item")
328
+ effects = child.get("effects", [])
329
+ duration = otio_seconds((child.get("source_range") or {}).get("duration"))
330
+ if kind == "Clip":
331
+ clips.append(child)
332
+ refs = child.get("media_references", {})
333
+ reference = child.get("media_reference") or refs.get(child.get("active_media_reference_key")) or {}
334
+ reference_type = str(reference.get("OTIO_SCHEMA", ""))
335
+ if reference_type.startswith("MissingReference."):
336
+ child_reasons.append("offline-media-reference")
337
+ elif not reference_type.startswith("ExternalReference."):
338
+ child_reasons.append("nested-media-reference")
339
+ track_clips.append({"startSeconds": cursor, "durationSeconds": duration,
340
+ "path": reference.get("target_url"), "referenceType": reference_type,
341
+ "effects": effects,
342
+ "reasons": sorted(set(child_reasons))})
343
+ else:
344
+ track_reasons.extend(child_reasons)
345
+ reasons.extend(child_reasons)
346
+ # Resolve's OTIO duration already represents the timeline span, even
347
+ # for LinearTimeWarp. Transitions overlap adjacent spans, not gaps.
348
+ if kind != "Transition":
349
+ cursor = cursor + duration if cursor is not None and duration is not None and duration >= 0 else None
350
+ children.append({"kind": kind, "range": child.get("source_range"), "inOffset": child.get("in_offset"), "outOffset": child.get("out_offset"), "transitionType": child.get("transition_type"), "effects": effects,
351
+ "metadata": child.get("metadata", {})})
352
+ semantics.append({"children": children, "effects": track.get("effects", []), "range": track.get("source_range")})
353
+ reasons.extend(track_reasons)
354
+ coverage.append({"clips": track_clips, "reasons": sorted(set(track_reasons))})
355
+ reasons = sorted(set(reasons + global_reasons))
356
+ return {"complete": not reasons, "reasons": reasons, "globalReasons": sorted(set(global_reasons)),
357
+ "hash": fingerprint({"tracks": semantics, "stackEffects": tracks.get("effects", [])}), "clipCount": len(clips), "tracks": coverage}
358
+
359
+
360
+ def otio_item_reasons(evidence, items, paths, timeline_start, fps):
361
+ """Match each exported clip to a native track/span/path before scoping effects.
362
+
363
+ A retime elsewhere must not poison a plain bridge clip. Unknown structure or
364
+ a failed match still blocks coverage; never assign effects by name or count.
365
+ """
366
+ result = {item["id"]: list(evidence.get("globalReasons", evidence["reasons"])) for item in items}
367
+ if "tracks" not in evidence:
368
+ return result
369
+ rate = fps["num"] / fps["den"]
370
+ for track, details in enumerate(evidence["tracks"], 1):
371
+ native = sorted((item for item in items if item["track"] == track and item["kind"] == "video" and item["mediaPoolItemId"]), key=lambda item: item["startFrame"])
372
+ exported = details["clips"]
373
+ matched = len(native) == len(exported)
374
+ for item, clip in zip(native, exported):
375
+ start, duration = clip["startSeconds"], clip["durationSeconds"]
376
+ path = clip["path"]
377
+ if path:
378
+ parsed = urlparse(path)
379
+ path = unquote(parsed.path) if parsed.scheme == "file" and parsed.netloc in ("", "localhost") else path
380
+ matched = matched and start is not None and duration is not None and (
381
+ abs(start * rate - (item["startFrame"] - timeline_start)) < 0.01
382
+ and abs(duration * rate - (item["endFrame"] - item["startFrame"])) < 0.01)
383
+ if clip["referenceType"].startswith("ExternalReference."):
384
+ matched = matched and not item.get('intrinsicEntry') and bool(path) and os.path.realpath(path) == os.path.realpath(paths[item["id"]])
385
+ elif clip["referenceType"].startswith("MissingReference.") and item.get('intrinsicEntry'):
386
+ matched = matched and not path
387
+ if not matched:
388
+ for item in items:
389
+ if item["track"] == track:
390
+ result[item["id"]].append("otio-item-coverage-mismatch")
391
+ for item, clip in zip(native, exported):
392
+ clip_reasons = list(clip["reasons"])
393
+ if matched and item.get('intrinsicEntry') and clip['referenceType'].startswith('MissingReference.'):
394
+ # Native generators export MissingReference even when their
395
+ # separately verified MediaIn reads an online media-pool item.
396
+ clip_reasons = [r for r in clip_reasons if r != 'offline-media-reference']
397
+ entry = item.get("inspectorEntry")
398
+ if matched and entry and inspector.experimental_enabled():
399
+ effects = clip.get("effects", [])
400
+ own = inspector.unknown_effects(effects, SUPPORTED_INSPECTOR_EFFECTS)
401
+ if inspector.signature(own) == entry.get("otioEffectsFingerprint"):
402
+ # Only the exact effects captured after attaching to a plain
403
+ # clip can be waived. Other effects/animation stay guarded.
404
+ ordinary = [effect for effect in effects if effect not in own]
405
+ clip_reasons = sorted((set(clip_reasons) - set(otio_effect_reasons(effects))) | set(otio_effect_reasons(ordinary)))
406
+ if matched:
407
+ time_effects = [effect for effect in clip.get("effects", []) if effect.get("OTIO_SCHEMA") in ("LinearTimeWarp.1", "FreezeFrame.1")]
408
+ percentage = (item.get("speed") or {}).get("Percentage")
409
+ if percentage != 100 and (len(time_effects) != 1 or not isinstance(percentage, (int, float))
410
+ or not isinstance(time_effects[0].get("time_scalar"), (int, float))
411
+ or abs(time_effects[0]["time_scalar"] * 100 - percentage) > 0.000001):
412
+ clip_reasons.append("retime-native-and-otio-disagree")
413
+ if percentage == 100 and any(effect.get("time_scalar") != 1 for effect in time_effects):
414
+ clip_reasons.append("retime-native-and-otio-disagree")
415
+ result[item["id"]].extend(details["reasons"] + (clip_reasons if matched else []))
416
+ if not native and (exported or details["reasons"]):
417
+ for item in items:
418
+ result[item["id"]].append("otio-item-coverage-mismatch")
419
+ for item in items:
420
+ if item["track"] > len(evidence["tracks"]):
421
+ result[item["id"]].append("otio-item-coverage-mismatch")
422
+ return result
423
+
424
+
425
+ def otio_evidence(resolve, timeline):
426
+ project = resolve.GetProjectManager().GetCurrentProject()
427
+ previous = project.GetCurrentTimeline()
428
+ try:
429
+ # Export has a current-timeline context on this build even when called
430
+ # through another Timeline object. Never fingerprint the selected tab.
431
+ require(project.SetCurrentTimeline(timeline), "CAPABILITY_UNAVAILABLE", "无法明确设置结构检查时间线。")
432
+ await_timeline(project, timeline, resolve=resolve)
433
+ with tempfile.TemporaryDirectory(prefix="mh-bridge-otio-") as directory:
434
+ path = os.path.join(directory, "timeline.otio")
435
+ if not timeline.Export(path, resolve.EXPORT_OTIO):
436
+ raise RuntimeError("OTIO export unavailable")
437
+ with open(path, encoding="utf8") as file:
438
+ return inspect_otio(json.load(file))
439
+ except BridgeFailure:
440
+ raise
441
+ except Exception:
442
+ return {"complete": False, "reasons": ["otio-inspection-unavailable"], "hash": None}
443
+ finally:
444
+ active = resolve.GetProjectManager().GetCurrentProject()
445
+ if (active and active.GetUniqueId() == project.GetUniqueId() and previous
446
+ and previous.GetUniqueId() != timeline.GetUniqueId()
447
+ and project.GetCurrentTimeline().GetUniqueId() == timeline.GetUniqueId()):
448
+ project.SetCurrentTimeline(previous)
449
+ await_timeline(project, previous, resolve=resolve)
450
+
451
+
452
+ def identity_lut(data):
453
+ if not data or data[0] != "LUT_3D_SIZE 17" or len(data) != 4914:
454
+ return False
455
+ try:
456
+ for index, line in enumerate(data[1:]):
457
+ values = [float(value) for value in line.split()]
458
+ expected = ((index % 17) / 16, ((index // 17) % 17) / 16, (index // 289) / 16)
459
+ # Resolve may export its identity cube on a 16-bit floor-quantized
460
+ # grid (4095/65535 instead of 1/16). Accept these exact two grids,
461
+ # with text serialization tolerance; a broad color tolerance would
462
+ # incorrectly accept a real primary correction as identity.
463
+ if len(values) != 3 or any(not math.isfinite(value) or min(abs(value - wanted), abs(value - math.floor(wanted * 65535) / 65535)) > .000001
464
+ for value, wanted in zip(values, expected)):
465
+ return False
466
+ return True
467
+ except (TypeError, ValueError):
468
+ return False
469
+
470
+
471
+ def grade_evidence(resolve, timeline, native_items, intrinsic_ids=None):
472
+ """ExportLUT is a public Color-page API; select and restore its context explicitly."""
473
+ result = {}
474
+ project, _scope = project_for(resolve)
475
+ previous, page = project.GetCurrentTimeline(), resolve.GetCurrentPage()
476
+ try:
477
+ require(not project.IsRenderingInProgress(), "RESOLVE_BUSY", "渲染期间不能完整检查调色。")
478
+ require(project.SetCurrentTimeline(timeline), "CAPABILITY_UNAVAILABLE", "无法设置调色检查时间线。")
479
+ await_timeline(project, timeline, resolve=resolve)
480
+ require(resolve.OpenPage("color"), "CAPABILITY_UNAVAILABLE", "无法进入受支持的调色检查上下文。")
481
+ await_page(resolve, project, "color")
482
+ with tempfile.TemporaryDirectory(prefix="mh-bridge-grade-") as directory:
483
+ for item in native_items:
484
+ intrinsic_item = item.GetUniqueId() in (intrinsic_ids or set())
485
+ if not intrinsic_item and (item.GetType() != "video" or not item.GetMediaPoolItem()):
486
+ continue
487
+ reasons = []
488
+ graph = item.GetNodeGraph()
489
+ tools = [graph.GetToolsInNode(index) or [] for index in range(1, graph.GetNumNodes() + 1)] if graph else []
490
+ allowed_tools = {"一级平衡", "饱和度,色相与亮度 混合", "Primaries", "Saturation, Hue and Lum Mix", "Primary Balance"}
491
+ if any(tool not in allowed_tools for group in tools for tool in group) or item.GetColorGroup():
492
+ reasons.append("grade-tools-or-group-unverified")
493
+ path = os.path.join(directory, item.GetUniqueId() + ".cube")
494
+ deadline, exported = time.monotonic() + 3, False
495
+ while time.monotonic() < deadline:
496
+ require_active_project(resolve, project)
497
+ require(project.GetCurrentTimeline().GetUniqueId() == timeline.GetUniqueId() and resolve.GetCurrentPage() == "color", "VERSION_CONFLICT", "调色检查期间上下文被另一控制端修改。")
498
+ if item.ExportLUT(resolve.EXPORT_LUT_17PTCUBE, path):
499
+ exported = True
500
+ break
501
+ time.sleep(0.05)
502
+ if exported:
503
+ with open(path, encoding="utf8") as file:
504
+ data = [line.strip() for line in file if line.strip() and not line.startswith(("TITLE", "#"))]
505
+ require(data and data[0] == "LUT_3D_SIZE 17" and len(data) >= 4914, "CAPABILITY_UNAVAILABLE", "调色校验 LUT 不完整。")
506
+ result[item.GetUniqueId()] = {"hash": fingerprint({"lut": data, "tools": tools, "version": item.GetCurrentVersion()}), "unsupported": reasons, "identity": identity_lut(data)}
507
+ else:
508
+ result[item.GetUniqueId()] = {"hash": None, "unsupported": ["grade-inspection-unavailable"], "identity": False}
509
+ finally:
510
+ active = resolve.GetProjectManager().GetCurrentProject()
511
+ if active and active.GetUniqueId() == project.GetUniqueId() and project.GetCurrentTimeline().GetUniqueId() == timeline.GetUniqueId() and resolve.GetCurrentPage() == "color":
512
+ if previous and previous.GetUniqueId() != timeline.GetUniqueId():
513
+ project.SetCurrentTimeline(previous)
514
+ await_timeline(project, previous, resolve=resolve)
515
+ if page != "color":
516
+ resolve.OpenPage(page)
517
+ await_page(resolve, project, page)
518
+ return result
519
+
520
+
521
+ @contextmanager
522
+ def timeline_context(resolve, timeline):
523
+ if resolve is None or not validated_host(resolve):
524
+ yield
525
+ return
526
+ project = resolve.GetProjectManager().GetCurrentProject()
527
+ previous = project.GetCurrentTimeline()
528
+ try:
529
+ require(project.SetCurrentTimeline(timeline), "CAPABILITY_UNAVAILABLE", "无法明确设置检查时间线。")
530
+ await_timeline(project, timeline, resolve=resolve)
531
+ yield
532
+ finally:
533
+ active = resolve.GetProjectManager().GetCurrentProject()
534
+ if (active and active.GetUniqueId() == project.GetUniqueId() and previous
535
+ and previous.GetUniqueId() != timeline.GetUniqueId()
536
+ and project.GetCurrentTimeline().GetUniqueId() == timeline.GetUniqueId()):
537
+ project.SetCurrentTimeline(previous)
538
+ await_timeline(project, previous, resolve=resolve)
539
+
540
+
541
+ def scan_timeline(timeline, resolve=None, grade_media_id=None):
542
+ with timeline_context(resolve, timeline):
543
+ return scan_current_timeline(timeline, resolve, grade_media_id)
544
+
545
+
546
+ def scan_current_timeline(timeline, resolve=None, grade_media_id=None):
547
+ validated = resolve is not None and validated_host(resolve)
548
+ items, native_items, paths = [], [], {}
549
+ media_index = None
550
+ for track in range(1, timeline.GetTrackCount("video") + 1):
551
+ native = timeline.GetItemListInTrack("video", track) or []
552
+ native_items.extend(native)
553
+ for item in native:
554
+ if validated and not item.GetMediaPoolItem() and item.GetFusionCompCount() > 0 and media_index is None:
555
+ project = resolve.GetProjectManager().GetCurrentProject()
556
+ media_index = {m.GetUniqueId(): m for m in all_media(project.GetMediaPool().GetRootFolder())}
557
+ snapshot = item_snapshot(item, timeline.GetUniqueId(), track, validated, media_index, fps_value(timeline.GetSetting("timelineFrameRate")) if validated else None)
558
+ snapshot.update(trackEnabled=timeline.GetIsTrackEnabled("video", track) if validated else None,
559
+ trackLocked=timeline.GetIsTrackLocked("video", track) if validated else None)
560
+ items.append(snapshot)
561
+ if validated and item.GetMediaPoolItem():
562
+ paths[snapshot["id"]] = item.GetMediaPoolItem().GetClipProperty("File Path") or ""
563
+ elif snapshot.get('intrinsicEntry'):
564
+ paths[snapshot['id']] = media_index[snapshot['mediaPoolItemId']].GetClipProperty('File Path') or ''
565
+ evidence = otio_evidence(resolve, timeline) if validated and items else {"complete": not items, "reasons": [], "hash": None}
566
+ if validated and items and evidence.get("clipCount") != sum(1 for item in items if item["kind"] == "video" and item["mediaPoolItemId"]):
567
+ evidence["reasons"] = sorted(set(evidence["reasons"] + ["otio-item-coverage-mismatch"]))
568
+ evidence["globalReasons"] = sorted(set(evidence.get("globalReasons", evidence["reasons"]) + ["otio-item-coverage-mismatch"]))
569
+ scoped_reasons = otio_item_reasons(evidence, items, paths, timeline.GetStartFrame(), fps_value(timeline.GetSetting("timelineFrameRate"))) if validated and items else {}
570
+ # A media readback retains grades only for that media's uses. Keep every
571
+ # native item and OTIO check for indirect references, but avoid exporting
572
+ # unrelated LUTs. Whole-timeline snapshots still inspect all grades.
573
+ grade_items = [native for native, item in zip(native_items, items)
574
+ if grade_media_id is None or item["mediaPoolItemId"] == grade_media_id]
575
+ grades = grade_evidence(resolve, timeline, grade_items, {i['id'] for i in items if i.get('intrinsicEntry')}) if validated and grade_items and any(i["kind"] == "video" for i in items) else {}
576
+ entry_scope = scope_for(resolve.GetProjectManager(), resolve.GetProjectManager().GetCurrentProject()) if validated and any(i.get("inspectorEntry") or i.get('intrinsicEntry') for i in items) else None
577
+ for item in items:
578
+ item["otioHash"] = evidence["hash"]
579
+ item["unsupported"] = sorted(set(item["unsupported"] + scoped_reasons.get(item["id"], evidence["reasons"])))
580
+ if item["id"] in grades:
581
+ item["gradeHash"] = grades[item["id"]]["hash"]
582
+ item["unsupported"] = sorted(set(item["unsupported"] + grades[item["id"]]["unsupported"]))
583
+ entry = item.get('intrinsicEntry') or item.get('inspectorEntry')
584
+ if entry and entry_scope:
585
+ owner = entry["identity"]
586
+ if any(owner[key] != value for key, value in entry_scope.items()):
587
+ item["unsupported"] = sorted(set(item["unsupported"] + ["inspector-identity-conflict"]))
588
+ if validated:
589
+ graph = timeline.GetNodeGraph()
590
+ if graph and any(graph.GetToolsInNode(index) for index in range(1, graph.GetNumNodes() + 1)):
591
+ for item in items: item["unsupported"] = sorted(set(item["unsupported"] + ["timeline-grade-unverified"]))
592
+ return items
593
+
594
+
595
+ def publish_playback(path, publication, before_path):
596
+ """Publish only a validated private copy. History is never overwritten or linked."""
597
+ source = os.path.realpath(publication["sourcePath"])
598
+ require(path == os.path.abspath(path) and not os.path.islink(path), "INVALID_ARTIFACT_PATH", "固定视频不能是符号链接。")
599
+ # artifacts/<version>/<file> -> artifacts/media/<binding>/<same file>
600
+ root = os.path.dirname(os.path.dirname(source))
601
+ require(os.path.dirname(os.path.dirname(os.path.dirname(path))) == root
602
+ and os.path.basename(os.path.dirname(os.path.dirname(path))) == "media"
603
+ and os.path.basename(os.path.dirname(path)).startswith("binding-")
604
+ and os.path.basename(source) == os.path.basename(path) and source != path,
605
+ "INVALID_ARTIFACT_PATH", "固定视频必须位于同一桥接目录的独立关联位置。")
606
+ require(file_digest(source) == publication["sha256"], "ARTIFACT_CHANGED", "新视频与已验证的历史产物不同。")
607
+ previous = publication.get("previousSha256")
608
+ if os.path.exists(path):
609
+ current = file_digest(path)
610
+ require(current in (previous, publication["sha256"]), "ARTIFACT_CHANGED", "固定视频已被其他操作改变,未覆盖。")
611
+ require(os.path.realpath(before_path) == path or current == publication["sha256"], "RECOVERY_CONFLICT", "固定视频已有不同关联,未覆盖。")
612
+ else:
613
+ require(not previous or not os.path.exists(before_path), "ARTIFACT_MISSING", "当前固定视频缺失,请通过管理窗口恢复。")
614
+ os.makedirs(os.path.dirname(path), exist_ok=True)
615
+ fd, pending = tempfile.mkstemp(prefix=".publish-", dir=os.path.dirname(path))
616
+ try:
617
+ with os.fdopen(fd, "wb") as out, open(source, "rb") as inp:
618
+ shutil.copyfileobj(inp, out)
619
+ out.flush()
620
+ os.fsync(out.fileno())
621
+ require(file_digest(pending) == publication["sha256"], "ARTIFACT_CHANGED", "发布副本与已验证产物不同。")
622
+ os.replace(pending, path)
623
+ directory = os.open(os.path.dirname(path), os.O_RDONLY)
624
+ try:
625
+ os.fsync(directory)
626
+ finally:
627
+ os.close(directory)
628
+ finally:
629
+ if os.path.exists(pending):
630
+ os.unlink(pending)
631
+
632
+
633
+ def media_identity(project, scope, media):
634
+ """The original media owns the binding, independently of its timeline uses."""
635
+ try:
636
+ properties = media.GetClipProperty() or {}
637
+ metadata = media.GetThirdPartyMetadata() or {}
638
+ except Exception as error:
639
+ raise BridgeFailure("CAPABILITY_UNAVAILABLE", "无法读取目标原素材的身份或关联。", diagnostic=str(error))
640
+ require(isinstance(properties, dict) and isinstance(metadata, dict)
641
+ and all(isinstance(key, str) and isinstance(value, str) for key, value in metadata.items()),
642
+ "CAPABILITY_UNAVAILABLE", "无法读取目标素材的身份或关联。")
643
+ data = {"scope": scope, "projectName": project.GetName(), "mediaPoolItemId": media.GetUniqueId(),
644
+ "path": properties.get("File Path", ""), "metadata": metadata,
645
+ "uses": [], "usageScanComplete": False,
646
+ "clipProperties": {key: properties.get(key) for key in ("FPS", "Frames", "Resolution", "Audio Ch", "Proxy Media Path", "Input Color Space", "Input Gamma", "Data Level", "Alpha mode", "PAR", "Field Dominance", "Clip Name")}}
647
+ data["fingerprint"] = fingerprint({"scope": scope, "mediaPoolItemId": data["mediaPoolItemId"], "path": data["path"],
648
+ "bridgeMetadata": metadata.get("motionharness.bridge"),
649
+ "properties": {key: value for key, value in data["clipProperties"].items() if key != "Clip Name"}})
650
+ return data
651
+
652
+
653
+ def media_snapshot(project, scope, media, resolve=None):
654
+ data = media_identity(project, scope, media)
655
+ media_id = media.GetUniqueId()
656
+ uses = []
657
+ complete = True
658
+ for timeline in timelines(project):
659
+ if not timeline:
660
+ complete = False
661
+ continue
662
+ items = scan_timeline(timeline, resolve, grade_media_id=media_id)
663
+ uses.extend(i for i in items if i["mediaPoolItemId"] == media_id)
664
+ # A nested timeline could reference this media indirectly.
665
+ if any(any(reason in i["unsupported"] for reason in ("nested-or-compound-timeline", "no-plain-media-source", "nested-or-unknown-otio-item", "nested-media-reference", "otio-inspection-unavailable", "otio-item-coverage-mismatch", "host-build-and-frame-conventions-unverified")) for i in items):
666
+ complete = False
667
+ settings = project.GetSettings() if resolve and validated_host(resolve) else {}
668
+ data.update({"uses": uses, "usageScanComplete": complete,
669
+ "playbackPolicy": {key: settings.get(key) for key in ("perfProxyMediaMode", "perfOptimisedMediaOn", "perfRenderCacheMode")}})
670
+ return data
671
+
672
+
673
+ def timeline_snapshot(project, scope, timeline, resolve=None):
674
+ with timeline_context(resolve, timeline):
675
+ return current_timeline_snapshot(project, scope, timeline, resolve)
676
+
677
+
678
+ def current_timeline_snapshot(project, scope, timeline, resolve=None):
679
+ items = scan_timeline(timeline, resolve)
680
+ sources = []
681
+ # Rebuild once per snapshot, not once per source. Do not cache across calls:
682
+ # imports/replacements and edits by another controller must remain visible.
683
+ media_index = {}
684
+ for media in all_media(project.GetMediaPool().GetRootFolder()):
685
+ media_id = media.GetUniqueId()
686
+ require(media_id not in media_index, "MEDIA_MISSING", "媒体池稳定身份不唯一。")
687
+ media_index[media_id] = media
688
+ for media_id in sorted(set(item["mediaPoolItemId"] for item in items if item["mediaPoolItemId"])):
689
+ media = media_index.get(media_id)
690
+ require(media, "MEDIA_MISSING", "指定媒体池对象不存在,未按文件名猜测。")
691
+ props = media.GetClipProperty() or {}
692
+ sources.append({"mediaPoolItemId": media_id, "properties": {key: props.get(key) for key in ("File Path", "FPS", "Frames", "Resolution", "Input Color Space", "Input Gamma", "Data Level", "Proxy Media Path")},
693
+ "bridgeMetadata": (media.GetThirdPartyMetadata() or {}).get("motionharness.bridge")})
694
+ settings = project.GetSettings() if resolve and validated_host(resolve) else {}
695
+ data = {"scope": scope, "timelineId": timeline.GetUniqueId(), "name": timeline.GetName(), "validatedHost": bool(resolve and validated_host(resolve)),
696
+ "fps": fps_value(timeline.GetSetting("timelineFrameRate")), "startFrame": timeline.GetStartFrame(),
697
+ "resolution": {"width": int(timeline.GetSetting("timelineResolutionWidth")), "height": int(timeline.GetSetting("timelineResolutionHeight"))},
698
+ "endFrame": timeline.GetEndFrame(), "items": items, "sources": sources,
699
+ "videoTracks": [{"index": track, "locked": timeline.GetIsTrackLocked("video", track), "enabled": timeline.GetIsTrackEnabled("video", track)} for track in range(1, timeline.GetTrackCount("video") + 1)],
700
+ "dropFrame": str(timeline.GetSetting("timelineDropFrameTimecode")) == "1",
701
+ "audioItems": sum(len(timeline.GetItemListInTrack("audio", track) or []) for track in range(1, timeline.GetTrackCount("audio") + 1)),
702
+ "missingMedia": [s["mediaPoolItemId"] for s in sources if not os.path.isfile(s["properties"].get("File Path") or "")],
703
+ "playbackPolicy": {key: settings.get(key) for key in ("perfProxyMediaMode", "perfOptimisedMediaOn", "perfRenderCacheMode")},
704
+ "projectSettingsHash": fingerprint({key: value for key, value in settings.items() if (key.startswith(("color", "timeline", "node", "isAutoColor", "useColor", "separateColor")) and not key.startswith("colorGallery"))}),
705
+ "colorPolicy": {"supported": settings.get("colorScienceMode") == "davinciYRGB" and str(settings.get("colorSpaceTimeline", "")).startswith("Rec.709") and str(settings.get("colorSpaceOutput", "")).startswith("Rec.709"),
706
+ "science": settings.get("colorScienceMode", ""), "timeline": settings.get("colorSpaceTimeline", ""), "output": settings.get("colorSpaceOutput", "")},
707
+ "complete": all(not i["unsupported"] for i in items), "unsupported": sorted(set(reason for i in items for reason in i["unsupported"]))}
708
+ data["fingerprint"] = fingerprint(data)
709
+ return data
710
+
711
+
712
+ def set_metadata(media, metadata):
713
+ require(media.SetThirdPartyMetadata(metadata), "APPLY_OUTCOME_UNKNOWN", "媒体已可能变更,但第三方关联元数据写入失败。")
714
+ actual = media.GetThirdPartyMetadata() or {}
715
+ require(all(actual.get(k) == v for k, v in metadata.items()), "APPLY_OUTCOME_UNKNOWN", "关联元数据读回不一致。")
716
+
717
+
718
+ def save_insert_progress(params, before, stage, media=None, item=None):
719
+ path = params.get("nativeJournal")
720
+ if not path:
721
+ return
722
+ scratch = os.path.realpath(os.environ.get("MH_BRIDGE_SCRATCH", ""))
723
+ require(scratch and os.path.dirname(os.path.realpath(path)) == scratch and os.path.basename(path).startswith("native-insert-"),
724
+ "INVALID_REQUEST", "原生插入记录必须位于桥接工作目录。")
725
+ record = {"schemaVersion": 1, "token": params["nativeToken"], "stage": stage,
726
+ "scope": params["scope"], "timelineId": params["timelineId"], "metadata": params["metadata"],
727
+ "originalFingerprint": params["expectedTimelineFingerprint"], "timelineFingerprint": before["fingerprint"],
728
+ "mediaPoolItemId": media.GetUniqueId() if media else None, "itemId": item.GetUniqueId() if item else None}
729
+ os.makedirs(scratch, mode=0o700, exist_ok=True)
730
+ atomic_json(path, record)
731
+
732
+
733
+ def frame_timecode(frame, fps, drop_frame=False):
734
+ require(isinstance(frame, int) and frame >= 0, "INVALID_RANGE", "帧坐标必须是非负整数。")
735
+ nominal = round(fps["num"] / fps["den"])
736
+ label = frame
737
+ if drop_frame:
738
+ require(fps == {"num": 30000, "den": 1001}, "UNSUPPORTED_FPS", "仅验证了 30000/1001 的 drop-frame 时间码。")
739
+ tens, remainder = divmod(frame, 17982)
740
+ label += 18 * tens + 2 * max(0, (remainder - 2) // 1798)
741
+ hours, rest = divmod(label, nominal * 3600)
742
+ minutes, rest = divmod(rest, nominal * 60)
743
+ seconds, frames = divmod(rest, nominal)
744
+ return "%02d:%02d:%02d%s%02d" % (hours, minutes, seconds, ";" if drop_frame else ":", frames)
745
+
746
+
747
+ def require_active_project(resolve, project):
748
+ active = resolve.GetProjectManager().GetCurrentProject()
749
+ require(active and active.GetUniqueId() == project.GetUniqueId(), "VERSION_CONFLICT", "操作期间当前 Resolve 工程发生变化。")
750
+
751
+
752
+ def await_page(resolve, project, page, timeout=3):
753
+ deadline = time.monotonic() + timeout
754
+ while time.monotonic() < deadline:
755
+ require_active_project(resolve, project)
756
+ if resolve.GetCurrentPage() == page:
757
+ return
758
+ time.sleep(0.02)
759
+ raise BridgeFailure("VERSION_CONFLICT", "Resolve 未就绪到明确指定的检查页面。", page=page)
760
+
761
+
762
+ def await_timeline(project, timeline, timeout=3.0, resolve=None):
763
+ deadline = time.monotonic() + timeout
764
+ while time.monotonic() < deadline:
765
+ if resolve is not None:
766
+ require_active_project(resolve, project)
767
+ active = project.GetCurrentTimeline()
768
+ if active and active.GetUniqueId() == timeline.GetUniqueId():
769
+ return
770
+ time.sleep(0.02)
771
+ raise BridgeFailure("VERSION_CONFLICT", "Resolve 未就绪到明确指定的时间线。")
772
+
773
+
774
+ def seek_timecode(project, timeline, requested, timeout=3.0, resolve=None):
775
+ deadline = time.monotonic() + timeout
776
+ last_request, actual = 0.0, None
777
+ while time.monotonic() < deadline:
778
+ if resolve is not None:
779
+ require_active_project(resolve, project)
780
+ active = project.GetCurrentTimeline()
781
+ require(active and active.GetUniqueId() == timeline.GetUniqueId(), "VERSION_CONFLICT", "定位期间当前时间线发生变化。")
782
+ if time.monotonic() - last_request >= 0.25:
783
+ require(timeline.SetCurrentTimecode(requested), "CAPABILITY_UNAVAILABLE", "无法定位到请求时间码。")
784
+ last_request = time.monotonic()
785
+ actual = timeline.GetCurrentTimecode()
786
+ if actual.replace(";", ":") == requested.replace(";", ":"):
787
+ return actual
788
+ time.sleep(0.02)
789
+ raise BridgeFailure("PREVIEW_READBACK_MISMATCH", "播放头未停在请求帧。", expected=requested, actual=actual)
790
+
791
+
792
+ def preview_frame(resolve, project, scope, params, before=None):
793
+ require(validated_host(resolve), "CAPABILITY_UNAVAILABLE", "该平台与构建的画面捕获尚未验证。")
794
+ require_active_project(resolve, project)
795
+ require(not project.IsRenderingInProgress(), "RESOLVE_BUSY", "Resolve 正在导出。")
796
+ timeline = timeline_for(project, params["timelineId"])
797
+ before = before if before is not None else timeline_snapshot(project, scope, timeline, resolve)
798
+ require(before["fingerprint"] == params["expectedFingerprint"], "VERSION_CONFLICT", "截图前时间线已改变。")
799
+ frame = params["frame"]
800
+ require(before["startFrame"] <= frame < before["endFrame"], "INVALID_RANGE", "截图帧超出时间线区间。")
801
+ output = params["outputPath"]
802
+ require(os.path.isabs(output) and output.lower().endswith(".png") and not os.path.exists(output), "INVALID_REQUEST", "检查图片需要新的绝对 PNG 路径。")
803
+ previous = project.GetCurrentTimeline()
804
+ previous_page = resolve.GetCurrentPage()
805
+ target_timecode = timeline.GetCurrentTimecode()
806
+ requested_timecode = frame_timecode(frame, before["fps"], before["dropFrame"])
807
+ current_timecode, restored = None, False
808
+ try:
809
+ require(project.SetCurrentTimeline(timeline), "VERSION_CONFLICT", "无法明确设置检查时间线。")
810
+ await_timeline(project, timeline, resolve=resolve)
811
+ require(resolve.OpenPage("edit"), "CAPABILITY_UNAVAILABLE", "无法明确设置合成截图页面。")
812
+ await_page(resolve, project, "edit")
813
+ current_timecode = seek_timecode(project, timeline, requested_timecode, resolve=resolve)
814
+ require(project.ExportCurrentFrameAsStill(output), "CAPABILITY_UNAVAILABLE", "Resolve 未能捕获当前合成画面。")
815
+ require_active_project(resolve, project)
816
+ require(project.GetCurrentTimeline().GetUniqueId() == timeline.GetUniqueId() and timeline.GetCurrentTimecode() == current_timecode,
817
+ "VERSION_CONFLICT", "捕获期间时间线或播放头已改变。")
818
+ require(timeline_snapshot(project, scope, timeline, resolve)["fingerprint"] == before["fingerprint"], "VERSION_CONFLICT", "捕获期间时间线内容已改变。")
819
+ finally:
820
+ active_project = resolve.GetProjectManager().GetCurrentProject()
821
+ active = project.GetCurrentTimeline() if active_project and active_project.GetUniqueId() == project.GetUniqueId() else None
822
+ restore_from = (current_timecode, requested_timecode, target_timecode) if current_timecode is None else (current_timecode,)
823
+ if active and active.GetUniqueId() == timeline.GetUniqueId() and timeline.GetCurrentTimecode() in restore_from:
824
+ try:
825
+ restored = bool(seek_timecode(project, timeline, target_timecode, resolve=resolve))
826
+ if previous and previous.GetUniqueId() != timeline.GetUniqueId():
827
+ restored = bool(project.SetCurrentTimeline(previous)) and restored
828
+ await_timeline(project, previous, resolve=resolve)
829
+ if resolve.GetCurrentPage() == "edit" and previous_page != "edit":
830
+ resolve.OpenPage(previous_page)
831
+ await_page(resolve, project, previous_page)
832
+ except BridgeFailure:
833
+ restored = False
834
+ return {"origin": "resolve-composite", "timelineId": timeline.GetUniqueId(), "frame": frame,
835
+ "timecode": requested_timecode, "fingerprint": before["fingerprint"], "uiRestored": restored}
836
+
837
+
838
+ def render_job(project, job_id):
839
+ found = [job for job in project.GetRenderJobList() or [] if job.get("JobId") == job_id]
840
+ require(len(found) == 1, "RENDER_JOB_MISSING", "指定渲染任务不存在,未启动其他队列项。", jobId=job_id)
841
+ return found[0]
842
+
843
+
844
+ def playback_policy(project):
845
+ settings = project.GetSettings()
846
+ return {key: settings.get(key) for key in ORIGINAL_PLAYBACK}
847
+
848
+
849
+ def render_preset_evidence(resolve, project):
850
+ name = "Motion Harness Bridge delivery check " + str(uuid.uuid4())
851
+ require(project.SaveAsNewRenderPreset(name), "CAPABILITY_UNAVAILABLE", "无法核对导出预设的缓存策略。")
852
+ try:
853
+ with tempfile.TemporaryDirectory(prefix="mh-bridge-preset-") as directory:
854
+ require(resolve.ExportRenderPreset(name, directory), "CAPABILITY_UNAVAILABLE", "无法导出自有临时预设以核对原始画面策略。")
855
+ root = ET.parse(os.path.join(directory, name + ".xml")).getroot()
856
+ fields = {key: root.findtext(key) for key in ("UseRenderCachedImagesForRecording", "RecordFormatType", "RecordFormatSubType", "RecordAudioEnabled", "RecordBitDepth")}
857
+ require(fields == {"UseRenderCachedImagesForRecording": "false", "RecordFormatType": "mp4", "RecordFormatSubType": "avc1", "RecordAudioEnabled": "false", "RecordBitDepth": "8"}, "PLAYBACK_POLICY_UNVERIFIED", "当前导出预设没有通过原始画面和格式核对。", actual=fields)
858
+ return fields
859
+ finally:
860
+ project.DeleteRenderPreset(name)
861
+
862
+
863
+ def create_render(resolve, project, scope, params):
864
+ require(validated_host(resolve), "CAPABILITY_UNAVAILABLE", "该构建尚未验证指定任务导出。")
865
+ require(not project.IsRenderingInProgress(), "RESOLVE_BUSY", "Resolve 已有输出任务在运行。")
866
+ timeline = timeline_for(project, params["timelineId"])
867
+ expected = params["expectedTimelineFingerprint"]
868
+ require(timeline_snapshot(project, scope, timeline, resolve)["fingerprint"] == expected, "VERSION_CONFLICT", "创建输出任务前时间线已改变。")
869
+ output, bounds = params["output"], params["range"]
870
+ path = output["filePath"]
871
+ require(os.path.isabs(path) and path.lower().endswith(".mp4") and not os.path.exists(path), "TARGET_OCCUPIED", "输出需要新的绝对 MP4 路径。")
872
+ previous = project.GetCurrentTimeline()
873
+ before_jobs = project.GetRenderJobList() or []
874
+ require(not any(os.path.realpath(job.get("TargetDir", "")) == os.path.realpath(os.path.dirname(path)) and job.get("OutputFilename") == os.path.basename(path) for job in before_jobs), "TARGET_OCCUPIED", "输出位置已被其他队列项占用。")
875
+ backup = "Motion Harness Bridge restore " + str(uuid.uuid4())
876
+ require(project.SaveAsNewRenderPreset(backup), "CAPABILITY_UNAVAILABLE", "无法保存原有导出配置,未修改设置。")
877
+ job_id = None
878
+ try:
879
+ require(project.SetCurrentTimeline(timeline), "VERSION_CONFLICT", "无法设置明确的输出时间线。")
880
+ await_timeline(project, timeline, resolve=resolve)
881
+ require(resolve.OpenPage("deliver"), "CAPABILITY_UNAVAILABLE", "无法明确设置输出页面。")
882
+ await_page(resolve, project, "deliver")
883
+ require(project.LoadRenderPreset("H.264 Master"), "CAPABILITY_UNAVAILABLE", "缺少经过验证的 H.264 Master 导出预设。")
884
+ require(project.SetCurrentRenderFormatAndCodec("mp4", "H264") and project.SetCurrentRenderMode(1), "CAPABILITY_UNAVAILABLE", "MP4/H.264 单文件输出不可用。")
885
+ # 21.1.0.14 rejects SelectAllFrames=False and string VideoQuality despite its declarations.
886
+ # Explicit marks select the range; the resulting job is checked before it can start.
887
+ settings = {"MarkIn": bounds["startFrame"], "MarkOut": bounds["endFrame"] - 1,
888
+ "TargetDir": os.path.dirname(path), "CustomName": os.path.splitext(os.path.basename(path))[0],
889
+ "UseUniqueFilenames": False, "ExportVideo": True, "ExportAudio": False,
890
+ "FormatWidth": output["width"], "FormatHeight": output["height"], "FrameRate": output["fps"]["num"] / output["fps"]["den"],
891
+ "PixelAspectRatio": "square", "VideoQuality": 0 if output["quality"] == "Best" else max(2400, int(output["width"] * output["height"] * output["fps"]["num"] / output["fps"]["den"] * 0.15 / 1000)),
892
+ "ColorSpaceTag": "Rec.709", "GammaTag": "Gamma 2.4",
893
+ "ExportSubtitle": False, "DataBurnIn": "None"}
894
+ require(project.SetRenderSettings(settings), "CAPABILITY_UNAVAILABLE", "Resolve 拒绝明确的输出设置。")
895
+ preset_evidence = render_preset_evidence(resolve, project)
896
+ require_active_project(resolve, project)
897
+ require(timeline_snapshot(project, scope, timeline, resolve)["fingerprint"] == expected, "VERSION_CONFLICT", "配置输出期间时间线已改变。")
898
+ job_id = project.AddRenderJob()
899
+ require(job_id, "APPLY_OUTCOME_UNKNOWN", "Resolve 未返回创建的任务 ID;必须按输出路径核对后恢复。")
900
+ job = render_job(project, job_id)
901
+ require(job.get("IsExportVideo") is True and job.get("IsExportAudio") is False and job.get("FormatWidth") == output["width"] and job.get("FormatHeight") == output["height"]
902
+ and int(job.get("MarkIn", -1)) == bounds["startFrame"] and int(job.get("MarkOut", -1)) == bounds["endFrame"] - 1
903
+ and os.path.realpath(job.get("TargetDir", "")) == os.path.realpath(os.path.dirname(path))
904
+ and job.get("OutputFilename") == os.path.basename(path) and job.get("ExportAlpha") is False
905
+ and fps_value(job.get("FrameRate")) == output["fps"] and job.get("VideoFormat") == "MP4" and job.get("VideoCodec") == "H.264" and job.get("RenderMode") == "Single clip",
906
+ "APPLY_OUTCOME_UNKNOWN", "已创建任务的输出设置与请求不一致,未开始输出。", job=job)
907
+ old = {job["JobId"]: job for job in before_jobs}
908
+ after = {job["JobId"]: job for job in project.GetRenderJobList() or []}
909
+ require(all(after.get(key) == value for key, value in old.items()), "APPLY_OUTCOME_UNKNOWN", "创建期间已有队列项发生变化,停止启动。")
910
+ finally:
911
+ active = resolve.GetProjectManager().GetCurrentProject()
912
+ if active and active.GetUniqueId() == project.GetUniqueId():
913
+ restored = project.LoadRenderPreset(backup)
914
+ if restored:
915
+ project.DeleteRenderPreset(backup)
916
+ if previous and project.GetCurrentTimeline().GetUniqueId() == timeline.GetUniqueId():
917
+ project.SetCurrentTimeline(previous)
918
+ await_timeline(project, previous, resolve=resolve)
919
+ return {"jobId": job_id, "job": job, "jobFingerprint": fingerprint(job), "presetEvidence": preset_evidence}
920
+
921
+
922
+ def start_render(resolve, project, scope, params):
923
+ require(validated_host(resolve), "CAPABILITY_UNAVAILABLE", "该构建尚未验证指定任务导出。")
924
+ require(not project.IsRenderingInProgress(), "RESOLVE_BUSY", "Resolve 正在执行其他渲染;未启动队列。")
925
+ job = render_job(project, params["jobId"])
926
+ require(params.get("expectedJobFingerprint") and fingerprint(job) == params["expectedJobFingerprint"], "VERSION_CONFLICT", "队列任务的输出设置已改变或没有核对记录,未启动。")
927
+ require(project.GetRenderJobStatus(params["jobId"]).get("JobStatus") == "Ready", "VERSION_CONFLICT", "目标任务已不处于待开始状态。")
928
+ timeline = timeline_for(project, params["timelineId"])
929
+ require(project.SetCurrentTimeline(timeline), "VERSION_CONFLICT", "无法明确设置指定输出任务的时间线。")
930
+ await_timeline(project, timeline, resolve=resolve)
931
+ require(resolve.OpenPage("deliver"), "CAPABILITY_UNAVAILABLE", "无法明确设置指定任务的输出上下文。")
932
+ await_page(resolve, project, "deliver")
933
+ before = timeline_snapshot(project, scope, timeline, resolve)
934
+ require(before["fingerprint"] == params["expectedFingerprint"], "VERSION_CONFLICT", "启动前时间线已改变,拒绝旧回执。")
935
+ previous = playback_policy(project)
936
+ try:
937
+ require(project.SetSettings(ORIGINAL_PLAYBACK) and playback_policy(project) == ORIGINAL_PLAYBACK, "PLAYBACK_POLICY_UNVERIFIED", "未能明确关闭代理、优化媒体和渲染缓存;未开始输出。")
938
+ after = timeline_snapshot(project, scope, timeline, resolve)
939
+ semantic = lambda data: {key: value for key, value in data.items() if key not in ("fingerprint", "playbackPolicy")}
940
+ require(semantic(before) == semantic(after), "VERSION_CONFLICT", "准备原始画面期间目标内容已改变,未开始输出。")
941
+ require(project.StartRendering([params["jobId"]], False), "APPLY_OUTCOME_UNKNOWN", "Resolve 未确认启动指定任务,必须查询该 ID;不能重启整个队列。")
942
+ except Exception:
943
+ if not project.IsRenderingInProgress() and playback_policy(project) == ORIGINAL_PLAYBACK:
944
+ project.SetSettings(previous)
945
+ raise
946
+ return {"jobId": params["jobId"], "job": job, "previousPolicy": previous, "deliveryPolicy": "originals"}
947
+
948
+
949
+ def finish_render(resolve, project, params):
950
+ require(validated_host(resolve), "CAPABILITY_UNAVAILABLE", "该构建尚未验证输出恢复。")
951
+ render_job(project, params["jobId"])
952
+ require(not project.IsRenderingInProgress(), "RESOLVE_BUSY", "仍有渲染运行,保留原始画面模式直至可核对的终态。")
953
+ expected, actual = params["playbackPolicy"], playback_policy(project)
954
+ require(set(expected) == set(ORIGINAL_PLAYBACK), "RECOVERY_EVIDENCE_MISSING", "缺少本任务开始前的播放设置。")
955
+ if actual != expected:
956
+ require(actual == ORIGINAL_PLAYBACK, "VERSION_CONFLICT", "播放设置已被其他控制端修改,未覆盖其新设置。", actual=actual, original=expected)
957
+ require(project.SetSettings(expected) and playback_policy(project) == expected, "APPLY_OUTCOME_UNKNOWN", "输出已经结束,但原播放设置尚未恢复。")
958
+ return {"jobId": params["jobId"], "playbackPolicy": playback_policy(project), "restored": True}
959
+
960
+
961
+ def export_references(resolve, project, scope, params):
962
+ require(validated_host(resolve) and not project.IsRenderingInProgress(), "RESOLVE_BUSY", "输出后画面核对需要已完成渲染的目标工程。")
963
+ timeline = timeline_for(project, params["timelineId"])
964
+ before = timeline_snapshot(project, scope, timeline, resolve)
965
+ require(before["fingerprint"] == params["expectedTimelineFingerprint"], "VERSION_CONFLICT", "输出后的时间线已改变,不能生成原回执的参考画面。")
966
+ directory = params["outputDirectory"]
967
+ require(os.path.isabs(directory) and os.path.isdir(directory), "INVALID_REQUEST", "检查参考图片需要明确的输出目录。")
968
+ original = playback_policy(project)
969
+ frames = []
970
+ try:
971
+ require(project.SetSettings(ORIGINAL_PLAYBACK) and playback_policy(project) == ORIGINAL_PLAYBACK, "PLAYBACK_POLICY_UNVERIFIED", "无法使用原始媒体核对输出。")
972
+ current = timeline_snapshot(project, scope, timeline, resolve)
973
+ semantic = lambda data: {key: value for key, value in data.items() if key not in ("fingerprint", "playbackPolicy")}
974
+ require(semantic(before) == semantic(current), "VERSION_CONFLICT", "设置原始画面策略期间时间线已改变。")
975
+ count = params["range"]["endFrame"] - params["range"]["startFrame"]
976
+ for index in sorted(set((0, count // 2, count - 1))):
977
+ frame = params["range"]["startFrame"] + index
978
+ path = os.path.join(directory, "%09d.png" % index)
979
+ preview_frame(resolve, project, scope, {"timelineId": params["timelineId"], "frame": frame, "expectedFingerprint": current["fingerprint"], "outputPath": path})
980
+ frames.append({"frame": frame, "index": index, "filePath": path})
981
+ finally:
982
+ active = resolve.GetProjectManager().GetCurrentProject()
983
+ if active and active.GetUniqueId() == project.GetUniqueId() and playback_policy(project) == ORIGINAL_PLAYBACK:
984
+ require(project.SetSettings(original) and playback_policy(project) == original, "APPLY_OUTCOME_UNKNOWN", "参考图片已生成,但原始播放设置未恢复。")
985
+ return {"frames": frames, "origin": "resolve-composite-original-media", "timelineFingerprint": before["fingerprint"]}
986
+
987
+
988
+ def preview_original(resolve, project, scope, params):
989
+ require(validated_host(resolve), "CAPABILITY_UNAVAILABLE", "该构建尚未验证原始媒体合成检查。")
990
+ require(not project.IsRenderingInProgress(), "RESOLVE_BUSY", "不能在导出期间改变合成画面检查策略。")
991
+ timeline = timeline_for(project, params["timelineId"])
992
+ before = timeline_snapshot(project, scope, timeline, resolve)
993
+ require(before["fingerprint"] == params["expectedFingerprint"], "VERSION_CONFLICT", "原始合成画面检查前时间线已改变。")
994
+ original = playback_policy(project)
995
+ try:
996
+ require(project.SetSettings(ORIGINAL_PLAYBACK) and playback_policy(project) == ORIGINAL_PLAYBACK, "PLAYBACK_POLICY_UNVERIFIED", "无法确定原始媒体检查策略。")
997
+ current = timeline_snapshot(project, scope, timeline, resolve)
998
+ semantic = lambda data: {key: value for key, value in data.items() if key not in ("fingerprint", "playbackPolicy")}
999
+ require(semantic(before) == semantic(current), "VERSION_CONFLICT", "检查策略切换期间时间线已改变。")
1000
+ result = preview_frame(resolve, project, scope, {**params, "expectedFingerprint": current["fingerprint"]}, before=current)
1001
+ finally:
1002
+ active = resolve.GetProjectManager().GetCurrentProject()
1003
+ if active and active.GetUniqueId() == project.GetUniqueId():
1004
+ require(playback_policy(project) == ORIGINAL_PLAYBACK, "VERSION_CONFLICT", "检查期间播放策略被另一控制端修改,未覆盖新设置。")
1005
+ require(project.SetSettings(original) and playback_policy(project) == original, "APPLY_OUTCOME_UNKNOWN", "检查完成,但播放设置未恢复。")
1006
+ require(timeline_snapshot(project, scope, timeline, resolve)["fingerprint"] == before["fingerprint"], "VERSION_CONFLICT", "原始合成截图后的时间线已改变。")
1007
+ return {**result, "fingerprint": before["fingerprint"], "captureFingerprint": current["fingerprint"], "mediaPolicy": "originals", "playbackPolicyRestored": True}
1008
+
1009
+
1010
+ def selection_snapshot(resolve, plain_media_only=False):
1011
+ require(validated_host(resolve), "CAPABILITY_UNAVAILABLE", "该构建尚未验证时间线选择读取。")
1012
+
1013
+ def plain_item(item):
1014
+ media = item.GetMediaPoolItem()
1015
+ require(media is not None and media is not False, "CAPABILITY_UNAVAILABLE", "请选择有直接视频源的普通片段;生成器入口暂不支持此操作。")
1016
+ require(not media.GetTimeline(), "CAPABILITY_UNAVAILABLE", "复合片段或嵌套时间线暂不支持此入口,请选择普通视频片段。")
1017
+ properties = media.GetClipProperty()
1018
+ require(isinstance(properties, dict), "CAPABILITY_UNAVAILABLE", "无法读取所选片段的素材路径。")
1019
+ path = properties.get("File Path")
1020
+ require(isinstance(path, str) and bool(path.strip()) and "\0" not in path and os.path.isabs(path),
1021
+ "CAPABILITY_UNAVAILABLE", "所选片段没有可验证的普通视频文件路径。")
1022
+ metadata = media.GetThirdPartyMetadata()
1023
+ require(isinstance(metadata, dict), "CAPABILITY_UNAVAILABLE", "无法读取所选片段的关联元数据。")
1024
+ identity = media.GetUniqueId()
1025
+ require(isinstance(identity, str) and bool(identity.strip()) and "\0" not in identity,
1026
+ "CAPABILITY_UNAVAILABLE", "无法读取所选片段的素材身份。")
1027
+ bridge_metadata = metadata.get("motionharness.bridge")
1028
+ return {"id": item.GetUniqueId(), "name": item.GetName(), "mediaPoolItemId": identity,
1029
+ "bridgeMetadata": bridge_metadata if isinstance(bridge_metadata, str) else None,
1030
+ "path": os.path.realpath(path)}
1031
+
1032
+ def selected_clips(timeline):
1033
+ selected = timeline.GetSelectedClips()
1034
+ if plain_media_only:
1035
+ require(isinstance(selected, list), "CAPABILITY_UNAVAILABLE", "无法完整读取时间线选择。")
1036
+ return selected or []
1037
+
1038
+ project = resolve.GetProjectManager().GetCurrentProject()
1039
+ data = {"scope": None, "timelineId": None, "selectedCount": 0, "item": None}
1040
+ if project:
1041
+ require(not project.IsRenderingInProgress(), "RESOLVE_BUSY", "导出期间暂不读取时间线选择。")
1042
+ project, scope = project_for(resolve)
1043
+ data["scope"] = scope
1044
+ timeline = project.GetCurrentTimeline()
1045
+ if timeline:
1046
+ raw_selected = selected_clips(timeline)
1047
+ identities = sorted(item.GetUniqueId() for item in raw_selected)
1048
+ selected = [item for item in raw_selected if item.GetType() == 'video']
1049
+ data.update(timelineId=timeline.GetUniqueId(), selectedCount=len(selected))
1050
+ if len(selected) == 1:
1051
+ item = selected[0]
1052
+ if plain_media_only:
1053
+ data["item"] = plain_item(item)
1054
+ else:
1055
+ media = item.GetMediaPoolItem()
1056
+ if not media:
1057
+ index = {m.GetUniqueId(): m for m in all_media(project.GetMediaPool().GetRootFolder())}
1058
+ entry = intrinsic.read_entry(item, index)
1059
+ if entry and all(entry['identity'][key] == value for key, value in scope.items()):
1060
+ media = index[entry['identity']['mediaPoolItemId']]
1061
+ metadata = (media.GetThirdPartyMetadata() or {}).get("motionharness.bridge") if media else None
1062
+ data["item"] = {"id": item.GetUniqueId(), "name": item.GetName(),
1063
+ "mediaPoolItemId": media.GetUniqueId() if media else None,
1064
+ "bridgeMetadata": metadata if isinstance(metadata, str) else None}
1065
+ require_active_project(resolve, project)
1066
+ active = project.GetCurrentTimeline()
1067
+ require(active and active.GetUniqueId() == data["timelineId"],
1068
+ "VERSION_CONFLICT", "读取期间所选时间线已改变。")
1069
+ after_selected = selected_clips(active)
1070
+ require(sorted(item.GetUniqueId() for item in after_selected) == identities,
1071
+ "VERSION_CONFLICT", "读取期间所选片段已改变。")
1072
+ if plain_media_only:
1073
+ after_video = [item for item in after_selected if item.GetType() == 'video']
1074
+ require(len(after_video) == data["selectedCount"], "VERSION_CONFLICT", "读取期间所选片段类型已改变。")
1075
+ if len(after_video) == 1:
1076
+ require(plain_item(after_video[0]) == data["item"], "VERSION_CONFLICT", "读取期间所选片段的素材身份或路径已改变。")
1077
+ if plain_media_only:
1078
+ if not timeline:
1079
+ require(not project.GetCurrentTimeline(), "VERSION_CONFLICT", "读取期间当前时间线已改变。")
1080
+ project_for(resolve, scope)
1081
+ elif plain_media_only:
1082
+ require(not resolve.GetProjectManager().GetCurrentProject(), "VERSION_CONFLICT", "读取期间当前工程已改变。")
1083
+ data["fingerprint"] = fingerprint(data)
1084
+ return data
1085
+
1086
+
1087
+ def entry_target(resolve, project, scope, params):
1088
+ """Resolve one captured menu target without selecting pages or scanning edits."""
1089
+ selection = params.get("selection") or {}
1090
+ require(scope == selection.get("scope"), "VERSION_CONFLICT", "所点击片段的工程已改变。")
1091
+ source = None
1092
+ if selection.get("path"):
1093
+ source = ofx_source_target(resolve, {"scope": scope, "mediaPoolItemId": selection.get("mediaPoolItemId"), "path": selection["path"]})
1094
+ if not selection.get("timelineId") and not selection.get("itemId"):
1095
+ require(source is not None, "INVALID_REQUEST", "素材级入口需要源视频路径。")
1096
+ return {**source, "name": source["clipProperties"].get("Clip Name") or ""}
1097
+ timeline = timeline_for(project, selection.get("timelineId"))
1098
+ found = [item for track in range(1, timeline.GetTrackCount("video") + 1)
1099
+ for item in timeline.GetItemListInTrack("video", track) or []
1100
+ if item.GetUniqueId() == selection.get("itemId")]
1101
+ require(len(found) == 1, "VERSION_CONFLICT", "所点击片段已不存在,未使用后来的选择。")
1102
+ item = found[0]
1103
+ media = item.GetMediaPoolItem()
1104
+ if not media:
1105
+ candidate = media_for(project, selection.get("mediaPoolItemId"))
1106
+ entry = intrinsic.read_entry(item, {candidate.GetUniqueId(): candidate})
1107
+ require(entry and all(entry["identity"].get(key) == value for key, value in scope.items()),
1108
+ "VERSION_CONFLICT", "该片段不再指向原关联媒体。")
1109
+ media = candidate
1110
+ require(media.GetUniqueId() == selection.get("mediaPoolItemId"), "VERSION_CONFLICT", "所点击片段的原生媒体身份已改变。")
1111
+ path, values = media.GetClipProperty("File Path"), media.GetThirdPartyMetadata() or {}
1112
+ result = {"scope": scope, "timelineId": timeline.GetUniqueId(), "itemId": item.GetUniqueId(),
1113
+ "projectName": project.GetName(), "timelineName": timeline.GetName(),
1114
+ "mediaPoolItemId": media.GetUniqueId(), "name": item.GetName(), "metadata": values, "path": path}
1115
+ require_active_project(resolve, project)
1116
+ require((media.GetThirdPartyMetadata() or {}) == values and media.GetClipProperty("File Path") == path,
1117
+ "VERSION_CONFLICT", "读取管理入口期间素材身份或文件发生变化。")
1118
+ return result
1119
+
1120
+
1121
+ def inspector_operation(resolve, params):
1122
+ require(params.get("experimental") is True, "CAPABILITY_UNAVAILABLE", "检查器入口尚待实机验收,仅开放明确的隔离实验操作。")
1123
+ # A composition imported through the API is not an Edit-page Fusion effect.
1124
+ # Keep the graph experiment separate from the product's Inspector entry.
1125
+ require(params.get("remove") or params.get("compositionOnly") is True, "CAPABILITY_UNAVAILABLE",
1126
+ "ImportFusionComp 不能创建 Edit 检查器效果,请通过原生特效库应用 Motion Harness Bridge 模板。")
1127
+ identity = params["identity"]
1128
+ require(identity.get("schemaVersion") == 1, "INVALID_REQUEST", "检查器入口身份版本无效。")
1129
+ selected = selection_snapshot(resolve)
1130
+ require(selected["fingerprint"] == params["expectedSelectionFingerprint"] and selected["selectedCount"] == 1,
1131
+ "VERSION_CONFLICT", "挂载前所选片段已改变。")
1132
+ require(selected["timelineId"] == params["timelineId"] and selected["item"]["id"] == params["itemId"]
1133
+ and selected["item"]["mediaPoolItemId"] == identity["mediaPoolItemId"], "VERSION_CONFLICT", "目标片段与入口身份不一致。")
1134
+ project, scope = project_for(resolve, {"databaseId": identity["databaseId"], "projectId": identity["projectId"]})
1135
+ timeline = project.GetCurrentTimeline()
1136
+ item = (timeline.GetSelectedClips() or [None])[0]
1137
+ link = json.loads(selected["item"]["bridgeMetadata"] or "null")
1138
+ require(isinstance(link, dict) and link.get("sourceId") == identity["sourceId"] and link.get("bindingId") == identity["bindingId"],
1139
+ "VERSION_CONFLICT", "媒体的桥接身份已改变。")
1140
+ owned = inspector.read_entry(item)
1141
+ if owned:
1142
+ require(owned["identity"] == identity, "VERSION_CONFLICT", "已有入口属于其他桥接工作目录。")
1143
+ if params.get("remove"):
1144
+ require(owned, "TARGET_OCCUPIED", "未找到完整且未改动的自有入口,未删除用户合成。")
1145
+ tracks = [track for track in range(1, timeline.GetTrackCount("video") + 1)
1146
+ if any(clip.GetUniqueId() == item.GetUniqueId() for clip in (timeline.GetItemListInTrack("video", track) or []))]
1147
+ require(len(tracks) == 1 and not timeline.GetIsTrackLocked("video", tracks[0]), "TARGET_LOCKED", "入口所在轨道已锁定或无法明确定位,未移除。")
1148
+ names = item.GetFusionCompNameList() or []
1149
+ require(len(names) == 1, "VERSION_CONFLICT", "Fusion 合成列表已改变。")
1150
+ require(selection_snapshot(resolve)["fingerprint"] == selected["fingerprint"], "VERSION_CONFLICT", "移除前所选片段已改变。")
1151
+ require(item.DeleteFusionCompByName(names[0]), "APPLY_OUTCOME_UNKNOWN", "移除入口的宿主结果不明,请先检查片段。")
1152
+ require(item.GetFusionCompCount() == 0, "APPLY_OUTCOME_UNKNOWN", "移除后的合成数量未能核实。")
1153
+ require_active_project(resolve, project)
1154
+ return {"removed": True, "itemId": item.GetUniqueId(), "identity": identity}
1155
+ if owned:
1156
+ return {"attached": True, "reused": True, "itemId": item.GetUniqueId(), "entry": owned, "nativeValidation": "pending"}
1157
+ require(item.GetFusionCompCount() == 0, "TARGET_OCCUPIED", "片段已有 Fusion 合成,未覆盖。")
1158
+ before = timeline_snapshot(project, scope, timeline, resolve)
1159
+ targets = [value for value in before["items"] if value["id"] == item.GetUniqueId()]
1160
+ require(len(targets) == 1 and not targets[0]["unsupported"] and not targets[0].get("trackLocked"),
1161
+ "UNSUPPORTED_TIMELINE_ITEM", "实验入口只接受完整读回通过、未锁定的普通 Motion Harness 片段。")
1162
+ with open(params["compositionPath"], "rb") as stream:
1163
+ data = stream.read()
1164
+ require(hashlib.sha256(data).hexdigest() == params["compositionSha256"] and data.startswith(b"-- MH Resolve Bridge inspector v1\n"),
1165
+ "VERSION_CONFLICT", "待挂载入口文件已改变。")
1166
+ require(selection_snapshot(resolve)["fingerprint"] == selected["fingerprint"], "VERSION_CONFLICT", "导入前所选片段已改变。")
1167
+ # From this point a native object may exist even when readback fails. Never
1168
+ # blindly import again or roll back another controller's composition.
1169
+ try:
1170
+ comp = item.ImportFusionComp(params["compositionPath"])
1171
+ require(comp and item.GetFusionCompCount() == 1, "APPLY_OUTCOME_UNKNOWN", "入口导入结果不明。")
1172
+ macro, media_in, media_out = (comp.FindTool(name) for name in ("MHBridge", "MediaIn1", "MediaOut1"))
1173
+ require(macro and media_in and media_out, "APPLY_OUTCOME_UNKNOWN", "入口图结构未能读回。")
1174
+ media_in.SetInput("MediaID", item.GetMediaPoolItem().GetMediaId(), 0)
1175
+ require(macro.ConnectInput("MainInput1", media_in.FindMainOutput(1)) and media_out.ConnectInput("Input", macro.FindMainOutput(1)),
1176
+ "APPLY_OUTCOME_UNKNOWN", "入口图像连接未能核实。")
1177
+ receipt = {"schemaVersion": 1, "identity": identity, "graphFingerprint": inspector.graph(comp, identity), "nativeValidation": "pending"}
1178
+ evidence = otio_evidence(resolve, timeline)
1179
+ rate = before["fps"]["num"] / before["fps"]["den"]
1180
+ target = targets[0]
1181
+ details = evidence["tracks"][target["track"] - 1]
1182
+ matches = []
1183
+ for clip in details["clips"]:
1184
+ if clip["startSeconds"] is None or clip["durationSeconds"] is None: continue
1185
+ parsed = urlparse(clip.get("path") or "")
1186
+ path = unquote(parsed.path) if parsed.scheme == "file" and parsed.netloc in ("", "localhost") else clip.get("path")
1187
+ if (abs(clip["startSeconds"] * rate - (target["startFrame"] - before["startFrame"])) < 0.01
1188
+ and abs(clip["durationSeconds"] * rate - (target["endFrame"] - target["startFrame"])) < 0.01
1189
+ and path and os.path.realpath(path) == os.path.realpath(item.GetMediaPoolItem().GetClipProperty("File Path"))):
1190
+ matches.append(clip)
1191
+ require(len(matches) == 1, "APPLY_OUTCOME_UNKNOWN", "无法将入口的 OTIO 效果对应到明确片段。")
1192
+ receipt["otioEffectsFingerprint"] = inspector.signature(inspector.unknown_effects(matches[0].get("effects", []), SUPPORTED_INSPECTOR_EFFECTS))
1193
+ comp.SetData(inspector.DATA_KEY, receipt)
1194
+ require(inspector.read_entry(item) == receipt, "APPLY_OUTCOME_UNKNOWN", "入口身份和图结构未能完整核实。")
1195
+ require_active_project(resolve, project)
1196
+ return {"attached": True, "reused": False, "itemId": item.GetUniqueId(), "entry": receipt, "nativeValidation": "pending"}
1197
+ except Exception as error:
1198
+ raise BridgeFailure("APPLY_OUTCOME_UNKNOWN", "检查器挂载可能已部分完成,请先检查目标片段,不要直接重放导入。", diagnostic=str(error), itemId=item.GetUniqueId())
1199
+
1200
+
1201
+ def dispatch(resolve, operation, params):
1202
+ if operation == "ofx_source_target":
1203
+ return ofx_source_target(resolve, params)
1204
+ if operation == "selection":
1205
+ return selection_snapshot(resolve, plain_media_only=params.get("plainMediaOnly") is True)
1206
+ if operation == "inspector":
1207
+ return inspector_operation(resolve, params)
1208
+ if operation in ("connection", "target"):
1209
+ manager = resolve.GetProjectManager()
1210
+ project = manager.GetCurrentProject()
1211
+ timeline = project.GetCurrentTimeline() if project else None
1212
+ if operation == "connection":
1213
+ return {"connected": True, "version": resolve.GetVersionString(), "scope": scope_for(manager, project) if project else None,
1214
+ "timelineId": timeline.GetUniqueId() if timeline else None, "projectName": project.GetName() if project else None}
1215
+ require(project and timeline, "TIMELINE_MISSING", "请打开一个达芬奇工程和时间线。")
1216
+ rate = fps_value(timeline.GetSetting("timelineFrameRate"))
1217
+ code = timeline.GetCurrentTimecode()
1218
+ drop = ";" in code or str(timeline.GetSetting("timelineDropFrameTimecode")) == "1"
1219
+ pieces = code.replace(";", ":").split(":")
1220
+ require(len(pieces) == 4 and all(value.isdigit() for value in pieces), "INVALID_RANGE", "无法读取当前播放头时间码。")
1221
+ hours, minutes, seconds, frames = map(int, pieces)
1222
+ nominal = round(rate["num"] / rate["den"])
1223
+ frame = (hours * 3600 + minutes * 60 + seconds) * nominal + frames
1224
+ if drop:
1225
+ require(nominal == 30, "UNSUPPORTED_FPS", "当前丢帧时间码尚不支持。")
1226
+ total_minutes = hours * 60 + minutes
1227
+ frame -= 2 * (total_minutes - total_minutes // 10)
1228
+ return {"scope": scope_for(manager, project), "timelineId": timeline.GetUniqueId(), "recordFrame": frame, "timecode": code}
1229
+ if operation == "discover":
1230
+ manager = resolve.GetProjectManager()
1231
+ project = manager.GetCurrentProject()
1232
+ return {"product": resolve.GetProductName(), "version": resolve.GetVersionString(),
1233
+ "scope": scope_for(manager, project) if project else None,
1234
+ "timelines": [{"id": t.GetUniqueId(), "name": t.GetName()} for t in timelines(project)] if project else [],
1235
+ "adapter": "documented-python-scripting", "officialMcp": "not-tested-by-scripting",
1236
+ "replacement": "stable-playback-file-with-media-identity" if validated_host(resolve) else "host-build-unverified",
1237
+ "frameConvention": "half-open" if validated_host(resolve) else "unverified",
1238
+ "profile": {"platform": sys.platform, "architecture": platform.machine(), "basicBridgeValidated": validated_host(resolve)},
1239
+ "timelineReadback": "top-level-video; native speed/properties/fades and conservative OTIO structure check"}
1240
+ proof = params if operation in ("render_status", "cancel_render") else None
1241
+ project, scope = project_for(resolve, params.get("scope"), proof)
1242
+ if operation == "entry_target":
1243
+ require(validated_host(resolve), "CAPABILITY_UNAVAILABLE", "该 Resolve 平台与构建尚未验证所选片段管理。")
1244
+ return entry_target(resolve, project, scope, params)
1245
+ if operation in ("legacy_preview", "legacy_convert", "legacy_check"):
1246
+ require(validated_host(resolve), "CAPABILITY_UNAVAILABLE", "该 Resolve 平台与构建尚未验证旧生成器结构转换。")
1247
+ require(not project.IsRenderingInProgress(), "RESOLVE_BUSY", "Resolve 正在导出。")
1248
+ action = {"legacy_preview": legacy.preview, "legacy_convert": legacy.convert, "legacy_check": legacy.check}[operation]
1249
+ return action(resolve, project, scope, params, globals())
1250
+ if operation == "duplicate_instance":
1251
+ require(validated_host(resolve), "CAPABILITY_UNAVAILABLE", "该 Resolve 平台与构建尚未验证单实例独立副本。")
1252
+ require(not project.IsRenderingInProgress(), "RESOLVE_BUSY", "Resolve 正在导出。")
1253
+ return instances.duplicate_instance(resolve, project, scope, params, globals())
1254
+ if operation == "timeline":
1255
+ return timeline_snapshot(project, scope, timeline_for(project, params["timelineId"]), resolve)
1256
+ if operation == "find_import":
1257
+ path = os.path.realpath(params["filePath"])
1258
+ found = [media for media in all_media(project.GetMediaPool().GetRootFolder()) if os.path.realpath(media.GetClipProperty("File Path") or "") == path]
1259
+ require(len(found) <= 1, "RECOVERY_CONFLICT", "多个素材池对象指向待恢复的产物,未按名称猜测。")
1260
+ return media_snapshot(project, scope, found[0], resolve) if found else None
1261
+ if operation == "preview":
1262
+ return preview_original(resolve, project, scope, params) if params.get("mediaPolicy") == "originals" else preview_frame(resolve, project, scope, params)
1263
+ if operation == "create_render":
1264
+ return create_render(resolve, project, scope, params)
1265
+ if operation == "start_render":
1266
+ return start_render(resolve, project, scope, params)
1267
+ if operation == "finish_render":
1268
+ return finish_render(resolve, project, params)
1269
+ if operation == "export_references":
1270
+ return export_references(resolve, project, scope, params)
1271
+ if operation == "find_render":
1272
+ path = os.path.realpath(params["filePath"])
1273
+ jobs = [job for job in project.GetRenderJobList() or [] if os.path.realpath(os.path.join(job.get("TargetDir", ""), job.get("OutputFilename", ""))) == path]
1274
+ require(len(jobs) <= 1, "RECOVERY_CONFLICT", "多个队列项指向同一输出路径,未按名称猜测。")
1275
+ expected = params.get("expected")
1276
+ if jobs and expected:
1277
+ job, output, bounds = jobs[0], expected["output"], expected["range"]
1278
+ timeline = timeline_for(project, expected["timelineId"])
1279
+ require(sum(t.GetName() == timeline.GetName() for t in timelines(project)) == 1, "RECOVERY_CONFLICT", "队列接口缺少稳定时间线 ID,存在同名时间线时不能据队列显示名称恢复。")
1280
+ require(job.get("TimelineName") == timeline.GetName() and job.get("FormatWidth") == output["width"] and job.get("FormatHeight") == output["height"] and fps_value(job.get("FrameRate")) == output["fps"]
1281
+ and job.get("MarkIn") == bounds["startFrame"] and job.get("MarkOut") == bounds["endFrame"] - 1 and job.get("VideoCodec") == "H.264" and job.get("IsExportAudio") is False and job.get("ExportAlpha") is False,
1282
+ "RECOVERY_CONFLICT", "中断队列项的时间线、区间或格式与原请求不一致。")
1283
+ return {"jobId": jobs[0]["JobId"], "status": project.GetRenderJobStatus(jobs[0]["JobId"]), "jobFingerprint": fingerprint(jobs[0])} if jobs else None
1284
+ if operation == "find_binding":
1285
+ found = []
1286
+ for media in all_media(project.GetMediaPool().GetRootFolder()):
1287
+ try:
1288
+ link = json.loads((media.GetThirdPartyMetadata() or {}).get("motionharness.bridge", "{}"))
1289
+ if link.get("bindingId") == params["bindingId"]:
1290
+ found.append(media)
1291
+ except (ValueError, TypeError):
1292
+ pass
1293
+ require(len(found) <= 1, "RECOVERY_CONFLICT", "多个媒体对象拥有同一绑定身份,未自动选择。")
1294
+ return media_snapshot(project, scope, found[0], resolve) if found else None
1295
+ if operation in ("render_status", "cancel_render"):
1296
+ if operation == "cancel_render":
1297
+ require(validated_host(resolve), "CAPABILITY_UNAVAILABLE", "该构建尚未验证指定任务取消。")
1298
+ render_job(project, params["jobId"])
1299
+ status = project.GetRenderJobStatus(params["jobId"])
1300
+ if operation == "cancel_render" and status.get("JobStatus") == "Rendering":
1301
+ running = [job["JobId"] for job in project.GetRenderJobList() or [] if project.GetRenderJobStatus(job["JobId"]).get("JobStatus") == "Rendering"]
1302
+ require(running == [params["jobId"]], "RESOLVE_BUSY", "有其他任务正在运行,未调用全局 StopRendering。", running=running)
1303
+ project.StopRendering()
1304
+ status = project.GetRenderJobStatus(params["jobId"])
1305
+ return status
1306
+ if operation == "media_identity":
1307
+ media = media_for(project, params["mediaPoolItemId"])
1308
+ result = media_identity(project, scope, media)
1309
+ require_active_project(resolve, project)
1310
+ return result
1311
+ if operation in ("media", "bind", "replace"):
1312
+ media = media_for(project, params["mediaPoolItemId"])
1313
+ if operation == "media":
1314
+ return media_snapshot(project, scope, media, resolve)
1315
+ before = media_identity(project, scope, media)
1316
+ require(not project.IsRenderingInProgress(), "RESOLVE_BUSY", "Resolve 正在导出,桥接没有修改媒体。")
1317
+ require(validated_host(resolve), "CAPABILITY_UNAVAILABLE", "该 Resolve 平台与构建尚未验证自动修改的帧边界。")
1318
+ require(before["fingerprint"] == params["expectedFingerprint"], "VERSION_CONFLICT", "Resolve 原素材的身份、关联或媒体规格已变化。")
1319
+ if operation == "replace":
1320
+ repair = params.get("offlineRepair")
1321
+ path = os.path.realpath(params["filePath"])
1322
+ publication = params.get("publication")
1323
+ require(publication or os.path.isfile(path), "ARTIFACT_MISSING", "新产物不存在。")
1324
+ if repair:
1325
+ require(not os.path.exists(before["path"]) and os.path.realpath(before["path"]) == os.path.realpath(repair["previousPath"])
1326
+ and before["metadata"].get("motionharness.bridge") == params["metadata"].get("motionharness.bridge"),
1327
+ "RECOVERY_CONFLICT", "离线搬迁修复只允许原绑定、原产物身份和已记录的旧路径。")
1328
+ require(file_digest(publication["sourcePath"] if publication else path) == repair["sha256"],
1329
+ "ARTIFACT_CHANGED", "搬迁媒体与记录摘要不同。")
1330
+ require(not before["clipProperties"].get("Proxy Media Path"), "PROXY_UNSUPPORTED", "当前替换路径尚未验证已关联代理。")
1331
+ require(os.path.basename(path) == os.path.basename(before["path"]), "BASENAME_CONFLICT", "安全重连要求版本目录中的文件名保持一致;未修改现有素材。")
1332
+ if publication:
1333
+ require_active_project(resolve, project)
1334
+ require(media_identity(project, scope, media)["fingerprint"] == before["fingerprint"],
1335
+ "VERSION_CONFLICT", "发布前原素材的身份或关联已改变。")
1336
+ # Require the still-current metadata; no selection or effect-stored ID is consulted.
1337
+ old_link = json.loads(before["metadata"].get("motionharness.bridge") or "{}")
1338
+ new_link = json.loads(params["metadata"].get("motionharness.bridge") or "{}")
1339
+ require(not old_link or all(old_link.get(k) == new_link.get(k) for k in ("schemaVersion", "bindingId", "sourceId")),
1340
+ "BINDING_CONFLICT", "素材的关联身份已经改变。")
1341
+ publish_playback(path, publication, before["path"])
1342
+ require_active_project(resolve, project)
1343
+ require(media_identity(project, scope, media)["fingerprint"] == before["fingerprint"],
1344
+ "APPLY_OUTCOME_UNKNOWN" if publication else "VERSION_CONFLICT", "重连前原素材的身份或关联已改变。")
1345
+ require(project.GetMediaPool().RelinkClips([media], os.path.dirname(path)), "APPLY_OUTCOME_UNKNOWN", "Resolve 未确认重连版本目录,需读回核对后处理。")
1346
+ # Clip interpretation belongs to the existing cut. Relinking may
1347
+ # re-read container defaults; preserve the user's native settings.
1348
+ current_properties = media.GetClipProperty() or {}
1349
+ for key in ("Alpha mode", "Input Color Space", "Input Gamma", "Data Level", "PAR", "Field Dominance", "Clip Name"):
1350
+ wanted = before["clipProperties"].get(key)
1351
+ if wanted is not None and current_properties.get(key) != wanted:
1352
+ require(media.SetName(wanted) if key == "Clip Name" else media.SetClipProperty(key, wanted), "APPLY_OUTCOME_UNKNOWN", "素材已替换,但媒体解释未能恢复。", property=key, expected=wanted)
1353
+ readback = media.GetClipProperty() or {}
1354
+ require(all(readback.get(key) == before["clipProperties"].get(key) for key in ("Alpha mode", "Input Color Space", "Input Gamma", "Data Level", "PAR", "Field Dominance", "Clip Name") if key in before["clipProperties"]),
1355
+ "APPLY_OUTCOME_UNKNOWN", "媒体解释读回与替换前不同,需核对原生状态。")
1356
+ set_metadata(media, {**before["metadata"], **params["metadata"]})
1357
+ after = media_identity(project, scope, media)
1358
+ require_active_project(resolve, project)
1359
+ if operation == "replace":
1360
+ require(os.path.realpath(after["path"]) == path, "APPLY_OUTCOME_UNKNOWN", "替换后的原素材路径读回不一致。")
1361
+ for key in ("FPS", "Frames", "Resolution", "Audio Ch", "Input Color Space", "Input Gamma", "Data Level"):
1362
+ require(before["clipProperties"].get(key) == after["clipProperties"].get(key), "APPLY_OUTCOME_UNKNOWN", "替换后媒体规格或颜色解释改变。", property=key)
1363
+ return after
1364
+ if operation == "insert":
1365
+ require(not project.IsRenderingInProgress(), "RESOLVE_BUSY", "Resolve 正在导出。")
1366
+ require(validated_host(resolve), "CAPABILITY_UNAVAILABLE", "该 Resolve 平台与构建尚未验证插入的帧边界。")
1367
+ timeline = timeline_for(project, params["timelineId"])
1368
+ before = timeline_snapshot(project, scope, timeline, resolve)
1369
+ require(before["fingerprint"] == params["expectedTimelineFingerprint"], "VERSION_CONFLICT", "时间线在插入前发生变化。")
1370
+ start, end, track, record = params["sourceIn"], params["sourceOut"], params["track"], params["recordFrame"]
1371
+ spec = params.get("outputSpec") or {}
1372
+ require(spec.get("fps") and spec.get("frameCount"), "OUTPUT_SPEC_UNVERIFIED", "插入需要已验证的源帧率与帧数。")
1373
+ timeline_frames = native_timeline_frames(end - start, spec["fps"], before["fps"])
1374
+ require(0 <= start < end and 1 <= track <= timeline.GetTrackCount("video") + 1 and record >= before["startFrame"], "INVALID_RANGE", "轨道或源区间不合法。")
1375
+ require(track > timeline.GetTrackCount("video") or not timeline.GetIsTrackLocked("video", track), "TARGET_LOCKED", "目标视频轨已锁定,未导入或插入媒体。")
1376
+ require(not any(i["track"] == track and i["startFrame"] < record + timeline_frames and record < i["endFrame"] for i in before["items"]), "TARGET_OCCUPIED", "目标轨道区间已有片段。")
1377
+ path = os.path.realpath(params["filePath"])
1378
+ require(os.path.isfile(path), "ARTIFACT_MISSING", "插入产物不存在。")
1379
+ require(project.SetCurrentTimeline(timeline), "VERSION_CONFLICT", "无法明确切换到目标时间线。")
1380
+ await_timeline(project, timeline, resolve=resolve)
1381
+ # Resolve's insertion API is current-timeline based: select explicitly and read back.
1382
+ require(project.GetCurrentTimeline().GetUniqueId() == params["timelineId"], "VERSION_CONFLICT", "当前时间线与目标不一致。")
1383
+ save_insert_progress(params, before, "prepared")
1384
+ if track == timeline.GetTrackCount("video") + 1:
1385
+ require(timeline.AddTrack("video") and timeline.GetTrackCount("video") == track, "APPLY_OUTCOME_UNKNOWN", "新视频轨创建结果需要核对。")
1386
+ before = timeline_snapshot(project, scope, timeline, resolve)
1387
+ save_insert_progress(params, before, "track-added")
1388
+ existing = params.get("existingMediaPoolItemId")
1389
+ if existing:
1390
+ media = media_for(project, existing)
1391
+ require(os.path.realpath(media.GetClipProperty().get("File Path", "")) == path, "VERSION_CONFLICT", "共享素材路径已改变。")
1392
+ actual_metadata = media.GetThirdPartyMetadata() or {}
1393
+ require(all(actual_metadata.get(k) == v for k, v in params["metadata"].items()), "VERSION_CONFLICT", "共享素材的关联元数据已改变。")
1394
+ else:
1395
+ known_media = {item.GetUniqueId() for item in all_media(project.GetMediaPool().GetRootFolder())}
1396
+ imported = project.GetMediaPool().ImportMedia([path]) or []
1397
+ require(len(imported) == 1, "APPLY_OUTCOME_UNKNOWN", "导入结果需核对,未继续盲目插入。")
1398
+ media = imported[0]
1399
+ linked = (media.GetThirdPartyMetadata() or {}).get("motionharness.bridge")
1400
+ require(not linked or linked == params["metadata"].get("motionharness.bridge"), "APPLY_OUTCOME_UNKNOWN", "Resolve 返回了已关联对象,需核对绑定身份,未覆盖其元数据。")
1401
+ save_insert_progress(params, before, "imported", media)
1402
+ set_metadata(media, params["metadata"])
1403
+ save_insert_progress(params, before, "identified", media)
1404
+ newly_imported = not existing and media.GetUniqueId() not in known_media
1405
+ if newly_imported and params.get("sourceName"):
1406
+ require(media.SetName(params["sourceName"]) and media.GetName() == params["sourceName"],
1407
+ "APPLY_OUTCOME_UNKNOWN", "媒体已导入,但工程名称没有确认;尚未插入时间线。")
1408
+ initialize_imported = params.get("initializeImported") is True
1409
+ if initialize_imported:
1410
+ imported_state = media_snapshot(project, scope, media, resolve)
1411
+ require(imported_state["usageScanComplete"] and not imported_state["uses"], "RECOVERY_CONFLICT", "待恢复的导入媒体已有时间线引用,未重新解释透明通道。")
1412
+ if (newly_imported or initialize_imported) and params.get("outputSpec", {}).get("alpha") == "straight":
1413
+ require(media.SetClipProperty("Alpha mode", "Straight") and (media.GetClipProperty() or {}).get("Alpha mode") == "Straight",
1414
+ "APPLY_OUTCOME_UNKNOWN", "媒体已导入,但直通透明解释没有确认;尚未插入时间线。")
1415
+ require(project.GetCurrentTimeline().GetUniqueId() == params["timelineId"], "APPLY_OUTCOME_UNKNOWN", "导入后当前时间线发生变化,已保留导入对象以便恢复。")
1416
+ actual = timeline_snapshot(project, scope, timeline, resolve)
1417
+ require(actual["fingerprint"] == before["fingerprint"], "APPLY_OUTCOME_UNKNOWN", "导入期间目标时间线发生变化,停止插入。")
1418
+ created = project.GetMediaPool().AppendToTimeline([{"mediaPoolItem": media, "startFrame": start, "endFrame": end,
1419
+ "mediaType": 1, "trackIndex": track, "recordFrame": record}]) or []
1420
+ require(len(created) == 1, "APPLY_OUTCOME_UNKNOWN", "Resolve 未返回唯一插入对象,需核对后恢复。")
1421
+ save_insert_progress(params, before, "appended", media, created[0])
1422
+ item = item_snapshot(created[0], timeline.GetUniqueId(), track, True, {media.GetUniqueId(): media}, before["fps"])
1423
+ sampling_matches = item["sourceIn"] == start and item["sourceOut"] == end
1424
+ if spec["fps"] != before["fps"]:
1425
+ expected_start = start * spec["fps"]["den"] / spec["fps"]["num"]
1426
+ expected_end = sample_end_time(start, timeline_frames, spec["fps"], before["fps"], spec["frameCount"])
1427
+ sampling_matches = abs(item["sourceStartTime"] - expected_start) < 1e-6 and abs(item["sourceEndTime"] - expected_end) < 1e-6
1428
+ require(item["startFrame"] == record and item["endFrame"] == record + timeline_frames and sampling_matches,
1429
+ "APPLY_OUTCOME_UNKNOWN", "实际插入的位置、源区间或帧数不匹配。", actual=item)
1430
+ return {"media": media_snapshot(project, scope, media, resolve), "item": item}
1431
+ raise BridgeFailure("CAPABILITY_UNAVAILABLE", "该脚本操作尚未实现。", operation=operation)
1432
+
1433
+
1434
+ def main():
1435
+ try:
1436
+ scratch = os.environ.get("MH_BRIDGE_SCRATCH")
1437
+ if scratch:
1438
+ os.makedirs(scratch, mode=0o700, exist_ok=True)
1439
+ tempfile.tempdir = scratch
1440
+ request = json.load(sys.stdin)
1441
+ result = dispatch(connect(), request["operation"], request.get("params", {}))
1442
+ response = {"ok": True, "result": result}
1443
+ except BridgeFailure as error:
1444
+ response = {"ok": False, "error": {"code": error.code, "message": error.message, "details": error.details}}
1445
+ except Exception as error:
1446
+ response = {"ok": False, "error": {"code": "RESOLVE_ADAPTER_ERROR", "message": str(error)}}
1447
+ print(json.dumps(response, ensure_ascii=False))
1448
+
1449
+
1450
+ if __name__ == "__main__":
1451
+ main()