RESOLVE API / QUEUE LAB / 03

How to create a Render Job with Python

Queue the job first. Render separately.

One AddRenderJob from manually reviewed Deliver settings: a plan, exact timeline, retained JobId and a before/after queue check without automatic rendering.

THE RESULT

Exactly one new entry is observed, its returned ID is retained, and its settings still require a separate review.

PYTHON / RESOLVESTUDY MODEL
01timeline.GetUniqueId()

The operator reviews settings and the current timeline before creating the entry.

CHECK

Not an accidental active tab.

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

Environment

External Python 3.9+ and a running Resolve Studio with local scripting. Check the interface file supplied with your installed version. This tutorial reviews a published Blackmagic interface mirror on 6 October 2026; it does not claim live certification, minimum-version coverage or Free compatibility.

02

Exact target

Keep an isolated test project and one operator. Project ID and job ID are exact opaque strings, not a queue position, timeline name or file name. Only adding a new job requires the current timeline; reading and managing existing jobs do not.

03

Before any write

Read the plan first. Apply requires an exact target, a matching plan hash and explicit test-copy acknowledgement. For add/start, inspect Deliver and the selected queued job yourself. A hash is not a lock, authority check or complete settings snapshot.

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.

ProjectGetCurrentTimeline()
Input
The project receiving the new job.
Returns
Timeline object or absence.
Check
Exact current timeline ID is required for apply.
ProjectAddRenderJob()
Input
No arguments; previously prepared render settings.
Returns
String JobId.
Check
One call; the ID is never converted to int.
ProjectGetRenderJobList()
Input
Before and after creation.
Returns
List of job information.
Check
Exactly one new entry with prior entries retained.

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.[18]

03 / UNDERSTAND

AddRenderJob uses prepared settings

The method adds a job from current render settings. It does not accept arbitrary settings JSON as its argument or select the source timeline from a filename. Prepare Deliver manually or with a separately verified configuration script, then check the current timeline.

This example does not change format, codec, mode or range. The old SetRenderRange call and IndividualClips/Format entries within SetRenderSettings have been removed from this task. Output configuration is not silently replaced while creating a queue entry; it belongs to the preceding stage.[18]

04 / UNDERSTAND

What the plan proves, and what it cannot know

The plan records project ID, current timeline ID, queue composition and readable states. Its hash binds application to those observations. It does not read every Deliver setting: a complete generic GetRenderSettings is not assumed in the reviewed interface.

--settings-reviewed is an operator acknowledgement, not an automated codec, range, directory or overwrite check. Do not modify Deliver between planning and applying. Even an unchanged plan hash does not establish that the complete settings contents stayed the same.[18]

05 / UNDERSTAND

Retain the returned ID before post-call verification

Immediately after the single AddRenderJob call, a nonempty returned string is retained as returned_job_id. The queue is then read again: this exact ID must be the only new one, with prior entries retained in their order and observed fields. Matching only the total count is insufficient.

job_added_needs_settings_review does not mean rendering started or completed. Inspect the new entry before passing its ID to StartRendering. A missing returned ID or unreadable queue leaves an uncertain outcome; do not automatically create another entry.[18]

06 / UNDERSTAND

Why uncertain entries are not automatically removed

An exception after AddRenderJob does not prove that Resolve did nothing. The helper therefore neither retries nor deletes a guessed result. This avoids introducing a second mutation while the first has not been established.

Keep the report and inspect the selected project’s queue in Resolve. Match the exact returned ID and entry information rather than choosing the last row. Removing an incorrect entry requires a separate, reviewed deletion plan.[18]

PYTHON / COMPLETE EXAMPLE

Read the code. Then run it deliberately.

Three Python files in the same folder

Put add_render_job.py, resolve_api_queue.py and resolve_api_connection.py in one folder. The bundle contains eight entry scripts, both helpers and RU/EN instructions. No official SDK is included; the installed Blackmagic bridge is used.

