01 / BEFORE THE CODE
Set the conditions before you run.
Environment and source
External Python 3.9+, running Resolve Studio and local scripting. Compare your installed Developer/Scripting documents. The published interface was reviewed on 6 October 2026; offline tests do not certify native Resolve, every release or the Free edition.
Exact target
Use an isolated test copy, one operator, exact project/timeline IDs and an idle renderer. Flags do not create a copy or lock the UI. Qualify a non-zero origin and one-frame range; for audio review channels and output-bus routing.
Before any mutation
Read the entry script and expanded helpers, then run the plan without --apply. Authorize only the selected command and its current hash. After a lost response or partial result, inspect the application before repeating a write.
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.
SetRenderSettings(settings)- Input
- An explicit Deliver settings dictionary.
- Returns
- Bool acknowledgement.
- Check
- True does not verify the whole export. One call; no implicit job creation.
AddRenderJob()- Input
- Current reviewed Deliver settings.
- Returns
- The new job ID as a string.
- Check
- Record the ID; a lost reply is not permission to create another job.
StartRendering([job_id], False)- Input
- A list containing exactly one ID.
- Returns
- Bool start acknowledgement.
- Check
- No whole-queue fallback. Command acceptance does not mean a finished file.
GetRenderJobStatus(job_id)- Input
- The exact selected job ID.
- Returns
- State and optional percentage.
- Check
- Handle Complete, Failed, Cancelled and unknown states separately.
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.[20][21]
03 / UNDERSTAND
Why a complete pipeline uses separate commands
Complete means the route from preparation to acceptance is covered, not that every write must occur inside one uninterrupted function. The CLI provides inspect, configure, add, start, wait and verify. With no subcommand it only reads the queue. configure selects the full span; format, quality, audio and directory are reviewed separately.
Each mutating command first produces a plan. Repeat that specific command with its fresh plan_hash and explicit authorization. Review Deliver between configuration and creation, then inspect the created job between add and start. These are useful checkpoints, not delays to hide behind automatic retries.[20]
04 / UNDERSTAND
Define the task before creating a JobId
Define the project/timeline, full or limited range, Single clip/Individual clips, format/codec, resolution, rate, audio, destination and naming policy. This bundle’s main path is Single clip and one file; an individual-clips output needs its own expected-file list instead of accepting the first file found.
A preset name and current Deliver page are not an immutable profile. No complete GetRenderSettings is assumed to establish every setting here. --settings-reviewed is your acknowledgement of manual review. The observation hash covers only read fields and is not an authorization policy, transaction, UI lock or test copy.[20]
05 / UNDERSTAND
Preserve the ID between add and start
AddRenderJob may create a job before a subsequent read fails. The helper therefore stores the returned ID in its report before queue verification. Record it together with the project and actual destination path. Start receives exactly one ID in a list; a filename or queue row number is never substituted.
If the process dies before a reply is received or saved, inspect the Resolve queue first. Repeating add can create a duplicate; repeating start can restart an unintended or already completed task. This CLI does not provide automatic journal-based resume or exactly-once behavior after failure. A plan_hash is not an execution journal.[18]
06 / UNDERSTAND
Waiting, timeout and unknown outcomes
wait uses a monotonic clock, an independent poll limit and the latest observation. It distinguishes completion, failure, cancellation and an unknown result. Timeout means the observation budget expired; rendering may continue. Ctrl+C does not send StopRendering either.
After connection loss, retain the report and inspect the same JobId again. Do not restart the sequence at configure/add. A soft deadline cannot interrupt a blocked native bridge call; hard isolation needs another process and a separately designed recovery policy, which this tutorial CLI does not pretend to provide.[18][19]
07 / UNDERSTAND
Never select the newest vaguely matching file
verify requires an exact absolute file path and an absolute path to a trusted ffprobe. It does not scan a prefix, choose the first/newest file, delete output or launch a shell. It checks the file identity/size/modification stamp across probing and compares explicit stream expectations.
With --video-frames, ffprobe counts frames rather than trusting container duration alone. Probing is time-limited but can be lengthy for large files. Metadata checks do not prove JobId provenance, color, quality, non-silence or full decoding. Viewing and listening remain part of acceptance.[21]
PYTHON / COMPLETE EXAMPLE
Read the code. Then run it deliberately.
Keep all files together: six entry scripts, delivery/queue/connection helpers, and verify_render_output.py. The queue and connection helpers are unchanged from earlier stages. The verifier can run without Resolve and requires your own trusted ffprobe. No SDK or ffprobe binaries are bundled. Python 3.9 or newer; the installed bridge may have additional requirements.
Download all examples + instructions (.zip) ↓"""Controlled end-to-end delivery: inspect/configure/add/start/wait/verify separately.
One explicit stage per invocation. No implicit resume, retries, queue purge or
'newest file' search. Record the returned JobId; a missing receipt is NOT permission
to AddRenderJob again. Review Deliver and the created job before the next stage.
"""
import argparse
from resolve_api_connection import connect
import resolve_api_delivery as delivery
import resolve_api_queue as queue
import verify_render_output as verification
def main():
p=argparse.ArgumentParser(description=__doc__)
sub=p.add_subparsers(dest='step')
inspect=sub.add_parser('inspect'); inspect.add_argument('--project-id')
configure=sub.add_parser('configure',help='Select entire timeline only; other settings remain unchanged')
delivery.cli_write(configure)
for name in ('add','start'):
step=sub.add_parser(name); queue.cli_write(step,name)
wait=sub.add_parser('wait'); wait.add_argument('--project-id',required=True)
wait.add_argument('--job-id',required=True); wait.add_argument('--timeout',type=float,default=120)
wait.add_argument('--interval',type=float,default=1); wait.add_argument('--max-polls',type=int,default=600)
verify=sub.add_parser('verify'); verification.add_arguments(verify)
a=p.parse_args()
if a.step == 'verify': return delivery.print_result(lambda:verification.from_args(a))
if a.step == 'configure':
return delivery.print_result(lambda:delivery.apply_settings(connect(),mode='entire',**delivery.cli_flags(a)))
if a.step in ('add','start'):
return queue.print_result(lambda:queue.write_from_args(connect(),a.step,a))
if a.step == 'wait':
return queue.print_result(lambda:queue.wait_job(connect(),a.project_id,a.job_id,
timeout=a.timeout,interval=a.interval,max_polls=a.max_polls))
return queue.print_result(lambda:queue.inspect_queue(connect(),getattr(a,'project_id',None),details=True))
if __name__ == "__main__":
raise SystemExit(main())
Delivery helper — ranges, WAV and one explicit change
Author tutorial code, not the SDK. No queue purge, hidden start, automatic track creation or retry. Structural hashes do not freeze Deliver settings or validate Fairlight routing.
"""S08 tutorial: explicit range settings and one audio import/append at a time.
Python 3.9+. This is author code, not a Blackmagic SDK. No render jobs, automatic
retry/rollback, track creation, normalization, UI commands or project save here.
Whole-frame range convention: [start,end) -> inclusive MarkOut=end-1, absolute
record coordinates. Qualify it with a one-frame test in your own Resolve build.
Two observations and a hash detect some changes; they are not a lock/transaction.
"""
from __future__ import annotations
import hashlib
import json
import math
import os
from pathlib import Path
import re
import stat
import wave
MAX_SAFE = 2**53 - 1
MAX_TRACKS = 128
MAX_ITEMS = 10000
MAX_WAV_BYTES = 512 * 1024 * 1024
class PreflightError(RuntimeError):
"""No intended mutation was sent by this call."""
def call(obj, name, *args):
fn = getattr(obj, name, None)
if not callable(fn):
raise PreflightError('Required method unavailable: ' + name)
return fn(*args)
def text(v, label):
if not isinstance(v, str) or not v.strip() or len(v) > 4096 or any(ord(c) < 32 for c in v):
raise PreflightError(label + ' must be a non-empty bounded string without controls')
return v
def integer(v, label):
if type(v) is not int or abs(v) > MAX_SAFE:
raise PreflightError(label + ' must be an exact safe integer (not bool)')
return v
def frame(v):
if type(v) not in (int, float) or not math.isfinite(v) or abs(v) > MAX_SAFE:
raise PreflightError('Invalid frame coordinate')
return v # Preserve subframes; render boundaries are validated separately.
def flag(v):
if type(v) is not bool:
raise PreflightError('Unknown boolean state')
return v
def bounded_list(v, limit=MAX_ITEMS):
if not isinstance(v, list) or len(v) > limit or any(x is None for x in v):
raise PreflightError('Expected a bounded list; None is not an empty list')
return v
def uid(obj):
return text(call(obj, 'GetUniqueId'), 'unique ID')
def digest(value):
return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True,
separators=(',', ':'), allow_nan=False).encode()).hexdigest()
def range_settings(origin, start, end, coordinates='absolute'):
"""Pure whole-frame model; origin is added only to explicitly relative input."""
origin, start, end = [integer(v, n) for v, n in ((origin, 'origin'), (start, 'start'), (end, 'end'))]
if coordinates not in ('absolute', 'offset'):
raise PreflightError('coordinates must be absolute or offset')
a, b = (origin + start, origin + end) if coordinates == 'offset' else (start, end)
integer(a, 'absolute start'); integer(b, 'absolute end')
if a < origin or b <= a:
raise PreflightError('Need a non-empty forward range at or after timeline origin')
integer(b - a, 'range length')
return {'SelectAllFrames': False, 'MarkIn': a, 'MarkOut': b - 1}
def envelope(rows):
"""Return enclosing interval and union length, not pixel visibility or a solo."""
if not isinstance(rows, list) or not rows or len(rows) > MAX_ITEMS:
raise PreflightError('No usable V1 intervals or scope exceeded')
spans = []
for row in rows:
if not isinstance(row, (list, tuple)) or len(row) != 2:
raise PreflightError("Each interval must contain exactly start and end")
a, b = row
if type(a) is float and a.is_integer(): a = int(a)
if type(b) is float and b.is_integer(): b = int(b)
integer(a, 'clip start'); integer(b, 'clip end')
if b <= a: raise PreflightError('Empty or reversed clip interval')
spans.append((a, b))
merged = []
for a, b in sorted(spans):
if merged and a <= merged[-1][1]: merged[-1][1] = max(b, merged[-1][1])
else: merged.append([a, b])
first, last = merged[0][0], merged[-1][1]
duration = integer(last - first, 'envelope duration')
covered = sum(b-a for a,b in merged)
return {'start': first, 'end_exclusive': last, 'duration_frames': duration,
'covered_frames': covered, 'gap_frames': duration-covered, 'union': merged,
'v1_soloed': False, 'pixel_visibility_verified': False}
def current(resolve, project_id=None, timeline_id=None):
manager = call(resolve, 'GetProjectManager')
if manager is None: raise PreflightError('No Project Manager')
project = call(manager, 'GetCurrentProject')
if project is None: raise PreflightError('No current project')
timeline = call(project, 'GetCurrentTimeline')
if timeline is None: raise PreflightError('No current timeline')
pid, tid = uid(project), uid(timeline)
if project_id is not None and pid != text(project_id, 'project ID'):
raise PreflightError('Active project differs from the requested target')
if timeline_id is not None and tid != text(timeline_id, 'timeline ID'):
raise PreflightError('Active timeline differs from the requested target')
return project, timeline, pid, tid
def snapshot(resolve, project_id=None, timeline_id=None):
project, timeline, pid, tid = current(resolve, project_id, timeline_id)
tracks, seen, total = [], set(), 0
for kind in ('video', 'audio', 'subtitle'):
count = integer(call(timeline, 'GetTrackCount', kind), 'track count')
if not 0 <= count <= MAX_TRACKS: raise PreflightError('Track count outside tutorial limit')
for index in range(1, count+1):
row = {'kind': kind, 'index': index,
'enabled': flag(call(timeline, 'GetIsTrackEnabled', kind, index)),
'locked': flag(call(timeline, 'GetIsTrackLocked', kind, index)), 'items': []}
if kind == 'audio': row['subtype'] = text(call(timeline, 'GetTrackSubType', kind, index), 'audio subtype')
for item in bounded_list(call(timeline, 'GetItemListInTrack', kind, index)):
total += 1
if total > MAX_ITEMS: raise PreflightError('Total item limit exceeded')
item_id = uid(item)
if item_id in seen: raise PreflightError('Duplicate timeline item ID')
seen.add(item_id)
a, b, d = [frame(call(item, method, True)) for method in ('GetStart', 'GetEnd', 'GetDuration')]
if b <= a or d <= 0 or b-a != d:
raise PreflightError('Unqualified clip boundary/duration convention')
row['items'].append({'id': item_id, 'start': a, 'end': b, 'duration': d,
'enabled': flag(call(item, 'GetClipEnabled'))})
row['items'].sort(key=lambda x: x['id']); tracks.append(row)
jobs = bounded_list(call(project, 'GetRenderJobList'))
job_ids = []
for job in jobs:
if not isinstance(job, dict): raise PreflightError('Invalid render queue row')
job_ids.append(text(job.get('JobId'), 'JobId'))
if len(set(job_ids)) != len(job_ids): raise PreflightError('Duplicate JobId')
active = flag(call(project, 'IsRenderingInProgress'))
result = {'project_id': pid, 'timeline_id': tid,
'origin': integer(call(timeline, 'GetStartFrame'), 'timeline origin'),
'raw_timeline_end': integer(call(timeline, 'GetEndFrame'), 'timeline end'),
'render_mode': integer(call(project, 'GetCurrentRenderMode'), 'render mode'),
'is_rendering': active, 'job_ids': sorted(job_ids), 'tracks': tracks}
current(resolve, pid, tid)
return project, timeline, result
def dictionary(raw, label):
if not isinstance(raw, dict) or len(raw) > 1000 or not all(isinstance(k, str) and isinstance(v, str) and k and v for k,v in raw.items()):
raise PreflightError('Invalid ' + label + ' dictionary')
return raw
def audio_catalog(project):
formats = dictionary(call(project, 'GetAudioRenderFormats'), 'audio formats')
codecs = dictionary(call(project, 'GetAudioRenderCodecs', 'wav'), 'WAV codecs') if 'wav' in formats.values() else {}
return {'formats': formats, 'wav_codecs': codecs}
def output_path(directory, name):
p = Path(text(directory, 'output directory')).expanduser()
if not p.is_absolute() or not p.is_dir(): raise PreflightError('Output directory must already exist and be absolute')
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_-]{0,79}', text(name, 'output name')):
raise PreflightError('Use a portable base name: letters, digits, _ and -; no extension')
# A pre-existing name is refused. This is not a filesystem reservation.
children = list(p.iterdir())
if len(children) > MAX_ITEMS: raise PreflightError('Output folder too large for this tutorial')
if any(x.name == name or x.name.startswith(name+'.') for x in children):
raise PreflightError('Output base name already exists; choose a new name')
return str(p)
def setting_plan(resolve, project_id, timeline_id, mode, *, start=None, end=None,
coordinates='absolute', codec=None, directory=None, name=None,
sample_rate=48000, bit_depth=24):
project, timeline, s = snapshot(resolve, project_id, timeline_id)
if s['is_rendering']: raise PreflightError('Renderer is busy; no stop will be sent')
if s['render_mode'] != 1: raise PreflightError('Select Single clip manually; no hidden render mode change')
all_items = [item for t in s['tracks'] for item in t['items']]
if not all_items: raise PreflightError('Timeline has no items; inspect empty content manually')
extra = {}
if mode == 'v1':
track = next((t for t in s['tracks'] if t['kind']=='video' and t['index']==1), None)
if track is None or not track['enabled']: raise PreflightError('No enabled V1')
extra = envelope([[i['start'], i['end']] for i in track['items'] if i['enabled']])
settings = range_settings(s['origin'], extra['start'], extra['end_exclusive'])
elif mode == 'range':
settings = range_settings(s['origin'], start, end, coordinates)
elif mode in ('entire', 'audio'):
settings = {'SelectAllFrames': True}
else: raise PreflightError('Unknown settings mode')
if mode in ('v1', 'range') and settings['MarkOut']+1 > max(i['end'] for i in all_items):
raise PreflightError('Range exceeds observed item envelope; no silent clamp')
if mode == 'audio':
catalog = audio_catalog(project)
if 'wav' not in catalog['formats'].values() or codec not in catalog['wav_codecs'].values():
raise PreflightError('Use the exact WAV codec token from the live audio catalogue')
if integer(sample_rate, 'sample rate') not in (44100,48000,96000) or integer(bit_depth, 'bit depth') not in (16,24):
raise PreflightError('Tutorial supports 44.1/48/96 kHz and 16/24-bit PCM requests only')
settings.update({'ExportVideo': False, 'ExportAudio': True, 'AudioFormat': 'wav',
'AudioCodec': codec, 'AudioSampleRate': sample_rate,
'AudioBitDepth': bit_depth, 'TargetDir': output_path(directory, name),
'CustomName': name, 'UseUniqueFilenames': True,
'ReplaceExistingFilesInPlace': False})
extra = {'audio_catalog': catalog, 'bus_routing_verified': False}
plan = {'action': mode, 'project_id': s['project_id'], 'timeline_id': s['timeline_id'],
'settings': settings, 'observed_hash': digest(s), 'details': extra,
'boundary_convention': '[start,end) -> inclusive MarkOut; qualify locally',
'settings_fully_read_back': False, 'atomic': False}
plan['plan_hash'] = digest(plan)
return project, timeline, s, plan
def apply_settings(resolve, project_id=None, timeline_id=None, mode='entire', *,
apply=False, expect_plan=None, test_copy=False,
range_calibrated=False, audio_reviewed=False, **options):
project, timeline, before, plan = setting_plan(resolve, project_id, timeline_id, mode, **options)
result = {'status': 'plan', 'plan': plan, 'mutations_sent': 0, 'file_verified': False, 'retry_allowed': False}
if not apply: return result
text(project_id, '--project-id'); text(timeline_id, '--timeline-id')
if not test_copy or expect_plan != plan['plan_hash']:
raise PreflightError('Apply needs --test-copy and the exact current --expect-plan')
if mode in ('v1','range') and not range_calibrated:
raise PreflightError('Qualify one-frame and non-zero-origin output; acknowledge --range-calibrated')
if mode == 'audio' and not audio_reviewed:
raise PreflightError('Review PCM choice, source mapping and Fairlight buses; acknowledge --audio-reviewed')
fn = getattr(project, 'SetRenderSettings', None)
if not callable(fn): raise PreflightError('SetRenderSettings unavailable')
_, _, _, again = setting_plan(resolve, project_id, timeline_id, mode, **options)
if again != plan: raise PreflightError('Observed state changed before mutation')
result['mutations_sent'] = 1 # Mark BEFORE dispatch. No retry after any exception.
try:
accepted = fn(plan['settings'])
if accepted is not True:
result.update(status='settings_not_confirmed', return_type=type(accepted).__name__)
return result
_, _, after = snapshot(resolve, project_id, timeline_id)
if after != before:
result.update(status='postcheck_changed', observed_after_hash=digest(after)); return result
result.update(status='accepted_manual_check_required', settings_fully_read_back=False)
except (Exception, KeyboardInterrupt) as exc:
result.update(status='outcome_unknown', error_type=type(exc).__name__)
return result
def file_stamp(p):
s = p.stat()
if not stat.S_ISREG(s.st_mode): raise PreflightError('Expected a regular local file')
return [s.st_dev, s.st_ino, s.st_size, s.st_mtime_ns]
def inspect_wav(path):
"""Bounded PCM read; no semantic silence/loudness/channel-routing certification."""
p = Path(text(str(path), 'WAV path')).expanduser()
if not p.is_absolute() or p.is_symlink(): raise PreflightError('Use an absolute regular WAV path, not a symlink')
before = file_stamp(p)
if not 44 <= before[2] <= MAX_WAV_BYTES: raise PreflightError('WAV size outside 44 B..512 MiB tutorial scope')
sha = hashlib.sha256()
try:
with p.open('rb') as f:
for chunk in iter(lambda: f.read(1024*1024), b''): sha.update(chunk)
with wave.open(str(p), 'rb') as f:
channels, width, rate, samples = f.getnchannels(), f.getsampwidth(), f.getframerate(), f.getnframes()
if f.getcomptype() != 'NONE' or channels not in (1,2) or width not in (2,3) or rate not in (44100,48000,96000) or samples <= 0:
raise PreflightError('Use non-empty mono/stereo 16/24-bit PCM WAV at 44.1/48/96 kHz')
expected = samples * channels * width
if expected > MAX_WAV_BYTES: raise PreflightError('Decoded WAV size exceeds scope')
read_bytes = 0
while True:
chunk = f.readframes(65536)
if not chunk: break
read_bytes += len(chunk)
if read_bytes > expected: raise PreflightError('Unexpected WAV payload size')
if read_bytes != expected: raise PreflightError('Truncated WAV payload')
except (wave.Error, EOFError) as exc:
raise PreflightError('Unsupported or malformed PCM WAV') from exc
if file_stamp(p) != before: raise PreflightError('WAV changed while reading')
return {'path': str(p), 'stamp': before, 'sha256': sha.hexdigest(), 'channels': channels,
'sample_rate': rate, 'bit_depth': width*8, 'sample_frames': samples,
'duration_fraction': f'{samples}/{rate}', 'audible_content_verified': False}
def folder_state(project):
pool = call(project, 'GetMediaPool')
if pool is None: raise PreflightError('No Media Pool')
folder = call(pool, 'GetCurrentFolder')
if folder is None: raise PreflightError('No current folder')
rows, objects = [], {}
for item in bounded_list(call(folder, 'GetClipList')):
item_id = uid(item)
if item_id in objects: raise PreflightError('Duplicate media ID')
objects[item_id] = item
path = call(item, 'GetClipProperty', 'File Path')
if not isinstance(path, str): raise PreflightError('Unreadable source path in current folder')
rows.append({'id': item_id, 'path': path})
return pool, folder, {'folder_id': uid(folder), 'items': sorted(rows,key=lambda x:x['id'])}, objects
def same_file(path, expected):
if not path: return False
try: return os.path.samefile(path, expected)
except OSError: return False
def audio_plan(resolve, project_id, timeline_id, step, wav, *, folder_id=None,
media_id=None, track_index=1, record_frame=None):
project, timeline, s = snapshot(resolve, project_id, timeline_id)
if s['is_rendering']: raise PreflightError('Renderer must be idle')
pool, folder, folder_data, objects = folder_state(project)
if folder_id is not None and folder_data['folder_id'] != text(folder_id,'folder ID'):
raise PreflightError('Current Media Pool folder differs from expected folder')
payload = {'FilePath': wav['path']}
media = None
if step == 'import':
if any(same_file(r['path'], wav['path']) for r in folder_data['items']):
raise PreflightError('Source already exists in current folder; inspect and use its exact media ID')
elif step == 'append':
text(media_id, 'media ID'); integer(track_index, 'track index'); integer(record_frame, 'record frame')
if media_id not in objects: raise PreflightError('Media ID not in current folder')
media = objects[media_id]
if not same_file(call(media, 'GetClipProperty', 'File Path'), wav['path']):
raise PreflightError('Media ID points to a different source file')
channels = call(media, 'GetClipProperty', 'Audio Ch')
if channels != str(wav['channels']): raise PreflightError('Audio channel metadata differs from the checked WAV')
marks = call(media, 'GetMarkInOut')
if not isinstance(marks, dict) or any(value for value in marks.values()):
raise PreflightError('Source has marks or unknown mark state; clear/review separately before whole-source append')
track = next((t for t in s['tracks'] if t['kind']=='audio' and t['index']==track_index), None)
expected_subtype = 'mono' if wav['channels']==1 else 'stereo'
if track is None or track['locked'] or not track['enabled'] or track['items'] or track['subtype'] != expected_subtype:
raise PreflightError('Use an existing empty, enabled, unlocked mono/stereo track matching the WAV')
if record_frame < s['origin']: raise PreflightError('recordFrame precedes timeline start; no implicit zero origin')
payload = {'media_id': media_id, 'mediaType': 2, 'trackIndex': track_index, 'recordFrame': record_frame}
else: raise PreflightError('Choose import or append as separate steps')
plan = {'step': step, 'project_id': s['project_id'], 'timeline_id': s['timeline_id'],
'folder_id': folder_data['folder_id'], 'wav': wav, 'payload': payload,
'observed_hash': digest(s), 'folder_hash': digest(folder_data), 'atomic': False}
plan['plan_hash'] = digest(plan)
return project, timeline, pool, media, s, folder_data, plan
def audio_action(resolve, wav, project_id=None, timeline_id=None, step='import', *, apply=False,
test_copy=False, expect_plan=None, audio_reviewed=False, **options):
project, timeline, pool, media, before, folder_before, plan = audio_plan(resolve, project_id, timeline_id, step, wav, **options)
report = {'status': 'plan', 'plan': plan, 'mutations_sent': 0, 'returned_ids': [], 'retry_allowed': False}
if not apply: return report
text(project_id, 'project ID'); text(timeline_id, 'timeline ID'); text(options.get('folder_id'),'folder ID')
if not test_copy or expect_plan != plan['plan_hash'] or (step=='append' and not audio_reviewed):
raise PreflightError('Apply needs test copy, exact plan and audio review for append')
method = 'ImportMedia' if step=='import' else 'AppendToTimeline'
fn = getattr(pool, method, None)
if not callable(fn): raise PreflightError(method + ' unavailable')
fresh_wav = inspect_wav(wav['path'])
*_, fresh_plan = audio_plan(resolve, project_id, timeline_id, step, fresh_wav, **options)
if fresh_plan != plan: raise PreflightError('Source or observed state changed before dispatch')
payload = [wav['path']] if step=='import' else [{k:v for k,v in plan['payload'].items() if k!='media_id'} | {'mediaPoolItem':media}]
report['mutations_sent'] = 1
try:
result = bounded_list(fn(payload))
for item in result: report['returned_ids'].append(uid(item))
if len(result) != 1: raise RuntimeError('Expected exactly one returned object')
_, _, after = snapshot(resolve, project_id, timeline_id)
_, _, folder_after, _ = folder_state(project)
added_id = report['returned_ids'][0]
if step=='import':
old_ids = {i['id'] for i in folder_before['items']}
if after != before or added_id in old_ids or folder_after['folder_id'] != folder_before['folder_id']:
raise RuntimeError('Unexpected target or timeline change during import')
retained = [i for i in folder_after['items'] if i['id']!=added_id]
if (len(folder_after['items']) != len(folder_before['items'])+1
or {i['id'] for i in folder_after['items']} != old_ids | {added_id}
or retained != folder_before['items']) or not same_file(call(result[0],'GetClipProperty','File Path'),wav['path']):
raise RuntimeError('Import postcheck does not match exact source/folder delta')
else:
if folder_after != folder_before: raise RuntimeError('Media Pool changed during append')
if any(added_id==i['id'] for t in before['tracks'] for i in t['items']): raise RuntimeError('Returned old item ID')
matches = [(t,i) for t in after['tracks'] for i in t['items'] if i['id']==added_id]
if len(matches)!=1: raise RuntimeError('New item not uniquely present')
track,item = matches[0]
if track['kind']!='audio' or track['index']!=options['track_index'] or item['start']!=options['record_frame']:
raise RuntimeError('Destination or record coordinate differs')
linked = call(result[0],'GetMediaPoolItem')
if linked is None or uid(linked)!=options['media_id']: raise RuntimeError('Appended item references another source')
# Only the requested item and raw timeline end may change.
stripped = json.loads(json.dumps(after))
for row in stripped['tracks']: row['items'] = [i for i in row['items'] if i['id']!=added_id]
stripped['raw_timeline_end'] = before['raw_timeline_end']
if stripped != before: raise RuntimeError('Other timeline content or queue changed')
report.update(status='observed_single_change_audio_unverified', audible_content_verified=False)
except (Exception, KeyboardInterrupt) as exc:
report.update(status='outcome_unknown', error_type=type(exc).__name__)
return report
def cli_write(parser):
parser.add_argument('--project-id'); parser.add_argument('--timeline-id')
parser.add_argument('--apply',action='store_true'); parser.add_argument('--test-copy',action='store_true')
parser.add_argument('--expect-plan'); parser.add_argument('--range-calibrated',action='store_true')
parser.add_argument('--audio-reviewed',action='store_true')
def cli_flags(args):
return {k:getattr(args,k) for k in ('project_id','timeline_id','apply','test_copy','expect_plan','range_calibrated','audio_reviewed')}
def print_result(work):
try: report = work()
except PreflightError as exc: report = {'status':'preflight_refused','error':str(exc),'mutations_sent':0}
except KeyboardInterrupt: report = {'status':'observation_interrupted','retry_allowed':False}
except Exception as exc: report = {'status':'read_or_connection_failed','error_type':type(exc).__name__}
print(json.dumps(report,ensure_ascii=False,indent=2,allow_nan=False),flush=True)
if report['status'] in ('plan','observed','metadata_matches','observed_single_change_audio_unverified'): return 0
return 2 if report['status']=='preflight_refused' else 3
Queue helper — unchanged S07 implementation
Separate add, start and wait actions. Capture the returned job ID. A timeout ends observation, not the render; do not repeat an unknown mutation.
"""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
Output verifier — inspect the exact file with ffprobe
Read-only local metadata checks with explicit expectations. No newest-file guessing, provenance claim, playback certification or repair. Counting video frames is opt-in and may read the entire stream.
"""Inspect exactly one local render file with a trusted ffprobe. Never pick 'newest'.
Metadata matching is NOT job provenance, listening, loudness, color, full decoding
or creative acceptance. ffprobe is a separate installed dependency, not bundled.
"""
from __future__ import annotations
import argparse
from fractions import Fraction
import json
from pathlib import Path
import subprocess
from resolve_api_delivery import PreflightError, file_stamp, integer, print_result
def verify_file(path, ffprobe, *, expected_video=None, expected_audio=None,
sample_rate=None, channels=None, bit_depth=None, video_frames=None,
timeout=60):
p, exe = Path(path).expanduser(), Path(ffprobe).expanduser()
if not p.is_absolute() or p.is_symlink() or not exe.is_absolute() or not exe.is_file():
raise PreflightError('Use an absolute regular media file and an absolute trusted ffprobe executable')
before = file_stamp(p)
if before[2] <= 0: raise PreflightError('Empty media file')
if not 1 <= integer(timeout,'timeout') <= 3600: raise PreflightError('Timeout must be 1..3600 seconds')
for v,n in ((expected_video,'expected video streams'),(expected_audio,'expected audio streams')):
if v is not None and not 0 <= integer(v,n) <= 64: raise PreflightError('Stream count must be 0..64')
for v,n in ((sample_rate,'sample rate'),(channels,'channels'),(bit_depth,'bit depth'),(video_frames,'video frames')):
if v is not None and integer(v,n) <= 0: raise PreflightError(n+' must be positive')
command = [str(exe), '-v', 'error', '-protocol_whitelist', 'file',
'-show_entries', 'stream=index,codec_type,codec_name,sample_rate,channels,channel_layout,bits_per_sample,bits_per_raw_sample,nb_read_frames,duration,time_base:format=format_name,duration,size',
'-of', 'json']
if video_frames is not None: command.append('-count_frames')
command += ['-i', str(p)]
try:
result = subprocess.run(command, capture_output=True, timeout=timeout, check=False, shell=False)
except subprocess.TimeoutExpired:
return {'status':'probe_timeout','metadata_matches':False,'content_verified':False}
if result.returncode != 0 or result.stderr.strip():
return {'status':'probe_failed','returncode':result.returncode,'metadata_matches':False,
'stderr':result.stderr[:2000].decode('utf-8','replace'),'content_verified':False}
if len(result.stdout) > 2*1024*1024: raise PreflightError('Probe report exceeds 2 MiB; not a native memory limit')
report = json.loads(result.stdout)
streams = report.get('streams')
if not isinstance(streams,list) or not streams or len(streams)>128 or not all(isinstance(x,dict) for x in streams):
raise PreflightError('Missing or invalid stream report')
if file_stamp(p) != before: raise PreflightError('Output file changed while probing')
video=[s for s in streams if s.get('codec_type')=='video']
audio=[s for s in streams if s.get('codec_type')=='audio']
mismatches=[]; checks=[]
def compare(actual, expected, label):
if expected is None: return
checks.append(label)
if actual != expected: mismatches.append({'field':label,'expected':expected,'actual':actual})
def numeric(raw):
if not isinstance(raw,(int,str)) or isinstance(raw,bool): return None
try: return int(raw)
except ValueError: return None
compare(len(video),expected_video,'video_streams'); compare(len(audio),expected_audio,'audio_streams')
if any(x is not None for x in (sample_rate, channels, bit_depth)):
if not audio: mismatches.append({'field':'audio','expected':'at least one audio stream','actual':0})
for s in audio:
compare(numeric(s.get('sample_rate')),sample_rate,'sample_rate:'+str(s.get('index')))
compare(numeric(s.get('channels')),channels,'channels:'+str(s.get('index')))
raw_bits=numeric(s.get('bits_per_raw_sample')) or numeric(s.get('bits_per_sample'))
compare(raw_bits,bit_depth,'bit_depth:'+str(s.get('index')))
if video_frames is not None:
if len(video)!=1: mismatches.append({'field':'counted_video_streams','expected':1,'actual':len(video)})
else: compare(numeric(video[0].get('nb_read_frames')),video_frames,'video_frame_count')
complete=expected_video is not None and expected_audio is not None
return {'status':'metadata_mismatch' if mismatches else 'metadata_matches' if complete else 'observed',
'path':str(p),'stamp':before,'probe':report,'checked_fields':checks,'mismatches':mismatches,
'metadata_matches':complete and not mismatches,'job_provenance_verified':False,
'content_verified':False,'audio_listened':False,'full_decode_performed':False}
def add_arguments(p):
p.add_argument('--file',required=True); p.add_argument('--ffprobe',required=True)
p.add_argument('--expected-video',type=int); p.add_argument('--expected-audio',type=int)
p.add_argument('--sample-rate',type=int); p.add_argument('--channels',type=int)
p.add_argument('--bit-depth',type=int); p.add_argument('--video-frames',type=int)
p.add_argument('--timeout',type=int,default=60)
def from_args(a):
return verify_file(a.file,a.ffprobe,expected_video=a.expected_video,expected_audio=a.expected_audio,
sample_rate=a.sample_rate,channels=a.channels,bit_depth=a.bit_depth,
video_frames=a.video_frames,timeout=a.timeout)
def main():
p=argparse.ArgumentParser(description=__doc__); add_arguments(p); a=p.parse_args()
return print_result(lambda:from_args(a))
if __name__ == "__main__":
raise SystemExit(main())
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.
"""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
1. Inspect the queue
python full_render_pipeline.py inspect --project-id PROJECT_IDRead-only. The report may include local paths; redact before sharing.
2. Plan the full span
python full_render_pipeline.py configure --project-id PROJECT_ID --timeline-id TIMELINE_IDFor a limited span use set_render_range.py; for WAV use export_timeline_audio.py.
2a. Apply the setting
python full_render_pipeline.py configure --project-id PROJECT_ID --timeline-id TIMELINE_ID --apply --test-copy --expect-plan PLAN_HASHAfter this, review the remaining Deliver settings manually.
3. Plan, then create one job
python full_render_pipeline.py add --project-id PROJECT_ID --timeline-id TIMELINE_ID
python full_render_pipeline.py add --project-id PROJECT_ID --timeline-id TIMELINE_ID --apply --test-copy --expect-plan PLAN_HASH --settings-reviewedThe second line uses the first add plan hash, not the configure hash. Record the returned ID.
4. Plan, then start one ID
python full_render_pipeline.py start --project-id PROJECT_ID --job-id JOB_ID
python full_render_pipeline.py start --project-id PROJECT_ID --job-id JOB_ID --apply --test-copy --expect-plan PLAN_HASH --settings-reviewedUse the fresh start plan and review that specific job. These commands are intended to be run manually in sequence.
5. Bounded observation
python full_render_pipeline.py wait --project-id PROJECT_ID --job-id JOB_ID --timeout 120 --interval 1 --max-polls 180120 seconds is an observation budget, not a render-time limit.
6. Inspect the exact file
python full_render_pipeline.py verify --file /absolute/render-test/result.mov --ffprobe /usr/bin/ffprobe --expected-video 1 --expected-audio 1 --video-frames 240 --timeout 120240 is the expected frame count of this teaching example. Substitute your actual specification and review the media.
What the code does
Plan before writing
The entry script calls the author helper below. Without --apply, mutating steps only produce a plan; source/file inspection reads local data and may take time.
One mutation scope
Configuration, import, placement and queue actions are separated. A hash compares observed fields; it does not create a transaction or detect every possible concurrent edit.
Read the result status
Exit code 0 means a plan/observation or a narrow check, 2 means preflight refusal, and 3 means uncertainty, mismatch or required manual review. A Complete status does not set file_verified/content_verified to True.
The bundle does not include the official SDK, ffprobe or ffmpeg. Use trusted installed tools.
The stated restrictions are tutorial policy, not proof that Resolve cannot do more.
JSON reports may contain IDs and local paths. There is no automatic redaction; review before sharing.
Live Resolve calls, boundary qualification and actual audio routing are not certified here.
DEBUG / OBSERVATION FIRST
An error is a starting point, not a diagnosis.
Preflight refused
Possible reasonWrong target, mode, range, track state or input file.
Next checkRead the reason and prepare the test case. Do not substitute zero, an empty list or another codec token for the error.
Settings accepted; review still required
Possible reasonA call acknowledgement does not read back all Deliver parameters.
Next checkReview target, range, format and audio in the UI before separate job creation. Exit code 3 is not permission to retry blindly.
Unknown outcome after dispatch
Possible reasonThe mutation or subsequent inspection failed or was interrupted.
Next checkRetain returned IDs and inspect actual state. No automatic retry, rollback, queue purge or file deletion is performed.
PRACTICE / YOUR OWN EVIDENCE
One pipeline with an unrelated job present
- Leave an unrelated Ready job in a test queue and record its ID.
- Run configure/add/start separately for a new job and record its ID.
- Let a short observer timeout expire, then inspect the same job again and verify the exact file.
The unrelated job is neither started nor deleted and only one new job is created. Observer timeout does not stop rendering; file checks remain separate from Complete.
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 no run-all command?
Without complete profile readback and live contract qualification, an automatic sequence hides important checks. The full route is executable here, but each stage is separately authorized.[20]
Can I continue after restarting Python?
You can manually choose the next stage after retaining the exact JobId and establishing the preceding outcome. Automatic durable resume and recovery of an unknown write are not implemented.[20]
What does metadata_matches mean?
Only the supplied metadata expectations matched the inspected file. It is not quality certification, JobId provenance or proof of full decoding.[21]
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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- Blackmagic DaVinciResolveScript.pyi — ranges, audio formats and append contracts
S08 review, 6 October 2026: SelectAllFrames, MarkIn/MarkOut, AudioFormat/AudioCodec, separate audio catalogues and AppendClipInfo. Third-party hosting of the primary interface, not a live test. Compare your local document; qualify boundaries with one- and two-frame tests.
- FFmpeg — ffprobe stream selection, JSON output and frame counting
ffprobe documentation: JSON, show_entries and count_frames. Metadata inspection does not establish creative correctness, bus routing, provenance or full decoding of all streams.
- Python — wave: reading PCM WAV headers and sample frames
wave reads PCM WAV. Extended-header support depends on Python version; this tutorial limits formats, reads the full PCM payload and does not claim audible-content verification.