Skip to content

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")
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)

DVR_RECORD=session.jsonl dvr timeline inspect   # record real Resolve traffic
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.