RESOLVE API / TIMELINE LAB / 01

Create a timeline with the Python API

One new timeline, without a hidden import or a different fallback task.

Understand CreateEmptyTimeline and CreateTimelineFromClips, prepare a reviewed plan and verify the new object. The executable example creates only an empty test timeline, with explicit write authorization.

THE RESULT

A new empty Timeline with a checked ID and an explicit report of the active timeline.

PYTHON / RESOLVESTUDY MODEL
01plan = create_empty(resolve, name)

Read the timeline inventory and bind the target first. A matching name blocks creation; it does not open an existing edit.

CHECK

No mutating calls have been made.

Switching steps changes the explanation. Nothing connects to Resolve or runs on your computer.

01 / BEFORE THE CODE

Set the conditions before you run.

01

Start with a working connection

Use local Python 3.8+ with Resolve Studio. Complete the S01 connection check, then keep the main example and both helpers together. The official Blackmagic module comes from your installed Resolve, not this download.

02

One identifiable target

Open a small test project and the intended timeline. Save changes and do not switch active objects during a read. To create a timeline, select the intended Media Pool folder first; an existing active timeline is not required.

03

Keep the example scope explicit

Enumeration is top-level only, with at most 128 tracks per type and 10,000 items overall. Nested edits are not expanded. Limits are checked after native lists return; they cannot interrupt a blocked API call or bound its memory allocation.

Current Studio edition guidance and the installed scripting documentation take precedence over older console or Free-edition tutorials.[1][4][3]

02 / API CONTRACT

Which object. Which method. Which result.

MediaPoolCreateEmptyTimeline(name)
Input
A unique new timeline name.
Returns
A Timeline object, not a Boolean.
Check
Verify ID, name, project inventory and emptiness.
MediaPoolCreateTimelineFromClips(name, [clips])
Input
A name and existing MediaPoolItems.
Returns
A new Timeline containing clips.
Check
Documented for comparison; not invoked by this example.
ProjectGetTimelineByIndex(index)
Input
An index from 1 through GetTimelineCount().
Returns
A Timeline in the project.
Check
Do not treat position as a permanent ID.
ProjectSetCurrentTimeline(timeline)
Input
A Timeline object.
Returns
Boolean result of selecting a timeline.
Check
A separate action, excluded from the example.

Resolve method contracts are documented by Blackmagic; Python and author helpers are labelled separately. The defensive checks are our example design. A non-empty object is not proof of full API compatibility.[3]

03 / UNDERSTAND

An empty timeline and a clip assembly are different commands

CreateEmptyTimeline(name) belongs to MediaPool, not Project. It creates an edit without a source list. CreateTimelineFromClips(name, [clips]) accepts existing MediaPoolItem objects. An ImportMedia result is not automatically a complete, correctly ordered assembly list.

The previous example silently dropped missing files and created an empty timeline when none remained, changing the requested task. This example only creates an empty edit. S02 covers import and checked assembly separately; brevity is not a reason to combine those writes.[3]

04 / UNDERSTAND

Bind the plan to the project, bin and timeline inventory

Without --apply the script returns project_id, folder_id, active_timeline_id, the existing timeline list and plan_digest. Verify the project and selected bin in the UI. Use values from your own plan; the IDs shown in the command are placeholders, not executable defaults.

The digest binds the proposed name to the observed state. A different bin, active timeline or inventory requires reviewing a new plan. It reduces accidental targeting errors; it is not a cryptographic authorization, a transaction or a concurrency-control mechanism.[3]

05 / UNDERSTAND

Creating, activating and saving are three separate actions

The returned Timeline and project.GetCurrentTimeline() are different concepts. The example does not assume that creation makes the object active; it reports the observed active_timeline_id. Project.SetCurrentTimeline(timeline) is a separate Boolean-returning method and is deliberately not called here.

FPS, starting timecode and resolution are not changed implicitly either. Check the new timeline settings in Resolve. The saved_by_script: false field means the example did not call SaveProject; application autosave can still operate independently according to your settings.[3]

06 / UNDERSTAND

When you must not repeat creation

