ВИДЕОМОНТАЖЁРDAVINCI RESOLVE · ВИДЕО · AI

RESOLVE API / QUEUE LAB / 05

Как прочитать статус Render Job через Python

Читать статус — не запускать рендер.

GetRenderJobStatus для существующего JobId: различаем Ready, Rendering, Complete, ошибку, отмену и неизвестность. Процент и общий флаг активности не подменяют результат задания.

НА ВЫХОДЕ

Одно ограниченное наблюдение за выбранным ID без создания, запуска или изменения очереди.

PYTHON / RESOLVEУЧЕБНАЯ МОДЕЛЬ
01jobs = project.GetRenderJobList()

Проверяем, что ID есть в очереди нужного проекта. Отсутствие не считается завершением.

ПРОВЕРКА

Не номер строки.

Переключатели меняют объяснение. Ничего не подключается к Resolve и не запускается на вашем компьютере.

01 / BEFORE THE CODE

До кода — условия запуска.

01

Среда и источник

Внешний Python 3.9+ и запущенный Resolve Studio с локальным scripting. Сверьте интерфейс своей установленной версии. В этом проходе проверена опубликованная копия интерфейса Blackmagic на 6 октября 2026; живая совместимость, минимальная версия и работа в Free не заявляются.

02

Точная цель

Используйте отдельную тестовую копию и одного оператора. ID проекта и задания — точные непрозрачные строки, не номер очереди, имя таймлайна или файла. Текущий таймлайн нужен только для создания нового задания; чтению и управлению существующими заданиями он не требуется.

03

Перед изменением

Сначала прочитайте план. Применение требует точной цели, совпавшего хеша и подтверждения тестовой копии. Для add/start самостоятельно проверьте Deliver и выбранное задание. Хеш не является блокировкой, проверкой прав или полным снимком настроек.

Ориентируйтесь на актуальные требования Studio и документацию установленной версии, а не на старые инструкции для консоли или Free.[1][4][3]

02 / API CONTRACT

Какой объект. Какой метод. Какой результат.

ProjectGetRenderJobStatus(job_id)
Вход
Точный ID существующей записи.
Возвращает
RenderJobStatus с необязательными полями.
Проверить
None, неверная форма и неизвестная метка не становятся Complete.
ProjectIsRenderingInProgress()
Вход
Без аргументов.
Возвращает
Bool активности.
Проверить
Дополнительное наблюдение, не статус этого ID.
Авторский helperclassify_status(raw)
Вход
Словарь ответа.
Возвращает
Категория, issues, сохранённые поля.
Проверить
Авторский классификатор; не новый метод Resolve.

Контракты методов Resolve описаны Blackmagic; Python и авторские helper обозначены отдельно. Дополнительные проверки — устройство наших примеров. Непустой объект не доказывает совместимость всего API.[18]

03 / UNDERSTAND

Информация о записи и её состояние — два ответа

GetRenderJobList возвращает сведения о заданиях, а GetRenderJobStatus получает состояние одного выбранного ID. Пример сначала проверяет наличие записи и после чтения убеждается, что та не исчезла и её читаемые параметры не сменились. Текущий таймлайн для этого не нужен.

Старый код создавал Render Job и запускал его прямо в статье о чтении статуса. Теперь здесь только наблюдение. Не добавляйте запись ради получения интересующего процента: используйте ID уже существующей работы, взятый из её создания или проверенной очереди.[18]

04 / UNDERSTAND

Не сводить все исходы к «готово»

В проверенном интерфейсе есть Ready, Rendering, Complete, Failed и Cancelled, а также состояния ожидания и отмены фонового или удалённого рендера. Наш классификатор оставляет исходную строку и выводит отдельную категорию. Готовность к удалённой обработке не является локальным завершением.

Формулировки Quick Export вроде Render Complete принадлежат другому ответу. Они не подставляются вместо Complete для обычного Render Job. Незнакомая строка сохраняется как unknown: читатель видит расхождение и сверяет документ своей установки, а не получает молчаливую догадку.[18]

05 / UNDERSTAND

Отсутствующее поле не нужно выдумывать

RenderJobStatus объявлен с необязательными полями. Пример сохраняет отсутствие CompletionPercentage как отсутствие; не подставляет ноль или альтернативный ключ Completion. Процент проверяется на числовой тип, конечность и диапазон 0–100.

Один процент 100 не делает Ready или Rendering завершённым. Complete с переданными 72% либо одновременно непустой ошибкой помечается противоречием, а не подтверждённым успехом. Отсутствующий процент при Complete допустим как ограниченное наблюдение статуса, но всё ещё не проверяет файл.[18]

