@m13v/s4l 1.7.3 → 1.7.4-rc.10

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,152 @@
1
+ #!/usr/bin/env python3
2
+ """scripts/recent_self_posts.py — cross-cycle anti-repetition context.
3
+
4
+ Prints a prompt block listing OUR most recent posted replies on a platform
5
+ (across ALL projects), so a drafting session can see what this account
6
+ already sounds like and deliberately diverge. This is the cross-cycle
7
+ complement to author_history_block.py (which is per-author only): before
8
+ 2026-07-10 the model never saw its own recent output across threads and
9
+ kept recycling the same openers and sentence skeletons cycle after cycle.
10
+
11
+ The block is explicitly NEGATIVE context ("do not sound like these"), the
12
+ opposite of the top_performers few-shots. Keep it that way: never add
13
+ engagement numbers or any "this one did well" framing here, or the model
14
+ will read it as examples to imitate.
15
+
16
+ Wired into (one callsite):
17
+ - skill/run-twitter-cycle.sh (Phase 2b-prep PREP_PROMPT)
18
+
19
+ CLI:
20
+ python3 scripts/recent_self_posts.py --platform twitter --limit 20
21
+
22
+ Stdout is a ready-to-inject prompt block; EMPTY stdout when there are no
23
+ rows or on any failure (the cycle must never block on this context).
24
+ Stderr carries diagnostics only.
25
+ """
26
+
27
+ import argparse
28
+ import os
29
+ import sys
30
+
31
+ REPO_DIR = os.path.expanduser("~/social-autoposter")
32
+ sys.path.insert(0, os.path.join(REPO_DIR, "scripts"))
33
+ from http_api import api_get # noqa: E402
34
+
35
+ # Truncation length per reply. Long enough to expose the opener + skeleton
36
+ # (the parts the model must avoid repeating), short enough that 20 rows stay
37
+ # a small fraction of the prompt.
38
+ SNIPPET_CHARS = 220
39
+
40
+
41
+ def _load_active_campaign_suffixes():
42
+ """Best-effort list of active campaign suffix literals to strip.
43
+
44
+ Same contract as author_history_block._load_active_campaign_suffixes:
45
+ the block must never teach the model to echo a campaign suffix (the
46
+ tool layer appends its own copy at post time). On failure returns [].
47
+ """
48
+ suffixes = []
49
+ try:
50
+ resp = api_get(
51
+ "/api/v1/campaigns",
52
+ query={"status": "active", "has_suffix": "true", "limit": 500},
53
+ )
54
+ rows = ((resp or {}).get("data") or {}).get("campaigns") or []
55
+ for r in rows:
56
+ s = (r.get("suffix") or "").strip()
57
+ if s and s not in suffixes:
58
+ suffixes.append(s)
59
+ except Exception as e:
60
+ print(f"[recent_self_posts] suffix load failed: {e!r}", file=sys.stderr)
61
+ return suffixes
62
+
63
+
64
+ def _strip_suffixes(text, suffixes):
65
+ """Trailing-only, idempotent strip (mirrors author_history_block)."""
66
+ if not text or not suffixes:
67
+ return text
68
+ cleaned = text.rstrip()
69
+ changed = True
70
+ while changed:
71
+ changed = False
72
+ for sfx in suffixes:
73
+ if sfx and cleaned.endswith(sfx):
74
+ cleaned = cleaned[: -len(sfx)].rstrip()
75
+ changed = True
76
+ return cleaned
77
+
78
+
79
+ def _snippet(text, suffixes):
80
+ """One-line, suffix-stripped, truncated rendering of a reply."""
81
+ t = _strip_suffixes((text or "").strip(), suffixes)
82
+ t = " ".join(t.split()) # collapse newlines/runs of whitespace
83
+ if len(t) > SNIPPET_CHARS:
84
+ t = t[: SNIPPET_CHARS - 1].rstrip() + "…"
85
+ return t
86
+
87
+
88
+ def build_block(platform, limit):
89
+ """Return the prompt block string, or "" when nothing to show."""
90
+ resp = api_get(
91
+ "/api/v1/posts",
92
+ query={
93
+ "platform": platform,
94
+ "status": "active",
95
+ "order_by": "posted_at",
96
+ "order_dir": "desc",
97
+ "limit": str(limit),
98
+ # Scope to THIS install's own posts (server filters on the
99
+ # authenticated X-Installation identity). Without this the
100
+ # default read returns the whole fleet's posts and the block
101
+ # would claim another account's replies as "yours"
102
+ # (found 2026-07-10: 8 of 20 rows were another install's).
103
+ "own_install": "true",
104
+ },
105
+ )
106
+ rows = ((resp or {}).get("data") or {}).get("posts") or []
107
+ suffixes = _load_active_campaign_suffixes()
108
+ items = []
109
+ for r in rows:
110
+ snip = _snippet(r.get("our_content"), suffixes)
111
+ if not snip:
112
+ continue
113
+ date = str(r.get("posted_at") or "")[:10]
114
+ proj = r.get("project_name") or "(no project)"
115
+ items.append(f"{len(items) + 1}. [{date} | {proj}] {snip}")
116
+ if len(items) >= limit:
117
+ break
118
+ if not items:
119
+ return ""
120
+ header = (
121
+ "## YOUR RECENT REPLIES (cross-cycle anti-repetition; NEGATIVE examples)\n"
122
+ f"The {len(items)} most recent replies this account posted, all projects. "
123
+ "This is what you ALREADY sound like. It is NOT a list to imitate. "
124
+ "Hard rules for every draft this cycle:\n"
125
+ "- Do NOT reuse any opener below (the first 6-8 words' shape counts, "
126
+ "not just the exact words).\n"
127
+ "- Do NOT reuse their sentence skeletons, rhetorical moves, or pet "
128
+ "phrases (recurring words like 'actually', 'the real X', copula "
129
+ "reframes 'X is the Y').\n"
130
+ "- If a draft you are writing starts to echo any entry below, stop "
131
+ "and rewrite it from a different entry point.\n"
132
+ )
133
+ return header + "\n".join(items)
134
+
135
+
136
+ def main():
137
+ parser = argparse.ArgumentParser(
138
+ description="Render the cross-cycle recent-self-replies prompt block")
139
+ parser.add_argument("--platform", default="twitter")
140
+ parser.add_argument("--limit", type=int, default=20)
141
+ args = parser.parse_args()
142
+ try:
143
+ block = build_block(args.platform, max(1, min(args.limit, 50)))
144
+ except Exception as e:
145
+ print(f"[recent_self_posts] failed: {e!r}", file=sys.stderr)
146
+ block = ""
147
+ if block:
148
+ print(block)
149
+
150
+
151
+ if __name__ == "__main__":
152
+ main()
@@ -29,7 +29,16 @@ Usage:
29
29
 