After CreateEmptyTimeline is attempted, any verification failure becomes OutcomeUnknown. The timeline may exist even if the response or readback failed. Inspect the project and inventory before doing anything else. Loading a project, creating another name or deleting an object is not an automatic repair.

A positive result only means that the compared inventories show the expected new object and its checked tracks are empty. It does not prove persistence to disk, absence of concurrent changes or readiness for import. Check those conditions separately before proceeding.[3]

PYTHON / COMPLETE EXAMPLE

Read the code. Then run it deliberately.

Three files in the same folder

Download create_timeline.py, resolve_api_timeline.py and resolve_api_connection.py into one folder. The bundle contains all seven examples, both helpers and RU/EN instructions. The helpers are fully visible below. For interval calculations, the assumptions are part of the input, not a verified image analysis.

Download all examples + instructions (.zip) ↓
create_timeline.py
"""Create ONE empty test timeline. Default: inspect a plan, without writes."""
import argparse
from resolve_api_connection import connect
from resolve_api_timeline import create_empty, dumps


def main(argv=None):
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("name", help="Unique new timeline name")
    p.add_argument("--apply", action="store_true")
    p.add_argument("--project-id")
    p.add_argument("--folder-id")
    p.add_argument("--plan-digest")
    a = p.parse_args(argv)
    result = create_empty(connect(), a.name, a.apply, a.project_id, a.folder_id, a.plan_digest)
    print(dumps(result))
    # No import, SetCurrentTimeline, SetSetting, SaveProject or cleanup.
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Timeline helper — enumeration, boundaries and interval model

Author code, not the SDK. Structural reads compare two observations; they do not lock the edit. create_empty is the only function that changes Resolve. export_map creates an explicitly requested local file. v1_segments never certifies pixel visibility.

resolve_api_timeline.py
"""S03 tutorial helpers. Python 3.8+. Read-only except create_empty().

A double read detects some changes, not every concurrent edit. No atomic snapshot,
UI lock, native-call timeout, pixel analysis, source retime mapping, or live certification.
Limits apply after each native list call; they cannot bound its memory/time cost.
"""
from __future__ import annotations

from datetime import datetime, timezone
from fractions import Fraction
import hashlib
import json
import math
from pathlib import Path
import re

TRACK_TYPES = ("video", "audio", "subtitle")
MAX_TRACKS = 128
MAX_ITEMS = 10000
MAX_TIMELINES = 1000


class InspectionError(RuntimeError):
    """An observation cannot be trusted or a requested target is ambiguous."""


class OutcomeUnknown(InspectionError):
    """A write was attempted. Inspect Resolve; do not retry or roll back blindly."""


def call(obj, method, *args):
    fn = getattr(obj, method, None)
    if not callable(fn):
        raise InspectionError("Required method unavailable: " + method)
    return fn(*args)  # Propagate native failure; never try an invented fallback.


def text(value, label):
    if not isinstance(value, str) or not value.strip():
        raise InspectionError(label + " is not a non-empty string")
    return value


def uid(obj):
    return text(call(obj, "GetUniqueId"), "unique ID")


def number(value):
    if type(value) not in (int, float) or not math.isfinite(value) or abs(value) > 2**53 - 1:
        raise InspectionError("Expected finite frame value within the exact integer range")
    return value


def count(value, cap):
    if type(value) is not int or not 0 <= value <= cap:
        raise InspectionError("Invalid count or tutorial scope limit exceeded")
    return value


def items(value, cap=MAX_ITEMS):
    if not isinstance(value, (list, tuple)) or len(value) > cap:
        raise InspectionError("Expected a bounded list; None is not an empty list")
    if any(x is None for x in value):
        raise InspectionError("Null object in native list")
    return list(value)


def boolean(value):
    if type(value) is not bool:
        raise InspectionError("Unknown enabled state; do not coerce to True/False")
    return value


def dumps(value):
    return json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False)


def digest(value):
    data = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
    return hashlib.sha256(data.encode("utf-8")).hexdigest()


