01 / BEFORE THE CODE
До кода — условия запуска.
Среда и источник
Внешний Python 3.9+ и запущенный Resolve Studio с локальным scripting. Сверьте интерфейс своей установленной версии. В этом проходе проверена опубликованная копия интерфейса Blackmagic на 6 октября 2026; живая совместимость, минимальная версия и работа в Free не заявляются.
Точная цель
Используйте отдельную тестовую копию и одного оператора. ID проекта и задания — точные непрозрачные строки, не номер очереди, имя таймлайна или файла. Текущий таймлайн нужен только для создания нового задания; чтению и управлению существующими заданиями он не требуется.
Перед изменением
Сначала прочитайте план. Применение требует точной цели, совпавшего хеша и подтверждения тестовой копии. Для add/start самостоятельно проверьте Deliver и выбранное задание. Хеш не является блокировкой, проверкой прав или полным снимком настроек.
Ориентируйтесь на актуальные требования Studio и документацию установленной версии, а не на старые инструкции для консоли или Free.[1][4][3]
02 / API CONTRACT
Какой объект. Какой метод. Какой результат.
DeleteRenderJob(job_id)- Вход
- Один непустой строковый ID.
- Возвращает
- Bool.
- Проверить
- Одна попытка; ID не заменяется объектом записи.
GetRenderJobList()- Вход
- Перед планом и после вызова.
- Возвращает
- Список RenderJobInfo.
- Проверить
- Исчезает только выбранная запись; прочитанные соседи сохранены.
IsRenderingInProgress()- Вход
- Перед изменением.
- Возвращает
- Bool.
- Проверить
- При активности отказ, не автоматическая остановка.
Контракты методов Resolve описаны Blackmagic; Python и авторские helper обозначены отдельно. Дополнительные проверки — устройство наших примеров. Непустой объект не доказывает совместимость всего API.[18]
03 / UNDERSTAND
У удаления должна быть точная адресация
Старый пример перебирал все записи и пытался передавать разные типы аргумента, хотя тема обещала удаление одного задания. Новый скрипт требует ровно один --job-id, находит его в ожидаемом проекте и не применяет совпадение по имени или номеру строки.
В документированном интерфейсе DeleteRenderJob принимает строковый jobId. Значение сохраняется без приведения к числу, обрезки пробелов или смены регистра. Дублирующийся ID в ответе очереди считается неоднозначностью, а не поводом выбрать первый объект.[18]
04 / UNDERSTAND
Удаление не должно незаметно останавливать очередь
Перед изменением пример требует свободную понятную локальную очередь. Он не вызывает StopRendering ради удобства удаления и не действует при неизвестном, фоновом или удалённом состоянии. Ready можно удалить только по точному явному решению, а не как часть автоматической уборки.
Если ID отсутствует до вызова, происходит отказ, а не фиктивное сообщение об успешном удалении. Отсутствие могло возникнуть из-за чужого действия или неверного проекта. Его нужно установить отдельно; повторная команда наугад не добавляет достоверности.[18]
05 / UNDERSTAND
Проверять не только исчезнувшую цель
План хранит наблюдаемую структуру очереди. После одного DeleteRenderJob нужен True и новый список, где отсутствует только выбранный ID, а прочие записи сохраняют порядок, состояния и прочитанные поля. Это более строгая проверка, чем уменьшение количества на единицу.
Несовпадение возвращаемого значения и прочитанного результата оставляет mutation_outcome_unknown. Например, False с исчезнувшей записью не разрешает повтор. Helper сохраняет попытку, но не перебирает другие аргументы и не создаёт запись обратно.[18]
06 / UNDERSTAND
Что именно завершает это упражнение
Проверяется состояние очереди, не целостность всего проекта. Нет сохранения проекта, удаления исходников, очистки папки рендера или восстановления экспортированного файла. Совпавшие чтения не блокируют другого пользователя от последующего изменения.
После неизвестного исхода прочитайте проект и очередь ещё раз, сохраните attempted_job_id и сравните с интерфейсом. Для удаления нескольких проверенных терминальных записей предназначен отдельный выборочный сценарий очистки, который также сообщает о частичном выполнении.[18]
PYTHON / COMPLETE EXAMPLE
Прочитайте код. Затем запускайте осознанно.
Положите delete_render_job.py, resolve_api_queue.py и resolve_api_connection.py в одну папку. Комплект содержит восемь основных скриптов, оба helper и инструкции RU/EN. Официальный SDK не включён; используется мост установленного Blackmagic.
Скачать все примеры с инструкцией (.zip) ↓"""Delete exactly one selected queue item; never stop or purge the queue."""
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, "delete")
args = parser.parse_args()
return print_result(lambda: write_from_args(connect(), "delete", args))
if __name__ == "__main__":
raise SystemExit(main())
Queue-helper — точные ID, ограниченное наблюдение и явные изменения
Авторский учебный код, не SDK. Нет запуска и удаления всей очереди. StopRendering остаётся общей командой. Выборочная очистка последовательна и может завершиться частично; неизвестные исходы не повторяются. Мягкий deadline не прерывает зависший нативный вызов.
"""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 может записывать кэш байткода, а нативный вызов — зависнуть; это не песочница и не механизм тайм-аута. Используйте доверенную локальную установку.
"""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 delete_render_job.py --project-id "PROJECT_ID" --job-id "JOB_ID"Проверьте выбранную цель и сохранённую очередь.
Применить один раз
python delete_render_job.py --project-id "PROJECT_ID" --job-id "JOB_ID" --apply --test-copy --expect-plan "PLAN_SHA256"Без StopRendering, DeleteAllRenderJobs и повторного вызова при расхождении.
Что делает код
Читать, а не угадывать
GetRenderJobList и GetRenderJobStatus остаются разными наблюдениями. Отсутствующее поле, неверный ответ, повтор ID и пустая очередь имеют разный смысл.
Сохранить выбранную область
Основной скрипт вызывает скачиваемый общий helper ниже. Проверки — политика учебного примера, не песочница и не новая система прав Resolve.
Правильно прочитать результат
Код 0 означает завершённое наблюдение, план или узко проверенную операцию. Код 2 — отказ предварительной проверки. Код 3 — ошибка, неизвестность или необходимость сверки; это не разрешение повторить вызов.
С --details отчёт может раскрыть имена клиентов, пути и ошибки. ID тоже могут идентифицировать работу: проверьте весь отчёт перед публикацией; автоматического обезличивания нет.
Нет смены пресета, создания папок, запуска всей очереди, автоматических повторов, отката и сохранения проекта. Нативные вызовы могут зависнуть. Bool и совпавшие чтения не гарантируют состояние всего проекта.
Complete в API — не сертификат качества файла. Отдельно проверьте ожидаемые файлы, декодирование, длительность, звук, размеры и требования сдачи.
DEBUG / OBSERVATION FIRST
Ошибка — отправная точка, не готовый диагноз.
Отказ до вызова
Возможная причинаНе совпали точный ID, план, структура очереди или требуемый метод.
Следующая проверкаПрочитайте отчёт и интерфейс своей установки. Не подбирайте псевдонимы методов и не превращайте непрозрачный ID в число.
Неизвестный исход после отправки
Возможная причинаИзменяющий вызов либо последующее чтение завершилось ошибкой; возможно параллельное изменение очереди.
Следующая проверкаСохраните возвращённый ID и наблюдения. Сначала сверьте Resolve; не повторяйте операцию вслепую.
Фоновый или удалённый режим
Возможная причинаОграниченный изменяющий пример не может установить локальную исключительную область для такого состояния.
Следующая проверкаПрочитайте исходное состояние с --details и используйте отдельно проверенный процесс. Не переименовывайте его в Ready ради обхода проверки.
PRACTICE / YOUR OWN EVIDENCE
Проверьте на своей тестовой копии
- Подготовьте три тестовые записи и выберите среднюю по её точному ID.
- Получите план и убедитесь, что в selected_job_ids только одна цель.
- Примените один раз; сопоставьте оставшиеся ID и сведения с исходным отчётом.
Цель исчезла, обе соседние записи остались с прежними наблюдаемыми полями. Других изменяющих вызовов нет; файлы не удалялись скриптом.
Это условия вашей проверки, а не отчёт об испытании, уже выполненном на сайте.
FAQ
Вопросы, которые возникают на практике.
Можно передать весь словарь GetRenderJobList?
В проверенном контракте нужен jobId: str. Полный словарь информации о записи не является эквивалентным аргументом.[18]
Почему скрипт не сообщает success для отсутствующего ID?
Он подтверждает совершённую выбранную операцию, а не желаемое конечное состояние. Отсутствующий до вызова ID требует выяснения причины и не доказывает удаление этим скриптом.[18]
Что делать, если True получен, но запись осталась?
Считать исход неподтверждённым, сохранить отчёт и проверить Resolve. Не повторять DeleteRenderJob автоматически и не переходить к удалению всей очереди.[18]
SOURCES / VERSION BOUNDARIES
Источники, а не подразумеваемая сертификация.
Сверка источников, офлайн-тесты Python и проверка интерфейса — разные виды подтверждения. Ни один из них не означает, что каждый пример проверен со всеми сборками Studio, ОС и версиями Python. Указанная дата — дата редакционной проверки, не сертификат живого прогона.
- Blackmagic Design — DaVinci Resolve Studio 21.1 release notes
8 сентября 2026. В объявлении выпуска Python scripting отнесён к Studio. Это источник требований редакции, а не протокол испытания наших примеров.
- Blackmagic Design — Support Center
Каталог обновлений проверен 5 октября 2026: опубликована ветка 21.1.1. Последний выпуск не равен минимальной версии каждого метода.
- 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; расположение пункта зависит от версии.
- Blackmagic Design — Scripting and Automation
Официальное описание Python/Lua scripting и интеграций Studio. Нельзя переносить обещания Studio на Free без проверки конкретного выпуска.
- Blackmagic DaVinciResolveScript.pyi — RenderJobInfo, RenderJobStatus and queue methods
Для S07 6 октября 2026 проверены опубликованная копия первичного интерфейса Blackmagic, поля JobId/JobStatus и методы очереди. Хостинг сторонний; сверьте .pyi своей установки. Контракт остановки не принимает ID и не объявляет bool успеха. Это проверка документа, не живая сертификация.
- Python — time.monotonic and time.sleep
Монотонная шкала подходит для измерения прошедшего времени. Пауза и проверка часов не прерывают заблокированный нативный вызов; в S07 реализован мягкий deadline и независимый предел опросов.