30
30
  Output (stdout, last line is JSON):
31
31
  {"ok": true, "handle": "...", "profile": {...}, "posts": [...],
32
- "comments": [...], "counts": {...}, "grounding_instructions": "..."}
32
+ "comments": [...], "top_posts": [...], "top_replies": [...],
33
+ "counts": {...}, "grounding_instructions": "..."}
34
+
35
+ top_posts / top_replies are the same items ranked by real engagement
36
+ (likes*3 + retweets*5 + replies*2), each stamped with rank + engagement_score.
37
+ On /with_replies each reply also carries `parent` = {author, text, url} (the
38
+ tweet it replied to, best-effort DOM-adjacent pairing), and the top posts get
39
+ their thread continuation expanded (`thread`: [tweet texts]) by visiting the
40
+ permalink. scripts/voice_exemplars.py turns these into voice.examples +
41
+ persona_corpus.txt exemplars.
33
42
  """
34
43
 
35
44
  from __future__ import annotations
@@ -182,7 +191,11 @@ def scrape_profile(send) -> dict:
182
191
  # --------------------------------------------------------------------------- #
183
192
  _TIMELINE_JS_TMPL = r"""(function(){
184
193
  var ME=%s; // lowercase handle without @
194
+ var WITH_PARENTS=%s; // true only on /with_replies: pair each reply with the
195
+ // other-author article directly above it (same
196
+ // conversation cell renders parent then our reply)
185
197
  var out=[];
198
+ var lastOther=null;
186
199
  var arts=document.querySelectorAll('article');
187
200
  for(var i=0;i<arts.length;i++){
188
201
  var art=arts[i];
@@ -194,10 +207,8 @@ _TIMELINE_JS_TMPL = r"""(function(){
194
207
  var mm=hh.match(/^\/([A-Za-z0-9_]{1,15})$/);
195
208
  if(mm){authorHandle=mm[1].toLowerCase();break;}
196
209
  }
197
- if(authorHandle && authorHandle!==ME) continue; // skip others' posts (reposts/quotes/threads)
198
210
  var tEl=art.querySelector('[data-testid="tweetText"]');
199
211
  var text=tEl?(tEl.innerText||'').trim():'';
200
- if(!text) continue;
201
212
  // permalink + id
202
213
  var url='';var id='';
203
214
  var statusLinks=art.querySelectorAll('a[href*="/status/"]');
@@ -206,6 +217,16 @@ _TIMELINE_JS_TMPL = r"""(function(){
206
217
  var sm=sh.match(/\/status\/(\d+)/);
207
218
  if(sm){id=sm[1];url='https://x.com'+sh.split('?')[0];break;}
208
219
  }
220
+ if(authorHandle && authorHandle!==ME){
221
+ // Someone else's article. On /with_replies it is the parent of our next
222
+ // reply in the same conversation cell; remember it. On the posts tab it
223
+ // is a repost/quote we simply skip (WITH_PARENTS=false there).
224
+ if(WITH_PARENTS && text){
225
+ lastOther={author:'@'+authorHandle,text:text.slice(0,500),url:url};
226
+ }
227
+ continue;
228
+ }
229
+ if(!text) continue;
209
230
  // reply? presence of a "Replying to" header in the cell
210
231
  var isReply=false, replyTo='';
211
232
  var spans=art.querySelectorAll('span,div');
@@ -226,15 +247,21 @@ _TIMELINE_JS_TMPL = r"""(function(){
226
247
  var m=al.match(/([\d,]+)/);
227
248
  return m?parseInt(m[1].replace(/,/g,''),10):0;
228
249
  }
229
- out.push({text:text,url:url,id:id,is_reply:isReply,reply_to:replyTo,
230
- likes:metric('like'),replies:metric('reply'),retweets:metric('retweet')});
250
+ var item={text:text,url:url,id:id,is_reply:isReply,reply_to:replyTo,
251
+ likes:metric('like'),replies:metric('reply'),retweets:metric('retweet')};
252
+ if(WITH_PARENTS){
253
+ item.parent=lastOther; // may be null (their own standalone post)
254
+ lastOther=null; // consume: never pair one parent with two replies
255
+ }
256
+ out.push(item);
231
257
  }
232
258
  return JSON.stringify(out);
233
259
  })()"""
234
260
 
235
261
 
236
262
  def scrape_timeline(send, me: str, want: int, max_scrolls: int = 30,
237
- exclude_ids: "set | None" = None) -> list:
263
+ exclude_ids: "set | None" = None,
264
+ capture_parents: bool = False) -> list:
238
265
  """Scroll the current timeline, collecting up to `want` of the user's OWN
239
266
  authored articles (in DOM order = newest first). `exclude_ids` drops items
240
267
  already captured elsewhere — that's how the comments pass (/with_replies)
@@ -250,7 +277,8 @@ def scrape_timeline(send, me: str, want: int, max_scrolls: int = 30,
250
277
  scrolling to the bottom each step to force the next lazy-load batch."""
251
278
  seen: dict[str, dict] = {}
252
279
  exclude_ids = exclude_ids or set()
253
- expr = _TIMELINE_JS_TMPL % json.dumps(me.lower())
280
+ expr = _TIMELINE_JS_TMPL % (json.dumps(me.lower()),
281
+ "true" if capture_parents else "false")
254
282
  STALL_LIMIT = 4
255
283
  stall = 0
256
284
  for n in range(max_scrolls):
@@ -283,6 +311,113 @@ def scrape_timeline(send, me: str, want: int, max_scrolls: int = 30,
283
311
  return items[:want]
284
312
 
285
313
 
314
+ def _own_posted_ids() -> set:
315
+ """Status ids of everything S4L itself has posted from this install, via
316
+ /api/v1/posts (install-scoped by the X-Installation header). Used to keep
317
+ the bot's own output OUT of the author-voice exemplar pool: on accounts
318
+ where S4L has been active, a recency scan is dominated by S4L drafts, and
319
+ feeding those back as 'the author's voice' is a feedback loop. Best-effort:
320
+ any failure (offline, fresh install, no API) returns an empty set and the
321
+ scan proceeds unfiltered."""
322
+ try:
323
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
324
+ from http_api import api_get # noqa: PLC0415
325
+ import re
326
+ ids: set = set()
327
+ # The route caps limit at 500, so walk the FULL posting history with a
328
+ # forward posted_at cursor (order_dir=asc + since>=). On prolific
329
+ # accounts a single newest-500 page misses older S4L posts that the
330
+ # profile scan still reaches (bit the operator account: 12.8k posted
331
+ # statuses, 2 of 5 picked exemplars were S4L's own). The since filter
332
+ # is inclusive, so overlap rows dedupe via the set; a page whose max
333
+ # posted_at equals the cursor would loop and breaks instead.
334
+ cursor = None
335
+ for _ in range(80): # hard cap 40k statuses
336
+ q = {"platform": "twitter", "has_our_url": "true", "limit": 500,
337
+ "order_by": "posted_at", "order_dir": "asc"}
338
+ if cursor:
339
+ q["since"] = cursor
340
+ resp = api_get("/api/v1/posts", q)
341
+ rows = ((resp or {}).get("data") or {}).get("posts") or []
342
+ for r in rows:
343
+ m = re.search(r"/status/(\d+)", str(r.get("our_url") or ""))
344
+ if m:
345
+ ids.add(m.group(1))
346
+ if len(rows) < 500:
347
+ break
348
+ page_max = max((r.get("posted_at") or "" for r in rows), default="")
349
+ if not page_max or page_max == cursor:
350
+ break
351
+ cursor = page_max
352
+ return ids
353
+ except Exception as e:
354
+ print(f"[scan_x_profile] own-posts exclusion unavailable: {e}", file=sys.stderr)
355
+ return set()
356
+
357
+
358
+ # --------------------------------------------------------------------------- #
359
+ # Engagement ranking + thread expansion for exemplar extraction.
360
+ # --------------------------------------------------------------------------- #
361
+ def _engagement_score(item: dict) -> int:
362
+ """Same weighting everywhere exemplars are ranked (voice_exemplars.py
363
+ re-derives it if absent): a retweet is a stronger signal than a like,
364
+ a like stronger than a reply-back."""
365
+ return (int(item.get("likes") or 0) * 3
366
+ + int(item.get("retweets") or 0) * 5
367
+ + int(item.get("replies") or 0) * 2)
368
+
369
+
370
+ def rank_top(items: list, n: int = 5) -> list:
371
+ """Top n items by engagement, stamped with rank + engagement_score.
372
+ Zero-engagement items still rank (small accounts often have nothing else);
373
+ the score on each entry lets downstream decide what to keep."""
374
+ ranked = sorted(items, key=_engagement_score, reverse=True)[:n]
375
+ out = []
376
+ for i, it in enumerate(ranked, 1):
377
+ e = dict(it)
378
+ e["rank"] = i
379
+ e["engagement_score"] = _engagement_score(it)
380
+ out.append(e)
381
+ return out
382
+
383
+
384
+ _THREAD_JS_TMPL = r"""(function(){
385
+ var ME=%s;
386
+ var out=[];var started=false;
387
+ var arts=document.querySelectorAll('article');
388
+ for(var i=0;i<arts.length;i++){
389
+ var art=arts[i];
390
+ var authorHandle='';
391
+ var links=art.querySelectorAll('a[href^="/"]');
392
+ for(var j=0;j<links.length;j++){
393
+ var hh=links[j].getAttribute('href')||'';
394
+ var mm=hh.match(/^\/([A-Za-z0-9_]{1,15})$/);
395
+ if(mm){authorHandle=mm[1].toLowerCase();break;}
396
+ }
397
+ var tEl=art.querySelector('[data-testid="tweetText"]');
398
+ var text=tEl?(tEl.innerText||'').trim():'';
399
+ if(authorHandle===ME && text){out.push(text);started=true;}
400
+ else if(started){break;} // first non-ME article after the run = end of thread
401
+ }
402
+ return JSON.stringify(out);
403
+ })()"""
404
+
405
+
406
+ def expand_thread(send, me: str, url: str) -> list:
407
+ """Visit a post's permalink and return the consecutive run of the user's own
408
+ tweets starting at the focal one ([focal, continuation, ...]). Length 1 =
409
+ not a thread. Bonus: the permalink shows full text, so this also untruncates
410
+ posts the timeline cut with 'Show more'. Best-effort; [] on failure."""
411
+ if not url or not _navigate(send, url, settle=3.5, expect="/status/"):
412
+ return []
413
+ raw = _eval(send, _THREAD_JS_TMPL % json.dumps(me.lower())) or "[]"
414
+ try:
415
+ parts = json.loads(raw)
416
+ except Exception:
417
+ parts = []
418
+ return parts if isinstance(parts, list) else []
419
+
420
+
286
421
  GROUNDING_INSTRUCTIONS = (
287
422
  "You now have this user's real X profile (bio, original posts, and their own "
288
423
  "replies). Use it as GROUND TRUTH to draft their autoposter config fields in "
@@ -294,9 +429,15 @@ GROUNDING_INSTRUCTIONS = (
294
429
  "capitalization habits, emoji/punctuation usage, and recurring phrases or tics. "
295
430
  "Write the `voice` field so a reply drafted with it would be indistinguishable "
296
431
  "from something they'd actually type.\n"
297
- "3. GOLDEN-RULE EXAMPLES: pick 2-4 of their strongest real replies/posts "
298
- "verbatim and keep them as exemplars (these become few-shot anchors). Choose "
299
- "ones that show the target reply behavior: helpful, specific, in-voice.\n"
432
+ "3. GOLDEN-RULE EXAMPLES: `top_replies` and `top_posts` are already ranked by "
433
+ "REAL engagement (each entry carries likes/retweets/replies, engagement_score, "
434
+ "the parent tweet it replied to, and thread continuations). From them keep up "
435
+ "to 5 replies verbatim as exemplars, skipping throwaway one-liners that show "
436
+ "no voice; on small accounts engagement is sparse, so judge voice quality too. "
437
+ "STORE them in the project's `voice.examples` (every drafter on every platform "
438
+ "mirrors that field), or run "
439
+ "`voice_exemplars.py apply --scan <scan.json> --project <name>` to write "
440
+ "voice.examples + the persona_corpus.txt exemplar section deterministically.\n"
300
441
  "4. PHRASE BANK: list the kinds of phrases / openers / sign-offs they reuse, "
301
442
  "and any words/claims they clearly AVOID (for `content_guardrails`).\n"
302
443
  "5. ICP: infer who they engage with (who they reply to, what communities) to "
@@ -312,8 +453,12 @@ GROUNDING_INSTRUCTIONS = (
312
453
  def main() -> int:
313
454
  ap = argparse.ArgumentParser()
314
455
  ap.add_argument("--handle", default=None, help="@handle to scan (default: live logged-in handle)")
315
- ap.add_argument("--posts", type=int, default=20, help="max original posts to collect")
316
- ap.add_argument("--comments", type=int, default=50, help="max replies/comments to collect")
456
+ ap.add_argument("--posts", type=int, default=60, help="max original posts to collect")
457
+ ap.add_argument("--comments", type=int, default=150, help="max replies/comments to collect")
458
+ ap.add_argument("--top", type=int, default=5, help="how many top posts/replies to rank")
459
+ ap.add_argument("--expand-threads", type=int, default=3,
460
+ help="visit this many top posts' permalinks to capture thread "
461
+ "continuations + full text (0 = off)")
317
462
  args = ap.parse_args()
318
463
 
319
464
  if create_connection is None:
@@ -345,19 +490,48 @@ def main() -> int:
345
490
  expect=f"/{handle}")
346
491
  profile = scrape_profile(send) if on_profile else {}
347
492
 
348
- # 2. Original posts (current page = posts tab).
349
- posts = scrape_timeline(send, handle, args.posts) if on_profile else []
493
+ # 2. Everything S4L itself posted from this install gets excluded from
494
+ # BOTH surfaces (posts and replies): the exemplars must be the
495
+ # human's writing, not the bot's own output echoed back.
496
+ s4l_ids = _own_posted_ids()
497
+ if s4l_ids:
498
+ print(f"[scan_x_profile] excluding {len(s4l_ids)} s4l-posted statuses "
499
+ "from the exemplar pool", file=sys.stderr)
500
+
501
+ # 3. Original posts (current page = posts tab). max_scrolls tracks the
502
+ # requested depth: the scan is programmatic, so scrolling deeper
503
+ # costs only time, and end-of-feed stall detection stops it early
504
+ # on small accounts.
505
+ posts = (scrape_timeline(send, handle, args.posts,
506
+ max_scrolls=max(30, args.posts),
507
+ exclude_ids=s4l_ids)
508
+ if on_profile else [])
350
509
  post_ids = {p.get("id") for p in posts if p.get("id")}
351
510
 
352
- # 3. Replies / comments = the user's own articles on /with_replies that
511
+ # 4. Replies / comments = the user's own articles on /with_replies that
353
512
  # are NOT among the original posts (set subtraction, not DOM text).
354
513
  on_replies = _navigate(send, f"https://x.com/{handle}/with_replies",
355
514
  settle=4.0, expect=f"/{handle}/with_replies")
356
515
  comments = (
357
- scrape_timeline(send, handle, args.comments, exclude_ids=post_ids)
516
+ scrape_timeline(send, handle, args.comments,
517
+ max_scrolls=max(30, args.comments),
518
+ exclude_ids=post_ids | s4l_ids,
519
+ capture_parents=True)
358
520
  if on_replies else []
359
521
  )
360
522
 
523
+ # 5. Rank both surfaces by real engagement, then expand the top posts'
524
+ # permalinks to capture thread continuations (and untruncated text).
525
+ top_posts = rank_top(posts, args.top)
526
+ top_replies = rank_top(comments, args.top)
527
+ for tp in top_posts[:max(args.expand_threads, 0)]:
528
+ parts = expand_thread(send, handle, tp.get("url") or "")
529
+ if parts:
530
+ if len(parts[0]) > len(tp.get("text") or ""):
531
+ tp["text"] = parts[0] # permalink text is never truncated
532
+ if len(parts) > 1:
533
+ tp["thread"] = parts
534
+
361
535
  result = {
362
536
  "ok": True,
363
537
  "state": "scanned",
@@ -365,9 +539,28 @@ def main() -> int:
365
539
  "profile": profile,
366
540
  "posts": posts,
367
541
  "comments": comments,
368
- "counts": {"posts": len(posts), "comments": len(comments)},
542
+ "top_posts": top_posts,
543
+ "top_replies": top_replies,
544
+ "counts": {"posts": len(posts), "comments": len(comments),
545
+ "s4l_posted_excluded": len(s4l_ids)},
369
546
  "grounding_instructions": GROUNDING_INSTRUCTIONS,
370
547
  }
548
+
549
+ # Persist the corpus beside config.json so the later project-save step
550
+ # (setup writes the project, then voice_exemplars.py auto-applies the
551
+ # exemplars) works without a re-scan. The scan can run BEFORE config.json
552
+ # exists on a fresh onboarding, hence mkdir. Best-effort: the scan is
553
+ # still fully usable from stdout if the write fails.
554
+ try:
555
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
556
+ import s4l_mode
557
+ sidecar = s4l_mode.config_path().parent / "last_profile_scan.json"
558
+ sidecar.parent.mkdir(parents=True, exist_ok=True)
559
+ sidecar.write_text(json.dumps(result, ensure_ascii=False))
560
+ result["scan_file"] = str(sidecar)
561
+ except Exception:
562
+ result["scan_file"] = None
563
+
371
564
  print(json.dumps(result, ensure_ascii=False))
372
565
  return 0
373
566
  except Exception as e:
@@ -0,0 +1,92 @@
1
+ import re, os, subprocess, json, hashlib
2
+ from collections import Counter, defaultdict
3
+ from itertools import combinations
4
+
5
+ DATA = os.path.expanduser("~/social-autoposter/mixer/remotion/src/mixer/data.ts")
6
+ PUB = os.path.expanduser("~/social-autoposter/mixer/remotion/public/mixer")
7
+ src = open(DATA).read()
8
+
9
+ # Extract every clipsV2 block and legacy clips arrays referencing tlh-*.mp4
10
+ # Find all "mixer/tlh-...mp4" occurrences grouped per variant via clipsV2:[ ... ] blocks
11
+ sets = []
12
+ for m in re.finditer(r'clipsV2:\s*\[(.*?)\]', src, re.S):
13
+ clips = re.findall(r'mixer/(tlh-[0-9a-z\-]+\.mp4)', m.group(1))
14
+ if clips:
15
+ sets.append(clips)
16
+ # legacy v1: clips arrays inside TLH? lesson-1 uses clips[] with tlh-1..5
17
+ for m in re.finditer(r'clips:\s*\[(.*?)\]', src, re.S):
18
+ clips = re.findall(r'"(tlh-[0-9a-z\-]+\.mp4)"', m.group(1))
19
+ if clips:
20
+ sets.append(clips)
21
+
22
+ def family(fn):
23
+ # tlh-<firstint>-... -> first integer group
24
+ mm = re.match(r'tlh-(\d+)', fn)
25
+ return mm.group(1) if mm else fn
26
+
27
+ usage = Counter()
28
+ cooc = Counter()
29
+ for s in sets:
30
+ for c in set(s):
31
+ usage[c]+=1
32
+ for a,b in combinations(sorted(set(s)),2):
33
+ cooc[(a,b)]+=1
34
+
35
+ # available encoded tlh clips on disk
36
+ avail = sorted([f for f in os.listdir(PUB) if re.match(r'tlh-[0-9a-z\-]+\.mp4$', f)])
37
+
38
+ def probe(fn):
39
+ p = os.path.join(PUB, fn)
40
+ out = subprocess.run(["ffprobe","-v","error","-select_streams","v:0",
41
+ "-show_entries","stream=width,height:format=duration","-of","json",p],
42
+ capture_output=True,text=True).stdout
43
+ j = json.loads(out)
44
+ st = j["stream"][0] if "stream" in j else j["streams"][0]
45
+ dur = float(j["format"]["duration"])
46
+ return st["width"], st["height"], dur
47
+
48
+ def blackframes(fn):
49
+ p = os.path.join(PUB, fn)
50
+ r = subprocess.run(["ffmpeg","-v","error","-i",p,"-vf","blackdetect=d=0.1:pic_th=0.98",
51
+ "-an","-f","null","-"],capture_output=True,text=True)
52
+ return "black_start" in r.stderr
53
+
54
+ def sha(fn):
55
+ return hashlib.sha256(open(os.path.join(PUB,fn),'rb').read()).hexdigest()
56
+
57
+ # Filter avail to valid 1080x1920, dur>=1.75 (holds a 2.0s slot acceptably or exact), non-black
58
+ os.environ["PATH"]="/opt/homebrew/Cellar/ffmpeg/8.1.1/bin:"+os.environ["PATH"]
59
+ valid=[]
60
+ info={}
61
+ for f in avail:
62
+ try:
63
+ w,h,d = probe(f)
64
+ except Exception as e:
65
+ continue
66
+ if (w,h)!=(1080,1920): continue
67
+ info[f]=(w,h,d)
68
+ valid.append(f)
69
+
70
+ print("total prior sets:", len(sets))
71
+ print("valid 1080x1920 clips on disk:", len(valid))
72
+
73
+ # Build candidate 4-sets: distinct families, zero pairwise co-occurrence, low total usage.
74
+ # Rank by (max pairwise cooc, total usage). Verify durations >=1.75 and non-black + distinct sha lazily on the winner.
75
+ from itertools import combinations as comb
76
+ # group valid by family, prefer low-usage clips
77
+ valid_sorted = sorted(valid, key=lambda f:(usage[f], f))
78
+ best=None
79
+ # to keep it tractable, restrict candidate pool to the 40 lowest-usage valid clips across distinct families
80
+ pool = valid_sorted[:60]
81
+ results=[]
82
+ for quad in comb(pool,4):
83
+ fams=[family(f) for f in quad]
84
+ if len(set(fams))!=4: continue
85
+ pairs=list(comb(sorted(quad),2))
86
+ maxco=max(cooc[p] for p in pairs)
87
+ tot=sum(usage[f] for f in quad)
88
+ results.append((maxco,tot,quad))
89
+ results.sort(key=lambda x:(x[0],x[1]))
90
+ print("\ntop 15 candidate quads (maxcooc, totalusage, clips):")
91
+ for maxco,tot,quad in results[:15]:
92
+ print(maxco,tot,list(quad),"durs",[round(info[f][2],3) for f in quad])