Download all examples + instructions (.zip) ↓
add_render_job.py
"""Add one job from manually reviewed Deliver settings; never start it."""
import argparse
from resolve_api_connection import connect
from resolve_api_queue import cli_write, write_from_args, print_result


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    cli_write(parser, "add")
    args = parser.parse_args()
    return print_result(lambda: write_from_args(connect(), "add", args))


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

Queue helper — exact IDs, bounded observation and explicit mutations

Author tutorial code, not the SDK. No whole-queue start or purge. StopRendering remains broad. Selected cleanup is sequential and may partially complete; unknown outcomes are never retried. A soft deadline cannot interrupt a blocked native call.

resolve_api_queue.py
"""S07 author tutorial helpers, NOT the Blackmagic SDK.

Plan-only by default. Opaque IDs; no all-queue start, preset changes, directory
creation, project save, auto retry or rollback. StopRendering is project-wide.
Selected cleanup is deliberately sequential and may partially complete.
Matching observations are not a lock or an atomic snapshot. Poll deadlines
cannot interrupt a blocked native API call. Use an idle, local test project
with one operator; remote/background workflows are outside mutation scope.
"""
from __future__ import annotations
import argparse
import copy
import hashlib
import json
import math
import time

MAX_JOBS = 1000      # Tutorial bounds, not Resolve limits.
MAX_DELETE = 25
MAX_POLLS = 10000
LOCAL_STATES = frozenset({'Ready', 'Rendering', 'Complete', 'Cancelled', 'Failed'})
CANCELLED = frozenset({'Cancelled', 'Background Render Cancelled', 'Remote Render Cancelled'})
EXTERNAL = frozenset({'Ready for background render', 'Ready to remotely render'})
VOLATILE = frozenset({'JobStatus', 'CompletionPercentage', 'TimeTakenToRenderInMs',
                     'EstimatedTimeRemainingInMs', 'Error'})


class PreflightError(RuntimeError):
    """No mutation has been sent by the failing preflight step."""


def read(obj, method, *args):
    fn = getattr(obj, method, None)
    if not callable(fn):
        raise PreflightError('Missing method: ' + method)
    try:
        return fn(*args)
    except Exception as exc:
        raise PreflightError('Read failed: ' + method + ' (' + type(exc).__name__ + ')') from exc


def text(value, name):
    if not isinstance(value, str) or not value.strip() or len(value) > 4096:
        raise PreflightError('Invalid ' + name + '; expected an exact nonempty string')
    if any(ord(c) < 32 for c in value):
        raise PreflightError('Control characters in ' + name)
    try:
        value.encode('utf-8')
    except UnicodeError as exc:
        raise PreflightError('Invalid Unicode in ' + name) from exc
    return value  # No trim, lower, int coercion or guessed aliases.


def boolean(value, name):
    if type(value) is not bool:
        raise PreflightError(name + ' must be bool; None is not False')
    return value


def json_value(value, depth=0, budget=None):
    if budget is None:
        budget = [200000]
    budget[0] -= 1
    if depth > 8 or budget[0] < 0:
        raise PreflightError('API result exceeds tutorial depth/size bounds')
    if value is None or type(value) in (bool, int):
        return value
    if isinstance(value, str) and len(value) <= 32768:
        try:
            value.encode('utf-8')
        except UnicodeError as exc:
            raise PreflightError('Invalid API Unicode') from exc
        return value
    if type(value) is float and math.isfinite(value):
        return value
    if isinstance(value, list):
        return [json_value(v, depth + 1, budget) for v in value]
    if isinstance(value, dict) and all(isinstance(k, str) for k in value):
        return {k: json_value(v, depth + 1, budget) for k, v in value.items()}
    raise PreflightError('Unsupported API JSON value; no default=str conversion')


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


