@hybridlabor-api/bdb-antigravity-skills 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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)