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

RESOLVE API / TIMELINE LAB / 06

Как экспортировать карту таймлайна в JSON

Полный отчёт — в новый файл. Ошибка чтения не становится пустым экспортом.

Версионированная схема, UTF-8, проверка полноты, запрет перезаписи и чтение записанных байтов. Разделяем отчёт о структуре и настоящий резервный формат монтажа.

НА ВЫХОДЕ

Новый локальный JSON с проверенными байтами, размером и SHA-256 — без изменения Resolve.

PYTHON / RESOLVEУЧЕБНАЯ МОДЕЛЬ
01report = collect_map(resolve)

Сначала получаем ограниченную карту и проверяем два чтения. Ошибка обхода не создаёт файл с пустым списком.

ПРОВЕРКА

Полнота заявлена явно.

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

01 / BEFORE THE CODE

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

01

Подключение уже работает

Используйте локальный Python 3.8+ и Resolve Studio. Проверьте подключение по S01, затем положите основной пример и оба helper в одну папку. Официальный модуль Blackmagic берётся из установленного Resolve, не из этого комплекта.

02

Одна проверяемая цель

Откройте небольшой тестовый проект и нужный таймлайн. Сохраните изменения и не переключайте активные объекты во время чтения. Для создания нового таймлайна заранее выберите папку Media Pool; открытый таймлайн не обязателен.

03

Граница примера

Обход ограничен верхним уровнем: до 128 дорожек каждого типа и 10 000 элементов всего. Вложенные монтажи не раскрываются. Ограничение проверяется после получения списка: оно не прерывает зависший нативный вызов и не ограничивает его выделение памяти.

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

02 / API CONTRACT

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

Python / authorcollect_map(resolve)
Вход
Подключённый объект Resolve.
Возвращает
JSON-совместимая карта с complete и issues.
Проверить
Неполную карту не экспортировать как полную.
Pythonjson.dumps(..., allow_nan=False)
Вход
Только явная схема примитивных данных.
Возвращает
Текст JSON.
Проверить
Никакого default=str для прокси.
PythonPath.open("xb")
Вход
Новый абсолютный путь .json.
Возвращает
Двоичный файловый поток.
Проверить
Существующее имя блокирует запись.
PythonPath.read_bytes()
Вход
Только что созданный путь.
Возвращает
Байты файла.
Проверить
Сравнить с payload до заявления об успехе.

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

03 / UNDERSTAND

JSON-карта не заменяет DRT, DRP или исходники

В отчёте есть монтажная структура верхнего уровня, но нет полного состояния эффектов, Fusion-графов, цветокоррекции, автоматизации Fairlight и вложенных монтажей. Отсутствие этих данных — заявленная граница схемы, а не ошибка сериализации. Из этого JSON нельзя обещать восстановить исходный проект.

Схема маркируется resolve-timeline-map/1. При изменении значения полей или единиц нужна осознанная версия формата. Программа-потребитель должна проверить schema_version, scope, units и complete до интерпретации items. Не используйте имя файла как доказательство формата и полноты.[3][10]

04 / UNDERSTAND

Сериализуем данные, не API-объекты

collect_map заранее превращает ответы в строки, числа, списки, словари, bool и null. json.dumps получает allow_nan=False: NaN и бесконечность не выдаются за нормальные координаты. ensure_ascii=False и UTF-8 сохраняют читаемые русские имена, не меняя их идентичность.

Не добавляйте default=str ради обхода TypeError: он превратит неизвестный прокси в бесполезную строку и спрячет дефект схемы. Сначала решите, какие проверяемые поля нужны от нового объекта. Сырые временные значения и нормализованный range остаются отдельными полями.[10]

05 / UNDERSTAND

Предпросмотр не пишет файл; --output создаёт новый

Без --output пример печатает отчёт в терминал. С параметром требуется абсолютный путь с расширением .json в существующей доверенной папке. Относительные пути и автоматическое создание каталога исключены. Если файл уже есть, получите FileExistsError и выберите новое имя после проверки.

Сериализация и лимит 16 МБ проверяются до открытия файла; режим xb исключает обычную перезапись существующего имени. Но это не транзакция файловой системы: нехватка места или сбой во время записи может оставить новый неполный файл. Пример не удаляет его автоматически и не обещает fsync, сетевую атомарность или защиту от изменения родительской папки.[11]

06 / UNDERSTAND

Проверьте байты — и содержимое перед передачей

После записи читаются байты и сравниваются с подготовленным payload. Итог сообщает путь, размер и SHA-256. Это проверка записанного содержимого, не подпись автора и не доказательство неизменности после завершения скрипта. Повторный экспорт лучше сохранять под отдельным именем.

Хотя исходные пути не читаются, названия проектов, таймлайнов, клипов и ID могут раскрывать рабочую информацию. Перед передачей вручную обезличьте копию. Никакой отправки на сайт нет. Перенаправление терминала через > живёт по правилам оболочки и может перезаписать файл в обход защиты --output.[10][11]

PYTHON / COMPLETE EXAMPLE

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

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

Положите export_timeline_json.py, resolve_api_timeline.py и resolve_api_connection.py в одну папку. Комплект содержит семь примеров, оба helper и инструкции RU/EN. Код helper полностью доступен ниже. Для расчёта интервалов допущения — часть входа, не подтверждённый анализ картинки.

Скачать все примеры с инструкцией (.zip) ↓
export_timeline_json.py
Скачать .py
"""Export a checked map to a NEW local JSON file. No Resolve writes."""
import argparse
from resolve_api_connection import connect
from resolve_api_timeline import collect_map, export_map, dumps


