paces 0.0.16__tar.gz → 0.0.18__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 (58) hide show
  1. {paces-0.0.16 → paces-0.0.18}/PKG-INFO +2 -1
  2. {paces-0.0.16 → paces-0.0.18}/README.md +1 -0
  3. paces-0.0.18/paces/ask.py +252 -0
  4. {paces-0.0.16 → paces-0.0.18}/paces/corrections.py +21 -9
  5. paces-0.0.18/paces/data/ask/ask.css +78 -0
  6. paces-0.0.18/paces/data/ask/ask.html +75 -0
  7. paces-0.0.18/paces/data/ask/ask.js +499 -0
  8. {paces-0.0.16 → paces-0.0.18}/paces/model.py +10 -2
  9. {paces-0.0.16 → paces-0.0.18}/paces/tools.py +47 -3
  10. {paces-0.0.16 → paces-0.0.18}/pyproject.toml +1 -1
  11. paces-0.0.18/tests/test_ask.py +543 -0
  12. {paces-0.0.16 → paces-0.0.18}/tests/test_corrections.py +25 -0
  13. {paces-0.0.16 → paces-0.0.18}/tests/test_segment.py +25 -0
  14. {paces-0.0.16 → paces-0.0.18}/.gitignore +0 -0
  15. {paces-0.0.16 → paces-0.0.18}/LICENSE +0 -0
  16. {paces-0.0.16 → paces-0.0.18}/docs/README.md +0 -0
  17. {paces-0.0.16 → paces-0.0.18}/docs/alignment/README.md +0 -0
  18. {paces-0.0.16 → paces-0.0.18}/docs/poc-reference/README.md +0 -0
  19. {paces-0.0.16 → paces-0.0.18}/docs/poc-reference/artifacts/clips.json +0 -0
  20. {paces-0.0.16 → paces-0.0.18}/paces/__init__.py +0 -0
  21. {paces-0.0.16 → paces-0.0.18}/paces/__main__.py +0 -0
  22. {paces-0.0.16 → paces-0.0.18}/paces/bodies/__init__.py +0 -0
  23. {paces-0.0.16 → paces-0.0.18}/paces/bodies/_base.py +0 -0
  24. {paces-0.0.16 → paces-0.0.18}/paces/bodies/derivation.py +0 -0
  25. {paces-0.0.16 → paces-0.0.18}/paces/bodies/document.py +0 -0
  26. {paces-0.0.16 → paces-0.0.18}/paces/bodies/signal.py +0 -0
  27. {paces-0.0.16 → paces-0.0.18}/paces/bodies/step.py +0 -0
  28. {paces-0.0.16 → paces-0.0.18}/paces/cues.py +0 -0
  29. {paces-0.0.16 → paces-0.0.18}/paces/derivation.py +0 -0
  30. {paces-0.0.16 → paces-0.0.18}/paces/edits.py +0 -0
  31. {paces-0.0.16 → paces-0.0.18}/paces/evidence.py +0 -0
  32. {paces-0.0.16 → paces-0.0.18}/paces/excerpts.py +0 -0
  33. {paces-0.0.16 → paces-0.0.18}/paces/measure.py +0 -0
  34. {paces-0.0.16 → paces-0.0.18}/paces/pose.py +0 -0
  35. {paces-0.0.16 → paces-0.0.18}/paces/projection.py +0 -0
  36. {paces-0.0.16 → paces-0.0.18}/paces/render.py +0 -0
  37. {paces-0.0.16 → paces-0.0.18}/paces/segmenters.py +0 -0
  38. {paces-0.0.16 → paces-0.0.18}/paces/timed_text.py +0 -0
  39. {paces-0.0.16 → paces-0.0.18}/tests/audio_synth.py +0 -0
  40. {paces-0.0.16 → paces-0.0.18}/tests/data/body_schemas.json +0 -0
  41. {paces-0.0.16 → paces-0.0.18}/tests/data/routine.json +0 -0
  42. {paces-0.0.16 → paces-0.0.18}/tests/poc_fixture.py +0 -0
  43. {paces-0.0.16 → paces-0.0.18}/tests/test_chapters.py +0 -0
  44. {paces-0.0.16 → paces-0.0.18}/tests/test_cli_surface.py +0 -0
  45. {paces-0.0.16 → paces-0.0.18}/tests/test_cues.py +0 -0
  46. {paces-0.0.16 → paces-0.0.18}/tests/test_derivation.py +0 -0
  47. {paces-0.0.16 → paces-0.0.18}/tests/test_edit_protection_residuals.py +0 -0
  48. {paces-0.0.16 → paces-0.0.18}/tests/test_edits.py +0 -0
  49. {paces-0.0.16 → paces-0.0.18}/tests/test_evidence.py +0 -0
  50. {paces-0.0.16 → paces-0.0.18}/tests/test_evidence_schemas.py +0 -0
  51. {paces-0.0.16 → paces-0.0.18}/tests/test_excerpts.py +0 -0
  52. {paces-0.0.16 → paces-0.0.18}/tests/test_measure.py +0 -0
  53. {paces-0.0.16 → paces-0.0.18}/tests/test_pose.py +0 -0
  54. {paces-0.0.16 → paces-0.0.18}/tests/test_render_media.py +0 -0
  55. {paces-0.0.16 → paces-0.0.18}/tests/test_roundtrip_poc.py +0 -0
  56. {paces-0.0.16 → paces-0.0.18}/tests/test_smoke.py +0 -0
  57. {paces-0.0.16 → paces-0.0.18}/tests/test_vertical_slice.py +0 -0
  58. {paces-0.0.16 → paces-0.0.18}/tests/video_synth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: paces
