Python library¶
The library is the source of truth — the CLI, daemon, and MCP server are all thin shells over it.
Connecting¶
from dvr import Resolve
r = Resolve() # auto-launches Resolve if needed
r = Resolve(auto_launch=False) # raise if Resolve isn't running
r = Resolve(timeout=60) # wait up to 60s for the connection
The connection handles macOS's LAN-IP quirk and timeouts every underlying API call so a hung Resolve can't deadlock you.
Domain accessors¶
r.app # page, version, product, UI/burn-in/preference presets, quit
r.project # list/attributes, current, create, load, ensure, delete, archive, export, import
r.timeline # list, current, get, ensure, create, switch, delete (project-scoped)
r.render # queue, presets, formats, codecs, submit, watch, status, stop
r.storage # filesystem-side: volumes, file lists, bulk import
Within a project:
project = r.project.ensure("MyShow")
project.timeline # same as r.timeline once project is current
project.media # MediaPool
project.gallery # stills, PowerGrades
project.set_setting(key, value)
Within a timeline:
tl = r.timeline.current
tl.tracks("video") # all video tracks
tl.track("video", 2) # V2
tl.clips("video") # ClipQuery over all video clips
tl.clips("video").where(lambda c: c.duration > 48)
tl.selected_clips() # Resolve 21 selected timeline items, with track metadata
tl.markers() # {frame: {...}}
tl.add_marker(120, color="Red", name="check sync")
Within a clip:
clip = tl.clips("video").first()
clip.inspect() # full state
clip.set_property("Pan", 0.25) # transform/composite/retime
clip.set_properties({"crop_top": 120, "blend": "multiply"})
clip.edit.transform(pan=40, zoom=1.1, rotation=2)
clip.edit.crop(top=120, bottom=120, retain=True)
tl.clips("video").where(lambda c: c.track_index == 2).crop(top=80, bottom=80)
clip.color.set_cdl(slope=(1, 1, 1, 0.95)) # color page operations
clip.color.export_lut("/Volumes/luts/grade.cube", size=33)
clip.fusion.add() # add a Fusion comp
clip.takes.add(asset) # alternate takes
clip.replace("/Volumes/new_source.mov") # relink, preserves grades
set_properties() accepts documented Resolve TimelineItem.SetProperty
keys plus DVR aliases (crop_top, blend, zoom, resize_filter, etc.)
and coerces enum names to Resolve's integer constants. It covers static
transform, crop, composite, dynamic zoom ease, retime quality, scaling, and
resize filter controls. Resolve does not expose reliable APIs for edit-page
transitions or general transform keyframes, so DVR reports those as
unsupported capabilities instead of fabricating them.
Text and titles¶
Insert a Fusion title (the default is the built-in Text+) and style it in
one call. The styling lives on TimelineItem.text, which targets the item's
TextPlus Fusion node:
tl.current_timecode = "01:00:02:00" # place at the playhead
item = tl.insert_title(
"Text+",
text="OPENING TITLE",
font="Open Sans",
style="Bold",
size=0.12,
color="#ffcc00", # hex, name, or (r, g, b[, a])
align="center",
vertical_align="center",
)
# Re-style or read existing titles.
item.text.set(text="REVISED", color="white", position=(0.5, 0.25))
item.text.value # current string
item.text.properties() # snapshot of editable inputs
item.is_text # True for Text+ items
generate_speech accepts the typed SpeechGenerationSettings payload,
including voice/custom-voice, speed, variation, pitch, generation ID,
filename, and timeline placement. create_subtitles_from_audio drives the
Whisper captioner:
project.generate_speech(
{
"TextInput": "Welcome back.",
"VoiceModel": "Female 1",
"Variation": 2,
"Speed": 1.0,
},
"01:00:00:00",
)
tl.create_subtitles_from_audio(language="en", chars_per_line=42)
Resolve 21.0.4 render additions pass through the typed RenderSettings
payload: UseFullExtents, AddFrameHandles, and DataBurnIn.
Idempotent context managers¶
with r.project.use("MyShow") as project:
with project.timeline.use("Edit_v2") as tl:
# ...
pass
# previous project + timeline restored on exit
# Scoped project setting flips — restored on exit, even on exception.
with project.setting_context("colorAcesODT", "Rec.709 BT.1886"):
r.render.submit_and_wait(...)
# previous colorAcesODT value restored
Querying¶
# Find clips matching a predicate.
short_clips = tl.clips("video").where(lambda c: c.duration < 24)
print(len(short_clips))
for clip in short_clips:
clip.add_marker(color="Red", name="too short")
# Compose queries.
v2_long = tl.clips("video").where(lambda c: c.track_index == 2 and c.duration > 48)
Renders¶
job = r.render.submit(
target_dir="/Volumes/out",
custom_name="MyShow_v2",
format="mov",
codec="ProRes4444XQ",
)
job.wait() # block with stall detection
print(job.output_path)
# Or stream events:
for event in r.render.watch([job.id]):
print(event)
# One-shot: submit, block, return the rendered path.
output = r.render.submit_and_wait(
target_dir="/Volumes/out",
custom_name="MyShow_v2",
format="mov",
codec="ProRes4444XQ",
)
# Normalized status snapshot — same payload as RenderJob.poll().
snap = r.render.status(job.id)
print(snap["status"], snap["percent"], snap["error"])
# Safe between shots — bounded queue cleanup with timeout, even after
# image-sequence (EXR / DPX) jobs leave the queue stuck at 100%.
r.render.clear()
Media imports¶
# Idempotent: returns the existing pool clip if the path is already
# imported, otherwise imports it. Useful when many shots come from one
# master and you don't want a duplicate Media Pool entry per shot.
clip = project.media.find_or_import("/Volumes/raw/master_v003.mov")
# IMF (Interoperable Master Format) — pass the OV folder, not the CPL.
clips = project.media.import_imf("/Volumes/deliveries/IMF_OV/")
# Nested bin paths: resolve or create "A/B/C" in one call.
dailies = project.media.ensure_folder_path("Footage/Day01/Dailies")
existing = project.media.find_folder_path("Footage/Day01")
# Preview what a bulk import would pick up — no Resolve needed.
from dvr.media import scan_media_files
files = scan_media_files("/Volumes/Card01", recursive=True)
Interchange¶
from dvr import interchange
interchange.export(tl, "out.fcpxml", format="fcpxml-1.10")
interchange.export(tl, "out.edl", format="edl-cdl")
interchange.export(tl, "out.aaf", format="aaf")
print(interchange.export_formats()) # all 21 supported names
new_tl = interchange.import_(project.media, "incoming.aaf")
Audio and Gallery¶
from dvr import audio, gallery
audio.set_voice_isolation(tl, enabled=True, amount=70)
audio.apply_fairlight_preset(project, "Dialogue Smooth")
g = gallery.gallery_for(project)
album = g.create_still_album("Hero shots")
album.import_stills(["/Volumes/stills/01.png"])
Transactions¶
with r.transaction():
r.project.current.set_setting("timelineFrameRate", "24")
r.timeline.ensure("Edit_v3")
# any DvrError in this block restores the pre-block snapshot
r.transaction() captures a snapshot of the current project on entry and restores it if the block raises a DvrError (the snapshot name is attached to the error's state). Only snapshot-representable state rolls back — settings, bins, timelines, tracks, markers; media imports and renders are not undone. The same machinery backs dvr apply --transactional.
Typed settings¶
s = r.project.require_current().settings
s.timeline_frame_rate = "24" # snake_case -> Resolve keys
s.hdr_mastering_on = True # bools normalized to "1"/"0"
s.color_science_mode = "nope" # SettingsError: valid values listed
s.describe("color_science_mode") # schema metadata for any setting
The attribute map is derived from the dvr.schema catalogs, and enum / bool-string values are validated before the write, so invalid values fail loudly instead of being silently ignored by Resolve. Unknown keys still pass through untouched.
Record / replay (VCR)¶
from dvr import vcr
r = vcr.resolve_from_cassette("session.jsonl") # replays with no Resolve
r.timeline.current.inspect() # served from disk
Every scripting call (method, args, result) is appended to a JSONL cassette; replaying it runs the same library code against the recorded responses — in CI, on machines without Resolve — and raises a structured error on divergence. This captures what Resolve actually returns, where hand-written mocks only capture what we believe it returns.
Errors¶
Every failure raises a subclass of dvr.errors.DvrError. See Errors and diagnostics.
r.project.require_current() returns the current project or raises a structured ProjectError — use it instead of hand-rolling if r.project.current is None checks.
Diagnostics¶
from dvr import doctor
report = doctor.diagnose() # static: paths, process, env
report = doctor.diagnose(probe=True) # also attempts a live connection
The same report backs dvr doctor and the MCP doctor tool.
Type hints¶
The library ships with py.typed and is checked in mypy --strict mode. Domain wrappers that wrap the inherently-Any Resolve handles relax warn_return_any for ergonomics; everywhere else, types are precise.