paces 0.0.12__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.
- {paces-0.0.12 → paces-0.0.14}/PKG-INFO +5 -5
- {paces-0.0.12 → paces-0.0.14}/paces/__init__.py +4 -0
- paces-0.0.14/paces/cues.py +207 -0
- {paces-0.0.12 → paces-0.0.14}/paces/segmenters.py +74 -9
- paces-0.0.14/paces/timed_text.py +140 -0
- {paces-0.0.12 → paces-0.0.14}/paces/tools.py +18 -1
- {paces-0.0.12 → paces-0.0.14}/pyproject.toml +5 -5
- {paces-0.0.12 → paces-0.0.14}/tests/data/body_schemas.json +8 -0
- paces-0.0.14/tests/test_cues.py +255 -0
- {paces-0.0.12 → paces-0.0.14}/tests/test_evidence_schemas.py +6 -0
- {paces-0.0.12 → paces-0.0.14}/tests/test_pose.py +1 -3
- {paces-0.0.12 → paces-0.0.14}/.gitignore +0 -0
- {paces-0.0.12 → paces-0.0.14}/LICENSE +0 -0
- {paces-0.0.12 → paces-0.0.14}/README.md +0 -0
- {paces-0.0.12 → paces-0.0.14}/docs/README.md +0 -0
- {paces-0.0.12 → paces-0.0.14}/docs/alignment/README.md +0 -0
- {paces-0.0.12 → paces-0.0.14}/docs/poc-reference/README.md +0 -0
- {paces-0.0.12 → paces-0.0.14}/docs/poc-reference/artifacts/clips.json +0 -0
- {paces-0.0.12 → paces-0.0.14}/paces/__main__.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/paces/bodies/__init__.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/paces/bodies/_base.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/paces/bodies/derivation.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/paces/bodies/document.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/paces/bodies/signal.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/paces/bodies/step.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/paces/derivation.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/paces/edits.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/paces/evidence.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/paces/excerpts.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/paces/measure.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/paces/model.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/paces/pose.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/paces/projection.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/paces/render.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/tests/audio_synth.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/tests/data/routine.json +0 -0
- {paces-0.0.12 → paces-0.0.14}/tests/poc_fixture.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/tests/test_chapters.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/tests/test_cli_surface.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/tests/test_derivation.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/tests/test_edit_protection_residuals.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/tests/test_edits.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/tests/test_evidence.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/tests/test_excerpts.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/tests/test_measure.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/tests/test_render_media.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/tests/test_roundtrip_poc.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/tests/test_segment.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/tests/test_smoke.py +0 -0
- {paces-0.0.12 → paces-0.0.14}/tests/test_vertical_slice.py +0 -0
- {paces-0.0.12 → 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.
|
|
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
|
|
@@ -20,8 +20,8 @@ Requires-Dist: argcomplete>=3; extra == 'cli'
|
|
|
20
20
|
Requires-Dist: cw<0.2,>=0.1.1; extra == 'cli'
|
|
21
21
|
Provides-Extra: dev
|
|
22
22
|
Requires-Dist: cw<0.2,>=0.1.1; extra == 'dev'
|
|
23
|
-
Requires-Dist: lacing>=0.0.
|
|
24
|
-
Requires-Dist: mixing[audio,beats]>=0.0.39; extra == 'dev'
|
|
23
|
+
Requires-Dist: lacing>=0.0.54; extra == 'dev'
|
|
24
|
+
Requires-Dist: mixing[audio,beats,media]>=0.0.39; extra == 'dev'
|
|
25
25
|
Requires-Dist: numba>=0.59; extra == 'dev'
|
|
26
26
|
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
27
27
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
@@ -31,9 +31,9 @@ Provides-Extra: docs
|
|
|
31
31
|
Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
|
|
32
32
|
Requires-Dist: sphinx>=6.0; extra == 'docs'
|
|
33
33
|
Provides-Extra: lacing
|
|
34
|
-
Requires-Dist: lacing>=0.0.
|
|
34
|
+
Requires-Dist: lacing>=0.0.54; extra == 'lacing'
|
|
35
35
|
Provides-Extra: media
|
|
36
|
-
Requires-Dist: mixing>=0.0.39; extra == 'media'
|
|
36
|
+
Requires-Dist: mixing[media]>=0.0.39; extra == 'media'
|
|
37
37
|
Provides-Extra: pose
|
|
38
38
|
Requires-Dist: onnxruntime; extra == 'pose'
|
|
39
39
|
Requires-Dist: rtmlib<0.1,>=0.0.16; extra == 'pose'
|
|
@@ -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, ...],
|
|
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
|
-
|
|
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"],
|
|
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.
|
|
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"
|
|
@@ -57,7 +57,7 @@ media = [
|
|
|
57
57
|
# all run on the pip-bundled imageio-ffmpeg binary — no system ffmpeg.
|
|
58
58
|
# Floor 0.0.39: make_gif, crop_box= and path-accepting
|
|
59
59
|
# get_video_dimensions ship there.
|
|
60
|
-
"mixing>=0.0.39",
|
|
60
|
+
"mixing[media]>=0.0.39",
|
|
61
61
|
]
|
|
62
62
|
# The pointable subject_locator (issue #15, ADR-0005 §3): rtmlib pose boxes,
|
|
63
63
|
# reached through `paces.pose:rtmlib_pose`. rtmlib is Apache-2.0 (a pure-Python
|
|
@@ -107,7 +107,7 @@ pose = [
|
|
|
107
107
|
# unknown keys — both of which this layer's round trip relies on to fail loudly
|
|
108
108
|
# instead of silently normalising.
|
|
109
109
|
lacing = [
|
|
110
|
-
"lacing>=0.0.
|
|
110
|
+
"lacing>=0.0.54",
|
|
111
111
|
]
|
|
112
112
|
dev = [
|
|
113
113
|
"pytest>=7.0",
|
|
@@ -120,11 +120,11 @@ dev = [
|
|
|
120
120
|
# synthesized audio; mirrored with [audio] so they run rather than skip.
|
|
121
121
|
# The derivation tests do the same on synthetic video — mirrored with
|
|
122
122
|
# [media] (the >=0.0.39 floor wins over [audio]'s >=0.0.36).
|
|
123
|
-
"mixing[beats,audio]>=0.0.39",
|
|
123
|
+
"mixing[beats,audio,media]>=0.0.39",
|
|
124
124
|
"numba>=0.59",
|
|
125
125
|
# The evidence-layer tests run against real, dict-backed lacing stores —
|
|
126
126
|
# mirrored with [lacing] so they run rather than skip.
|
|
127
|
-
"lacing>=0.0.
|
|
127
|
+
"lacing>=0.0.54",
|
|
128
128
|
# tests/test_pose.py reads pyproject.toml to guard the licence perimeter;
|
|
129
129
|
# stdlib tomllib is 3.11+, and a skipped guard guards nothing.
|
|
130
130
|
"tomli>=2.0; python_version < '3.11'",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"attrs": {
|
|
29
29
|
"additionalProperties": true,
|
|
30
|
+
"default": {},
|
|
30
31
|
"description": "Open bag.",
|
|
31
32
|
"title": "Attrs",
|
|
32
33
|
"type": "object"
|
|
@@ -146,6 +147,7 @@
|
|
|
146
147
|
"properties": {
|
|
147
148
|
"attrs": {
|
|
148
149
|
"additionalProperties": true,
|
|
150
|
+
"default": {},
|
|
149
151
|
"description": "Namespaced open bag, as on the document.",
|
|
150
152
|
"title": "Attrs",
|
|
151
153
|
"type": "object"
|
|
@@ -175,6 +177,7 @@
|
|
|
175
177
|
"type": "string"
|
|
176
178
|
},
|
|
177
179
|
"flags": {
|
|
180
|
+
"default": [],
|
|
178
181
|
"description": "The run's honesty flags (``Segmentation.flags``) — 'naming-abstained', 'origin-estimated: ...', and friends. Evidence about the run, never projected into the document.",
|
|
179
182
|
"items": {
|
|
180
183
|
"type": "string"
|
|
@@ -389,6 +392,7 @@
|
|
|
389
392
|
"pattern": "^-?\\d+(\\.\\d+)?$",
|
|
390
393
|
"type": "string"
|
|
391
394
|
},
|
|
395
|
+
"default": {},
|
|
392
396
|
"description": "Policy parameters the box was resolved under (aspect, pad). A change here re-locates an unlocked entry.",
|
|
393
397
|
"title": "Params",
|
|
394
398
|
"type": "object"
|
|
@@ -460,6 +464,7 @@
|
|
|
460
464
|
},
|
|
461
465
|
"attrs": {
|
|
462
466
|
"additionalProperties": true,
|
|
467
|
+
"default": {},
|
|
463
468
|
"description": "Open bag.",
|
|
464
469
|
"title": "Attrs",
|
|
465
470
|
"type": "object"
|
|
@@ -546,6 +551,7 @@
|
|
|
546
551
|
"properties": {
|
|
547
552
|
"attrs": {
|
|
548
553
|
"additionalProperties": true,
|
|
554
|
+
"default": {},
|
|
549
555
|
"description": "Namespaced open bag, as on the step.",
|
|
550
556
|
"title": "Attrs",
|
|
551
557
|
"type": "object"
|
|
@@ -610,6 +616,7 @@
|
|
|
610
616
|
},
|
|
611
617
|
"span_attrs": {
|
|
612
618
|
"additionalProperties": true,
|
|
619
|
+
"default": {},
|
|
613
620
|
"description": "Open bag on the span.",
|
|
614
621
|
"title": "Span Attrs",
|
|
615
622
|
"type": "object"
|
|
@@ -712,6 +719,7 @@
|
|
|
712
719
|
"type": "string"
|
|
713
720
|
},
|
|
714
721
|
"tags": {
|
|
722
|
+
"default": [],
|
|
715
723
|
"description": "Open tag vocabulary.",
|
|
716
724
|
"items": {
|
|
717
725
|
"type": "string"
|
|
@@ -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
|
|
@@ -12,6 +12,12 @@ halves of that claim:
|
|
|
12
12
|
migration; a *changed* one is a wire break for every already-written store,
|
|
13
13
|
and this test is what makes that a decision rather than an accident.
|
|
14
14
|
|
|
15
|
+
Annotation-only keywords a lacing upgrade starts exporting (``default``,
|
|
16
|
+
``description``) may be re-snapshotted — they validate nothing, so no stored
|
|
17
|
+
body changes meaning (lacing 0.0.54 added the ``default_factory`` defaults;
|
|
18
|
+
the floor in pyproject follows). A change to a field, type or ``required`` is
|
|
19
|
+
the wire break and needs ``/v2``.
|
|
20
|
+
|
|
15
21
|
Regenerating the snapshot deliberately::
|
|
16
22
|
|
|
17
23
|
python -m tests.test_evidence_schemas # writes tests/data/body_schemas.json
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|