3
- Version: 0.0.16
3
+ Version: 0.0.18
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
@@ -220,6 +220,7 @@ decisions, not measurements. Full table and rationale in
220
220
  | cut media into steps | `segment(media, steps=..., grid=...)` → `Segmentation` |
221
221
  | explicit/human boundaries | `segment(media, boundaries=[...], steps=[names])` |
222
222
  | keep a human's corrections across re-runs | `segment(..., corrections=[split(t), merge(t), pin((s, e)), forbid(t, (s, e)), absent(t)])` — from `paces.corrections`; a time-keyed log, replayed as constraints, anything it could not honour flagged |
223
+ | ask a human to check a segmentation | `paces ask seg.json --media video.mp4 --output ask.html` → they confirm the step count, tap along to add cuts (or, when nothing was found, tap the cuts themselves), strike wrong ones → `answers.json` → `paces segment ... --answers answers.json`. The versioned proposal/answers JSON (`load_answers` → `Answers(k, corrections, boundaries)`) is the contract any other UI (or an agent) can speak |
223
224
  | use the video's own chapters | `segment(media, metadata=<yt-dlp info.json>)` |
224
225
  | measure the grid from the media | `segment(local_media, steps=[(name, counts), ...])` — no grid needed; tempo + structure measured, origin estimated and flagged (`pip install paces[audio]`) |
225
226
  | protect edits from regeneration | `apply_edits(doc, patches, by="user:you")` + `merge_regenerated(committed, fresh)` |
@@ -179,6 +179,7 @@ decisions, not measurements. Full table and rationale in
179
179
  | cut media into steps | `segment(media, steps=..., grid=...)` → `Segmentation` |
180
180
  | explicit/human boundaries | `segment(media, boundaries=[...], steps=[names])` |
181
181
  | keep a human's corrections across re-runs | `segment(..., corrections=[split(t), merge(t), pin((s, e)), forbid(t, (s, e)), absent(t)])` — from `paces.corrections`; a time-keyed log, replayed as constraints, anything it could not honour flagged |
182
+ | ask a human to check a segmentation | `paces ask seg.json --media video.mp4 --output ask.html` → they confirm the step count, tap along to add cuts (or, when nothing was found, tap the cuts themselves), strike wrong ones → `answers.json` → `paces segment ... --answers answers.json`. The versioned proposal/answers JSON (`load_answers` → `Answers(k, corrections, boundaries)`) is the contract any other UI (or an agent) can speak |
182
183
  | use the video's own chapters | `segment(media, metadata=<yt-dlp info.json>)` |
183
184
  | measure the grid from the media | `segment(local_media, steps=[(name, counts), ...])` — no grid needed; tempo + structure measured, origin estimated and flagged (`pip install paces[audio]`) |
