Spaces:
Sleeping
Sleeping
| """ | |
| services/script_engine.py [NEW] | |
| Fills the Timestamp Breakdown from the TRANSCRIPT ALONE β no retention curve | |
| required. | |
| Why this exists: retention is owner-only AND YouTube suppresses it on | |
| low-view videos. A 491-sub channel may legitimately get zero retention rows for | |
| months. If the Coach can only work when retention exists, the Coach doesn't work. | |
| But the script is always there, and most retention deaths are visible in it | |
| before a single viewer shows up: | |
| β’ 30 seconds of "hey guys welcome back, before we start, smash that like" | |
| β’ time-to-value of 1:50 in an 8-minute video | |
| β’ asking for the subscribe before delivering anything | |
| β’ 4 minutes of monologue with no question, no "you", no pattern interrupt | |
| β’ filler density that makes the first minute unlistenable | |
| Every finding here carries a REAL timestamp and a REAL quote from the video, so | |
| the Timestamp Breakdown card is never empty and never generic. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from services.transcript_service import Transcript, fmt_ts | |
| FILLER = ("um", "uh", "erm", "like", "you know", "kind of", "sort of", "basically", | |
| "actually", "literally", "i mean", "so yeah", "right?", "okay so", "anyway") | |
| HOUSEKEEPING = ("welcome back", "welcome to my channel", "hey guys", "what's up guys", | |
| "my name is", "before we start", "before we begin", "before we get into", | |
| "don't forget to", "smash that like", "hit the like", "hit the bell", | |
| "subscribe to the channel", "if you're new here", "quick word", | |
| "today's video is sponsored", "let's get into it", "without further ado") | |
| VALUE_MARKERS = ("here's", "here is", "the first", "step one", "step 1", "the trick", | |
| "the reason", "turns out", "what most people", "the problem is", | |
| "let me show you", "look at this", "watch what happens", "the answer is") | |
| CTA = ("subscribe", "hit the bell", "like this video", "comment below", "link in the description", | |
| "join the channel", "smash that") | |
| ENGAGE = ("you", "your", "?", "imagine", "look", "watch", "notice", "here's the thing") | |
| def _words(text: str) -> int: | |
| return len(text.split()) | |
| def _filler_hits(text: str) -> list[str]: | |
| low = " " + text.lower() + " " | |
| return [f for f in FILLER if f" {f} " in low or low.count(f) >= 2] | |
| def analyse_script(t: Transcript, duration_sec: int, is_short: bool) -> list[dict]: | |
| """ | |
| Returns [{timestamp, t, issue, evidence, fix, severity}] β hard findings with | |
| receipts. Empty list if there's no speech transcript. | |
| """ | |
| if not t.is_speech or duration_sec < 15: | |
| return [] | |
| out: list[dict] = [] | |
| full = t.window(0, duration_sec, max_chars=100_000).lower() | |
| # ββ 1. HOUSEKEEPING BEFORE VALUE ββββββββββββββββββββββββββββββββββββββ | |
| opener = t.window(0, 30, max_chars=800) | |
| hk = [h for h in HOUSEKEEPING if h in opener.lower()] | |
| if hk: | |
| out.append({ | |
| "t": 0.0, "timestamp": "0:00", | |
| "issue": f"You open with housekeeping, not the promise ({', '.join(hk[:2])})", | |
| "evidence": opener[:180], | |
| "fix": "Delete it. Open on the single most surprising sentence in the video β the one you " | |
| "currently say later. Intros, sub-begs and self-intros belong after the first payoff.", | |
| "severity": "high", | |
| }) | |
| # ββ 2. TIME TO VALUE ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ttv = None | |
| for s in t.segments: | |
| if any(v in s.text.lower() for v in VALUE_MARKERS) or re.search(r"\b\d+\b", s.text): | |
| ttv = s.start | |
| break | |
| budget = 8 if is_short else 25 | |
| if ttv is None: | |
| out.append({ | |
| "t": 0.0, "timestamp": "0:00", | |
| "issue": "No concrete payoff anywhere in the script β no number, no claim, no demonstration", | |
| "evidence": t.window(0, 25, max_chars=140), | |
| "fix": "Every video needs one specific, checkable thing. Put it in the first 15 seconds " | |
| "and repeat it at the midpoint.", | |
| "severity": "high", | |
| }) | |
| elif ttv > budget: | |
| out.append({ | |
| "t": ttv, "timestamp": fmt_ts(ttv), | |
| "issue": f"Time-to-value is {int(ttv)}s. Viewers decide in the first {budget}s", | |
| "evidence": t.at(ttv, before=2, after=8)[:180], | |
| "fix": f"Cut everything before {fmt_ts(ttv)} and start the video there.", | |
| "severity": "high", | |
| }) | |
| else: | |
| out.append({ | |
| "t": ttv, "timestamp": fmt_ts(ttv), | |
| "issue": f"Payoff lands fast ({int(ttv)}s) β this is working", | |
| "evidence": t.at(ttv, before=1, after=7)[:180], | |
| "fix": "Keep it. Front-load the payoff like this every time.", | |
| "severity": "good", | |
| }) | |
| # ββ 3. CTA POSITION βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| cta_at = next((s.start for s in t.segments if any(c in s.text.lower() for c in CTA)), None) | |
| if cta_at is None: | |
| out.append({ | |
| "t": max(0, duration_sec - 30), "timestamp": fmt_ts(max(0, duration_sec - 30)), | |
| "issue": "No call to action anywhere in the video", | |
| "evidence": t.window(max(0, duration_sec - 40), duration_sec, max_chars=140), | |
| "fix": "One line, after the payoff, tied to the content: " | |
| "'if this changed how you think about X, subscribe β the next one goes deeper.'", | |
| "severity": "medium", | |
| }) | |
| elif cta_at < duration_sec * 0.25 and not is_short: | |
| out.append({ | |
| "t": cta_at, "timestamp": fmt_ts(cta_at), | |
| "issue": "You ask for the subscribe before you've earned it", | |
| "evidence": t.at(cta_at, before=2, after=6)[:180], | |
| "fix": "Move the ask to after the first real payoff. Asking early trains viewers to leave.", | |
| "severity": "high", | |
| }) | |
| # ββ 4. FILLER DENSITY, per 30s block βββββββββββββββββββββββββββββββββ | |
| worst = None | |
| step = 30 | |
| tt = 0.0 | |
| while tt < duration_sec: | |
| block = t.window(tt, tt + step, max_chars=1200) | |
| w = _words(block) | |
| if w >= 30: | |
| hits = _filler_hits(block) | |
| density = len(hits) / max(1, w / 100) # filler per 100 words | |
| if worst is None or density > worst[1]: | |
| worst = (tt, density, hits, block) | |
| tt += step | |
| if worst and worst[1] >= 3: | |
| tt0, density, hits, block = worst | |
| out.append({ | |
| "t": tt0, "timestamp": fmt_ts(tt0), | |
| "issue": f"Densest filler in the video ({density:.1f} per 100 words: {', '.join(hits[:3])})", | |
| "evidence": block[:180], | |
| "fix": "Cut the pauses in the edit. This block reads as thinking-out-loud β viewers hear " | |
| "'nothing is happening' and leave.", | |
| "severity": "medium", | |
| }) | |
| # ββ 5. PACE βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| wpm = (t.word_count / (duration_sec / 60)) if duration_sec else 0 | |
| if wpm and wpm < 110 and not is_short: | |
| out.append({ | |
| "t": 0.0, "timestamp": "0:00", | |
| "issue": f"Delivery is slow β {int(wpm)} words/min (150β180 is the retention sweet spot)", | |
| "evidence": f"{t.word_count} words across {fmt_ts(duration_sec)}", | |
| "fix": "Tighten in the edit: cut every pause over 400ms. Same script, ~20% shorter runtime.", | |
| "severity": "medium", | |
| }) | |
| # ββ 6. LONGEST MONOLOGUE (no question, no direct address) ββββββββββββ | |
| gap_start, longest = 0.0, (0.0, 0.0) | |
| for s in t.segments: | |
| low = s.text.lower() | |
| if "?" in s.text or any(e in low for e in ENGAGE): | |
| span = s.start - gap_start | |
| if span > longest[1]: | |
| longest = (gap_start, span) | |
| gap_start = s.end | |
| tail = duration_sec - gap_start | |
| if tail > longest[1]: | |
| longest = (gap_start, tail) | |
| if longest[1] >= 90 and not is_short: | |
| out.append({ | |
| "t": longest[0], "timestamp": fmt_ts(longest[0]), | |
| "issue": f"{int(longest[1])}s with no question, no 'you', no pattern interrupt", | |
| "evidence": t.at(longest[0] + longest[1] / 2, before=5, after=5)[:180], | |
| "fix": "Break it. Every ~40s: a question, a 'here's why that matters to you', a cut, or a " | |
| "visual change. Flat stretches are where the graph falls.", | |
| "severity": "high", | |
| }) | |
| out.sort(key=lambda x: x["t"]) | |
| return out[:6] | |