def parse_rate(raw):
    """Describe the numeric text exactly; never infer 24000/1001 from 23.976."""
    result = {"raw": raw if (isinstance(raw, str) or type(raw) is int or (type(raw) is float and math.isfinite(raw))) else None,
              "status": "unavailable", "label_fraction": None,
              "drop_frame_suffix": "unknown", "physical_timebase": "not_inferred"}
    if not isinstance(raw, str) or not raw.strip():
        return result
    result["status"] = "unrecognized"
    if len(raw) > 64:
        return result
    m = re.fullmatch(r"\s*(\d+(?:\.\d+)?|\d+/\d+)\s*(DF)?\s*", raw)
    if not m:
        return result
    try:
        rate = Fraction(m.group(1))
    except (ValueError, ZeroDivisionError):
        return result
    if not 0 < rate <= 1000:
        return result
    result.update(status="parsed_label", label_fraction={"numerator": rate.numerator, "denominator": rate.denominator},
                  drop_frame_suffix="present" if m.group(2) else "not_marked")
    return result


def project_context(resolve):
    manager = call(resolve, "GetProjectManager")
    if manager is None:
        raise InspectionError("Project Manager unavailable")
    project = call(manager, "GetCurrentProject")
    if project is None:
        raise InspectionError("Open a test project first")
    return manager, project


def timeline_context(resolve):
    manager, project = project_context(resolve)
    timeline = call(project, "GetCurrentTimeline")
    if timeline is None:
        raise InspectionError("Open the intended timeline; no automatic selection")
    return manager, project, timeline


def assert_current(manager, project_id, timeline_id):
    current = call(manager, "GetCurrentProject")
    if current is None or uid(current) != project_id:
        raise InspectionError("Active project changed during the read")
    active = call(current, "GetCurrentTimeline")
    if active is None or uid(active) != timeline_id:
        raise InspectionError("Active timeline changed during the read")


def read_rates(resolve):
    manager, project, timeline = timeline_context(resolve)
    identity = (uid(project), uid(timeline))
    def read():
        # Read the two owners independently. Project is never a silent fallback.
        return {"timeline": parse_rate(call(timeline, "GetSetting", "timelineFrameRate")),
                "project": parse_rate(call(project, "GetSetting", "timelineFrameRate"))}
    first, second = read(), read()
    assert_current(manager, *identity)
    if first != second:
        raise InspectionError("Rate settings changed between observations")
    return {"project_id": identity[0], "timeline_id": identity[1], **second,
            "status": "observed_not_timecode_calibrated", "consistency": "two_equal_reads_not_atomic"}


