CtrlK
BlogDocsLog inGet started
Tessl Logo

gamussa/reels-producer-skill

help quicky produce instagram reels and youtube shorts

80

1.95x
Quality

93%

Does it follow best practices?

Impact

47%

1.95x

Average score across 1 eval scenario

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

build_cut_plan.pyskills/reel-builder/scripts/

#!/usr/bin/env python3
"""Propose a beat-synced cut plan from normalized clips + beat data.

Pacing model — phase-based, all durations snapped to actual beat timestamps:

  HOOK       ~2 bars hold      grab attention; most kinetic clip first
  ESTABLISH  4-beat cuts       let shots read; set the scene
  GROOVE     2-beat cuts       settle into rhythm
  PUNCH      2-beat cuts       energy peak (1-beat only via --punch-beats 1 —
                               sub-second cuts read as "busy", so opt-in)
  BREATHE    4-beat cuts       relief after the peak
  BUILD      2-beat cuts       re-accelerate into the ending
  CLOSE      ~2 bars hold      payoff / smile / CTA

Guarantees:
  - minimum cut length is 2 beats (never the 1-beat flicker) unless --punch-beats 1
  - in-points are spread across each clip's FULL duration (usage N of K starts
    ~N/K of the way through the clip), not clustered in the first few seconds
  - two adjacent segments never come from the same clip (that's a jump cut)

The output plan/cut_plan.json is meant to be HUMAN-EDITED before rendering.
Every field is safe to change: reorder segments, change "in" points, durations
(renderer re-snaps audio automatically), swap clip paths.

Usage:
  build_cut_plan.py work/clips.json work/beats.json --target-duration 30 \
      --mezz-dir work/mezz --out plan/cut_plan.json \
      [--order clipA,clipB,...] [--hook clipC] [--close clipD] [--punch-beats 1]
"""
import argparse, json, sys
from pathlib import Path