def classify_status(raw):
    """Preserve unknown status. Progress/global idle alone never means success."""
    if not isinstance(raw, dict):
        raise PreflightError('GetRenderJobStatus must return a dict, not None')
    raw = json_value(raw)
    name = raw.get('JobStatus')
    progress = raw.get('CompletionPercentage')
    issues = []
    if not isinstance(name, str) or not name.strip():
        issues.append('missing_or_invalid_JobStatus')
    if progress is not None and (type(progress) not in (int, float)
                                or not math.isfinite(progress) or not 0 <= progress <= 100):
        issues.append('invalid_CompletionPercentage')
    category = {'Ready': 'ready', 'Rendering': 'rendering', 'Complete': 'complete',
                'Failed': 'failed'}.get(name, 'unknown') if isinstance(name, str) else 'unknown'
    if isinstance(name, str) and name in CANCELLED:
        category = 'cancelled'
    if isinstance(name, str) and name in EXTERNAL:
        category = 'external'
    if category == 'complete' and progress is not None and progress != 100:
        issues.append('complete_progress_conflict')
    if category == 'complete' and raw.get('Error') not in (None, ''):
        issues.append('complete_error_conflict')
    if issues:
        category = 'unknown'
    return {'category': category, 'job_status': name, 'completion_percentage': progress,
            'issues': issues, 'raw': raw, 'file_verified': False}


def target(resolve, project_id=None, timeline_id=None, need_timeline=False):
    manager = read(resolve, 'GetProjectManager')
    if not manager:
        raise PreflightError('No Project Manager')
    project = read(manager, 'GetCurrentProject')
    if not project:
        raise PreflightError('No current project')
    pid = text(read(project, 'GetUniqueId'), 'project ID')
    if project_id is not None and pid != text(project_id, 'expected project ID'):
        raise PreflightError('Active project changed or differs from --project-id')
    tid = None
    if need_timeline:
        timeline = read(project, 'GetCurrentTimeline')
        if not timeline:
            raise PreflightError('No current timeline for AddRenderJob')
        tid = text(read(timeline, 'GetUniqueId'), 'timeline ID')
        if timeline_id is not None and tid != text(timeline_id, 'expected timeline ID'):
            raise PreflightError('Active timeline differs from --timeline-id')
    return project, pid, tid


def jobs(project):
    raw = read(project, 'GetRenderJobList')
    if not isinstance(raw, list) or len(raw) > MAX_JOBS:
        raise PreflightError('Expected bounded list from GetRenderJobList; None is not []')
    result = []
    seen = set()
    for row in raw:
        if not isinstance(row, dict):
            raise PreflightError('RenderJobInfo must be a dict')
        item = json_value(row)
        jid = text(item.get('JobId'), 'JobId')
        if jid in seen:
            raise PreflightError('Duplicate JobId; cannot address a unique queue item')
        seen.add(jid)
        result.append(item)
    return result


def queue_structure(rows):
    return [{k: v for k, v in r.items() if k not in VOLATILE} for r in rows]


def snapshot(resolve, project_id=None, timeline_id=None, need_timeline=False):
    """Two queue reads bracket status reads; this is still non-atomic."""
    project, pid, tid = target(resolve, project_id, timeline_id, need_timeline)
    rows = jobs(project)
    active = boolean(read(project, 'IsRenderingInProgress'), 'IsRenderingInProgress')
    states = {r['JobId']: classify_status(read(project, 'GetRenderJobStatus', r['JobId'])) for r in rows}
    if queue_structure(jobs(project)) != queue_structure(rows):
        raise PreflightError('Queue structure changed during observation')
    if boolean(read(project, 'IsRenderingInProgress'), 'IsRenderingInProgress') != active:
        raise PreflightError('Rendering activity changed during observation')
    target(resolve, pid, tid, need_timeline)
    return project, {'project_id': pid, 'timeline_id': tid, 'is_rendering': active,
                     'jobs': rows, 'statuses': states}