def collect_once(resolve, project, timeline, track_types):
    issues = []
    def observe(label, operation):
        try:
            return operation()
        except Exception as exc:
            issues.append({"field": label, "error_type": type(exc).__name__})
            return None
    result = {"schema_version": "resolve-timeline-map/1", "project_id": uid(project),
              "project_name": text(call(project, "GetName"), "project name"),
              "timeline_id": uid(timeline), "timeline_name": text(call(timeline, "GetName"), "timeline name"),
              "resolve_version": text(call(resolve, "GetVersionString"), "Resolve version"),
              "scope": {"track_types": list(track_types), "depth": "top_level_only", "source_paths": "not_read"},
              "units": {"positions": "timeline_frames", "ranges": "half_open_start_plus_duration", "subframes": True},
              "timeline_start_frame": number(call(timeline, "GetStartFrame")),
              "timeline_end_frame_raw": number(call(timeline, "GetEndFrame")),
              "start_timecode_raw": text(call(timeline, "GetStartTimecode"), "start timecode"),
              "rate": parse_rate(call(timeline, "GetSetting", "timelineFrameRate")), "tracks": [], "issues": issues}
    seen = set()
    total = 0
    for kind in track_types:
        n = count(call(timeline, "GetTrackCount", kind), MAX_TRACKS)
        for index in range(1, n + 1):
            key = kind + ":" + str(index)
            track = {"type": kind, "index": index,
                     "name": observe(key+".name", lambda: text(call(timeline, "GetTrackName", kind, index), "track name")),
                     "enabled": observe(key+".enabled", lambda: boolean(call(timeline, "GetIsTrackEnabled", kind, index))),
                     "items": []}
            # Failure to enumerate is fatal: an unknown track cannot become empty.
            native_items = items(call(timeline, "GetItemListInTrack", kind, index))
            total += len(native_items)
            count(total, MAX_ITEMS)
            for item in native_items:
                item_id = uid(item)
                if item_id in seen:
                    raise InspectionError("Duplicate TimelineItem ID in enumeration")
                seen.add(item_id)
                row = {"id": item_id, "track_type": kind, "track_index": index}
                row["name"] = observe(item_id+".name", lambda: text(call(item, "GetName"), "item name"))
                row["enabled"] = observe(item_id+".enabled", lambda: boolean(call(item, "GetClipEnabled")))
                pair = observe(item_id+".track", lambda: items(call(item, "GetTrackTypeAndIndex"), 2))
                row["reported_track"] = pair
                if pair is not None and (len(pair) != 2 or type(pair[1]) is not int or pair != [kind, index]):
                    issues.append({"field": item_id+".track", "error_type": "TrackMismatch"})
                for field, method in (("start_raw", "GetStart"), ("end_raw", "GetEnd"), ("duration_raw", "GetDuration")):
                    row[field] = observe(item_id+"."+field, lambda method=method: number(call(item, method, True)))
                a, b, d = row["start_raw"], row["end_raw"], row["duration_raw"]
                row["range"] = None
                if a is not None and b is not None and d is not None:
                    # This is a checked model, not a claim about every API endpoint's end convention.
                    if d <= 0 or b <= a or not math.isclose(b-a, d, rel_tol=0, abs_tol=1e-7):
                        issues.append({"field": item_id+".range", "error_type": "UnqualifiedBoundaryConvention"})
                    else:
                        row["range"] = {"start": a, "end_exclusive": number(a+d),
                                        "offset_from_timeline_start": number(a-result["timeline_start_frame"])}
                def source():
                    media = call(item, "GetMediaPoolItem")
                    return {"status": "none"} if media is None else {"status": "linked", "media_pool_id": uid(media)}
                row["source"] = observe(item_id+".source", source)
                track["items"].append(row)
            track["items"].sort(key=lambda row: (row["start_raw"] is None, row["start_raw"] or 0, row["id"]))
            result["tracks"].append(track)
    result["item_count"] = total
    result["complete"] = not issues
    result["rate_usable_as_label"] = result["rate"]["status"] == "parsed_label"
    return result


def collect_map(resolve, track_types=TRACK_TYPES):
    kinds = tuple(track_types)
    if not kinds or len(set(kinds)) != len(kinds) or any(x not in TRACK_TYPES for x in kinds):
        raise InspectionError("Select distinct documented track types")
    manager, project, timeline = timeline_context(resolve)
    identity = uid(project), uid(timeline)
    first = collect_once(resolve, project, timeline, kinds)
    assert_current(manager, *identity)
    second = collect_once(resolve, project, timeline, kinds)
    assert_current(manager, *identity)
    if first != second:
        raise InspectionError("Timeline changed or reads disagree; no stable report produced")
    second["consistency"] = "two_equal_reads_not_atomic"
    second["captured_at_utc"] = datetime.now(timezone.utc).isoformat()
    second["limitations"] = ["No nested timeline expansion, source-time mapping or compositing analysis",
                             "Equal reads do not prove no intervening edits or provide a database revision"]
    return second


def locate_item(report, item_id):
    matches = [x for t in report["tracks"] for x in t["items"] if x["id"] == item_id]
    if len(matches) != 1:
        raise InspectionError("Item ID must identify exactly one entry in the observed scope")
    row = matches[0]
    pair = row["reported_track"]
    if not isinstance(pair, (list, tuple)) or len(pair) != 2 or type(pair[1]) is not int or list(pair) != [row["track_type"], row["track_index"]]:
        raise InspectionError("Reported track was not verified")
    return row


