CtrlK
BlogDocsLog inGet started
Tessl Logo

gamussa/reels-producer-skill

Write talking-head scripts and produce Instagram reels and YouTube shorts

94

2.36x
Quality

97%

Does it follow best practices?

Impact

85%

2.36x

Average score across 3 eval scenarios

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

resolve_bridge.pyskills/reel-builder/scripts/

#!/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()

.mcp.json

tile.json