@motionharness/bridge-davinci 0.0.0-stage → 0.0.22

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