def export_map(report, output):
    """Explicit local file write, never overwrite. Does not change Resolve."""
    if report.get("complete") is not True or report.get("issues") or report.get("consistency") != "two_equal_reads_not_atomic":
        raise InspectionError("Refusing an incomplete/unmatched map as a complete export")
    path = Path(output).expanduser()
    if not path.is_absolute() or not path.parent.is_dir() or path.suffix.lower() != ".json":
        raise InspectionError("Use an absolute .json path in an existing trusted directory")
    payload = (dumps(report) + "\n").encode("utf-8")  # Validate before opening output.
    if len(payload) > 16 * 1024 * 1024:
        raise InspectionError("Export exceeds tutorial size limit")
    # Exclusive create handles existing files and final-component symlinks without overwrite.
    # A disk failure may leave a partial new file. No silent deletion/repair follows.
    with path.open("xb") as stream:
        stream.write(payload)
        stream.flush()
    if path.read_bytes() != payload:
        raise InspectionError("Export readback differs; inspect the new file")
    return {"status": "written_and_read_back", "path": str(path), "bytes": len(payload),
            "sha256": hashlib.sha256(payload).hexdigest(), "restorable_project_backup": False}


def subtract_ranges(base, covers):
    """Half-open interval subtraction. Pure math, not visibility detection."""
    start, end = map(number, base)
    if end <= start:
        raise InspectionError("Base interval must have positive length")
    clipped = []
    for a, b in covers:
        number(a); number(b)
        if b <= a:
            raise InspectionError("Cover interval must have positive length")
        lo, hi = max(start, a), min(end, b)
        if lo < hi:
            clipped.append((lo, hi))
    merged = []
    for a, b in sorted(clipped):
        if merged and a <= merged[-1][1]:
            merged[-1] = (merged[-1][0], max(merged[-1][1], b))
        else:
            merged.append((a, b))
    cursor, remaining = start, []
    for a, b in merged:
        if cursor < a:
            remaining.append([cursor, a])
        cursor = max(cursor, b)
    if cursor < end:
        remaining.append([cursor, end])
    return remaining


def v1_segments(report, opaque_v2_model=False):
    if report.get("complete") is not True or report.get("issues"):
        raise InspectionError("Cannot calculate from incomplete observations")
    tracks = {t["index"]: t for t in report["tracks"] if t["type"] == "video"}
    if 1 not in tracks:
        raise InspectionError("No V1 in this scope")
    if report.get("scope", {}).get("track_types") != ["video"]:
        raise InspectionError("Use an explicit video-only observation")
    for idx, track in tracks.items():
        boolean(track["enabled"])
        if idx > 2 and track["enabled"] and any(boolean(x["enabled"]) for x in track["items"]):
            raise InspectionError("Active V3+ items are outside the two-layer tutorial model")
    result = {"status": "model_not_confirmed", "pixel_visibility_verified": False,
              "model": "opaque_full_frame_V2_hard_cuts_only", "segments": [],
              "assumptions": ["No alpha, opacity animation, crop, transform, transition or compositing effects",
                              "No adjustment clips, nested compositions or other visible layers"]}
    if not opaque_v2_model:
        return result  # No numeric result that could be mistaken for actual visibility.
    base_track = tracks[1]
    if not base_track["enabled"]:
        result.update(status="conditional_model_result", reason="V1_disabled")
        return result
    upper = tracks.get(2)
    covers = []
    if upper and upper["enabled"]:
        for row in upper["items"]:
            if boolean(row["enabled"]):
                if row["range"] is None:
                    raise InspectionError("Unqualified V2 range")
                covers.append((row["range"]["start"], row["range"]["end_exclusive"]))
    for row in base_track["items"]:
        if not boolean(row["enabled"]):
            continue
        if row["range"] is None:
            raise InspectionError("Unqualified V1 range")
        spans = subtract_ranges((row["range"]["start"], row["range"]["end_exclusive"]), covers)
        result["segments"].append({"item_id": row["id"], "timeline_ranges": spans})
    result["status"] = "conditional_model_result"
    return result


