Write talking-head scripts and produce Instagram reels and YouTube shorts
94
97%
Does it follow best practices?
Impact
85%
2.36xAverage score across 3 eval scenarios
Low
Low-risk findings worth noting
#!/usr/bin/env python3
"""Push a cut plan into DaVinci Resolve, and pull the edited timeline back.
export_timeline.py already round-trips through FCPXML, and that stays the
portable path — it needs no Resolve running, no Studio licence, and works on
another machine. This is the direct one: with Resolve Studio open, a plan
becomes a live timeline and an edited timeline becomes a plan, with no export
or import step in between.
Requires Resolve STUDIO. Blackmagic gates external scripting to Studio; on the
free edition scriptapp() refuses a foreign process whatever the preference
says, and this reports that rather than hanging. Set Preferences > General >
External scripting using = Local.
What this deliberately does NOT do is render or grade. Our grades are ffmpeg
filter chains and render_reel.py owns the master; letting Resolve render too
would put two editors on one timeline and the approval gates would stop meaning
anything. Picture decisions round-trip here. The look does not.
Two Resolve details that silently corrupt an edit if you get them wrong, both
handled here:
endFrame is EXCLUSIVE. duration = endFrame - startFrame. Treating it as
inclusive adds a frame to every segment.
Timelines start at 01:00:00:00, not zero — frame 108000 at 30fps. Timeline
positions must have GetStartFrame() subtracted before they mean anything.
Usage:
resolve_bridge.py status
resolve_bridge.py push plan/cut_plan.json --timeline reel_v1
resolve_bridge.py pull --out plan/cut_plan_edited.json --base-plan plan/cut_plan.json
"""
import argparse, json, os, sys
from pathlib import Path
API = "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting"
DEFAULT_FPS = 30.0
def connect():
"""The live Resolve object, or exit saying which precondition failed."""
mod = Path(os.environ.get("RESOLVE_SCRIPT_API", API)) / "Modules"
if not (mod / "DaVinciResolveScript.py").exists():
sys.exit(f"Resolve scripting module not found at {mod}. Install "
f"DaVinci Resolve, or set RESOLVE_SCRIPT_API.")
sys.path.insert(0, str(mod))
try:
import DaVinciResolveScript as dvr
except ImportError as e:
sys.exit(f"Could not import the Resolve scripting module: {e}")
resolve = dvr.scriptapp("Resolve")
if resolve is None:
sys.exit("Resolve refused the connection. Check, in order:\n"
" 1. Resolve is running with a project open\n"
" 2. It is Resolve STUDIO — external scripting is gated to it\n"
" 3. Preferences > General > External scripting using = Local\n"
"Use export_timeline.py for the FCPXML path instead; it needs "
"none of the above.")
return resolve
def secs_to_frames(seconds, fps):
"""Seconds to a frame count, rounded once.
Rounding per value rather than accumulating: a cut plan's segments are
independent source in-points, not a running total, so there is no drift to
accumulate here (unlike export_timeline.quantize_bounds, where boundaries
ARE cumulative).
"""
return int(round(float(seconds) * fps))
def frames_to_secs(frames, fps):
return round(int(frames) / fps, 3)
def plan_to_appends(segments, items_by_path, fps):
"""Cut-plan segments to AppendToTimeline dicts.
endFrame is EXCLUSIVE — Resolve gives back duration = end - start — so a
segment of N frames spans [in, in + N), not [in, in + N].
"""
appends, missing = [], []
for s in segments:
path = str(Path(s["clip"]).resolve())
item = items_by_path.get(path)
if item is None:
missing.append(s["clip"])
continue
start = secs_to_frames(s["in"], fps)
appends.append({
"mediaPoolItem": item,
"startFrame": start,
"endFrame": start + secs_to_frames(s["duration"], fps),
})
return appends, missing
def items_to_segments(items, fps, start_frame):
"""Timeline items back to cut-plan segments.
GetStart() is a TIMELINE position offset by the timeline's start frame
(01:00:00:00 by convention, 108000 at 30fps). The source in-point is
GetLeftOffset(); the timeline position is only used to order the cut.
"""
segs = []
for it in sorted(items, key=lambda x: x.GetStart()):
segs.append({
"clip": it.GetName(),
"in": frames_to_secs(it.GetLeftOffset(), fps),
"duration": frames_to_secs(it.GetDuration(), fps),
"at": frames_to_secs(it.GetStart() - start_frame, fps),
})
return segs
def fill_zoom(src_w, src_h, tl_w, tl_h):
"""Zoom that makes a source COVER the timeline frame instead of fitting it.
Resolve's only scriptable mismatch behaviour is scaleToFit, which
letterboxes. The mezzanine is deliberately wider than the target when
normalize_clips.py ran with --no-crop, so render_reel.py --pan has spare
pixels to reframe with — and fitting that into a 9:16 timeline pillarboxes
a 3414x1920 clip down to a stripe.
Zoom 1.0 IS the fitted size, so the factor is the ratio of the two aspects.
A source already at the target aspect returns 1.0 and nothing moves.
"""
if not all((src_w, src_h, tl_w, tl_h)):
return 1.0
src_ar, tl_ar = src_w / src_h, tl_w / tl_h
return max(1.0, src_ar / tl_ar if src_ar > tl_ar else tl_ar / src_ar)
def project_fps(project):
try:
return float(project.GetSetting("timelineFrameRate"))
except (TypeError, ValueError):
return DEFAULT_FPS
def main():
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("status", help="is Resolve reachable, and on what")
p = sub.add_parser("push", help="cut plan -> live timeline")
p.add_argument("plan")
p.add_argument("--timeline", default="reel", help="Timeline name to create")
p.add_argument("--project", help="Create/load this project instead of the current one")
p.add_argument("--width", type=int, help="Timeline width (e.g. 1080 for 9:16)")
p.add_argument("--height", type=int, help="Timeline height (e.g. 1920 for 9:16)")
p.add_argument("--fps", type=float,
help=f"Timeline frame rate (default: the plan's, else "
f"{DEFAULT_FPS} — the mezzanine grid)")
p = sub.add_parser("pull", help="live timeline -> cut plan")
p.add_argument("--out", required=True)
p.add_argument("--base-plan", help="Inherit music/bpm from this plan")
p.add_argument("--timeline", help="Timeline name (default: the current one)")
a = ap.parse_args()
resolve = connect()
pm = resolve.GetProjectManager()
if a.cmd == "status":
proj = pm.GetCurrentProject()
tl = proj.GetCurrentTimeline() if proj else None
print(json.dumps({
"product": resolve.GetProductName(),
"version": resolve.GetVersionString(),
"project": proj.GetName() if proj else None,
"fps": project_fps(proj) if proj else None,
"timeline": tl.GetName() if tl else None,
"timelines": proj.GetTimelineCount() if proj else 0,
}, indent=2))
return
if a.cmd == "push":
plan = json.loads(Path(a.plan).read_text())
segs = plan.get("segments") or []
if not segs:
sys.exit(f"{a.plan} has no segments.")
proj = None
if a.project:
proj = pm.CreateProject(a.project) or pm.LoadProject(a.project)
proj = proj or pm.GetCurrentProject()
if proj is None:
sys.exit("No project open in Resolve, and none named with --project.")
# Set the rate BEFORE anything else: a fresh Resolve project defaults
# to 24, the mezzanine is CFR 30, and converting seconds on the wrong
# grid mis-times every segment silently. Resolve also refuses to change
# timelineFrameRate once the project holds a timeline.
want_fps = a.fps or plan.get("fps") or DEFAULT_FPS
if not proj.SetSetting("timelineFrameRate", str(float(want_fps))):
have = project_fps(proj)
if abs(have - float(want_fps)) > 1e-6:
sys.exit(f"Resolve kept this project at {have}fps and the plan "
f"needs {want_fps}. A project's frame rate cannot "
f"change once it holds a timeline — push into a new "
f"one with --project.")
fps = project_fps(proj)
if abs(fps - float(want_fps)) > 1e-6:
sys.exit(f"Timeline is {fps}fps but the plan is {want_fps}fps. "
f"Every duration would land on the wrong grid.")
if a.width and a.height:
proj.SetSetting("timelineResolutionWidth", str(a.width))
proj.SetSetting("timelineResolutionHeight", str(a.height))
paths = []
for s in segs:
rp = Path(s["clip"]).resolve()
if not rp.exists():
sys.exit(f"Clip not found: {s['clip']} — push from the project "
f"directory so relative paths resolve.")
if str(rp) not in paths:
paths.append(str(rp))
added = resolve.GetMediaStorage().AddItemListToMediaPool(paths) or []
by_path = {}
for item in added:
fp = item.GetClipProperty("File Path")
if fp:
by_path[str(Path(fp).resolve())] = item
# already-imported clips come back empty from AddItemListToMediaPool
for item in proj.GetMediaPool().GetRootFolder().GetClipList() or []:
fp = item.GetClipProperty("File Path")
if fp:
by_path.setdefault(str(Path(fp).resolve()), item)
appends, missing = plan_to_appends(segs, by_path, fps)
if missing:
sys.exit("Resolve did not import: " + ", ".join(missing[:5]))
mp = proj.GetMediaPool()
tl = mp.CreateEmptyTimeline(a.timeline)
if tl is None:
sys.exit(f"Could not create timeline {a.timeline!r} — a timeline "
f"with that name may already exist.")
done = mp.AppendToTimeline(appends)
# Cover the frame rather than letterboxing into it.
tl_w = int(proj.GetSetting("timelineResolutionWidth") or 0)
tl_h = int(proj.GetSetting("timelineResolutionHeight") or 0)
zoomed = 0
for item in (done or []):
mpi = item.GetMediaPoolItem()
try:
sw = int(mpi.GetClipProperty("Resolution").split("x")[0])
sh = int(mpi.GetClipProperty("Resolution").split("x")[1])
except (AttributeError, ValueError, IndexError):
continue
z = fill_zoom(sw, sh, tl_w, tl_h)
if z > 1.0 and item.SetProperty("ZoomX", z) and item.SetProperty("ZoomY", z):
zoomed += 1
print(json.dumps({
"project": proj.GetName(), "timeline": a.timeline, "fps": fps,
"segments": len(segs), "appended": len(done or []),
"timeline_size": f"{tl_w}x{tl_h}", "zoomed_to_fill": zoomed,
}, indent=2))
print("\nEdit it in Resolve, then pull it back. Do not render there — "
"the grade and the master belong to render_reel.py.",
file=sys.stderr)
return
proj = pm.GetCurrentProject()
if proj is None:
sys.exit("No project open in Resolve.")
fps = project_fps(proj)
tl = proj.GetCurrentTimeline()
if a.timeline:
for i in range(1, proj.GetTimelineCount() + 1):
cand = proj.GetTimelineByIndex(i)
if cand and cand.GetName() == a.timeline:
tl = cand
break
else:
sys.exit(f"No timeline named {a.timeline!r} in {proj.GetName()!r}.")
if tl is None:
sys.exit("No timeline open in Resolve.")
items = tl.GetItemListInTrack("video", 1) or []
if not items:
sys.exit(f"Timeline {tl.GetName()!r} has nothing on video track 1.")
segs = items_to_segments(items, fps, tl.GetStartFrame())
plan = {}
if a.base_plan:
plan = json.loads(Path(a.base_plan).read_text())
# names come back bare from Resolve; restore the paths the renderer needs
by_name = {Path(s["clip"]).name: s["clip"]
for s in plan.get("segments", [])}
for s in segs:
s["clip"] = by_name.get(s["clip"], s["clip"])
plan["segments"] = [{k: v for k, v in s.items() if k != "at"} for s in segs]
plan["total_duration"] = round(sum(s["duration"] for s in plan["segments"]), 2)
out = Path(a.out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(plan, indent=2))
print(json.dumps({"timeline": tl.GetName(), "fps": fps,
"segments": len(segs),
"duration": plan["total_duration"],
"out": str(out)}, indent=2))
print("\nRe-probe and render a preview before Gate 2 — the cut changed.",
file=sys.stderr)
if __name__ == "__main__":
main().tessl-plugin
evals
skills
reel-builder
assets
references
scripts
yap-writer