184
185
  | protect edits from regeneration | `apply_edits(doc, patches, by="user:you")` + `merge_regenerated(committed, fresh)` |
@@ -0,0 +1,252 @@
1
+ """The ask segmenter's interaction half: what the machine proposes, and the page
2
+ a human answers it on (issue #10, ``docs/alignment/07 §7.2–7.4``).
3
+
4
+ "Ask the user" is first-class, and the gap between a 30-minute chore and a
5
+ 2-minute confirmation is the *proposal*. So this module is three pure pieces
6
+ around one JSON contract:
7
+
8
+ - :func:`propose` — what the machine pre-computes from a :class:`Segmentation`:
9
+ a proposed step count K with the evidence for it (the cheapest, highest-value
10
+ question comes first, §7.3 step 1), the candidate cuts, the steps, and the
11
+ snap targets a tap lands on (the cuts, plus the grid's unit ticks when there
12
+ is a grid: on rhythmic content a boundary is a downbeat).
13
+ - :func:`render_ask_page` — the default UI: one self-contained static HTML page
14
+ (no server, no framework, no dependency) that asks for K, lets a human tap
15
+ along with the media to add cuts, strike cuts that are wrong and mark steps
16
+ that are not in the media, and exports the answers as JSON.
17
+ - :func:`load_answers` — reads those answers back as :class:`Answers`: the
18
+ step count (only when the human touched it), a :mod:`paces.corrections`
19
+ log, and — when the proposal had nothing to correct (the refused state) —
20
+ the boundaries the human tapped, so the answers re-enter the next
21
+ :func:`~paces.segmenters.segment` run as constraints or as boundaries.
22
+
23
+ **The seam is the contract, not a callback.** Any other UI — the real
24
+ frontend, a CLI prompt, a notebook widget, or an agent with the proposal in
25
+ context (§7.4) — reads the proposal JSON and writes the answers JSON; nothing
26
+ here has to change for it.
27
+
28
+ >>> from paces.segmenters import segment
29
+ >>> proposal = propose(segment(boundaries=[0, 10, 20, 30]))
30
+ >>> proposal["k"], proposal["cuts"]
31
+ (3, ['10', '20'])
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import html
37
+ import json
38
+ import math
39
+ import re
40
+ from collections.abc import Mapping
41
+ from dataclasses import dataclass
42
+ from importlib.resources import files
43
+
44
+ from paces.corrections import (
45
+ DFLT_MERGE_TOLERANCE_S,
46
+ DFLT_MIN_STEP_S,
47
+ Correction,
48
+ normalize_corrections,
49
+ time_str,
50
+ )
51
+ from paces.model import seconds_per_unit
52
+ from paces.segmenters import Segmentation
53
+
54
+ PROPOSAL_KIND = "PacesAskProposal"
55
+ ANSWERS_KIND = "PacesAskAnswers"
56
+ #: The proposal/answers contract version. Both documents carry it; a reader
57
+ #: refuses any other, so a page and a loader of different ages fail loudly.
58
+ CONTRACT_VERSION = 1
59
+
60
+ #: How far a tap may land from a snap target and still snap to it (§7.3 step
61
+ #: 3). It is the merge tolerance on purpose: both absorb the same thing, the
62
+ #: imprecision of a human tapping along, so one number serves both.
63
+ DFLT_SNAP_S = DFLT_MERGE_TOLERANCE_S
64
+ #: Above this many grid ticks the page snaps to cuts only — a dense tick list
65
+ #: would make every tap snap somewhere and bloat the page.
66
+ DFLT_MAX_GRID_TICKS = 2000
67
+
68
+ _ASSETS = files("paces") / "data" / "ask"
69
+ _ANSWERS_KEYS = {"kind", "version", "k", "corrections", "boundaries", "proposal"}
70
+
71
+
72
+ def _grid_ticks(seg: Segmentation, lo: float, hi: float, *, limit: int) -> list[str]:
73
+ """The grid's unit boundaries inside ``[lo, hi]`` (downbeats, for a dance)."""
74
+ grid = seg.grid
75
+ spu = seconds_per_unit(grid) if grid is not None else None
76
+ if not spu or grid.origin is None or hi <= lo:
77
+ return []
78
+ origin = float(grid.origin)
79
+ first, last = math.ceil((lo - origin) / spu), math.floor((hi - origin) / spu)
80
+ if last - first + 1 > limit:
81
+ return []
82
+ return [time_str(origin + n * spu) for n in range(first, last + 1)]
83
+
84
+
85
+ def propose(
86
+ seg: Segmentation,
87
+ *,
88
+ media: str | None = None,
89
+ title: str = "",
90
+ snap_s: float = DFLT_SNAP_S,
91
+ min_step_s: float = DFLT_MIN_STEP_S,
92
+ max_grid_ticks: int = DFLT_MAX_GRID_TICKS,
93
+ ) -> dict:
94
+ """What the machine pre-computes for a human to confirm — a JSON-ready dict.
95
+
96
+ ``k`` is the proposed step count (``None`` when the segmentation has
97
+ nothing to count) and ``k_evidence`` says where it came from, so the human
98
+ confirms a number they can see the reasoning for. Times are decimal
99
+ strings, as everywhere on paces' wire; cuts that are the same millisecond
100
+ are one cut. ``minStepS`` is the narrowest step a split may make
101
+ (:data:`~paces.corrections.DFLT_MIN_STEP_S`): the page ignores a tap
102
+ closer than that to a cut, as replay would.
103
+
104
+ >>> propose(Segmentation(boundaries=(0, 10, 10.0001, 20)))["cuts"]
105
+ ['10']
106
+ """
107
+ cuts = sorted({float(time_str(t)) for t in seg.boundaries})
108
+ interior = cuts[1:-1]
109
+ if seg.steps:
110
+ k, why = (
111
+ len(seg.steps),
112
+ f"{len(seg.steps)} steps found by {seg.method or 'the segmenter'}",
113
+ )
114
+ elif len(cuts) >= 2:
115
+ k, why = len(cuts) - 1, f"{len(cuts)} cuts make {len(cuts) - 1} spans (unnamed)"
116
+ else:
117
+ k, why = None, "nothing to count yet: the segmenter found no cuts"
118
+ extent = (cuts[0], cuts[-1]) if len(cuts) >= 2 else None
119
+ ticks = _grid_ticks(seg, *extent, limit=max_grid_ticks) if extent else []
120
+ return {
121
+ "kind": PROPOSAL_KIND,
122
+ "version": CONTRACT_VERSION,
123
+ "answersKind": ANSWERS_KIND,
124
+ "title": title,
125
+ "media": media,
126
+ "method": seg.method,
127
+ "flags": list(seg.flags),
128
+ "k": k,
129
+ "kEvidence": why,
130
+ "extent": [time_str(t) for t in extent] if extent else None,
131
+ "cuts": [time_str(t) for t in interior],
132
+ "steps": [
133
+ {
134
+ "id": step.id,
135
+ "name": step.name,
136
+ "spans": [[time_str(a), time_str(b)] for a, b in step.spans],
137
+ }
138
+ for step in seg.steps
139
+ ],
140
+ "minStepS": time_str(min_step_s),
141
+ "snap": {
142
+ "windowS": time_str(snap_s),
143
+ "targets": sorted({*map(time_str, interior), *ticks}, key=float),
144
+ },
145
+ }
146
+
147
+
148
+ def render_ask_page(proposal: Mapping, *, author: str = "") -> str:
149
+ """The default ``ask`` UI: a self-contained static HTML page.
150
+
151
+ It posts nothing. The human's answers autosave in the browser (so a
152
+ closed tab loses nothing) and leave as a downloaded ``answers.json`` or
153
+ copied text, which :func:`load_answers` reads. ``author`` is stamped on
154
+ every correction the page emits.
155
+ """
156
+ if proposal.get("kind") != PROPOSAL_KIND:
157
+ raise ValueError(
158
+ f"expected a {PROPOSAL_KIND} (from propose()), got {proposal.get('kind')!r}"
159
+ )
160
+ if proposal.get("version") != CONTRACT_VERSION:
161
+ raise ValueError(
162
+ f"expected a version {CONTRACT_VERSION} proposal, "
163
+ f"got version {proposal.get('version')!r}"
164
+ )
165
+ data = json.dumps({**proposal, "author": author}, ensure_ascii=False)
166
+ # No "<" may reach the data block: a "</script>" (or "<!--") inside a
167
+ # title or flag would end it early. JSON reads "\u003c" back as "<".
168
+ data = data.replace("<", "\\u003c")
169
+ fills = {
170
+ "title": html.escape(proposal.get("title") or "Check the steps"),
171
+ "css": (_ASSETS / "ask.css").read_text(encoding="utf-8"),
172
+ "js": (_ASSETS / "ask.js").read_text(encoding="utf-8"),
173
+ "data": data,
174
+ }
175
+ template = (_ASSETS / "ask.html").read_text(encoding="utf-8")
176
+ # One pass: a filled value is never scanned again, so a title such as
177
+ # "{{js}}" stays text instead of pulling the script into the heading.
178
+ return re.sub(r"\{\{(\w+)\}\}", lambda m: fills[m.group(1)], template)
179
+
180
+
181
+ @dataclass(frozen=True, slots=True)
182
+ class Answers:
183
+ """What a human answered on the ask page, ready for the next run.
184
+
185
+ ``k`` is the step count they gave (``None`` unless they touched it);
186
+ ``corrections`` is a :mod:`paces.corrections` log; ``boundaries`` holds
187
+ the cuts they tapped when the proposal had none to correct (the refused
188
+ state), from 0 to the media's end — ``None`` otherwise.
189
+ """
190
+
191
+ k: int | None
192
+ corrections: tuple[Correction, ...] = ()
193
+ boundaries: tuple[float, ...] | None = None
194
+
195
+
196
+ def _answered_boundaries(value) -> tuple[float, ...] | None:
197
+ if value is None:
198
+ return None
199
+ if not isinstance(value, list) or len(value) < 2:
200
+ raise ValueError(f"boundaries must be a list of 2 or more times, got {value!r}")
201
+ if any(isinstance(t, bool) for t in value):
202
+ raise ValueError(f"boundaries must be times in seconds, got {value!r}")
203
+ try:
204
+ times = tuple(float(time_str(float(t))) for t in value)
205
+ except (TypeError, ValueError):
206
+ raise ValueError(
207
+ f"boundaries must be times in seconds, got {value!r}"
208
+ ) from None
209
+ if not all(math.isfinite(t) and t >= 0 for t in times) or any(
210
+ a >= b for a, b in zip(times, times[1:])
211
+ ):
212
+ raise ValueError(f"boundaries must be increasing times >= 0, got {value!r}")
213
+ return times
214
+
215
+
216
+ def load_answers(answers: Mapping) -> Answers:
217
+ """:class:`Answers` from the page's answers JSON.
218
+
219
+ Validated here, so a bad file fails now and not halfway through a re-run:
220
+ the kind, the contract ``version`` (required), ``k``, every correction,
221
+ and the boundaries.
222
+
223
+ >>> load_answers({"kind": "PacesAskAnswers", "version": 1, "k": 4,
224
+ ... "corrections": [{"verb": "split", "anchorT": "15"}]})
225
+ Answers(k=4, corrections=(Correction(verb='split', anchor_t=15.0, span=None, step_id=None, author='', at='', media_key=None, reason=''),), boundaries=None)
226
+ """
227
+ if not isinstance(answers, Mapping) or answers.get("kind") != ANSWERS_KIND:
228
+ got = (
229
+ answers.get("kind")
230
+ if isinstance(answers, Mapping)
231
+ else type(answers).__name__
232
+ )
233
+ raise ValueError(
234
+ f"expected {ANSWERS_KIND} answers (from the ask page), got {got!r}"
235
+ )
236
+ unknown = sorted(set(answers) - _ANSWERS_KEYS)
237
+ if unknown:
238
+ raise ValueError(f"unknown answers keys {unknown}")
239
+ version = answers.get("version")
240
+ if version != CONTRACT_VERSION:
241
+ raise ValueError(
242
+ f"expected version {CONTRACT_VERSION} answers, got version {version!r} "
243
+ "(re-export them from a page made by this paces)"
244
+ )
245
+ k = answers.get("k")
246
+ if k is not None and (isinstance(k, bool) or not isinstance(k, int) or k < 1):
247
+ raise ValueError(f"k must be a positive integer or null, got {k!r}")
248
+ return Answers(
249
+ k=k,
250
+ corrections=normalize_corrections(answers.get("corrections", [])),
251
+ boundaries=_answered_boundaries(answers.get("boundaries")),
252
+ )
@@ -177,15 +177,21 @@ _WIRE_KEYS = {
177
177
  _REQUIRED_WIRE_KEYS = ("verb", "anchorT")
178
178
 
179
179
 
180
- def _time_str(t: float) -> str:
180
+ def time_str(t: float) -> str:
181
+ """A time as paces' millisecond wire decimal — the one formatter every
182
+ corrections producer shares (the ask page's ``timeStr`` mirrors it).
183
+
184
+ >>> time_str(95.80000001), time_str(10.0625) # exact binary ties round half-even
185
+ ('95.8', '10.062')
186
+ """
181
187
  return decimal_str(t, places=TIME_PLACES)
182
188
 
183
189
 
184
190
  def to_wire(correction: Correction) -> dict:
185
191
  """A JSON-ready dict: camelCase keys, times as decimal strings, defaults omitted."""
186
- wire: dict = {"verb": correction.verb, "anchorT": _time_str(correction.anchor_t)}
192
+ wire: dict = {"verb": correction.verb, "anchorT": time_str(correction.anchor_t)}
187
193
  if correction.span is not None:
188
- wire["span"] = [_time_str(t) for t in correction.span]
194
+ wire["span"] = [time_str(t) for t in correction.span]
189
195
  for field_name in ("step_id", "author", "at", "media_key", "reason"):
190
196
  value = getattr(correction, field_name)
191
197
  if value:
@@ -381,7 +387,7 @@ def _too_close(t: float, cuts: Iterable[float], min_step_s: float) -> str:
381
387
  if gap == 0:
382
388
  return "already a cut there"
383
389
  if gap < min_step_s:
384
- return f"within {_time_str(min_step_s)} s of a cut"
390
+ return f"within {time_str(min_step_s)} s of a cut"
385
391
  return ""
386
392
 
387
393
 
@@ -482,9 +488,13 @@ def _apply_merge(state: _State, correction: Correction, options: _Options):
482
488
  return _merge_cuts(state, correction, options)
483
489
  t, tolerance = correction.anchor_t, options.merge_tolerance_s
484
490
  meets = _meeting_cuts(state.steps)
491
+ if t in state.boundaries and t not in meets:
492
+ # exactly on a cut that joins no two steps (e.g. the edge of an absent
493
+ # step): the human meant THAT cut, so another one nearby is not a match
494
+ return _unmatched(state, correction, "no two steps meet at that exact cut")
485
495
  cut = _nearest(meets, t, tolerance)
486
496
  if cut is None:
487
- within = f"within {_time_str(tolerance)} s"
497
+ within = f"within {time_str(tolerance)} s"
488
498
  if _nearest(state.boundaries, t, tolerance) is None:
489
499
  return _unmatched(state, correction, f"no cut {within}")
490
500
  return _unmatched(state, correction, f"no two steps meet {within}")
@@ -522,9 +532,11 @@ def _merge_cuts(state: _State, correction: Correction, options: _Options):
522
532
  """Merge over a boundaries-only segmentation: remove the interior cut."""
523
533
  tolerance = options.merge_tolerance_s
524
534
  interior = sorted(state.boundaries)[1:-1]
535
+ if correction.anchor_t in state.boundaries and correction.anchor_t not in interior:
536
+ return _unmatched(state, correction, "that is an end, not a cut between steps")
525
537
  cut = _nearest(interior, correction.anchor_t, tolerance)
526
538
  if cut is None:
527
- return _unmatched(state, correction, f"no cut within {_time_str(tolerance)} s")
539
+ return _unmatched(state, correction, f"no cut within {time_str(tolerance)} s")
528
540
  return _done(
529
541
  dataclasses.replace(state, boundaries=state.boundaries - {cut}), correction
530
542
  )
@@ -677,13 +689,13 @@ def _inconsistencies(state: _State) -> tuple[str, ...]:
677
689
  ``boundaries``."""
678
690
  spans = sorted((span, step.id) for step in state.steps for span in step.spans)
679
691
  flags = [
680
- f"inconsistent: step {step_id!r} has an empty span at {_time_str(span[0])}"
692
+ f"inconsistent: step {step_id!r} has an empty span at {time_str(span[0])}"
681
693
  for span, step_id in spans
682
694
  if span[0] >= span[1]
683
695
  ]
684
696
  flags += [
685
697
  f"inconsistent: steps {a_id!r} and {b_id!r} overlap "
686
- f"({_time_str(max(a[0], b[0]))}–{_time_str(min(a[1], b[1]))})"
698
+ f"({time_str(max(a[0], b[0]))}–{time_str(min(a[1], b[1]))})"
687
699
  for k, (a, a_id) in enumerate(spans)
688
700
  for b, b_id in spans[k + 1 :]
689
701
  if a_id != b_id and _overlaps(a, b)
@@ -697,6 +709,6 @@ def _inconsistencies(state: _State) -> tuple[str, ...]:
697
709
  ]
698
710
  missing = sorted(_edges(state.steps) - state.boundaries)
699
711
  if missing:
700
- listed = ", ".join(_time_str(t) for t in missing)
712
+ listed = ", ".join(time_str(t) for t in missing)
701
713
  flags.append(f"inconsistent: step edges not in boundaries: {listed}")
702
714
  return tuple(flags)
@@ -0,0 +1,78 @@
1
+ :root {
2
+ --bg: #fbfaf7;
3
+ --fg: #1d1d1b;
4
+ --muted: #6b6a66;
5
+ --line: #e2dfd8;
6
+ --accent: #2f5d8a;
7
+ --accent-fg: #ffffff;
8
+ --struck: #a33;
9
+ --added: #2d7a46;
10
+ --notice: #f3ecd6;
11
+ }
12
+ @media (prefers-color-scheme: dark) {
13
+ :root {
14
+ --bg: #171716;
15
+ --fg: #ecebe7;
16
+ --muted: #a3a19b;
17
+ --line: #34332f;
18
+ --accent: #7fb0e0;
19
+ --accent-fg: #10161c;
20
+ --struck: #e38080;
21
+ --added: #7fcf98;
22
+ --notice: #3a3424;
23
+ }
24
+ }
25
+ * { box-sizing: border-box; }
26
+ body {
27
+ margin: 0;
28
+ background: var(--bg);
29
+ color: var(--fg);
30
+ font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
31
+ }
32
+ main { max-width: 44rem; margin: 0 auto; padding: 1rem 16px 4rem; }
33
+ h1 { font-size: 1.5rem; margin: 0.5rem 0 0.25rem; }
34
+ h2 { font-size: 1.1rem; margin: 2rem 0 0.5rem; }
35
+ .muted { color: var(--muted); }
36
+ .small { font-size: 0.875rem; }
37
+ .notice { background: var(--notice); padding: 0.5rem 0.75rem; border-radius: 6px; }
38
+ section { border-top: 1px solid var(--line); }
39
+ video { width: 100%; aspect-ratio: 16 / 9; max-height: 50vh; background: #000; border-radius: 6px; }
40
+ label.k { display: inline-flex; gap: 0.5rem; align-items: center; font-size: 1.25rem; }
41
+ label.k input { width: 5rem; font-size: 1.25rem; padding: 0.25rem 0.5rem; }
42
+ button {
43
+ font: inherit;
44
+ min-height: 44px;
45
+ padding: 0.5rem 1rem;
46
+ border: 1px solid var(--line);
47
+ border-radius: 6px;
48
+ background: transparent;
49
+ color: var(--fg);
50
+ cursor: pointer;
51
+ touch-action: manipulation;
52
+ transition: transform 80ms ease-out, opacity 80ms ease-out;
53
+ }
54
+ button[data-pressed] { transform: scale(0.97); opacity: 0.8; }
55
+ button:disabled { opacity: 0.4; cursor: not-allowed; }
56
+ button.primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
57
+ button.link { border: 0; min-height: 0; padding: 0; text-decoration: underline; }
58
+ /* a time in the cut list is a tap target too: the 44px floor holds for it */
59
+ button.link.time { min-height: 44px; min-width: 44px; padding: 0 0.25rem; text-align: left; }
60
+ @media (prefers-reduced-motion: reduce) { button { transition: none; } }
61
+ kbd { font-size: 0.75em; opacity: 0.8; margin-left: 0.25rem; }
62
+ #tap { width: 100%; margin: 0.75rem 0 0.25rem; font-size: 1.1rem; }
63
+ .rows { list-style: none; padding: 0; margin: 0.5rem 0; }
64
+ .rows li {
65
+ display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
66
+ padding: 0.25rem 0; border-bottom: 1px solid var(--line);
67
+ }
68
+ .rows li .grow { flex: 1 1 10rem; }
69
+ .rows li.struck .time { text-decoration: line-through; color: var(--struck); }
70
+ .rows li.added .time { color: var(--added); font-weight: 600; }
71
+ .rows li.fresh { animation: fresh 600ms ease-out; }
72
+ @keyframes fresh { from { background: var(--notice); } to { background: transparent; } }
73
+ @media (prefers-reduced-motion: reduce) { .rows li.fresh { animation: none; } }
74
+ .time { font-variant-numeric: tabular-nums; min-width: 4.5rem; }
75
+ .actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
76
+ textarea { width: 100%; font: 0.8rem/1.4 ui-monospace, monospace; margin-top: 0.5rem; }
77
+ #status { position: sticky; bottom: 0; background: var(--bg); border-top: 1px solid var(--line); min-height: 44px; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
78
+ .notice button.link { min-height: 44px; }
@@ -0,0 +1,75 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{{title}}</title>
7
+ <style>
8
+ {{css}}
9
+ </style>
10
+ </head>
11
+ <body>
12
+ <main>
13
+ <header>
14
+ <h1>{{title}}</h1>
15
+ <p class="muted" id="method"></p>
16
+ <p class="notice" id="restored" hidden>
17
+ <span id="restored-text"></span>
18
+ <button type="button" id="start-over" class="link">Start over</button>
19
+ </p>
20
+ </header>
21
+
22
+ <section aria-labelledby="q-k">
23
+ <h2 id="q-k">1. How many steps are there?</h2>
24
+ <p class="muted" id="k-evidence"></p>
25
+ <label class="k">
26
+ <input id="k" type="number" min="1" step="1" inputmode="numeric">
27
+ <span>steps</span>
28
+ </label>
29
+ <p class="muted small" id="k-state"></p>
30
+ </section>
31
+
32
+ <section aria-labelledby="q-cuts">
33
+ <h2 id="q-cuts">2. Where does each step start?</h2>
34
+ <div id="player" hidden>
35
+ <video id="video" controls playsinline preload="metadata"></video>
36
+ <p id="media-state" class="muted small" hidden></p>
37
+ </div>
38
+ <p id="no-media" class="muted small">
39
+ <span id="no-media-text">Open the video to tap along with it:</span>
40
+ <input id="file" type="file" accept="video/*,audio/*">
41
+ </p>
42
+ <button type="button" id="tap" class="primary" disabled>Cut here <kbd>Space</kbd></button>
43
+ <p class="muted small">Tap at each change of step while it plays. A tap near a proposed cut or a beat snaps to it.</p>
44
+ <ol id="cuts" class="rows" tabindex="-1"></ol>
45
+ </section>
46
+
47
+ <section aria-labelledby="q-steps">
48
+ <h2 id="q-steps">3. Is any of these not in the video?</h2>
49
+ <ol id="steps" class="rows" tabindex="-1"></ol>
50
+ </section>
51
+
52
+ <section aria-labelledby="q-send">
53
+ <h2 id="q-send">4. Send the answers back</h2>
54
+ <p class="muted small">Your answers are kept in this browser as you go. To use them, download the file and pass it to the next run: <code>paces segment … --answers answers.json</code></p>
55
+ <div class="actions">
56
+ <button type="button" id="download" class="primary">Download answers.json</button>
57
+ <button type="button" id="copy">Copy</button>
58
+ </div>
59
+ <details>
60
+ <summary>Show the answers</summary>
61
+ <textarea id="answers" readonly rows="10"></textarea>
62
+ </details>
63
+ </section>
64
+
65
+ <p id="status" role="status" aria-live="polite">
66
+ <span id="status-text"></span>
67
+ <button type="button" id="undo" hidden>Undo</button>
68
+ </p>
69
+ </main>
70
+ <script type="application/json" id="proposal">{{data}}</script>
71
+ <script>
72
+ {{js}}
73
+ </script>
74
+ </body>
75
+ </html>