def stable(snapshot_value):
    return {'project_id': snapshot_value['project_id'], 'timeline_id': snapshot_value['timeline_id'],
            'is_rendering': snapshot_value['is_rendering'],
            'jobs': queue_structure(snapshot_value['jobs']),
            'statuses': {jid: {'job_status': v['job_status'], 'category': v['category'],
                               'issues': v['issues']} for jid, v in snapshot_value['statuses'].items()}}


def public_snapshot(s, details=False):
    out = {'project_id': s['project_id'], 'timeline_id': s['timeline_id'],
           'is_rendering': s['is_rendering'], 'count': len(s['jobs']),
           'job_ids': [r['JobId'] for r in s['jobs']], 'observation_hash': digest(stable(s)),
           'statuses': {k: {a: b for a, b in v.items() if a != 'raw'} for k, v in s['statuses'].items()},
           'atomic': False, 'file_verified': False}
    if details:
        out['raw_jobs'] = copy.deepcopy(s['jobs'])
        out['raw_statuses'] = {k: copy.deepcopy(v['raw']) for k, v in s['statuses'].items()}
    return out


def inspect_queue(resolve, project_id=None, details=False):
    _, s = snapshot(resolve, project_id)
    return {'status': 'observed', **public_snapshot(s, details)}


def status_once(resolve, project_id, job_id, details=False):
    jid = text(job_id, 'job ID')
    project, pid, _ = target(resolve, project_id)
    rows = jobs(project)
    matches = [r for r in rows if r['JobId'] == jid]
    if not matches:
        return {'status': 'not_found', 'project_id': pid, 'job_id': jid,
                'file_verified': False, 'repeat_mutation': False}
    state = classify_status(read(project, 'GetRenderJobStatus', jid))
    active = boolean(read(project, 'IsRenderingInProgress'), 'IsRenderingInProgress')
    after = [r for r in jobs(project) if r['JobId'] == jid]
    target(resolve, pid)
    if queue_structure(after) != queue_structure(matches):
        raise PreflightError('Selected job changed or disappeared during status read')
    result = {'status': 'observed', 'project_id': pid, 'job_id': jid,
              'is_rendering': active, **{k: v for k, v in state.items() if k != 'raw'}}
    if details:
        result['raw_status'] = state['raw']
        result['raw_job'] = matches[0]
    return result


def bounds(timeout, interval, max_polls):
    if type(timeout) not in (int, float) or not math.isfinite(timeout) or not 0 < timeout <= 86400:
        raise PreflightError('timeout must be finite, > 0 and <= 86400 seconds')
    if type(interval) not in (int, float) or not math.isfinite(interval) or not 0.1 <= interval <= 60:
        raise PreflightError('interval must be finite and 0.1..60 seconds')
    if type(max_polls) is not int or not 1 <= max_polls <= MAX_POLLS:
        raise PreflightError('max_polls must be an integer in 1..10000')


def wait_job(resolve, project_id, job_id, timeout=120, interval=1, max_polls=600,
             details=False, clock=time.monotonic, sleep=time.sleep):
    """Read-only soft deadline, including Ready. It never stops/restarts Resolve."""
    text(project_id, 'project ID')
    text(job_id, 'job ID')
    bounds(timeout, interval, max_polls)
    begin = clock()
    deadline = begin + timeout
    last = None
    samples = 0
    def result(outcome):
        return {'status': outcome, 'project_id': project_id, 'job_id': job_id,
                'samples': samples, 'elapsed_seconds': max(0, clock() - begin),
                'last_observation': last, 'file_verified': False,
                'render_stopped_by_script': False, 'repeat_mutation': False,
                'timeout_kind': 'soft; cannot interrupt a blocked native call'}
    try:
        while samples < max_polls:
            if clock() >= deadline:
                return result('timeout_unknown')
            last = status_once(resolve, project_id, job_id, details)
            samples += 1
            # A native call may have returned after the deadline; retain evidence,
            # but do not claim a within-budget successful wait.
            if clock() >= deadline:
                return result('timeout_unknown')
            if last['status'] == 'not_found':
                return result('job_missing_unknown')
            category = last['category']
            if category == 'complete':
                return result('job_complete_file_unverified')
            if category in ('failed', 'cancelled'):
                return result('job_' + category)
            if category in ('unknown', 'external'):
                return result('state_unknown' if category == 'unknown' else 'external_mode_not_supported')
            sleep(min(interval, max(0, deadline - clock())))
    except KeyboardInterrupt:
        return result('observer_interrupted_render_unchanged')
    except Exception as exc:
        out = result('observation_failed_unknown')
        out['error_type'] = type(exc).__name__
        return out
    return result('poll_limit_unknown')