def creation_state(resolve, name):
    manager, project = project_context(resolve)
    pool = call(project, "GetMediaPool")
    folder = call(pool, "GetCurrentFolder")
    if folder is None:
        raise InspectionError("Choose a Media Pool folder")
    n = count(call(project, "GetTimelineCount"), MAX_TIMELINES)
    timelines = []
    for i in range(1, n+1):
        t = call(project, "GetTimelineByIndex", i)
        if t is None:
            raise InspectionError("Timeline enumeration incomplete")
        timelines.append({"id": uid(t), "name": text(call(t, "GetName"), "timeline name")})
    if len({x["id"] for x in timelines}) != n:
        raise InspectionError("Repeated timeline ID")
    active = call(project, "GetCurrentTimeline")
    state = {"name": name, "project_id": uid(project), "folder_id": uid(folder),
             "active_timeline_id": uid(active) if active is not None else None,
             "timelines": sorted(timelines, key=lambda x: x["id"])}
    if uid(call(manager, "GetCurrentProject")) != state["project_id"]:
        raise InspectionError("Project changed")
    return state, pool


def create_empty(resolve, name, apply=False, project_id=None, folder_id=None, plan_digest=None):
    text(name, "new timeline name")
    if len(name) > 128 or name != name.strip() or any(ord(c) < 32 for c in name):
        raise InspectionError("Use a trimmed name, at most 128 characters, without control characters")
    before, pool = creation_state(resolve, name)
    again, _ = creation_state(resolve, name)
    if before != again:
        raise InspectionError("Creation plan changed between reads")
    if any(x["name"].casefold() == name.casefold() for x in before["timelines"]):
        raise InspectionError("Name already exists; no create-or-open fallback")
    token = digest(before)
    if not apply:
        return {"status": "plan_only", "plan_digest": token, **before}
    if (project_id, folder_id, plan_digest) != (before["project_id"], before["folder_id"], token):
        raise InspectionError("Explicit project/folder IDs and matching reviewed digest required")
    latest, pool = creation_state(resolve, name)
    if latest != before:
        raise InspectionError("State changed just before create")
    try:
        created = call(pool, "CreateEmptyTimeline", name)
        if created is None:
            raise InspectionError("CreateEmptyTimeline returned no object")
        new_id = uid(created)
        after, _ = creation_state(resolve, name)
        old = before["timelines"]
        expected = sorted(old+[{"id": new_id, "name": name}], key=lambda x: x["id"])
        if new_id in {x["id"] for x in old} or after["timelines"] != expected:
            raise InspectionError("Timeline inventory does not match exactly one new timeline")
        if (after["project_id"], after["folder_id"]) != (project_id, folder_id):
            raise InspectionError("Target changed during create")
        if after["active_timeline_id"] not in (before["active_timeline_id"], new_id):
            raise InspectionError("Unexpected active timeline after create")
        for kind in TRACK_TYPES:
            for index in range(1, count(call(created, "GetTrackCount", kind), MAX_TRACKS)+1):
                if items(call(created, "GetItemListInTrack", kind, index)):
                    raise InspectionError("New timeline is not empty")
        return {"status": "created_empty_readback_confirmed", "timeline_id": new_id,
                "active_timeline_id": after["active_timeline_id"], "saved_by_script": False}
    except Exception as exc:
        raise OutcomeUnknown("Create attempted; inspect the project before any further write") from exc

Shared connection helper — inspect and download

This file only locates and loads the installed bridge. Native imports can write Python bytecode caches and can block; this is not a sandbox or a timeout mechanism. Use a trusted local installation.

resolve_api_connection.py
"""Load the installed Blackmagic module; do not install a similarly named package.

This helper uses standard desktop installation paths or RESOLVE_SCRIPT_API.
It does not enable scripting, start Resolve, open projects or retry API calls.
Validate Python/native-library compatibility against your installed README.txt.
"""
from __future__ import annotations

import importlib.util
import os
from pathlib import Path
import struct
import sys


class ResolveConnectionError(RuntimeError):
    """An explicit environment, module, or application-connection failure."""


