videoxplainer 0.3.3__py3-none-any.whl
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.
- videoxplainer/__init__.py +3 -0
- videoxplainer/__main__.py +3 -0
- videoxplainer/budget.py +198 -0
- videoxplainer/captions.py +132 -0
- videoxplainer/chapters.py +126 -0
- videoxplainer/cli.py +303 -0
- videoxplainer/data/SKILL.md +19 -0
- videoxplainer/fetch.py +91 -0
- videoxplainer/ids.py +31 -0
- videoxplainer/localfile.py +90 -0
- videoxplainer/model.py +62 -0
- videoxplainer/normalize.py +86 -0
- videoxplainer/pipeline.py +21 -0
- videoxplainer/render.py +83 -0
- videoxplainer/selfcheck.py +82 -0
- videoxplainer/store.py +114 -0
- videoxplainer/textutil.py +23 -0
- videoxplainer/verify.py +76 -0
- videoxplainer-0.3.3.dist-info/METADATA +172 -0
- videoxplainer-0.3.3.dist-info/RECORD +23 -0
- videoxplainer-0.3.3.dist-info/WHEEL +4 -0
- videoxplainer-0.3.3.dist-info/entry_points.txt +3 -0
- videoxplainer-0.3.3.dist-info/licenses/LICENSE +21 -0
videoxplainer/budget.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Token budgeting: fit a transcript into N tokens, spread across sections.
|
|
2
|
+
|
|
3
|
+
Extractive and lossy by design: when the transcript exceeds the budget we keep
|
|
4
|
+
the highest-information passages from every section of the video (TF-IDF
|
|
5
|
+
within the transcript), in original order, with their timestamps. Sections are
|
|
6
|
+
YouTube chapters when the video has them, otherwise auto-detected topics.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from typing import Dict, List, Set, Tuple
|
|
15
|
+
|
|
16
|
+
from .model import Group, Paragraph
|
|
17
|
+
from .textutil import words
|
|
18
|
+
|
|
19
|
+
STOPWORDS = frozenset(
|
|
20
|
+
"the and but for with from are was were been this that these those you your "
|
|
21
|
+
"our their they them his her its not does did have has had will would can "
|
|
22
|
+
"could just like really very about into than then there here what which who "
|
|
23
|
+
"when how also because going know yeah okay right".split()
|
|
24
|
+
)
|
|
25
|
+
_TS_OVERHEAD = 4 # tokens for the "[mm:ss] " prefix
|
|
26
|
+
_MIN_WORDS = 8 # shorter fragments ("the", "that went viral") are noise
|
|
27
|
+
_MIN_INFO = 4 # ...and so are passages with fewer informative words than this
|
|
28
|
+
HEADING_COST = 12 # tokens reserved per section heading
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def estimate_tokens(text: str) -> int:
|
|
32
|
+
"""Cheap, deliberately conservative estimate; fine for budgeting, not billing.
|
|
33
|
+
|
|
34
|
+
Latin text is about 4 characters per token. Other scripts (Tamil, Hindi,
|
|
35
|
+
Cyrillic, CJK...) need many more tokens per character, so non-ASCII
|
|
36
|
+
characters are counted at 1.5 per token to stay under a budget.
|
|
37
|
+
"""
|
|
38
|
+
if not text:
|
|
39
|
+
return 0
|
|
40
|
+
ascii_chars = sum(1 for ch in text if ord(ch) < 128)
|
|
41
|
+
return math.ceil(ascii_chars / 4 + (len(text) - ascii_chars) / 1.5)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cost(text: str) -> int:
|
|
45
|
+
return estimate_tokens(text) + _TS_OVERHEAD
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def informative_words(text: str) -> List[str]:
|
|
49
|
+
return [w for w in words(text) if len(w) > 2 and w not in STOPWORDS]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _eligible(text: str) -> bool:
|
|
53
|
+
return len(text.split()) >= _MIN_WORDS and len(informative_words(text)) >= _MIN_INFO
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _units(paragraphs: List[Paragraph], max_words: int = 60) -> List[Paragraph]:
|
|
57
|
+
units: List[Paragraph] = []
|
|
58
|
+
for para in paragraphs:
|
|
59
|
+
buf: List[str] = []
|
|
60
|
+
for sentence in re.split(r"(?<=[.!?])\s+", para.text):
|
|
61
|
+
words = sentence.split()
|
|
62
|
+
if not words:
|
|
63
|
+
continue
|
|
64
|
+
if len(words) > max_words: # unpunctuated auto-captions
|
|
65
|
+
if buf:
|
|
66
|
+
units.append(Paragraph(para.start, " ".join(buf)))
|
|
67
|
+
buf = []
|
|
68
|
+
for i in range(0, len(words), max_words):
|
|
69
|
+
units.append(Paragraph(para.start, " ".join(words[i : i + max_words])))
|
|
70
|
+
continue
|
|
71
|
+
if buf and len(buf) + len(words) > max_words:
|
|
72
|
+
units.append(Paragraph(para.start, " ".join(buf)))
|
|
73
|
+
buf = []
|
|
74
|
+
buf.extend(words)
|
|
75
|
+
if buf:
|
|
76
|
+
units.append(Paragraph(para.start, " ".join(buf)))
|
|
77
|
+
return units
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _scores(units: List[Paragraph]) -> List[float]:
|
|
81
|
+
tokens = [informative_words(u.text) for u in units]
|
|
82
|
+
df: Counter = Counter()
|
|
83
|
+
for toks in tokens:
|
|
84
|
+
df.update(set(toks))
|
|
85
|
+
n = len(units)
|
|
86
|
+
out = []
|
|
87
|
+
for toks in tokens:
|
|
88
|
+
tf = Counter(toks)
|
|
89
|
+
raw = sum(c * math.log(1 + n / df[w]) for w, c in tf.items())
|
|
90
|
+
out.append(raw / (max(len(toks), 1) ** 0.7))
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _select(indices: List[int], alloc: float, scores, costs, eligible: Set[int], max_buckets: int) -> Tuple[Set[int], int]:
|
|
95
|
+
"""Pick the best units within `alloc` tokens, spread over the index range."""
|
|
96
|
+
# never pad a section with junk: a section with nothing worth quoting is simply dropped
|
|
97
|
+
cand = [i for i in indices if i in eligible]
|
|
98
|
+
if not cand or alloc <= 0:
|
|
99
|
+
return set(), 0
|
|
100
|
+
avg = sum(costs[i] for i in cand) / len(cand)
|
|
101
|
+
# With a small allocation we cannot afford many slices: use as many as fit.
|
|
102
|
+
buckets = max(1, min(max_buckets, int(alloc // max(avg, 1))))
|
|
103
|
+
per = math.ceil(len(cand) / buckets)
|
|
104
|
+
share = alloc / buckets
|
|
105
|
+
chosen: Set[int] = set()
|
|
106
|
+
spent = 0
|
|
107
|
+
for k in range(buckets):
|
|
108
|
+
part = cand[k * per : (k + 1) * per]
|
|
109
|
+
used = 0
|
|
110
|
+
for rank, i in enumerate(sorted(part, key=lambda j: scores[j], reverse=True)):
|
|
111
|
+
# every slice gets its single best unit, so coverage never collapses
|
|
112
|
+
if (rank == 0 or used + costs[i] <= share) and spent + costs[i] <= alloc:
|
|
113
|
+
chosen.add(i)
|
|
114
|
+
used += costs[i]
|
|
115
|
+
spent += costs[i]
|
|
116
|
+
for i in sorted((j for j in cand if j not in chosen), key=lambda j: scores[j], reverse=True):
|
|
117
|
+
if spent + costs[i] <= alloc:
|
|
118
|
+
chosen.add(i)
|
|
119
|
+
spent += costs[i]
|
|
120
|
+
return chosen, spent
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _merge(units: List[Paragraph], chosen_sorted: List[int]) -> List[Paragraph]:
|
|
124
|
+
merged: List[Paragraph] = []
|
|
125
|
+
prev = None
|
|
126
|
+
for i in chosen_sorted:
|
|
127
|
+
if prev is not None and i == prev + 1:
|
|
128
|
+
merged[-1] = Paragraph(merged[-1].start, merged[-1].text + " " + units[i].text)
|
|
129
|
+
else:
|
|
130
|
+
merged.append(Paragraph(units[i].start, units[i].text))
|
|
131
|
+
prev = i
|
|
132
|
+
return merged
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def fit_groups(groups: List[Group], budget: int, heading_cost: int = 0, max_buckets: int = 6) -> Tuple[List[Group], Dict[str, object]]:
|
|
136
|
+
"""Condense sections to `budget` tokens; each section keeps a share of it."""
|
|
137
|
+
units: List[Paragraph] = []
|
|
138
|
+
owner: List[int] = []
|
|
139
|
+
for gi, g in enumerate(groups):
|
|
140
|
+
for u in _units(g.paragraphs):
|
|
141
|
+
units.append(u)
|
|
142
|
+
owner.append(gi)
|
|
143
|
+
costs = [_cost(u.text) for u in units]
|
|
144
|
+
full = sum(costs) + heading_cost * len(groups)
|
|
145
|
+
stats: Dict[str, object] = {"full_tokens": full, "budget": budget, "trimmed": False, "sections": len(groups)}
|
|
146
|
+
if budget <= 0 or full <= budget:
|
|
147
|
+
stats["kept_tokens"] = full
|
|
148
|
+
return groups, stats
|
|
149
|
+
|
|
150
|
+
scores = _scores(units)
|
|
151
|
+
eligible = {i for i, u in enumerate(units) if _eligible(u.text)} or set(range(len(units)))
|
|
152
|
+
members = [[i for i, o in enumerate(owner) if o == gi] for gi in range(len(groups))]
|
|
153
|
+
gcost = [sum(costs[i] for i in m) for m in members]
|
|
154
|
+
avail = budget - heading_cost * len(groups)
|
|
155
|
+
|
|
156
|
+
# Floor: roughly one average passage per section, biggest sections first while budget lasts.
|
|
157
|
+
floors = [0] * len(groups)
|
|
158
|
+
remaining = avail
|
|
159
|
+
for gi in sorted(range(len(groups)), key=lambda g: gcost[g], reverse=True):
|
|
160
|
+
if not members[gi]:
|
|
161
|
+
continue
|
|
162
|
+
floor = min(gcost[gi], math.ceil(gcost[gi] / len(members[gi])))
|
|
163
|
+
if floor <= remaining:
|
|
164
|
+
floors[gi] = floor
|
|
165
|
+
remaining -= floor
|
|
166
|
+
live = [gi for gi in range(len(groups)) if floors[gi]]
|
|
167
|
+
live_cost = sum(gcost[g] for g in live) or 1
|
|
168
|
+
chosen: Set[int] = set()
|
|
169
|
+
spent = 0
|
|
170
|
+
for gi in live:
|
|
171
|
+
alloc = floors[gi] + remaining * gcost[gi] / live_cost # rest is proportional to length
|
|
172
|
+
c, s = _select(members[gi], alloc, scores, costs, eligible, max_buckets)
|
|
173
|
+
chosen |= c
|
|
174
|
+
spent += s
|
|
175
|
+
live_set = set(live)
|
|
176
|
+
for i in sorted((j for j in eligible if j not in chosen and owner[j] in live_set), key=lambda j: scores[j], reverse=True):
|
|
177
|
+
if spent + costs[i] <= avail:
|
|
178
|
+
chosen.add(i)
|
|
179
|
+
spent += costs[i]
|
|
180
|
+
|
|
181
|
+
out: List[Group] = []
|
|
182
|
+
for gi in live:
|
|
183
|
+
picked = sorted(i for i in chosen if owner[i] == gi)
|
|
184
|
+
if picked:
|
|
185
|
+
out.append(Group(groups[gi].title, groups[gi].start, _merge(units, picked)))
|
|
186
|
+
stats.update(
|
|
187
|
+
trimmed=True,
|
|
188
|
+
kept_tokens=spent + heading_cost * len(out),
|
|
189
|
+
kept_ratio=round(spent / max(full, 1), 2),
|
|
190
|
+
sections_kept=len(out),
|
|
191
|
+
)
|
|
192
|
+
return out, stats
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def fit(paragraphs: List[Paragraph], budget: int, buckets: int = 8) -> Tuple[List[Paragraph], Dict[str, object]]:
|
|
196
|
+
"""Flat convenience wrapper: one section spread over the whole timeline."""
|
|
197
|
+
out, stats = fit_groups([Group("", 0.0, paragraphs)], budget, max_buckets=buckets)
|
|
198
|
+
return [p for g in out for p in g.paragraphs], stats
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Caption parsing and track selection (pure functions, no network)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from .model import Segment
|
|
11
|
+
|
|
12
|
+
_TAG = re.compile(r"<[^>]*>")
|
|
13
|
+
_TS = re.compile(r"(?:(\d+):)?(\d{1,2}):(\d{2})[.,](\d{3})")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _to_seconds(stamp: str) -> float:
|
|
17
|
+
m = _TS.search(stamp)
|
|
18
|
+
if not m:
|
|
19
|
+
return 0.0
|
|
20
|
+
hours, minutes, secs, millis = (int(m.group(1) or 0), int(m.group(2)), int(m.group(3)), int(m.group(4)))
|
|
21
|
+
return hours * 3600 + minutes * 60 + secs + millis / 1000.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_json3(text: str) -> List[Segment]:
|
|
25
|
+
data = json.loads(text)
|
|
26
|
+
out: List[Segment] = []
|
|
27
|
+
for event in data.get("events", []):
|
|
28
|
+
segs = event.get("segs")
|
|
29
|
+
if not segs:
|
|
30
|
+
continue
|
|
31
|
+
line = "".join(s.get("utf8", "") for s in segs).replace("\n", " ").strip()
|
|
32
|
+
if line:
|
|
33
|
+
out.append(Segment(event.get("tStartMs", 0) / 1000.0, html.unescape(line)))
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_vtt(text: str) -> List[Segment]:
|
|
38
|
+
out: List[Segment] = []
|
|
39
|
+
for block in re.split(r"\n\s*\n", text.replace("\r\n", "\n")):
|
|
40
|
+
lines = [ln for ln in block.split("\n") if ln.strip()]
|
|
41
|
+
idx = next((i for i, ln in enumerate(lines) if "-->" in ln), None)
|
|
42
|
+
if idx is None:
|
|
43
|
+
continue
|
|
44
|
+
start = _to_seconds(lines[idx].split("-->")[0])
|
|
45
|
+
body = " ".join(_TAG.sub("", ln).strip() for ln in lines[idx + 1 :])
|
|
46
|
+
body = re.sub(r"\s+", " ", html.unescape(body)).strip()
|
|
47
|
+
if body:
|
|
48
|
+
out.append(Segment(start, body))
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse(text: str, ext: str = "") -> List[Segment]:
|
|
53
|
+
if ext == "json3" or text.lstrip().startswith("{"):
|
|
54
|
+
try:
|
|
55
|
+
return parse_json3(text)
|
|
56
|
+
except (ValueError, AttributeError):
|
|
57
|
+
pass
|
|
58
|
+
return parse_vtt(text)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
Track = Tuple[str, bool, Dict[str, Any]] # (language code, is_auto, yt-dlp format dict)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _pick_format(formats: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
65
|
+
by_ext = {f.get("ext"): f for f in formats if f.get("url")}
|
|
66
|
+
for ext in ("json3", "vtt"):
|
|
67
|
+
if ext in by_ext:
|
|
68
|
+
return by_ext[ext]
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _base(code: str) -> str:
|
|
73
|
+
return code.lower().split("-")[0]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _find(tracks: Dict[str, Any], predicates) -> Optional[str]:
|
|
77
|
+
for pred in predicates:
|
|
78
|
+
for key in tracks:
|
|
79
|
+
if pred(key.lower()):
|
|
80
|
+
return key
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def choose_track(info: Dict[str, Any], lang: Optional[str] = None) -> Optional[Track]:
|
|
85
|
+
"""Pick the best caption track from a yt-dlp info dict.
|
|
86
|
+
|
|
87
|
+
Preference: author-uploaded captions, then the video's *original* auto
|
|
88
|
+
captions. Machine translations are never chosen.
|
|
89
|
+
"""
|
|
90
|
+
manual = info.get("subtitles") or {}
|
|
91
|
+
auto = info.get("automatic_captions") or {}
|
|
92
|
+
want = (lang or "").lower()
|
|
93
|
+
vid_lang = (info.get("language") or "").lower()
|
|
94
|
+
|
|
95
|
+
if want: # an explicit request is never answered in another language
|
|
96
|
+
prefs = [lambda k: k == want, lambda k: _base(k) == _base(want)]
|
|
97
|
+
else:
|
|
98
|
+
prefs = []
|
|
99
|
+
if vid_lang:
|
|
100
|
+
prefs += [lambda k: k == vid_lang, lambda k: _base(k) == _base(vid_lang)]
|
|
101
|
+
prefs.append(lambda k: _base(k) == "en")
|
|
102
|
+
|
|
103
|
+
key = _find(manual, prefs)
|
|
104
|
+
if key is None and not want:
|
|
105
|
+
key = next(iter(manual), None) # no preference: take whatever the author uploaded
|
|
106
|
+
if key:
|
|
107
|
+
fmt = _pick_format(manual[key])
|
|
108
|
+
if fmt:
|
|
109
|
+
return key, False, fmt
|
|
110
|
+
|
|
111
|
+
if want: # an explicit request is never silently answered in another language
|
|
112
|
+
# only the speaker's original track: YouTube also lists machine *translations* under plain codes
|
|
113
|
+
auto_prefs = [lambda k: k.endswith("-orig") and _base(k) == _base(want),
|
|
114
|
+
lambda k: k == want and _base(vid_lang) == _base(want)]
|
|
115
|
+
else:
|
|
116
|
+
auto_prefs = [lambda k: k.endswith("-orig")]
|
|
117
|
+
if vid_lang:
|
|
118
|
+
auto_prefs.append(lambda k: k == vid_lang)
|
|
119
|
+
auto_prefs.append(lambda k: k == "en")
|
|
120
|
+
key = _find(auto, auto_prefs)
|
|
121
|
+
if key:
|
|
122
|
+
fmt = _pick_format(auto[key])
|
|
123
|
+
if fmt:
|
|
124
|
+
return key.replace("-orig", ""), True, fmt
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def available_languages(info: Dict[str, Any]) -> List[str]:
|
|
129
|
+
"""Languages we could read: uploaded tracks plus original auto-captions."""
|
|
130
|
+
manual = [k for k, v in (info.get("subtitles") or {}).items() if _pick_format(v)]
|
|
131
|
+
auto = [k.replace("-orig", "") + " (auto)" for k, v in (info.get("automatic_captions") or {}).items() if k.endswith("-orig") and _pick_format(v)]
|
|
132
|
+
return sorted(manual) + sorted(auto)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Sections of a video: YouTube chapters, or topics detected from the text.
|
|
2
|
+
|
|
3
|
+
Auto-detection is lexical cohesion (TextTiling-style): compare the vocabulary
|
|
4
|
+
of the passages before and after each gap and cut where they differ most.
|
|
5
|
+
No LLM involved, so it is free and offline.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from .budget import STOPWORDS
|
|
16
|
+
from .model import Chapter, Group, Paragraph, Segment
|
|
17
|
+
from .normalize import merge_paragraphs
|
|
18
|
+
from .textutil import words
|
|
19
|
+
|
|
20
|
+
Section = Tuple[float, str] # (start seconds, title)
|
|
21
|
+
|
|
22
|
+
# Conversational filler that makes poor headings. Used for labels only, never for detection.
|
|
23
|
+
_LABEL_STOP = frozenset(
|
|
24
|
+
"said says say think thing things people want wants going get gets got make makes made way one two "
|
|
25
|
+
"three four five many much come comes came look looks see saw little lot kind sort actually basically "
|
|
26
|
+
"something anything everything every still around back first even though maybe quite bit time "
|
|
27
|
+
"good great really thing's let's".split()
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def from_info(info: Dict[str, Any]) -> List[Chapter]:
|
|
32
|
+
out: List[Chapter] = []
|
|
33
|
+
for c in info.get("chapters") or []:
|
|
34
|
+
try:
|
|
35
|
+
out.append(Chapter(float(c["start_time"]), float(c.get("end_time") or 0), str(c.get("title") or "").strip()))
|
|
36
|
+
except (KeyError, TypeError, ValueError):
|
|
37
|
+
continue
|
|
38
|
+
return sorted(out, key=lambda c: c.start)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _bag(text: str) -> Counter:
|
|
42
|
+
return Counter(w for w in words(text) if len(w) > 3 and w not in STOPWORDS)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _cosine(a: Counter, b: Counter) -> float:
|
|
46
|
+
if not a or not b:
|
|
47
|
+
return 0.0
|
|
48
|
+
dot = sum(c * b.get(w, 0) for w, c in a.items())
|
|
49
|
+
na = math.sqrt(sum(c * c for c in a.values()))
|
|
50
|
+
nb = math.sqrt(sum(c * c for c in b.values()))
|
|
51
|
+
return dot / (na * nb) if na and nb else 0.0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def detect_sections(
|
|
55
|
+
paragraphs: List[Paragraph], target: Optional[int] = None, min_seconds: float = 480, min_gap_seconds: float = 90,
|
|
56
|
+
min_z: float = 1.0,
|
|
57
|
+
min_depth: float = 0.3,
|
|
58
|
+
) -> List[Section]:
|
|
59
|
+
"""Split into topical sections. Returns [] for short videos (not worth it)."""
|
|
60
|
+
n = len(paragraphs)
|
|
61
|
+
if n < 8 or paragraphs[-1].start - paragraphs[0].start < min_seconds:
|
|
62
|
+
return []
|
|
63
|
+
duration = paragraphs[-1].start - paragraphs[0].start
|
|
64
|
+
target = target or max(3, min(12, round(duration / 360)))
|
|
65
|
+
k = max(2, min(4, n // 10))
|
|
66
|
+
bags = [_bag(p.text) for p in paragraphs]
|
|
67
|
+
sims = {}
|
|
68
|
+
for i in range(k, n - k + 1):
|
|
69
|
+
sims[i] = _cosine(sum(bags[i - k : i], Counter()), sum(bags[i : i + k], Counter()))
|
|
70
|
+
if len(sims) < 3:
|
|
71
|
+
return []
|
|
72
|
+
idx = sorted(sims)
|
|
73
|
+
w = 3
|
|
74
|
+
depth = {}
|
|
75
|
+
for pos, i in enumerate(idx):
|
|
76
|
+
left = max(sims[j] for j in idx[max(0, pos - w) : pos + 1])
|
|
77
|
+
right = max(sims[j] for j in idx[pos : pos + w + 1])
|
|
78
|
+
depth[i] = (left - sims[i]) + (right - sims[i])
|
|
79
|
+
sep = max(2 * k, n // (2 * target))
|
|
80
|
+
# Only cut at *significant* topic shifts: a valley must stand out from the typical
|
|
81
|
+
# dip (min_z standard deviations above average), otherwise the video stays in one piece.
|
|
82
|
+
values = list(depth.values())
|
|
83
|
+
mean = sum(values) / len(values)
|
|
84
|
+
std = math.sqrt(sum((v - mean) ** 2 for v in values) / len(values))
|
|
85
|
+
# ...and the vocabulary must really change: an absolute drop in cohesion as well, so a
|
|
86
|
+
# uniform talk is never carved up just because some dips are bigger than others.
|
|
87
|
+
floor = max(mean + min_z * std, min_depth)
|
|
88
|
+
cuts: List[int] = []
|
|
89
|
+
for i in sorted(depth, key=lambda g: depth[g], reverse=True):
|
|
90
|
+
if depth[i] < floor or len(cuts) >= target - 1:
|
|
91
|
+
break
|
|
92
|
+
if all(abs(i - c) >= sep and abs(paragraphs[i].start - paragraphs[c].start) >= min_gap_seconds for c in cuts):
|
|
93
|
+
cuts.append(i)
|
|
94
|
+
if not cuts:
|
|
95
|
+
return []
|
|
96
|
+
cuts.sort()
|
|
97
|
+
starts = [0] + cuts
|
|
98
|
+
ends = cuts + [n]
|
|
99
|
+
section_bags = [sum(bags[a:b], Counter()) for a, b in zip(starts, ends)]
|
|
100
|
+
df: Counter = Counter()
|
|
101
|
+
for sb in section_bags:
|
|
102
|
+
df.update(set(sb))
|
|
103
|
+
m = len(section_bags)
|
|
104
|
+
sections: List[Section] = []
|
|
105
|
+
for a, sb in zip(starts, section_bags):
|
|
106
|
+
usable = [(w, c) for w, c in sb.items() if "'" not in w and w not in _LABEL_STOP]
|
|
107
|
+
ranked = sorted(usable, key=lambda kv: kv[1] * math.log(1 + m / df[kv[0]]) ** 2, reverse=True)
|
|
108
|
+
title = ", ".join(word for word, _ in ranked[:3]) or "section"
|
|
109
|
+
sections.append((0.0 if a == 0 else paragraphs[a].start, title))
|
|
110
|
+
return sections
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_groups(segments: List[Segment], sections: List[Section]) -> List[Group]:
|
|
114
|
+
"""Cut cleaned segments into sections and paragraph-ise each one."""
|
|
115
|
+
if not sections:
|
|
116
|
+
return [Group("", 0.0, merge_paragraphs(segments))]
|
|
117
|
+
sections = sorted(sections)
|
|
118
|
+
if sections[0][0] > 0:
|
|
119
|
+
sections.insert(0, (0.0, "Intro"))
|
|
120
|
+
groups: List[Group] = []
|
|
121
|
+
for i, (start, title) in enumerate(sections):
|
|
122
|
+
end = sections[i + 1][0] if i + 1 < len(sections) else float("inf")
|
|
123
|
+
segs = [s for s in segments if start <= s.start < end]
|
|
124
|
+
if segs:
|
|
125
|
+
groups.append(Group(title, start, merge_paragraphs(segs)))
|
|
126
|
+
return groups
|