Spaces:
Sleeping
Sleeping
File size: 9,086 Bytes
ee62fc1 | 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 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 | """
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]
|