resolvescript 0.1.2__py3-none-any.whl
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.
- resolve_script/__init__.py +3 -0
- resolve_script/analyze.py +277 -0
- resolve_script/cli.py +748 -0
- resolve_script/config.py +62 -0
- resolve_script/consolidate.py +604 -0
- resolve_script/fetch.py +90 -0
- resolve_script/install/__init__.py +49 -0
- resolve_script/install/discovery.py +57 -0
- resolve_script/install/installer.py +397 -0
- resolve_script/install/registry.py +106 -0
- resolve_script/manifest/__init__.py +1 -0
- resolve_script/manifest/json_reader.py +40 -0
- resolve_script/manifest/model.py +316 -0
- resolve_script/manifest/validation.py +81 -0
- resolve_script/manifest/xml_reader.py +162 -0
- resolve_script/package.py +103 -0
- resolve_script/resolver.py +204 -0
- resolve_script/sandbox/__init__.py +38 -0
- resolve_script/sandbox/api.py +393 -0
- resolve_script/sandbox/env.py +82 -0
- resolve_script/sandbox/loader.py +72 -0
- resolve_script/sandbox/repl.py +57 -0
- resolve_script/sandbox/smoke.py +104 -0
- resolve_script/scaffold.py +126 -0
- resolve_script/semver.py +236 -0
- resolve_script/sources/__init__.py +15 -0
- resolve_script/sources/archive.py +82 -0
- resolve_script/sources/git.py +107 -0
- resolve_script/sources/known.py +47 -0
- resolve_script/sources/release.py +55 -0
- resolve_script/spec.py +137 -0
- resolve_script/templates/extension/@NAME@/__init__.py +7 -0
- resolve_script/templates/extension/@NAME@/menu.py +12 -0
- resolve_script/templates/extension/@NAME@.py +13 -0
- resolve_script/templates/extension/README.md +20 -0
- resolve_script/templates/extension/conftest.py +13 -0
- resolve_script/templates/extension/manifest.json.j2 +23 -0
- resolve_script/templates/extension/manifest.xml.j2 +24 -0
- resolve_script/templates/extension/tests/test_smoke.py +26 -0
- resolve_script/templates/inapp/register.py +28 -0
- resolve_script/testing/__init__.py +6 -0
- resolve_script/testing/fixtures.py +47 -0
- resolve_script/workspace.py +66 -0
- resolvescript-0.1.2.dist-info/METADATA +146 -0
- resolvescript-0.1.2.dist-info/RECORD +49 -0
- resolvescript-0.1.2.dist-info/WHEEL +5 -0
- resolvescript-0.1.2.dist-info/entry_points.txt +2 -0
- resolvescript-0.1.2.dist-info/licenses/LICENSE +21 -0
- resolvescript-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Mock DaVinci Resolve / Fusion API objects for headless development.
|
|
2
|
+
|
|
3
|
+
The classes here mirror the surface area a Resolve script actually touches:
|
|
4
|
+
Resolve connection, project management, timelines, clips, the media pool,
|
|
5
|
+
and a Fusion comp (tools, splines, strokes). They are intentionally small —
|
|
6
|
+
enough to be useful, not a full emulation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
_SPLINE_TYPES = ("BSpline", "Polyline")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FakeResolve:
|
|
18
|
+
def __init__(self, project_manager: Any):
|
|
19
|
+
self._pm = project_manager
|
|
20
|
+
|
|
21
|
+
def GetProjectManager(self) -> Any:
|
|
22
|
+
return self._pm
|
|
23
|
+
|
|
24
|
+
def __repr__(self) -> str:
|
|
25
|
+
return "<FakeResolve>"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FakeFusion:
|
|
29
|
+
def __init__(self, comp: Any):
|
|
30
|
+
self._comp = comp
|
|
31
|
+
|
|
32
|
+
def GetCurrentComp(self) -> Any:
|
|
33
|
+
return self._comp
|
|
34
|
+
|
|
35
|
+
def __repr__(self) -> str:
|
|
36
|
+
return "<FakeFusion>"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FakeProjectManager:
|
|
40
|
+
def __init__(self, projects: dict[str, Any], current: str):
|
|
41
|
+
self._projects = projects
|
|
42
|
+
self._current = current
|
|
43
|
+
|
|
44
|
+
def GetCurrentProject(self) -> Any:
|
|
45
|
+
return self._projects.get(self._current)
|
|
46
|
+
|
|
47
|
+
def GetProject(self, name: str) -> Any | None:
|
|
48
|
+
return self._projects.get(name)
|
|
49
|
+
|
|
50
|
+
def GetProjectList(self) -> list[str]:
|
|
51
|
+
return list(self._projects)
|
|
52
|
+
|
|
53
|
+
def __repr__(self) -> str:
|
|
54
|
+
return f"<FakeProjectManager projects={list(self._projects)}>"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class FakeProject:
|
|
58
|
+
def __init__(self, name: str, timelines: list[Any], media_pool: Any):
|
|
59
|
+
self._name = name
|
|
60
|
+
self._timelines = timelines
|
|
61
|
+
self._media_pool = media_pool
|
|
62
|
+
self._current = 0
|
|
63
|
+
|
|
64
|
+
def GetName(self) -> str:
|
|
65
|
+
return self._name
|
|
66
|
+
|
|
67
|
+
def GetTimelineCount(self) -> int:
|
|
68
|
+
return len(self._timelines)
|
|
69
|
+
|
|
70
|
+
def GetTimelineByIndex(self, index: int) -> Any:
|
|
71
|
+
return self._timelines[index - 1]
|
|
72
|
+
|
|
73
|
+
def GetTimelineByName(self, name: str) -> Any | None:
|
|
74
|
+
for timeline in self._timelines:
|
|
75
|
+
if timeline.GetName() == name:
|
|
76
|
+
return timeline
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def GetCurrentTimeline(self) -> Any:
|
|
80
|
+
return self._timelines[self._current]
|
|
81
|
+
|
|
82
|
+
def GetMediaPool(self) -> Any:
|
|
83
|
+
return self._media_pool
|
|
84
|
+
|
|
85
|
+
def GetRenderJobList(self) -> list[Any]:
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
def __repr__(self) -> str:
|
|
89
|
+
return f"<FakeProject {self._name!r}>"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class FakeTimeline:
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
name: str,
|
|
96
|
+
start_frame: int,
|
|
97
|
+
end_frame: int,
|
|
98
|
+
fps: float,
|
|
99
|
+
video_tracks: list[list[Any]],
|
|
100
|
+
audio_tracks: list[list[Any]],
|
|
101
|
+
):
|
|
102
|
+
self._name = name
|
|
103
|
+
self._start = start_frame
|
|
104
|
+
self._end = end_frame
|
|
105
|
+
self._fps = fps
|
|
106
|
+
self._video = video_tracks
|
|
107
|
+
self._audio = audio_tracks
|
|
108
|
+
self._tc = "01:00:00:00"
|
|
109
|
+
|
|
110
|
+
def GetName(self) -> str:
|
|
111
|
+
return self._name
|
|
112
|
+
|
|
113
|
+
def GetStartFrame(self) -> int:
|
|
114
|
+
return self._start
|
|
115
|
+
|
|
116
|
+
def GetEndFrame(self) -> int:
|
|
117
|
+
return self._end
|
|
118
|
+
|
|
119
|
+
def GetSetting(self, key: str) -> Any:
|
|
120
|
+
if key == "timelineFrameRate":
|
|
121
|
+
return self._fps
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def GetTrackCount(self, track_type: str) -> int:
|
|
125
|
+
return len(self._video if track_type == "video" else self._audio)
|
|
126
|
+
|
|
127
|
+
def GetItemListInTrack(self, track_type: str, track_index: int) -> list[Any]:
|
|
128
|
+
tracks = self._video if track_type == "video" else self._audio
|
|
129
|
+
if track_index < 1 or track_index > len(tracks):
|
|
130
|
+
return []
|
|
131
|
+
return list(tracks[track_index - 1])
|
|
132
|
+
|
|
133
|
+
def GetTrackName(self, track_type: str, track_index: int) -> str:
|
|
134
|
+
return f"{track_type.capitalize()} Track {track_index}"
|
|
135
|
+
|
|
136
|
+
def SetCurrentTimecode(self, timecode: str) -> bool:
|
|
137
|
+
self._tc = timecode
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
def GetCurrentTimecode(self) -> str:
|
|
141
|
+
return self._tc
|
|
142
|
+
|
|
143
|
+
def __repr__(self) -> str:
|
|
144
|
+
return f"<FakeTimeline {self._name!r}>"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class FakeClip:
|
|
148
|
+
def __init__(self, name: str, start: int, end: int, media_item: Any):
|
|
149
|
+
self._name = name
|
|
150
|
+
self._start = start
|
|
151
|
+
self._end = end
|
|
152
|
+
self._item = media_item
|
|
153
|
+
self._comps: list[Any] = []
|
|
154
|
+
|
|
155
|
+
def GetName(self) -> str:
|
|
156
|
+
return self._name
|
|
157
|
+
|
|
158
|
+
def GetStart(self) -> int:
|
|
159
|
+
return self._start
|
|
160
|
+
|
|
161
|
+
def GetEnd(self) -> int:
|
|
162
|
+
return self._end
|
|
163
|
+
|
|
164
|
+
def GetDuration(self) -> int:
|
|
165
|
+
return self._end - self._start + 1
|
|
166
|
+
|
|
167
|
+
def GetMediaPoolItem(self) -> Any:
|
|
168
|
+
return self._item
|
|
169
|
+
|
|
170
|
+
def GetFusionCompCount(self) -> int:
|
|
171
|
+
return len(self._comps)
|
|
172
|
+
|
|
173
|
+
def GetFusionCompByIndex(self, index: int) -> Any | None:
|
|
174
|
+
if 1 <= index <= len(self._comps):
|
|
175
|
+
return self._comps[index - 1]
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
def AddFusionComp(self) -> Any:
|
|
179
|
+
comp = FakeComp(f"{self._name} Comp {len(self._comps) + 1}")
|
|
180
|
+
self._comps.append(comp)
|
|
181
|
+
return comp
|
|
182
|
+
|
|
183
|
+
def __repr__(self) -> str:
|
|
184
|
+
return f"<FakeClip {self._name!r}>"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class FakeMediaPoolItem:
|
|
188
|
+
def __init__(self, name: str, props: dict[str, str] | None = None):
|
|
189
|
+
self._name = name
|
|
190
|
+
self._props = props or {"FPS": "24", "Duration": "100", "Width": "1920", "Height": "1080"}
|
|
191
|
+
|
|
192
|
+
def GetName(self) -> str:
|
|
193
|
+
return self._name
|
|
194
|
+
|
|
195
|
+
def GetClipProperty(self, key: str) -> str | None:
|
|
196
|
+
return self._props.get(key)
|
|
197
|
+
|
|
198
|
+
def SetClipProperty(self, key: str, value: str) -> bool:
|
|
199
|
+
self._props[key] = value
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
def __repr__(self) -> str:
|
|
203
|
+
return f"<FakeMediaPoolItem {self._name!r}>"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class FakeFolder:
|
|
207
|
+
def __init__(self, name: str, clips: list[Any], subfolders: list[Any] | None = None):
|
|
208
|
+
self._name = name
|
|
209
|
+
self._clips = clips
|
|
210
|
+
self._subfolders = subfolders or []
|
|
211
|
+
|
|
212
|
+
def GetName(self) -> str:
|
|
213
|
+
return self._name
|
|
214
|
+
|
|
215
|
+
def GetClipList(self) -> list[Any]:
|
|
216
|
+
return list(self._clips)
|
|
217
|
+
|
|
218
|
+
def GetSubFolderList(self) -> list[Any]:
|
|
219
|
+
return list(self._subfolders)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class FakeMediaPool:
|
|
223
|
+
def __init__(self, root: Any):
|
|
224
|
+
self._root = root
|
|
225
|
+
|
|
226
|
+
def GetRootFolder(self) -> Any:
|
|
227
|
+
return self._root
|
|
228
|
+
|
|
229
|
+
def ImportMedia(self, file_paths: list[str]) -> list[Any]:
|
|
230
|
+
return [FakeMediaPoolItem(Path(p).stem) for p in file_paths]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class FakeKey:
|
|
234
|
+
def __init__(self, frame: int, x: float, y: float):
|
|
235
|
+
self.Frame = frame
|
|
236
|
+
self.X = x
|
|
237
|
+
self.Y = y
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class FakeSpline:
|
|
241
|
+
def __init__(self) -> None:
|
|
242
|
+
self._keys: dict[int, FakeKey] = {}
|
|
243
|
+
|
|
244
|
+
def AddKey(self, frame: int, value: dict[str, float]) -> None:
|
|
245
|
+
self._keys[frame] = FakeKey(frame, value.get("X", 0.0), value.get("Y", 0.0))
|
|
246
|
+
|
|
247
|
+
def DeleteKey(self, frame: int) -> None:
|
|
248
|
+
self._keys.pop(frame, None)
|
|
249
|
+
|
|
250
|
+
def DeleteAllKeys(self) -> None:
|
|
251
|
+
self._keys.clear()
|
|
252
|
+
|
|
253
|
+
def GetKeyCount(self) -> int:
|
|
254
|
+
return len(self._keys)
|
|
255
|
+
|
|
256
|
+
def GetKey(self, index: int) -> FakeKey:
|
|
257
|
+
ordered = sorted(self._keys.values(), key=lambda k: k.Frame)
|
|
258
|
+
return ordered[index]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class FakeStroke:
|
|
262
|
+
def __init__(self, number: int):
|
|
263
|
+
self._attrs = {
|
|
264
|
+
"TOOLS_Name": f"Stroke_{number}",
|
|
265
|
+
"BrushType": "Stroke",
|
|
266
|
+
"BrushColor": (1.0, 1.0, 1.0, 1.0),
|
|
267
|
+
"BrushSize": 10.0,
|
|
268
|
+
"TOOLB_StartFrame": 0,
|
|
269
|
+
"TOOLB_EndFrame": 0,
|
|
270
|
+
}
|
|
271
|
+
self._points: list[tuple[float, float, float]] = []
|
|
272
|
+
|
|
273
|
+
def SetAttrs(self, attrs: dict[str, Any]) -> None:
|
|
274
|
+
self._attrs.update(attrs)
|
|
275
|
+
|
|
276
|
+
def GetAttrs(self) -> dict[str, Any]:
|
|
277
|
+
return dict(self._attrs)
|
|
278
|
+
|
|
279
|
+
def AddPoint(self, x: float, y: float, frame: float) -> None:
|
|
280
|
+
self._points.append((x, y, frame))
|
|
281
|
+
int_frame = int(frame)
|
|
282
|
+
prev_start = self._attrs.get("TOOLB_StartFrame") or int_frame
|
|
283
|
+
prev_end = self._attrs.get("TOOLB_EndFrame") or int_frame
|
|
284
|
+
self._attrs["TOOLB_StartFrame"] = min(prev_start, int_frame)
|
|
285
|
+
self._attrs["TOOLB_EndFrame"] = max(prev_end, int_frame)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class FakeTool:
|
|
289
|
+
def __init__(self, regid: str, name: str, x: float, y: float):
|
|
290
|
+
self._attrs = {
|
|
291
|
+
"TOOLS_Name": name,
|
|
292
|
+
"TOOLS_RegID": regid,
|
|
293
|
+
"TOOLS_PosX": x,
|
|
294
|
+
"TOOLS_PosY": y,
|
|
295
|
+
}
|
|
296
|
+
self._spline = FakeSpline() if regid in _SPLINE_TYPES else None
|
|
297
|
+
self._strokes: dict[str, Any] = {}
|
|
298
|
+
|
|
299
|
+
def GetAttrs(self) -> dict[str, Any]:
|
|
300
|
+
return dict(self._attrs)
|
|
301
|
+
|
|
302
|
+
def SetAttrs(self, attrs: dict[str, Any]) -> None:
|
|
303
|
+
self._attrs.update(attrs)
|
|
304
|
+
|
|
305
|
+
def GetInput(self, name: str) -> Any:
|
|
306
|
+
return self._spline if name == "Spline" else None
|
|
307
|
+
|
|
308
|
+
def GetInputList(self) -> dict[str, Any]:
|
|
309
|
+
return {}
|
|
310
|
+
|
|
311
|
+
def GetOutputList(self) -> dict[str, Any]:
|
|
312
|
+
return {}
|
|
313
|
+
|
|
314
|
+
def SetInput(self, index: int | str, value: Any) -> bool:
|
|
315
|
+
return True
|
|
316
|
+
|
|
317
|
+
def ConnectInput(self, index: int | str, source: Any) -> bool:
|
|
318
|
+
self._attrs.setdefault("_connected", {})[index] = source
|
|
319
|
+
return True
|
|
320
|
+
|
|
321
|
+
def AddStroke(self) -> Any:
|
|
322
|
+
stroke = FakeStroke(len(self._strokes) + 1)
|
|
323
|
+
self._strokes[f"Stroke{len(self._strokes) + 1}"] = stroke
|
|
324
|
+
return stroke
|
|
325
|
+
|
|
326
|
+
def GetStrokeList(self) -> dict[str, Any]:
|
|
327
|
+
return dict(self._strokes)
|
|
328
|
+
|
|
329
|
+
def DeleteStroke(self, stroke: Any) -> None:
|
|
330
|
+
for key, value in list(self._strokes.items()):
|
|
331
|
+
if value is stroke:
|
|
332
|
+
del self._strokes[key]
|
|
333
|
+
|
|
334
|
+
def __repr__(self) -> str:
|
|
335
|
+
return f"<FakeTool {self._attrs['TOOLS_Name']!r} {self._attrs['TOOLS_RegID']}>"
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class FakeComp:
|
|
339
|
+
def __init__(
|
|
340
|
+
self,
|
|
341
|
+
name: str,
|
|
342
|
+
width: int = 1920,
|
|
343
|
+
height: int = 1080,
|
|
344
|
+
fps: float = 24.0,
|
|
345
|
+
start: int = 0,
|
|
346
|
+
end: int = 100,
|
|
347
|
+
):
|
|
348
|
+
self._attrs = {
|
|
349
|
+
"COMPS_Name": name,
|
|
350
|
+
"COMPS_Width": width,
|
|
351
|
+
"COMPS_Height": height,
|
|
352
|
+
"COMPS_FrameRate": fps,
|
|
353
|
+
"COMPS_RenderStart": start,
|
|
354
|
+
"COMPS_RenderEnd": end,
|
|
355
|
+
}
|
|
356
|
+
self._tools: dict[str, Any] = {}
|
|
357
|
+
self._counters: dict[str, int] = {}
|
|
358
|
+
|
|
359
|
+
def GetAttrs(self) -> dict[str, Any]:
|
|
360
|
+
return dict(self._attrs)
|
|
361
|
+
|
|
362
|
+
def SetAttrs(self, attrs: dict[str, Any]) -> None:
|
|
363
|
+
self._attrs.update(attrs)
|
|
364
|
+
|
|
365
|
+
def GetToolList(self, tool_type: str | None = None) -> dict[str, Any]:
|
|
366
|
+
if tool_type is None:
|
|
367
|
+
return dict(self._tools)
|
|
368
|
+
return {
|
|
369
|
+
name: tool
|
|
370
|
+
for name, tool in self._tools.items()
|
|
371
|
+
if tool.GetAttrs()["TOOLS_RegID"] == tool_type
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
def AddTool(self, tool_type: str, x: float = 0, y: float = 0) -> Any:
|
|
375
|
+
self._counters[tool_type] = self._counters.get(tool_type, 0) + 1
|
|
376
|
+
name = f"{tool_type}{self._counters[tool_type]}"
|
|
377
|
+
tool = FakeTool(tool_type, name, x, y)
|
|
378
|
+
self._tools[name] = tool
|
|
379
|
+
return tool
|
|
380
|
+
|
|
381
|
+
def DeleteTool(self, tool: Any) -> None:
|
|
382
|
+
for key, value in list(self._tools.items()):
|
|
383
|
+
if value is tool:
|
|
384
|
+
del self._tools[key]
|
|
385
|
+
|
|
386
|
+
def Save(self, path: str | None = None) -> bool:
|
|
387
|
+
return True
|
|
388
|
+
|
|
389
|
+
def Close(self) -> None:
|
|
390
|
+
pass
|
|
391
|
+
|
|
392
|
+
def __repr__(self) -> str:
|
|
393
|
+
return f"<FakeComp {self._attrs['COMPS_Name']!r}>"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Environment injection: build a mock Resolve session and install it.
|
|
2
|
+
|
|
3
|
+
``install_fake_resolve`` registers a fake ``DaVinciResolveScript`` module in
|
|
4
|
+
``sys.modules`` so any extension that does ``import DaVinciResolveScript`` runs
|
|
5
|
+
headless. It is idempotent — calling it again just replaces the module.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from types import ModuleType
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .api import (
|
|
15
|
+
FakeClip,
|
|
16
|
+
FakeComp,
|
|
17
|
+
FakeFolder,
|
|
18
|
+
FakeFusion,
|
|
19
|
+
FakeMediaPool,
|
|
20
|
+
FakeMediaPoolItem,
|
|
21
|
+
FakeProject,
|
|
22
|
+
FakeProjectManager,
|
|
23
|
+
FakeResolve,
|
|
24
|
+
FakeTimeline,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
FUSION_SCRIPT_MODULE = "DaVinciResolveScript"
|
|
28
|
+
|
|
29
|
+
DEFAULT_PROJECT = "Demo Project"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_default_env() -> tuple[FakeResolve, FakeFusion]:
|
|
33
|
+
"""Create a realistic mock session with clips, timelines and a Fusion comp."""
|
|
34
|
+
items = {f"Shot {i}": FakeMediaPoolItem(f"Shot {i}") for i in range(1, 7)}
|
|
35
|
+
|
|
36
|
+
def _clip(name: str, start: int, end: int) -> FakeClip:
|
|
37
|
+
return FakeClip(name, start, end, items[name])
|
|
38
|
+
|
|
39
|
+
video_1 = [
|
|
40
|
+
_clip("Shot 1", 1001, 1040),
|
|
41
|
+
None,
|
|
42
|
+
_clip("Shot 3", 1061, 1100),
|
|
43
|
+
]
|
|
44
|
+
video_2 = [
|
|
45
|
+
_clip("Shot 2", 1001, 1040),
|
|
46
|
+
_clip("Shot 4", 1120, 1180),
|
|
47
|
+
]
|
|
48
|
+
audio_1 = [
|
|
49
|
+
_clip("Shot 5", 1001, 1080),
|
|
50
|
+
_clip("Shot 6", 1081, 1180),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
timeline_1 = FakeTimeline("Main Timeline", 1001, 1200, 24.0, [video_1, video_2], [audio_1])
|
|
54
|
+
timeline_2 = FakeTimeline("Alt Timeline", 2001, 3000, 24.0, [video_2], [audio_1])
|
|
55
|
+
|
|
56
|
+
root = FakeFolder("Master", list(items.values()))
|
|
57
|
+
media_pool = FakeMediaPool(root)
|
|
58
|
+
project = FakeProject(DEFAULT_PROJECT, [timeline_1, timeline_2], media_pool)
|
|
59
|
+
pm = FakeProjectManager({DEFAULT_PROJECT: project}, DEFAULT_PROJECT)
|
|
60
|
+
|
|
61
|
+
resolve = FakeResolve(pm)
|
|
62
|
+
fusion = FakeFusion(FakeComp("Comp 1", start=1001, end=1200))
|
|
63
|
+
return resolve, fusion
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def fake_resolve_module() -> ModuleType:
|
|
67
|
+
"""Build (but do not install) a fake DaVinciResolveScript module."""
|
|
68
|
+
resolve, fusion = build_default_env()
|
|
69
|
+
module = ModuleType(FUSION_SCRIPT_MODULE)
|
|
70
|
+
|
|
71
|
+
def _scriptapp(kind: str) -> Any:
|
|
72
|
+
return resolve if kind == "Resolve" else fusion
|
|
73
|
+
|
|
74
|
+
module.scriptapp = _scriptapp # type: ignore[attr-defined]
|
|
75
|
+
return module
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def install_fake_resolve() -> ModuleType:
|
|
79
|
+
"""Register the fake DaVinciResolveScript module (idempotent)."""
|
|
80
|
+
module = fake_resolve_module()
|
|
81
|
+
sys.modules[FUSION_SCRIPT_MODULE] = module
|
|
82
|
+
return module
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Module loading helpers: source packages and consolidated single files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import importlib.util
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import ModuleType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def purge_module(prefix: str) -> None:
|
|
13
|
+
"""Remove a module (and its submodules) from ``sys.modules``."""
|
|
14
|
+
for key in [k for k in list(sys.modules) if k == prefix or k.startswith(prefix + ".")]:
|
|
15
|
+
del sys.modules[key]
|
|
16
|
+
importlib.invalidate_caches()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _entry_file(module_name: str, package_dir: Path) -> tuple[Path, str | None]:
|
|
20
|
+
"""Locate the on-disk entry file for ``module_name``.
|
|
21
|
+
|
|
22
|
+
Returns ``(file, package)`` where ``package`` is the package name for a
|
|
23
|
+
``__init__.py`` entry (drives ``__package__``/``__path__``) or ``None`` for
|
|
24
|
+
a single-file module.
|
|
25
|
+
"""
|
|
26
|
+
package_dir = Path(package_dir)
|
|
27
|
+
rel_dotted = module_name.replace(".", "/")
|
|
28
|
+
init = package_dir / rel_dotted / "__init__.py"
|
|
29
|
+
if init.is_file():
|
|
30
|
+
return init, module_name
|
|
31
|
+
single = package_dir / f"{rel_dotted}.py"
|
|
32
|
+
if single.is_file():
|
|
33
|
+
return single, None
|
|
34
|
+
raise ImportError(f"cannot find entrypoint for module '{module_name}' under {package_dir}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_source_module(module_name: str, package_dir: Path) -> ModuleType:
|
|
38
|
+
"""Import ``module_name`` fresh from ``package_dir``.
|
|
39
|
+
|
|
40
|
+
The entry file is compiled directly from source (bypassing the bytecode
|
|
41
|
+
cache) so re-installs and rebuilds always see the current code.
|
|
42
|
+
"""
|
|
43
|
+
purge_module(module_name)
|
|
44
|
+
package_dir = Path(package_dir).resolve()
|
|
45
|
+
if str(package_dir) not in sys.path:
|
|
46
|
+
sys.path.insert(0, str(package_dir))
|
|
47
|
+
|
|
48
|
+
file_path, package = _entry_file(module_name, package_dir)
|
|
49
|
+
module = ModuleType(module_name)
|
|
50
|
+
module.__file__ = str(file_path)
|
|
51
|
+
module.__package__ = package or ""
|
|
52
|
+
if package:
|
|
53
|
+
module.__path__ = [str(file_path.parent)]
|
|
54
|
+
code = compile(file_path.read_text(encoding="utf-8"), str(file_path), "exec")
|
|
55
|
+
sys.modules[module_name] = module
|
|
56
|
+
exec(code, module.__dict__)
|
|
57
|
+
return module
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_built_module(module_name: str, built_file: Path) -> ModuleType:
|
|
61
|
+
"""Import a consolidated single-file build under ``module_name``."""
|
|
62
|
+
purge_module(module_name)
|
|
63
|
+
built_file = Path(built_file).resolve()
|
|
64
|
+
if not built_file.is_file():
|
|
65
|
+
raise FileNotFoundError(f"built package not found: {built_file}")
|
|
66
|
+
spec = importlib.util.spec_from_file_location(module_name, built_file)
|
|
67
|
+
if spec is None or spec.loader is None:
|
|
68
|
+
raise ImportError(f"cannot load {built_file} as a Python module")
|
|
69
|
+
module = importlib.util.module_from_spec(spec)
|
|
70
|
+
sys.modules[module_name] = module
|
|
71
|
+
spec.loader.exec_module(module)
|
|
72
|
+
return module
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Interactive REPL namespace for the sandbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import code
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .api import FakeProject, FakeResolve
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _collect_clips(project: FakeProject) -> list[Any]:
|
|
13
|
+
clips: list[Any] = []
|
|
14
|
+
seen: set[int] = set()
|
|
15
|
+
for index in range(1, project.GetTimelineCount() + 1):
|
|
16
|
+
timeline = project.GetTimelineByIndex(index)
|
|
17
|
+
for track in range(1, timeline.GetTrackCount("video") + 1):
|
|
18
|
+
for clip in timeline.GetItemListInTrack("video", track):
|
|
19
|
+
if clip is None or id(clip) in seen:
|
|
20
|
+
continue
|
|
21
|
+
seen.add(id(clip))
|
|
22
|
+
clips.append(clip)
|
|
23
|
+
return clips
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def default_namespace(module: ModuleType, resolve: FakeResolve | None = None) -> dict[str, Any]:
|
|
27
|
+
"""Build the interactive namespace: ``resolve``, ``project``, ``timeline``,
|
|
28
|
+
``clips`` and the loaded module under its own name."""
|
|
29
|
+
if resolve is None:
|
|
30
|
+
import sys
|
|
31
|
+
|
|
32
|
+
from .env import FUSION_SCRIPT_MODULE
|
|
33
|
+
|
|
34
|
+
dvr = sys.modules.get(FUSION_SCRIPT_MODULE)
|
|
35
|
+
resolve = dvr.scriptapp("Resolve") if dvr is not None else None # type: ignore[attr-defined]
|
|
36
|
+
namespace: dict[str, Any] = {}
|
|
37
|
+
if resolve is not None:
|
|
38
|
+
namespace["resolve"] = resolve
|
|
39
|
+
project = resolve.GetProjectManager().GetCurrentProject()
|
|
40
|
+
if project is not None:
|
|
41
|
+
namespace["project"] = project
|
|
42
|
+
namespace["timeline"] = project.GetCurrentTimeline()
|
|
43
|
+
namespace["clips"] = _collect_clips(project)
|
|
44
|
+
namespace[module.__name__] = module
|
|
45
|
+
return namespace
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def start_repl(module: ModuleType, resolve: FakeResolve | None = None) -> None:
|
|
49
|
+
"""Drop into an interactive interpreter with a live mock session."""
|
|
50
|
+
namespace = default_namespace(module, resolve)
|
|
51
|
+
banner = (
|
|
52
|
+
"\nInteractive sandbox. Available:\n"
|
|
53
|
+
f" {module.__name__} - the package under test\n"
|
|
54
|
+
" resolve / project / timeline\n"
|
|
55
|
+
" clips - list of clips across the timeline video tracks\n"
|
|
56
|
+
)
|
|
57
|
+
code.interact(local=namespace, banner=banner)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Generic smoke checks for any Resolve script package.
|
|
2
|
+
|
|
3
|
+
``run_smoke`` walks the package's public API (or an explicit list of export
|
|
4
|
+
names) and calls every zero-arg callable against the injected mock Resolve
|
|
5
|
+
environment. Any callable that raises is reported as a failure; functions that
|
|
6
|
+
require arguments are skipped by design.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from types import ModuleType
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
_REQUIRED = (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SmokeCheck:
|
|
21
|
+
name: str
|
|
22
|
+
ok: bool
|
|
23
|
+
detail: str = "ok"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SmokeResult:
|
|
28
|
+
checks: list[SmokeCheck] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def ok(self) -> bool:
|
|
32
|
+
return all(check.ok for check in self.checks)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def failed(self) -> int:
|
|
36
|
+
return sum(0 if check.ok else 1 for check in self.checks)
|
|
37
|
+
|
|
38
|
+
def __len__(self) -> int:
|
|
39
|
+
return len(self.checks)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def callable_without_args(obj: Any) -> bool:
|
|
43
|
+
"""True if ``obj`` is a plain callable that takes no required arguments."""
|
|
44
|
+
if inspect.isclass(obj):
|
|
45
|
+
return False
|
|
46
|
+
if not callable(obj):
|
|
47
|
+
return False
|
|
48
|
+
try:
|
|
49
|
+
signature = inspect.signature(obj)
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
return False
|
|
52
|
+
for param in signature.parameters.values():
|
|
53
|
+
if param.kind in _REQUIRED and param.default is inspect.Parameter.empty:
|
|
54
|
+
return False
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def discover_exports(module: ModuleType) -> dict[str, Callable[..., Any]]:
|
|
59
|
+
"""Public zero-arg callables of a module (in ``dir()`` order)."""
|
|
60
|
+
exports: dict[str, Callable[..., Any]] = {}
|
|
61
|
+
for name in dir(module):
|
|
62
|
+
if name.startswith("_"):
|
|
63
|
+
continue
|
|
64
|
+
obj = getattr(module, name)
|
|
65
|
+
if isinstance(obj, ModuleType):
|
|
66
|
+
continue
|
|
67
|
+
if callable_without_args(obj):
|
|
68
|
+
exports[name] = obj
|
|
69
|
+
return exports
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run_smoke(module: ModuleType, exports: list[str] | None = None, verbose: bool = False) -> SmokeResult:
|
|
73
|
+
"""Exercise ``module``'s public API against the mock environment.
|
|
74
|
+
|
|
75
|
+
When ``exports`` is given it selects named attributes; otherwise the
|
|
76
|
+
module's public zero-arg callables are discovered automatically.
|
|
77
|
+
"""
|
|
78
|
+
names = exports if exports is not None else sorted(discover_exports(module))
|
|
79
|
+
result = SmokeResult()
|
|
80
|
+
|
|
81
|
+
def _check(name: str, fn: Any) -> None:
|
|
82
|
+
try:
|
|
83
|
+
fn()
|
|
84
|
+
except Exception as exc: # noqa: BLE001 - a smoke run must not abort
|
|
85
|
+
result.checks.append(SmokeCheck(name, False, f"{type(exc).__name__}: {exc}"))
|
|
86
|
+
else:
|
|
87
|
+
result.checks.append(SmokeCheck(name, True, "ok"))
|
|
88
|
+
|
|
89
|
+
for name in names:
|
|
90
|
+
attr = getattr(module, name, None)
|
|
91
|
+
if attr is None or not callable(attr):
|
|
92
|
+
result.checks.append(SmokeCheck(name, False, f"missing export '{name}'"))
|
|
93
|
+
continue
|
|
94
|
+
if not callable_without_args(attr):
|
|
95
|
+
result.checks.append(SmokeCheck(name, True, "skipped (requires arguments)"))
|
|
96
|
+
continue
|
|
97
|
+
_check(name, attr)
|
|
98
|
+
|
|
99
|
+
if verbose:
|
|
100
|
+
for check in result.checks:
|
|
101
|
+
tag = "PASS" if check.ok else "FAIL"
|
|
102
|
+
print(f" [{tag}] {check.name} ({check.detail})")
|
|
103
|
+
print(f"\n{len(result) - result.failed}/{len(result)} checks passed")
|
|
104
|
+
return result
|