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.
@@ -0,0 +1,3 @@
1
+ """videoxplainer - token-budgeted, evidence-checked action briefs from videos."""
2
+
3
+ __version__ = "0.3.3"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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