# (name, beats_per_cut_multiplier, share of body time). Multiplier is applied
# to the minimum cut length: with the default 2-beat minimum, establish/breathe
# are 4-beat and groove/punch/build are 2-beat.
BODY_PHASES = [
    ("establish", 2, 0.22),
    ("groove",    1, 0.24),
    ("punch",     0, 0.16),   # 0 = punch_beats (the fastest allowed cut)
    ("breathe",   2, 0.16),
    ("build",     1, 0.22),
]


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("clips_json")
    ap.add_argument("beats_json")
    ap.add_argument("--mezz-dir", default="work/mezz")
    ap.add_argument("--target-duration", type=float, default=30)
    ap.add_argument("--out", default="plan/cut_plan.json")
    ap.add_argument("--order", help="Comma-separated clip stems to force rotation order")
    ap.add_argument("--hook", help="Clip stem to open with (pick from contact sheets)")
    ap.add_argument("--close", help="Clip stem to end on (pick from contact sheets)")
    ap.add_argument("--min-beats", type=int, default=2,
                    help="Minimum cut length in beats outside the punch phase (default 2)")
    ap.add_argument("--punch-beats", type=int, default=None,
                    help="Cut length during the punch phase (default = --min-beats; "
                         "pass 1 for sub-second flash cuts — use sparingly)")
    args = ap.parse_args()

    punch_beats = args.punch_beats or args.min_beats
    clips = [c for c in json.loads(Path(args.clips_json).read_text())["clips"]
             if "error" not in c]
    if not clips:
        sys.exit("No usable clips in clips.json")
    beats = json.loads(Path(args.beats_json).read_text())
    beat_times = [0.0] + beats["beats"]
    mezz = Path(args.mezz_dir)

    def mezz_path(c):
        return str(mezz / (Path(c["file"]).stem + ".mp4"))

    by_stem = {Path(c["file"]).stem: c for c in clips}
    if args.order:
        clips = [by_stem[s] for s in args.order.split(",") if s in by_stem]
    if args.hook and args.hook in by_stem:
        clips = [by_stem[args.hook]] + [c for c in clips if c is not by_stem[args.hook]]
    close_clip = by_stem.get(args.close) if args.close else None

    spb = 60.0 / beats["bpm"]                      # seconds per beat
    hold_beats = 8 if args.target_duration >= 20 else 4   # hook/close holds
    hold_beats = min(hold_beats, max(2, (len(beat_times) - 1) // 4))

    # --- Pass 1: lay out (phase, beat-count) slots against the beat grid ---
    def beats_for(phase_mult):
        return punch_beats if phase_mult == 0 else args.min_beats * phase_mult

    body_target = max(0.0, args.target_duration - 2 * hold_beats * spb)
    slots = [("hook", hold_beats)]
    body_elapsed, i = 0.0, hold_beats
    while body_elapsed < body_target and i < len(beat_times) - 1:
        frac, cum = body_elapsed / body_target if body_target else 1.0, 0.0
        phase = BODY_PHASES[-1]
        for p in BODY_PHASES:
            cum += p[2]
            if frac < cum:
                phase = p
                break
        n = beats_for(phase[1])
        end_idx = min(i + n, len(beat_times) - 1)
        dur = beat_times[end_idx] - beat_times[i]
        if dur <= 0.05:
            break
        slots.append((phase[0], end_idx - i))
        body_elapsed += dur
        i = end_idx
    close_end = min(i + hold_beats, len(beat_times) - 1)
    if beat_times[close_end] - beat_times[i] > 0.5:
        slots.append(("close", close_end - i))

    # --- Pass 2: assign a clip to each slot (rotation, no same-clip adjacency) ---
    durations, t_idx = [], 0
    for _, n in slots:
        durations.append(round(beat_times[t_idx + n] - beat_times[t_idx], 3))
        t_idx += n

    assigned, prev, ci = [], None, 0
    for k, (phase, _) in enumerate(slots):
        dur = durations[k]
        pick = None
        if phase == "close" and close_clip and close_clip["duration"] >= dur + 0.1 \
                and (close_clip is not prev or len(clips) == 1):
            pick = close_clip
        else:
            for probe in range(len(clips)):
                cand = clips[(ci + probe) % len(clips)]
                if cand is prev and len(clips) > 1:
                    continue                       # never jump-cut within a clip
                if cand["duration"] >= dur + 0.1:
                    pick = cand
                    ci = (ci + probe + 1) % len(clips)
                    break
        if pick is None:                            # nothing long enough: allow repeat
            pick = max((c for c in clips if c is not prev), default=clips[0],
                       key=lambda x: x["duration"])
            durations[k] = dur = min(dur, round(pick["duration"] - 0.1, 3))
        assigned.append(pick)
        prev = pick

    # --- Pass 3: spread in-points across each clip's full duration ---
    usage = {}
    for k, c in enumerate(assigned):
        usage.setdefault(id(c), (c, []))[1].append(k)
    in_points = [0.0] * len(slots)
    for c, ks in usage.values():
        for j, k in enumerate(ks):                 # usage j of len(ks), chronological
            window = (c["duration"] - durations[k] - 0.1) / len(ks)
            in_points[k] = round(max(0.0, j * max(window, 0.0)), 2)

    NOTES = {"hook": "HOOK — most kinetic shot here",
             "close": "CLOSE — payoff / smile / CTA"}
    segments = [{"clip": mezz_path(c), "in": in_points[k], "duration": durations[k],
                 "note": NOTES.get(slots[k][0], f"{slots[k][0]} · {slots[k][1]}-beat")}
                for k, c in enumerate(assigned)]
    total = round(sum(s["duration"] for s in segments), 2)

    plan = {
        "music": beats["audio"],
        "music_start": 0.0,
        "bpm": beats["bpm"],
        "total_duration": total,
        "segments": segments,
    }
    out = Path(args.out)
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(json.dumps(plan, indent=2))

    print(f"Cut plan: {len(segments)} segments, {total:.1f}s @ {beats['bpm']} BPM -> {out}\n")
    cum = 0.0
    for n, s in enumerate(segments):
        print(f"  {n:2d}  {cum:6.2f}s  {s['duration']:5.2f}s  "
              f"{Path(s['clip']).stem:<24} in={s['in']:<7} {s['note']}")
        cum += s["duration"]
    print("\nEdit plan/cut_plan.json (or ask for changes), then render a preview.")


if __name__ == "__main__":
    main()

tile.json