def scripting_root() -> Path:
    configured = os.environ.get("RESOLVE_SCRIPT_API")
    if configured is not None:
        if not configured.strip() or configured != configured.strip():
            raise ResolveConnectionError("RESOLVE_SCRIPT_API is empty or has outer whitespace")
        if configured.startswith(('"', "'")) or configured.endswith(('"', "'")):
            raise ResolveConnectionError("Do not store literal outer quotes in RESOLVE_SCRIPT_API")
        root = Path(os.path.expandvars(configured)).expanduser()
    elif sys.platform == "win32":
        root = Path(os.environ.get("PROGRAMDATA", r"C:\ProgramData")) / "Blackmagic Design/DaVinci Resolve/Support/Developer/Scripting"
    elif sys.platform == "darwin":
        root = Path("/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting")
    elif sys.platform.startswith("linux"):
        root = Path("/opt/resolve/Developer/Scripting")
    else:
        raise ResolveConnectionError("Set RESOLVE_SCRIPT_API for this platform/installation")
    if not root.is_absolute():
        raise ResolveConnectionError("RESOLVE_SCRIPT_API must be an absolute Scripting directory")
    return root


def module_file() -> Path:
    # RESOLVE_SCRIPT_API points to Scripting, not its Modules subdirectory.
    return scripting_root() / "Modules" / "DaVinciResolveScript.py"


def load_resolve_module():
    if struct.calcsize("P") * 8 != 64:
        raise ResolveConnectionError("Use a compatible 64-bit Python interpreter")
    source = module_file()
    if not source.is_file():
        raise ResolveConnectionError("Official DaVinciResolveScript.py not found at: " + str(source))
    configured_lib = os.environ.get("RESOLVE_SCRIPT_LIB")
    if configured_lib is not None:
        lib = Path(os.path.expandvars(configured_lib)).expanduser()
        if not lib.is_absolute() or not lib.is_file():
            raise ResolveConnectionError("RESOLVE_SCRIPT_LIB must name the installed native library file")
    name = "DaVinciResolveScript"
    spec = importlib.util.spec_from_file_location(name, source)
    if spec is None or spec.loader is None:
        raise ResolveConnectionError("Cannot create a loader for the official module")
    previous = sys.modules.get(name)
    module = importlib.util.module_from_spec(spec)
    sys.modules[name] = module
    try:
        spec.loader.exec_module(module)
        # Blackmagic's wrapper can replace its sys.modules entry with a native module.
        loaded = sys.modules[name]
        if not callable(getattr(loaded, "scriptapp", None)):
            raise ResolveConnectionError("Loaded module does not expose scriptapp")
        return loaded
    except Exception as exc:
        if previous is None:
            sys.modules.pop(name, None)
        else:
            sys.modules[name] = previous
        raise ResolveConnectionError("Official module was found, but loading it failed") from exc


def connect():
    module = load_resolve_module()
    try:
        resolve = module.scriptapp("Resolve")
    except Exception as exc:
        raise ResolveConnectionError("scriptapp failed; inspect the original exception") from exc
    if not resolve:
        raise ResolveConnectionError("No Resolve object: check running Studio, Local access and Python environment")
    return resolve


def project_manager(resolve):
    manager = resolve.GetProjectManager()
    if not manager:
        raise ResolveConnectionError("Resolve connected, but Project Manager is unavailable")
    return manager

Launch commands

Inspect the plan

Inspect the plan
python create_timeline.py "API_S03_Test"

Does not change the project. On Linux/macOS use python3 or the full path of your verified interpreter.

Create after reviewing the plan

Create after reviewing the plan
python create_timeline.py "API_S03_Test" --apply --project-id "PROJECT_ID" --folder-id "FOLDER_ID" --plan-digest "PLAN_DIGEST"

Replace the three placeholders with your own plan values. One call only; do not rerun after an uncertain outcome.

What the code does

  1. Plan

    Read the timeline inventory and bind the target first. A matching name blocks creation; it does not open an existing edit.

  2. Create

    Make one call after --apply, exact IDs and a matching digest. An empty response is not permission to retry.

  3. Verify

    Read back exactly one new ID, the expected name and no clips. The script does not save the project.

  4. Result boundaries

    No import, automatic selection, FPS change, save, deletion or retry after a write.

