
Video Resolve
- 1 installs
- 7 repo stars
- Updated April 12, 2026
- isaac-flath/agent-starter-skills
Build edited YouTube videos in DaVinci Resolve by writing inline Python against its scripting API.
About
Builds edited YouTube videos in DaVinci Resolve by writing inline Python that calls its scripting API directly. A developer uses it to assemble an edited video project in Resolve programmatically.
- Builds edited YouTube videos via DaVinci Resolve's Python scripting API
- Writes inline Python that calls the API directly, no wrapper scripts
Video Resolve by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,200 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Jul 8, 2026 (Skillselion catalog sync)
npx skills add https://github.com/isaac-flath/agent-starter-skills --skill video-resolveAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 7 |
| Last updated | April 12, 2026 |
| Repository | isaac-flath/agent-starter-skills ↗ |
What it does
Build edited YouTube videos in DaVinci Resolve by writing inline Python against its scripting API.
Files
Video Resolve Skill
Build edited YouTube videos in DaVinci Resolve using its Python scripting API directly. No wrapper scripts — write inline Python that calls the API via Bash.
Prerequisites
- DaVinci Resolve Studio must be running
- Python 3.6+ with access to Resolve's scripting module
API Documentation
All API reference material is in references/:
resolve_scripting_api.txt— Official Blackmagic API reference (complete)api_practical_notes.md— Tested patterns, gotchas, and working examples
Always read `api_practical_notes.md` before writing Resolve API calls. It documents critical gotchas like the recordFrame offset and mediaType semantics.
How to Use
Write inline Python scripts via Bash that call the Resolve API directly. Connection boilerplate:
import sys
sys.path.insert(0, "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting/Modules")
import DaVinciResolveScript as dvr
resolve = dvr.scriptapp("Resolve")
pm = resolve.GetProjectManager()
resolve.OpenPage("edit")Test Script
scripts/test_api.py — Verifies each API operation individually. Run to confirm the API is working.
Building a Project
When building a project from EDL + overlay + chapter data, write the API calls inline. The key operations:
1. Create project — pm.CreateProject(name), set resolution/fps, import source media 2. Build timeline — media_pool.CreateEmptyTimeline(name), then AppendToTimeline for each kept segment. Omit `mediaType` for video+audio. 3. Get timeline start frame — t1_items[0].GetStart() (typically 108000 for 01:00:00:00 at 30fps) 4. Add overlay track — timeline.AddTrack("video") 5. Place overlays — AppendToTimeline with trackIndex, recordFrame (must include start offset!), mediaType: 1 6. Set overlay transforms — item.SetProperty("ZoomX"/"ZoomY"/"Pan"/"Tilt", value) per overlay 7. Add markers — timeline.AddMarker(frame, color, name, note, duration)
Each of these is a few lines of Python. No wrapper needed.
File Structure
scripts/
test_api.py # API test suite
references/
resolve_scripting_api.txt # Official Blackmagic API docs
api_practical_notes.md # Tested patterns and gotchas[project]
name = "video-resolve"
version = "0.1.0"
description = "DaVinci Resolve automation via Python scripting API"
requires-python = ">=3.10"
dependencies = []
DaVinci Resolve API — Practical Notes
Tested against DaVinci Resolve Studio 20.3.2 on macOS. See resolve_scripting_api.txt for full API reference.
Connection Boilerplate
import sys
sys.path.insert(0, "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting/Modules")
import DaVinciResolveScript as dvr
resolve = dvr.scriptapp("Resolve")
pm = resolve.GetProjectManager()
resolve.OpenPage("edit")The process name is Resolve (not DaVinci Resolve) for pgrep checks.
Project + Timeline Creation
project = pm.CreateProject("My Project")
project.SetSetting("timelineFrameRate", "30")
project.SetSetting("timelineResolutionWidth", "3840")
project.SetSetting("timelineResolutionHeight", "2160")
media_pool = project.GetMediaPool()
items = media_pool.ImportMedia(["/absolute/path/to/video.mp4"])
source = items[0]
timeline = media_pool.CreateEmptyTimeline("My Timeline")
project.SetCurrentTimeline(timeline)Appending Subclips (Track 1)
media_pool.AppendToTimeline([{
"mediaPoolItem": source,
"startFrame": 180, # source frame (not seconds)
"endFrame": 330,
}])CRITICAL: Do NOT include "mediaType": 1 — that means VIDEO ONLY (no audio). To get both video AND audio, omit the mediaType key entirely.
Frames are source-file frames. At 30fps: seconds * 30 = frame number.
Placing Overlays on Higher Tracks (No Gaps)
This is the correct way to place images/overlays on track 2+ without disrupting the video on track 1.
CRITICAL: recordFrame must include the timeline start offset. Timelines default to 01:00:00:00 which is frame 108000 at 30fps.
# Add overlay track
timeline.AddTrack("video") # creates track 2
# Get the timeline start frame offset
t1_items = timeline.GetItemListInTrack("video", 1)
tl_start_frame = t1_items[0].GetStart() # 108000 for 01:00:00:00 at 30fps
# Import image
img_items = media_pool.ImportMedia(["/path/to/overlay.png"])
# Place on track 2 at 3 seconds into the timeline, lasting 2 seconds
offset_frames = int(3.0 * 30) # 3 seconds at 30fps = 90 frames
dur_frames = int(2.0 * 30) # 2 seconds = 60 frames
result = media_pool.AppendToTimeline([{
"mediaPoolItem": img_items[0],
"startFrame": 0,
"endFrame": dur_frames,
"trackIndex": 2,
"recordFrame": tl_start_frame + offset_frames, # MUST add offset!
"mediaType": 1, # video only (correct for images)
}])
# Set position/scale
item = result[0]
item.SetProperty("ZoomX", 0.3) # 30% of native size
item.SetProperty("ZoomY", 0.3)
item.SetProperty("Pan", float(width) * 0.3) # pixels right of center
item.SetProperty("Tilt", float(-height) * 0.3) # pixels below centerIf you use `recordFrame` without the start offset, the overlay will create gaps in track 1.
Rendering text as PNG for overlays
Text+ titles via InsertFusionTitleIntoTimeline are unreliable for positioning (always insert at playhead, hard to control which track). Instead, render text as PNG via ffmpeg and place as image overlay:
ffmpeg -y -f lavfi -i "color=c=0x222222@0.85:s=900x100:d=1,format=rgba" \
-vf "drawtext=text='My Label':fontfile=/System/Library/Fonts/Helvetica.ttc:fontsize=42:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2" \
-frames:v 1 -update 1 overlay.pngffmpeg drawtext escaping gotchas
Apostrophes are the biggest pain point. The drawtext filter uses single quotes for its text value, so apostrophes must be escaped with a shell-level trick:
# To render: don't
# Use shell quote-break: '...don'\''t...'
-vf "drawtext=...text='Personal tools don\\'\''t need library abstractions'"Unicode characters (em dashes, etc.) do NOT work with hex escaping in drawtext. \xe2\x80\x94 renders literally as "xe2x80x94". Use ASCII alternatives instead:
- Em dash → regular hyphen with spaces:
- - Smart quotes → straight quotes
The `-update 1` flag is required when writing a single frame to a named output file (not a sequence pattern like %03d.png). Without it, ffmpeg warns and may not write the file.
Filter-complex escaping differs from -vf escaping. In filter_complex, colons within drawtext must be escaped with backslashes: \:text= not :text=.
Then place using the image overlay method above. This gives exact control over position and timing.
Timeline Markers
# frameId is relative to timeline start (frame 0 = first frame of content)
timeline.AddMarker(0, "Blue", "Chapter 1: Intro", "optional note", 1)
timeline.AddMarker(150, "Green", "Chapter 2: Topic", "", 1)
# Verify
markers = timeline.GetMarkers() # {frameId: {color, duration, note, name, customData}}Deleting a Project
# Must close and switch to a different project first
pm.CloseProject(project)
pm.CreateProject("_temp")
pm.DeleteProject("My Project")
pm.CloseProject(pm.GetCurrentProject())
pm.DeleteProject("_temp")Timeline Item Properties (SetProperty/GetProperty)
Key properties for transforms:
Pan: float, pixels from center (-4width to 4width), positive = rightTilt: float, pixels from center (-4height to 4height), positive = up (negative = down)ZoomX,ZoomY: float, 0.0 to 100.0 (1.0 = native size, 0.3 = 30%)RotationAngle: float, -360 to 360CropLeft/Right/Top/Bottom: float, pixelsCompositeMode: int (0 = Normal)FlipX,FlipY: bool
Overlay Positioning — Hard-Earned Lessons
ZoomX behavior for overlay images is unintuitive. Despite the docs saying 1.0 = native size, overlay images placed on a 3840x2160 timeline behave as if their coordinate space is much larger than native pixels. The effective displayed size is much bigger than expected.
Calibrated values for text overlay PNGs on a 3840x2160 timeline (rendered to 1920x1080):
| Overlay image width | ZoomX/Y | Approx. frame coverage |
|---|---|---|
| 1300-1400px | 1.3 | ~47% of frame |
| 1500px | 1.2 | ~47% of frame |
| 2600px | 0.8 | ~54% of frame |
Pan calibration (on 3840x2160 timeline):
Pan=0does NOT center the overlay — it appears shifted left, with text clipped off the left edgePan=500-700is needed to left-align an overlay with a comfortable left margin- Higher Pan values push the overlay further right
- For a 1400px overlay at ZoomX=1.3,
Pan=650gives a good left-aligned position with margin
Tilt calibration:
Tilt=-420places overlays roughly in the middle-lower area — this often overlaps a webcam PiPTilt=-700moves overlays into the lower quarter but may still clip a bottom-right webcamTilt=-850places overlays at the very bottom of the frame, below a typical webcam PiP- For screens with a webcam in the bottom-right, use
Tilt=-800to-900to clear it
Always render and screenshot to verify. Coordinate math alone is unreliable for this API. The render→screenshot→review loop is essential. Use a short test render (MarkIn/MarkOut around one overlay) to iterate quickly before doing full renders.
Common mistakes:
- Setting
Pan=-960(half of 1920) thinking it will left-align — this pushes the overlay far off-screen - Using ZoomX values >1.5 for narrow images — they become enormous and clip
- Not accounting for webcam PiP when setting Tilt — always check frames with the speaker visible
Image Overlays (Website Cards, Screenshots, Logos)
Image overlays are website screenshots, GitHub social cards, or tool logos shown briefly when the speaker mentions a tool or resource. They differ from text overlay cards in sizing and placement.
Asset selection hierarchy
1. GitHub social cards — best for tools/repos. Shows repo name, one-line description, star count, and logo. Instantly recognizable by dev audiences. Use gather_assets.py with the GitHub repo URL to fetch (e.g., https://github.com/casey/just). 2. Website homepage OG image — use only if it's a clean, branded graphic. Avoid landing pages that have navigation links, non-English text, or multiple CTAs — these look confusing at small overlay size. Always view the OG image before using it. 3. Full-page screenshot — last resort. Text-heavy pages are unreadable at 30% frame width.
Always view the fetched image before placing it. Some OG images look great at full size but are confusing at overlay size (e.g., the just.systems OG image has "j u s t" with Discord/GitHub links and Chinese characters — not useful as a small overlay).
Calibrated values for image overlays (3840x2160 timeline, 1080p render)
Image overlays should be smaller than text overlays and placed in a corner (typically upper-right to avoid the webcam in bottom-right).
| Image native size | ZoomX/Y | Approx. frame coverage |
|---|---|---|
| 1280x800 (OG image) | 0.35 | ~30% of frame width |
| 1200x600 (GitHub card) | 0.35 | ~28% of frame width |
| 1920x1080 (full screenshot) | 0.25-0.30 | ~30% of frame width |
Positioning for upper-right corner:
Pan=900,Tilt=550— places image in upper-right, clear of webcam and code content- Adjust Pan higher (950+) if the image is wider than expected
- Adjust Tilt lower (400-500) if it clips the top title bar
Timing rules
- Show image overlays for 4-5 seconds — long enough to register, short enough to not be distracting
- Don't overlap with text overlays on the same topic. Sequence them: image card first (visual intro) → image fades → text takeaway appears
- Place image overlays when the speaker first names the tool, not when they're deep into explaining it
Compositing cards with URLs
Add a URL bar below GitHub social cards so viewers know where to find the tool. Use ffmpeg to vstack the card with a rendered URL strip:
ffmpeg -y \
-i card.png \
-f lavfi -i "color=c=0xF6F8FA:s=1200x60:d=1" \
-filter_complex "[1:v]drawtext=fontfile=/System/Library/Fonts/HelveticaNeue.ttc\
:text='github.com/owner/repo':fontcolor=0x0969DA:fontsize=32\
:x=(w-text_w)/2:y=(h-text_h)/2[url];[0:v][url]vstack" \
-update 1 -frames:v 1 card_with_url.pngThis creates a composite that looks like a social media link preview — card + clickable-looking URL below in blue text on light gray background.
Adding drop shadows to image cards
Light-background cards blend into light IDE backgrounds. Add a subtle drop shadow to make them float:
# Create shadow layer (padded + blurred original)
ffmpeg -y -i card.png \
-vf "pad=w=iw+20:h=ih+20:x=10:y=10:color=0x00000040,boxblur=4:4" \
-update 1 -frames:v 1 /tmp/shadow.png
# Composite original on top of shadow
ffmpeg -y -i /tmp/shadow.png -i card.png \
-filter_complex "[0:v][1:v]overlay=7:7" \
-update 1 -frames:v 1 card_with_shadow.pngAsset selection: pick the most informative card per tool
Don't default to one source for all tools. Evaluate per tool:
1. Check the tool's website OG card first — if it exists and looks good (branded, clean, readable at small size, includes the URL), use it. Example: airwebframework.org has a beautiful branded OG card with the tagline and URL already included. 2. Fall back to GitHub social card if the website has no OG image, a confusing OG image (navigation links, non-English text, multiple CTAs), or a text-heavy docs page. Example: just.systems has an OG image with Japanese characters and Discord links — not useful as a small overlay, so github.com/casey/just is better. 3. Add a URL bar (ffmpeg vstack technique above) only if the card doesn't already include the URL. 4. Link to the canonical repo, not a fork. Always verify you're using the original (e.g., feldroy/air not a personal fork).
Key learnings
- OG/social cards > full screenshots for small overlay use. Text-heavy pages are unreadable at 30% frame size.
- Website OG cards > GitHub cards when the website has a well-designed one — they're more branded and visually distinctive.
- GitHub social cards are the reliable fallback — they always exist and show name, description, stars.
- Add drop shadows to light-background cards so they pop against light IDE backgrounds.
- Always view OG images before using them. Some look great at full size but are confusing at overlay size (e.g., landing pages with nav links, non-English text, or multiple CTAs).
- Use a separate track (track 3) for image overlays to keep them independent from text overlays (track 2).
- Always test with render→screenshot — the coordinate system is non-linear and hard to predict.
Text Overlays via Fusion Comp
Two approaches, depending on needs:
Approach 1: Fusion Text+ on existing clips (animated text) Add text directly to a clip's Fusion composition. Supports keyframe animation (fade, size, position) via BezierSpline. See video-annotations/references/technical-learnings.md for the full pattern.
item = timeline.GetItemListInTrack("video", 1)[0]
comp = item.GetFusionCompByIndex(1)
text = comp.AddTool("TextPlus")
text.SetInput("StyledText", "My Text")
text.SetInput("Font", "Arial")
text.SetInput("Size", 0.07)
text.SetInput("Center", {1: 0.5, 2: 0.12})
# Animate with BezierSpline (SetInput with frame number does NOT keyframe)
text.Opacity = comp.BezierSpline({})
text.Opacity[0] = 0.0
text.Opacity[30] = 1.0
# Wire: MediaIn -> Merge(+Text) -> MediaOut
merge = comp.AddTool("Merge")
merge.Background = comp.FindTool("MediaIn1")
merge.Foreground = text
comp.FindTool("MediaOut1").Input = mergeApproach 2: Render text as PNG image overlay (static labels) For simple static labels without animation, render as PNG via ffmpeg and place on an overlay track. See "Placing Overlays on Higher Tracks" section above.
Avoid `InsertFusionTitleIntoTimeline("Text+")` — it inserts at the playhead and you cannot control which track it lands on.
"""Add chapter markers to a DaVinci Resolve timeline.
Usage:
python3 add_markers.py <chapters_json> [--project <name>]
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from resolve_api import connect, add_markers, switch_to_edit_page
def main(chapters_path: str, project_name: str = None):
with open(chapters_path) as f:
chapters = json.load(f)
if isinstance(chapters, dict):
chapters = chapters.get('chapters', [])
print("Connecting to DaVinci Resolve...")
resolve, pm, project = connect()
if project_name:
project = pm.LoadProject(project_name)
switch_to_edit_page(resolve)
timeline = project.GetCurrentTimeline()
if not timeline:
raise RuntimeError("No current timeline")
fps = float(project.GetSetting("timelineFrameRate") or 25)
print(f"Adding {len(chapters)} markers to {timeline.GetName()}...")
add_markers(timeline, chapters, fps=fps)
print("Done.")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 add_markers.py <chapters_json> [--project <name>]")
sys.exit(1)
chapters_path = sys.argv[1]
project_name = None
if "--project" in sys.argv:
idx = sys.argv.index("--project")
if idx + 1 < len(sys.argv):
project_name = sys.argv[idx + 1]
main(chapters_path, project_name)
"""Add overlays (titles and images) to a DaVinci Resolve timeline.
Takes an overlay spec JSON and adds each overlay to the current timeline
as either a Text+ title or an image on a higher video track.
Usage:
python3 add_overlays.py <overlays_json> [--project <name>]
Requires DaVinci Resolve Studio to be running with the project open.
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from resolve_api import connect, add_title, add_image_overlay, switch_to_edit_page
def add_overlays(overlays_path: str, edl_path: str = None, project_name: str = None):
"""Add overlays from spec to the current Resolve timeline."""
with open(overlays_path) as f:
spec = json.load(f)
overlays = spec.get('overlays', [])
if not overlays:
print("No overlays to add.")
return
# If EDL provided, build source-to-timeline mapping
timeline_map = None
if edl_path:
with open(edl_path) as f:
edl = json.load(f)
kept = [s for s in edl['segments'] if s.get('action') == 'keep']
timeline_map = []
tl_pos = 0.0
for seg in kept:
timeline_map.append((seg['start'], seg['end'], tl_pos))
tl_pos += seg['end'] - seg['start']
def source_to_timeline(src_time):
"""Map source timestamp to edited timeline position."""
if timeline_map is None:
return src_time
for src_start, src_end, tl_start in timeline_map:
if src_start <= src_time <= src_end:
return tl_start + (src_time - src_start)
# Nearest
best = 0.0
best_dist = float('inf')
for src_start, src_end, tl_start in timeline_map:
for st, tt in [(src_start, tl_start), (src_end, tl_start + src_end - src_start)]:
if abs(src_time - st) < best_dist:
best_dist = abs(src_time - st)
best = tt
return best
# Connect to Resolve
print("Connecting to DaVinci Resolve...")
resolve, pm, project = connect()
if project_name:
project = pm.LoadProject(project_name)
if not project:
raise RuntimeError(f"Cannot load project: {project_name}")
switch_to_edit_page(resolve)
timeline = project.GetCurrentTimeline()
if not timeline:
raise RuntimeError("No current timeline. Open a timeline first.")
fps = float(project.GetSetting("timelineFrameRate") or 25)
print(f"Timeline: {timeline.GetName()} @ {fps}fps")
print(f"Adding {len(overlays)} overlays...")
for overlay in overlays:
src_start = overlay['timing']['source_start']
tl_start = source_to_timeline(src_start)
duration = overlay['timing'].get('display_duration', 5.0)
start_frame = int(round(tl_start * fps))
dur_frames = int(round(duration * fps))
overlay_type = overlay.get('type', 'key-term')
label = overlay.get('label', '')
position = overlay.get('position', 'lower-right')
# Map position names to Resolve coordinates
pos_map = {
'lower-right': (0.3, -0.3),
'lower-third': (0.0, -0.35),
'lower-left': (-0.3, -0.3),
'upper-right': (0.3, 0.3),
'center': (0.0, 0.0),
}
pos_x, pos_y = pos_map.get(position, (0.3, -0.3))
if overlay_type in ('blog-card', 'generic-image') and overlay.get('asset_path'):
# Image overlay
asset_path = overlay['asset_path']
if not os.path.isabs(asset_path):
asset_path = os.path.join(os.path.dirname(overlays_path), asset_path)
print(f" [{overlay['id']}] Image: {label} @ {tl_start:.1f}s ({dur_frames}f)")
add_image_overlay(
project, timeline,
image_path=asset_path,
track_index=3,
start_frame=start_frame,
duration_frames=dur_frames,
scale=0.4,
position_x=pos_x,
position_y=pos_y,
)
else:
# Text title overlay
url = overlay.get('url', '')
display_text = label
if url:
display_text = f"{label}\n{url}"
print(f" [{overlay['id']}] Title: {label} @ {tl_start:.1f}s ({dur_frames}f)")
add_title(
timeline,
text=display_text,
track_index=2,
start_frame=start_frame,
duration_frames=dur_frames,
position_x=pos_x,
position_y=pos_y,
)
print(f"\nDone. {len(overlays)} overlays added to timeline.")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 add_overlays.py <overlays_json> [--edl <edl_json>] [--project <name>]")
sys.exit(1)
overlays_path = sys.argv[1]
edl_path = None
project_name = None
args = sys.argv[2:]
i = 0
while i < len(args):
if args[i] == "--edl" and i + 1 < len(args):
edl_path = args[i + 1]
i += 2
elif args[i] == "--project" and i + 1 < len(args):
project_name = args[i + 1]
i += 2
else:
i += 1
add_overlays(overlays_path, edl_path, project_name)
"""Build a DaVinci Resolve project from analysis JSON + EDL.
Takes the same analysis and EDL files produced by the video-editor skill
and creates a complete Resolve project with all cuts applied.
Usage:
python3 build_project.py <analysis_json> <edl_json> [--name <project_name>]
Requires DaVinci Resolve Studio to be running.
"""
import json
import os
import sys
from pathlib import Path
# Add parent directory to path for resolve_api import
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from resolve_api import (
connect,
create_project,
create_timeline,
import_media,
append_clips,
switch_to_edit_page,
)
def build_project(analysis_path: str, edl_path: str, project_name: str = "Edit"):
"""Build a Resolve project from analysis + EDL."""
# Load data
with open(analysis_path) as f:
analysis = json.load(f)
with open(edl_path) as f:
edl = json.load(f)
meta = analysis['metadata']
source_file = analysis['source_file']
video_info = meta.get('video', {})
fps = video_info.get('fps', 25.0)
width = video_info.get('width', 1920)
height = video_info.get('height', 1080)
# Connect to Resolve
print("Connecting to DaVinci Resolve...")
resolve, pm, _ = connect()
print(f" Connected: {resolve.GetProductName()} {resolve.GetVersionString()}")
# Switch to Edit page
switch_to_edit_page(resolve)
# Create project
print(f"Creating project: {project_name}")
project = create_project(pm, project_name)
# Import source media
print(f"Importing: {source_file}")
media_items = import_media(project, [source_file])
source_item = media_items[0]
print(f" Imported: {source_item.GetName()}")
# Create timeline
stem = Path(source_file).stem
print(f"Creating timeline: {stem}")
timeline = create_timeline(project, stem, width=width, height=height, fps=fps)
# Get kept segments from EDL
kept_segments = [s for s in edl['segments'] if s.get('action') == 'keep']
print(f"Adding {len(kept_segments)} segments to timeline...")
# Add segments
items = append_clips(project, source_item, kept_segments, fps=fps)
# Apply volume adjustments
vol_adjusted = 0
for i, (seg, item) in enumerate(zip(kept_segments, items)):
vol_db = seg.get('volume_adjust_db', 0)
if vol_db != 0 and item:
# Resolve uses linear volume, not dB
# Approximate: linear = 10^(dB/20)
import math
linear = math.pow(10, vol_db / 20.0)
item.SetProperty("Volume", linear)
vol_adjusted += 1
total_kept = sum(s['end'] - s['start'] for s in kept_segments)
print(f"\nProject built:")
print(f" Timeline: {stem}")
print(f" Clips: {len(items)}")
print(f" Duration: {int(total_kept//60)}:{int(total_kept%60):02d} "
f"(from {int(meta['duration']//60)}:{int(meta['duration']%60):02d})")
print(f" Volume adjustments: {vol_adjusted}")
print(f"\nOpen DaVinci Resolve to review the edit.")
return project, timeline
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python3 build_project.py <analysis_json> <edl_json> [--name <project_name>]")
sys.exit(1)
analysis_path = sys.argv[1]
edl_path = sys.argv[2]
project_name = "Edit"
args = sys.argv[3:]
for i, arg in enumerate(args):
if arg == "--name" and i + 1 < len(args):
project_name = args[i + 1]
build_project(analysis_path, edl_path, project_name)
"""DaVinci Resolve API wrapper — connect, import, timeline operations.
Handles the boilerplate of connecting to a running Resolve instance
and provides clean helpers for common operations.
"""
import os
import sys
import time
from pathlib import Path
from typing import Dict, List, Optional, Tuple
def _find_resolve_script_module():
"""Find and import DaVinci Resolve's scripting module.
Resolve's Python module lives in a platform-specific location.
This function adds the correct path to sys.path.
"""
# macOS paths for DaVinci Resolve
resolve_paths = [
"/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting/Modules",
os.path.expanduser("~/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting/Modules"),
]
for p in resolve_paths:
if os.path.isdir(p) and p not in sys.path:
sys.path.insert(0, p)
# Also check RESOLVE_SCRIPT_API env var
env_path = os.environ.get("RESOLVE_SCRIPT_API")
if env_path and os.path.isdir(env_path):
sys.path.insert(0, os.path.join(env_path, "Modules"))
def connect() -> Tuple:
"""Connect to a running DaVinci Resolve instance.
Returns (resolve, project_manager, current_project) tuple.
Raises RuntimeError if Resolve is not running.
"""
_find_resolve_script_module()
try:
import DaVinciResolveScript as dvr
except ImportError:
raise RuntimeError(
"Cannot import DaVinci Resolve scripting module. "
"Make sure DaVinci Resolve Studio is installed and running. "
"The free version does not support scripting."
)
resolve = dvr.scriptapp("Resolve")
if resolve is None:
raise RuntimeError(
"Cannot connect to DaVinci Resolve. "
"Make sure it is running before executing this script."
)
pm = resolve.GetProjectManager()
project = pm.GetCurrentProject()
return resolve, pm, project
def create_project(pm, name: str):
"""Create a new project and return it."""
project = pm.CreateProject(name)
if project is None:
# Project might already exist — try loading it
project = pm.LoadProject(name)
if project is None:
raise RuntimeError(f"Failed to create or load project: {name}")
return project
def import_media(project, file_paths: List[str]) -> List:
"""Import media files into the project's media pool.
Returns list of MediaPoolItem objects.
"""
media_pool = project.GetMediaPool()
abs_paths = [os.path.abspath(p) for p in file_paths]
items = media_pool.ImportMedia(abs_paths)
if not items:
raise RuntimeError(f"Failed to import media: {abs_paths}")
return items
def create_timeline(project, name: str, width: int = 1920, height: int = 1080, fps: float = 25.0):
"""Create an empty timeline with specified settings."""
# Set project settings for the timeline
project.SetSetting("timelineResolutionWidth", str(width))
project.SetSetting("timelineResolutionHeight", str(height))
project.SetSetting("timelineFrameRate", str(fps))
media_pool = project.GetMediaPool()
timeline = media_pool.CreateEmptyTimeline(name)
if timeline is None:
raise RuntimeError(f"Failed to create timeline: {name}")
project.SetCurrentTimeline(timeline)
return timeline
def append_clips(
project,
media_pool_item,
segments: List[dict],
fps: float = 25.0,
) -> List:
"""Append segments from a single source to the current timeline.
Each segment needs 'start' and 'end' in seconds.
Returns list of TimelineItem objects.
"""
media_pool = project.GetMediaPool()
timeline_items = []
for seg in segments:
start_frame = int(round(seg['start'] * fps))
end_frame = int(round(seg['end'] * fps))
clip_info = {
"mediaPoolItem": media_pool_item,
"startFrame": start_frame,
"endFrame": end_frame,
"mediaType": 1, # 1 = video+audio
}
result = media_pool.AppendToTimeline([clip_info])
if result:
timeline_items.extend(result)
return timeline_items
def add_title(
timeline,
text: str,
track_index: int = 2,
start_frame: int = 0,
duration_frames: int = 200,
position_x: float = 0.0,
position_y: float = -0.3,
font_size: float = 0.05,
) -> Optional[object]:
"""Add a Text+ title to the timeline.
Args:
timeline: The Timeline object
text: Text content to display
track_index: Video track number (2 = first overlay track)
start_frame: Frame position on timeline
duration_frames: Duration in frames
position_x: X position (-0.5 to 0.5, center = 0)
position_y: Y position (-0.5 to 0.5, center = 0)
font_size: Font size (0.0 to 1.0)
Returns:
TimelineItem or None
"""
# InsertFusionTitleIntoTimeline places a Fusion Text+ title
result = timeline.InsertFusionTitleIntoTimeline("Text+")
if not result:
# Fallback to standard title
result = timeline.InsertTitleIntoTimeline("Text")
# The title gets inserted at the playhead position
# We need to move it to the right position and set its text
# This requires accessing the Fusion composition
return result
def add_image_overlay(
project,
timeline,
image_path: str,
track_index: int = 3,
start_frame: int = 0,
duration_frames: int = 200,
scale: float = 0.3,
position_x: float = 0.3,
position_y: float = -0.3,
) -> Optional[object]:
"""Add an image as an overlay on a higher track.
Args:
project: The Project object
timeline: The Timeline object
image_path: Path to the image file
track_index: Video track number (3 = second overlay track)
start_frame: Frame position on timeline
duration_frames: Duration in frames
scale: Scale factor (0.0 to 1.0)
position_x: X position (-0.5 to 0.5)
position_y: Y position (-0.5 to 0.5)
"""
media_pool = project.GetMediaPool()
# Import the image
items = media_pool.ImportMedia([os.path.abspath(image_path)])
if not items:
print(f" Warning: failed to import {image_path}")
return None
# Add to timeline on specified track
clip_info = {
"mediaPoolItem": items[0],
"startFrame": 0,
"endFrame": duration_frames,
"trackIndex": track_index,
"recordFrame": start_frame,
"mediaType": 1,
}
result = media_pool.AppendToTimeline([clip_info])
if result:
item = result[0]
# Set transform properties
item.SetProperty("ZoomX", scale)
item.SetProperty("ZoomY", scale)
item.SetProperty("Pan", position_x)
item.SetProperty("Tilt", position_y)
item.SetProperty("CompositeMode", 0) # Normal blend mode
return item
return None
def add_markers(timeline, markers: List[dict], fps: float = 25.0):
"""Add markers to the timeline.
Each marker needs 'timeline_secs', 'title', and optionally 'color'.
"""
for m in markers:
frame = int(round(m['timeline_secs'] * fps))
color = m.get('color', 'Blue')
timeline.AddMarker(
frame, color,
m['title'],
m.get('note', ''),
1, # duration in frames
)
def switch_to_edit_page(resolve):
"""Switch to the Edit page."""
resolve.OpenPage("edit")
time.sleep(0.5)
"""Verify DaVinci Resolve scripting environment is set up correctly.
Checks:
1. Resolve Studio is running
2. Python scripting module is accessible
3. Can connect and read project info
Usage:
python3 setup.py
"""
import os
import subprocess
import sys
def check_resolve_running():
"""Check if DaVinci Resolve is running."""
result = subprocess.run(
['pgrep', '-x', 'DaVinci Resolve'],
capture_output=True,
)
return result.returncode == 0
def check_resolve_installed():
"""Check if DaVinci Resolve is installed."""
app_path = "/Applications/DaVinci Resolve/DaVinci Resolve.app"
return os.path.exists(app_path)
def main():
print("DaVinci Resolve Setup Check")
print("=" * 40)
# Check installation
installed = check_resolve_installed()
print(f" Installed: {'Yes' if installed else 'No'}")
if not installed:
print("\n DaVinci Resolve is not installed.")
print(" Download from: https://www.blackmagicdesign.com/products/davinciresolve")
print(" Note: Scripting requires DaVinci Resolve Studio ($295 one-time)")
sys.exit(1)
# Check if running
running = check_resolve_running()
print(f" Running: {'Yes' if running else 'No'}")
if not running:
print("\n Please start DaVinci Resolve before running scripts.")
sys.exit(1)
# Check scripting module
module_paths = [
"/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting/Modules",
os.path.expanduser("~/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting/Modules"),
]
module_found = False
for p in module_paths:
if os.path.isdir(p):
print(f" Scripting module: {p}")
module_found = True
sys.path.insert(0, p)
break
if not module_found:
print("\n Scripting module not found.")
print(" This usually means you have the free version.")
print(" DaVinci Resolve Studio ($295) is required for Python scripting.")
sys.exit(1)
# Try to connect
try:
import DaVinciResolveScript as dvr
resolve = dvr.scriptapp("Resolve")
if resolve is None:
print("\n Connected to scripting module but Resolve is not responding.")
print(" Try restarting DaVinci Resolve.")
sys.exit(1)
print(f" Product: {resolve.GetProductName()}")
print(f" Version: {resolve.GetVersionString()}")
pm = resolve.GetProjectManager()
project = pm.GetCurrentProject()
if project:
print(f" Current project: {project.GetName()}")
else:
print(" No project currently open")
print("\n Setup OK — ready to use Resolve scripting.")
except ImportError:
print("\n Cannot import DaVinciResolveScript module.")
print(" Check that DaVinci Resolve Studio is properly installed.")
sys.exit(1)
except Exception as e:
print(f"\n Error connecting: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
"""Test each Resolve API operation individually on a short test project.
Creates a 10-second test project and verifies:
1. Project creation + media import
2. Timeline creation + clip append with subclips
3. Adding a second video track + placing a title at a specific time
4. Setting the title text via Fusion comp
5. Adding image overlay on track 3 at a specific position
6. Adding timeline markers
"""
import os
import sys
import time
# Setup Resolve scripting module
SCRIPT_MODULES = "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting/Modules"
if SCRIPT_MODULES not in sys.path:
sys.path.insert(0, SCRIPT_MODULES)
import DaVinciResolveScript as dvr
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
SOURCE_VIDEO = os.path.join(PROJECT_ROOT, "raw", "My workflow with Just, Uv Scripts, air, and Agents.mp4")
TEST_PROJECT = "_API_Test"
def connect():
resolve = dvr.scriptapp("Resolve")
if not resolve:
raise RuntimeError("Cannot connect to Resolve. Is it running?")
pm = resolve.GetProjectManager()
return resolve, pm
def cleanup(pm):
"""Delete test project if it exists."""
existing = pm.LoadProject(TEST_PROJECT)
if existing:
pm.CloseProject(existing)
# Need a temp project to delete the test one
pm.CreateProject("_temp_cleanup")
pm.DeleteProject(TEST_PROJECT)
pm.CloseProject(pm.GetCurrentProject())
pm.DeleteProject("_temp_cleanup")
def test_1_project_and_import(resolve, pm):
"""Test: Create project, import media."""
print("\n=== TEST 1: Project creation + media import ===")
resolve.OpenPage("edit")
project = pm.CreateProject(TEST_PROJECT)
assert project, "Failed to create project"
print(f" Created: {project.GetName()}")
# Set to 30fps 1920x1080 for test
project.SetSetting("timelineFrameRate", "30")
project.SetSetting("timelineResolutionWidth", "1920")
project.SetSetting("timelineResolutionHeight", "1080")
media_pool = project.GetMediaPool()
items = media_pool.ImportMedia([SOURCE_VIDEO])
assert items and len(items) > 0, "Failed to import media"
source = items[0]
print(f" Imported: {source.GetName()}")
return project, media_pool, source
def test_2_timeline_and_clips(project, media_pool, source):
"""Test: Create timeline, append two subclips."""
print("\n=== TEST 2: Timeline + subclip append ===")
timeline = media_pool.CreateEmptyTimeline("Test Timeline")
assert timeline, "Failed to create timeline"
project.SetCurrentTimeline(timeline)
print(f" Created timeline: {timeline.GetName()}")
# Append two 5-second clips (frames 180-330 and 900-1050 at 30fps)
# Clip 1: 6s-11s of source (frames 180-330)
# Clip 2: 30s-35s of source (frames 900-1050)
clips = []
for start_f, end_f, label in [(180, 330, "Clip A"), (900, 1050, "Clip B")]:
result = media_pool.AppendToTimeline([{
"mediaPoolItem": source,
"startFrame": start_f,
"endFrame": end_f,
"mediaType": 1, # 1 = video+audio
}])
assert result, f"Failed to append {label}"
clips.extend(result)
print(f" Appended {label}: frames {start_f}-{end_f}")
# Verify
track1_items = timeline.GetItemListInTrack("video", 1)
print(f" Track 1 items: {len(track1_items)}")
for item in track1_items:
print(f" {item.GetName()} @ timeline frames {item.GetStart()}-{item.GetEnd()}")
audio1_items = timeline.GetItemListInTrack("audio", 1)
print(f" Audio track 1 items: {len(audio1_items) if audio1_items else 0}")
return timeline, clips
def test_3_title_at_position(resolve, timeline):
"""Test: Insert Text+ title at a specific timeline position."""
print("\n=== TEST 3: Insert title at specific position ===")
# We want the title at frame 30 (1 second into timeline)
target_frame = 30
# Move playhead to target position using timecode
# At 30fps, frame 30 = 00:00:01:00
fps = 30
hours = 0
minutes = 0
seconds = target_frame // fps
frames = target_frame % fps
tc = f"{hours:02d}:{minutes:02d}:{seconds:02d}:{frames:02d}"
print(f" Setting playhead to timecode: {tc} (frame {target_frame})")
result = timeline.SetCurrentTimecode(tc)
print(f" SetCurrentTimecode result: {result}")
print(f" Current timecode: {timeline.GetCurrentTimecode()}")
time.sleep(0.3)
# Insert title
title_item = timeline.InsertFusionTitleIntoTimeline("Text+")
print(f" InsertFusionTitleIntoTimeline result: {title_item}")
# Check what tracks we have now
track_count = timeline.GetTrackCount("video")
print(f" Video tracks after insert: {track_count}")
for t in range(1, track_count + 1):
items = timeline.GetItemListInTrack("video", t)
if items:
for item in items:
print(f" Track {t}: {item.GetName()} @ {item.GetStart()}-{item.GetEnd()}")
return title_item
def test_4_set_title_text(timeline):
"""Test: Set the text content of the title via Fusion comp."""
print("\n=== TEST 4: Set title text via Fusion ===")
# Find the Text+ item
track_count = timeline.GetTrackCount("video")
title_item = None
for t in range(1, track_count + 1):
items = timeline.GetItemListInTrack("video", t)
if items:
for item in items:
if "Text" in (item.GetName() or ""):
title_item = item
break
if title_item:
break
if not title_item:
print(" ERROR: No title item found!")
return
print(f" Found title: {title_item.GetName()} on track")
print(f" Fusion comp count: {title_item.GetFusionCompCount()}")
comp = title_item.GetFusionCompByIndex(1)
if not comp:
print(" ERROR: No Fusion comp found!")
return
tools = comp.GetToolList(False)
print(f" Tools in comp: {list(tools.keys())}")
for tool_name, tool in tools.items():
attrs = tool.GetAttrs()
reg_id = attrs.get("TOOLS_RegID", "")
print(f" Tool: {tool_name}, RegID: {reg_id}")
if reg_id == "TextPlus":
# Get current text
current = tool.GetInput("StyledText")
print(f" Current text: '{current}'")
# Set new text
tool.SetInput("StyledText", "Hello from API!")
new_text = tool.GetInput("StyledText")
print(f" After SetInput: '{new_text}'")
# Try setting font and size
tool.SetInput("Font", "Open Sans")
tool.SetInput("Style", "Bold")
tool.SetInput("Size", 0.08)
# Set position (Center is {1: x, 2: y} in 0-1 range)
tool.SetInput("Center", {1: 0.5, 2: 0.1}) # Bottom center
print(f" Font: {tool.GetInput('Font')}")
print(f" Size: {tool.GetInput('Size')}")
print(f" Center: {tool.GetInput('Center')}")
break
def test_5_image_overlay(project, media_pool, timeline):
"""Test: Add an image as overlay on track 3."""
print("\n=== TEST 5: Image overlay on track 3 ===")
# Use one of the existing overlay assets
img_path = os.path.join(PROJECT_ROOT, "claude-edits", "overlays", "assets", "mention_000.png")
if not os.path.exists(img_path):
print(f" SKIP: Image not found at {img_path}")
return
# Add video track if needed
while timeline.GetTrackCount("video") < 3:
timeline.AddTrack("video")
print(f" Video tracks: {timeline.GetTrackCount('video')}")
# Import image
img_items = media_pool.ImportMedia([img_path])
assert img_items, "Failed to import image"
print(f" Imported image: {img_items[0].GetName()}")
# Place on track 3 at frame 60 (2 seconds in), for 90 frames (3 seconds)
result = media_pool.AppendToTimeline([{
"mediaPoolItem": img_items[0],
"startFrame": 0,
"endFrame": 90,
"trackIndex": 3,
"recordFrame": 60,
"mediaType": 1,
}])
print(f" AppendToTimeline result: {result}")
if result:
item = result[0]
print(f" Placed: {item.GetName()} @ {item.GetStart()}-{item.GetEnd()}")
# Set position/scale
item.SetProperty("ZoomX", 0.3)
item.SetProperty("ZoomY", 0.3)
item.SetProperty("Pan", 500.0) # Right side
item.SetProperty("Tilt", -400.0) # Lower
props = item.GetProperty()
print(f" ZoomX: {props.get('ZoomX')}, Pan: {props.get('Pan')}, Tilt: {props.get('Tilt')}")
# Check track 3
t3_items = timeline.GetItemListInTrack("video", 3)
print(f" Track 3 items: {len(t3_items) if t3_items else 0}")
def test_6_markers(timeline):
"""Test: Add markers to timeline."""
print("\n=== TEST 6: Timeline markers ===")
timeline.AddMarker(0, "Blue", "Chapter 1: Intro", "", 1)
timeline.AddMarker(150, "Green", "Chapter 2: Middle", "", 1)
markers = timeline.GetMarkers()
print(f" Markers: {markers}")
def main():
print("DaVinci Resolve API Test Suite")
print("=" * 50)
resolve, pm = connect()
print(f"Connected: {resolve.GetProductName()} {resolve.GetVersionString()}")
cleanup(pm)
project, media_pool, source = test_1_project_and_import(resolve, pm)
timeline, clips = test_2_timeline_and_clips(project, media_pool, source)
test_3_title_at_position(resolve, timeline)
test_4_set_title_text(timeline)
test_5_image_overlay(project, media_pool, timeline)
test_6_markers(timeline)
print("\n" + "=" * 50)
print("All tests complete. Check the '_API_Test' project in Resolve.")
print("You should see:")
print(" - Track 1: Two 5-second video clips with audio")
print(" - Track 2: A Text+ title saying 'Hello from API!' at ~1s")
print(" - Track 3: An image overlay at ~2s")
print(" - Two chapter markers (blue at 0s, green at 5s)")
if __name__ == "__main__":
main()