def main(argv=None):
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("--output", help="Absolute .json path; existing files are never overwritten")
    a = p.parse_args(argv)
    report = collect_map(connect())
    if a.output is None:
        print(dumps(report))  # Preview only. Shell redirection has its own overwrite rules.
    else:
        print(dumps(export_map(report, a.output)))
    return 0 if report["complete"] else 2


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

Timeline-helper — обход, границы и интервальная модель

Авторский код, не SDK. Структурные чтения сравнивают два наблюдения, но не блокируют монтаж. Только create_empty изменяет Resolve. export_map создаёт явно указанный локальный файл. v1_segments не подтверждает видимость пикселей.

resolve_api_timeline.py
Скачать .py
"""S03 tutorial helpers. Python 3.8+. Read-only except create_empty().

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

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

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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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

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

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

resolve_api_connection.py
Скачать .py
"""Load the installed Blackmagic module; do not install a similarly named package.

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

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


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


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


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


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


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


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

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

Предпросмотр

Предпросмотр
python export_timeline_json.py

Печатает данные без создания файла.

Новый файл Windows

Новый файл Windows
python export_timeline_json.py --output "D:/Reports/timeline-s03.json"

Папка должна существовать; выберите собственный путь.

Новый файл Linux/macOS

Новый файл Linux/macOS
python3 export_timeline_json.py --output "/absolute/path/reports/timeline-s03.json"

Замените путь. Нет автоматического создания папок или перезаписи.

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

  1. Собрать

    Сначала получаем ограниченную карту и проверяем два чтения. Ошибка обхода не создаёт файл с пустым списком.

  2. Записать

    Только явно заданный путь .json в существующей папке. Режим xb не перезапишет существующий файл.

  3. Перечитать

    Проверяется содержимое созданного файла, а не только отсутствие исключения write. Это не проверка восстановления монтажа.

  4. Границы результата

    Пример читает Resolve, но --output явно создаёт файл. Это не обещание отсутствия любых изменений файловой системы.

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

Пример читает Resolve, но --output явно создаёт файл. Это не обещание отсутствия любых изменений файловой системы.

Два одинаковых чтения — проверка согласованности наблюдений, не атомарный снимок и не блокировка совместного редактирования.

В JSON могут быть названия проектов, клипов и их ID. Исходные пути не читаются, но автоматического обезличивания остальных полей нет.

DEBUG / OBSERVATION FIRST

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

FileExistsError

Возможная причинаИмя файла уже занято, в том числе ссылкой.

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

Неполная карта

Возможная причинаcomplete false, issues или нет подтверждения двух чтений.

Следующая проверкаИспользуйте диагностический вывод и устраните причину; файл полной карты не создаётся.

JSON не сериализуется

Возможная причинаВ схему попал прокси или нечисловая координата.

Следующая проверкаИсправьте схему данных, а не добавляйте default=str.

Сбой диска или чтения файла

Возможная причинаЗапись могла оставить частичный новый файл.

Следующая проверкаПроверьте файл вручную; успех и автоматическая очистка не заявляются.

PRACTICE / YOUR OWN EVIDENCE

Проверьте полный путь экспорт → чтение

  1. Сначала просмотрите карту без --output.
  2. Экспортируйте в новое имя и загрузите файл обычным json.loads.
  3. Повторите запрос в то же имя: исходные байты должны сохраниться.
КРИТЕРИЙ ПРИЁМКИ

Схема и число вхождений совпали; UTF-8 читается; существующий файл не заменён; неполный отчёт отвергнут до записи.

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

FAQ

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

Можно восстановить проект из JSON?

Нет. Это структурный отчёт с явно ограниченными полями. Для резервного копирования нужен отдельный штатный процесс с тестом восстановления.[3]

Почему не перезаписывать report.json?

Учебный пример защищает существующий отчёт. Осознанную замену, историю версий и атомарную публикацию нужно проектировать отдельно.[11]

SHA-256 доказывает достоверность карты?

Нет, он идентифицирует записанные байты. Достоверность полей определяется чтением и проверками, а не самим хешем.[3]

Экспорт меняет Resolve?

Нет. Изменяется только явно заданный локальный файл. Компонент сайта также не получает этот файл автоматически.[3]

SOURCES / VERSION BOUNDARIES

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

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

  1. Blackmagic Design — DaVinci Resolve Studio 21.1 release notes

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

  2. Blackmagic Design — Support Center

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

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

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

  4. Blackmagic Design — Scripting and Automation

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

  5. Python — json encoder and decoder

    Типы JSON, ensure_ascii, allow_nan и явная сериализация. Наш экспорт не использует default=str для сокрытия неизвестных API-объектов.

  6. Python — open: exclusive creation and file modes

    Режим x создаёт новый файл с отказом при существующем имени. Это не обещает файловую транзакцию или долговечность после аварии.

KEEP BUILDING

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

API / Timeline

Как рассчитать участки V1 вне перекрытий V2

Вычитаем объединение диапазонов V2 из каждого клипа V1, учитываем отключение и дробные границы. Отдельно разбираем прозрачность, переходы и другие случаи, где такой расчёт не определяет видимость.

API / Timeline

Как создать таймлайн через Python API

CreateEmptyTimeline и CreateTimelineFromClips: когда нужен каждый метод, как подготовить план и проверить созданный объект. Исполняемый пример создаёт только пустой тестовый таймлайн после явного подтверждения.

API / Проекты

Как получить текущий проект DaVinci Resolve через Python

Получаем Project Manager, проверяем текущий проект по имени и ID, читаем число таймлайнов и активный Timeline. Пустой таймлайн допускается; автоматическая загрузка и создание проекта исключены.

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