def selected_ids(ids, allow_empty=False):
    if not isinstance(ids, list) or len(ids) > MAX_DELETE or (not ids and not allow_empty):
        raise PreflightError('Supply 1..25 explicit job IDs; no wildcard or implicit all')
    values = [text(x, 'job ID') for x in ids]
    if len(set(values)) != len(values):
        raise PreflightError('Duplicate selected job IDs')
    return values


def local_scope(s, allow_running=False):
    for state in s['statuses'].values():
        if not isinstance(state['job_status'], str) or state['job_status'] not in LOCAL_STATES or state['issues']:
            raise PreflightError('Unknown or background/remote state: mutation refused')
    rendering = [k for k, v in s['statuses'].items() if v['category'] == 'rendering']
    if not allow_running and (s['is_rendering'] or rendering):
        raise PreflightError('Queue must be idle; this script will not stop it first')
    if allow_running and bool(rendering) != s['is_rendering']:
        raise PreflightError('Global rendering flag and job states disagree')
    return rendering


def plan_action(resolve, action, project_id=None, job_ids=None, timeline_id=None):
    if action not in ('add', 'start', 'delete', 'cleanup', 'stop'):
        raise PreflightError('Unknown action')
    ids = selected_ids(job_ids or [], allow_empty=action in ('add', 'stop'))
    if action == 'add' and ids:
        raise PreflightError('AddRenderJob does not accept an existing job ID')
    if action in ('start', 'delete') and len(ids) != 1:
        raise PreflightError('Exactly one selected job is required')
    project, s = snapshot(resolve, project_id, timeline_id, action == 'add')
    rendering = local_scope(s, allow_running=action == 'stop')
    known = {r['JobId'] for r in s['jobs']}
    if any(jid not in known for jid in ids):
        raise PreflightError('A selected ID is not present in this project queue')
    if action == 'start' and s['statuses'][ids[0]]['job_status'] != 'Ready':
        raise PreflightError('This tutorial starts only an existing local Ready job; no restart')
    if action == 'cleanup' and any(s['statuses'][jid]['category'] not in ('complete', 'failed', 'cancelled') for jid in ids):
        raise PreflightError('Cleanup accepts selected terminal jobs only; use single-delete for Ready')
    if action == 'stop' and ids and set(ids) != set(rendering):
        raise PreflightError('StopRendering is not job-scoped; supplied IDs must cover every observed Rendering job')
    plan = {'action': action, 'project_id': s['project_id'], 'timeline_id': s['timeline_id'],
            'selected_job_ids': ids, 'observed_rendering_ids': rendering,
            'scope': 'project-wide stop' if action == 'stop' else 'explicit IDs' if ids else 'one new job',
            'observed_state_hash': digest(stable(s)),
            'settings_fully_read_back': False, 'atomic': False}
    plan['plan_hash'] = digest(plan)
    return project, s, plan


