paces 0.0.13__tar.gz → 0.0.14__tar.gz

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.
Files changed (51) hide show
  1. {paces-0.0.13 → paces-0.0.14}/PKG-INFO +1 -1
  2. {paces-0.0.13 → paces-0.0.14}/paces/__init__.py +4 -0
  3. paces-0.0.14/paces/cues.py +207 -0
  4. {paces-0.0.13 → paces-0.0.14}/paces/segmenters.py +74 -9
  5. paces-0.0.14/paces/timed_text.py +140 -0
  6. {paces-0.0.13 → paces-0.0.14}/paces/tools.py +18 -1
  7. {paces-0.0.13 → paces-0.0.14}/pyproject.toml +1 -1
  8. paces-0.0.14/tests/test_cues.py +255 -0
  9. {paces-0.0.13 → paces-0.0.14}/tests/test_pose.py +1 -3
  10. {paces-0.0.13 → paces-0.0.14}/.gitignore +0 -0
  11. {paces-0.0.13 → paces-0.0.14}/LICENSE +0 -0
  12. {paces-0.0.13 → paces-0.0.14}/README.md +0 -0
  13. {paces-0.0.13 → paces-0.0.14}/docs/README.md +0 -0
  14. {paces-0.0.13 → paces-0.0.14}/docs/alignment/README.md +0 -0
  15. {paces-0.0.13 → paces-0.0.14}/docs/poc-reference/README.md +0 -0
  16. {paces-0.0.13 → paces-0.0.14}/docs/poc-reference/artifacts/clips.json +0 -0
  17. {paces-0.0.13 → paces-0.0.14}/paces/__main__.py +0 -0
  18. {paces-0.0.13 → paces-0.0.14}/paces/bodies/__init__.py +0 -0
  19. {paces-0.0.13 → paces-0.0.14}/paces/bodies/_base.py +0 -0
  20. {paces-0.0.13 → paces-0.0.14}/paces/bodies/derivation.py +0 -0
  21. {paces-0.0.13 → paces-0.0.14}/paces/bodies/document.py +0 -0
  22. {paces-0.0.13 → paces-0.0.14}/paces/bodies/signal.py +0 -0
  23. {paces-0.0.13 → paces-0.0.14}/paces/bodies/step.py +0 -0
  24. {paces-0.0.13 → paces-0.0.14}/paces/derivation.py +0 -0
  25. {paces-0.0.13 → paces-0.0.14}/paces/edits.py +0 -0
  26. {paces-0.0.13 → paces-0.0.14}/paces/evidence.py +0 -0
  27. {paces-0.0.13 → paces-0.0.14}/paces/excerpts.py +0 -0
  28. {paces-0.0.13 → paces-0.0.14}/paces/measure.py +0 -0
  29. {paces-0.0.13 → paces-0.0.14}/paces/model.py +0 -0
  30. {paces-0.0.13 → paces-0.0.14}/paces/pose.py +0 -0
  31. {paces-0.0.13 → paces-0.0.14}/paces/projection.py +0 -0
  32. {paces-0.0.13 → paces-0.0.14}/paces/render.py +0 -0
  33. {paces-0.0.13 → paces-0.0.14}/tests/audio_synth.py +0 -0
  34. {paces-0.0.13 → paces-0.0.14}/tests/data/body_schemas.json +0 -0
  35. {paces-0.0.13 → paces-0.0.14}/tests/data/routine.json +0 -0
  36. {paces-0.0.13 → paces-0.0.14}/tests/poc_fixture.py +0 -0
  37. {paces-0.0.13 → paces-0.0.14}/tests/test_chapters.py +0 -0
  38. {paces-0.0.13 → paces-0.0.14}/tests/test_cli_surface.py +0 -0
  39. {paces-0.0.13 → paces-0.0.14}/tests/test_derivation.py +0 -0
  40. {paces-0.0.13 → paces-0.0.14}/tests/test_edit_protection_residuals.py +0 -0
  41. {paces-0.0.13 → paces-0.0.14}/tests/test_edits.py +0 -0
  42. {paces-0.0.13 → paces-0.0.14}/tests/test_evidence.py +0 -0
  43. {paces-0.0.13 → paces-0.0.14}/tests/test_evidence_schemas.py +0 -0
  44. {paces-0.0.13 → paces-0.0.14}/tests/test_excerpts.py +0 -0
  45. {paces-0.0.13 → paces-0.0.14}/tests/test_measure.py +0 -0
  46. {paces-0.0.13 → paces-0.0.14}/tests/test_render_media.py +0 -0
  47. {paces-0.0.13 → paces-0.0.14}/tests/test_roundtrip_poc.py +0 -0
  48. {paces-0.0.13 → paces-0.0.14}/tests/test_segment.py +0 -0
  49. {paces-0.0.13 → paces-0.0.14}/tests/test_smoke.py +0 -0
  50. {paces-0.0.13 → paces-0.0.14}/tests/test_vertical_slice.py +0 -0
  51. {paces-0.0.13 → paces-0.0.14}/tests/video_synth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: paces
3
- Version: 0.0.13
3
+ Version: 0.0.14
4
4
  Summary: Turn instructional media into structured, interactive learning material
5
5
  Project-URL: Homepage, https://github.com/thorwhalen/paces
6
6
  Project-URL: Repository, https://github.com/thorwhalen/paces
@@ -72,6 +72,10 @@ from paces.segmenters import (
72
72
  segment,
73
73
  )
74
74
 