QUEUE / OUTCOME LAB

Один сигнал — ещё не исход.

Только учебные данные. Без подключения и команд.

ИСХОД МОДЕЛИ

continue_observation

Продолжайте ограниченное наблюдение за тем же ID. Ready при 100% и свободное приложение не доказывают завершение.

Класс заданияreadyФайл проверенНет

Поля меняют пример, не ваш проект. Автосохранения и отправки нет. Скачанный JSON — отредактированная учебная модель, не журнал Resolve, сертификат завершения или разрешение выполнить команду.

06 / UNDERSTAND

Как использовать отчёт без утечки лишних данных

По умолчанию выводятся ID, категория, исходная метка состояния, процент и общий флаг активности. С --details добавляются сырые сведения, включая возможные локальные пути и текст Error. Сохранение сырого ответа полезно для диагностики, но требует проверки перед пересылкой.

Статус observed означает, что чтение завершилось, а не что задание успешно. not_found означает отсутствие выбранного ID в текущем наблюдении, а не его успешный рендер или гарантированное удаление. Чтобы ждать исхода, используйте отдельный ограниченный наблюдатель.[18]

PYTHON / COMPLETE EXAMPLE

Прочитайте код. Затем запускайте осознанно.

Три Python-файла в одной папке

Положите get_render_job_status.py, resolve_api_queue.py и resolve_api_connection.py в одну папку. Комплект содержит восемь основных скриптов, оба helper и инструкции RU/EN. Официальный SDK не включён; используется мост установленного Blackmagic.

Скачать все примеры с инструкцией (.zip) ↓
get_render_job_status.py
Скачать .py
"""Read the status of one existing job. No hidden AddRenderJob or StartRendering."""
import argparse
from resolve_api_connection import connect
from resolve_api_queue import cli_target, status_once, print_result


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    cli_target(parser)
    parser.add_argument("--job-id", required=True, help="Exact opaque queue ID")
    args = parser.parse_args()
    return print_result(lambda: status_once(connect(), args.project_id, args.job_id, args.details))


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

Queue-helper — точные ID, ограниченное наблюдение и явные изменения

Авторский учебный код, не SDK. Нет запуска и удаления всей очереди. StopRendering остаётся общей командой. Выборочная очистка последовательна и может завершиться частично; неизвестные исходы не повторяются. Мягкий deadline не прерывает зависший нативный вызов.

resolve_api_queue.py
Скачать .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

Общий helper подключения — посмотреть и скачать

Этот файл находит и загружает установленный мост. При импорте Python может записывать кэш байткода, а нативный вызов — зависнуть; это не песочница и не механизм тайм-аута. Используйте доверенную локальную установку.

resolve_api_connection.py
Скачать .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

Команды запуска

Одно чтение

Одно чтение
python get_render_job_status.py --project-id "PROJECT_ID" --job-id "JOB_ID"

Не создаёт, не запускает и не останавливает задания.

Сырые сведения для локальной проверки

Сырые сведения для локальной проверки
python get_render_job_status.py --project-id "PROJECT_ID" --job-id "JOB_ID" --details

Перед пересылкой удалите приватные сведения вручную.

Что делает код

  1. Читать, а не угадывать

    GetRenderJobList и GetRenderJobStatus остаются разными наблюдениями. Отсутствующее поле, неверный ответ, повтор ID и пустая очередь имеют разный смысл.

  2. Сохранить выбранную область

    Основной скрипт вызывает скачиваемый общий helper ниже. Проверки — политика учебного примера, не песочница и не новая система прав Resolve.

  3. Правильно прочитать результат

    Код 0 означает завершённое наблюдение, план или узко проверенную операцию. Код 2 — отказ предварительной проверки. Код 3 — ошибка, неизвестность или необходимость сверки; это не разрешение повторить вызов.

Границы, которые важно сохранить

С --details отчёт может раскрыть имена клиентов, пути и ошибки. ID тоже могут идентифицировать работу: проверьте весь отчёт перед публикацией; автоматического обезличивания нет.

Нет смены пресета, создания папок, запуска всей очереди, автоматических повторов, отката и сохранения проекта. Нативные вызовы могут зависнуть. Bool и совпавшие чтения не гарантируют состояние всего проекта.

Complete в API — не сертификат качества файла. Отдельно проверьте ожидаемые файлы, декодирование, длительность, звук, размеры и требования сдачи.

DEBUG / OBSERVATION FIRST

Ошибка — отправная точка, не готовый диагноз.

Отказ до вызова

Возможная причинаНе совпали точный ID, план, структура очереди или требуемый метод.

