01 / BEFORE THE CODE
Set the conditions before you run.
Choose the execution context first
The mathematical example runs in Python 3.8+ without Resolve or third-party packages. Put the main file beside resolve_api_timecode.py. The connection helper is not needed.
Exact rate and units
This helper supports 24, 25, 30, 48, 50, 60 and 24000/1001, 30000/1001, 48000/1001, 60000/1001. This is the example scope, not Resolve’s capability list. Select the rational rate explicitly; 29.97 is not silently rewritten as 30000/1001.
Keep the exercise bounded
Labels are limited to one timecode day, whole frames and explicit DF/NDF selection. No inferred midnight wrapping, negative durations, interlaced fields or audio subframes. Stop playback for application reads; use a test copy for seeking and do not change targets during the call.
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.
re.fullmatch(pattern, timecode)- Input
- Four two-digit fields.
- Returns
- Complete match or failure.
- Check
- Reject suffixes, spaces and extra fields.
timecode_to_frames(tc, clock)- Input
- Valid label and explicit Clock.
- Returns
- Frame count from TC zero.
- Check
- Never return 0 on failure.
timecode_to_offset(tc, start, clock)- Input
- Target and start labels.
- Returns
- Non-negative offset.
- Check
- Reject a label before the start.
GetStartFrame()- Input
- No arguments; needed for API coordinates.
- Returns
- Timeline start frame.
- Check
- The pure mathematical example does not call it.
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.[3]
03 / UNDERSTAND
Frame zero is a valid result, not an error code
The previous parse_tc_to_frames caught every exception and returned 0. A typo could therefore look like a valid request to seek to the beginning, while missing data could look like a zero-origin timeline. “The label is zero” and “The label could not be read” must remain different states before another action.
ClockError now stops the script. The format requires exactly four two-digit fields: hours 00–23, minutes and seconds 00–59, and FF below the nominal rate. This strict representation uses a colon for NDF and semicolon for DF. Invalid text is not automatically cleaned or completed.
04 / UNDERSTAND
Inverse formula: nominal numbering minus skipped labels
For NDF, first compute ((HH × 3600 + MM × 60 + SS) × nominal) + FF. At 24000/1001 NDF, nominal is 24. Multiplying the hours field by 23.976 instead of 24 would mix numbering hours with elapsed seconds and would no longer invert the formatter.
For DF, subtract drop × (total_minutes − total_minutes // 10) from the nominal count. Here drop is 2 or 4 for the two supported DF rates. Validate skipped labels before applying the formula: arithmetic alone will not detect that 00:01:00;00 is invalid.[12]
05 / UNDERSTAND
Derive an offset before deriving recordFrame
Both origin and target are parsed with the same Clock. Their difference gives the offset. At 24 NDF, 01:00:10:00 and 01:00:00:00 count 86640 and 86400 frames from TC zero, so the offset is 240. An API coordinate then adds the separately observed start_frame.
Do not assume that parsing the start label must numerically equal GetStartFrame at every timebase. The helper keeps these anchors independent. This makes their relationship inspectable rather than “correcting” an API result by replacing it with a computed value.[3]
06 / UNDERSTAND
Try an invalid label, not just a successful calculation
Load the DF preset, select “TC → Frames” and change ;02 to ;00 at minute one. The numeric result must disappear and the message must explain the skipped label. Restoring ;02 makes the result available again. A stale number is never left beside newly invalid input.
For 24 NDF, test FF = 24 and a minute value of 60. These are invalid strings under this contract, not requests to carry into the next field. The caller or user must correct the source data. Automatic normalization would blur the distinction between a typo and an intentional operation.
Whole-frame conversion: no rounding.
The base coordinate and start label are independent anchors, not values inferred from each other. This calculator supports one 24-hour timecode day, whole-frame labels and explicitly listed rates. It does not validate your project's settings or timeline bounds. Input is not sent or saved.
07 / UNDERSTAND
Labels before the origin and sequences longer than a day
A negative difference can be useful in a different mathematical API, but this executable example targets positions after the sequence origin. It rejects a target before start_timecode. This is an explicit scope decision, not a claim that editing never uses negative values.
Timecode has no date field. Material crossing midnight needs an additional day index or absolute time axis with a defined label mapping. Silently adding 24 hours could redirect an operation to an entirely different point, so the example never does it.
PYTHON / COMPLETE EXAMPLE
Read the code. Then run it deliberately.
Download timecode_to_frames.py and resolve_api_timecode.py. For application reads or a seek, also download resolve_api_connection.py. The bundle contains all six examples, both helpers and RU/EN instructions. Pure converters never import the Blackmagic module; seconds conversion connects only with --timeline.
Download all examples + instructions (.zip) ↓"""Validated timecode -> offset from an explicit origin, never error-as-zero."""
import argparse
import json
from resolve_api_timecode import Clock, timecode_to_frames, timecode_to_offset
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("timecode")
p.add_argument("--start", required=True)
p.add_argument("--rate", required=True)
p.add_argument("--mode", choices=("ndf", "df"), required=True)
a = p.parse_args()
clock = Clock(a.rate, a.mode)
print(json.dumps({"timecode": a.timecode, "start_timecode": a.start,
"frames_since_tc_zero": timecode_to_frames(a.timecode, clock),
"offset_frames": timecode_to_offset(a.timecode, a.start, clock),
"rate": str(clock.rate), "mode": clock.mode}, indent=2))
if __name__ == "__main__":
main()
Timecode helper — exact arithmetic, origins and one guarded seek
Author implementation, not the Blackmagic SDK. DF/NDF label rules are separate from elapsed seconds. Clock accepts an explicit exact rate; raw FPS labels do not automatically select one. Only move_playhead with apply can call a setter, and only once.
"""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)
Launch commands
Offset from a one-hour label
python timecode_to_frames.py "01:00:10:00" --start "01:00:00:00" --rate 24 --mode ndfJSON: frames_since_tc_zero = 86640; offset_frames = 240.
Inverse DF boundary
python timecode_to_frames.py "00:01:00;02" --start "00:00:00;00" --rate 30000/1001 --mode dfExpected: 1800. The ;00 variant must fail.
What the code does
Check the shape
fullmatch rejects partial matches, trailing text and extra fields.
Check the ranges
Hours, minutes, seconds, FF and skipped DF labels are validated before arithmetic.
Subtract DF skips
The nominal count is adjusted for total minutes, excluding every tenth minute.
Separate the origin
JSON retains both the full TC-zero count and the relative offset.
All helper conversions are author functions, not Blackmagic methods. A successful calculation does not validate project settings, frame rate or a call in your installed Resolve.
An error never reuses a previous result. Native calls have no added timeout; two matching reads are not an atomic snapshot and do not lock the interface.
DEBUG / OBSERVATION FIRST
An error is a starting point, not a diagnosis.
Old code returned 0 for the label
Possible reasonA broad except hid the parse failure.
Next checkHandle the exception at the task boundary; do not pass 0 onward.
This label is skipped
Possible reasonA skipped DF label was entered at a non-tenth minute.
Next checkCheck the original timecode and mode, not the nearest label.
Timecode precedes start
Possible reasonThe target precedes the origin or implicitly assumes another day.
Next checkChoose the correct origin or a separate day model.
Offset is smaller than expected
Possible reasonCount from TC zero was confused with a relative offset.
Next checkInspect frames_since_tc_zero and offset_frames separately.
PRACTICE / YOUR OWN EVIDENCE
Exercise the validator with bad inputs
- Convert 01:00:10:00 relative to 01:00:00:00.
- Submit FF = 24 at 24 NDF and 00:01:00;00 at 29.97 DF.
- Check a target before origin and a valid zero label.
Valid results are named by coordinate space; every invalid input produces an explicit error, not 0.
This is a checklist for your test, not a report of a test already performed on the website.
FAQ
Questions that matter in practice.
Does 00:00:00:00 return 0?
Yes, with a compatible NDF clock. An invalid string raises an exception, so it cannot be confused with a valid zero.
Can I pass 1:2:3:4?
Not in this strict example. Normalize a known external format in a separate function and retain the original text.
Why do 29.97 NDF and DF give different counts?
They use different numbering at the same physical rate. DF subtracts skipped labels from the nominal count.[12]
Does this locate a source frame?
No. A timeline offset does not account for source In, speed changes, reverse playback or compound clips.
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.
- FFmpeg — timecode.c (primary implementation)
The primary implementation distinguishes nominal rate, rational timebase, DF skipped labels and day-wrapping flags. Our independently written tutorial helper has a narrower explicit scope: two DF rates, strict labels and no automatic wrapping.