75
+ # Segmenters in their own modules register on import (ADR-0003); importing
76
+ # them here is what puts them in the catalog for every ``paces.*`` import.
77
+ from paces import cues # noqa: F401
78
+
75
79
  __all__ = [
76
80
  # the document (the contract)
77
81
  "StepDocument",
@@ -0,0 +1,207 @@
1
+ """Lexical cue segmenters: subtitles and transcripts → proposed named spans.
2
+
3
+ Instructional narration announces its own structure — "now", "next", "step
4
+ two", "and finally". Over *timed text* those words are a near-free boundary
5
+ detector with a name attached (``docs/alignment/07 §2.13(a)``, ``§6.3``): no
6
+ ASR bill when the text already exists (uploader subtitles, auto-captions, a
7
+ transcript from an earlier run).
8
+
9
+ Two capabilities share one detector, because the *source* differs in how far
10
+ it can be trusted, not in what is done with it:
11
+
12
+ - ``subtitle-cues`` — ``metadata={"subtitles": ...}``: human-written or
13
+ auto-caption cues.
14
+ - ``transcript-cues`` — ``transcript=...``: any transcript (ASR output).
15
+
16
+ Both are *proposers*, not authorities: the result is graded
17
+ ``proposed-uncertain`` (``DFLT_CUES_CONFIDENCE``) and flagged so. When no cue
18
+ word is found the result is the refused state, never an invented step list.
19
+ The LLM rung over a transcript is ``mixing.chapters.detect_chapters``; it is
20
+ a different capability and is not wrapped here.
21
+
22
+ Accepted timed text: whatever :func:`paces.timed_text.normalize_timed_text`
23
+ takes — SRT/WebVTT text or file, cue mappings, ``(start, end, text)`` triples,
24
+ a Whisper ``{"segments": ...}`` result. Parsing subtitle *text* needs the
25
+ optional ``mixing`` package; cue rows do not.
26
+
27
+ Word-level transcripts (one cue per word) mostly yield *unnamed* steps: the
28
+ marker is found but the name that follows it lives in later cues. Pass
29
+ phrase- or sentence-level cues when you want names.
30
+
31
+ Registration: these capabilities register when this module is imported, and
32
+ ``paces/__init__.py`` imports it — so any ``paces.*`` import (including
33
+ ``from paces.segmenters import segment``) sees them. ``paces.segmenters``
34
+ does not import this module itself, which would be circular.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import re
40
+ from collections.abc import Mapping, Sequence
41
+ from typing import Any
42
+
43
+ from paces.segmenters import (
44
+ Capability,
45
+ SegStep,
46
+ Segmentation,
47
+ register,
48
+ unique_slug_ids,
49
+ )
50
+ from paces.timed_text import normalize_timed_text
51
+
52
+ __all__ = ["DFLT_CUE_PATTERNS", "detect_cues", "normalize_timed_text"]
53
+
54
+ DFLT_CUES_CONFIDENCE = 0.5 # lexical cues are a hint, not a measurement
55
+ DFLT_MIN_GAP_S = 8.0 # cues closer than this to the last boundary are one step
56
+ DFLT_MAX_NAME_CHARS = 60
57
+
58
+ #: Discourse markers that open a step, matched at the start of a cue (case-
59
+ #: insensitive). Each may carry a leading "and"/"so"/"okay". Capture group 1
60
+ #: is the marker, stripped from the step's name. Deliberately narrow: bare
61
+ #: "then", "and then", "let's" and leading numerals ("2.") were dropped as
62
+ #: too false-positive-prone in running speech.
63
+ DFLT_CUE_PATTERNS: tuple[str, ...] = (
64
+ r"(?:(?:and|so|ok(?:ay)?|alright)[,\s]+)?(now|next|first\s+of\s+all|first(?:ly)?|second(?:ly)?"
65
+ r"|third(?:ly)?|finally|lastly)\b[,\s]*",
66
+ r"((?:step|number|part)\s+(?:\d+|one|two|three|four|five|six|seven|eight"
67
+ r"|nine|ten))\b[\s:.,)-]*",
68
+ r"(to\s+start|to\s+begin|moving\s+on|after\s+that)\b[,\s]*",
69
+ )
70
+
71
+ #: Speaker / sound-tag prefixes stripped before matching: "- ", ">> ", "[music] ".
72
+ _PREFIX_RE = re.compile(r"^(?:\s*(?:-+|>>+|\[[^\]]*\])\s*)+")
73
+
74
+
75
+ # ── the detector ────────────────────────────────────────────────────────────
76
+
77
+
78
+ def detect_cues(
79
+ cues: Sequence[Mapping],
80
+ *,
81
+ patterns: Sequence[str] = DFLT_CUE_PATTERNS,
82
+ min_gap_s: float = DFLT_MIN_GAP_S,
83
+ max_name_chars: int = DFLT_MAX_NAME_CHARS,
84
+ ) -> list[dict]:
85
+ """Cue rows that open a step: ``{start, name, cue}`` (the matched marker).
86
+
87
+ A cue opens a step when its text — after any speaker or sound-tag prefix
88
+ ("- ", ">> ", "[music] ") — starts with one of *patterns*; one that lands
89
+ within *min_gap_s* of the previous accepted step is folded into it (and
90
+ lends it its name if that step had none). The name is the cue's text
91
+ without its lead-in marker, trimmed to *max_name_chars* at a word boundary
92
+ (a marker alone leaves the name empty — found, not named).
93
+
94
+ >>> rows = [{"start": 0, "end": 4, "text": "Hi everyone"},
95
+ ... {"start": 10, "end": 14, "text": ">> Now we do the turn."}]
96
+ >>> detect_cues(rows)
97
+ [{'start': 10.0, 'name': 'we do the turn', 'cue': 'Now'}]
98
+ """
99
+ compiled = [re.compile(p, re.IGNORECASE) for p in patterns]
100
+
101
+ def _name(text: str) -> str:
102
+ name = text.strip(" ,.;:!?-")
103
+ if len(name) > max_name_chars:
104
+ name = name[:max_name_chars].rsplit(" ", 1)[0].rstrip(" ,.;:-")
105
+ return name
106
+
107
+ found: list[dict] = []
108
+ for cue in cues:
109
+ text = _PREFIX_RE.sub("", cue["text"])
110
+ match = next((m for m in (p.match(text) for p in compiled) if m), None)
111
+ if match is None:
112
+ continue
113
+ name = _name(text[match.end() :])
114
+ if found and cue["start"] - found[-1]["start"] < min_gap_s:
115
+ if not found[-1]["name"]:
116
+ found[-1]["name"] = name
117
+ continue
118
+ found.append(
119
+ {
120
+ "start": float(cue["start"]),
121
+ "name": name,
122
+ "cue": (match.group(1) if match.re.groups else match.group(0)).strip(),
123
+ }
124
+ )
125
+ return found
126
+
127
+
128
+ def _segment_cue_source(cues: list[dict], *, source: str) -> Segmentation:
129
+ openers = detect_cues(cues)
130
+ end = max((cue["end"] for cue in cues), default=0.0)
131
+ flags: tuple[str, ...] = ()
132
+ if openers and openers[-1]["start"] >= end:
133
+ openers = openers[:-1] # a marker on the very last instant opens nothing
134
+ flags += ("trailing-cue-dropped",)
135
+ if not openers:
136
+ return Segmentation(
137
+ flags=(
138
+ "no-signal",
139
+ "try: the other cue source via segmenter=, chapters in metadata, "
140
+ "boundaries=[...], or an LLM pass",
141
+ *flags,
142
+ ),
143
+ )
144
+ starts = [row["start"] for row in openers]
145
+ spans = zip(starts, starts[1:] + [end])
146
+ ids = unique_slug_ids([row["name"] for row in openers], fallback="cue")
147
+ steps = tuple(
148
+ SegStep(
149
+ id=step_id,
150
+ name=row["name"],
151
+ spans=(span,),
152
+ confidence=DFLT_CUES_CONFIDENCE,
153
+ source=source,
154
+ evidence={"cue": row["cue"]},
155
+ )
156
+ for step_id, row, span in zip(ids, openers, spans)
157
+ )
158
+ flags = ("proposed-uncertain", *flags)
159
+ if starts[0] > 0:
160
+ flags += (f"intro-before: {starts[0]:g}s",)
161
+ if not any(step.name for step in steps):
162
+ flags += ("naming-abstained",)
163
+ return Segmentation(
164
+ steps=steps,
165
+ boundaries=tuple(starts + [end]),
166
+ confidence=DFLT_CUES_CONFIDENCE,
167
+ flags=flags,
168
+ )
169
+
170
+
171
+ def _segment_subtitles(inputs: Mapping[str, Any]) -> Segmentation:
172
+ return _segment_cue_source(inputs["subtitles"], source="subtitle-cues")
173
+
174
+
175
+ def _segment_transcript(inputs: Mapping[str, Any]) -> Segmentation:
176
+ return _segment_cue_source(inputs["transcript"], source="transcript-cues")
177
+
178
+
179
+ SUBTITLE_CUES = register(
180
+ Capability(
181
+ name="subtitle-cues",
182
+ gives="segmentation",
183
+ summary=(
184
+ "Propose named spans from a subtitle track by lexical step cues "
185
+ "('now', 'next', 'step two'). Free; a hint, not an authority."
186
+ ),
187
+ target="paces.cues:_segment_subtitles",
188
+ needs=frozenset({"meta.subtitles"}),
189
+ base=0.4, # below chapters (0.75) and explicit boundaries (0.5)
190
+ resolution_s=DFLT_MIN_GAP_S,
191
+ )
192
+ )
193
+
194
+ TRANSCRIPT_CUES = register(
195
+ Capability(
196
+ name="transcript-cues",
197
+ gives="segmentation",
198
+ summary=(
199
+ "Propose named spans from any transcript by the same lexical "
200
+ "step cues. Free once the transcript exists."
201
+ ),
202
+ target="paces.cues:_segment_transcript",
203
+ needs=frozenset({"transcript"}),
204
+ base=0.35, # an ASR transcript is a step less trustworthy than subtitles
205
+ resolution_s=DFLT_MIN_GAP_S,
206
+ )
207
+ )
@@ -21,6 +21,13 @@ Plus ``chapters`` (external, explicit): author-supplied chapter metadata — a
21
21
  yt-dlp info dict or ffprobe's ``-show_chapters`` output — is a complete named
22
22
  segmentation for ~0 cost, and most tutorial content on the web carries it.
23
23
 
24
+ Plus ``subtitle-cues`` and ``transcript-cues`` (``paces/cues.py``): proposers
25
+ that read step markers ("now", "next", "step two") out of timed text — uploader
26
+ subtitles or any transcript — and return low-confidence, honestly flagged
27
+ spans. Like ``grid-measured`` (``paces/measure.py``) they register when
28
+ ``paces`` is imported — the package ``__init__`` imports their module, so
29
+ ``from paces.segmenters import segment`` sees them too.
30
+
24
31
  Honesty rules (``docs/alignment/07 §9.5``): a segmenter may never return a
25
32
  step count it has no evidence for. Four legitimate return states — confident /
26
33
  found-unnamed / proposed-uncertain / refused — all expressed by the type, none
@@ -38,6 +45,7 @@ from pathlib import Path
38
45
  from typing import Any, Callable
39
46
 
40
47
  from paces.model import MetricGrid, seconds_per_unit
48
+ from paces.timed_text import normalize_timed_text
41
49
 
42
50
  Span = tuple[float, float] # seconds, half-open
43
51
 
@@ -160,6 +168,21 @@ def _slugify(text: str) -> str:
160
168
  return slug or "step"
161
169
 
162
170
 
171
+ def unique_slug_ids(names: Sequence[str], *, fallback: str = "step") -> list[str]:
172
+ """One unique, git-readable id per name; unnamed ones get ``{fallback}-{i}``.
173
+
174
+ >>> unique_slug_ids(["Turn", "", "Turn"], fallback="cue")
175
+ ['turn', 'cue-2', 'turn-2']
176
+ """
177
+ seen: dict[str, int] = {}
178
+ ids = []
179
+ for index, name in enumerate(names):
180
+ slug = _slugify(name) if name else f"{fallback}-{index + 1}"
181
+ seen[slug] = seen.get(slug, 0) + 1
182
+ ids.append(slug if seen[slug] == 1 else f"{slug}-{seen[slug]}")
183
+ return ids
184
+
185
+
163
186
  def _normalize_steps(steps: Sequence | None) -> list[dict]:
164
187
  """Accept names, (name, duration) pairs, or mappings; emit uniform dicts.
165
188
 
@@ -257,6 +280,34 @@ def _normalize_chapters(metadata: Mapping | Sequence | None) -> list[dict]:
257
280
  return rows
258
281
 
259
282
 
283
+ def _subtitle_cues(
284
+ metadata: Mapping | Sequence | None,
285
+ ) -> tuple[list[dict], str | None]:
286
+ """``(cues, problem)`` from ``metadata["subtitles"]``; cues ``[]`` if none.
287
+
288
+ Metadata is a bag of things the caller did not curate, so an unusable
289
+ ``subtitles`` value must not abort a segmentation another key could serve
290
+ (e.g. chapters): it is no fact, and *problem* names why (surfaced as a
291
+ flag).
292
+
293
+ Usable means timed text itself (SRT/VTT text, a file path, cue rows, a
294
+ Whisper ``{"segments": ...}`` result). A yt-dlp info dict's ``subtitles``
295
+ is a ``{lang: [{url, ext}]}`` index of tracks still to be fetched — not
296
+ text — and is no fact.
297
+ """
298
+ value = metadata.get("subtitles") if isinstance(metadata, Mapping) else None
299
+ if (
300
+ isinstance(value, (str, Path))
301
+ or (isinstance(value, Mapping) and "segments" in value)
302
+ or (isinstance(value, Sequence) and not isinstance(value, bytes))
303
+ ):
304
+ try:
305
+ return normalize_timed_text(value), None
306
+ except (ValueError, TypeError, ImportError) as error:
307
+ return [], f"subtitles-unusable: {error}"
308
+ return [], None
309
+
310
+
260
311
  def _facts(
261
312
  *,
262
313
  media: str | None,
@@ -265,6 +316,8 @@ def _facts(
265
316
  grid: MetricGrid | None,
266
317
  k: int | None,
267
318
  chapters: list[dict] | None = None,
319
+ subtitles: list[dict] | None = None,
320
+ transcript: list[dict] | None = None,
268
321
  ) -> frozenset[str]:
269
322
  """The free half of the fact vocabulary (docs/alignment/07 §9.3): what is
270
323
  present among the inputs. Probed (intrinsic) facts arrive with the first
@@ -291,6 +344,10 @@ def _facts(
291
344
  facts.add("k")
292
345
  if chapters:
293
346
  facts.add("meta.chapters")
347
+ if subtitles:
348
+ facts.add("meta.subtitles")
349
+ if transcript:
350
+ facts.add("transcript")
294
351
  return frozenset(facts)
295
352
 
296
353
 
@@ -305,6 +362,7 @@ def segment(
305
362
  boundaries: Sequence[float] | None = None,
306
363
  grid: MetricGrid | Mapping | None = None,
307
364
  metadata: Mapping | Sequence | None = None,
365
+ transcript: str | Sequence | None = None,
308
366
  k: int | None = None,
309
367
  catalog: Mapping[str, Capability] | None = None,
310
368
  ) -> Segmentation:
@@ -326,6 +384,9 @@ def segment(
326
384
  step_rows = _normalize_steps(steps)
327
385
  grid = _normalize_grid(grid)
328
386
  chapters = _normalize_chapters(metadata)
387
+ subtitles, subtitles_problem = _subtitle_cues(metadata)
388
+ problem_flags = (subtitles_problem,) if subtitles_problem else ()
389
+ transcript_cues = normalize_timed_text(transcript)
329
390
  facts = _facts(
330
391
  media=media,
331
392
  steps=step_rows,
@@ -333,6 +394,8 @@ def segment(
333
394
  grid=grid,
334
395
  k=k,
335
396
  chapters=chapters,
397
+ subtitles=subtitles,
398
+ transcript=transcript_cues,
336
399
  )
337
400
  inputs = {
338
401
  "media": media,
@@ -340,6 +403,8 @@ def segment(
340
403
  "boundaries": tuple(float(b) for b in boundaries) if boundaries else (),
341
404
  "grid": grid,
342
405
  "chapters": chapters,
406
+ "subtitles": subtitles,
407
+ "transcript": transcript_cues,
343
408
  "k": k,
344
409
  }
345
410
 
@@ -369,10 +434,12 @@ def segment(
369
434
  if not candidates:
370
435
  return Segmentation(
371
436
  confidence=0.0,
372
- flags=(
437
+ flags=problem_flags
438
+ + (
373
439
  "no-signal",
374
440
  "try: steps=[(name, duration), ...] with grid=, "
375
- "boundaries=[t0, t1, ...], or metadata= with chapters",
441
+ "boundaries=[t0, t1, ...], metadata= with chapters or subtitles, "
442
+ "or transcript=",
376
443
  ),
377
444
  elapsed_s=time.perf_counter() - started,
378
445
  )
@@ -399,6 +466,8 @@ def segment(
399
466
  "steps": "steps",
400
467
  "boundaries": "boundaries",
401
468
  "meta.chapters": "metadata (chapters)",
469
+ "meta.subtitles": "metadata (subtitles)",
470
+ "transcript": "transcript",
402
471
  "k": "k",
403
472
  }
404
473
  used = capability.needs | capability.reads
@@ -406,6 +475,7 @@ def segment(
406
475
  label for fact, label in ignorable.items() if fact in facts and fact not in used
407
476
  )
408
477
  extra_flags = (f"ignored: {', '.join(ignored)}",) if ignored else ()
478
+ extra_flags += problem_flags
409
479
 
410
480
  # A grid describes the media's own clock; any segmentation over that
411
481
  # media may carry it (and its unit) even when placement didn't use it —
@@ -550,12 +620,7 @@ def _segment_chapters(inputs: Mapping[str, Any]) -> Segmentation:
550
620
  f"step-count-mismatch: {len(step_rows)} steps vs "
551
621
  f"{len(chapters)} chapters (using chapter titles)",
552
622
  )
553
- seen: dict[str, int] = {}
554
-
555
- def _unique_id(title: str, index: int) -> str:
556
- slug = _slugify(title) if title else f"chapter-{index + 1}"
557
- seen[slug] = seen.get(slug, 0) + 1
558
- return slug if seen[slug] == 1 else f"{slug}-{seen[slug]}"
623
+ chapter_ids = unique_slug_ids([c["title"] for c in chapters], fallback="chapter")
559
624
 
560
625
  def _step(i: int, chapter: dict) -> SegStep:
561
626
  if user_names:
@@ -563,7 +628,7 @@ def _segment_chapters(inputs: Mapping[str, Any]) -> Segmentation:
563
628
  name, step_id = row["name"], row["id"]
564
629
  evidence = {"chapter_title": chapter["title"]}
565
630
  else:
566
- name, step_id = chapter["title"], _unique_id(chapter["title"], i)
631
+ name, step_id = chapter["title"], chapter_ids[i]
567
632
  evidence = {}
568
633
  return SegStep(
569
634
  id=step_id,
@@ -0,0 +1,140 @@
1
+ """Timed-text normalisation: any subtitle/transcript shape → ``{start, end, text}`` rows.
2
+
3
+ Depends on nothing else in ``paces`` — it is the input boundary of the cue
4
+ segmenters (``paces/cues.py``) and of ``segment(transcript=...)``.
5
+
6
+ Accepted shapes:
7
+
8
+ - SRT or WebVTT *text* (VTT when it starts with ``WEBVTT``, after any BOM or
9
+ whitespace; hour fields optional) — parsed by the fleet's ``mixing.srt``,
10
+ which is an optional dependency here: only text parsing needs it.
11
+ - a path (``str`` or :class:`~pathlib.Path`) to an ``.srt``/``.vtt`` file;
12
+ - cue mappings ``{start, end, text}`` or ``(start, end, text)`` triples (the
13
+ shape ``paces.evidence`` takes for transcripts);
14
+ - a Whisper result ``{"segments": [{start, end, text}, ...]}``.
15
+
16
+ Simple markup (``<i>..</i>``, VTT ``<c>`` and inline timestamp tags, ASS
17
+ overrides like ``{\\an8}``) is stripped from cue text.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import math
23
+ import re
24
+ from collections.abc import Mapping, Sequence
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ _MARKUP_RE = re.compile(r"<[^>]*>|\{\\[^}]*\}")
29
+ _MAX_PATH_CHARS = 1024
30
+ _MIXING_INSTALL_HINT = (
31
+ "Parsing SRT/VTT text needs the optional `mixing` package: "
32
+ "pip install mixing (or pass cue mappings / (start, end, text) triples)."
33
+ )
34
+
35
+
36
+ def _mixing_srt():
37
+ try:
38
+ from mixing import srt
39
+ except ImportError as error:
40
+ raise ImportError(_MIXING_INSTALL_HINT) from error
41
+ return srt
42
+
43
+
44
+ def _parse_srt(text: str) -> list[dict]:
45
+ return [
46
+ {"start": c.start, "end": c.end, "text": c.text}
47
+ for c in _mixing_srt().parse_srt(text)
48
+ ]
49
+
50
+
51
+ def _parse_vtt(text: str) -> list[dict]:
52
+ """Cue-level WebVTT via mixing's private helpers.
53
+
54
+ TODO: switch to a public ``mixing.srt.parse_vtt`` once it exists —
55
+ https://github.com/reeleehq/mixing/issues/79
56
+ """
57
+ srt = _mixing_srt()
58
+ return [
59
+ {"start": c.start, "end": c.end, "text": srt._vtt_clean(c.text)}
60
+ for c in srt._vtt_cues(text)
61
+ ]
62
+
63
+
64
+ def _read_if_path(source: str | Path) -> str:
65
+ if isinstance(source, Path):
66
+ return source.read_text(encoding="utf-8")
67
+ if "-->" in source:
68
+ return source # timed text itself
69
+ if len(source) < _MAX_PATH_CHARS and "\n" not in source:
70
+ try:
71
+ if Path(source).is_file():
72
+ return Path(source).read_text(encoding="utf-8")
73
+ except OSError:
74
+ pass
75
+ raise ValueError(
76
+ "timed text: not SRT/VTT (no '-->' timing line) and not an existing "
77
+ f"file: {source[:80]!r}"
78
+ )
79
+
80
+
81
+ def _parse_text(text: str) -> list[dict]:
82
+ stripped = text.lstrip(" \t\r\n")
83
+ if stripped.startswith("WEBVTT"):
84
+ return _parse_vtt(stripped)
85
+ return _parse_srt(stripped)
86
+
87
+
88
+ def _cue_row(i: int, item: Any) -> dict:
89
+ if isinstance(item, Sequence) and not isinstance(item, (str, bytes)):
90
+ if len(item) != 3:
91
+ raise ValueError(f"cues[{i}]: expected (start, end, text); got {item!r}")
92
+ item = dict(zip(("start", "end", "text"), item))
93
+ if not isinstance(item, Mapping):
94
+ raise TypeError(
95
+ f"cues[{i}]: expected a mapping or (start, end, text) — "
96
+ f"got {type(item).__name__}"
97
+ )
98
+ if item.get("text") is None or isinstance(item.get("start"), bool):
99
+ raise ValueError(f"cues[{i}]: needs numeric start and text; got {dict(item)!r}")
100
+ try:
101
+ start = float(item["start"])
102
+ end = float(item.get("end", start))
103
+ text = " ".join(_MARKUP_RE.sub("", str(item["text"])).split())
104
+ except (KeyError, TypeError, ValueError) as error:
105
+ raise ValueError(
106
+ f"cues[{i}]: needs numeric start (and end) plus text; got "
107
+ f"keys {sorted(item)} ({error})"
108
+ ) from None
109
+ if not (math.isfinite(start) and math.isfinite(end)) or start < 0 or end < start:
110
+ raise ValueError(f"cues[{i}]: unusable times start={start}, end={end}")
111
+ return {"start": start, "end": end, "text": text}
112
+
113
+
114
+ def normalize_timed_text(source: str | Path | Sequence | Mapping | None) -> list[dict]:
115
+ """Uniform, sorted ``{start, end, text}`` rows from any accepted timed text.
116
+
117
+ >>> normalize_timed_text([(1, 3, "<i>Now</i> we turn")])
118
+ [{'start': 1.0, 'end': 3.0, 'text': 'Now we turn'}]
119
+ >>> normalize_timed_text({"segments": [{"start": 0, "end": 2, "text": " Hi"}]})
120
+ [{'start': 0.0, 'end': 2.0, 'text': 'Hi'}]
121
+ >>> normalize_timed_text(None)
122
+ []
123
+ """
124
+ if source is None:
125
+ return []
126
+ if isinstance(source, (str, Path)):
127
+ raw: Sequence = _parse_text(_read_if_path(source))
128
+ elif isinstance(source, Mapping):
129
+ if "segments" not in source:
130
+ raise ValueError(
131
+ "timed text: a mapping must be a Whisper result with 'segments'; "
132
+ f"got keys {sorted(source)}"
133
+ )
134
+ raw = list(source["segments"])
135
+ else:
136
+ raw = list(source)
137
+ rows = [_cue_row(i, item) for i, item in enumerate(raw)]
138
+ rows = [row for row in rows if row["text"]]
139
+ rows.sort(key=lambda row: (row["start"], row["end"]))
140
+ return rows
@@ -39,6 +39,18 @@ def _structured(value, *, what: str = "argument"):
39
39
  ) from None
40
40
 
41
41
 
42
+ def _timed_text(value):
43
+ """JSON cues parsed when the string *is* JSON; anything else passes through
44
+ (SRT/VTT text, or a path — even one like ``[draft].srt``)."""
45
+ if isinstance(value, str):
46
+ try:
47
+ parsed = json.loads(value)
48
+ except ValueError:
49
+ return value
50
+ return parsed if isinstance(parsed, (list, dict)) else value
51
+ return value
52
+
53
+
42
54
  def _as_document(document) -> model.StepDocument:
43
55
  if isinstance(document, model.StepDocument):
44
56
  return document
@@ -114,6 +126,7 @@ def segment(
114
126
  boundaries=None,
115
127
  grid=None,
116
128
  metadata=None,
129
+ transcript=None,
117
130
  segmenter: str | None = None,
118
131
  k: int | None = None,
119
132
  output: str | None = None,
@@ -122,7 +135,10 @@ def segment(
122
135
 
123
136
  ``steps``, ``boundaries``, ``grid`` and ``metadata`` accept JSON values,
124
137
  JSON strings, or paths to JSON files — ``metadata`` notably takes a
125
- yt-dlp ``.info.json`` (its ``chapters`` become a named segmentation).
138
+ yt-dlp ``.info.json`` (its ``chapters`` become a named segmentation) or
139
+ ``{"subtitles": <SRT text | .srt/.vtt path | cue list>}``. ``transcript``
140
+ is timed text — SRT text, an ``.srt``/``.vtt`` path, or JSON cues
141
+ ``[{"start", "end", "text"}]`` — read for step markers ("now", "next").
126
142
  Returns the segmentation as a JSON-ready dict (also written to
127
143
  ``output=`` when given) — honest about naming
128
144
  (``flags: ['naming-abstained']``) and about refusal
@@ -135,6 +151,7 @@ def segment(
135
151
  boundaries=_structured(boundaries, what="boundaries"),
136
152
  grid=_structured(grid, what="grid"),
137
153
  metadata=_structured(metadata, what="metadata"),
154
+ transcript=_timed_text(transcript),
138
155
  k=k,
139
156
  )
140
157
  payload = _segmentation_to_dict(result)
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "paces"
9
- version = "0.0.13"
9
+ version = "0.0.14"
10
10
  description = "Turn instructional media into structured, interactive learning material"
11
11
  readme = "README.md"
12
12
  license = "MIT"
@@ -0,0 +1,255 @@
1
+ """The subtitle / transcript cue segmenters (issue #6)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from paces import tools
11
+ from paces.cues import detect_cues, normalize_timed_text
12
+ from paces.segmenters import capabilities, segment
13
+
14
+ SRT = """1
15
+ 00:00:00,000 --> 00:00:05,000
16
+ Hi everyone, welcome back.
17
+
18
+ 2
19
+ 00:00:12,000 --> 00:00:18,000
20
+ Now we start with the basic step.
21
+
22
+ 3
23
+ 00:00:20,000 --> 00:00:24,000
24
+ Keep your weight forward.
25
+
26
+ 4
27
+ 00:00:40,000 --> 00:00:46,000
28
+ Step two: add the turn.
29
+
30
+ 5
31
+ 00:01:10,000 --> 00:01:15,000
32
+ And finally, put it together.
33
+ """
34
+
35
+
36
+ def test_srt_subtitles_become_proposed_named_spans():
37
+ seg = segment("v.mp4", metadata={"subtitles": SRT})
38
+ assert seg.method == "subtitle-cues"
39
+ assert [s.name for s in seg.steps] == [
40
+ "we start with the basic step",
41
+ "add the turn",
42
+ "put it together",
43
+ ]
44
+ assert seg.steps[0].spans == ((12.0, 40.0),)
45
+ assert seg.boundaries == (12.0, 40.0, 70.0, 75.0)
46
+ assert "proposed-uncertain" in seg.flags
47
+ assert seg.confidence < 0.9 # a proposer, not an authority
48
+
49
+
50
+ def test_transcript_cue_dicts_use_the_transcript_capability():
51
+ cues = [
52
+ {"start": 0, "end": 5, "text": "Intro"},
53
+ {"start": 20, "end": 25, "text": "First, the stance."},
54
+ {"start": 60, "end": 65, "text": "Next, the turn."},
55
+ ]
56
+ seg = segment(transcript=cues)
57
+ assert seg.method == "transcript-cues"
58
+ assert [s.name for s in seg.steps] == ["the stance", "the turn"]
59
+ assert seg.steps[0].source == "transcript-cues"
60
+
61
+
62
+ def test_srt_file_path_is_read(tmp_path):
63
+ path = tmp_path / "talk.srt"
64
+ path.write_text(SRT, encoding="utf-8")
65
+ seg = segment(transcript=str(path))
66
+ assert len(seg.steps) == 3
67
+
68
+
69
+ def test_no_markers_is_the_refused_state_not_an_invention():
70
+ seg = segment(transcript=[{"start": 0, "end": 5, "text": "Just chatting."}])
71
+ assert seg.steps == () and seg.boundaries == ()
72
+ assert "no-signal" in seg.flags
73
+
74
+
75
+ def test_close_cues_fold_into_one_step():
76
+ rows = [
77
+ {"start": 10, "end": 12, "text": "Now we begin."},
78
+ {"start": 14, "end": 16, "text": "Next keep going."},
79
+ ]
80
+ assert [r["start"] for r in detect_cues(rows)] == [10.0]
81
+ assert len(detect_cues(rows, min_gap_s=1)) == 2
82
+
83
+
84
+ def test_marker_alone_is_found_but_unnamed():
85
+ seg = segment(transcript=[{"start": 5, "end": 9, "text": "Next."}])
86
+ assert seg.steps[0].name == "" and seg.steps[0].id == "cue-1"
87
+ assert "naming-abstained" in seg.flags
88
+
89
+
90
+ def test_long_names_trim_at_a_word_boundary():
91
+ text = "Now " + "word " * 30
92
+ (row,) = detect_cues([{"start": 0, "end": 1, "text": text}], max_name_chars=20)
93
+ assert row["name"] == "word word word word"
94
+
95
+
96
+ def test_ytdlp_subtitle_index_is_no_fact():
97
+ seg = segment(
98
+ "v.mp4", metadata={"subtitles": {"en": [{"ext": "vtt", "url": "http://x"}]}}
99
+ )
100
+ assert "no-signal" in seg.flags
101
+
102
+
103
+ def test_priority_chapters_over_cues_and_cues_over_nothing():
104
+ chapters = [{"start": 0, "end": 10, "title": "A"}]
105
+ seg = segment(metadata={"chapters": chapters, "subtitles": SRT})
106
+ assert seg.method == "chapters"
107
+ assert "ignored: metadata (subtitles)" in seg.flags
108
+ seg = segment(transcript=SRT, metadata={"subtitles": SRT})
109
+ assert seg.method == "subtitle-cues" # human cues outrank ASR
110
+ assert "ignored: transcript" in seg.flags
111
+
112
+
113
+ def test_explicit_segmenter_pin_and_missing_input():
114
+ assert segment(transcript=SRT, segmenter="transcript-cues").steps
115
+ with pytest.raises(ValueError, match="needs inputs"):
116
+ segment(segmenter="transcript-cues")
117
+
118
+
119
+ def test_malformed_cues_say_what_is_wrong():
120
+ with pytest.raises(ValueError, match="cues\\[0\\]"):
121
+ normalize_timed_text([{"start": "x", "text": "hi"}])
122
+ with pytest.raises(ValueError, match="unusable times"):
123
+ normalize_timed_text([{"start": 5, "end": 1, "text": "hi"}])
124
+
125
+
126
+ def test_registered_and_listed():
127
+ assert {"subtitle-cues", "transcript-cues"} <= set(capabilities())
128
+
129
+
130
+ VTT_NO_HOURS = """WEBVTT
131
+
132
+ 00:12.000 --> 00:15.000
133
+ <c>Now</c> the <00:00:13.000><c>basic step</c>
134
+
135
+ 00:40.000 --> 00:44.000
136
+ Next, the turn.
137
+ """
138
+
139
+
140
+ VTT_HOURS = "\ufeff " + VTT_NO_HOURS.replace("00:12", "00:00:12").replace(
141
+ "00:15", "00:00:15"
142
+ ).replace("00:40", "00:00:40").replace("00:44", "00:00:44")
143
+
144
+
145
+ @pytest.mark.parametrize("vtt", [VTT_NO_HOURS, VTT_HOURS])
146
+ def test_vtt_with_and_without_hours(vtt):
147
+ rows = normalize_timed_text(vtt)
148
+ assert rows[0] == {"start": 12.0, "end": 15.0, "text": "Now the basic step"}
149
+ assert [r["start"] for r in rows] == [12.0, 40.0]
150
+
151
+
152
+ def test_markup_and_speaker_prefixes_are_stripped():
153
+ rows = normalize_timed_text(
154
+ [
155
+ (5, 9, "{\\an8}<i>- Now the stance</i>"),
156
+ (30, 34, ">> [music] Next, the turn"),
157
+ ]
158
+ )
159
+ assert rows[0]["text"] == "- Now the stance"
160
+ assert [r["name"] for r in detect_cues(rows)] == ["the stance", "the turn"]
161
+
162
+
163
+ def test_text_that_is_neither_timed_text_nor_a_file_raises():
164
+ with pytest.raises(ValueError, match="not an existing file"):
165
+ normalize_timed_text("no-such-file.srt")
166
+
167
+
168
+ def test_folded_cue_names_an_unnamed_opener():
169
+ rows = [
170
+ {"start": 10, "end": 11, "text": "Now."},
171
+ {"start": 12, "end": 16, "text": "Step two: the turn."},
172
+ ]
173
+ assert detect_cues(rows) == [{"start": 10.0, "name": "the turn", "cue": "Now"}]
174
+
175
+
176
+ def test_zero_length_trailing_step_is_dropped():
177
+ seg = segment(transcript=[(5, 30, "First, the stance."), (30, 30, "Finally.")])
178
+ assert [s.name for s in seg.steps] == ["the stance"]
179
+ assert seg.boundaries == (5.0, 30.0)
180
+ assert "trailing-cue-dropped" in seg.flags
181
+
182
+
183
+ def test_intro_before_first_cue_is_flagged_only_when_present():
184
+ assert "intro-before: 5s" in segment(transcript=[(5, 9, "Next, x")]).flags
185
+ flags = segment(transcript=[(0, 9, "Next, x")]).flags
186
+ assert not any(f.startswith("intro-before") for f in flags)
187
+
188
+
189
+ def test_path_object_input(tmp_path):
190
+ path = tmp_path / "talk.srt"
191
+ path.write_text(SRT, encoding="utf-8")
192
+ assert len(segment(transcript=Path(path)).steps) == 3
193
+
194
+
195
+ def test_triples_and_whisper_segments():
196
+ triples = [(0, 4, "Hi"), (20, 25, "First, the stance.")]
197
+ whisper = {"segments": [{"start": s, "end": e, "text": t} for s, e, t in triples]}
198
+ assert normalize_timed_text(triples) == normalize_timed_text(whisper)
199
+ assert segment(transcript=whisper).steps[0].name == "the stance"
200
+
201
+
202
+ def test_missing_mixing_names_the_install(monkeypatch):
203
+ real_import = builtins.__import__
204
+
205
+ def no_mixing(name, *args, **kwargs):
206
+ if name == "mixing" or name.startswith("mixing."):
207
+ raise ImportError("No module named 'mixing'")
208
+ return real_import(name, *args, **kwargs)
209
+
210
+ monkeypatch.setattr(builtins, "__import__", no_mixing)
211
+ with pytest.raises(ImportError, match="pip install mixing"):
212
+ normalize_timed_text(SRT)
213
+ assert normalize_timed_text([(0, 1, "Now")]) # cue rows need no mixing
214
+
215
+
216
+ def test_cli_timed_text_only_parses_real_json():
217
+ assert tools._timed_text("[draft].srt") == "[draft].srt"
218
+ assert tools._timed_text('[{"start": 0, "end": 1, "text": "x"}]')[0]["text"] == "x"
219
+
220
+
221
+ # ── second-review regressions ───────────────────────────────────────────────
222
+
223
+
224
+ def test_unusable_subtitles_do_not_break_the_chapters_path():
225
+ chapters = [{"start": 0, "end": 10, "title": "A"}]
226
+ seg = segment(metadata={"chapters": chapters, "subtitles": "en"})
227
+ assert seg.method == "chapters"
228
+ assert any(flag.startswith("subtitles-unusable") for flag in seg.flags)
229
+
230
+
231
+ def test_custom_pattern_without_a_group_works():
232
+ rows = [{"start": 0, "end": 5, "text": "Right, go."}]
233
+ (hit,) = detect_cues(rows, patterns=[r"right[,\s]+"])
234
+ assert hit["cue"] == "Right," and hit["name"] == "go"
235
+
236
+
237
+ def test_first_of_all_is_one_marker():
238
+ (hit,) = detect_cues([{"start": 0, "end": 5, "text": "First of all, the stance."}])
239
+ assert hit["name"] == "the stance"
240
+
241
+
242
+ def test_null_text_and_bool_times_are_refused():
243
+ with pytest.raises(ValueError):
244
+ normalize_timed_text([{"start": 0, "end": 1, "text": None}])
245
+ with pytest.raises(ValueError):
246
+ normalize_timed_text([{"start": True, "end": 1, "text": "x"}])
247
+
248
+
249
+ def test_cli_segment_end_to_end_on_a_transcript(tmp_path):
250
+ from paces import tools
251
+
252
+ path = tmp_path / "talk.srt"
253
+ path.write_text(SRT, encoding="utf-8")
254
+ out = tools.segment(transcript=str(path))
255
+ assert out["method"] == "transcript-cues" and len(out["steps"]) == 3
@@ -464,9 +464,7 @@ def test_check_pose_requirements_names_the_repair_when_both_cv2_providers_presen
464
464
  monkeypatch.setattr(importlib.metadata, "version", fake_version)
465
465
  report = check_pose_requirements()
466
466
  assert report["cv2_providers"] == ["opencv-python", "opencv-contrib-python"]
467
- assert any(
468
- "pip install --force-reinstall" in note for note in report["notes"]
469
- )
467
+ assert any("pip install --force-reinstall" in note for note in report["notes"])
470
468
 
471
469
 
472
470
  # ── the licence perimeter (packaging facts, guarded because nothing else is) ─
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes