@kolbo/mcp 1.112.1 → 1.112.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
1
+ """Burn captions (and an optional hook) into a finished cut with libass. Audio is copied untouched.
2
+
3
+ usage:
4
+ python burn.py social <in.mp4> <words.srt> <out.mp4> [--hook "Line one|Line two"] [--hook-dy 0] [--font NAME] [--fontsdir DIR]
5
+ python burn.py film <in.mp4> <words.srt> <out.mp4> [--font NAME] [--fontsdir DIR]
6
+
7
+ words.srt = one-word-per-cue SRT from Kolbo transcribe_audio (word_by_word_srt_url) of THIS file, corrected
8
+ against the script's words (keep the timings, fix only mishears).
9
+ social: word-pop captions, max 3 words per line, the spoken word in yellow, each word one frame early, sitting
10
+ just above the bottom 35% platform UI band; hook = two lines in a white box for exactly 0-3 s, at the waist,
11
+ right above the captions. All text stays inside the centre 82% so the right-side buttons never cover it.
12
+ film: plain subtitles for the full episode page - max 4 words per row, 2 rows, low in frame, soft shadow.
13
+ Also writes <out>.ass next to the output for inspection.
14
+ """
15
+ import argparse
16
+ import os
17
+ import subprocess
18
+
19
+ from media import probe, srt_words
20
+
21
+ YELLOW, WHITE, BLACK = "&H003DE2FF", "&H00FFFFFF", "&H00000000"
22
+
23
+
24
+ def ts(t):
25
+ t = max(t, 0)
26
+ return f"{int(t // 3600)}:{int(t % 3600 // 60):02d}:{t % 60:05.2f}"
27
+
28
+
29
+ def lines_of(words, max_words, pause=0.6):
30
+ out, cur = [], []
31
+ for w in words:
32
+ if cur and (len(cur) >= max_words or w[1] - cur[-1][2] > pause or cur[-1][0][-1:] in ",.?!"):
33
+ out.append(cur)
34
+ cur = []
35
+ cur.append(w)
36
+ return out + ([cur] if cur else [])
37
+
38
+
39
+ def build(mode, video, srt, hook, hook_dy, font):
40
+ info = probe(video)
41
+ W, H, frame = info["w"], info["h"], 1 / info["fps"]
42
+ margin = int(W * 0.09)
43
+ cap_y = int(H * 0.65) - int(H * 0.02) # just above the bottom 35% UI band
44
+ words = srt_words(srt)
45
+ head = (f"[Script Info]\nScriptType: v4.00+\nPlayResX: {W}\nPlayResY: {H}\nWrapStyle: 2\n\n[V4+ Styles]\n"
46
+ "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, "
47
+ "Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, "
48
+ "MarginR, MarginV, Encoding\n")
49
+ ev = "\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
50
+ if mode == "film":
51
+ size = int(W * 0.063)
52
+ head += (f"Style: Sub,{font},{size},{WHITE},{WHITE},{BLACK},&H80000000,0,0,0,0,100,100,0,0,1,1.5,2,2,"
53
+ f"{margin},{margin},{int(H * 0.08)},1\n")
54
+ for ln in lines_of(words, 8):
55
+ text = " ".join(w[0] for w in ln)
56
+ parts = text.split()
57
+ if len(parts) > 4:
58
+ text = " ".join(parts[:len(parts) // 2 + len(parts) % 2]) + "\\N" + " ".join(parts[len(parts) // 2 + len(parts) % 2:])
59
+ ev += f"Dialogue: 0,{ts(ln[0][1] - frame)},{ts(ln[-1][2] + 0.2)},Sub,,0,0,0,,{text}\n"
60
+ return head + ev
61
+ cap = int(W * 0.07)
62
+ head += (f"Style: Cap,{font},{cap},{WHITE},{WHITE},{BLACK},&H00000000,1,0,0,0,100,100,0,0,1,{cap * 0.12:.1f},0,2,"
63
+ f"{margin},{margin},{H - cap_y},1\n")
64
+ head += (f"Style: Hook,{font},10,{BLACK},{BLACK},{BLACK},{BLACK},1,0,0,0,100,100,0,0,1,0,0,5,0,0,0,1\n"
65
+ f"Style: Box,{font},10,{WHITE},{WHITE},{WHITE},{WHITE},0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1\n")
66
+ caps = []
67
+ for ln in lines_of(words, 3):
68
+ for k, (w, a, b) in enumerate(ln):
69
+ end = ln[k + 1][1] if k + 1 < len(ln) else b + 0.15
70
+ text = " ".join(("{\\c" + YELLOW + "}" + x + "{\\c" + WHITE + "}") if j == k else x
71
+ for j, (x, _, _) in enumerate(ln))
72
+ caps.append([a - frame, end - frame, text])
73
+ for k in range(len(caps) - 1): # never two caption states on screen at once (libass stacks them)
74
+ caps[k][1] = max(min(caps[k][1], caps[k + 1][0]), caps[k][0] + frame)
75
+ for a, b, text in caps:
76
+ ev += f"Dialogue: 1,{ts(a)},{ts(b)},Cap,,0,0,0,,{text}\n"
77
+ if hook:
78
+ lines = hook.split("|")
79
+ # ponytail: box width estimated at 0.6 x font size per character (bold sans); measure with a font
80
+ # library if a hook font renders much wider.
81
+ size = min(W * 0.068, (W * 0.82 - W * 0.07) / (0.6 * max(len(x) for x in lines)))
82
+ pad, r = size * 0.5, size * 0.45
83
+ bw = 0.6 * size * max(len(x) for x in lines) + 2 * pad
84
+ bh = size * 1.2 * len(lines) + 2 * pad
85
+ bottom = cap_y - cap * 1.3 - hook_dy # sits right above the caption line
86
+ x0, y0 = (W - bw) / 2, bottom - bh
87
+ path = (f"m {r:.0f} 0 l {bw - r:.0f} 0 b {bw - .45 * r:.0f} 0 {bw:.0f} {.45 * r:.0f} {bw:.0f} {r:.0f} "
88
+ f"l {bw:.0f} {bh - r:.0f} b {bw:.0f} {bh - .45 * r:.0f} {bw - .45 * r:.0f} {bh:.0f} {bw - r:.0f} {bh:.0f} "
89
+ f"l {r:.0f} {bh:.0f} b {.45 * r:.0f} {bh:.0f} 0 {bh - .45 * r:.0f} 0 {bh - r:.0f} "
90
+ f"l 0 {r:.0f} b 0 {.45 * r:.0f} {.45 * r:.0f} 0 {r:.0f} 0")
91
+ ev += f"Dialogue: 2,{ts(0)},{ts(3.0)},Box,,0,0,0,,{{\\an7\\pos({x0:.0f},{y0:.0f})\\p1}}{path}{{\\p0}}\n"
92
+ ev += (f"Dialogue: 3,{ts(0)},{ts(3.0)},Hook,,0,0,0,,{{\\an5\\pos({W / 2:.0f},{y0 + bh / 2:.0f})\\fs{size:.0f}}}"
93
+ + "\\N".join(lines) + "\n")
94
+ return head + ev
95
+
96
+
97
+ def main():
98
+ p = argparse.ArgumentParser()
99
+ p.add_argument("mode", choices=["social", "film"])
100
+ p.add_argument("video")
101
+ p.add_argument("srt")
102
+ p.add_argument("out")
103
+ p.add_argument("--hook", default="")
104
+ p.add_argument("--hook-dy", type=int, default=0, help="move the hook down by N pixels if it covers a face or body")
105
+ p.add_argument("--font", default="Montserrat ExtraBold")
106
+ p.add_argument("--fontsdir", default="")
107
+ a = p.parse_args()
108
+ ass = os.path.abspath(a.out) + ".ass"
109
+ open(ass, "w", encoding="utf-8").write(build(a.mode, a.video, a.srt, a.hook, a.hook_dy, a.font))
110
+ # libass path escaping is fragile on Windows: run from the .ass folder with a bare file name.
111
+ flt = f"subtitles={os.path.basename(ass)}" + (f":fontsdir={a.fontsdir}" if a.fontsdir else "")
112
+ tmp = os.path.abspath(a.out) + ".part.mp4"
113
+ subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", os.path.abspath(a.video), "-vf", flt, "-c:v", "libx264",
114
+ "-crf", "16", "-preset", "medium", "-pix_fmt", "yuv420p", "-c:a", "copy", "-movflags", "+faststart",
115
+ tmp], check=True, cwd=os.path.dirname(ass))
116
+ os.replace(tmp, os.path.abspath(a.out))
117
+ print("->", a.out)
118
+
119
+
120
+ if __name__ == "__main__":
121
+ main()
@@ -0,0 +1,107 @@
1
+ """Build the tight cut from final clips with exact frames, L/J-cuts and smooth sound seams. Levels never change.
2
+
3
+ usage: python cut.py <cut.json>
4
+ cut.json:
5
+ {
6
+ "out": "final/ep01-tight.mp4",
7
+ "xfade": 0.3,
8
+ "segments": [
9
+ {"file": "final/ep01-part1.mp4", "start": 0.0, "end": 9.4},
10
+ {"file": "final/ep01-part1.mp4", "start": 11.2, "end": 14.0, "lcut": 0.3},
11
+ {"file": "final/ep01-part2.mp4", "start": 0.2, "end": 6.1, "zoom": 1.5, "y": 0.2, "flip": false, "jcut": 0}
12
+ ]
13
+ }
14
+ - start/end: seconds on that file. In/out points within 0.15 s of a shot change are moved 1.5 frames inside the
15
+ shot, so no frame of the neighbouring shot flashes in.
16
+ - lcut: this shot's PICTURE comes in that many seconds early over the end of the previous line (L-cut).
17
+ - jcut: this shot's SOUND starts that many seconds early under the previous picture (J-cut).
18
+ - zoom/y: punch-in (1.5 = 150%) anchored at y (0 top, 0.5 centre) for a jump inside one shot.
19
+ - flip: horizontal mirror to fix a 180-degree break. Check every one-sided detail (scar, parting, ring, text) first.
20
+ - Seams borrow `xfade` s of the incoming shot's own sound and crossfade it, so room tone never jumps.
21
+ Contiguous segments of the same file butt-join with no borrow.
22
+ """
23
+ import bisect
24
+ import json
25
+ import os
26
+ import subprocess
27
+ import sys
28
+
29
+ from media import frame_pts, probe, shot_changes
30
+
31
+
32
+ def snap(s, e, changes, fps):
33
+ for c in changes:
34
+ if abs(e - c) < 0.15:
35
+ e = c - 1.5 / fps
36
+ if abs(s - c) < 0.15:
37
+ s = c + 1.5 / fps
38
+ return s, e
39
+
40
+
41
+ def main(spec_path):
42
+ spec = json.load(open(spec_path, encoding="utf-8"))
43
+ base = os.path.dirname(os.path.abspath(spec_path))
44
+ path = lambda x: x if os.path.isabs(x) else os.path.join(base, x)
45
+ X = spec.get("xfade", 0.3)
46
+ cache, segs = {}, []
47
+ for raw in spec["segments"]:
48
+ f = path(raw["file"])
49
+ if f not in cache:
50
+ info = probe(f)
51
+ cache[f] = (info, shot_changes(f), frame_pts(f))
52
+ info, changes, pts = cache[f]
53
+ fps = info["fps"]
54
+ s, e = snap(raw["start"], raw["end"], changes, fps)
55
+ contiguous = bool(segs) and segs[-1]["file"] == f and abs(segs[-1]["raw_end"] - raw["start"]) < 1e-6
56
+ if contiguous: # one shared cut point, mid-frame, so no word repeats
57
+ c = min(changes, key=lambda c: abs(c - raw["start"]), default=raw["start"])
58
+ s = c - 0.5 / fps if abs(c - raw["start"]) < 0.15 else raw["start"]
59
+ segs[-1]["end"] = s
60
+ # Snap to real frame times so audio length == frame count (lip sync never drifts).
61
+ k = bisect.bisect_left(pts, s - 1e-4)
62
+ s = pts[min(k, len(pts) - 1)]
63
+ k = bisect.bisect_left(pts, e - 1e-4)
64
+ e = pts[max(k - 1, 0)] + 1 / fps
65
+ segs.append({"file": f, "start": s, "end": e, "raw_end": raw["end"], "fps": fps, "w": info["w"], "h": info["h"],
66
+ "zoom": raw.get("zoom", 1), "y": raw.get("y", 0.5), "flip": raw.get("flip", False),
67
+ "lcut": round(raw.get("lcut", 0) * fps) / fps, "jcut": round(raw.get("jcut", 0) * fps) / fps,
68
+ "contiguous": contiguous})
69
+ ins, fc, vl = [], "", ""
70
+ for i, sg in enumerate(segs):
71
+ nxt = segs[i + 1] if i + 1 < len(segs) else {"lcut": 0, "jcut": 0}
72
+ ins += ["-i", sg["file"]]
73
+ z = sg["zoom"]
74
+ crop = (f",crop=iw/{z}:ih/{z}:(iw-iw/{z})/2:(ih-ih/{z})*{sg['y']},scale={sg['w']}:{sg['h']}:flags=lanczos"
75
+ if z != 1 else "")
76
+ fc += (f"[{i}:v]trim={sg['start'] - 0.002}:{sg['end'] - nxt['lcut'] - 0.002},setpts=PTS-STARTPTS{crop}"
77
+ f"{',hflip' if sg['flip'] else ''},setsar=1[v{i}];")
78
+ a0 = sg["start"] + sg["lcut"] - sg["jcut"] - (X if i and not sg["contiguous"] else 0)
79
+ if a0 < 0:
80
+ sys.exit(f"segment {i} needs {X + sg['jcut']:.2f}s of sound before its in-point; start it later")
81
+ fc += f"[{i}:a]atrim={a0}:{sg['end'] - nxt['jcut']},asetpts=PTS-STARTPTS[a{i}];"
82
+ vl += f"[v{i}]"
83
+ fc += f"{vl}concat=n={len(segs)}:v=1:a=0[v];"
84
+ prev = "a0"
85
+ for i in range(1, len(segs)):
86
+ join = "concat=n=2:v=0:a=1" if segs[i]["contiguous"] else f"acrossfade=d={X}:c1=tri:c2=tri"
87
+ fc += f"[{prev}][a{i}]{join}[x{i}];"
88
+ prev = f"x{i}"
89
+ out = path(spec["out"])
90
+ os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
91
+ tmp = out + ".part.mp4"
92
+ subprocess.run(["ffmpeg", "-v", "error", "-y", *ins, "-filter_complex", fc.rstrip(";"), "-map", "[v]",
93
+ "-map", f"[{prev}]", "-c:v", "libx264", "-crf", "16", "-preset", "medium", "-pix_fmt", "yuv420p",
94
+ "-c:a", "aac", "-b:a", "256k", "-movflags", "+faststart", tmp], check=True)
95
+ os.replace(tmp, out)
96
+ t = 0.0
97
+ for i, sg in enumerate(segs):
98
+ nxt = segs[i + 1]["lcut"] if i + 1 < len(segs) else 0
99
+ print(f"{t:7.2f} {os.path.basename(sg['file'])} {sg['start']:.3f}-{sg['end'] - nxt:.3f}"
100
+ + (f" zoom {sg['zoom']}" if sg["zoom"] != 1 else "") + (" flip" if sg["flip"] else "")
101
+ + (f" L {sg['lcut']:.2f}" if sg["lcut"] else "") + (f" J {sg['jcut']:.2f}" if sg["jcut"] else ""))
102
+ t += sg["end"] - nxt - sg["start"]
103
+ print(f"total {t:.2f}s -> {out}")
104
+
105
+
106
+ if __name__ == "__main__":
107
+ main(sys.argv[1])
@@ -0,0 +1,20 @@
1
+ """Find one-frame flashes and too-short shots in a cut. Exit code 1 if anything is found.
2
+ usage: python flash_check.py <video> [min_shot_s=1.2]"""
3
+ import sys
4
+
5
+ import numpy as np
6
+
7
+ from media import frame_diffs, probe
8
+
9
+ video = sys.argv[1]
10
+ min_shot = float(sys.argv[2]) if len(sys.argv) > 2 else 1.2
11
+ fps = probe(video)["fps"]
12
+ d = frame_diffs(video, fps)
13
+ cuts = [int(i) for i in np.where(d > 18)[0] if i > 0]
14
+ bad = 0
15
+ for a, b in zip(cuts, cuts[1:]):
16
+ if (b - a) / fps < min_shot:
17
+ print(f"short shot {a / fps:.3f}-{b / fps:.3f}s ({b - a} frames)")
18
+ bad += 1
19
+ print(f"{len(cuts)} cuts checked, {bad} problems")
20
+ sys.exit(1 if bad else 0)
@@ -0,0 +1,54 @@
1
+ """Shared ffmpeg helpers for the micro-drama edit scripts. Needs ffmpeg/ffprobe on PATH and numpy."""
2
+ import json
3
+ import subprocess
4
+
5
+ import numpy as np
6
+
7
+
8
+ def probe(video):
9
+ out = subprocess.check_output(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
10
+ "stream=width,height,avg_frame_rate:format=duration", "-of", "json", video])
11
+ d = json.loads(out)
12
+ s = d["streams"][0]
13
+ num, den = s["avg_frame_rate"].split("/")
14
+ return {"w": s["width"], "h": s["height"], "fps": float(num) / float(den or 1),
15
+ "duration": float(d["format"]["duration"])}
16
+
17
+
18
+ def frame_pts(video):
19
+ """Real presentation time of every video frame, sorted."""
20
+ out = subprocess.check_output(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
21
+ "packet=pts_time", "-of", "csv=p=0", video], text=True)
22
+ return sorted(float(x) for x in out.split() if x and x != "N/A")
23
+
24
+
25
+ def gray_frames(video, fps):
26
+ raw = subprocess.check_output(["ffmpeg", "-v", "error", "-i", video, "-vf",
27
+ f"fps={fps},scale=90:160,format=gray", "-f", "rawvideo", "-"])
28
+ return np.frombuffer(raw, np.uint8).reshape(-1, 160, 90).astype(np.int16)
29
+
30
+
31
+ def frame_diffs(video, fps):
32
+ f = gray_frames(video, fps)
33
+ return np.concatenate([[0], np.abs(np.diff(f, axis=0)).mean(axis=(1, 2))])
34
+
35
+
36
+ def shot_changes(video, threshold=18):
37
+ """Time of the first frame of every new shot (hard cuts only)."""
38
+ fps = probe(video)["fps"]
39
+ d = frame_diffs(video, fps)
40
+ return [round(i / fps, 4) for i in np.where(d > threshold)[0] if i > 0]
41
+
42
+
43
+ def srt_words(path):
44
+ """[(word, start, end)] from a one-word-per-cue SRT (Kolbo transcribe_audio word_by_word_srt_url)."""
45
+ def sec(t):
46
+ h, m, s = t.replace(",", ".").split(":")
47
+ return int(h) * 3600 + int(m) * 60 + float(s)
48
+ words = []
49
+ for block in open(path, encoding="utf-8").read().strip().split("\n\n"):
50
+ lines = block.strip().splitlines()
51
+ if len(lines) >= 3 and "-->" in lines[1]:
52
+ a, b = (x.strip() for x in lines[1].split("-->"))
53
+ words.append((" ".join(lines[2:]).strip(), sec(a), sec(b)))
54
+ return words
@@ -0,0 +1,76 @@
1
+ """Pace report: find dead air (no speech, little motion) in a joined episode. Candidates only - a human or the
2
+ agent judges each one against the script beat (keep a look that lands, cut a stare that re-establishes nothing).
3
+
4
+ usage: python pace_report.py <joined.mp4> <words.srt> [out.json]
5
+ words.srt = one-word-per-cue SRT from Kolbo transcribe_audio (word_by_word_srt_url).
6
+ Writes proposed keep-segments [[start, end], ...]; a piece whose removed gap has no real shot change gets
7
+ [start, end, 1.5, 0.2] (1.5x punch-in so the jump reads as a new angle).
8
+ """
9
+ import json
10
+ import sys
11
+
12
+ import numpy as np
13
+
14
+ from media import frame_diffs, probe, shot_changes, srt_words
15
+
16
+ STEP = 1 / 8 # analysis resolution (s)
17
+ MAX_DEAD = 0.6 # dead air longer than this is a trim candidate
18
+ LEAD_IN = 0.25 # keep before a line
19
+ HOLD = 0.45 # keep after a line (the reaction beat)
20
+ MIN_PIECE = 0.5
21
+
22
+
23
+ def main(video, srt, out=None):
24
+ dur = probe(video)["duration"]
25
+ n = int(dur / STEP)
26
+ t = np.arange(n) * STEP
27
+ m = frame_diffs(video, 1 / STEP)[:n]
28
+ m = np.pad(m, (0, n - len(m)))
29
+ shots = shot_changes(video)
30
+ for c in shots: # a hard cut is a spike, not action
31
+ m[max(int(c / STEP) - 1, 0):int(c / STEP) + 2] = 0
32
+ on = max(4.0, float(np.percentile(m, 70)))
33
+ keep = np.zeros(n, bool)
34
+ for _, a, b in srt_words(srt):
35
+ keep[(t >= a - LEAD_IN) & (t < b + HOLD)] = True
36
+ keep |= np.convolve(m > on, np.ones(4), "same") > 0
37
+ keep[t < 0.5] = True # hook frame
38
+ keep[t > dur - 1.0] = True # cliff hold
39
+ i = 0
40
+ while i < n: # short gaps are breath, keep them
41
+ if keep[i]:
42
+ i += 1
43
+ continue
44
+ j = i
45
+ while j < n and not keep[j]:
46
+ j += 1
47
+ if (j - i) * STEP <= MAX_DEAD:
48
+ keep[i:j] = True
49
+ i = j
50
+ segs, i = [], 0
51
+ while i < n:
52
+ if not keep[i]:
53
+ i += 1
54
+ continue
55
+ j = i
56
+ while j < n and keep[j]:
57
+ j += 1
58
+ s, e = round(float(t[i]), 3), round(min(float(t[j - 1]) + STEP, dur), 3)
59
+ if e - s >= MIN_PIECE:
60
+ segs.append([s, e])
61
+ i = j
62
+ for k in range(1, len(segs)):
63
+ a, b = segs[k - 1][1], segs[k][0]
64
+ if not any(a - 0.1 <= c <= b + 0.1 for c in shots) and len(segs[k - 1]) == 2:
65
+ segs[k] += [1.5, 0.2]
66
+ new = sum(s[1] - s[0] for s in segs)
67
+ print(f"{video}: {dur:.1f}s -> {new:.1f}s ({len(segs) - 1} trim candidates)")
68
+ for k in range(1, len(segs)):
69
+ a, b = segs[k - 1][1], segs[k][0]
70
+ print(f" trim {a:6.2f}-{b:6.2f} ({b - a:.2f}s){' + 1.5x punch-in' if len(segs[k]) > 2 else ''}")
71
+ if out:
72
+ json.dump(segs, open(out, "w"), indent=1)
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main(*sys.argv[1:4])
@@ -0,0 +1,38 @@
1
+ """Shot map: list every shot of a joined episode and save one contact sheet (first / middle / last frame per shot).
2
+
3
+ usage: python shot_map.py <joined.mp4> <out_dir>
4
+ Writes <out_dir>/shots.json ([{n, start, end}]) and <out_dir>/shot-NN.jpg strips. Look at every strip and note,
5
+ per shot: who is frame-left / frame-right, where each person looks, shot size. That is the shot map the cut uses.
6
+ """
7
+ import json
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ from concurrent.futures import ThreadPoolExecutor
12
+
13
+ from media import probe, shot_changes
14
+
15
+
16
+ def main(video, out_dir):
17
+ os.makedirs(out_dir, exist_ok=True)
18
+ info = probe(video)
19
+ edges = [0.0] + shot_changes(video) + [info["duration"]]
20
+ shots = [{"n": i + 1, "start": round(a, 3), "end": round(b, 3)} for i, (a, b) in enumerate(zip(edges, edges[1:]))]
21
+
22
+ def strip(s):
23
+ a, b = s["start"], s["end"]
24
+ times = [a + 0.05, (a + b) / 2, max(b - 0.08, a + 0.05)]
25
+ inputs = sum((["-ss", f"{t:.3f}", "-i", video] for t in times), [])
26
+ subprocess.run(["ffmpeg", "-v", "error", "-y", *inputs, "-filter_complex",
27
+ "".join(f"[{i}:v]scale=270:-2,trim=end_frame=1[s{i}];" for i in range(3)) + "[s0][s1][s2]hstack=3",
28
+ "-frames:v", "1", os.path.join(out_dir, f"shot-{s['n']:02d}.jpg")], check=True)
29
+
30
+ with ThreadPoolExecutor(os.cpu_count()) as pool:
31
+ list(pool.map(strip, shots))
32
+ json.dump(shots, open(os.path.join(out_dir, "shots.json"), "w"), indent=1)
33
+ for s in shots:
34
+ print(f"shot {s['n']:2d} {s['start']:7.2f}-{s['end']:7.2f} ({s['end'] - s['start']:.2f}s)")
35
+
36
+
37
+ if __name__ == "__main__":
38
+ main(*sys.argv[1:3])