def mutate(resolve, action, project_id=None, job_ids=None, timeline_id=None,
           apply=False, expect_plan=None, test_copy=False, settings_reviewed=False,
           allow_project_stop=False, details=False):
    project, before, plan = plan_action(resolve, action, project_id, job_ids, timeline_id)
    report = {'status': 'plan', 'plan': plan, 'before': public_snapshot(before, details),
              'mutations_sent': 0, 'verified_deleted_ids': [], 'file_verified': False,
              'retry_allowed': False}
    if not apply:
        return report
    text(project_id, '--project-id required for apply')
    if not test_copy or not expect_plan or expect_plan != plan['plan_hash']:
        raise PreflightError('Apply needs --test-copy and the exact current --expect-plan')
    if action == 'add':
        text(timeline_id, '--timeline-id required for AddRenderJob apply')
    if action in ('add', 'start') and not settings_reviewed:
        raise PreflightError('Review Deliver/job settings and acknowledge --settings-reviewed')
    if action == 'stop' and not allow_project_stop:
        raise PreflightError('Stop is project-wide; explicit --allow-project-stop required')
    method = {'add': 'AddRenderJob', 'start': 'StartRendering', 'stop': 'StopRendering',
              'delete': 'DeleteRenderJob', 'cleanup': 'DeleteRenderJob'}[action]
    # Resolve callable BEFORE any sent marker; the same capability is reused.
    fn = getattr(project, method, None)
    if not callable(fn):
        raise PreflightError('Missing mutating method: ' + method)
    _, current, current_plan = plan_action(resolve, action, project_id, job_ids, timeline_id)
    if current_plan != plan:
        raise PreflightError('Plan changed immediately before mutation; no call sent')
    if action == 'stop' and not current['is_rendering']:
        report['status'] = 'already_idle_no_call'
        return report
    try:
        if action in ('delete', 'cleanup'):
            expected = stable(current)
            for jid in plan['selected_job_ids']:
                _, now = snapshot(resolve, project_id)
                local_scope(now)
                if stable(now) != expected:
                    report['status'] = 'partial_or_changed_before_next_call'
                    return report
                report['attempted_job_id'] = jid
                report['mutations_sent'] += 1
                returned = fn(jid)  # One attempt per explicitly selected ID.
                _, after = snapshot(resolve, project_id)
                expected['jobs'] = [r for r in expected['jobs'] if r['JobId'] != jid]
                expected['statuses'].pop(jid, None)
                report['after'] = public_snapshot(after, details)
                if returned is not True or stable(after) != expected:
                    report['status'] = 'mutation_outcome_unknown'
                    report['return_type'] = type(returned).__name__
                    return report
                report['verified_deleted_ids'].append(jid)
            report['status'] = 'selected_deletions_verified'
            return report
        report['mutations_sent'] = 1
        returned = fn() if action in ('add', 'stop') else fn([plan['selected_job_ids'][0]], False)
        report['return_type'] = type(returned).__name__
        if action == 'add' and isinstance(returned, str) and returned.strip():
            report['returned_job_id'] = returned  # Preserve before post-call read may fail.
        _, after = snapshot(resolve, project_id, timeline_id, action == 'add')
        report['after'] = public_snapshot(after, details)
        if action == 'add':
            jid = text(returned, 'AddRenderJob returned ID')
            old_ids = [r['JobId'] for r in before['jobs']]
            new_ids = [r['JobId'] for r in after['jobs'] if r['JobId'] not in old_ids]
            retained = copy.deepcopy(after)
            retained['jobs'] = [r for r in retained['jobs'] if r['JobId'] != jid]
            retained['statuses'].pop(jid, None)
            if jid in old_ids or new_ids != [jid] or stable(retained) != stable(before):
                report['status'] = 'mutation_outcome_unknown'
            else:
                report['status'] = 'job_added_needs_settings_review'
        elif action == 'start':
            # A True accepts a launch request, not proof of output completion.
            jid = plan['selected_job_ids'][0]
            unchanged = queue_structure(before['jobs']) == queue_structure(after['jobs'])
            others_ok = all(after['statuses'].get(k, {}).get('job_status') == v['job_status']
                            for k, v in before['statuses'].items() if k != jid)
            report['status'] = ('start_accepted_needs_observation' if returned is True and unchanged and others_ok
                                else 'mutation_outcome_unknown')
        else:
            # StopRendering has no documented bool success contract; None is valid.
            unchanged = queue_structure(before['jobs']) == queue_structure(after['jobs'])
            rendering = any(v['category'] == 'rendering' for v in after['statuses'].values())
            unknown = any(v['category'] in ('unknown', 'external') for v in after['statuses'].values())
            report['status'] = ('idle_observed_after_stop' if unchanged and not after['is_rendering'] and not rendering and not unknown
                                else 'stop_sent_needs_observation')
            report['rolled_back'] = False
        return report
    except (Exception, KeyboardInterrupt) as exc:
        report['status'] = 'mutation_outcome_unknown'
        report['error_type'] = type(exc).__name__
        return report  # Never retry, reverse or delete a newly added job here.