Boundaries to keep

No import, automatic selection, FPS change, save, deletion or retry after a write.

Two matching reads check observation consistency; they are not an atomic snapshot or a collaborative-editing lock.

JSON can contain project names, clip names and IDs. Source paths are not read, but the other fields are not automatically anonymized.

DEBUG / OBSERVATION FIRST

An error is a starting point, not a diagnosis.

The name already exists

Possible reasonA matching name was found, including a case-only difference.

Next checkChoose a different name or use the existing edit manually.

Plan digest mismatch

Possible reasonThe observed target changed after the plan was reviewed.

Next checkRepeat the read-only plan and review differences; do not bypass the check.

OutcomeUnknown

Possible reasonCreation was attempted but its full outcome is unconfirmed.

Next checkDo not retry creation. Inspect the timeline inventory and any new object.

Created but not selected

Possible reasonActivation was not part of the requested operation.

Next checkVerify the ID and select the intended timeline manually.

PRACTICE / YOUR OWN EVIDENCE

Create one empty practice edit

  1. Save the test project, select a bin and inspect the plan.
  2. Change the selected bin: applying the old plan must be rejected before creation.
  3. Return to the intended target, review a new plan and apply once.
ACCEPT WHEN

Exactly one new Timeline with the expected name and empty tracks; another request with the same name is rejected. Saving is checked separately.

This is a checklist for your test, not a report of a test already performed on the website.

FAQ

Questions that matter in practice.

Can I create a timeline without an open project?

No: MediaPool belongs to a project. S01 covers project creation separately. An active timeline is not required for CreateEmptyTimeline.[3]

Where should I choose FPS?

In Resolve settings before populating the new timeline. This example does not set FPS; the next guide reads the timeline setting separately from Project.[3]

Does the digest authorize any project?

No. Project and bin IDs, name, active target and timeline inventory are checked together. The digest checks a reviewed plan; it grants no independent access rights.[3]

Does the script remove a failed result?

No. Automatic removal could destroy an object created by a partially successful operation. An uncertain outcome requires manual inspection.[3]

SOURCES / VERSION BOUNDARIES

Sources, not implied certification.

The source review, offline Python tests and UI checks are different kinds of evidence. None establishes that every example works with every Studio build, operating system or Python version. The date below is the editorial review date, not a live-test certificate.

  1. Blackmagic Design — DaVinci Resolve Studio 21.1 release notes

    8 September 2026. The release announcement assigns Python scripting to Studio. Edition requirements, not a test report for our examples.

  2. Blackmagic Design — Support Center

    Release catalogue checked on 5 October 2026: 21.1.1 is listed. The latest release is not the minimum version of every method.

  3. Blackmagic Scripting README — snapshot 24 July 2026 (Resolve 21.0.4)

    Public mirror of Blackmagic’s original README, maintained by X-Raym. Used for basic signatures; not official hosting and not a 21.1.1 README. Locate your installed Developer/Scripting/README.txt through Resolve’s Help/Documentation menu; exact menu nesting can vary.

  4. Blackmagic Design — Scripting and Automation

    Official description of Studio Python/Lua scripting and integrations. Do not extend Studio capabilities to Free without checking the exact release.

KEEP BUILDING

Continue with a precise next task.

API / Timeline

Read timeline FPS through the API

Read timelineFrameRate from Timeline and Project independently, preserve the original string and handle fractional labels and DF. Never substitute a rate after failure or guess the underlying timebase.

API / Timeline

Enumerate timeline clips with Python

Enumerate video, audio and subtitle items without hiding empty states or failures. Preserve track indices, occurrence IDs and fractional boundaries, then compare two observations.

API / Projects

How to Get the Current DaVinci Resolve Project in Python

Obtain Project Manager, check the current project name and ID, and read timeline count and the active Timeline. An absent timeline is allowed; implicit loading and project creation are excluded.

Back to the API library →MCP and AI assistants →