Следующая проверкаПрочитайте отчёт и интерфейс своей установки. Не подбирайте псевдонимы методов и не превращайте непрозрачный ID в число.

Неизвестный исход после отправки

Возможная причинаИзменяющий вызов либо последующее чтение завершилось ошибкой; возможно параллельное изменение очереди.

Следующая проверкаСохраните возвращённый ID и наблюдения. Сначала сверьте Resolve; не повторяйте операцию вслепую.

Фоновый или удалённый режим

Возможная причинаОграниченный изменяющий пример не может установить локальную исключительную область для такого состояния.

Следующая проверкаПрочитайте исходное состояние с --details и используйте отдельно проверенный процесс. Не переименовывайте его в Ready ради обхода проверки.

PRACTICE / YOUR OWN EVIDENCE

Проверьте на своей тестовой копии

  1. Создайте тестовую запись вручную и прочитайте её Ready без запуска.
  2. Во время отдельного короткого запуска получите статус того же ID, затем прочитайте терминальное состояние.
  3. Проверьте поведение при несуществующем ID и сравните Complete с реальным файлом.
КРИТЕРИЙ ПРИЁМКИ

Чтение не создало и не запустило заданий. Ready, ошибка, отсутствие и Complete не перепутаны; файл проверен отдельно.

Это условия вашей проверки, а не отчёт об испытании, уже выполненном на сайте.

FAQ

Вопросы, которые возникают на практике.

Всегда ли есть CompletionPercentage?

Нет, контракт содержит необязательные поля. Пример не подставляет отсутствующий процент. Если поле есть, оно должно иметь допустимое числовое значение.[18]

Почему Complete при 72% считается противоречием?

Это консервативная политика нашего классификатора: два переданных поля не дают согласованного результата. Сохраняется исходный ответ и требуется сверка, а не автоматический успех.[18]

Можно считать пустой ответ готовым заданием?

Нет. Пустой словарь не содержит читаемой JobStatus, а None нарушает ожидаемую форму. Ни один случай не даёт подтверждения завершения.[18]

SOURCES / VERSION BOUNDARIES

Источники, а не подразумеваемая сертификация.

Сверка источников, офлайн-тесты Python и проверка интерфейса — разные виды подтверждения. Ни один из них не означает, что каждый пример проверен со всеми сборками Studio, ОС и версиями Python. Указанная дата — дата редакционной проверки, не сертификат живого прогона.

  1. Blackmagic Design — DaVinci Resolve Studio 21.1 release notes

    8 сентября 2026. В объявлении выпуска Python scripting отнесён к Studio. Это источник требований редакции, а не протокол испытания наших примеров.

  2. Blackmagic Design — Support Center

    Каталог обновлений проверен 5 октября 2026: опубликована ветка 21.1.1. Последний выпуск не равен минимальной версии каждого метода.

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

    Публичная копия первичного README Blackmagic, поддерживаемая X-Raym. По ней сверены базовые сигнатуры; это не официальный хостинг и не README 21.1.1. Оригинал своей установки: Help → Documentation → Developer → Scripting → README.txt; расположение пункта зависит от версии.

  4. Blackmagic Design — Scripting and Automation

    Официальное описание Python/Lua scripting и интеграций Studio. Нельзя переносить обещания Studio на Free без проверки конкретного выпуска.

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

    Для S07 6 октября 2026 проверены опубликованная копия первичного интерфейса Blackmagic, поля JobId/JobStatus и методы очереди. Хостинг сторонний; сверьте .pyi своей установки. Контракт остановки не принимает ID и не объявляет bool успеха. Это проверка документа, не живая сертификация.

  6. Python — time.monotonic and time.sleep

    Монотонная шкала подходит для измерения прошедшего времени. Пауза и проверка часов не прерывают заблокированный нативный вызов; в S07 реализован мягкий deadline и независимый предел опросов.

KEEP BUILDING

Продолжайте с конкретной задачей.

API / Render

Как дождаться завершения рендера через Python

Наблюдаем один JobId с монотонным временем, интервалом и лимитом опросов. Тайм-аут, отмена, ошибка, пропавшее задание и Complete получают разные результаты.

API / Render

Как остановить рендер через Python API

Один StopRendering с явно подтверждённой общей областью. Проверяем проект и активные задания, не принимаем None за неуспех и не обещаем откат файлов.

API / Render

Render Settings: точные ключи и проверяемый запрос

Подготовка SetRenderSettings через ограниченный JSON: правильные ключи, строгие типы, существующая папка и просмотр плана. Без неизвестных полей, создания каталогов, скрытой смены кодека и запуска рендера.

Вернуться в библиотеку API →MCP и AI-ассистенты →