def cli_target(parser, timeline=False):
    parser.add_argument('--project-id', help='Exact opaque ID; required for apply/wait')
    if timeline:
        parser.add_argument('--timeline-id', help='Exact current timeline ID for AddRenderJob')
    parser.add_argument('--details', action='store_true', help='Include local names/paths/errors; redact before sharing')


def cli_write(parser, action):
    cli_target(parser, timeline=action == 'add')
    if action != 'add':
        parser.add_argument('--job-id', action='append', default=[], required=action != 'stop',
                            help='Exact ID; repeat only for cleanup or project-stop scope check')
    parser.add_argument('--apply', action='store_true')
    parser.add_argument('--expect-plan', help='SHA-256 of the reviewed current plan')
    parser.add_argument('--test-copy', action='store_true', help='Acknowledge isolated test project and sole operator')
    if action in ('add', 'start'):
        parser.add_argument('--settings-reviewed', action='store_true', help='Acknowledge manual review; not a technical verification')
    if action == 'stop':
        parser.add_argument('--allow-project-stop', action='store_true', help='Acknowledge non-job-scoped stop')


def write_from_args(resolve, action, args):
    return mutate(resolve, action, project_id=args.project_id, job_ids=getattr(args, 'job_id', []),
                  timeline_id=getattr(args, 'timeline_id', None), apply=args.apply,
                  expect_plan=args.expect_plan, test_copy=args.test_copy,
                  settings_reviewed=getattr(args, 'settings_reviewed', False),
                  allow_project_stop=getattr(args, 'allow_project_stop', False), details=args.details)


def print_result(work):
    try:
        out = work()
    except PreflightError as exc:
        out = {'status': 'preflight_refused', 'error': str(exc), 'mutations_sent': 0}
    except KeyboardInterrupt:
        out = {'status': 'observer_interrupted', 'render_stopped_by_script': False}
    except Exception as exc:
        out = {'status': 'read_or_connection_failed', 'error_type': type(exc).__name__}
    print(json.dumps(out, ensure_ascii=False, allow_nan=False, indent=2))
    if out['status'] in ('plan', 'observed', 'selected_deletions_verified', 'already_idle_no_call',
                         'idle_observed_after_stop', 'job_complete_file_unverified'):
        return 0
    return 2 if out['status'] == 'preflight_refused' else 3

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 add_render_job.py --project-id "PROJECT_ID" --timeline-id "TIMELINE_ID"

Prepare and review Deliver first.

Add one entry

Add one entry
python add_render_job.py --project-id "PROJECT_ID" --timeline-id "TIMELINE_ID" --apply --test-copy --settings-reviewed --expect-plan "PLAN_SHA256"

Flags acknowledge the operator’s decision; they do not certify every export property.

