1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147 | #!/usr/bin/env python3
"""batch_cards.py — render a ranked quote-bank into HIGH LVL cards (conversation + quote + xpost).
Input: a bank JSON (list, or {"kept":[...]}) of items with:
refined_quote, refined_headline, start_s, end_s, speaker ("19keys"|"guest"|"unclear")
For each of the top N it renders the requested styles off the episode source, deriving:
- conversation: head=headline, speaker label, trans=verbatim transcript for the span w/ auto-gold keywords
- quote: the quote over the full wide frame (16:9, no crop)
- xpost: the quote as a tweet card w/ 1-2 red-underlined keywords
Usage:
python3 tools/batch_cards.py --bank bank.json --source <mp4> --words words.json \
--outdir ~/Movies/HLC-clips/reels/eiw-batch --top 20 --styles conversation,quote,xpost
"""
import argparse
import json
import re
import subprocess
from pathlib import Path
import style_render as sr
FFFULL = "/opt/homebrew/opt/ffmpeg-full/bin"
def make_thumb(mp4, thumbs_dir):
"""One poster WebP per clip so the Library gallery shows a real frame instantly (matches server.make_thumb)."""
try:
thumbs_dir.mkdir(parents=True, exist_ok=True)
t = thumbs_dir / (Path(mp4).stem + ".webp")
subprocess.run([f"{FFFULL}/ffmpeg", "-y", "-ss", "0.5", "-i", str(mp4), "-frames:v", "1",
"-vf", "scale=480:-2", "-c:v", "libwebp", "-quality", "72", str(t)],
capture_output=True, timeout=30)
except Exception:
pass
STOP = set("the a an and or but so of to in on at for with your you we they it is are was were be been "
"that this these those i he she them his her our my me if as by from not no do does did have has "
"had will would can could should about into than then there here what who how why when where all "
"just like right um mm yeah".split())
def load_words(words_path):
d = json.loads(Path(words_path).read_text())
w = d["words"] if isinstance(d, dict) else d
return [x for x in w if x.get("type", "word") == "word"]
def verbatim(words, a, b):
span = [x for x in words if a - 0.05 <= x["start"] <= b + 0.05]
return " ".join(x["text"] for x in span).strip()
def keywords(text, n=2):
"""Pick the n most salient words (longest, non-stopword) for highlighting."""
seen, picks = set(), []
for w in sorted(re.findall(r"[A-Za-z][A-Za-z'-]+", text), key=len, reverse=True):
lw = w.lower()
if lw in STOP or lw in seen or len(w) < 4:
continue
seen.add(lw)
picks.append(w)
if len(picks) >= n:
break
return picks
def mark(text, words_to_mark, open_t, close_t):
"""Wrap the first occurrence of each word (whole-word, case-insensitive) in open_t/close_t."""
out = text
for w in words_to_mark:
out = re.sub(rf"(?<![\w]){re.escape(w)}(?![\w])", f"{open_t}{w}{close_t}", out, count=1)
return out
SPEAKER_LABEL = {"19keys": "19 KEYS", "guest": "BOYCE WATKINS", "unclear": "19 KEYS"}
def render_one(item, idx, source, words, outdir, styles):
a, b = float(item["start_s"]), float(item["end_s"])
quote = item["refined_quote"].strip().strip('"').strip()
head = item["refined_headline"].strip().strip('"').strip()
speaker = SPEAKER_LABEL.get(str(item.get("speaker", "")).lower(), "19 KEYS")
made = {}
for style in styles:
out = Path(outdir) / f"eiw-q{idx:02d}-{style}.mp4"
if style == "conversation":
tr = verbatim(words, a, b) or quote
tr = mark(tr, keywords(head, 2), "[g]", "[/g]")
params = {"head": head, "speaker": speaker, "trans": tr}
elif style == "quote":
params = {"quote": quote, "aspect": "wide"}
elif style == "xpost":
# card shows ONLY the one-liner hook (card_line: pre-picked verbatim line w/ [u] marks +
# signature already applied by speaker) — the CLIP runs the full riff independently.
if item.get("card_line"):
post = item["card_line"]
else: # fallback: derive from the quote; sign only 19Keys' own words
sig = " -19Keys" if str(item.get("speaker", "")).lower() == "19keys" else ""
post = mark(quote, keywords(quote, 2), "[u]", "[/u]") + sig
params = {"name": "19keys", "at": "@19keys_", "post": post}
else:
continue
try:
sr.render(source, a, b, style, str(out), params=params, captions="off")
make_thumb(out, Path(outdir).parent / ".thumbs") # poster so it shows in the Library gallery
made[style] = str(out)
except Exception as e: # keep the batch going; report the failure
made[style] = f"FAILED: {e}"
return made
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--bank", required=True)
ap.add_argument("--source", required=True)
ap.add_argument("--words", required=True)
ap.add_argument("--outdir", required=True)
ap.add_argument("--top", type=int, default=20)
ap.add_argument("--offset", type=int, default=0, help="skip the first N (for parallel workers)")
ap.add_argument("--count", type=int, default=0, help="render only this many after offset (0 = through --top)")
ap.add_argument("--styles", default="conversation,quote,xpost")
a = ap.parse_args()
bank = json.loads(Path(a.bank).read_text())
items = bank["kept"] if isinstance(bank, dict) and "kept" in bank else bank
items = items[: a.top]
lo = a.offset
hi = (a.offset + a.count) if a.count else len(items)
styles = [s.strip() for s in a.styles.split(",") if s.strip()]
words = load_words(a.words)
Path(a.outdir).mkdir(parents=True, exist_ok=True)
manifest = []
for i in range(lo, min(hi, len(items))):
it = items[i]
made = render_one(it, i + 1, a.source, words, a.outdir, styles) # i+1 = global rank
row = {"idx": i + 1, "headline": it["refined_headline"], "span": [it["start_s"], it["end_s"]],
"score": it.get("final_score"), "renders": made}
manifest.append(row)
print(f"[{i+1}/{len(items)}] {it['refined_headline']} -> {', '.join(k for k,v in made.items() if not str(v).startswith('FAILED'))}")
Path(a.outdir, f"manifest-{lo:02d}.json").write_text(json.dumps(manifest, indent=2)) # offset-scoped: parallel-safe
print("manifest:", str(Path(a.outdir, f"manifest-{lo:02d}.json")))
if __name__ == "__main__":
main()
|