paces 0.0.16__tar.gz → 0.0.17__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.16 → paces-0.0.17}/PKG-INFO +2 -1
- {paces-0.0.16 → paces-0.0.17}/README.md +1 -0
- paces-0.0.17/paces/ask.py +252 -0
- {paces-0.0.16 → paces-0.0.17}/paces/corrections.py +21 -9
- paces-0.0.17/paces/data/ask/ask.css +78 -0
- paces-0.0.17/paces/data/ask/ask.html +75 -0
- paces-0.0.17/paces/data/ask/ask.js +499 -0
- {paces-0.0.16 → paces-0.0.17}/paces/tools.py +47 -3
- {paces-0.0.16 → paces-0.0.17}/pyproject.toml +1 -1
- paces-0.0.17/tests/test_ask.py +541 -0
- {paces-0.0.16 → paces-0.0.17}/tests/test_corrections.py +25 -0
- {paces-0.0.16 → paces-0.0.17}/.gitignore +0 -0
- {paces-0.0.16 → paces-0.0.17}/LICENSE +0 -0
- {paces-0.0.16 → paces-0.0.17}/docs/README.md +0 -0
- {paces-0.0.16 → paces-0.0.17}/docs/alignment/README.md +0 -0
- {paces-0.0.16 → paces-0.0.17}/docs/poc-reference/README.md +0 -0
- {paces-0.0.16 → paces-0.0.17}/docs/poc-reference/artifacts/clips.json +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/__init__.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/__main__.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/bodies/__init__.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/bodies/_base.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/bodies/derivation.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/bodies/document.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/bodies/signal.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/bodies/step.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/cues.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/derivation.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/edits.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/evidence.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/excerpts.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/measure.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/model.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/pose.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/projection.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/render.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/segmenters.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/paces/timed_text.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/audio_synth.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/data/body_schemas.json +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/data/routine.json +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/poc_fixture.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/test_chapters.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/test_cli_surface.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/test_cues.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/test_derivation.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/test_edit_protection_residuals.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/test_edits.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/test_evidence.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/test_evidence_schemas.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/test_excerpts.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/test_measure.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/test_pose.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/test_render_media.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/test_roundtrip_poc.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/test_segment.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/test_smoke.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/tests/test_vertical_slice.py +0 -0
- {paces-0.0.16 → paces-0.0.17}/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.17
|
|
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
|
|
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":
|
|
192
|
+
wire: dict = {"verb": correction.verb, "anchorT": time_str(correction.anchor_t)}
|
|
187
193
|
if correction.span is not None:
|
|
188
|
-
wire["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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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"({
|
|
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(
|
|
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>
|