@hybridlabor-api/bdb-antigravity-skills 1.2.1 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Project-overview.html +5 -4
- package/README.md +63 -92
- package/installer.js +1 -0
- package/mcp_config.json +4 -0
- package/mcps/davinci-resolve-mcp-free/LICENSE +21 -0
- package/mcps/davinci-resolve-mcp-free/README.md +220 -0
- package/mcps/davinci-resolve-mcp-free/examples/mcp.json +8 -0
- package/mcps/davinci-resolve-mcp-free/requirements.txt +5 -0
- package/mcps/davinci-resolve-mcp-free/src/CursorBridge.py +2910 -0
- package/mcps/davinci-resolve-mcp-free/src/resolve_mcp_bridge.py +2529 -0
- package/package.json +1 -1
- package/skills/global_config/bdb-adobe-suite-mcp.md +106 -0
- package/skills/global_config/bdb-after-effects-mcp.md +117 -0
- package/skills/global_config/bdb-blender-mcp.md +130 -0
- package/skills/global_config/bdb-computer-use-mcp.md +119 -0
- package/skills/global_config/bdb-davinci-mcp.md +139 -0
- package/skills/global_config/bdb-grandma3-mcp.md +109 -0
- package/skills/global_config/bdb-resolume-mcp.md +103 -0
- package/skills/global_config/bdb-rhino-mcp.md +153 -0
- package/skills/global_config/bdb-touchdesigner-mcp.md +116 -0
- package/skills/global_config/bdb-unreal-mcp.md +130 -0
- package/skills/global_config/bdb-vectorworks-mcp.md +110 -0
|
@@ -0,0 +1,2910 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CursorBridge - HTTP bridge between DaVinci Resolve and Cursor MCP.
|
|
4
|
+
|
|
5
|
+
Launch from: Workspace > Scripts > CursorBridge
|
|
6
|
+
Exposes a JSON API on localhost:9876 that an external MCP server can query.
|
|
7
|
+
Works with DaVinci Resolve Free (no external scripting required).
|
|
8
|
+
|
|
9
|
+
GET endpoints = read-only queries
|
|
10
|
+
POST endpoints = write / mutation operations
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
import traceback
|
|
16
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
17
|
+
from urllib.parse import urlparse, parse_qs
|
|
18
|
+
|
|
19
|
+
HOST = "127.0.0.1"
|
|
20
|
+
PORT = 9876
|
|
21
|
+
BRIDGE_VERSION = "2.0.0"
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Resolve bootstrap — grab the object while Fusion globals are in scope
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
resolve_obj = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _init_resolve():
|
|
30
|
+
global resolve_obj
|
|
31
|
+
for attempt in (
|
|
32
|
+
lambda: fu.GetResolve(), # noqa: F821
|
|
33
|
+
lambda: fusion.GetResolve(), # noqa: F821
|
|
34
|
+
lambda: bmd.scriptapp("Resolve"), # noqa: F821
|
|
35
|
+
):
|
|
36
|
+
try:
|
|
37
|
+
resolve_obj = attempt()
|
|
38
|
+
if resolve_obj:
|
|
39
|
+
return
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
try:
|
|
43
|
+
import DaVinciResolveScript as dvr
|
|
44
|
+
resolve_obj = dvr.scriptapp("Resolve")
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_init_resolve()
|
|
50
|
+
|
|
51
|
+
if resolve_obj:
|
|
52
|
+
print("[CursorBridge] Connected to Resolve: %s %s" % (
|
|
53
|
+
resolve_obj.GetProductName(), resolve_obj.GetVersionString()))
|
|
54
|
+
else:
|
|
55
|
+
print("[CursorBridge] WARNING: Could not obtain Resolve object.")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Shared helpers
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def safe(fn):
|
|
63
|
+
try:
|
|
64
|
+
return fn()
|
|
65
|
+
except Exception:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve():
|
|
70
|
+
r = resolve_obj
|
|
71
|
+
if not r:
|
|
72
|
+
return None, {"error": "Not connected to Resolve"}
|
|
73
|
+
return r, None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _project():
|
|
77
|
+
r, err = _resolve()
|
|
78
|
+
if err:
|
|
79
|
+
return None, None, err
|
|
80
|
+
pm = r.GetProjectManager()
|
|
81
|
+
if not pm:
|
|
82
|
+
return None, None, {"error": "No project manager"}
|
|
83
|
+
proj = pm.GetCurrentProject()
|
|
84
|
+
if not proj:
|
|
85
|
+
return None, None, {"error": "No project open"}
|
|
86
|
+
return r, proj, None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _timeline():
|
|
90
|
+
r, proj, err = _project()
|
|
91
|
+
if err:
|
|
92
|
+
return None, None, None, err
|
|
93
|
+
tl = proj.GetCurrentTimeline()
|
|
94
|
+
if not tl:
|
|
95
|
+
return None, None, None, {"error": "No timeline open"}
|
|
96
|
+
return r, proj, tl, None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _clip_at(body):
|
|
100
|
+
"""Locate a TimelineItem by trackType / trackIndex / clipIndex."""
|
|
101
|
+
_, _, tl, err = _timeline()
|
|
102
|
+
if err:
|
|
103
|
+
return None, err
|
|
104
|
+
tt = body.get("trackType", "video")
|
|
105
|
+
ti = int(body.get("trackIndex", 1))
|
|
106
|
+
ci = int(body.get("clipIndex", 0))
|
|
107
|
+
items = safe(lambda: tl.GetItemListInTrack(tt, ti))
|
|
108
|
+
if not items:
|
|
109
|
+
return None, {"error": "No clips on %s track %d" % (tt, ti)}
|
|
110
|
+
if ci < 0 or ci >= len(items):
|
|
111
|
+
return None, {"error": "clipIndex %d out of range (0-%d)" % (ci, len(items) - 1)}
|
|
112
|
+
return items[ci], None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _find_pool_item(pool, name):
|
|
116
|
+
"""Recursively search media pool for an item by name."""
|
|
117
|
+
def search(folder):
|
|
118
|
+
clips = folder.GetClipList() or []
|
|
119
|
+
for c in clips:
|
|
120
|
+
if safe(lambda: c.GetName()) == name:
|
|
121
|
+
return c
|
|
122
|
+
for sf in (folder.GetSubFolderList() or []):
|
|
123
|
+
hit = search(sf)
|
|
124
|
+
if hit:
|
|
125
|
+
return hit
|
|
126
|
+
return None
|
|
127
|
+
root = pool.GetRootFolder()
|
|
128
|
+
return search(root) if root else None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _find_folder_by_path(pool, path):
|
|
132
|
+
"""Find a media pool folder by slash-separated path (e.g. 'Footage/Day1')."""
|
|
133
|
+
root = pool.GetRootFolder()
|
|
134
|
+
if not root:
|
|
135
|
+
return None
|
|
136
|
+
if not path or path.lower() in ("root", "/", ""):
|
|
137
|
+
return root
|
|
138
|
+
parts = [p for p in path.split("/") if p and p.lower() not in ("root", "master")]
|
|
139
|
+
current = root
|
|
140
|
+
for part in parts:
|
|
141
|
+
subfolders = current.GetSubFolderList() or []
|
|
142
|
+
found = None
|
|
143
|
+
for sf in subfolders:
|
|
144
|
+
if safe(lambda sf=sf: sf.GetName()) == part:
|
|
145
|
+
found = sf
|
|
146
|
+
break
|
|
147
|
+
if not found:
|
|
148
|
+
return None
|
|
149
|
+
current = found
|
|
150
|
+
return current
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _resolve_clip_refs(tl, clip_refs):
|
|
154
|
+
"""Resolve a list of {trackType, trackIndex, clipIndex} dicts to TimelineItem objects."""
|
|
155
|
+
items = []
|
|
156
|
+
for ref in clip_refs:
|
|
157
|
+
tt = ref.get("trackType", "video")
|
|
158
|
+
ti = int(ref.get("trackIndex", 1))
|
|
159
|
+
ci = int(ref.get("clipIndex", 0))
|
|
160
|
+
track_items = safe(lambda tt=tt, ti=ti: tl.GetItemListInTrack(tt, ti))
|
|
161
|
+
if track_items and 0 <= ci < len(track_items):
|
|
162
|
+
items.append(track_items[ci])
|
|
163
|
+
return items
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _get_album(gallery, body):
|
|
167
|
+
"""Get a gallery album from body params (albumIndex + albumType), or current album."""
|
|
168
|
+
album_index = int(body.get("albumIndex", 0))
|
|
169
|
+
album_type = body.get("albumType", "still")
|
|
170
|
+
if album_index > 0:
|
|
171
|
+
if album_type == "powergrade":
|
|
172
|
+
albums = safe(lambda: gallery.GetGalleryPowerGradeAlbums()) or []
|
|
173
|
+
else:
|
|
174
|
+
albums = safe(lambda: gallery.GetGalleryStillAlbums()) or []
|
|
175
|
+
if album_index < 1 or album_index > len(albums):
|
|
176
|
+
return None, {"error": "Album index %d out of range (1-%d)" % (album_index, len(albums))}
|
|
177
|
+
return albums[album_index - 1], None
|
|
178
|
+
album = safe(lambda: gallery.GetCurrentStillAlbum())
|
|
179
|
+
if not album:
|
|
180
|
+
return None, {"error": "No album selected"}
|
|
181
|
+
return album, None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
# GET handlers (read-only)
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def gather_status():
|
|
189
|
+
r, err = _resolve()
|
|
190
|
+
if err:
|
|
191
|
+
return {"connected": False, "bridgeVersion": BRIDGE_VERSION, **err}
|
|
192
|
+
return {
|
|
193
|
+
"connected": True,
|
|
194
|
+
"bridgeVersion": BRIDGE_VERSION,
|
|
195
|
+
"product": safe(lambda: r.GetProductName()),
|
|
196
|
+
"version": safe(lambda: r.GetVersionString()),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def gather_project():
|
|
201
|
+
_, proj, err = _project()
|
|
202
|
+
if err:
|
|
203
|
+
return err
|
|
204
|
+
keys = [
|
|
205
|
+
"timelineResolutionWidth", "timelineResolutionHeight",
|
|
206
|
+
"timelineFrameRate", "timelinePlaybackFrameRate",
|
|
207
|
+
"colorScienceMode", "audioCaptureNumChannels", "superScale",
|
|
208
|
+
]
|
|
209
|
+
settings = {}
|
|
210
|
+
for k in keys:
|
|
211
|
+
v = safe(lambda k=k: proj.GetSetting(k))
|
|
212
|
+
if v not in (None, ""):
|
|
213
|
+
settings[k] = v
|
|
214
|
+
return {
|
|
215
|
+
"name": safe(lambda: proj.GetName()),
|
|
216
|
+
"timelineCount": safe(lambda: proj.GetTimelineCount()),
|
|
217
|
+
"currentRenderFormatAndCodec": safe(lambda: proj.GetCurrentRenderFormatAndCodec()),
|
|
218
|
+
"settings": settings,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def gather_page():
|
|
223
|
+
r, err = _resolve()
|
|
224
|
+
if err:
|
|
225
|
+
return err
|
|
226
|
+
return {"page": safe(lambda: r.GetCurrentPage())}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def gather_timeline():
|
|
230
|
+
_, proj, tl, err = _timeline()
|
|
231
|
+
if err:
|
|
232
|
+
return err
|
|
233
|
+
vt = safe(lambda: tl.GetTrackCount("video")) or 0
|
|
234
|
+
at = safe(lambda: tl.GetTrackCount("audio")) or 0
|
|
235
|
+
st = safe(lambda: tl.GetTrackCount("subtitle")) or 0
|
|
236
|
+
names = {}
|
|
237
|
+
for i in range(1, vt + 1):
|
|
238
|
+
n = safe(lambda i=i: tl.GetTrackName("video", i))
|
|
239
|
+
if n:
|
|
240
|
+
names["video_%d" % i] = n
|
|
241
|
+
for i in range(1, at + 1):
|
|
242
|
+
n = safe(lambda i=i: tl.GetTrackName("audio", i))
|
|
243
|
+
if n:
|
|
244
|
+
names["audio_%d" % i] = n
|
|
245
|
+
tl_keys = [
|
|
246
|
+
"timelineResolutionWidth", "timelineResolutionHeight",
|
|
247
|
+
"timelineFrameRate", "timelineOutputResolutionWidth",
|
|
248
|
+
"timelineOutputResolutionHeight",
|
|
249
|
+
]
|
|
250
|
+
settings = {}
|
|
251
|
+
for k in tl_keys:
|
|
252
|
+
v = safe(lambda k=k: tl.GetSetting(k))
|
|
253
|
+
if v not in (None, ""):
|
|
254
|
+
settings[k] = v
|
|
255
|
+
return {
|
|
256
|
+
"name": safe(lambda: tl.GetName()),
|
|
257
|
+
"startFrame": safe(lambda: tl.GetStartFrame()),
|
|
258
|
+
"endFrame": safe(lambda: tl.GetEndFrame()),
|
|
259
|
+
"startTimecode": safe(lambda: tl.GetStartTimecode()),
|
|
260
|
+
"currentTimecode": safe(lambda: tl.GetCurrentTimecode()),
|
|
261
|
+
"trackCount": {"video": vt, "audio": at, "subtitle": st},
|
|
262
|
+
"trackNames": names,
|
|
263
|
+
"markInOut": safe(lambda: tl.GetMarkInOut()),
|
|
264
|
+
"settings": settings,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def gather_clips(track_type, track_index):
|
|
269
|
+
_, _, tl, err = _timeline()
|
|
270
|
+
if err:
|
|
271
|
+
return err
|
|
272
|
+
mx = safe(lambda: tl.GetTrackCount(track_type)) or 0
|
|
273
|
+
if track_index < 1 or track_index > mx:
|
|
274
|
+
return {"error": "Track index %d out of range (1-%d) for %s" % (track_index, mx, track_type)}
|
|
275
|
+
items = safe(lambda: tl.GetItemListInTrack(track_type, track_index))
|
|
276
|
+
if not items:
|
|
277
|
+
return {"trackType": track_type, "trackIndex": track_index, "clips": []}
|
|
278
|
+
clips = []
|
|
279
|
+
for item in items:
|
|
280
|
+
cd = {
|
|
281
|
+
"name": safe(lambda: item.GetName()),
|
|
282
|
+
"duration": safe(lambda: item.GetDuration()),
|
|
283
|
+
"start": safe(lambda: item.GetStart()),
|
|
284
|
+
"end": safe(lambda: item.GetEnd()),
|
|
285
|
+
"enabled": safe(lambda: item.GetClipEnabled()),
|
|
286
|
+
"color": safe(lambda: item.GetClipColor()),
|
|
287
|
+
}
|
|
288
|
+
mp = safe(lambda: item.GetMediaPoolItem())
|
|
289
|
+
if mp:
|
|
290
|
+
props = safe(lambda: mp.GetClipProperty()) or {}
|
|
291
|
+
for key in ("File Path", "Clip Name", "Resolution", "FPS", "Frames", "Duration", "Audio Ch"):
|
|
292
|
+
if key in props:
|
|
293
|
+
cd[key] = props[key]
|
|
294
|
+
clips.append(cd)
|
|
295
|
+
return {"trackType": track_type, "trackIndex": track_index, "clips": clips}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def gather_markers():
|
|
299
|
+
_, _, tl, err = _timeline()
|
|
300
|
+
if err:
|
|
301
|
+
return err
|
|
302
|
+
raw = safe(lambda: tl.GetMarkers()) or {}
|
|
303
|
+
return {"markers": [{"frameId": fid, **info} for fid, info in raw.items()]}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def gather_render():
|
|
307
|
+
_, proj, err = _project()
|
|
308
|
+
if err:
|
|
309
|
+
return err
|
|
310
|
+
return {
|
|
311
|
+
"formatAndCodec": safe(lambda: proj.GetCurrentRenderFormatAndCodec()),
|
|
312
|
+
"renderMode": safe(lambda: proj.GetCurrentRenderMode()),
|
|
313
|
+
"renderJobList": safe(lambda: proj.GetRenderJobList()),
|
|
314
|
+
"isRendering": safe(lambda: proj.IsRenderingInProgress()),
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def gather_media_pool():
|
|
319
|
+
"""List clips in current media pool folder."""
|
|
320
|
+
_, proj, err = _project()
|
|
321
|
+
if err:
|
|
322
|
+
return err
|
|
323
|
+
pool = proj.GetMediaPool()
|
|
324
|
+
if not pool:
|
|
325
|
+
return {"error": "No media pool"}
|
|
326
|
+
folder = pool.GetCurrentFolder()
|
|
327
|
+
if not folder:
|
|
328
|
+
return {"error": "No current folder"}
|
|
329
|
+
clips = folder.GetClipList() or []
|
|
330
|
+
result = []
|
|
331
|
+
for c in clips:
|
|
332
|
+
result.append({
|
|
333
|
+
"name": safe(lambda: c.GetName()),
|
|
334
|
+
"clipColor": safe(lambda: c.GetClipColor()),
|
|
335
|
+
"mediaId": safe(lambda: c.GetMediaId()),
|
|
336
|
+
})
|
|
337
|
+
subfolders = [safe(lambda sf=sf: sf.GetName()) for sf in (folder.GetSubFolderList() or [])]
|
|
338
|
+
return {
|
|
339
|
+
"folderName": safe(lambda: folder.GetName()),
|
|
340
|
+
"clips": result,
|
|
341
|
+
"subfolders": subfolders,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def gather_media_pool_structure(qs):
|
|
346
|
+
"""Get the media pool folder tree structure."""
|
|
347
|
+
_, proj, err = _project()
|
|
348
|
+
if err:
|
|
349
|
+
return err
|
|
350
|
+
pool = proj.GetMediaPool()
|
|
351
|
+
if not pool:
|
|
352
|
+
return {"error": "No media pool"}
|
|
353
|
+
include_clips = qs.get("include_clips", ["false"])[0].lower() == "true"
|
|
354
|
+
max_depth = int(qs.get("max_depth", ["10"])[0])
|
|
355
|
+
|
|
356
|
+
def build_tree(folder, depth=0):
|
|
357
|
+
if depth > max_depth:
|
|
358
|
+
return {"name": safe(lambda: folder.GetName()), "truncated": True}
|
|
359
|
+
clips = folder.GetClipList() or []
|
|
360
|
+
subfolders = folder.GetSubFolderList() or []
|
|
361
|
+
node = {
|
|
362
|
+
"name": safe(lambda: folder.GetName()),
|
|
363
|
+
"clipCount": len(clips),
|
|
364
|
+
"subfolderCount": len(subfolders),
|
|
365
|
+
}
|
|
366
|
+
if include_clips:
|
|
367
|
+
node["clips"] = [{"name": safe(lambda c=c: c.GetName()),
|
|
368
|
+
"mediaId": safe(lambda c=c: c.GetMediaId())} for c in clips]
|
|
369
|
+
node["subfolders"] = [build_tree(sf, depth + 1) for sf in subfolders]
|
|
370
|
+
return node
|
|
371
|
+
|
|
372
|
+
root = pool.GetRootFolder()
|
|
373
|
+
if not root:
|
|
374
|
+
return {"error": "No root folder"}
|
|
375
|
+
current = pool.GetCurrentFolder()
|
|
376
|
+
return {
|
|
377
|
+
"tree": build_tree(root),
|
|
378
|
+
"currentFolder": safe(lambda: current.GetName()) if current else None,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def gather_clip_metadata(qs):
|
|
383
|
+
"""Get metadata for a media pool clip by name."""
|
|
384
|
+
_, proj, err = _project()
|
|
385
|
+
if err:
|
|
386
|
+
return err
|
|
387
|
+
pool = proj.GetMediaPool()
|
|
388
|
+
if not pool:
|
|
389
|
+
return {"error": "No media pool"}
|
|
390
|
+
clip_name = qs.get("clip_name", [""])[0]
|
|
391
|
+
if not clip_name:
|
|
392
|
+
return {"error": "clip_name parameter is required"}
|
|
393
|
+
item = _find_pool_item(pool, clip_name)
|
|
394
|
+
if not item:
|
|
395
|
+
return {"error": "Clip '%s' not found in media pool" % clip_name}
|
|
396
|
+
metadata = safe(lambda: item.GetMetadata()) or {}
|
|
397
|
+
third_party = safe(lambda: item.GetThirdPartyMetadata()) or {}
|
|
398
|
+
return {
|
|
399
|
+
"name": safe(lambda: item.GetName()),
|
|
400
|
+
"mediaId": safe(lambda: item.GetMediaId()),
|
|
401
|
+
"metadata": metadata,
|
|
402
|
+
"thirdPartyMetadata": third_party,
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def gather_clip_info(qs):
|
|
407
|
+
"""Get detailed clip properties for a media pool clip."""
|
|
408
|
+
_, proj, err = _project()
|
|
409
|
+
if err:
|
|
410
|
+
return err
|
|
411
|
+
pool = proj.GetMediaPool()
|
|
412
|
+
if not pool:
|
|
413
|
+
return {"error": "No media pool"}
|
|
414
|
+
clip_name = qs.get("clip_name", [""])[0]
|
|
415
|
+
if not clip_name:
|
|
416
|
+
return {"error": "clip_name parameter is required"}
|
|
417
|
+
item = _find_pool_item(pool, clip_name)
|
|
418
|
+
if not item:
|
|
419
|
+
return {"error": "Clip '%s' not found in media pool" % clip_name}
|
|
420
|
+
props = safe(lambda: item.GetClipProperty()) or {}
|
|
421
|
+
flags = safe(lambda: item.GetFlagList()) or []
|
|
422
|
+
markers = safe(lambda: item.GetMarkers()) or {}
|
|
423
|
+
mark_in_out = safe(lambda: item.GetMarkInOut()) or {}
|
|
424
|
+
return {
|
|
425
|
+
"name": safe(lambda: item.GetName()),
|
|
426
|
+
"mediaId": safe(lambda: item.GetMediaId()),
|
|
427
|
+
"clipColor": safe(lambda: item.GetClipColor()) or "",
|
|
428
|
+
"flags": flags,
|
|
429
|
+
"markers": {str(k): v for k, v in markers.items()},
|
|
430
|
+
"markInOut": mark_in_out,
|
|
431
|
+
"properties": props,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def gather_clip_markers(qs):
|
|
436
|
+
"""Get markers on a specific timeline item."""
|
|
437
|
+
_, _, tl, err = _timeline()
|
|
438
|
+
if err:
|
|
439
|
+
return err
|
|
440
|
+
tt = qs.get("track_type", ["video"])[0]
|
|
441
|
+
ti = int(qs.get("track_index", ["1"])[0])
|
|
442
|
+
ci = int(qs.get("clip_index", ["0"])[0])
|
|
443
|
+
items = safe(lambda: tl.GetItemListInTrack(tt, ti))
|
|
444
|
+
if not items:
|
|
445
|
+
return {"error": "No clips on %s track %d" % (tt, ti)}
|
|
446
|
+
if ci < 0 or ci >= len(items):
|
|
447
|
+
return {"error": "clipIndex %d out of range (0-%d)" % (ci, len(items) - 1)}
|
|
448
|
+
item = items[ci]
|
|
449
|
+
markers = safe(lambda: item.GetMarkers()) or {}
|
|
450
|
+
return {
|
|
451
|
+
"clipName": safe(lambda: item.GetName()),
|
|
452
|
+
"markers": [{"frameId": fid, **info} for fid, info in markers.items()],
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def gather_clip_flags(qs):
|
|
457
|
+
"""Get flags on a specific timeline item."""
|
|
458
|
+
_, _, tl, err = _timeline()
|
|
459
|
+
if err:
|
|
460
|
+
return err
|
|
461
|
+
tt = qs.get("track_type", ["video"])[0]
|
|
462
|
+
ti = int(qs.get("track_index", ["1"])[0])
|
|
463
|
+
ci = int(qs.get("clip_index", ["0"])[0])
|
|
464
|
+
items = safe(lambda: tl.GetItemListInTrack(tt, ti))
|
|
465
|
+
if not items:
|
|
466
|
+
return {"error": "No clips on %s track %d" % (tt, ti)}
|
|
467
|
+
if ci < 0 or ci >= len(items):
|
|
468
|
+
return {"error": "clipIndex %d out of range (0-%d)" % (ci, len(items) - 1)}
|
|
469
|
+
item = items[ci]
|
|
470
|
+
flags = safe(lambda: item.GetFlagList()) or []
|
|
471
|
+
return {"clipName": safe(lambda: item.GetName()), "flags": flags}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def gather_current_video_item(qs):
|
|
475
|
+
"""Get the current video item at the playhead."""
|
|
476
|
+
_, _, tl, err = _timeline()
|
|
477
|
+
if err:
|
|
478
|
+
return err
|
|
479
|
+
item = safe(lambda: tl.GetCurrentVideoItem())
|
|
480
|
+
if not item:
|
|
481
|
+
return {"error": "No current video item at playhead"}
|
|
482
|
+
mp = safe(lambda: item.GetMediaPoolItem())
|
|
483
|
+
props = {}
|
|
484
|
+
if mp:
|
|
485
|
+
props = safe(lambda: mp.GetClipProperty()) or {}
|
|
486
|
+
track_info = safe(lambda: item.GetTrackTypeAndIndex()) or []
|
|
487
|
+
return {
|
|
488
|
+
"name": safe(lambda: item.GetName()),
|
|
489
|
+
"duration": safe(lambda: item.GetDuration()),
|
|
490
|
+
"start": safe(lambda: item.GetStart()),
|
|
491
|
+
"end": safe(lambda: item.GetEnd()),
|
|
492
|
+
"enabled": safe(lambda: item.GetClipEnabled()),
|
|
493
|
+
"color": safe(lambda: item.GetClipColor()),
|
|
494
|
+
"trackType": track_info[0] if len(track_info) > 0 else None,
|
|
495
|
+
"trackIndex": track_info[1] if len(track_info) > 1 else None,
|
|
496
|
+
"properties": {k: props[k] for k in ("File Path", "Clip Name", "Resolution", "FPS", "Frames", "Duration") if k in props},
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def gather_clip_thumbnail(qs):
|
|
501
|
+
"""Get thumbnail for current clip (Color page only)."""
|
|
502
|
+
_, _, tl, err = _timeline()
|
|
503
|
+
if err:
|
|
504
|
+
return err
|
|
505
|
+
data = safe(lambda: tl.GetCurrentClipThumbnailImage())
|
|
506
|
+
if not data:
|
|
507
|
+
return {"error": "Failed to get thumbnail. Make sure you are on the Color page."}
|
|
508
|
+
return data
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def gather_gallery_albums(qs):
|
|
512
|
+
"""List gallery still albums and PowerGrade albums."""
|
|
513
|
+
_, proj, err = _project()
|
|
514
|
+
if err:
|
|
515
|
+
return err
|
|
516
|
+
gallery = safe(lambda: proj.GetGallery())
|
|
517
|
+
if not gallery:
|
|
518
|
+
return {"error": "Cannot access gallery"}
|
|
519
|
+
still_albums = safe(lambda: gallery.GetGalleryStillAlbums()) or []
|
|
520
|
+
pg_albums = safe(lambda: gallery.GetGalleryPowerGradeAlbums()) or []
|
|
521
|
+
current = safe(lambda: gallery.GetCurrentStillAlbum())
|
|
522
|
+
current_name = safe(lambda: gallery.GetAlbumName(current)) if current else None
|
|
523
|
+
result = {"currentAlbum": current_name, "stillAlbums": [], "powerGradeAlbums": []}
|
|
524
|
+
for i, album in enumerate(still_albums):
|
|
525
|
+
name = safe(lambda a=album: gallery.GetAlbumName(a))
|
|
526
|
+
stills = safe(lambda a=album: a.GetStills()) or []
|
|
527
|
+
result["stillAlbums"].append({"index": i + 1, "name": name, "stillCount": len(stills)})
|
|
528
|
+
for i, album in enumerate(pg_albums):
|
|
529
|
+
name = safe(lambda a=album: gallery.GetAlbumName(a))
|
|
530
|
+
stills = safe(lambda a=album: a.GetStills()) or []
|
|
531
|
+
result["powerGradeAlbums"].append({"index": i + 1, "name": name, "stillCount": len(stills)})
|
|
532
|
+
return result
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def gather_album_stills(qs):
|
|
536
|
+
"""List stills in a gallery album."""
|
|
537
|
+
_, proj, err = _project()
|
|
538
|
+
if err:
|
|
539
|
+
return err
|
|
540
|
+
gallery = safe(lambda: proj.GetGallery())
|
|
541
|
+
if not gallery:
|
|
542
|
+
return {"error": "Cannot access gallery"}
|
|
543
|
+
album_index = int(qs.get("album_index", ["0"])[0])
|
|
544
|
+
album_type = qs.get("album_type", ["still"])[0]
|
|
545
|
+
if album_index > 0:
|
|
546
|
+
if album_type == "powergrade":
|
|
547
|
+
albums = safe(lambda: gallery.GetGalleryPowerGradeAlbums()) or []
|
|
548
|
+
else:
|
|
549
|
+
albums = safe(lambda: gallery.GetGalleryStillAlbums()) or []
|
|
550
|
+
if album_index < 1 or album_index > len(albums):
|
|
551
|
+
return {"error": "Album index %d out of range (1-%d)" % (album_index, len(albums))}
|
|
552
|
+
album = albums[album_index - 1]
|
|
553
|
+
else:
|
|
554
|
+
album = safe(lambda: gallery.GetCurrentStillAlbum())
|
|
555
|
+
if not album:
|
|
556
|
+
return {"error": "No album selected"}
|
|
557
|
+
album_name = safe(lambda: gallery.GetAlbumName(album))
|
|
558
|
+
stills = safe(lambda: album.GetStills()) or []
|
|
559
|
+
result = []
|
|
560
|
+
for i, still in enumerate(stills):
|
|
561
|
+
label = safe(lambda s=still: album.GetLabel(s))
|
|
562
|
+
result.append({"index": i + 1, "label": label or ""})
|
|
563
|
+
return {"albumName": album_name, "stills": result}
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
# ---------------------------------------------------------------------------
|
|
567
|
+
# POST handlers (write / mutate)
|
|
568
|
+
# ---------------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
VALID_PAGES = ("media", "cut", "edit", "fusion", "color", "fairlight", "deliver")
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def action_open_page(body):
|
|
574
|
+
r, err = _resolve()
|
|
575
|
+
if err:
|
|
576
|
+
return err
|
|
577
|
+
page = body.get("page", "")
|
|
578
|
+
if page not in VALID_PAGES:
|
|
579
|
+
return {"error": "Invalid page. Must be one of: %s" % ", ".join(VALID_PAGES)}
|
|
580
|
+
return {"success": bool(r.OpenPage(page)), "page": page}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def action_set_timecode(body):
|
|
584
|
+
_, _, tl, err = _timeline()
|
|
585
|
+
if err:
|
|
586
|
+
return err
|
|
587
|
+
tc = body.get("timecode", "")
|
|
588
|
+
if not tc:
|
|
589
|
+
return {"error": "timecode is required (e.g. '01:00:05:00')"}
|
|
590
|
+
return {"success": bool(tl.SetCurrentTimecode(tc)), "timecode": tc}
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
# -- Markers ---------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
def action_add_marker(body):
|
|
596
|
+
_, _, tl, err = _timeline()
|
|
597
|
+
if err:
|
|
598
|
+
return err
|
|
599
|
+
frame_id = body.get("frameId")
|
|
600
|
+
if frame_id is None:
|
|
601
|
+
return {"error": "frameId is required"}
|
|
602
|
+
color = body.get("color", "Blue")
|
|
603
|
+
name = body.get("name", "")
|
|
604
|
+
note = body.get("note", "")
|
|
605
|
+
duration = body.get("duration", 1)
|
|
606
|
+
custom = body.get("customData", "")
|
|
607
|
+
ok = tl.AddMarker(int(frame_id), color, name, note, int(duration), custom)
|
|
608
|
+
return {"success": bool(ok), "frameId": frame_id, "color": color}
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def action_delete_marker(body):
|
|
612
|
+
_, _, tl, err = _timeline()
|
|
613
|
+
if err:
|
|
614
|
+
return err
|
|
615
|
+
frame = body.get("frameId")
|
|
616
|
+
color = body.get("color")
|
|
617
|
+
if frame is not None:
|
|
618
|
+
return {"success": bool(tl.DeleteMarkerAtFrame(int(frame))), "frameId": frame}
|
|
619
|
+
if color:
|
|
620
|
+
return {"success": bool(tl.DeleteMarkersByColor(color)), "color": color}
|
|
621
|
+
return {"error": "Provide frameId or color (use 'All' to delete all)"}
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# -- Timeline management ---------------------------------------------------
|
|
625
|
+
|
|
626
|
+
def action_switch_timeline(body):
|
|
627
|
+
_, proj, err = _project()
|
|
628
|
+
if err:
|
|
629
|
+
return err
|
|
630
|
+
idx = body.get("index")
|
|
631
|
+
if idx is None:
|
|
632
|
+
return {"error": "index is required (1-based)"}
|
|
633
|
+
idx = int(idx)
|
|
634
|
+
total = proj.GetTimelineCount() or 0
|
|
635
|
+
if idx < 1 or idx > total:
|
|
636
|
+
return {"error": "index %d out of range (1-%d)" % (idx, total)}
|
|
637
|
+
tl = proj.GetTimelineByIndex(idx)
|
|
638
|
+
if not tl:
|
|
639
|
+
return {"error": "Could not get timeline at index %d" % idx}
|
|
640
|
+
ok = proj.SetCurrentTimeline(tl)
|
|
641
|
+
return {"success": bool(ok), "timeline": safe(lambda: tl.GetName())}
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def action_create_timeline(body):
|
|
645
|
+
_, proj, err = _project()
|
|
646
|
+
if err:
|
|
647
|
+
return err
|
|
648
|
+
name = body.get("name", "")
|
|
649
|
+
if not name:
|
|
650
|
+
return {"error": "name is required"}
|
|
651
|
+
pool = proj.GetMediaPool()
|
|
652
|
+
if not pool:
|
|
653
|
+
return {"error": "No media pool"}
|
|
654
|
+
tl = pool.CreateEmptyTimeline(name)
|
|
655
|
+
if not tl:
|
|
656
|
+
return {"error": "Failed to create timeline '%s'" % name}
|
|
657
|
+
return {"success": True, "timeline": name}
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def action_rename_timeline(body):
|
|
661
|
+
_, _, tl, err = _timeline()
|
|
662
|
+
if err:
|
|
663
|
+
return err
|
|
664
|
+
name = body.get("name", "")
|
|
665
|
+
if not name:
|
|
666
|
+
return {"error": "name is required"}
|
|
667
|
+
return {"success": bool(tl.SetName(name)), "name": name}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def action_duplicate_timeline(body):
|
|
671
|
+
_, _, tl, err = _timeline()
|
|
672
|
+
if err:
|
|
673
|
+
return err
|
|
674
|
+
name = body.get("name", "")
|
|
675
|
+
new_tl = tl.DuplicateTimeline(name) if name else tl.DuplicateTimeline()
|
|
676
|
+
if not new_tl:
|
|
677
|
+
return {"error": "Failed to duplicate timeline"}
|
|
678
|
+
return {"success": True, "timeline": safe(lambda: new_tl.GetName())}
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
# -- Track management ------------------------------------------------------
|
|
682
|
+
|
|
683
|
+
def action_add_track(body):
|
|
684
|
+
_, _, tl, err = _timeline()
|
|
685
|
+
if err:
|
|
686
|
+
return err
|
|
687
|
+
tt = body.get("trackType", "video")
|
|
688
|
+
sub = body.get("subTrackType", "")
|
|
689
|
+
if sub:
|
|
690
|
+
ok = tl.AddTrack(tt, sub)
|
|
691
|
+
else:
|
|
692
|
+
ok = tl.AddTrack(tt)
|
|
693
|
+
return {"success": bool(ok), "trackType": tt}
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def action_delete_track(body):
|
|
697
|
+
_, _, tl, err = _timeline()
|
|
698
|
+
if err:
|
|
699
|
+
return err
|
|
700
|
+
tt = body.get("trackType", "video")
|
|
701
|
+
ti = int(body.get("trackIndex", 0))
|
|
702
|
+
if ti < 1:
|
|
703
|
+
return {"error": "trackIndex must be >= 1"}
|
|
704
|
+
return {"success": bool(tl.DeleteTrack(tt, ti)), "trackType": tt, "trackIndex": ti}
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def action_set_track_enable(body):
|
|
708
|
+
_, _, tl, err = _timeline()
|
|
709
|
+
if err:
|
|
710
|
+
return err
|
|
711
|
+
tt = body.get("trackType", "video")
|
|
712
|
+
ti = int(body.get("trackIndex", 1))
|
|
713
|
+
en = bool(body.get("enabled", True))
|
|
714
|
+
return {"success": bool(tl.SetTrackEnable(tt, ti, en)), "enabled": en}
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def action_set_track_lock(body):
|
|
718
|
+
_, _, tl, err = _timeline()
|
|
719
|
+
if err:
|
|
720
|
+
return err
|
|
721
|
+
tt = body.get("trackType", "video")
|
|
722
|
+
ti = int(body.get("trackIndex", 1))
|
|
723
|
+
locked = bool(body.get("locked", True))
|
|
724
|
+
return {"success": bool(tl.SetTrackLock(tt, ti, locked)), "locked": locked}
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def action_set_track_name(body):
|
|
728
|
+
_, _, tl, err = _timeline()
|
|
729
|
+
if err:
|
|
730
|
+
return err
|
|
731
|
+
tt = body.get("trackType", "video")
|
|
732
|
+
ti = int(body.get("trackIndex", 1))
|
|
733
|
+
name = body.get("name", "")
|
|
734
|
+
if not name:
|
|
735
|
+
return {"error": "name is required"}
|
|
736
|
+
return {"success": bool(tl.SetTrackName(tt, ti, name)), "name": name}
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
# -- Media management ------------------------------------------------------
|
|
740
|
+
|
|
741
|
+
def action_import_media(body):
|
|
742
|
+
_, proj, err = _project()
|
|
743
|
+
if err:
|
|
744
|
+
return err
|
|
745
|
+
paths = body.get("filePaths", [])
|
|
746
|
+
if not paths:
|
|
747
|
+
return {"error": "filePaths array is required"}
|
|
748
|
+
pool = proj.GetMediaPool()
|
|
749
|
+
if not pool:
|
|
750
|
+
return {"error": "No media pool"}
|
|
751
|
+
items = pool.ImportMedia(paths)
|
|
752
|
+
if not items:
|
|
753
|
+
return {"error": "Import failed — check file paths are valid Windows paths accessible from Resolve"}
|
|
754
|
+
return {
|
|
755
|
+
"success": True,
|
|
756
|
+
"imported": [safe(lambda i=i: i.GetName()) for i in items],
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def action_import_media_from_storage(body):
|
|
761
|
+
r, proj, err = _project()
|
|
762
|
+
if err:
|
|
763
|
+
return err
|
|
764
|
+
paths = body.get("filePaths", [])
|
|
765
|
+
if not paths:
|
|
766
|
+
return {"error": "filePaths array is required"}
|
|
767
|
+
ms = r.GetMediaStorage()
|
|
768
|
+
if not ms:
|
|
769
|
+
return {"error": "No media storage"}
|
|
770
|
+
items = ms.AddItemListToMediaPool(paths)
|
|
771
|
+
if not items:
|
|
772
|
+
return {"error": "Import from storage failed"}
|
|
773
|
+
return {
|
|
774
|
+
"success": True,
|
|
775
|
+
"imported": [safe(lambda i=i: i.GetName()) for i in items],
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def action_append_to_timeline(body):
|
|
780
|
+
_, proj, err = _project()
|
|
781
|
+
if err:
|
|
782
|
+
return err
|
|
783
|
+
pool = proj.GetMediaPool()
|
|
784
|
+
if not pool:
|
|
785
|
+
return {"error": "No media pool"}
|
|
786
|
+
clip_name = body.get("clipName", "")
|
|
787
|
+
if not clip_name:
|
|
788
|
+
return {"error": "clipName is required"}
|
|
789
|
+
item = _find_pool_item(pool, clip_name)
|
|
790
|
+
if not item:
|
|
791
|
+
return {"error": "Clip '%s' not found in media pool" % clip_name}
|
|
792
|
+
tl_items = pool.AppendToTimeline([item])
|
|
793
|
+
if not tl_items:
|
|
794
|
+
return {"error": "Failed to append clip to timeline"}
|
|
795
|
+
return {"success": True, "appended": clip_name}
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
# -- Clip operations -------------------------------------------------------
|
|
799
|
+
|
|
800
|
+
def action_set_clip_color(body):
|
|
801
|
+
item, err = _clip_at(body)
|
|
802
|
+
if err:
|
|
803
|
+
return err
|
|
804
|
+
color = body.get("color", "")
|
|
805
|
+
if not color:
|
|
806
|
+
ok = item.ClearClipColor()
|
|
807
|
+
return {"success": bool(ok), "action": "cleared"}
|
|
808
|
+
return {"success": bool(item.SetClipColor(color)), "color": color}
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def action_set_clip_enabled(body):
|
|
812
|
+
item, err = _clip_at(body)
|
|
813
|
+
if err:
|
|
814
|
+
return err
|
|
815
|
+
en = bool(body.get("enabled", True))
|
|
816
|
+
return {"success": bool(item.SetClipEnabled(en)), "enabled": en}
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def action_set_clip_properties(body):
|
|
820
|
+
item, err = _clip_at(body)
|
|
821
|
+
if err:
|
|
822
|
+
return err
|
|
823
|
+
props = body.get("properties", {})
|
|
824
|
+
if not props:
|
|
825
|
+
return {"error": "properties dict is required (e.g. {\"Pan\": 0, \"Opacity\": 80})"}
|
|
826
|
+
results = {}
|
|
827
|
+
for k, v in props.items():
|
|
828
|
+
results[k] = bool(item.SetProperty(k, v))
|
|
829
|
+
return {"success": all(results.values()), "results": results}
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def action_add_clip_marker(body):
|
|
833
|
+
item, err = _clip_at(body)
|
|
834
|
+
if err:
|
|
835
|
+
return err
|
|
836
|
+
fid = body.get("frameId")
|
|
837
|
+
if fid is None:
|
|
838
|
+
return {"error": "frameId is required"}
|
|
839
|
+
ok = item.AddMarker(
|
|
840
|
+
int(fid),
|
|
841
|
+
body.get("color", "Blue"),
|
|
842
|
+
body.get("name", ""),
|
|
843
|
+
body.get("note", ""),
|
|
844
|
+
int(body.get("duration", 1)),
|
|
845
|
+
body.get("customData", ""),
|
|
846
|
+
)
|
|
847
|
+
return {"success": bool(ok)}
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
# -- Titles / Generators ---------------------------------------------------
|
|
851
|
+
|
|
852
|
+
def action_insert_title(body):
|
|
853
|
+
_, _, tl, err = _timeline()
|
|
854
|
+
if err:
|
|
855
|
+
return err
|
|
856
|
+
name = body.get("titleName", "")
|
|
857
|
+
if not name:
|
|
858
|
+
return {"error": "titleName is required (e.g. 'Text+', 'Scroll')"}
|
|
859
|
+
fusion_title = body.get("fusionTitle", False)
|
|
860
|
+
if fusion_title:
|
|
861
|
+
item = tl.InsertFusionTitleIntoTimeline(name)
|
|
862
|
+
else:
|
|
863
|
+
item = tl.InsertTitleIntoTimeline(name)
|
|
864
|
+
if not item:
|
|
865
|
+
return {"error": "Failed to insert title '%s'" % name}
|
|
866
|
+
return {"success": True, "title": name, "clipName": safe(lambda: item.GetName())}
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def action_insert_generator(body):
|
|
870
|
+
_, _, tl, err = _timeline()
|
|
871
|
+
if err:
|
|
872
|
+
return err
|
|
873
|
+
name = body.get("generatorName", "")
|
|
874
|
+
if not name:
|
|
875
|
+
return {"error": "generatorName is required (e.g. 'Solid Color', '10 Step')"}
|
|
876
|
+
fusion_gen = body.get("fusionGenerator", False)
|
|
877
|
+
if fusion_gen:
|
|
878
|
+
item = tl.InsertFusionGeneratorIntoTimeline(name)
|
|
879
|
+
else:
|
|
880
|
+
item = tl.InsertGeneratorIntoTimeline(name)
|
|
881
|
+
if not item:
|
|
882
|
+
return {"error": "Failed to insert generator '%s'" % name}
|
|
883
|
+
return {"success": True, "generator": name, "clipName": safe(lambda: item.GetName())}
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def action_insert_fusion_comp(body):
|
|
887
|
+
_, _, tl, err = _timeline()
|
|
888
|
+
if err:
|
|
889
|
+
return err
|
|
890
|
+
item = tl.InsertFusionCompositionIntoTimeline()
|
|
891
|
+
if not item:
|
|
892
|
+
return {"error": "Failed to insert Fusion composition"}
|
|
893
|
+
return {"success": True, "clipName": safe(lambda: item.GetName())}
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
# -- Render -----------------------------------------------------------------
|
|
897
|
+
|
|
898
|
+
def action_set_render_settings(body):
|
|
899
|
+
_, proj, err = _project()
|
|
900
|
+
if err:
|
|
901
|
+
return err
|
|
902
|
+
settings = body.get("settings", {})
|
|
903
|
+
if not settings:
|
|
904
|
+
return {"error": "settings dict is required"}
|
|
905
|
+
return {"success": bool(proj.SetRenderSettings(settings))}
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def action_set_render_format(body):
|
|
909
|
+
_, proj, err = _project()
|
|
910
|
+
if err:
|
|
911
|
+
return err
|
|
912
|
+
fmt = body.get("format", "")
|
|
913
|
+
codec = body.get("codec", "")
|
|
914
|
+
if not fmt or not codec:
|
|
915
|
+
return {"error": "format and codec are required"}
|
|
916
|
+
return {"success": bool(proj.SetCurrentRenderFormatAndCodec(fmt, codec))}
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def action_add_render_job(body):
|
|
920
|
+
_, proj, err = _project()
|
|
921
|
+
if err:
|
|
922
|
+
return err
|
|
923
|
+
job_id = proj.AddRenderJob()
|
|
924
|
+
if not job_id:
|
|
925
|
+
return {"error": "Failed to add render job — check render settings are complete"}
|
|
926
|
+
return {"success": True, "jobId": job_id}
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def action_start_rendering(body):
|
|
930
|
+
_, proj, err = _project()
|
|
931
|
+
if err:
|
|
932
|
+
return err
|
|
933
|
+
job_ids = body.get("jobIds", [])
|
|
934
|
+
if job_ids:
|
|
935
|
+
ok = proj.StartRendering(job_ids)
|
|
936
|
+
else:
|
|
937
|
+
ok = proj.StartRendering()
|
|
938
|
+
return {"success": bool(ok)}
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def action_stop_rendering(body):
|
|
942
|
+
_, proj, err = _project()
|
|
943
|
+
if err:
|
|
944
|
+
return err
|
|
945
|
+
proj.StopRendering()
|
|
946
|
+
return {"success": True}
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def action_delete_render_job(body):
|
|
950
|
+
_, proj, err = _project()
|
|
951
|
+
if err:
|
|
952
|
+
return err
|
|
953
|
+
job_id = body.get("jobId", "")
|
|
954
|
+
if job_id:
|
|
955
|
+
return {"success": bool(proj.DeleteRenderJob(job_id))}
|
|
956
|
+
if body.get("all", False):
|
|
957
|
+
return {"success": bool(proj.DeleteAllRenderJobs())}
|
|
958
|
+
return {"error": "Provide jobId or set all=true"}
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def action_get_render_formats(body):
|
|
962
|
+
_, proj, err = _project()
|
|
963
|
+
if err:
|
|
964
|
+
return err
|
|
965
|
+
formats = safe(lambda: proj.GetRenderFormats()) or {}
|
|
966
|
+
fmt = body.get("format", "")
|
|
967
|
+
if fmt:
|
|
968
|
+
codecs = safe(lambda: proj.GetRenderCodecs(fmt)) or {}
|
|
969
|
+
return {"format": fmt, "codecs": codecs}
|
|
970
|
+
return {"formats": formats}
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
# -- Project ----------------------------------------------------------------
|
|
974
|
+
|
|
975
|
+
def action_save_project(body):
|
|
976
|
+
_, proj, err = _project()
|
|
977
|
+
if err:
|
|
978
|
+
return err
|
|
979
|
+
return {"success": bool(proj.GetName() and resolve_obj.GetProjectManager().SaveProject())}
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def action_set_project_setting(body):
|
|
983
|
+
_, proj, err = _project()
|
|
984
|
+
if err:
|
|
985
|
+
return err
|
|
986
|
+
key = body.get("key", "")
|
|
987
|
+
value = body.get("value", "")
|
|
988
|
+
if not key:
|
|
989
|
+
return {"error": "key is required"}
|
|
990
|
+
return {"success": bool(proj.SetSetting(key, str(value))), "key": key, "value": value}
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def action_set_timeline_setting(body):
|
|
994
|
+
_, _, tl, err = _timeline()
|
|
995
|
+
if err:
|
|
996
|
+
return err
|
|
997
|
+
key = body.get("key", "")
|
|
998
|
+
value = body.get("value", "")
|
|
999
|
+
if not key:
|
|
1000
|
+
return {"error": "key is required"}
|
|
1001
|
+
return {"success": bool(tl.SetSetting(key, str(value))), "key": key, "value": value}
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def action_export_frame(body):
|
|
1005
|
+
_, proj, err = _project()
|
|
1006
|
+
if err:
|
|
1007
|
+
return err
|
|
1008
|
+
path = body.get("filePath", "")
|
|
1009
|
+
if not path:
|
|
1010
|
+
return {"error": "filePath is required (e.g. 'C:\\\\output\\\\frame.png')"}
|
|
1011
|
+
return {"success": bool(proj.ExportCurrentFrameAsStill(path)), "filePath": path}
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def action_create_subtitles(body):
|
|
1015
|
+
_, _, tl, err = _timeline()
|
|
1016
|
+
if err:
|
|
1017
|
+
return err
|
|
1018
|
+
ok = tl.CreateSubtitlesFromAudio(body.get("settings", {}))
|
|
1019
|
+
return {"success": bool(ok)}
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def action_detect_scene_cuts(body):
|
|
1023
|
+
_, _, tl, err = _timeline()
|
|
1024
|
+
if err:
|
|
1025
|
+
return err
|
|
1026
|
+
return {"success": bool(tl.DetectSceneCuts())}
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
# -- Media Pool Deep Access -------------------------------------------------
|
|
1030
|
+
|
|
1031
|
+
def action_navigate_media_pool(body):
|
|
1032
|
+
_, proj, err = _project()
|
|
1033
|
+
if err:
|
|
1034
|
+
return err
|
|
1035
|
+
pool = proj.GetMediaPool()
|
|
1036
|
+
if not pool:
|
|
1037
|
+
return {"error": "No media pool"}
|
|
1038
|
+
path = body.get("path", "")
|
|
1039
|
+
if not path or path.lower() in ("root", "/"):
|
|
1040
|
+
folder = pool.GetRootFolder()
|
|
1041
|
+
else:
|
|
1042
|
+
folder = _find_folder_by_path(pool, path)
|
|
1043
|
+
if not folder:
|
|
1044
|
+
return {"error": "Folder not found: '%s'" % path}
|
|
1045
|
+
ok = pool.SetCurrentFolder(folder)
|
|
1046
|
+
return {"success": bool(ok), "folder": safe(lambda: folder.GetName())}
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def action_create_media_pool_folder(body):
|
|
1050
|
+
_, proj, err = _project()
|
|
1051
|
+
if err:
|
|
1052
|
+
return err
|
|
1053
|
+
pool = proj.GetMediaPool()
|
|
1054
|
+
if not pool:
|
|
1055
|
+
return {"error": "No media pool"}
|
|
1056
|
+
name = body.get("name", "")
|
|
1057
|
+
if not name:
|
|
1058
|
+
return {"error": "name is required"}
|
|
1059
|
+
parent_path = body.get("parentPath", "")
|
|
1060
|
+
if parent_path:
|
|
1061
|
+
parent = _find_folder_by_path(pool, parent_path)
|
|
1062
|
+
if not parent:
|
|
1063
|
+
return {"error": "Parent folder not found: '%s'" % parent_path}
|
|
1064
|
+
else:
|
|
1065
|
+
parent = pool.GetCurrentFolder()
|
|
1066
|
+
if not parent:
|
|
1067
|
+
return {"error": "No parent folder"}
|
|
1068
|
+
new_folder = pool.AddSubFolder(parent, name)
|
|
1069
|
+
if not new_folder:
|
|
1070
|
+
return {"error": "Failed to create folder '%s'" % name}
|
|
1071
|
+
return {"success": True, "folder": name}
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def action_set_clip_metadata(body):
|
|
1075
|
+
_, proj, err = _project()
|
|
1076
|
+
if err:
|
|
1077
|
+
return err
|
|
1078
|
+
pool = proj.GetMediaPool()
|
|
1079
|
+
if not pool:
|
|
1080
|
+
return {"error": "No media pool"}
|
|
1081
|
+
clip_name = body.get("clipName", "")
|
|
1082
|
+
if not clip_name:
|
|
1083
|
+
return {"error": "clipName is required"}
|
|
1084
|
+
item = _find_pool_item(pool, clip_name)
|
|
1085
|
+
if not item:
|
|
1086
|
+
return {"error": "Clip '%s' not found in media pool" % clip_name}
|
|
1087
|
+
metadata = body.get("metadata", {})
|
|
1088
|
+
if not metadata:
|
|
1089
|
+
return {"error": "metadata dict is required"}
|
|
1090
|
+
ok = item.SetMetadata(metadata)
|
|
1091
|
+
return {"success": bool(ok)}
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def action_set_pool_clip_property(body):
|
|
1095
|
+
_, proj, err = _project()
|
|
1096
|
+
if err:
|
|
1097
|
+
return err
|
|
1098
|
+
pool = proj.GetMediaPool()
|
|
1099
|
+
if not pool:
|
|
1100
|
+
return {"error": "No media pool"}
|
|
1101
|
+
clip_name = body.get("clipName", "")
|
|
1102
|
+
if not clip_name:
|
|
1103
|
+
return {"error": "clipName is required"}
|
|
1104
|
+
item = _find_pool_item(pool, clip_name)
|
|
1105
|
+
if not item:
|
|
1106
|
+
return {"error": "Clip '%s' not found in media pool" % clip_name}
|
|
1107
|
+
prop_name = body.get("propertyName", "")
|
|
1108
|
+
prop_value = body.get("propertyValue", "")
|
|
1109
|
+
if not prop_name:
|
|
1110
|
+
return {"error": "propertyName is required"}
|
|
1111
|
+
ok = item.SetClipProperty(prop_name, prop_value)
|
|
1112
|
+
return {"success": bool(ok), "property": prop_name}
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def action_delete_media_pool_clips(body):
|
|
1116
|
+
_, proj, err = _project()
|
|
1117
|
+
if err:
|
|
1118
|
+
return err
|
|
1119
|
+
pool = proj.GetMediaPool()
|
|
1120
|
+
if not pool:
|
|
1121
|
+
return {"error": "No media pool"}
|
|
1122
|
+
clip_names = body.get("clipNames", [])
|
|
1123
|
+
if not clip_names:
|
|
1124
|
+
return {"error": "clipNames array is required"}
|
|
1125
|
+
items, not_found = [], []
|
|
1126
|
+
for name in clip_names:
|
|
1127
|
+
item = _find_pool_item(pool, name)
|
|
1128
|
+
if item:
|
|
1129
|
+
items.append(item)
|
|
1130
|
+
else:
|
|
1131
|
+
not_found.append(name)
|
|
1132
|
+
if not items:
|
|
1133
|
+
return {"error": "No matching clips found", "notFound": not_found}
|
|
1134
|
+
ok = pool.DeleteClips(items)
|
|
1135
|
+
return {"success": bool(ok), "deleted": len(items), "notFound": not_found}
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def action_move_media_pool_clips(body):
|
|
1139
|
+
_, proj, err = _project()
|
|
1140
|
+
if err:
|
|
1141
|
+
return err
|
|
1142
|
+
pool = proj.GetMediaPool()
|
|
1143
|
+
if not pool:
|
|
1144
|
+
return {"error": "No media pool"}
|
|
1145
|
+
clip_names = body.get("clipNames", [])
|
|
1146
|
+
target_path = body.get("targetFolder", "")
|
|
1147
|
+
if not clip_names:
|
|
1148
|
+
return {"error": "clipNames array is required"}
|
|
1149
|
+
if not target_path:
|
|
1150
|
+
return {"error": "targetFolder path is required"}
|
|
1151
|
+
target = _find_folder_by_path(pool, target_path)
|
|
1152
|
+
if not target:
|
|
1153
|
+
return {"error": "Target folder not found: '%s'" % target_path}
|
|
1154
|
+
items = [i for name in clip_names for i in [_find_pool_item(pool, name)] if i]
|
|
1155
|
+
if not items:
|
|
1156
|
+
return {"error": "No matching clips found"}
|
|
1157
|
+
ok = pool.MoveClips(items, target)
|
|
1158
|
+
return {"success": bool(ok), "moved": len(items)}
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def action_relink_media_pool_clips(body):
|
|
1162
|
+
_, proj, err = _project()
|
|
1163
|
+
if err:
|
|
1164
|
+
return err
|
|
1165
|
+
pool = proj.GetMediaPool()
|
|
1166
|
+
if not pool:
|
|
1167
|
+
return {"error": "No media pool"}
|
|
1168
|
+
clip_names = body.get("clipNames", [])
|
|
1169
|
+
folder_path = body.get("folderPath", "")
|
|
1170
|
+
if not clip_names:
|
|
1171
|
+
return {"error": "clipNames array is required"}
|
|
1172
|
+
if not folder_path:
|
|
1173
|
+
return {"error": "folderPath is required (filesystem path)"}
|
|
1174
|
+
items = [i for name in clip_names for i in [_find_pool_item(pool, name)] if i]
|
|
1175
|
+
if not items:
|
|
1176
|
+
return {"error": "No matching clips found"}
|
|
1177
|
+
ok = pool.RelinkClips(items, folder_path)
|
|
1178
|
+
return {"success": bool(ok), "relinked": len(items)}
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def action_unlink_media_pool_clips(body):
|
|
1182
|
+
_, proj, err = _project()
|
|
1183
|
+
if err:
|
|
1184
|
+
return err
|
|
1185
|
+
pool = proj.GetMediaPool()
|
|
1186
|
+
if not pool:
|
|
1187
|
+
return {"error": "No media pool"}
|
|
1188
|
+
clip_names = body.get("clipNames", [])
|
|
1189
|
+
if not clip_names:
|
|
1190
|
+
return {"error": "clipNames array is required"}
|
|
1191
|
+
items = [i for name in clip_names for i in [_find_pool_item(pool, name)] if i]
|
|
1192
|
+
if not items:
|
|
1193
|
+
return {"error": "No matching clips found"}
|
|
1194
|
+
ok = pool.UnlinkClips(items)
|
|
1195
|
+
return {"success": bool(ok), "unlinked": len(items)}
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def action_auto_sync_audio(body):
|
|
1199
|
+
_, proj, err = _project()
|
|
1200
|
+
if err:
|
|
1201
|
+
return err
|
|
1202
|
+
pool = proj.GetMediaPool()
|
|
1203
|
+
if not pool:
|
|
1204
|
+
return {"error": "No media pool"}
|
|
1205
|
+
clip_names = body.get("clipNames", [])
|
|
1206
|
+
if len(clip_names) < 2:
|
|
1207
|
+
return {"error": "At least 2 clipNames required (video + audio)"}
|
|
1208
|
+
items = [i for name in clip_names for i in [_find_pool_item(pool, name)] if i]
|
|
1209
|
+
if len(items) < 2:
|
|
1210
|
+
return {"error": "Need at least 2 matching clips"}
|
|
1211
|
+
settings = body.get("settings", {})
|
|
1212
|
+
ok = pool.AutoSyncAudio(items, settings)
|
|
1213
|
+
return {"success": bool(ok)}
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def action_import_timeline_from_file(body):
|
|
1217
|
+
_, proj, err = _project()
|
|
1218
|
+
if err:
|
|
1219
|
+
return err
|
|
1220
|
+
pool = proj.GetMediaPool()
|
|
1221
|
+
if not pool:
|
|
1222
|
+
return {"error": "No media pool"}
|
|
1223
|
+
file_path = body.get("filePath", "")
|
|
1224
|
+
if not file_path:
|
|
1225
|
+
return {"error": "filePath is required"}
|
|
1226
|
+
options = body.get("importOptions", {})
|
|
1227
|
+
tl = pool.ImportTimelineFromFile(file_path, options) if options else pool.ImportTimelineFromFile(file_path)
|
|
1228
|
+
if not tl:
|
|
1229
|
+
return {"error": "Failed to import timeline from '%s'" % file_path}
|
|
1230
|
+
return {"success": True, "timeline": safe(lambda: tl.GetName())}
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def action_export_metadata(body):
|
|
1234
|
+
_, proj, err = _project()
|
|
1235
|
+
if err:
|
|
1236
|
+
return err
|
|
1237
|
+
pool = proj.GetMediaPool()
|
|
1238
|
+
if not pool:
|
|
1239
|
+
return {"error": "No media pool"}
|
|
1240
|
+
file_name = body.get("fileName", "")
|
|
1241
|
+
if not file_name:
|
|
1242
|
+
return {"error": "fileName is required (CSV path)"}
|
|
1243
|
+
clip_names = body.get("clipNames", [])
|
|
1244
|
+
items = [i for name in clip_names for i in [_find_pool_item(pool, name)] if i] if clip_names else []
|
|
1245
|
+
ok = pool.ExportMetadata(file_name, items) if items else pool.ExportMetadata(file_name)
|
|
1246
|
+
return {"success": bool(ok), "fileName": file_name}
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def action_insert_to_timeline(body):
|
|
1250
|
+
"""Insert a media pool clip at a specific track and record frame position."""
|
|
1251
|
+
_, proj, err = _project()
|
|
1252
|
+
if err:
|
|
1253
|
+
return err
|
|
1254
|
+
pool = proj.GetMediaPool()
|
|
1255
|
+
if not pool:
|
|
1256
|
+
return {"error": "No media pool"}
|
|
1257
|
+
clip_name = body.get("clipName", "")
|
|
1258
|
+
if not clip_name:
|
|
1259
|
+
return {"error": "clipName is required"}
|
|
1260
|
+
item = _find_pool_item(pool, clip_name)
|
|
1261
|
+
if not item:
|
|
1262
|
+
return {"error": "Clip '%s' not found in media pool" % clip_name}
|
|
1263
|
+
clip_info = {"mediaPoolItem": item}
|
|
1264
|
+
for key, body_key in [("trackIndex", "trackIndex"), ("recordFrame", "recordFrame"),
|
|
1265
|
+
("startFrame", "startFrame"), ("endFrame", "endFrame"),
|
|
1266
|
+
("mediaType", "mediaType")]:
|
|
1267
|
+
val = body.get(body_key)
|
|
1268
|
+
if val is not None:
|
|
1269
|
+
clip_info[key] = int(val)
|
|
1270
|
+
tl_items = pool.AppendToTimeline([clip_info])
|
|
1271
|
+
if not tl_items:
|
|
1272
|
+
return {"error": "Failed to insert clip to timeline"}
|
|
1273
|
+
return {"success": True, "inserted": clip_name, "count": len(tl_items)}
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
# -- Per-Clip Markers & Flags ----------------------------------------------
|
|
1277
|
+
|
|
1278
|
+
def action_delete_clip_marker(body):
|
|
1279
|
+
item, err = _clip_at(body)
|
|
1280
|
+
if err:
|
|
1281
|
+
return err
|
|
1282
|
+
frame = body.get("frameId")
|
|
1283
|
+
color = body.get("color")
|
|
1284
|
+
custom_data = body.get("customData")
|
|
1285
|
+
if frame is not None:
|
|
1286
|
+
return {"success": bool(item.DeleteMarkerAtFrame(int(frame)))}
|
|
1287
|
+
if color:
|
|
1288
|
+
return {"success": bool(item.DeleteMarkersByColor(color))}
|
|
1289
|
+
if custom_data:
|
|
1290
|
+
return {"success": bool(item.DeleteMarkerByCustomData(custom_data))}
|
|
1291
|
+
return {"error": "Provide frameId, color (use 'All' for all), or customData"}
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def action_add_clip_flag(body):
|
|
1295
|
+
item, err = _clip_at(body)
|
|
1296
|
+
if err:
|
|
1297
|
+
return err
|
|
1298
|
+
color = body.get("color", "")
|
|
1299
|
+
if not color:
|
|
1300
|
+
return {"error": "color is required"}
|
|
1301
|
+
return {"success": bool(item.AddFlag(color)), "color": color}
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def action_clear_clip_flags(body):
|
|
1305
|
+
item, err = _clip_at(body)
|
|
1306
|
+
if err:
|
|
1307
|
+
return err
|
|
1308
|
+
color = body.get("color", "All")
|
|
1309
|
+
return {"success": bool(item.ClearFlags(color)), "color": color}
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
# -- Timeline Clip Manipulation ---------------------------------------------
|
|
1313
|
+
|
|
1314
|
+
def action_delete_timeline_clips(body):
|
|
1315
|
+
_, _, tl, err = _timeline()
|
|
1316
|
+
if err:
|
|
1317
|
+
return err
|
|
1318
|
+
clip_refs = body.get("clips", [])
|
|
1319
|
+
ripple = body.get("ripple", False)
|
|
1320
|
+
if not clip_refs:
|
|
1321
|
+
return {"error": "clips array is required (each: {trackType, trackIndex, clipIndex})"}
|
|
1322
|
+
items = _resolve_clip_refs(tl, clip_refs)
|
|
1323
|
+
if not items:
|
|
1324
|
+
return {"error": "No matching clips found"}
|
|
1325
|
+
ok = tl.DeleteClips(items, ripple)
|
|
1326
|
+
return {"success": bool(ok), "deleted": len(items), "ripple": ripple}
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def action_link_timeline_clips(body):
|
|
1330
|
+
_, _, tl, err = _timeline()
|
|
1331
|
+
if err:
|
|
1332
|
+
return err
|
|
1333
|
+
clip_refs = body.get("clips", [])
|
|
1334
|
+
linked = body.get("linked", True)
|
|
1335
|
+
if len(clip_refs) < 2:
|
|
1336
|
+
return {"error": "At least 2 clip references required"}
|
|
1337
|
+
items = _resolve_clip_refs(tl, clip_refs)
|
|
1338
|
+
if len(items) < 2:
|
|
1339
|
+
return {"error": "Need at least 2 matching clips"}
|
|
1340
|
+
ok = tl.SetClipsLinked(items, linked)
|
|
1341
|
+
return {"success": bool(ok), "linked": linked, "count": len(items)}
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def action_create_compound_clip(body):
|
|
1345
|
+
_, _, tl, err = _timeline()
|
|
1346
|
+
if err:
|
|
1347
|
+
return err
|
|
1348
|
+
clip_refs = body.get("clips", [])
|
|
1349
|
+
if not clip_refs:
|
|
1350
|
+
return {"error": "clips array is required"}
|
|
1351
|
+
items = _resolve_clip_refs(tl, clip_refs)
|
|
1352
|
+
if not items:
|
|
1353
|
+
return {"error": "No matching clips found"}
|
|
1354
|
+
clip_info = {}
|
|
1355
|
+
if body.get("name"):
|
|
1356
|
+
clip_info["name"] = body["name"]
|
|
1357
|
+
if body.get("startTimecode"):
|
|
1358
|
+
clip_info["startTimecode"] = body["startTimecode"]
|
|
1359
|
+
result = tl.CreateCompoundClip(items, clip_info) if clip_info else tl.CreateCompoundClip(items)
|
|
1360
|
+
if not result:
|
|
1361
|
+
return {"error": "Failed to create compound clip"}
|
|
1362
|
+
return {"success": True, "name": safe(lambda: result.GetName())}
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def action_create_fusion_clip(body):
|
|
1366
|
+
_, _, tl, err = _timeline()
|
|
1367
|
+
if err:
|
|
1368
|
+
return err
|
|
1369
|
+
clip_refs = body.get("clips", [])
|
|
1370
|
+
if not clip_refs:
|
|
1371
|
+
return {"error": "clips array is required"}
|
|
1372
|
+
items = _resolve_clip_refs(tl, clip_refs)
|
|
1373
|
+
if not items:
|
|
1374
|
+
return {"error": "No matching clips found"}
|
|
1375
|
+
result = tl.CreateFusionClip(items)
|
|
1376
|
+
if not result:
|
|
1377
|
+
return {"error": "Failed to create Fusion clip"}
|
|
1378
|
+
return {"success": True, "name": safe(lambda: result.GetName())}
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def action_export_timeline(body):
|
|
1382
|
+
"""Export timeline to AAF/EDL/FCPXML/OTIO etc."""
|
|
1383
|
+
r, _, tl, err = _timeline()
|
|
1384
|
+
if err:
|
|
1385
|
+
return err
|
|
1386
|
+
file_name = body.get("fileName", "")
|
|
1387
|
+
export_type = body.get("exportType", "")
|
|
1388
|
+
export_subtype = body.get("exportSubtype", "EXPORT_NONE")
|
|
1389
|
+
if not file_name:
|
|
1390
|
+
return {"error": "fileName is required"}
|
|
1391
|
+
if not export_type:
|
|
1392
|
+
return {"error": "exportType is required"}
|
|
1393
|
+
type_map = {
|
|
1394
|
+
"AAF": "EXPORT_AAF", "DRT": "EXPORT_DRT", "EDL": "EXPORT_EDL",
|
|
1395
|
+
"FCP_7_XML": "EXPORT_FCP_7_XML",
|
|
1396
|
+
"FCPXML_1_8": "EXPORT_FCPXML_1_8", "FCPXML_1_9": "EXPORT_FCPXML_1_9",
|
|
1397
|
+
"FCPXML_1_10": "EXPORT_FCPXML_1_10",
|
|
1398
|
+
"HDR_10_PROFILE_A": "EXPORT_HDR_10_PROFILE_A",
|
|
1399
|
+
"HDR_10_PROFILE_B": "EXPORT_HDR_10_PROFILE_B",
|
|
1400
|
+
"TEXT_CSV": "EXPORT_TEXT_CSV", "TEXT_TAB": "EXPORT_TEXT_TAB",
|
|
1401
|
+
"DOLBY_VISION_VER_2_9": "EXPORT_DOLBY_VISION_VER_2_9",
|
|
1402
|
+
"DOLBY_VISION_VER_4_0": "EXPORT_DOLBY_VISION_VER_4_0",
|
|
1403
|
+
"DOLBY_VISION_VER_5_1": "EXPORT_DOLBY_VISION_VER_5_1",
|
|
1404
|
+
"OTIO": "EXPORT_OTIO", "ALE": "EXPORT_ALE", "ALE_CDL": "EXPORT_ALE_CDL",
|
|
1405
|
+
}
|
|
1406
|
+
subtype_map = {
|
|
1407
|
+
"EXPORT_NONE": "EXPORT_NONE", "EXPORT_AAF_NEW": "EXPORT_AAF_NEW",
|
|
1408
|
+
"EXPORT_AAF_EXISTING": "EXPORT_AAF_EXISTING",
|
|
1409
|
+
"EXPORT_CDL": "EXPORT_CDL", "EXPORT_SDL": "EXPORT_SDL",
|
|
1410
|
+
"EXPORT_MISSING_CLIPS": "EXPORT_MISSING_CLIPS",
|
|
1411
|
+
}
|
|
1412
|
+
et_attr = type_map.get(export_type)
|
|
1413
|
+
if not et_attr:
|
|
1414
|
+
return {"error": "Unknown exportType '%s'. Valid: %s" % (export_type, ", ".join(type_map.keys()))}
|
|
1415
|
+
es_attr = subtype_map.get(export_subtype, "EXPORT_NONE")
|
|
1416
|
+
et = safe(lambda: getattr(r, et_attr))
|
|
1417
|
+
es = safe(lambda: getattr(r, es_attr))
|
|
1418
|
+
if et is None:
|
|
1419
|
+
return {"error": "Resolve does not support export constant '%s'" % et_attr}
|
|
1420
|
+
ok = tl.Export(file_name, et, es)
|
|
1421
|
+
return {"success": bool(ok), "fileName": file_name, "exportType": export_type}
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
# -- Gallery / Stills -------------------------------------------------------
|
|
1425
|
+
|
|
1426
|
+
def action_set_current_album(body):
|
|
1427
|
+
_, proj, err = _project()
|
|
1428
|
+
if err:
|
|
1429
|
+
return err
|
|
1430
|
+
gallery = safe(lambda: proj.GetGallery())
|
|
1431
|
+
if not gallery:
|
|
1432
|
+
return {"error": "Cannot access gallery"}
|
|
1433
|
+
album, aerr = _get_album(gallery, body)
|
|
1434
|
+
if aerr:
|
|
1435
|
+
return aerr
|
|
1436
|
+
ok = gallery.SetCurrentStillAlbum(album)
|
|
1437
|
+
name = safe(lambda: gallery.GetAlbumName(album))
|
|
1438
|
+
return {"success": bool(ok), "albumName": name}
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def action_create_gallery_album(body):
|
|
1442
|
+
_, proj, err = _project()
|
|
1443
|
+
if err:
|
|
1444
|
+
return err
|
|
1445
|
+
gallery = safe(lambda: proj.GetGallery())
|
|
1446
|
+
if not gallery:
|
|
1447
|
+
return {"error": "Cannot access gallery"}
|
|
1448
|
+
album_type = body.get("albumType", "still")
|
|
1449
|
+
if album_type == "powergrade":
|
|
1450
|
+
album = gallery.CreateGalleryPowerGradeAlbum()
|
|
1451
|
+
else:
|
|
1452
|
+
album = gallery.CreateGalleryStillAlbum()
|
|
1453
|
+
if not album:
|
|
1454
|
+
return {"error": "Failed to create %s album" % album_type}
|
|
1455
|
+
name = body.get("name", "")
|
|
1456
|
+
if name:
|
|
1457
|
+
gallery.SetAlbumName(album, name)
|
|
1458
|
+
final_name = safe(lambda: gallery.GetAlbumName(album))
|
|
1459
|
+
return {"success": True, "albumName": final_name, "albumType": album_type}
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
def action_grab_still(body):
|
|
1463
|
+
_, _, tl, err = _timeline()
|
|
1464
|
+
if err:
|
|
1465
|
+
return err
|
|
1466
|
+
still = tl.GrabStill()
|
|
1467
|
+
if not still:
|
|
1468
|
+
return {"error": "Failed to grab still. Make sure you are on the Color page."}
|
|
1469
|
+
return {"success": True}
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def action_grab_all_stills(body):
|
|
1473
|
+
_, _, tl, err = _timeline()
|
|
1474
|
+
if err:
|
|
1475
|
+
return err
|
|
1476
|
+
source = int(body.get("stillFrameSource", 2))
|
|
1477
|
+
stills = tl.GrabAllStills(source)
|
|
1478
|
+
if not stills:
|
|
1479
|
+
return {"error": "Failed to grab stills"}
|
|
1480
|
+
return {"success": True, "count": len(stills)}
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def action_export_stills(body):
|
|
1484
|
+
_, proj, err = _project()
|
|
1485
|
+
if err:
|
|
1486
|
+
return err
|
|
1487
|
+
gallery = safe(lambda: proj.GetGallery())
|
|
1488
|
+
if not gallery:
|
|
1489
|
+
return {"error": "Cannot access gallery"}
|
|
1490
|
+
album, aerr = _get_album(gallery, body)
|
|
1491
|
+
if aerr:
|
|
1492
|
+
return aerr
|
|
1493
|
+
folder_path = body.get("folderPath", "")
|
|
1494
|
+
file_prefix = body.get("filePrefix", "still")
|
|
1495
|
+
fmt = body.get("format", "png")
|
|
1496
|
+
if not folder_path:
|
|
1497
|
+
return {"error": "folderPath is required"}
|
|
1498
|
+
stills = safe(lambda: album.GetStills()) or []
|
|
1499
|
+
still_indices = body.get("stillIndices", [])
|
|
1500
|
+
if still_indices:
|
|
1501
|
+
selected = [stills[i - 1] for i in still_indices if 1 <= i <= len(stills)]
|
|
1502
|
+
else:
|
|
1503
|
+
selected = stills
|
|
1504
|
+
if not selected:
|
|
1505
|
+
return {"error": "No stills to export"}
|
|
1506
|
+
ok = album.ExportStills(selected, folder_path, file_prefix, fmt)
|
|
1507
|
+
return {"success": bool(ok), "exported": len(selected), "folderPath": folder_path}
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
def action_import_stills(body):
|
|
1511
|
+
_, proj, err = _project()
|
|
1512
|
+
if err:
|
|
1513
|
+
return err
|
|
1514
|
+
gallery = safe(lambda: proj.GetGallery())
|
|
1515
|
+
if not gallery:
|
|
1516
|
+
return {"error": "Cannot access gallery"}
|
|
1517
|
+
album = safe(lambda: gallery.GetCurrentStillAlbum())
|
|
1518
|
+
if not album:
|
|
1519
|
+
return {"error": "No album selected"}
|
|
1520
|
+
file_paths = body.get("filePaths", [])
|
|
1521
|
+
if not file_paths:
|
|
1522
|
+
return {"error": "filePaths array is required"}
|
|
1523
|
+
ok = album.ImportStills(file_paths)
|
|
1524
|
+
return {"success": bool(ok)}
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
def action_delete_stills(body):
|
|
1528
|
+
_, proj, err = _project()
|
|
1529
|
+
if err:
|
|
1530
|
+
return err
|
|
1531
|
+
gallery = safe(lambda: proj.GetGallery())
|
|
1532
|
+
if not gallery:
|
|
1533
|
+
return {"error": "Cannot access gallery"}
|
|
1534
|
+
album, aerr = _get_album(gallery, body)
|
|
1535
|
+
if aerr:
|
|
1536
|
+
return aerr
|
|
1537
|
+
stills = safe(lambda: album.GetStills()) or []
|
|
1538
|
+
still_indices = body.get("stillIndices", [])
|
|
1539
|
+
if not still_indices:
|
|
1540
|
+
return {"error": "stillIndices array is required"}
|
|
1541
|
+
selected = [stills[i - 1] for i in still_indices if 1 <= i <= len(stills)]
|
|
1542
|
+
if not selected:
|
|
1543
|
+
return {"error": "No stills found at given indices"}
|
|
1544
|
+
ok = album.DeleteStills(selected)
|
|
1545
|
+
return {"success": bool(ok), "deleted": len(selected)}
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
def action_set_still_label(body):
|
|
1549
|
+
_, proj, err = _project()
|
|
1550
|
+
if err:
|
|
1551
|
+
return err
|
|
1552
|
+
gallery = safe(lambda: proj.GetGallery())
|
|
1553
|
+
if not gallery:
|
|
1554
|
+
return {"error": "Cannot access gallery"}
|
|
1555
|
+
album = safe(lambda: gallery.GetCurrentStillAlbum())
|
|
1556
|
+
if not album:
|
|
1557
|
+
return {"error": "No album selected"}
|
|
1558
|
+
still_index = int(body.get("stillIndex", 0))
|
|
1559
|
+
label = body.get("label", "")
|
|
1560
|
+
stills = safe(lambda: album.GetStills()) or []
|
|
1561
|
+
if still_index < 1 or still_index > len(stills):
|
|
1562
|
+
return {"error": "stillIndex %d out of range (1-%d)" % (still_index, len(stills))}
|
|
1563
|
+
ok = album.SetLabel(stills[still_index - 1], label)
|
|
1564
|
+
return {"success": bool(ok), "label": label}
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
# -- Color Grading / Node Graph / LUT / CDL --------------------------------
|
|
1568
|
+
|
|
1569
|
+
def gather_node_graph(qs):
|
|
1570
|
+
"""Get the color node graph for a timeline item or the timeline itself."""
|
|
1571
|
+
scope = qs.get("scope", ["clip"])[0]
|
|
1572
|
+
if scope == "timeline":
|
|
1573
|
+
_, _, tl, err = _timeline()
|
|
1574
|
+
if err:
|
|
1575
|
+
return err
|
|
1576
|
+
graph = safe(lambda: tl.GetNodeGraph())
|
|
1577
|
+
if not graph:
|
|
1578
|
+
return {"error": "Cannot get timeline node graph"}
|
|
1579
|
+
else:
|
|
1580
|
+
item, err = _clip_at({
|
|
1581
|
+
"trackType": qs.get("track_type", ["video"])[0],
|
|
1582
|
+
"trackIndex": int(qs.get("track_index", ["1"])[0]),
|
|
1583
|
+
"clipIndex": int(qs.get("clip_index", ["0"])[0]),
|
|
1584
|
+
})
|
|
1585
|
+
if err:
|
|
1586
|
+
return err
|
|
1587
|
+
layer = int(qs.get("layer_index", ["1"])[0])
|
|
1588
|
+
graph = safe(lambda: item.GetNodeGraph(layer))
|
|
1589
|
+
if not graph:
|
|
1590
|
+
return {"error": "Cannot get clip node graph"}
|
|
1591
|
+
num = safe(lambda: graph.GetNumNodes()) or 0
|
|
1592
|
+
nodes = []
|
|
1593
|
+
for i in range(1, num + 1):
|
|
1594
|
+
nodes.append({
|
|
1595
|
+
"index": i,
|
|
1596
|
+
"label": safe(lambda i=i: graph.GetNodeLabel(i)) or "",
|
|
1597
|
+
"lut": safe(lambda i=i: graph.GetLUT(i)) or "",
|
|
1598
|
+
"tools": safe(lambda i=i: graph.GetToolsInNode(i)) or [],
|
|
1599
|
+
"cacheMode": safe(lambda i=i: graph.GetNodeCacheMode(i)),
|
|
1600
|
+
})
|
|
1601
|
+
return {"nodeCount": num, "nodes": nodes}
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
def action_set_lut(body):
|
|
1605
|
+
item, err = _clip_at(body)
|
|
1606
|
+
if err:
|
|
1607
|
+
return err
|
|
1608
|
+
layer = int(body.get("layerIndex", 1))
|
|
1609
|
+
graph = safe(lambda: item.GetNodeGraph(layer))
|
|
1610
|
+
if not graph:
|
|
1611
|
+
return {"error": "Cannot get node graph"}
|
|
1612
|
+
node_index = int(body.get("nodeIndex", 1))
|
|
1613
|
+
lut_path = body.get("lutPath", "")
|
|
1614
|
+
if not lut_path:
|
|
1615
|
+
return {"error": "lutPath is required"}
|
|
1616
|
+
ok = graph.SetLUT(node_index, lut_path)
|
|
1617
|
+
return {"success": bool(ok), "nodeIndex": node_index, "lutPath": lut_path}
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
def action_get_lut(body):
|
|
1621
|
+
item, err = _clip_at(body)
|
|
1622
|
+
if err:
|
|
1623
|
+
return err
|
|
1624
|
+
layer = int(body.get("layerIndex", 1))
|
|
1625
|
+
graph = safe(lambda: item.GetNodeGraph(layer))
|
|
1626
|
+
if not graph:
|
|
1627
|
+
return {"error": "Cannot get node graph"}
|
|
1628
|
+
node_index = int(body.get("nodeIndex", 1))
|
|
1629
|
+
lut = safe(lambda: graph.GetLUT(node_index))
|
|
1630
|
+
return {"nodeIndex": node_index, "lutPath": lut or ""}
|
|
1631
|
+
|
|
1632
|
+
|
|
1633
|
+
def action_set_node_enabled(body):
|
|
1634
|
+
item, err = _clip_at(body)
|
|
1635
|
+
if err:
|
|
1636
|
+
return err
|
|
1637
|
+
layer = int(body.get("layerIndex", 1))
|
|
1638
|
+
graph = safe(lambda: item.GetNodeGraph(layer))
|
|
1639
|
+
if not graph:
|
|
1640
|
+
return {"error": "Cannot get node graph"}
|
|
1641
|
+
node_index = int(body.get("nodeIndex", 1))
|
|
1642
|
+
enabled = bool(body.get("enabled", True))
|
|
1643
|
+
ok = graph.SetNodeEnabled(node_index, enabled)
|
|
1644
|
+
return {"success": bool(ok), "nodeIndex": node_index, "enabled": enabled}
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
def action_apply_grade_from_drx(body):
|
|
1648
|
+
item, err = _clip_at(body)
|
|
1649
|
+
if err:
|
|
1650
|
+
return err
|
|
1651
|
+
layer = int(body.get("layerIndex", 1))
|
|
1652
|
+
graph = safe(lambda: item.GetNodeGraph(layer))
|
|
1653
|
+
if not graph:
|
|
1654
|
+
return {"error": "Cannot get node graph"}
|
|
1655
|
+
path = body.get("drxPath", "")
|
|
1656
|
+
grade_mode = int(body.get("gradeMode", 0))
|
|
1657
|
+
if not path:
|
|
1658
|
+
return {"error": "drxPath is required"}
|
|
1659
|
+
ok = graph.ApplyGradeFromDRX(path, grade_mode)
|
|
1660
|
+
return {"success": bool(ok), "drxPath": path, "gradeMode": grade_mode}
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
def action_reset_all_grades(body):
|
|
1664
|
+
item, err = _clip_at(body)
|
|
1665
|
+
if err:
|
|
1666
|
+
return err
|
|
1667
|
+
layer = int(body.get("layerIndex", 1))
|
|
1668
|
+
graph = safe(lambda: item.GetNodeGraph(layer))
|
|
1669
|
+
if not graph:
|
|
1670
|
+
return {"error": "Cannot get node graph"}
|
|
1671
|
+
ok = graph.ResetAllGrades()
|
|
1672
|
+
return {"success": bool(ok)}
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
def action_apply_arri_cdl_lut(body):
|
|
1676
|
+
item, err = _clip_at(body)
|
|
1677
|
+
if err:
|
|
1678
|
+
return err
|
|
1679
|
+
layer = int(body.get("layerIndex", 1))
|
|
1680
|
+
graph = safe(lambda: item.GetNodeGraph(layer))
|
|
1681
|
+
if not graph:
|
|
1682
|
+
return {"error": "Cannot get node graph"}
|
|
1683
|
+
ok = graph.ApplyArriCdlLut()
|
|
1684
|
+
return {"success": bool(ok)}
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
def action_set_cdl(body):
|
|
1688
|
+
item, err = _clip_at(body)
|
|
1689
|
+
if err:
|
|
1690
|
+
return err
|
|
1691
|
+
cdl_map = body.get("cdl", {})
|
|
1692
|
+
if not cdl_map:
|
|
1693
|
+
return {"error": "cdl dict is required with keys: NodeIndex, Slope, Offset, Power, Saturation"}
|
|
1694
|
+
ok = item.SetCDL(cdl_map)
|
|
1695
|
+
return {"success": bool(ok)}
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
def action_export_lut(body):
|
|
1699
|
+
r, _ = _resolve()
|
|
1700
|
+
item, err = _clip_at(body)
|
|
1701
|
+
if err:
|
|
1702
|
+
return err
|
|
1703
|
+
export_type_str = body.get("exportType", "33PTCUBE")
|
|
1704
|
+
path = body.get("path", "")
|
|
1705
|
+
if not path:
|
|
1706
|
+
return {"error": "path is required"}
|
|
1707
|
+
type_map = {
|
|
1708
|
+
"17PTCUBE": "EXPORT_LUT_17PTCUBE",
|
|
1709
|
+
"33PTCUBE": "EXPORT_LUT_33PTCUBE",
|
|
1710
|
+
"65PTCUBE": "EXPORT_LUT_65PTCUBE",
|
|
1711
|
+
"PANASONICVLUT": "EXPORT_LUT_PANASONICVLUT",
|
|
1712
|
+
}
|
|
1713
|
+
attr = type_map.get(export_type_str)
|
|
1714
|
+
if not attr:
|
|
1715
|
+
return {"error": "Unknown exportType. Valid: %s" % ", ".join(type_map.keys())}
|
|
1716
|
+
et = safe(lambda: getattr(r, attr))
|
|
1717
|
+
if et is None:
|
|
1718
|
+
return {"error": "Resolve does not support %s" % attr}
|
|
1719
|
+
ok = item.ExportLUT(et, path)
|
|
1720
|
+
return {"success": bool(ok), "path": path}
|
|
1721
|
+
|
|
1722
|
+
|
|
1723
|
+
def action_copy_grades(body):
|
|
1724
|
+
_, _, tl, err = _timeline()
|
|
1725
|
+
if err:
|
|
1726
|
+
return err
|
|
1727
|
+
source_ref = body.get("source", {})
|
|
1728
|
+
target_refs = body.get("targets", [])
|
|
1729
|
+
if not source_ref or not target_refs:
|
|
1730
|
+
return {"error": "source and targets are required"}
|
|
1731
|
+
source_item, serr = _clip_at(source_ref)
|
|
1732
|
+
if serr:
|
|
1733
|
+
return serr
|
|
1734
|
+
target_items = _resolve_clip_refs(tl, target_refs)
|
|
1735
|
+
if not target_items:
|
|
1736
|
+
return {"error": "No matching target clips found"}
|
|
1737
|
+
ok = source_item.CopyGrades(target_items)
|
|
1738
|
+
return {"success": bool(ok), "copiedTo": len(target_items)}
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
def action_reset_node_colors(body):
|
|
1742
|
+
item, err = _clip_at(body)
|
|
1743
|
+
if err:
|
|
1744
|
+
return err
|
|
1745
|
+
ok = item.ResetAllNodeColors()
|
|
1746
|
+
return {"success": bool(ok)}
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
# -- Color Versions ---------------------------------------------------------
|
|
1750
|
+
|
|
1751
|
+
def gather_color_versions(qs):
|
|
1752
|
+
item, err = _clip_at({
|
|
1753
|
+
"trackType": qs.get("track_type", ["video"])[0],
|
|
1754
|
+
"trackIndex": int(qs.get("track_index", ["1"])[0]),
|
|
1755
|
+
"clipIndex": int(qs.get("clip_index", ["0"])[0]),
|
|
1756
|
+
})
|
|
1757
|
+
if err:
|
|
1758
|
+
return err
|
|
1759
|
+
current = safe(lambda: item.GetCurrentVersion()) or {}
|
|
1760
|
+
local_versions = safe(lambda: item.GetVersionNameList(0)) or []
|
|
1761
|
+
remote_versions = safe(lambda: item.GetVersionNameList(1)) or []
|
|
1762
|
+
return {
|
|
1763
|
+
"clipName": safe(lambda: item.GetName()),
|
|
1764
|
+
"currentVersion": current,
|
|
1765
|
+
"localVersions": local_versions,
|
|
1766
|
+
"remoteVersions": remote_versions,
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
def action_add_color_version(body):
|
|
1771
|
+
item, err = _clip_at(body)
|
|
1772
|
+
if err:
|
|
1773
|
+
return err
|
|
1774
|
+
name = body.get("versionName", "")
|
|
1775
|
+
vtype = int(body.get("versionType", 0))
|
|
1776
|
+
if not name:
|
|
1777
|
+
return {"error": "versionName is required"}
|
|
1778
|
+
ok = item.AddVersion(name, vtype)
|
|
1779
|
+
return {"success": bool(ok), "versionName": name, "versionType": vtype}
|
|
1780
|
+
|
|
1781
|
+
|
|
1782
|
+
def action_load_color_version(body):
|
|
1783
|
+
item, err = _clip_at(body)
|
|
1784
|
+
if err:
|
|
1785
|
+
return err
|
|
1786
|
+
name = body.get("versionName", "")
|
|
1787
|
+
vtype = int(body.get("versionType", 0))
|
|
1788
|
+
if not name:
|
|
1789
|
+
return {"error": "versionName is required"}
|
|
1790
|
+
ok = item.LoadVersionByName(name, vtype)
|
|
1791
|
+
return {"success": bool(ok), "versionName": name}
|
|
1792
|
+
|
|
1793
|
+
|
|
1794
|
+
def action_delete_color_version(body):
|
|
1795
|
+
item, err = _clip_at(body)
|
|
1796
|
+
if err:
|
|
1797
|
+
return err
|
|
1798
|
+
name = body.get("versionName", "")
|
|
1799
|
+
vtype = int(body.get("versionType", 0))
|
|
1800
|
+
if not name:
|
|
1801
|
+
return {"error": "versionName is required"}
|
|
1802
|
+
ok = item.DeleteVersionByName(name, vtype)
|
|
1803
|
+
return {"success": bool(ok), "versionName": name}
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
def action_rename_color_version(body):
|
|
1807
|
+
item, err = _clip_at(body)
|
|
1808
|
+
if err:
|
|
1809
|
+
return err
|
|
1810
|
+
old_name = body.get("oldName", "")
|
|
1811
|
+
new_name = body.get("newName", "")
|
|
1812
|
+
vtype = int(body.get("versionType", 0))
|
|
1813
|
+
if not old_name or not new_name:
|
|
1814
|
+
return {"error": "oldName and newName are required"}
|
|
1815
|
+
ok = item.RenameVersionByName(old_name, new_name, vtype)
|
|
1816
|
+
return {"success": bool(ok)}
|
|
1817
|
+
|
|
1818
|
+
|
|
1819
|
+
# -- Color Groups -----------------------------------------------------------
|
|
1820
|
+
|
|
1821
|
+
def gather_color_groups(qs):
|
|
1822
|
+
_, proj, err = _project()
|
|
1823
|
+
if err:
|
|
1824
|
+
return err
|
|
1825
|
+
groups = safe(lambda: proj.GetColorGroupsList()) or []
|
|
1826
|
+
result = []
|
|
1827
|
+
for i, g in enumerate(groups):
|
|
1828
|
+
name = safe(lambda g=g: g.GetName())
|
|
1829
|
+
result.append({"index": i + 1, "name": name})
|
|
1830
|
+
return {"colorGroups": result}
|
|
1831
|
+
|
|
1832
|
+
|
|
1833
|
+
def action_add_color_group(body):
|
|
1834
|
+
_, proj, err = _project()
|
|
1835
|
+
if err:
|
|
1836
|
+
return err
|
|
1837
|
+
name = body.get("groupName", "")
|
|
1838
|
+
if not name:
|
|
1839
|
+
return {"error": "groupName is required"}
|
|
1840
|
+
group = proj.AddColorGroup(name)
|
|
1841
|
+
if not group:
|
|
1842
|
+
return {"error": "Failed to create color group '%s'" % name}
|
|
1843
|
+
return {"success": True, "groupName": name}
|
|
1844
|
+
|
|
1845
|
+
|
|
1846
|
+
def action_delete_color_group(body):
|
|
1847
|
+
_, proj, err = _project()
|
|
1848
|
+
if err:
|
|
1849
|
+
return err
|
|
1850
|
+
name = body.get("groupName", "")
|
|
1851
|
+
if not name:
|
|
1852
|
+
return {"error": "groupName is required"}
|
|
1853
|
+
groups = safe(lambda: proj.GetColorGroupsList()) or []
|
|
1854
|
+
target = None
|
|
1855
|
+
for g in groups:
|
|
1856
|
+
if safe(lambda g=g: g.GetName()) == name:
|
|
1857
|
+
target = g
|
|
1858
|
+
break
|
|
1859
|
+
if not target:
|
|
1860
|
+
return {"error": "Color group '%s' not found" % name}
|
|
1861
|
+
ok = proj.DeleteColorGroup(target)
|
|
1862
|
+
return {"success": bool(ok)}
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
def action_assign_to_color_group(body):
|
|
1866
|
+
_, proj, err = _project()
|
|
1867
|
+
if err:
|
|
1868
|
+
return err
|
|
1869
|
+
item, ierr = _clip_at(body)
|
|
1870
|
+
if ierr:
|
|
1871
|
+
return ierr
|
|
1872
|
+
group_name = body.get("groupName", "")
|
|
1873
|
+
if not group_name:
|
|
1874
|
+
return {"error": "groupName is required"}
|
|
1875
|
+
groups = safe(lambda: proj.GetColorGroupsList()) or []
|
|
1876
|
+
target = None
|
|
1877
|
+
for g in groups:
|
|
1878
|
+
if safe(lambda g=g: g.GetName()) == group_name:
|
|
1879
|
+
target = g
|
|
1880
|
+
break
|
|
1881
|
+
if not target:
|
|
1882
|
+
return {"error": "Color group '%s' not found" % group_name}
|
|
1883
|
+
ok = item.AssignToColorGroup(target)
|
|
1884
|
+
return {"success": bool(ok), "groupName": group_name}
|
|
1885
|
+
|
|
1886
|
+
|
|
1887
|
+
def action_remove_from_color_group(body):
|
|
1888
|
+
item, err = _clip_at(body)
|
|
1889
|
+
if err:
|
|
1890
|
+
return err
|
|
1891
|
+
ok = item.RemoveFromColorGroup()
|
|
1892
|
+
return {"success": bool(ok)}
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
# -- Fusion Composition Management ------------------------------------------
|
|
1896
|
+
|
|
1897
|
+
def gather_fusion_comps(qs):
|
|
1898
|
+
item, err = _clip_at({
|
|
1899
|
+
"trackType": qs.get("track_type", ["video"])[0],
|
|
1900
|
+
"trackIndex": int(qs.get("track_index", ["1"])[0]),
|
|
1901
|
+
"clipIndex": int(qs.get("clip_index", ["0"])[0]),
|
|
1902
|
+
})
|
|
1903
|
+
if err:
|
|
1904
|
+
return err
|
|
1905
|
+
count = safe(lambda: item.GetFusionCompCount()) or 0
|
|
1906
|
+
names = safe(lambda: item.GetFusionCompNameList()) or []
|
|
1907
|
+
return {"clipName": safe(lambda: item.GetName()), "fusionCompCount": count, "fusionCompNames": names}
|
|
1908
|
+
|
|
1909
|
+
|
|
1910
|
+
def action_add_fusion_comp(body):
|
|
1911
|
+
item, err = _clip_at(body)
|
|
1912
|
+
if err:
|
|
1913
|
+
return err
|
|
1914
|
+
comp = item.AddFusionComp()
|
|
1915
|
+
if not comp:
|
|
1916
|
+
return {"error": "Failed to add Fusion composition"}
|
|
1917
|
+
return {"success": True}
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
def action_import_fusion_comp(body):
|
|
1921
|
+
item, err = _clip_at(body)
|
|
1922
|
+
if err:
|
|
1923
|
+
return err
|
|
1924
|
+
path = body.get("path", "")
|
|
1925
|
+
if not path:
|
|
1926
|
+
return {"error": "path is required (.comp file)"}
|
|
1927
|
+
comp = item.ImportFusionComp(path)
|
|
1928
|
+
if not comp:
|
|
1929
|
+
return {"error": "Failed to import Fusion composition from '%s'" % path}
|
|
1930
|
+
return {"success": True, "path": path}
|
|
1931
|
+
|
|
1932
|
+
|
|
1933
|
+
def action_export_fusion_comp(body):
|
|
1934
|
+
item, err = _clip_at(body)
|
|
1935
|
+
if err:
|
|
1936
|
+
return err
|
|
1937
|
+
path = body.get("path", "")
|
|
1938
|
+
comp_index = int(body.get("compIndex", 1))
|
|
1939
|
+
if not path:
|
|
1940
|
+
return {"error": "path is required"}
|
|
1941
|
+
ok = item.ExportFusionComp(path, comp_index)
|
|
1942
|
+
return {"success": bool(ok), "path": path, "compIndex": comp_index}
|
|
1943
|
+
|
|
1944
|
+
|
|
1945
|
+
def action_delete_fusion_comp(body):
|
|
1946
|
+
item, err = _clip_at(body)
|
|
1947
|
+
if err:
|
|
1948
|
+
return err
|
|
1949
|
+
comp_name = body.get("compName", "")
|
|
1950
|
+
if not comp_name:
|
|
1951
|
+
return {"error": "compName is required"}
|
|
1952
|
+
ok = item.DeleteFusionCompByName(comp_name)
|
|
1953
|
+
return {"success": bool(ok), "compName": comp_name}
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
def action_load_fusion_comp(body):
|
|
1957
|
+
item, err = _clip_at(body)
|
|
1958
|
+
if err:
|
|
1959
|
+
return err
|
|
1960
|
+
comp_name = body.get("compName", "")
|
|
1961
|
+
if not comp_name:
|
|
1962
|
+
return {"error": "compName is required"}
|
|
1963
|
+
comp = item.LoadFusionCompByName(comp_name)
|
|
1964
|
+
if not comp:
|
|
1965
|
+
return {"error": "Failed to load Fusion composition '%s'" % comp_name}
|
|
1966
|
+
return {"success": True, "compName": comp_name}
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
def action_rename_fusion_comp(body):
|
|
1970
|
+
item, err = _clip_at(body)
|
|
1971
|
+
if err:
|
|
1972
|
+
return err
|
|
1973
|
+
old_name = body.get("oldName", "")
|
|
1974
|
+
new_name = body.get("newName", "")
|
|
1975
|
+
if not old_name or not new_name:
|
|
1976
|
+
return {"error": "oldName and newName are required"}
|
|
1977
|
+
ok = item.RenameFusionCompByName(old_name, new_name)
|
|
1978
|
+
return {"success": bool(ok)}
|
|
1979
|
+
|
|
1980
|
+
|
|
1981
|
+
# -- Smart Features (Studio) ------------------------------------------------
|
|
1982
|
+
|
|
1983
|
+
def action_create_magic_mask(body):
|
|
1984
|
+
item, err = _clip_at(body)
|
|
1985
|
+
if err:
|
|
1986
|
+
return err
|
|
1987
|
+
mode = body.get("mode", "F")
|
|
1988
|
+
ok = item.CreateMagicMask(mode)
|
|
1989
|
+
return {"success": bool(ok), "mode": mode}
|
|
1990
|
+
|
|
1991
|
+
|
|
1992
|
+
def action_regenerate_magic_mask(body):
|
|
1993
|
+
item, err = _clip_at(body)
|
|
1994
|
+
if err:
|
|
1995
|
+
return err
|
|
1996
|
+
ok = item.RegenerateMagicMask()
|
|
1997
|
+
return {"success": bool(ok)}
|
|
1998
|
+
|
|
1999
|
+
|
|
2000
|
+
def action_stabilize(body):
|
|
2001
|
+
item, err = _clip_at(body)
|
|
2002
|
+
if err:
|
|
2003
|
+
return err
|
|
2004
|
+
ok = item.Stabilize()
|
|
2005
|
+
return {"success": bool(ok)}
|
|
2006
|
+
|
|
2007
|
+
|
|
2008
|
+
def action_smart_reframe(body):
|
|
2009
|
+
item, err = _clip_at(body)
|
|
2010
|
+
if err:
|
|
2011
|
+
return err
|
|
2012
|
+
ok = item.SmartReframe()
|
|
2013
|
+
return {"success": bool(ok)}
|
|
2014
|
+
|
|
2015
|
+
|
|
2016
|
+
# -- Audio / Fairlight -------------------------------------------------------
|
|
2017
|
+
|
|
2018
|
+
def gather_fairlight_presets(qs):
|
|
2019
|
+
r, err = _resolve()
|
|
2020
|
+
if err:
|
|
2021
|
+
return err
|
|
2022
|
+
presets = safe(lambda: r.GetFairlightPresets()) or []
|
|
2023
|
+
return {"presets": presets}
|
|
2024
|
+
|
|
2025
|
+
|
|
2026
|
+
def action_apply_fairlight_preset(body):
|
|
2027
|
+
_, proj, err = _project()
|
|
2028
|
+
if err:
|
|
2029
|
+
return err
|
|
2030
|
+
name = body.get("presetName", "")
|
|
2031
|
+
if not name:
|
|
2032
|
+
return {"error": "presetName is required"}
|
|
2033
|
+
ok = proj.ApplyFairlightPresetToCurrentTimeline(name)
|
|
2034
|
+
return {"success": bool(ok), "presetName": name}
|
|
2035
|
+
|
|
2036
|
+
|
|
2037
|
+
def action_insert_audio_at_playhead(body):
|
|
2038
|
+
_, proj, err = _project()
|
|
2039
|
+
if err:
|
|
2040
|
+
return err
|
|
2041
|
+
media_path = body.get("mediaPath", "")
|
|
2042
|
+
start_offset = int(body.get("startOffsetInSamples", 0))
|
|
2043
|
+
duration = int(body.get("durationInSamples", 0))
|
|
2044
|
+
if not media_path:
|
|
2045
|
+
return {"error": "mediaPath is required"}
|
|
2046
|
+
ok = proj.InsertAudioToCurrentTrackAtPlayhead(media_path, start_offset, duration)
|
|
2047
|
+
return {"success": bool(ok)}
|
|
2048
|
+
|
|
2049
|
+
|
|
2050
|
+
def gather_voice_isolation_state(qs):
|
|
2051
|
+
scope = qs.get("scope", ["clip"])[0]
|
|
2052
|
+
if scope == "track":
|
|
2053
|
+
_, _, tl, err = _timeline()
|
|
2054
|
+
if err:
|
|
2055
|
+
return err
|
|
2056
|
+
ti = int(qs.get("track_index", ["1"])[0])
|
|
2057
|
+
state = safe(lambda: tl.GetVoiceIsolationState(ti))
|
|
2058
|
+
return {"scope": "track", "trackIndex": ti, "state": state or {}}
|
|
2059
|
+
else:
|
|
2060
|
+
item, err = _clip_at({
|
|
2061
|
+
"trackType": qs.get("track_type", ["video"])[0],
|
|
2062
|
+
"trackIndex": int(qs.get("track_index", ["1"])[0]),
|
|
2063
|
+
"clipIndex": int(qs.get("clip_index", ["0"])[0]),
|
|
2064
|
+
})
|
|
2065
|
+
if err:
|
|
2066
|
+
return err
|
|
2067
|
+
state = safe(lambda: item.GetVoiceIsolationState())
|
|
2068
|
+
return {"scope": "clip", "clipName": safe(lambda: item.GetName()), "state": state or {}}
|
|
2069
|
+
|
|
2070
|
+
|
|
2071
|
+
def action_set_voice_isolation_state(body):
|
|
2072
|
+
scope = body.get("scope", "clip")
|
|
2073
|
+
state = body.get("state", {})
|
|
2074
|
+
if not state:
|
|
2075
|
+
return {"error": "state dict required: {isEnabled: bool, amount: int (0-100)}"}
|
|
2076
|
+
if scope == "track":
|
|
2077
|
+
_, _, tl, err = _timeline()
|
|
2078
|
+
if err:
|
|
2079
|
+
return err
|
|
2080
|
+
ti = int(body.get("trackIndex", 1))
|
|
2081
|
+
ok = tl.SetVoiceIsolationState(ti, state)
|
|
2082
|
+
return {"success": bool(ok), "scope": "track", "trackIndex": ti}
|
|
2083
|
+
else:
|
|
2084
|
+
item, err = _clip_at(body)
|
|
2085
|
+
if err:
|
|
2086
|
+
return err
|
|
2087
|
+
ok = item.SetVoiceIsolationState(state)
|
|
2088
|
+
return {"success": bool(ok), "scope": "clip"}
|
|
2089
|
+
|
|
2090
|
+
|
|
2091
|
+
# -- Take Selector -----------------------------------------------------------
|
|
2092
|
+
|
|
2093
|
+
def gather_takes(qs):
|
|
2094
|
+
item, err = _clip_at({
|
|
2095
|
+
"trackType": qs.get("track_type", ["video"])[0],
|
|
2096
|
+
"trackIndex": int(qs.get("track_index", ["1"])[0]),
|
|
2097
|
+
"clipIndex": int(qs.get("clip_index", ["0"])[0]),
|
|
2098
|
+
})
|
|
2099
|
+
if err:
|
|
2100
|
+
return err
|
|
2101
|
+
count = safe(lambda: item.GetTakesCount()) or 0
|
|
2102
|
+
selected = safe(lambda: item.GetSelectedTakeIndex()) or 0
|
|
2103
|
+
takes = []
|
|
2104
|
+
for i in range(1, count + 1):
|
|
2105
|
+
info = safe(lambda i=i: item.GetTakeByIndex(i)) or {}
|
|
2106
|
+
takes.append({"index": i, "startFrame": info.get("startFrame"), "endFrame": info.get("endFrame")})
|
|
2107
|
+
return {"clipName": safe(lambda: item.GetName()), "takesCount": count, "selectedTakeIndex": selected, "takes": takes}
|
|
2108
|
+
|
|
2109
|
+
|
|
2110
|
+
def action_add_take(body):
|
|
2111
|
+
_, proj, err = _project()
|
|
2112
|
+
if err:
|
|
2113
|
+
return err
|
|
2114
|
+
pool = proj.GetMediaPool()
|
|
2115
|
+
if not pool:
|
|
2116
|
+
return {"error": "No media pool"}
|
|
2117
|
+
item, ierr = _clip_at(body)
|
|
2118
|
+
if ierr:
|
|
2119
|
+
return ierr
|
|
2120
|
+
mp_clip_name = body.get("mediaPoolClipName", "")
|
|
2121
|
+
if not mp_clip_name:
|
|
2122
|
+
return {"error": "mediaPoolClipName is required"}
|
|
2123
|
+
mp_item = _find_pool_item(pool, mp_clip_name)
|
|
2124
|
+
if not mp_item:
|
|
2125
|
+
return {"error": "Clip '%s' not found in media pool" % mp_clip_name}
|
|
2126
|
+
start = body.get("startFrame")
|
|
2127
|
+
end = body.get("endFrame")
|
|
2128
|
+
if start is not None and end is not None:
|
|
2129
|
+
ok = item.AddTake(mp_item, int(start), int(end))
|
|
2130
|
+
else:
|
|
2131
|
+
ok = item.AddTake(mp_item)
|
|
2132
|
+
return {"success": bool(ok)}
|
|
2133
|
+
|
|
2134
|
+
|
|
2135
|
+
def action_select_take(body):
|
|
2136
|
+
item, err = _clip_at(body)
|
|
2137
|
+
if err:
|
|
2138
|
+
return err
|
|
2139
|
+
idx = int(body.get("takeIndex", 1))
|
|
2140
|
+
ok = item.SelectTakeByIndex(idx)
|
|
2141
|
+
return {"success": bool(ok), "takeIndex": idx}
|
|
2142
|
+
|
|
2143
|
+
|
|
2144
|
+
def action_delete_take(body):
|
|
2145
|
+
item, err = _clip_at(body)
|
|
2146
|
+
if err:
|
|
2147
|
+
return err
|
|
2148
|
+
idx = int(body.get("takeIndex", 1))
|
|
2149
|
+
ok = item.DeleteTakeByIndex(idx)
|
|
2150
|
+
return {"success": bool(ok), "takeIndex": idx}
|
|
2151
|
+
|
|
2152
|
+
|
|
2153
|
+
def action_finalize_take(body):
|
|
2154
|
+
item, err = _clip_at(body)
|
|
2155
|
+
if err:
|
|
2156
|
+
return err
|
|
2157
|
+
ok = item.FinalizeTake()
|
|
2158
|
+
return {"success": bool(ok)}
|
|
2159
|
+
|
|
2160
|
+
|
|
2161
|
+
# -- Proxy / Cache / Misc Clip Ops ------------------------------------------
|
|
2162
|
+
|
|
2163
|
+
def action_link_proxy_media(body):
|
|
2164
|
+
_, proj, err = _project()
|
|
2165
|
+
if err:
|
|
2166
|
+
return err
|
|
2167
|
+
pool = proj.GetMediaPool()
|
|
2168
|
+
if not pool:
|
|
2169
|
+
return {"error": "No media pool"}
|
|
2170
|
+
clip_name = body.get("clipName", "")
|
|
2171
|
+
proxy_path = body.get("proxyMediaFilePath", "")
|
|
2172
|
+
if not clip_name or not proxy_path:
|
|
2173
|
+
return {"error": "clipName and proxyMediaFilePath are required"}
|
|
2174
|
+
item = _find_pool_item(pool, clip_name)
|
|
2175
|
+
if not item:
|
|
2176
|
+
return {"error": "Clip '%s' not found" % clip_name}
|
|
2177
|
+
ok = item.LinkProxyMedia(proxy_path)
|
|
2178
|
+
return {"success": bool(ok)}
|
|
2179
|
+
|
|
2180
|
+
|
|
2181
|
+
def action_unlink_proxy_media(body):
|
|
2182
|
+
_, proj, err = _project()
|
|
2183
|
+
if err:
|
|
2184
|
+
return err
|
|
2185
|
+
pool = proj.GetMediaPool()
|
|
2186
|
+
if not pool:
|
|
2187
|
+
return {"error": "No media pool"}
|
|
2188
|
+
clip_name = body.get("clipName", "")
|
|
2189
|
+
if not clip_name:
|
|
2190
|
+
return {"error": "clipName is required"}
|
|
2191
|
+
item = _find_pool_item(pool, clip_name)
|
|
2192
|
+
if not item:
|
|
2193
|
+
return {"error": "Clip '%s' not found" % clip_name}
|
|
2194
|
+
ok = item.UnlinkProxyMedia()
|
|
2195
|
+
return {"success": bool(ok)}
|
|
2196
|
+
|
|
2197
|
+
|
|
2198
|
+
def action_replace_clip(body):
|
|
2199
|
+
_, proj, err = _project()
|
|
2200
|
+
if err:
|
|
2201
|
+
return err
|
|
2202
|
+
pool = proj.GetMediaPool()
|
|
2203
|
+
if not pool:
|
|
2204
|
+
return {"error": "No media pool"}
|
|
2205
|
+
clip_name = body.get("clipName", "")
|
|
2206
|
+
file_path = body.get("filePath", "")
|
|
2207
|
+
if not clip_name or not file_path:
|
|
2208
|
+
return {"error": "clipName and filePath are required"}
|
|
2209
|
+
item = _find_pool_item(pool, clip_name)
|
|
2210
|
+
if not item:
|
|
2211
|
+
return {"error": "Clip '%s' not found" % clip_name}
|
|
2212
|
+
ok = item.ReplaceClip(file_path)
|
|
2213
|
+
return {"success": bool(ok)}
|
|
2214
|
+
|
|
2215
|
+
|
|
2216
|
+
def action_set_clip_cache(body):
|
|
2217
|
+
item, err = _clip_at(body)
|
|
2218
|
+
if err:
|
|
2219
|
+
return err
|
|
2220
|
+
cache_type = body.get("cacheType", "color")
|
|
2221
|
+
cache_value = int(body.get("cacheValue", 1))
|
|
2222
|
+
if cache_type == "fusion":
|
|
2223
|
+
ok = item.SetFusionOutputCache(cache_value)
|
|
2224
|
+
else:
|
|
2225
|
+
ok = item.SetColorOutputCache(cache_value)
|
|
2226
|
+
return {"success": bool(ok), "cacheType": cache_type, "cacheValue": cache_value}
|
|
2227
|
+
|
|
2228
|
+
|
|
2229
|
+
def action_update_sidecar(body):
|
|
2230
|
+
item, err = _clip_at(body)
|
|
2231
|
+
if err:
|
|
2232
|
+
return err
|
|
2233
|
+
ok = item.UpdateSidecar()
|
|
2234
|
+
return {"success": bool(ok)}
|
|
2235
|
+
|
|
2236
|
+
|
|
2237
|
+
def gather_linked_items(qs):
|
|
2238
|
+
item, err = _clip_at({
|
|
2239
|
+
"trackType": qs.get("track_type", ["video"])[0],
|
|
2240
|
+
"trackIndex": int(qs.get("track_index", ["1"])[0]),
|
|
2241
|
+
"clipIndex": int(qs.get("clip_index", ["0"])[0]),
|
|
2242
|
+
})
|
|
2243
|
+
if err:
|
|
2244
|
+
return err
|
|
2245
|
+
linked = safe(lambda: item.GetLinkedItems()) or []
|
|
2246
|
+
result = []
|
|
2247
|
+
for li in linked:
|
|
2248
|
+
ti = safe(lambda li=li: li.GetTrackTypeAndIndex()) or []
|
|
2249
|
+
result.append({
|
|
2250
|
+
"name": safe(lambda li=li: li.GetName()),
|
|
2251
|
+
"trackType": ti[0] if len(ti) > 0 else None,
|
|
2252
|
+
"trackIndex": ti[1] if len(ti) > 1 else None,
|
|
2253
|
+
})
|
|
2254
|
+
return {"clipName": safe(lambda: item.GetName()), "linkedItems": result}
|
|
2255
|
+
|
|
2256
|
+
|
|
2257
|
+
def action_set_timeline_mark_in_out(body):
|
|
2258
|
+
_, _, tl, err = _timeline()
|
|
2259
|
+
if err:
|
|
2260
|
+
return err
|
|
2261
|
+
mark_in = body.get("markIn")
|
|
2262
|
+
mark_out = body.get("markOut")
|
|
2263
|
+
mark_type = body.get("type", "all")
|
|
2264
|
+
if mark_in is not None and mark_out is not None:
|
|
2265
|
+
ok = tl.SetMarkInOut(int(mark_in), int(mark_out), mark_type)
|
|
2266
|
+
return {"success": bool(ok)}
|
|
2267
|
+
return {"error": "markIn and markOut are required"}
|
|
2268
|
+
|
|
2269
|
+
|
|
2270
|
+
def action_clear_timeline_mark_in_out(body):
|
|
2271
|
+
_, _, tl, err = _timeline()
|
|
2272
|
+
if err:
|
|
2273
|
+
return err
|
|
2274
|
+
mark_type = body.get("type", "all")
|
|
2275
|
+
ok = tl.ClearMarkInOut(mark_type)
|
|
2276
|
+
return {"success": bool(ok)}
|
|
2277
|
+
|
|
2278
|
+
|
|
2279
|
+
# -- Project Manager ---------------------------------------------------------
|
|
2280
|
+
|
|
2281
|
+
def gather_project_list(qs):
|
|
2282
|
+
r, err = _resolve()
|
|
2283
|
+
if err:
|
|
2284
|
+
return err
|
|
2285
|
+
pm = r.GetProjectManager()
|
|
2286
|
+
if not pm:
|
|
2287
|
+
return {"error": "No project manager"}
|
|
2288
|
+
projects = safe(lambda: pm.GetProjectListInCurrentFolder()) or []
|
|
2289
|
+
folders = safe(lambda: pm.GetFolderListInCurrentFolder()) or []
|
|
2290
|
+
current_folder = safe(lambda: pm.GetCurrentFolder()) or ""
|
|
2291
|
+
current_db = safe(lambda: pm.GetCurrentDatabase()) or {}
|
|
2292
|
+
return {
|
|
2293
|
+
"currentFolder": current_folder,
|
|
2294
|
+
"currentDatabase": current_db,
|
|
2295
|
+
"projects": projects,
|
|
2296
|
+
"folders": folders,
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
|
|
2300
|
+
def gather_database_list(qs):
|
|
2301
|
+
r, err = _resolve()
|
|
2302
|
+
if err:
|
|
2303
|
+
return err
|
|
2304
|
+
pm = r.GetProjectManager()
|
|
2305
|
+
if not pm:
|
|
2306
|
+
return {"error": "No project manager"}
|
|
2307
|
+
dbs = safe(lambda: pm.GetDatabaseList()) or []
|
|
2308
|
+
current = safe(lambda: pm.GetCurrentDatabase()) or {}
|
|
2309
|
+
return {"currentDatabase": current, "databases": dbs}
|
|
2310
|
+
|
|
2311
|
+
|
|
2312
|
+
def action_load_project(body):
|
|
2313
|
+
r, err = _resolve()
|
|
2314
|
+
if err:
|
|
2315
|
+
return err
|
|
2316
|
+
pm = r.GetProjectManager()
|
|
2317
|
+
if not pm:
|
|
2318
|
+
return {"error": "No project manager"}
|
|
2319
|
+
name = body.get("projectName", "")
|
|
2320
|
+
if not name:
|
|
2321
|
+
return {"error": "projectName is required"}
|
|
2322
|
+
proj = pm.LoadProject(name)
|
|
2323
|
+
if not proj:
|
|
2324
|
+
return {"error": "Failed to load project '%s'" % name}
|
|
2325
|
+
return {"success": True, "projectName": safe(lambda: proj.GetName())}
|
|
2326
|
+
|
|
2327
|
+
|
|
2328
|
+
def action_create_project(body):
|
|
2329
|
+
r, err = _resolve()
|
|
2330
|
+
if err:
|
|
2331
|
+
return err
|
|
2332
|
+
pm = r.GetProjectManager()
|
|
2333
|
+
if not pm:
|
|
2334
|
+
return {"error": "No project manager"}
|
|
2335
|
+
name = body.get("projectName", "")
|
|
2336
|
+
if not name:
|
|
2337
|
+
return {"error": "projectName is required"}
|
|
2338
|
+
proj = pm.CreateProject(name)
|
|
2339
|
+
if not proj:
|
|
2340
|
+
return {"error": "Failed to create project '%s' (name may already exist)" % name}
|
|
2341
|
+
return {"success": True, "projectName": safe(lambda: proj.GetName())}
|
|
2342
|
+
|
|
2343
|
+
|
|
2344
|
+
def action_delete_project(body):
|
|
2345
|
+
r, err = _resolve()
|
|
2346
|
+
if err:
|
|
2347
|
+
return err
|
|
2348
|
+
pm = r.GetProjectManager()
|
|
2349
|
+
name = body.get("projectName", "")
|
|
2350
|
+
if not name:
|
|
2351
|
+
return {"error": "projectName is required"}
|
|
2352
|
+
ok = pm.DeleteProject(name)
|
|
2353
|
+
return {"success": bool(ok)}
|
|
2354
|
+
|
|
2355
|
+
|
|
2356
|
+
def action_archive_project(body):
|
|
2357
|
+
r, err = _resolve()
|
|
2358
|
+
if err:
|
|
2359
|
+
return err
|
|
2360
|
+
pm = r.GetProjectManager()
|
|
2361
|
+
name = body.get("projectName", "")
|
|
2362
|
+
file_path = body.get("filePath", "")
|
|
2363
|
+
if not name or not file_path:
|
|
2364
|
+
return {"error": "projectName and filePath are required"}
|
|
2365
|
+
src_media = body.get("archiveSrcMedia", True)
|
|
2366
|
+
render_cache = body.get("archiveRenderCache", True)
|
|
2367
|
+
proxy_media = body.get("archiveProxyMedia", False)
|
|
2368
|
+
ok = pm.ArchiveProject(name, file_path, src_media, render_cache, proxy_media)
|
|
2369
|
+
return {"success": bool(ok)}
|
|
2370
|
+
|
|
2371
|
+
|
|
2372
|
+
def action_export_project(body):
|
|
2373
|
+
r, err = _resolve()
|
|
2374
|
+
if err:
|
|
2375
|
+
return err
|
|
2376
|
+
pm = r.GetProjectManager()
|
|
2377
|
+
name = body.get("projectName", "")
|
|
2378
|
+
file_path = body.get("filePath", "")
|
|
2379
|
+
if not name or not file_path:
|
|
2380
|
+
return {"error": "projectName and filePath are required"}
|
|
2381
|
+
with_stills = body.get("withStillsAndLUTs", True)
|
|
2382
|
+
ok = pm.ExportProject(name, file_path, with_stills)
|
|
2383
|
+
return {"success": bool(ok)}
|
|
2384
|
+
|
|
2385
|
+
|
|
2386
|
+
def action_import_project(body):
|
|
2387
|
+
r, err = _resolve()
|
|
2388
|
+
if err:
|
|
2389
|
+
return err
|
|
2390
|
+
pm = r.GetProjectManager()
|
|
2391
|
+
file_path = body.get("filePath", "")
|
|
2392
|
+
if not file_path:
|
|
2393
|
+
return {"error": "filePath is required"}
|
|
2394
|
+
project_name = body.get("projectName")
|
|
2395
|
+
ok = pm.ImportProject(file_path, project_name) if project_name else pm.ImportProject(file_path)
|
|
2396
|
+
return {"success": bool(ok)}
|
|
2397
|
+
|
|
2398
|
+
|
|
2399
|
+
def action_navigate_project_folder(body):
|
|
2400
|
+
r, err = _resolve()
|
|
2401
|
+
if err:
|
|
2402
|
+
return err
|
|
2403
|
+
pm = r.GetProjectManager()
|
|
2404
|
+
action = body.get("action", "")
|
|
2405
|
+
folder_name = body.get("folderName", "")
|
|
2406
|
+
if action == "root":
|
|
2407
|
+
ok = pm.GotoRootFolder()
|
|
2408
|
+
elif action == "parent":
|
|
2409
|
+
ok = pm.GotoParentFolder()
|
|
2410
|
+
elif action == "open" and folder_name:
|
|
2411
|
+
ok = pm.OpenFolder(folder_name)
|
|
2412
|
+
elif action == "create" and folder_name:
|
|
2413
|
+
ok = pm.CreateFolder(folder_name)
|
|
2414
|
+
elif action == "delete" and folder_name:
|
|
2415
|
+
ok = pm.DeleteFolder(folder_name)
|
|
2416
|
+
else:
|
|
2417
|
+
return {"error": "action required: 'root', 'parent', 'open', 'create', or 'delete'"}
|
|
2418
|
+
return {"success": bool(ok), "action": action}
|
|
2419
|
+
|
|
2420
|
+
|
|
2421
|
+
def action_set_database(body):
|
|
2422
|
+
r, err = _resolve()
|
|
2423
|
+
if err:
|
|
2424
|
+
return err
|
|
2425
|
+
pm = r.GetProjectManager()
|
|
2426
|
+
db_info = body.get("dbInfo", {})
|
|
2427
|
+
if not db_info:
|
|
2428
|
+
return {"error": "dbInfo dict required with keys DbType, DbName, optional IpAddress"}
|
|
2429
|
+
ok = pm.SetCurrentDatabase(db_info)
|
|
2430
|
+
return {"success": bool(ok)}
|
|
2431
|
+
|
|
2432
|
+
|
|
2433
|
+
# -- Resolve-level / Presets / MediaStorage ----------------------------------
|
|
2434
|
+
|
|
2435
|
+
def action_layout_preset(body):
|
|
2436
|
+
r, err = _resolve()
|
|
2437
|
+
if err:
|
|
2438
|
+
return err
|
|
2439
|
+
action = body.get("action", "")
|
|
2440
|
+
name = body.get("presetName", "")
|
|
2441
|
+
path = body.get("presetFilePath", "")
|
|
2442
|
+
if action == "load" and name:
|
|
2443
|
+
return {"success": bool(r.LoadLayoutPreset(name))}
|
|
2444
|
+
elif action == "save" and name:
|
|
2445
|
+
return {"success": bool(r.SaveLayoutPreset(name))}
|
|
2446
|
+
elif action == "update" and name:
|
|
2447
|
+
return {"success": bool(r.UpdateLayoutPreset(name))}
|
|
2448
|
+
elif action == "delete" and name:
|
|
2449
|
+
return {"success": bool(r.DeleteLayoutPreset(name))}
|
|
2450
|
+
elif action == "export" and name and path:
|
|
2451
|
+
return {"success": bool(r.ExportLayoutPreset(name, path))}
|
|
2452
|
+
elif action == "import" and path:
|
|
2453
|
+
return {"success": bool(r.ImportLayoutPreset(path, name))}
|
|
2454
|
+
return {"error": "action required: 'load', 'save', 'update', 'delete', 'export', 'import'"}
|
|
2455
|
+
|
|
2456
|
+
|
|
2457
|
+
def action_render_preset(body):
|
|
2458
|
+
r, err = _resolve()
|
|
2459
|
+
if err:
|
|
2460
|
+
return err
|
|
2461
|
+
_, proj, perr = _project()
|
|
2462
|
+
if perr:
|
|
2463
|
+
return perr
|
|
2464
|
+
action = body.get("action", "")
|
|
2465
|
+
name = body.get("presetName", "")
|
|
2466
|
+
path = body.get("presetPath", "")
|
|
2467
|
+
if action == "import" and path:
|
|
2468
|
+
return {"success": bool(r.ImportRenderPreset(path))}
|
|
2469
|
+
elif action == "export" and name and path:
|
|
2470
|
+
return {"success": bool(r.ExportRenderPreset(name, path))}
|
|
2471
|
+
elif action == "load" and name:
|
|
2472
|
+
return {"success": bool(proj.LoadRenderPreset(name))}
|
|
2473
|
+
elif action == "saveAs" and name:
|
|
2474
|
+
return {"success": bool(proj.SaveAsNewRenderPreset(name))}
|
|
2475
|
+
elif action == "delete" and name:
|
|
2476
|
+
return {"success": bool(proj.DeleteRenderPreset(name))}
|
|
2477
|
+
elif action == "list":
|
|
2478
|
+
return {"presets": safe(lambda: proj.GetRenderPresetList()) or []}
|
|
2479
|
+
return {"error": "action required: 'load', 'saveAs', 'delete', 'list', 'import', 'export'"}
|
|
2480
|
+
|
|
2481
|
+
|
|
2482
|
+
def action_burnin_preset(body):
|
|
2483
|
+
r, err = _resolve()
|
|
2484
|
+
if err:
|
|
2485
|
+
return err
|
|
2486
|
+
action = body.get("action", "")
|
|
2487
|
+
name = body.get("presetName", "")
|
|
2488
|
+
path = body.get("presetPath", "")
|
|
2489
|
+
if action == "import" and path:
|
|
2490
|
+
return {"success": bool(r.ImportBurnInPreset(path))}
|
|
2491
|
+
elif action == "export" and name and path:
|
|
2492
|
+
return {"success": bool(r.ExportBurnInPreset(name, path))}
|
|
2493
|
+
elif action == "load" and name:
|
|
2494
|
+
_, proj, perr = _project()
|
|
2495
|
+
if perr:
|
|
2496
|
+
return perr
|
|
2497
|
+
return {"success": bool(proj.LoadBurnInPreset(name))}
|
|
2498
|
+
return {"error": "action required: 'load', 'import', 'export'"}
|
|
2499
|
+
|
|
2500
|
+
|
|
2501
|
+
def action_set_keyframe_mode(body):
|
|
2502
|
+
r, err = _resolve()
|
|
2503
|
+
if err:
|
|
2504
|
+
return err
|
|
2505
|
+
mode = int(body.get("mode", 0))
|
|
2506
|
+
ok = r.SetKeyframeMode(mode)
|
|
2507
|
+
return {"success": bool(ok), "mode": mode}
|
|
2508
|
+
|
|
2509
|
+
|
|
2510
|
+
def gather_keyframe_mode(qs):
|
|
2511
|
+
r, err = _resolve()
|
|
2512
|
+
if err:
|
|
2513
|
+
return err
|
|
2514
|
+
mode = safe(lambda: r.GetKeyframeMode())
|
|
2515
|
+
labels = {0: "All", 1: "Color", 2: "Sizing"}
|
|
2516
|
+
return {"keyframeMode": mode, "label": labels.get(mode, "Unknown")}
|
|
2517
|
+
|
|
2518
|
+
|
|
2519
|
+
def action_quick_export(body):
|
|
2520
|
+
_, proj, err = _project()
|
|
2521
|
+
if err:
|
|
2522
|
+
return err
|
|
2523
|
+
preset_name = body.get("presetName", "")
|
|
2524
|
+
if not preset_name:
|
|
2525
|
+
presets = safe(lambda: proj.GetQuickExportRenderPresets()) or []
|
|
2526
|
+
return {"error": "presetName is required", "availablePresets": presets}
|
|
2527
|
+
params = body.get("params", {})
|
|
2528
|
+
result = proj.RenderWithQuickExport(preset_name, params)
|
|
2529
|
+
return {"result": result}
|
|
2530
|
+
|
|
2531
|
+
|
|
2532
|
+
def gather_quick_export_presets(qs):
|
|
2533
|
+
_, proj, err = _project()
|
|
2534
|
+
if err:
|
|
2535
|
+
return err
|
|
2536
|
+
presets = safe(lambda: proj.GetQuickExportRenderPresets()) or []
|
|
2537
|
+
return {"presets": presets}
|
|
2538
|
+
|
|
2539
|
+
|
|
2540
|
+
def action_set_render_mode(body):
|
|
2541
|
+
_, proj, err = _project()
|
|
2542
|
+
if err:
|
|
2543
|
+
return err
|
|
2544
|
+
mode = int(body.get("renderMode", 0))
|
|
2545
|
+
ok = proj.SetCurrentRenderMode(mode)
|
|
2546
|
+
return {"success": bool(ok), "renderMode": mode}
|
|
2547
|
+
|
|
2548
|
+
|
|
2549
|
+
def action_get_render_job_status(body):
|
|
2550
|
+
_, proj, err = _project()
|
|
2551
|
+
if err:
|
|
2552
|
+
return err
|
|
2553
|
+
job_id = body.get("jobId", "")
|
|
2554
|
+
if not job_id:
|
|
2555
|
+
return {"error": "jobId is required"}
|
|
2556
|
+
status = safe(lambda: proj.GetRenderJobStatus(job_id))
|
|
2557
|
+
return {"jobId": job_id, "status": status or {}}
|
|
2558
|
+
|
|
2559
|
+
|
|
2560
|
+
def action_refresh_lut_list(body):
|
|
2561
|
+
_, proj, err = _project()
|
|
2562
|
+
if err:
|
|
2563
|
+
return err
|
|
2564
|
+
ok = proj.RefreshLUTList()
|
|
2565
|
+
return {"success": bool(ok)}
|
|
2566
|
+
|
|
2567
|
+
|
|
2568
|
+
def gather_render_resolutions(qs):
|
|
2569
|
+
_, proj, err = _project()
|
|
2570
|
+
if err:
|
|
2571
|
+
return err
|
|
2572
|
+
fmt = qs.get("format", [""])[0]
|
|
2573
|
+
codec = qs.get("codec", [""])[0]
|
|
2574
|
+
if fmt and codec:
|
|
2575
|
+
res = safe(lambda: proj.GetRenderResolutions(fmt, codec)) or []
|
|
2576
|
+
else:
|
|
2577
|
+
res = safe(lambda: proj.GetRenderResolutions()) or []
|
|
2578
|
+
return {"resolutions": res}
|
|
2579
|
+
|
|
2580
|
+
|
|
2581
|
+
def gather_media_storage(qs):
|
|
2582
|
+
r, err = _resolve()
|
|
2583
|
+
if err:
|
|
2584
|
+
return err
|
|
2585
|
+
ms = r.GetMediaStorage()
|
|
2586
|
+
if not ms:
|
|
2587
|
+
return {"error": "No media storage"}
|
|
2588
|
+
volumes = safe(lambda: ms.GetMountedVolumeList()) or []
|
|
2589
|
+
folder_path = qs.get("folder_path", [""])[0]
|
|
2590
|
+
result = {"volumes": volumes}
|
|
2591
|
+
if folder_path:
|
|
2592
|
+
subfolders = safe(lambda: ms.GetSubFolderList(folder_path)) or []
|
|
2593
|
+
files = safe(lambda: ms.GetFileList(folder_path)) or []
|
|
2594
|
+
result["subfolders"] = subfolders
|
|
2595
|
+
result["files"] = files
|
|
2596
|
+
return result
|
|
2597
|
+
|
|
2598
|
+
|
|
2599
|
+
def action_reveal_in_storage(body):
|
|
2600
|
+
r, err = _resolve()
|
|
2601
|
+
if err:
|
|
2602
|
+
return err
|
|
2603
|
+
ms = r.GetMediaStorage()
|
|
2604
|
+
if not ms:
|
|
2605
|
+
return {"error": "No media storage"}
|
|
2606
|
+
path = body.get("path", "")
|
|
2607
|
+
if not path:
|
|
2608
|
+
return {"error": "path is required"}
|
|
2609
|
+
ok = ms.RevealInStorage(path)
|
|
2610
|
+
return {"success": bool(ok)}
|
|
2611
|
+
|
|
2612
|
+
|
|
2613
|
+
# ---------------------------------------------------------------------------
|
|
2614
|
+
# Route tables
|
|
2615
|
+
# ---------------------------------------------------------------------------
|
|
2616
|
+
|
|
2617
|
+
GET_ROUTES = {
|
|
2618
|
+
"/status": lambda qs: gather_status(),
|
|
2619
|
+
"/project": lambda qs: gather_project(),
|
|
2620
|
+
"/page": lambda qs: gather_page(),
|
|
2621
|
+
"/timeline": lambda qs: gather_timeline(),
|
|
2622
|
+
"/timeline/clips": lambda qs: gather_clips(
|
|
2623
|
+
qs.get("track_type", ["video"])[0],
|
|
2624
|
+
int(qs.get("track_index", ["1"])[0])),
|
|
2625
|
+
"/timeline/markers": lambda qs: gather_markers(),
|
|
2626
|
+
"/timeline/current-item": lambda qs: gather_current_video_item(qs),
|
|
2627
|
+
"/timeline/thumbnail": lambda qs: gather_clip_thumbnail(qs),
|
|
2628
|
+
"/render": lambda qs: gather_render(),
|
|
2629
|
+
"/render/resolutions": gather_render_resolutions,
|
|
2630
|
+
"/render/quick-export-presets": gather_quick_export_presets,
|
|
2631
|
+
"/mediapool": lambda qs: gather_media_pool(),
|
|
2632
|
+
"/mediapool/structure": gather_media_pool_structure,
|
|
2633
|
+
"/mediapool/clip/metadata": gather_clip_metadata,
|
|
2634
|
+
"/mediapool/clip/info": gather_clip_info,
|
|
2635
|
+
"/clip/markers": gather_clip_markers,
|
|
2636
|
+
"/clip/flags": gather_clip_flags,
|
|
2637
|
+
"/clip/node-graph": gather_node_graph,
|
|
2638
|
+
"/clip/color-versions": gather_color_versions,
|
|
2639
|
+
"/clip/fusion-comps": gather_fusion_comps,
|
|
2640
|
+
"/clip/takes": gather_takes,
|
|
2641
|
+
"/clip/linked-items": gather_linked_items,
|
|
2642
|
+
"/color/groups": gather_color_groups,
|
|
2643
|
+
"/audio/voice-isolation": gather_voice_isolation_state,
|
|
2644
|
+
"/fairlight/presets": gather_fairlight_presets,
|
|
2645
|
+
"/gallery/albums": gather_gallery_albums,
|
|
2646
|
+
"/gallery/stills": gather_album_stills,
|
|
2647
|
+
"/keyframe-mode": gather_keyframe_mode,
|
|
2648
|
+
"/projects": gather_project_list,
|
|
2649
|
+
"/databases": gather_database_list,
|
|
2650
|
+
"/media-storage": gather_media_storage,
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
def action_shutdown(body):
|
|
2654
|
+
"""Gracefully stop the HTTP server (used for hot-reload)."""
|
|
2655
|
+
import threading
|
|
2656
|
+
threading.Thread(target=lambda: server.shutdown(), daemon=True).start()
|
|
2657
|
+
return {"success": True, "message": "Shutting down"}
|
|
2658
|
+
|
|
2659
|
+
|
|
2660
|
+
POST_ROUTES = {
|
|
2661
|
+
"/bridge/shutdown": action_shutdown,
|
|
2662
|
+
"/page": action_open_page,
|
|
2663
|
+
"/playhead": action_set_timecode,
|
|
2664
|
+
# markers
|
|
2665
|
+
"/marker/add": action_add_marker,
|
|
2666
|
+
"/marker/delete": action_delete_marker,
|
|
2667
|
+
# timeline
|
|
2668
|
+
"/timeline/switch": action_switch_timeline,
|
|
2669
|
+
"/timeline/create": action_create_timeline,
|
|
2670
|
+
"/timeline/rename": action_rename_timeline,
|
|
2671
|
+
"/timeline/duplicate": action_duplicate_timeline,
|
|
2672
|
+
"/timeline/export": action_export_timeline,
|
|
2673
|
+
"/timeline/mark-in-out": action_set_timeline_mark_in_out,
|
|
2674
|
+
"/timeline/clear-mark-in-out": action_clear_timeline_mark_in_out,
|
|
2675
|
+
# timeline clip manipulation
|
|
2676
|
+
"/timeline/clips/delete": action_delete_timeline_clips,
|
|
2677
|
+
"/timeline/clips/link": action_link_timeline_clips,
|
|
2678
|
+
"/timeline/compound-clip": action_create_compound_clip,
|
|
2679
|
+
"/timeline/fusion-clip": action_create_fusion_clip,
|
|
2680
|
+
# tracks
|
|
2681
|
+
"/track/add": action_add_track,
|
|
2682
|
+
"/track/delete": action_delete_track,
|
|
2683
|
+
"/track/enable": action_set_track_enable,
|
|
2684
|
+
"/track/lock": action_set_track_lock,
|
|
2685
|
+
"/track/rename": action_set_track_name,
|
|
2686
|
+
# media
|
|
2687
|
+
"/media/import": action_import_media,
|
|
2688
|
+
"/media/import-storage": action_import_media_from_storage,
|
|
2689
|
+
"/media/append": action_append_to_timeline,
|
|
2690
|
+
"/media/insert": action_insert_to_timeline,
|
|
2691
|
+
# media pool deep access
|
|
2692
|
+
"/mediapool/navigate": action_navigate_media_pool,
|
|
2693
|
+
"/mediapool/folder/create": action_create_media_pool_folder,
|
|
2694
|
+
"/mediapool/clip/metadata": action_set_clip_metadata,
|
|
2695
|
+
"/mediapool/clip/property": action_set_pool_clip_property,
|
|
2696
|
+
"/mediapool/clips/delete": action_delete_media_pool_clips,
|
|
2697
|
+
"/mediapool/clips/move": action_move_media_pool_clips,
|
|
2698
|
+
"/mediapool/clips/relink": action_relink_media_pool_clips,
|
|
2699
|
+
"/mediapool/clips/unlink": action_unlink_media_pool_clips,
|
|
2700
|
+
"/mediapool/audio-sync": action_auto_sync_audio,
|
|
2701
|
+
"/mediapool/timeline/import": action_import_timeline_from_file,
|
|
2702
|
+
"/mediapool/metadata/export": action_export_metadata,
|
|
2703
|
+
# clip operations
|
|
2704
|
+
"/clip/color": action_set_clip_color,
|
|
2705
|
+
"/clip/enabled": action_set_clip_enabled,
|
|
2706
|
+
"/clip/properties": action_set_clip_properties,
|
|
2707
|
+
"/clip/marker/add": action_add_clip_marker,
|
|
2708
|
+
"/clip/marker/delete": action_delete_clip_marker,
|
|
2709
|
+
"/clip/flag/add": action_add_clip_flag,
|
|
2710
|
+
"/clip/flag/clear": action_clear_clip_flags,
|
|
2711
|
+
"/clip/cache": action_set_clip_cache,
|
|
2712
|
+
"/clip/sidecar": action_update_sidecar,
|
|
2713
|
+
# color grading / LUT / CDL
|
|
2714
|
+
"/color/set-lut": action_set_lut,
|
|
2715
|
+
"/color/get-lut": action_get_lut,
|
|
2716
|
+
"/color/set-node-enabled": action_set_node_enabled,
|
|
2717
|
+
"/color/apply-drx": action_apply_grade_from_drx,
|
|
2718
|
+
"/color/reset-grades": action_reset_all_grades,
|
|
2719
|
+
"/color/apply-arri-cdl": action_apply_arri_cdl_lut,
|
|
2720
|
+
"/color/set-cdl": action_set_cdl,
|
|
2721
|
+
"/color/export-lut": action_export_lut,
|
|
2722
|
+
"/color/copy-grades": action_copy_grades,
|
|
2723
|
+
"/color/reset-node-colors": action_reset_node_colors,
|
|
2724
|
+
# color versions
|
|
2725
|
+
"/color/version/add": action_add_color_version,
|
|
2726
|
+
"/color/version/load": action_load_color_version,
|
|
2727
|
+
"/color/version/delete": action_delete_color_version,
|
|
2728
|
+
"/color/version/rename": action_rename_color_version,
|
|
2729
|
+
# color groups
|
|
2730
|
+
"/color/group/add": action_add_color_group,
|
|
2731
|
+
"/color/group/delete": action_delete_color_group,
|
|
2732
|
+
"/color/group/assign": action_assign_to_color_group,
|
|
2733
|
+
"/color/group/remove": action_remove_from_color_group,
|
|
2734
|
+
# fusion comps per-clip
|
|
2735
|
+
"/clip/fusion/add": action_add_fusion_comp,
|
|
2736
|
+
"/clip/fusion/import": action_import_fusion_comp,
|
|
2737
|
+
"/clip/fusion/export": action_export_fusion_comp,
|
|
2738
|
+
"/clip/fusion/delete": action_delete_fusion_comp,
|
|
2739
|
+
"/clip/fusion/load": action_load_fusion_comp,
|
|
2740
|
+
"/clip/fusion/rename": action_rename_fusion_comp,
|
|
2741
|
+
# smart features
|
|
2742
|
+
"/clip/magic-mask": action_create_magic_mask,
|
|
2743
|
+
"/clip/magic-mask/regenerate": action_regenerate_magic_mask,
|
|
2744
|
+
"/clip/stabilize": action_stabilize,
|
|
2745
|
+
"/clip/smart-reframe": action_smart_reframe,
|
|
2746
|
+
# audio / fairlight
|
|
2747
|
+
"/audio/fairlight-preset": action_apply_fairlight_preset,
|
|
2748
|
+
"/audio/insert-at-playhead": action_insert_audio_at_playhead,
|
|
2749
|
+
"/audio/voice-isolation": action_set_voice_isolation_state,
|
|
2750
|
+
# take selector
|
|
2751
|
+
"/clip/take/add": action_add_take,
|
|
2752
|
+
"/clip/take/select": action_select_take,
|
|
2753
|
+
"/clip/take/delete": action_delete_take,
|
|
2754
|
+
"/clip/take/finalize": action_finalize_take,
|
|
2755
|
+
# proxy
|
|
2756
|
+
"/mediapool/proxy/link": action_link_proxy_media,
|
|
2757
|
+
"/mediapool/proxy/unlink": action_unlink_proxy_media,
|
|
2758
|
+
"/mediapool/clip/replace": action_replace_clip,
|
|
2759
|
+
# titles & generators
|
|
2760
|
+
"/title/insert": action_insert_title,
|
|
2761
|
+
"/generator/insert": action_insert_generator,
|
|
2762
|
+
"/fusion/insert": action_insert_fusion_comp,
|
|
2763
|
+
# render
|
|
2764
|
+
"/render/settings": action_set_render_settings,
|
|
2765
|
+
"/render/format": action_set_render_format,
|
|
2766
|
+
"/render/formats": action_get_render_formats,
|
|
2767
|
+
"/render/job/add": action_add_render_job,
|
|
2768
|
+
"/render/job/status": action_get_render_job_status,
|
|
2769
|
+
"/render/start": action_start_rendering,
|
|
2770
|
+
"/render/stop": action_stop_rendering,
|
|
2771
|
+
"/render/job/delete": action_delete_render_job,
|
|
2772
|
+
"/render/mode": action_set_render_mode,
|
|
2773
|
+
"/render/quick-export": action_quick_export,
|
|
2774
|
+
"/render/refresh-luts": action_refresh_lut_list,
|
|
2775
|
+
"/render/preset": action_render_preset,
|
|
2776
|
+
# project
|
|
2777
|
+
"/project/save": action_save_project,
|
|
2778
|
+
"/project/setting": action_set_project_setting,
|
|
2779
|
+
"/timeline/setting": action_set_timeline_setting,
|
|
2780
|
+
"/project/export-frame": action_export_frame,
|
|
2781
|
+
"/timeline/subtitles": action_create_subtitles,
|
|
2782
|
+
"/timeline/scene-cuts": action_detect_scene_cuts,
|
|
2783
|
+
# project manager
|
|
2784
|
+
"/projects/load": action_load_project,
|
|
2785
|
+
"/projects/create": action_create_project,
|
|
2786
|
+
"/projects/delete": action_delete_project,
|
|
2787
|
+
"/projects/archive": action_archive_project,
|
|
2788
|
+
"/projects/export": action_export_project,
|
|
2789
|
+
"/projects/import": action_import_project,
|
|
2790
|
+
"/projects/folder": action_navigate_project_folder,
|
|
2791
|
+
"/projects/database": action_set_database,
|
|
2792
|
+
# resolve-level presets
|
|
2793
|
+
"/resolve/layout-preset": action_layout_preset,
|
|
2794
|
+
"/resolve/burnin-preset": action_burnin_preset,
|
|
2795
|
+
"/resolve/keyframe-mode": action_set_keyframe_mode,
|
|
2796
|
+
# media storage
|
|
2797
|
+
"/media-storage/reveal": action_reveal_in_storage,
|
|
2798
|
+
# gallery / stills
|
|
2799
|
+
"/gallery/album/set": action_set_current_album,
|
|
2800
|
+
"/gallery/album/create": action_create_gallery_album,
|
|
2801
|
+
"/gallery/grab": action_grab_still,
|
|
2802
|
+
"/gallery/grab-all": action_grab_all_stills,
|
|
2803
|
+
"/gallery/stills/export": action_export_stills,
|
|
2804
|
+
"/gallery/stills/import": action_import_stills,
|
|
2805
|
+
"/gallery/stills/delete": action_delete_stills,
|
|
2806
|
+
"/gallery/stills/label": action_set_still_label,
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
|
|
2810
|
+
# ---------------------------------------------------------------------------
|
|
2811
|
+
# HTTP handler
|
|
2812
|
+
# ---------------------------------------------------------------------------
|
|
2813
|
+
|
|
2814
|
+
class BridgeHandler(BaseHTTPRequestHandler):
|
|
2815
|
+
def log_message(self, fmt, *args):
|
|
2816
|
+
pass
|
|
2817
|
+
|
|
2818
|
+
def _respond(self, data):
|
|
2819
|
+
body = json.dumps(data, default=str).encode("utf-8")
|
|
2820
|
+
self.send_response(200)
|
|
2821
|
+
self.send_header("Content-Type", "application/json")
|
|
2822
|
+
self.send_header("Content-Length", str(len(body)))
|
|
2823
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
2824
|
+
self.end_headers()
|
|
2825
|
+
self.wfile.write(body)
|
|
2826
|
+
|
|
2827
|
+
def _error(self, code, msg):
|
|
2828
|
+
body = json.dumps({"error": msg}).encode("utf-8")
|
|
2829
|
+
self.send_response(code)
|
|
2830
|
+
self.send_header("Content-Type", "application/json")
|
|
2831
|
+
self.send_header("Content-Length", str(len(body)))
|
|
2832
|
+
self.end_headers()
|
|
2833
|
+
self.wfile.write(body)
|
|
2834
|
+
|
|
2835
|
+
def do_GET(self):
|
|
2836
|
+
parsed = urlparse(self.path)
|
|
2837
|
+
path = parsed.path.rstrip("/")
|
|
2838
|
+
qs = parse_qs(parsed.query)
|
|
2839
|
+
handler = GET_ROUTES.get(path)
|
|
2840
|
+
if handler:
|
|
2841
|
+
try:
|
|
2842
|
+
self._respond(handler(qs))
|
|
2843
|
+
except Exception:
|
|
2844
|
+
self._error(500, traceback.format_exc())
|
|
2845
|
+
else:
|
|
2846
|
+
self._error(404, "Unknown GET endpoint: %s" % path)
|
|
2847
|
+
|
|
2848
|
+
def do_POST(self):
|
|
2849
|
+
parsed = urlparse(self.path)
|
|
2850
|
+
path = parsed.path.rstrip("/")
|
|
2851
|
+
content_len = int(self.headers.get("Content-Length", 0))
|
|
2852
|
+
raw = self.rfile.read(content_len) if content_len else b"{}"
|
|
2853
|
+
try:
|
|
2854
|
+
body = json.loads(raw)
|
|
2855
|
+
except Exception:
|
|
2856
|
+
self._error(400, "Invalid JSON body")
|
|
2857
|
+
return
|
|
2858
|
+
handler = POST_ROUTES.get(path)
|
|
2859
|
+
if handler:
|
|
2860
|
+
try:
|
|
2861
|
+
self._respond(handler(body))
|
|
2862
|
+
except Exception:
|
|
2863
|
+
self._error(500, traceback.format_exc())
|
|
2864
|
+
else:
|
|
2865
|
+
self._error(404, "Unknown POST endpoint: %s" % path)
|
|
2866
|
+
|
|
2867
|
+
def do_OPTIONS(self):
|
|
2868
|
+
self.send_response(204)
|
|
2869
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
2870
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
2871
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
2872
|
+
self.end_headers()
|
|
2873
|
+
|
|
2874
|
+
|
|
2875
|
+
# ---------------------------------------------------------------------------
|
|
2876
|
+
# Start server (with hot-reload: shut down any previous instance first)
|
|
2877
|
+
# ---------------------------------------------------------------------------
|
|
2878
|
+
import time
|
|
2879
|
+
try:
|
|
2880
|
+
import urllib.request
|
|
2881
|
+
req = urllib.request.Request(
|
|
2882
|
+
"http://%s:%d/bridge/shutdown" % (HOST, PORT),
|
|
2883
|
+
data=b"{}",
|
|
2884
|
+
headers={"Content-Type": "application/json"},
|
|
2885
|
+
method="POST",
|
|
2886
|
+
)
|
|
2887
|
+
urllib.request.urlopen(req, timeout=2)
|
|
2888
|
+
print("[CursorBridge] Sent shutdown to previous instance, waiting...")
|
|
2889
|
+
time.sleep(1.5)
|
|
2890
|
+
except Exception:
|
|
2891
|
+
pass
|
|
2892
|
+
|
|
2893
|
+
print("[CursorBridge] Starting HTTP server on http://%s:%d ..." % (HOST, PORT))
|
|
2894
|
+
server = None
|
|
2895
|
+
try:
|
|
2896
|
+
server = HTTPServer((HOST, PORT), BridgeHandler)
|
|
2897
|
+
print("[CursorBridge] Bridge is running (read + write). %d GET routes, %d POST routes." % (
|
|
2898
|
+
len(GET_ROUTES), len(POST_ROUTES)))
|
|
2899
|
+
print("[CursorBridge] To stop: close DaVinci Resolve or re-run this script.")
|
|
2900
|
+
server.serve_forever()
|
|
2901
|
+
except OSError as e:
|
|
2902
|
+
if "Address already in use" in str(e) or "Only one usage" in str(e) or getattr(e, "errno", 0) == 10048:
|
|
2903
|
+
print("[CursorBridge] Port %d already in use — could not replace old bridge." % PORT)
|
|
2904
|
+
print("[CursorBridge] Restart DaVinci Resolve and try again.")
|
|
2905
|
+
else:
|
|
2906
|
+
print("[CursorBridge] ERROR: %s" % e)
|
|
2907
|
+
except KeyboardInterrupt:
|
|
2908
|
+
print("[CursorBridge] Shutting down.")
|
|
2909
|
+
except Exception as e:
|
|
2910
|
+
print("[CursorBridge] ERROR: %s" % e)
|