01 / BEFORE THE CODE
До кода — условия запуска.
Сначала выберите контекст
Математический пример работает в Python 3.8+ без Resolve и без сторонних пакетов. Положите основной файл и resolve_api_timecode.py в одну папку. Модуль подключения не нужен.
Точная частота и единицы
Поддерживаемые частоты этого примера: 24, 25, 30, 48, 50, 60 и 24000/1001, 30000/1001, 48000/1001, 60000/1001. Это граница нашего helper, а не список возможностей Resolve. Дробь задаётся явно; строка 29.97 не заменяется молча на 30000/1001.
Граница безопасного опыта
Метки ограничены одними сутками, целыми кадрами и явно выбранным DF/NDF. Переход через полночь, отрицательные длительности, поля interlaced и субкадровый звук не угадываются. Для чтения приложения остановите воспроизведение; для перемещения playhead используйте тестовую копию и не меняйте цель во время вызова.
Ориентируйтесь на актуальные требования Studio и документацию установленной версии, а не на старые инструкции для консоли или Free.[1][4][3]
02 / API CONTRACT
Какой объект. Какой метод. Какой результат.
Clock(rate, mode)- Вход
- Точная строка частоты и ndf/df.
- Возвращает
- Проверенный объект частоты и номинала.
- Проверить
- 29.97 без точной дроби отклоняется.
frames_to_timecode(frames, clock)- Вход
- Целые кадры от нулевой метки TC.
- Возвращает
- Строка NDF или DF.
- Проверить
- Не абсолютный кадр API и не секунды.
offset_to_timecode(offset, start, clock)- Вход
- Смещение от указанного начала.
- Возвращает
- Целевая метка в тех же сутках.
- Проверить
- Начало разбирается до сложения.
timecode_to_offset(tc, start, clock)- Вход
- Полученная метка и та же опора.
- Возвращает
- Исходное целое смещение.
- Проверить
- Сверить обратное преобразование.
Контракты методов Resolve описаны Blackmagic; Python и авторские helper обозначены отдельно. Дополнительные проверки — устройство наших примеров. Непустой объект не доказывает совместимость всего API.[3]
03 / UNDERSTAND
Три разных числа, которые нельзя назвать просто frame
В API встречается координата таймлайна, в задаче монтажёра — смещение от начала, а в таймкоде — номер в системе меток. Если GetStartFrame вернул 86400 и нужна позиция через 240 кадров, координата равна 86640. Для метки 01:00:00:00 при 24 NDF целевой таймкод — 01:00:10:00. Передача 86640 как offset добавила бы начало второй раз.
Поэтому интерфейс функции требует start_timecode и offset раздельно. Нижний уровень frames_to_timecode считает от 00:00:00:00 или 00:00:00;00; основной запускаемый файл переводит именно смещение от вашего начала. Source timecode исходника сюда не подходит: после монтажа и retime он не обязан совпадать с координатами таймлайна.[3]
04 / UNDERSTAND
Номинал нумерации — не физическая частота
При 24 NDF в поле FF используются 00–23. При 24000/1001 NDF поле такое же, но реальные кадры идут немного реже. Поэтому 2400 кадров дают метку 00:01:40:00 в обоих случаях, а физическая длительность составляет соответственно 100 и 100.1 секунды. Форматирование метки и вычисление длительности решают разные задачи.
Старый helper округлял любой FPS и кадр, а недопустимую частоту поднимал до единицы. Новый принимает только перечисленные timebase и целое смещение. Fraction хранит точную выбранную дробь. Ввод 23.976 не считается доказательством частоты 24000/1001 и не переписывается автоматически.[9]
05 / UNDERSTAND
DF пропускает номера, не кадры
Для 30000/1001 DF на каждой минуте, кроме каждой десятой, не используются номера ;00 и ;01 в нулевой секунде. Последовательность выглядит так: 00:00:59;29 → 00:01:00;02. При 60000/1001 DF пропускаются четыре номера: следующий после 00:00:59;59 — 00:01:00;04. Видеокадры между ними не удаляются.
В десятиминутном блоке 29.97 DF находится 17982 кадра; метка на его границе — 00:10:00;00. Для 59.94 DF чисел вдвое больше. Пример обрабатывает блоки и оставшиеся минуты целочисленно. DF при 23.976 здесь не поддерживается: нельзя распространить правило двух пропусков на произвольную частоту.[12]
06 / UNDERSTAND
Проверьте начало и переход минуты
В примере 24 NDF измените только стартовый кадр API, оставив метку и offset неизменными. Координата API изменится, таймкод — нет. Это намеренно: калькулятор не предполагает равенство двух опор и показывает, что именно добавляется в каждом пространстве.
Загрузите пример границы DF, затем измените 1800 на 1799. Смена номера должна произойти между 00:00:59;29 и 00:01:00;02. После этого попробуйте обратное направление. Калькулятор принимает учебные данные и не проверяет, совпадают ли они с настройками открытого проекта.
Целые кадры: округления нет.
Кадр API и метка начала — независимые опорные значения, одно не вычисляется из другого. Калькулятор ограничен одними сутками таймкода, целокадровыми метками и перечисленными частотами. Настройки и границы вашего проекта он не проверяет. Ввод не отправляется и не сохраняется.
07 / UNDERSTAND
Почему нет автоматических 24 часов и дробного FF
Метка из четырёх полей не содержит номер дня. Значение 00:00:05:00 после 23:59:59:23 может принадлежать следующим суткам, а может указывать на начало той же шкалы. Без отдельного day index такой выбор неоднозначен. Здесь переход за пределы суток останавливается, а не маскируется остатком от деления.
FF представляет целый кадр. Субкадровые координаты аудио и методы с subframe_precision требуют отдельного контракта: округлять их внутри форматтера нельзя. Сначала выберите осознанное правило дискретизации; для длительности в секундах это показано в отдельной статье S04.[3]
PYTHON / COMPLETE EXAMPLE
Прочитайте код. Затем запускайте осознанно.
Скачайте frames_to_timecode.py и resolve_api_timecode.py. Для чтения приложения или перехода playhead нужен также resolve_api_connection.py. В комплекте шесть примеров, два helper и инструкции RU/EN. Чистые конвертеры не импортируют модуль Blackmagic; расчёт секунд подключается только с --timeline.
Скачать все примеры с инструкцией (.zip) ↓"""Whole-frame offset from an explicit start label -> timecode. No Resolve required."""
import argparse
import json
from resolve_api_timecode import Clock, offset_to_timecode, timecode_to_offset
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("offset", type=int, help="Whole frames from start, not absolute API coordinates")
p.add_argument("--start", required=True, help="HH:MM:SS:FF or HH:MM:SS;FF")
p.add_argument("--rate", required=True, help="24 or e.g. 30000/1001; no 29.97 alias")
p.add_argument("--mode", choices=("ndf", "df"), required=True)
a = p.parse_args()
clock = Clock(a.rate, a.mode)
tc = offset_to_timecode(a.offset, a.start, clock)
print(json.dumps({"offset_frames": a.offset, "start_timecode": a.start, "timecode": tc,
"rate": str(clock.rate), "mode": clock.mode,
"round_trip_offset": timecode_to_offset(tc, a.start, clock)}, indent=2))
if __name__ == "__main__":
main()
Timecode-helper — точная математика, опоры и контролируемый переход
Авторская реализация, не SDK Blackmagic. DF/NDF-метки отделены от секунд длительности. Clock принимает явно выбранную точную частоту, а не угадывает её из сырой строки FPS. Только move_playhead с apply может вызвать setter, и только один раз.
"""S04 author helpers: exact timecode math and explicitly scoped Resolve reads.
Python 3.8+. Standard library only. Not a Blackmagic SDK, transaction or sandbox.
String timecode policy: HH:MM:SS:FF (NDF) / HH:MM:SS;FF (DF), within one 24h day.
Supported timebases: 24/25/30/48/50/60 and 24000/30000/48000/60000 over 1001.
DF is deliberately limited to 30000/1001 and 60000/1001. No silent rate aliases.
Only move_playhead(..., apply=True) calls a setter, once, after reviewed guards.
"""
from dataclasses import dataclass
from fractions import Fraction
import hashlib
import json
import re
from typing import Any, Dict
class ClockError(ValueError):
"""Input or observed state does not meet this example's contract."""
class OutcomeUnknown(RuntimeError):
"""A setter was dispatched but its result was not fully verified. Do not retry."""
def integer(value: Any, name: str) -> int:
# bool is a subclass of int; never accept it as a frame index.
if type(value) is not int:
raise ClockError(name + " must be an integer, not a float, bool or string")
return value
def exact_number(value: Any, name: str) -> Fraction:
if isinstance(value, Fraction):
return value
if type(value) is int:
return Fraction(value)
if not isinstance(value, str) or len(value) > 80:
raise ClockError(name + " must be exact decimal/rational text (not float)")
# Avoid scientific exponents/unbounded integers and locale-dependent commas.
if not re.fullmatch(r"[+-]?[0-9]{1,18}(?:\.[0-9]{1,18}|/[0-9]{1,18})?", value):
raise ClockError(name + " requires ASCII decimal or numerator/denominator text")
try:
return Fraction(value)
except (ValueError, ZeroDivisionError) as exc:
raise ClockError(name + " is invalid") from exc
SUPPORTED = {Fraction(x): x for x in (24, 25, 30, 48, 50, 60)}
SUPPORTED.update({Fraction(x * 1000, 1001): x for x in (24, 30, 48, 60)})
@dataclass(frozen=True)
class Clock:
rate: Fraction
mode: str
def __post_init__(self):
rate = exact_number(self.rate, "rate")
object.__setattr__(self, "rate", rate)
if rate not in SUPPORTED:
raise ClockError("Unsupported/ambiguous rate. Use e.g. 24 or 30000/1001, not 29.97")
if self.mode not in ("ndf", "df"):
raise ClockError("mode must explicitly be ndf or df")
if self.mode == "df" and rate not in (Fraction(30000, 1001), Fraction(60000, 1001)):
raise ClockError("DF example supports only 30000/1001 and 60000/1001")
@property
def nominal(self) -> int:
return SUPPORTED[self.rate]
@property
def drop(self) -> int:
return self.nominal // 15 if self.mode == "df" else 0
@property
def frames_per_day(self) -> int:
return self.nominal * 86400 - self.drop * (1440 - 144)
def frames_to_timecode(frames: int, clock: Clock) -> str:
"""Actual whole frames since TC zero -> label. No wrapping or rounding."""
frames = integer(frames, "frames")
if not 0 <= frames < clock.frames_per_day:
raise ClockError("Frame count must be within one timecode day; no negative/wrap")
labelled = frames
if clock.drop:
# Independent implementation of the ten-minute/minute numbering rule.
per_ten = clock.nominal * 600 - clock.drop * 9
per_short_minute = clock.nominal * 60 - clock.drop
tens, rest = divmod(frames, per_ten)
skipped_minutes = max(0, (rest - clock.drop) // per_short_minute)
labelled += clock.drop * (tens * 9 + skipped_minutes)
seconds, ff = divmod(labelled, clock.nominal)
hours, seconds = divmod(seconds, 3600)
minutes, seconds = divmod(seconds, 60)
separator = ";" if clock.mode == "df" else ":"
return "{:02d}:{:02d}:{:02d}{}{:02d}".format(hours, minutes, seconds, separator, ff)
def timecode_to_frames(timecode: str, clock: Clock) -> int:
"""Label -> actual whole frames since TC zero. Invalid labels never become 0."""
if not isinstance(timecode, str):
raise ClockError("timecode must be text")
match = re.fullmatch(r"([0-9]{2}):([0-9]{2}):([0-9]{2})([:;])([0-9]{2})", timecode)
if not match:
raise ClockError("Expected HH:MM:SS:FF (NDF) or HH:MM:SS;FF (DF)")
h, m, s, sep, f = match.groups()
h, m, s, f = map(int, (h, m, s, f))
if h >= 24 or m >= 60 or s >= 60 or f >= clock.nominal:
raise ClockError("Timecode field outside this clock's range")
if sep != (";" if clock.mode == "df" else ":"):
raise ClockError("Timecode separator disagrees with explicit DF/NDF mode")
if clock.drop and m % 10 != 0 and s == 0 and f < clock.drop:
raise ClockError("This label is skipped by drop-frame numbering")
minutes = h * 60 + m
count = (h * 3600 + m * 60 + s) * clock.nominal + f
return count - clock.drop * (minutes - minutes // 10)
def offset_to_timecode(offset: int, start_timecode: str, clock: Clock) -> str:
offset = integer(offset, "offset")
if offset < 0:
raise ClockError("Negative offsets are outside this tutorial's scope")
return frames_to_timecode(timecode_to_frames(start_timecode, clock) + offset, clock)
def timecode_to_offset(timecode: str, start_timecode: str, clock: Clock) -> int:
offset = timecode_to_frames(timecode, clock) - timecode_to_frames(start_timecode, clock)
if offset < 0:
raise ClockError("Timecode precedes start; no implicit midnight wrap")
return offset
def seconds_to_frames(seconds: Any, clock: Clock, rounding: str) -> Dict[str, Any]:
"""Duration -> offset, with exact arithmetic and an explicit rounding policy."""
value = exact_number(seconds, "seconds")
if value < 0:
raise ClockError("Negative durations are rejected, not clamped to zero")
exact = value * clock.rate
floor = exact.numerator // exact.denominator
if rounding == "exact":
if exact.denominator != 1:
raise ClockError("Duration is not a whole frame; choose a rounding policy")
result = floor
elif rounding == "floor":
result = floor
elif rounding == "ceil":
result = floor + (exact.denominator != 1)
elif rounding == "nearest":
# Documented half-up for non-negative durations, not Python's ties-to-even.
result = floor + ((exact - floor) >= Fraction(1, 2))
else:
raise ClockError("rounding must be exact, floor, ceil or nearest")
return {"rate": str(clock.rate), "seconds_exact": str(value), "frames_exact": str(exact),
"offset_frames": result, "rounding": rounding,
"error_seconds_exact": str(Fraction(result, 1) / clock.rate - value)}
def call(obj: Any, method: str, *args: Any) -> Any:
try:
fn = getattr(obj, method, None)
if not callable(fn):
raise ClockError(method + " is not callable")
return fn(*args)
except ClockError:
raise
except Exception as exc:
raise ClockError(method + " failed; original exception retained") from exc
def text(value: Any, field: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ClockError(field + " must be non-empty text")
return value
def context(resolve):
manager = call(resolve, "GetProjectManager")
project = call(manager, "GetCurrentProject")
if project is None:
raise ClockError("No open project")
timeline = call(project, "GetCurrentTimeline")
if timeline is None:
raise ClockError("No active timeline")
identity = {"project_id": text(call(project, "GetUniqueId"), "project ID"),
"timeline_id": text(call(timeline, "GetUniqueId"), "timeline ID")}
return project, timeline, identity
def observation(resolve, include_playhead=False):
_, timeline, identity = context(resolve)
result = dict(identity)
result.update({"start_frame": integer(call(timeline, "GetStartFrame"), "start_frame"),
"end_frame": integer(call(timeline, "GetEndFrame"), "end_frame"),
"start_timecode_raw": text(call(timeline, "GetStartTimecode"), "start timecode"),
"fps_raw": text(call(timeline, "GetSetting", "timelineFrameRate"), "timeline FPS")})
if include_playhead:
page = call(resolve, "GetCurrentPage")
if page not in ("cut", "edit", "color", "fairlight", "deliver"):
raise ClockError("Open Cut/Edit/Color/Fairlight/Deliver manually; no page switch is made")
result["page"] = page
result["current_timecode_raw"] = text(call(timeline, "GetCurrentTimecode"), "current timecode")
_, _, after = context(resolve)
if identity != after:
raise ClockError("Active target changed while reading")
return result
def read_clock(resolve, include_playhead=False):
"""Two matching observations, NOT an atomic snapshot or a process timeout."""
a = observation(resolve, include_playhead)
b = observation(resolve, include_playhead)
if a != b:
raise ClockError("Clock observations differ; stop playback/editing and inspect")
return dict(a, observation_policy="two_matching_reads_not_atomic")
# These labels are accepted only AFTER the caller explicitly selects the exact rate.
# The report retains the raw label and marks that rate origin as user-declared.
DISPLAY_LABELS = {Fraction(24000, 1001): "23.976", Fraction(30000, 1001): "29.97",
Fraction(48000, 1001): "47.952", Fraction(60000, 1001): "59.94"}
def validate_clock(state, clock: Clock):
raw = state["fps_raw"].strip()
has_df = raw.upper().endswith(" DF")
numeric = raw[:-3].strip() if has_df else raw
actual = exact_number(numeric, "observed FPS label")
alias = DISPLAY_LABELS.get(clock.rate)
if actual != clock.rate and (alias is None or actual != Fraction(alias)):
raise ClockError("Explicit rate conflicts with the observed timeline FPS label")
if has_df and clock.mode != "df":
raise ClockError("Observed FPS label says DF but NDF was requested")
# Absence of DF in the FPS string does not itself prove NDF; inspect start label.
timecode_to_frames(state["start_timecode_raw"], clock)
if "current_timecode_raw" in state:
timecode_to_frames(state["current_timecode_raw"], clock)
def seconds_at_timeline(resolve, seconds, rate, mode, rounding):
clock = Clock(rate, mode)
state = read_clock(resolve)
validate_clock(state, clock)
result = seconds_to_frames(seconds, clock, rounding)
result.update({"state": state, "rate_origin": "explicit_user_selection_checked_against_label",
"absolute_timeline_frame": state["start_frame"] + result["offset_frames"],
"timecode": offset_to_timecode(result["offset_frames"], state["start_timecode_raw"], clock),
"inside_conservative_bounds": state["start_frame"] + result["offset_frames"] < state["end_frame"],
"operation": "calculation_only_no_seek_or_insert"})
return result
def make_plan(state, offset, clock):
validate_clock(state, clock)
offset = integer(offset, "offset")
absolute = state["start_frame"] + offset
if offset < 0 or not state["start_frame"] <= absolute < state["end_frame"]:
raise ClockError("Target outside conservative [start, end) guard; end endpoint is excluded")
target_tc = offset_to_timecode(offset, state["start_timecode_raw"], clock)
plan = {"state": state, "offset_frames": offset, "absolute_timeline_frame": absolute,
"target_timecode": target_tc, "rate": str(clock.rate), "mode": clock.mode,
"rate_origin": "explicit_user_selection_checked_against_label"}
payload = json.dumps(plan, sort_keys=True, ensure_ascii=True, separators=(",", ":"))
plan["plan_digest"] = hashlib.sha256(payload.encode("utf-8")).hexdigest()
return plan
def move_playhead(resolve, offset, rate, mode, apply=False, expected_project_id=None,
expected_timeline_id=None, expected_plan_digest=None):
"""Plan only unless explicit guards match. Setter dispatched at most once."""
if type(apply) is not bool:
raise ClockError("apply must be bool")
clock = Clock(rate, mode)
before = read_clock(resolve, include_playhead=True)
plan = make_plan(before, offset, clock)
if not apply:
return dict(plan, status="plan_only", setter_called=False)
if (not expected_project_id or not expected_timeline_id or not expected_plan_digest or
expected_project_id != before["project_id"] or expected_timeline_id != before["timeline_id"] or
expected_plan_digest != plan["plan_digest"]):
raise ClockError("--apply requires matching reviewed project, timeline and plan digest")
if read_clock(resolve, include_playhead=True) != before:
raise ClockError("Target or playhead changed since the plan; no setter called")
_, timeline, identity = context(resolve)
if any(identity[k] != before[k] for k in identity):
raise ClockError("Target changed before dispatch")
if before["current_timecode_raw"] == plan["target_timecode"]:
return dict(plan, status="already_at_target", setter_called=False)
try:
# The only setter in this file. No fallback, polling, save, playback or retry.
accepted = call(timeline, "SetCurrentTimecode", plan["target_timecode"])
after = read_clock(resolve, include_playhead=True)
static_before = {k: v for k, v in before.items() if k != "current_timecode_raw"}
static_after = {k: v for k, v in after.items() if k != "current_timecode_raw"}
if accepted is not True or static_before != static_after:
raise ClockError("Setter not confirmed or target/clock changed")
if after["current_timecode_raw"] != plan["target_timecode"]:
raise ClockError("Read-back does not match requested timecode exactly")
except Exception as exc:
raise OutcomeUnknown("Playhead setter dispatched; inspect Resolve before any retry") from exc
return dict(plan, status="read_back_matched_check_ui", setter_called=True, after=after,
ui_verified=False)
Команды запуска
240 кадров после часовой опоры
python frames_to_timecode.py 240 --start "01:00:00:00" --rate 24 --mode ndfОжидается 01:00:10:00, обратное смещение 240.
Первая недесятая минута DF
python frames_to_timecode.py 1800 --start "00:00:00;00" --rate 30000/1001 --mode dfОжидается 00:01:00;02, без удаления кадров.
Что делает код
Разобрать параметры
argparse принимает целый offset, начало, точную частоту и режим. Нет defaults, зависящих от проекта.
Проверить Clock
Проверяются поддерживаемая частота и допустимость DF. Номинал не получают округлением случайного числа.
Прибавить смещение
Сначала разбирается стартовая метка; затем прибавляются реальные целые кадры и форматируется результат.
Проверить возврат
В JSON выводится round_trip_offset. Совпадение подтверждает преобразование на этих входах, не подключение API.
Все преобразования в helper — авторские функции, не методы Blackmagic. Успешный расчёт не подтверждает FPS, настройки проекта или возможность вызова в установленном Resolve.
При ошибке старый результат не подставляется. Native-вызовы не имеют добавленного тайм-аута; два одинаковых чтения не создают атомарный снимок и не блокируют интерфейс.
DEBUG / OBSERVATION FIRST
Ошибка — отправная точка, не готовый диагноз.
ClockError: ambiguous rate
Возможная причинаПередана краткая десятичная метка или частота вне списка.
Следующая проверкаУстановите точную частоту по настройкам задачи; не выбирайте ближайшую автоматически.
Ошибка разделителя
Возможная причинаВыбран DF, а начало записано через двоеточие, или наоборот.
Следующая проверкаСверьте режим и метку, не заменяйте символ вслепую.
Frame count outside one day
Возможная причинаСмещение с началом вышло за 24 часа или стало отрицательным.
Следующая проверкаДобавьте отдельный контракт дней вне этого helper.
Слишком большая метка
Возможная причинаАбсолютный кадр API передан как offset.
Следующая проверкаВычтите прочитанный GetStartFrame один раз до преобразования.
PRACTICE / YOUR OWN EVIDENCE
Соберите граничный набор, а не один удачный пример
- Проверьте 0, 23, 24 и 240 кадров при 24 NDF.
- Проверьте 1799, 1800 и 17982 при 30000/1001 DF.
- Сравните обратные смещения и намеренно подайте дробный кадр.
Граничные метки верны, обратное преобразование совпадает, дробный кадр и переход суток отклоняются.
Это условия вашей проверки, а не отчёт об испытании, уже выполненном на сайте.
FAQ
Вопросы, которые возникают на практике.
Почему не делить кадры на 29.97?
Так получится приблизительная длительность, а не правильная последовательность меток. Для секунд нужна точная дробь, для TC — ещё и номинал с правилом DF/NDF.[12]
Работает без установленного Resolve?
Да. Два файла выполняют чистую математику на входных данных; модуль Blackmagic не импортируется.
Можно передать 240.5 кадра?
Нет: этот форматтер не выбирает за пользователя, к какому кадру привязать субкадровую позицию.
Что означает ; в таймкоде?
В соглашении примера он обозначает DF. Сам символ не включает DF в Resolve; настройку приложения нужно проверить отдельно.[12]
SOURCES / VERSION BOUNDARIES
Источники, а не подразумеваемая сертификация.
Сверка источников, офлайн-тесты Python и проверка интерфейса — разные виды подтверждения. Ни один из них не означает, что каждый пример проверен со всеми сборками Studio, ОС и версиями Python. Указанная дата — дата редакционной проверки, не сертификат живого прогона.
- Blackmagic Design — DaVinci Resolve Studio 21.1 release notes
8 сентября 2026. В объявлении выпуска Python scripting отнесён к Studio. Это источник требований редакции, а не протокол испытания наших примеров.
- Blackmagic Design — Support Center
Каталог обновлений проверен 5 октября 2026: опубликована ветка 21.1.1. Последний выпуск не равен минимальной версии каждого метода.
- Blackmagic Scripting README — snapshot 24 July 2026 (Resolve 21.0.4)
Публичная копия первичного README Blackmagic, поддерживаемая X-Raym. По ней сверены базовые сигнатуры; это не официальный хостинг и не README 21.1.1. Оригинал своей установки: Help → Documentation → Developer → Scripting → README.txt; расположение пункта зависит от версии.
- Blackmagic Design — Scripting and Automation
Официальное описание Python/Lua scripting и интеграций Studio. Нельзя переносить обещания Studio на Free без проверки конкретного выпуска.
- Python — fractions.Fraction
Точное представление прочитанного числового текста. Десятичная метка 23.976 не доказывает, что исходная частота равна 24000/1001; сырая строка сохраняется.
- FFmpeg — timecode.c (primary implementation)
Первичная реализация различает номинал, рациональную частоту, DF-пропуски и флаг перехода суток. Наш независимый учебный helper имеет более узкий явный scope: две DF-частоты, строгие метки и запрет автоматического wrap.