What the code does

  1. Read, do not guess

    GetRenderJobList and GetRenderJobStatus remain separate observations. A missing field, invalid response, duplicate ID and empty queue have different meanings.

  2. Keep the selected scope

    The main script calls the downloadable shared helper below. Guards are tutorial policy, not a sandbox or a new permission system in Resolve.

  3. Interpret the result

    Exit 0 means a completed observation, plan or narrowly verified operation. Exit 2 is preflight refusal. Exit 3 means failure, uncertainty or pending review; it is not permission to retry.

Boundaries to keep

Queue/status reads may expose client names, paths and errors with --details. IDs can also identify work: review the whole report before sharing; automatic redaction is not promised.

No preset changes, directory creation, all-queue start, automatic retries, rollback or project save. Native calls can block. A returned bool or matching reads do not guarantee the entire project state.

API Complete is not a media quality certificate. Verify the expected output files, decoding, duration, audio, dimensions and delivery requirements separately.

DEBUG / OBSERVATION FIRST

An error is a starting point, not a diagnosis.

Preflight refused

Possible reasonThe exact ID, plan, queue structure or required method did not match.

Next checkRead the report and your installed interface. Do not guess alternate method names or convert opaque IDs to numbers.

Unknown outcome after sending

Possible reasonA mutating call or the following read failed, or the queue changed concurrently.

Next checkRetain the returned ID and observations. Check Resolve first; never repeat the operation blindly.

Remote/background state

Possible reasonThis bounded mutation example cannot establish local exclusive control for that state.

Next checkRead the raw state with --details and use a separately qualified workflow. Do not rename it Ready just to pass the guard.

PRACTICE / YOUR OWN EVIDENCE

Verify on your own test project

  1. Configure a short test export in a separate directory; review range, audio and existing-file behaviour.
  2. Plan with exact project and timeline IDs, then apply without changing Deliver between runs.
  3. Locate returned_job_id and inspect its settings in the queue. Do not launch it during this exercise.
ACCEPT WHEN

Exactly one new entry appears and existing entries remain. Rendering does not start. The report retains the ID and explicitly requests settings review.

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

FAQ

Questions that matter in practice.

Why require --timeline-id for a Project method?

The new entry uses the current prepared state. Checking the timeline ID restricts the tutorial to the expected active source; it is not an additional AddRenderJob parameter.[18]

Why exit 3 after a verified addition?

That is deliberate tutorial policy: entry creation is checked, but its settings need inspection. The status does not tell you to repeat AddRenderJob.[18]

Can the last queue ID replace a lost return value?

Not reliably: another actor could have changed the queue. Retain observations and investigate without automatically creating a duplicate.[18]

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.

  5. Blackmagic DaVinciResolveScript.pyi — RenderJobInfo, RenderJobStatus and queue methods

    S07 reviews a published mirror of Blackmagic’s interface on 6 October 2026: JobId/JobStatus fields and queue methods. Third-party hosting; compare your installed .pyi. Stop takes no ID and declares no success bool. Document review, not live certification.

  6. Python — time.monotonic and time.sleep

    A monotonic clock measures elapsed time. Sleep and clock checks cannot interrupt a blocked native call; S07 uses a soft deadline plus an independent poll limit.

KEEP BUILDING

Continue with a precise next task.

API / Render

How to start one Render Job with Python

Start one existing local Ready job using StartRendering([job_id], False). Verify the target and queue, and distinguish an accepted launch from a finished output file.

API / Render

How to read a Render Job status with Python

Read GetRenderJobStatus for an existing JobId and distinguish Ready, Rendering, Complete, failure, cancellation and uncertainty. Progress and global activity are not job outcomes.

API / Render

Render Settings: exact keys and a reviewable request

Prepare SetRenderSettings with a bounded JSON object: exact keys, strict types, an existing directory and a reviewed plan. No unknown fields, directory creation, hidden codec changes or render start.

Back to the API library →MCP and AI assistants →