paces 0.0.5__tar.gz → 0.0.6__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 (35) hide show
  1. {paces-0.0.5 → paces-0.0.6}/PKG-INFO +2 -1
  2. {paces-0.0.5 → paces-0.0.6}/README.md +1 -0
  3. {paces-0.0.5 → paces-0.0.6}/paces/__init__.py +3 -0
  4. paces-0.0.6/paces/excerpts.py +154 -0
  5. {paces-0.0.5 → paces-0.0.6}/paces/measure.py +29 -1
  6. {paces-0.0.5 → paces-0.0.6}/paces/tools.py +34 -0
  7. {paces-0.0.5 → paces-0.0.6}/pyproject.toml +16 -1
  8. paces-0.0.6/tests/test_excerpts.py +276 -0
  9. paces-0.0.6/tests/test_vertical_slice.py +224 -0
  10. paces-0.0.6/tests/video_synth.py +74 -0
  11. paces-0.0.5/tests/video_synth.py +0 -32
  12. {paces-0.0.5 → paces-0.0.6}/.gitignore +0 -0
  13. {paces-0.0.5 → paces-0.0.6}/LICENSE +0 -0
  14. {paces-0.0.5 → paces-0.0.6}/docs/README.md +0 -0
  15. {paces-0.0.5 → paces-0.0.6}/docs/alignment/README.md +0 -0
  16. {paces-0.0.5 → paces-0.0.6}/docs/poc-reference/README.md +0 -0
  17. {paces-0.0.5 → paces-0.0.6}/docs/poc-reference/artifacts/clips.json +0 -0
  18. {paces-0.0.5 → paces-0.0.6}/paces/__main__.py +0 -0
  19. {paces-0.0.5 → paces-0.0.6}/paces/derivation.py +0 -0
  20. {paces-0.0.5 → paces-0.0.6}/paces/edits.py +0 -0
  21. {paces-0.0.5 → paces-0.0.6}/paces/model.py +0 -0
  22. {paces-0.0.5 → paces-0.0.6}/paces/projection.py +0 -0
  23. {paces-0.0.5 → paces-0.0.6}/paces/render.py +0 -0
  24. {paces-0.0.5 → paces-0.0.6}/paces/segmenters.py +0 -0
  25. {paces-0.0.5 → paces-0.0.6}/tests/audio_synth.py +0 -0
  26. {paces-0.0.5 → paces-0.0.6}/tests/data/routine.json +0 -0
  27. {paces-0.0.5 → paces-0.0.6}/tests/poc_fixture.py +0 -0
  28. {paces-0.0.5 → paces-0.0.6}/tests/test_chapters.py +0 -0
  29. {paces-0.0.5 → paces-0.0.6}/tests/test_derivation.py +0 -0
  30. {paces-0.0.5 → paces-0.0.6}/tests/test_edits.py +0 -0
  31. {paces-0.0.5 → paces-0.0.6}/tests/test_measure.py +0 -0
  32. {paces-0.0.5 → paces-0.0.6}/tests/test_render_media.py +0 -0
  33. {paces-0.0.5 → paces-0.0.6}/tests/test_roundtrip_poc.py +0 -0
  34. {paces-0.0.5 → paces-0.0.6}/tests/test_segment.py +0 -0
  35. {paces-0.0.5 → paces-0.0.6}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: paces
3
- Version: 0.0.5
3
+ Version: 0.0.6
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
@@ -81,6 +81,7 @@ Same thing from the shell:
81
81
  ```bash
82
82
  paces segment VIDEO_URL --steps steps.json --grid grid.json --output seg.json
83
83
  paces to-document seg.json --source VIDEO_URL --title "My routine" --output document.json
84
+ paces suggest-excerpts document.json --output document.json # mark each block's loop window
84
85
  paces derive document.json --media routine.mp4 # real loop clips + gifs + posters
85
86
  paces render document.json --output page.html
86
87
  ```
@@ -47,6 +47,7 @@ Same thing from the shell:
47
47
  ```bash
48
48
  paces segment VIDEO_URL --steps steps.json --grid grid.json --output seg.json
49
49
  paces to-document seg.json --source VIDEO_URL --title "My routine" --output document.json
50
+ paces suggest-excerpts document.json --output document.json # mark each block's loop window
50
51
  paces derive document.json --media routine.mp4 # real loop clips + gifs + posters
51
52
  paces render document.json --output page.html
52
53
  ```
@@ -59,6 +59,7 @@ from paces.derivation import (
59
59
  resolve_crop_box,
60
60
  )
61
61
  from paces.edits import apply_edits, merge_regenerated
62
+ from paces.excerpts import ExcerptResult, suggest_excerpts
62
63
  from paces.measure import GridMeasurement, measure_grid
63
64
  from paces.projection import to_document
64
65
  from paces.render import render_html
@@ -102,6 +103,8 @@ __all__ = [
102
103
  "GridMeasurement",
103
104
  "to_document",
104
105
  # media derivation (issue #1, ADR-0005)
106
+ "suggest_excerpts",
107
+ "ExcerptResult",
105
108
  "derive_document",
106
109
  "DeriveResult",
107
110
  "DirStore",
@@ -0,0 +1,154 @@
1
+ """Excerpt suggestion: mark the loopable sub-window each step's clip is cut
2
+ from.
3
+
4
+ A :class:`~paces.model.SourceSpan` with an ``excerpt`` is what media
5
+ derivation (ADR-0005) cuts; nothing upstream sets one. This module holds the
6
+ v1 heuristic (issue #3's cost note): **a step's excerpt is its own grid
7
+ window** — the span itself — or, with ``units=``, its first N metric units.
8
+ Editorial window-picking (the POC spent ~11 min of LLM agents over contact
9
+ sheets choosing windows by eye) is a later capability that replaces this
10
+ default behind the same seam; corrections meanwhile are ordinary document
11
+ edits, which a suggestion must never eat:
12
+
13
+ - an existing excerpt is only replaced under ``overwrite=True``, and even
14
+ then only by a real new suggestion — **never by nothing** (a heuristic
15
+ that cannot compute a window keeps what the human picked, with a flag);
16
+ - a step carrying any span-level lock that could denote an excerpt gets
17
+ **no suggestions at all**: lock paths are recorded in index form and a
18
+ regeneration merge reorders spans (``edits.py``'s own premise), so "which
19
+ span does ``/spans/0/excerpt`` mean now?" cannot be answered here —
20
+ abstaining loses a default; guessing can silently undo a locked edit.
21
+
22
+ Honesty flags over silence: every span this module skips or cannot fill is
23
+ flagged by name.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import math
29
+ import re
30
+ from dataclasses import dataclass, field
31
+ from fractions import Fraction
32
+
33
+ from paces.model import (
34
+ SourceSpan,
35
+ Step,
36
+ StepDocument,
37
+ decimal_str,
38
+ seconds_per_unit,
39
+ )
40
+
41
+ #: The span role a clip is cut from by default — the at-tempo run-through.
42
+ DFLT_EXCERPT_ROLE = "performance"
43
+
44
+ #: A lock path that could denote a span's excerpt: the whole spans list, a
45
+ #: whole span, or anything mentioning an excerpt. A narrower span field
46
+ #: (caption, label) denotes that field under any reordering and never blocks.
47
+ _SPAN_LOCK = re.compile(r"^/spans(/\d+)?$|^/spans/.*excerpt")
48
+
49
+
50
+ @dataclass
51
+ class ExcerptResult:
52
+ """The updated document plus the honesty flags."""
53
+
54
+ document: StepDocument
55
+ flags: list[str] = field(default_factory=list)
56
+ suggested: list[str] = field(default_factory=list) # step ids touched
57
+
58
+
59
+ def _walk_steps(steps: list[Step]):
60
+ for step in steps:
61
+ yield step
62
+ yield from _walk_steps(step.steps)
63
+
64
+
65
+ def _excerpts_locked(step: Step) -> bool:
66
+ return any(_SPAN_LOCK.search(lock.path) for lock in step.locks)
67
+
68
+
69
+ def suggest_excerpts(
70
+ document: StepDocument,
71
+ *,
72
+ units: float | None = None,
73
+ role: str = DFLT_EXCERPT_ROLE,
74
+ overwrite: bool = False,
75
+ ) -> ExcerptResult:
76
+ """Fill each step's first *role* span with a suggested ``excerpt``.
77
+
78
+ Args:
79
+ document: the committed document (worked on a deep copy).
80
+ units: ``None`` (default) → the excerpt is the span's whole window
81
+ (``start``..``end`` — for grid-placed segmentation that IS the
82
+ block's grid window, which is issue #3's acceptance). A number →
83
+ the first that-many metric units from the span's start, computed
84
+ through the document's grid and clipped to the span's end.
85
+ role: which span carries the clip (default ``"performance"`` — the
86
+ run-through). A step whose spans lack the role is flagged; a
87
+ step with SEVERAL role spans gets a suggestion on the first and
88
+ a ``multiple-<role>-spans:`` flag for the rest (the POC's b8
89
+ shape — the other moments still derive once excerpts are set by
90
+ hand or a future picker).
91
+ overwrite: replace existing excerpts with new suggestions. Off by
92
+ default; replacement never degrades to deletion, and a span-
93
+ locked step abstains entirely (see the module docstring).
94
+ """
95
+ if units is not None:
96
+ if not math.isfinite(units) or units <= 0:
97
+ raise ValueError(f"units must be a positive number, got {units}")
98
+ doc = document.model_copy(deep=True)
99
+ result = ExcerptResult(document=doc)
100
+ flags = result.flags
101
+
102
+ spu = None
103
+ if units is not None:
104
+ grid = doc.metric
105
+ spu = seconds_per_unit(grid) if grid is not None else None
106
+ if spu is None:
107
+ flags.append(
108
+ "no-grid: units= needs a metric grid with a tempo; "
109
+ "use units=None (the span's whole window) instead"
110
+ )
111
+ return result
112
+
113
+ for step in _walk_steps(doc.steps):
114
+ if not step.spans:
115
+ continue
116
+ role_spans = [span for span in step.spans if span.role == role]
117
+ if not role_spans:
118
+ flags.append(f"no-{role}-span:{step.id}")
119
+ continue
120
+ if len(role_spans) > 1:
121
+ flags.append(f"multiple-{role}-spans:{step.id}")
122
+ if _excerpts_locked(step):
123
+ flags.append(f"locked-excerpt-kept:{step.id}")
124
+ continue
125
+ span = role_spans[0]
126
+ if span.excerpt is not None and not overwrite:
127
+ continue
128
+ suggestion = _suggest_for_span(span, units=units, spu=spu)
129
+ if suggestion is None:
130
+ # nothing computable: keep whatever is there — replacement must
131
+ # never degrade to deletion — and say so
132
+ flags.append(f"no-extent:{step.id}")
133
+ continue
134
+ span.excerpt = suggestion
135
+ result.suggested.append(step.id)
136
+ return result
137
+
138
+
139
+ def _suggest_for_span(
140
+ span: SourceSpan, *, units: float | None, spu: float | None
141
+ ) -> tuple[str, str] | None:
142
+ """One span's suggested window, as exact wire decimals — or ``None``
143
+ when the span records no extent to cut (inventing one would be data)."""
144
+ if units is None:
145
+ if span.end is None:
146
+ return None
147
+ return (span.start, span.end)
148
+ end_s = float(Fraction(span.start)) + units * spu
149
+ if span.end is not None:
150
+ end_s = min(end_s, float(Fraction(span.end)))
151
+ end = decimal_str(end_s)
152
+ if Fraction(end) <= Fraction(span.start):
153
+ return None
154
+ return (span.start, end)
@@ -84,6 +84,34 @@ class GridMeasurement:
84
84
  evidence: Mapping[str, Any] = field(default_factory=dict)
85
85
 
86
86
 
87
+ def _load_audio(path, *, sample_rate):
88
+ """Decode *path* to ``(samples, rate)`` — wav-family natively, anything
89
+ else through pydub's ffmpeg decode to a temp wav.
90
+
91
+ librosa reads only what soundfile can open (wav/flac/ogg); for an .mp4
92
+ or .mp3 it silently falls back to its DEPRECATED audioread path when
93
+ that package happens to be installed, and raises when it is not — which
94
+ is exactly how this worked on a dev machine and failed on CI. Routing
95
+ non-wav through pydub gives one decode path (the same ffmpeg pydub
96
+ already uses for ``find_segments`` on the same file) that survives
97
+ librosa 1.0.
98
+ """
99
+ import librosa
100
+
101
+ try:
102
+ return librosa.load(str(path), sr=sample_rate)
103
+ except Exception:
104
+ import tempfile
105
+
106
+ from pydub import AudioSegment
107
+
108
+ audio = AudioSegment.from_file(str(path))
109
+ with tempfile.TemporaryDirectory() as tmp:
110
+ wav_path = Path(tmp) / "decoded.wav"
111
+ audio.export(str(wav_path), format="wav")
112
+ return librosa.load(str(wav_path), sr=sample_rate)
113
+
114
+
87
115
  def _measured_tempo(beat_grid_result) -> tuple[str | None, float | None]:
88
116
  """The tempo as a wire decimal, or None when there is no beat structure.
89
117
 
@@ -168,7 +196,7 @@ def measure_grid(
168
196
  if music:
169
197
  region = max(music, key=lambda s: s.end - s.start)
170
198
  try:
171
- samples, rate = librosa.load(str(path), sr=sample_rate)
199
+ samples, rate = _load_audio(path, sample_rate=sample_rate)
172
200
  except Exception as error:
173
201
  raise MediaDecodeError(
174
202
  f"could not decode {media!r} as audio ({type(error).__name__})"
@@ -15,6 +15,7 @@ from pathlib import Path
15
15
 
16
16
  from paces import (
17
17
  edits as edits_module,
18
+ excerpts as excerpts_module,
18
19
  model,
19
20
  projection,
20
21
  render as render_module,
@@ -402,6 +403,38 @@ def derive(
402
403
  }
403
404
 
404
405
 
406
+ def suggest_excerpts(
407
+ document,
408
+ *,
409
+ units: float | None = None,
410
+ role: str = excerpts_module.DFLT_EXCERPT_ROLE,
411
+ overwrite: bool = False,
412
+ output: str | None = None,
413
+ ) -> dict:
414
+ """Mark each step's loopable sub-window (the ``excerpt`` derive cuts).
415
+
416
+ v1 heuristic (issue #3): the excerpt is the span's whole grid window;
417
+ ``units=N`` takes the first N metric units instead. Never overwrites an
418
+ existing excerpt unless ``overwrite`` (and never a locked one). Returns
419
+ ``{"document", "flags", "suggested"}``; writes to ``output=`` when given.
420
+ """
421
+ result = excerpts_module.suggest_excerpts(
422
+ _as_document(document),
423
+ # the CLI hands numbers over as strings
424
+ units=None if units is None else float(units),
425
+ role=role,
426
+ overwrite=overwrite,
427
+ )
428
+ text = model.dumps_document(result.document)
429
+ if output:
430
+ Path(output).write_text(text, encoding="utf-8")
431
+ return {
432
+ "document": json.loads(text),
433
+ "flags": result.flags,
434
+ "suggested": result.suggested,
435
+ }
436
+
437
+
405
438
  def list_segmenters() -> dict:
406
439
  """The registered segmentation capabilities: name → what it needs/gives."""
407
440
  return {
@@ -420,6 +453,7 @@ _dispatch_funcs = [
420
453
  segment,
421
454
  to_document,
422
455
  render,
456
+ suggest_excerpts,
423
457
  derive,
424
458
  edit,
425
459
  merge,
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "paces"
9
- version = "0.0.5"
9
+ version = "0.0.6"
10
10
  description = "Turn instructional media into structured, interactive learning material"
11
11
  readme = "README.md"
12
12
  license = "MIT"
@@ -78,6 +78,21 @@ docs = [
78
78
  "sphinx-rtd-theme>=1.0",
79
79
  ]
80
80
 
81
+ # System ffmpeg (ADR-0005 §2's trigger, fired by the vertical slice test:
82
+ # measure_grid on an .mp4 goes through pydub/librosa's PATH-ffmpeg, which the
83
+ # pip-bundled imageio-ffmpeg binary cannot serve). Consumed by wads CI's
84
+ # install-system-deps on the Linux AND Windows legs; install.macos serves
85
+ # local dev. Shape mirrors mixing's block.
86
+ [tool.wads.ops.ffmpeg]
87
+ description = "Multimedia framework for video/audio processing"
88
+ url = "https://ffmpeg.org/"
89
+ check.linux = "which ffmpeg"
90
+ check.macos = "which ffmpeg"
91
+ check.windows = "which ffmpeg"
92
+ install.linux = ["sudo apt-get update", "sudo apt-get install -y ffmpeg"]
93
+ install.macos = "brew install ffmpeg"
94
+ install.windows = "choco install ffmpeg -y --no-progress"
95
+
81
96
  [tool.hatch.build.targets.wheel]
82
97
  packages = ["paces"]
83
98
 
@@ -0,0 +1,276 @@
1
+ """Excerpt suggestion (issue #3's v1 heuristic) — pure, no media."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import pytest
8
+
9
+ from paces.excerpts import suggest_excerpts
10
+ from paces.model import (
11
+ Lock,
12
+ Measure,
13
+ MetricGrid,
14
+ Source,
15
+ SourceSpan,
16
+ Step,
17
+ StepDocument,
18
+ dumps_document,
19
+ )
20
+
21
+ GRID = MetricGrid(unit="eight", subdivisions=8, tempo_bpm="120", origin="10")
22
+ SPU = 4.0 # 8 * 60 / 120
23
+
24
+
25
+ def _doc(steps, *, metric=GRID):
26
+ return StepDocument(
27
+ id="routine",
28
+ title="Routine",
29
+ metric=metric,
30
+ sources=[Source(id="perf", kind="video", uri="https://x")],
31
+ steps=steps,
32
+ )
33
+
34
+
35
+ def _step(step_id="b1", *, spans=None, **kwargs):
36
+ return Step(
37
+ id=step_id,
38
+ name="Step",
39
+ duration=Measure(value="2", unit="eight"),
40
+ spans=spans
41
+ if spans is not None
42
+ else [SourceSpan(source="perf", start="10", end="18")],
43
+ **kwargs,
44
+ )
45
+
46
+
47
+ def test_default_is_the_spans_whole_window():
48
+ result = suggest_excerpts(_doc([_step()]))
49
+ (span,) = result.document.steps[0].spans
50
+ assert span.excerpt == ("10", "18")
51
+ assert result.suggested == ["b1"] and result.flags == []
52
+
53
+
54
+ def test_units_takes_the_first_n_grid_units():
55
+ result = suggest_excerpts(_doc([_step()]), units=1)
56
+ (span,) = result.document.steps[0].spans
57
+ assert span.excerpt == ("10", "14") # 1 unit at 4 s/unit
58
+
59
+
60
+ def test_units_is_clipped_to_the_spans_end():
61
+ result = suggest_excerpts(_doc([_step()]), units=5)
62
+ (span,) = result.document.steps[0].spans
63
+ assert span.excerpt == ("10", "18") # 5 x 4 = 20s, clipped at end
64
+
65
+
66
+ def test_units_without_a_grid_flags_and_suggests_nothing():
67
+ result = suggest_excerpts(_doc([_step()], metric=None), units=1)
68
+ assert any(f.startswith("no-grid") for f in result.flags)
69
+ assert result.document.steps[0].spans[0].excerpt is None
70
+ with pytest.raises(ValueError, match="positive"):
71
+ suggest_excerpts(_doc([_step()]), units=0)
72
+
73
+
74
+ def test_deep_link_span_with_units_gets_a_window():
75
+ # end=None ("starts here, extent not recorded") + units: the grid knows
76
+ step = _step(spans=[SourceSpan(source="perf", start="10")])
77
+ result = suggest_excerpts(_doc([step]), units=2)
78
+ assert result.document.steps[0].spans[0].excerpt == ("10", "18")
79
+
80
+
81
+ def test_deep_link_span_without_units_is_flagged_not_invented():
82
+ step = _step(spans=[SourceSpan(source="perf", start="10")])
83
+ result = suggest_excerpts(_doc([step]))
84
+ assert result.document.steps[0].spans[0].excerpt is None
85
+ assert "no-extent:b1" in result.flags
86
+
87
+
88
+ def test_existing_excerpt_is_never_eaten_by_default():
89
+ step = _step(
90
+ spans=[SourceSpan(source="perf", start="10", end="18", excerpt=("11", "13"))]
91
+ )
92
+ result = suggest_excerpts(_doc([step]))
93
+ assert result.document.steps[0].spans[0].excerpt == ("11", "13")
94
+ assert result.suggested == []
95
+ overwritten = suggest_excerpts(_doc([step]), overwrite=True)
96
+ assert overwritten.document.steps[0].spans[0].excerpt == ("10", "18")
97
+
98
+
99
+ def test_locked_excerpt_survives_even_overwrite():
100
+ step = _step(
101
+ spans=[SourceSpan(source="perf", start="10", end="18", excerpt=("11", "13"))],
102
+ locks=[
103
+ Lock(path="/spans/0/excerpt", by="user:thor", at="2026-08-31T00:00:00Z")
104
+ ],
105
+ )
106
+ result = suggest_excerpts(_doc([step]), overwrite=True)
107
+ assert result.document.steps[0].spans[0].excerpt == ("11", "13")
108
+ assert "locked-excerpt-kept:b1" in result.flags
109
+
110
+
111
+ def test_missing_role_is_flagged_and_role_is_selectable():
112
+ step = _step(
113
+ spans=[SourceSpan(source="perf", role="instruction", start="10", end="18")]
114
+ )
115
+ result = suggest_excerpts(_doc([step]))
116
+ assert "no-performance-span:b1" in result.flags
117
+ by_role = suggest_excerpts(_doc([step]), role="instruction")
118
+ assert by_role.document.steps[0].spans[0].excerpt == ("10", "18")
119
+
120
+
121
+ def test_child_steps_are_walked_and_the_original_is_untouched():
122
+ child = _step("b1-1", spans=[SourceSpan(source="perf", start="10", end="14")])
123
+ parent = _step("b1", spans=[])
124
+ parent.steps.append(child)
125
+ doc = _doc([parent])
126
+ result = suggest_excerpts(doc)
127
+ assert result.document.steps[0].steps[0].spans[0].excerpt == ("10", "14")
128
+ assert doc.steps[0].steps[0].spans[0].excerpt is None # deep copy
129
+
130
+
131
+ def test_wire_stays_float_free():
132
+ def fail(value):
133
+ raise AssertionError(f"float on the wire: {value}")
134
+
135
+ grid = MetricGrid(unit="eight", subdivisions=8, tempo_bpm="129.2", origin="51.2")
136
+ step = _step(spans=[SourceSpan(source="perf", start="95.8")])
137
+ result = suggest_excerpts(_doc([step], metric=grid), units=1.4)
138
+ json.loads(dumps_document(result.document), parse_float=fail)
139
+
140
+
141
+ # ── review-round regressions ────────────────────────────────────────────────
142
+
143
+
144
+ def test_overwrite_never_degrades_to_deletion():
145
+ # the review's blocker: overwrite=True on a span the heuristic cannot
146
+ # window (end=None, units=None) must KEEP the hand excerpt, not None it
147
+ step = _step(spans=[SourceSpan(source="perf", start="10", excerpt=("11", "13"))])
148
+ result = suggest_excerpts(_doc([step]), overwrite=True)
149
+ assert result.document.steps[0].spans[0].excerpt == ("11", "13")
150
+ assert "no-extent:b1" in result.flags and result.suggested == []
151
+
152
+
153
+ def test_tiny_units_collapse_keeps_the_existing_excerpt():
154
+ # decimal rounding can collapse the window to zero length — same rule
155
+ step = _step(
156
+ spans=[SourceSpan(source="perf", start="10", end="18", excerpt=("11", "13"))]
157
+ )
158
+ result = suggest_excerpts(_doc([step]), units=0.0001, overwrite=True)
159
+ assert result.document.steps[0].spans[0].excerpt == ("11", "13")
160
+ assert "no-extent:b1" in result.flags
161
+
162
+
163
+ def test_span_locks_abstain_even_when_reordered():
164
+ # the review's other blocker: lock paths are index-form and a merge
165
+ # reorders spans, so "/spans/0/excerpt" may denote a DIFFERENT span now.
166
+ # The suggestion abstains for the whole step rather than guess.
167
+ step = _step(
168
+ spans=[
169
+ SourceSpan(source="perf", role="instruction", start="5", end="9"),
170
+ SourceSpan(source="perf", start="10", end="18"), # role span at 1
171
+ ],
172
+ locks=[
173
+ Lock(path="/spans/0/excerpt", by="user:thor", at="2026-08-31T00:00:00Z")
174
+ ],
175
+ )
176
+ result = suggest_excerpts(_doc([step]), overwrite=True)
177
+ assert all(span.excerpt is None for span in result.document.steps[0].spans)
178
+ assert "locked-excerpt-kept:b1" in result.flags
179
+
180
+
181
+ def test_whole_span_lock_protects_its_excerpt():
182
+ # `paces edit` on a whole span records Lock(path="/spans/0") — the merge
183
+ # honors it for the whole span, so suggestion must too
184
+ step = _step(
185
+ spans=[SourceSpan(source="perf", start="10", end="18", excerpt=("11", "13"))],
186
+ locks=[Lock(path="/spans/0", by="user:thor", at="2026-08-31T00:00:00Z")],
187
+ )
188
+ result = suggest_excerpts(_doc([step]), overwrite=True)
189
+ assert result.document.steps[0].spans[0].excerpt == ("11", "13")
190
+ assert "locked-excerpt-kept:b1" in result.flags
191
+
192
+
193
+ def test_locked_hand_cleared_excerpt_stays_cleared():
194
+ # a hand-CLEARED excerpt (edit to None writes a lock) must not be refilled
195
+ step = _step(
196
+ spans=[SourceSpan(source="perf", start="10", end="18")],
197
+ locks=[
198
+ Lock(path="/spans/0/excerpt", by="user:thor", at="2026-08-31T00:00:00Z")
199
+ ],
200
+ )
201
+ result = suggest_excerpts(_doc([step]))
202
+ assert result.document.steps[0].spans[0].excerpt is None
203
+ assert "locked-excerpt-kept:b1" in result.flags
204
+
205
+
206
+ def test_caption_lock_does_not_block_suggestion():
207
+ # a narrower span-field lock denotes that field under any reordering
208
+ step = _step(
209
+ locks=[Lock(path="/spans/0/caption", by="user:thor", at="2026-08-31T00:00:00Z")]
210
+ )
211
+ result = suggest_excerpts(_doc([step]))
212
+ assert result.document.steps[0].spans[0].excerpt == ("10", "18")
213
+
214
+
215
+ def test_multiple_role_spans_fill_first_and_flag_the_rest():
216
+ # the POC's b8 shape: two performance moments on one step. The first
217
+ # gets the suggestion; the second is FLAGGED, never silently skipped.
218
+ step = _step(
219
+ "b8",
220
+ spans=[
221
+ SourceSpan(source="perf", start="10", end="18"),
222
+ SourceSpan(source="perf", start="120", end="128"),
223
+ ],
224
+ )
225
+ result = suggest_excerpts(_doc([step]))
226
+ first, second = result.document.steps[0].spans
227
+ assert first.excerpt == ("10", "18") and second.excerpt is None
228
+ assert "multiple-performance-spans:b8" in result.flags
229
+
230
+
231
+ def test_every_top_level_step_is_suggested():
232
+ # kills the walk-only-the-last-step mutant the e2e's membership assert let by
233
+ steps = [
234
+ _step("b1", spans=[SourceSpan(source="perf", start="0", end="8")]),
235
+ _step("b2", spans=[SourceSpan(source="perf", start="8", end="16")]),
236
+ _step("b3", spans=[SourceSpan(source="perf", start="16", end="24")]),
237
+ ]
238
+ result = suggest_excerpts(_doc(steps))
239
+ assert result.suggested == ["b1", "b2", "b3"]
240
+ assert result.flags == []
241
+
242
+
243
+ def test_bad_units_raise_regardless_of_grid():
244
+ for bad in (0, -3, float("nan"), float("inf")):
245
+ with pytest.raises(ValueError, match="positive"):
246
+ suggest_excerpts(_doc([_step()], metric=None), units=bad)
247
+
248
+
249
+ def test_computed_end_is_an_exact_clean_decimal():
250
+ # 95.8 + 1.4 x (8*60/129.2) = 101.0012... -> exactly "101.001" on the
251
+ # wire, never a float-repr tail (the parse_float gate cannot see string
252
+ # payloads, so pin the string itself)
253
+ grid = MetricGrid(unit="eight", subdivisions=8, tempo_bpm="129.2", origin="51.2")
254
+ step = _step(spans=[SourceSpan(source="perf", start="95.8")])
255
+ result = suggest_excerpts(_doc([step], metric=grid), units=1.4)
256
+ assert result.document.steps[0].spans[0].excerpt == ("95.8", "101.001")
257
+
258
+
259
+ def test_container_steps_stay_flag_free():
260
+ child = _step("b1-1", spans=[SourceSpan(source="perf", start="10", end="14")])
261
+ parent = _step("b1", spans=[])
262
+ parent.steps.append(child)
263
+ result = suggest_excerpts(_doc([parent]))
264
+ assert result.flags == []
265
+
266
+
267
+ def test_tools_verb_coerces_cli_string_units():
268
+ from paces import tools
269
+
270
+ doc = _doc([_step()])
271
+ payload = tools.suggest_excerpts(
272
+ json.loads(dumps_document(doc)),
273
+ units="1", # argh hands strings over
274
+ )
275
+ span = payload["document"]["steps"][0]["spans"][0]
276
+ assert span["excerpt"] == ["10", "14"]
@@ -0,0 +1,224 @@
1
+ """Issue #3: the vertical slice — raw audio-bearing video in, practice page
2
+ with a real loop clip at the grid-computed boundaries out.
3
+
4
+ This is the integration test of the whole pipeline — grid measurement (#2) →
5
+ grid-placed segmentation → excerpt suggestion → media derivation (#1) →
6
+ render — run through the SAME tool surface the CLI dispatches, on synthetic
7
+ media whose ground truth is known (``audio_synth.practice_audio`` muxed onto
8
+ the rect video). Deliberately NO new architecture: everything here is the
9
+ shipped verbs composed. Fail — never skip — when deps are missing.
10
+
11
+ ``measure_grid`` on an .mp4 needs a SYSTEM ffmpeg (pydub/librosa read the
12
+ audio track via PATH ffmpeg — the bundled moviepy binary cannot serve them),
13
+ which is why paces now declares ``[tool.wads.ops.ffmpeg]`` (ADR-0005 §2's
14
+ fired trigger). Everything derive does still runs bundled-only.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import subprocess
21
+ import sys
22
+ from fractions import Fraction
23
+ from pathlib import Path
24
+
25
+ import librosa # noqa: F401 — fail loudly, never skip
26
+ import mixing # noqa: F401
27
+ import moviepy # noqa: F401
28
+ import pytest
29
+
30
+ from video_synth import FRAME_SIZE, practice_av
31
+
32
+ #: The synthetic routine: 8 eights of music at the POC's tempo. The last
33
+ #: block is the "Déhanchés" analog — the one whose card must carry a clip.
34
+ BPM = 129.2
35
+ STEPS = [["Intro", 2], ["Bras", 2], ["Déhanchés", 4]]
36
+ TOTAL_UNITS = 8
37
+ SPU_TRUTH = 8 * 60 / BPM # ≈ 3.715 s per eight
38
+
39
+ #: Measurement tolerances, from test_measure's proven bounds: tempo ±0.5 bpm
40
+ #: and origin ≈ the music start. The block boundary inherits origin error
41
+ #: plus 4 units of tempo drift — ±1.0 s is generous against those and still
42
+ #: fails on a half-unit (1.86 s) placement error.
43
+ BOUNDARY_TOLERANCE_S = 1.0
44
+
45
+ SOURCE = {"id": "rt", "kind": "video", "uri": "https://example.com/run-through"}
46
+
47
+
48
+ @pytest.fixture(scope="module")
49
+ def av(tmp_path_factory):
50
+ path, truth = practice_av(
51
+ tmp_path_factory.mktemp("slice") / "run_through.mp4",
52
+ bpm=BPM,
53
+ music_s=32.0, # 8 units ≈ 29.7 s + headroom for measurement drift
54
+ )
55
+ return path, truth
56
+
57
+
58
+ def test_the_slice_end_to_end(tmp_path, av):
59
+ from paces import tools
60
+
61
+ video, truth = av
62
+
63
+ # 1. segment: measures the grid from the media, places the step list
64
+ seg = tools.segment(str(video), steps=STEPS)
65
+ grid = seg["grid"]
66
+ assert grid is not None, seg["flags"]
67
+ assert float(Fraction(grid["tempoBpm"])) == pytest.approx(BPM, abs=0.5)
68
+ assert float(Fraction(grid["origin"])) == pytest.approx(
69
+ truth["music_start_s"], abs=0.6
70
+ )
71
+
72
+ # 2. project into the committed document
73
+ doc_path = tmp_path / "document.json"
74
+ tools.to_document(
75
+ seg, doc_id="routine", title="Routine", source=SOURCE, output=str(doc_path)
76
+ )
77
+
78
+ # 3. suggest excerpts: each block's clip window IS its grid window —
79
+ # EVERY block, not just the asserted one
80
+ payload = tools.suggest_excerpts(str(doc_path), output=str(doc_path))
81
+ assert payload["suggested"] == ["intro", "bras", "dehanches"]
82
+ doc = json.loads(doc_path.read_text())
83
+ block = next(s for s in doc["steps"] if s["id"] == "dehanches")
84
+ excerpt = block["spans"][0]["excerpt"]
85
+ start_s, end_s = (float(Fraction(v)) for v in excerpt)
86
+
87
+ # the excerpt sits on the measured grid: block 3 spans units 4..8
88
+ origin = float(Fraction(grid["origin"]))
89
+ spu = 8 * 60 / float(Fraction(grid["tempoBpm"]))
90
+ assert start_s == pytest.approx(origin + 4 * spu, abs=0.01)
91
+ assert end_s == pytest.approx(origin + 8 * spu, abs=0.01)
92
+ # ...and on the TRUTH the media was synthesized with
93
+ assert start_s == pytest.approx(
94
+ truth["music_start_s"] + 4 * SPU_TRUTH, abs=BOUNDARY_TOLERANCE_S
95
+ )
96
+ assert end_s == pytest.approx(
97
+ truth["music_start_s"] + 8 * SPU_TRUTH, abs=BOUNDARY_TOLERANCE_S
98
+ )
99
+
100
+ # 4. derive: a real clip is cut at those boundaries
101
+ derived = tools.derive(str(doc_path), media=str(video))
102
+ assert not any(f.startswith("stale-artifact") for f in derived["flags"])
103
+ clip_path = tmp_path / "media" / "dehanches.mp4"
104
+ assert clip_path.is_file()
105
+ probed_w, probed_h = mixing.get_video_dimensions(str(clip_path))
106
+ assert (probed_w, probed_h) == FRAME_SIZE # no-crop default: full frame
107
+ doc = json.loads(doc_path.read_text())
108
+ block = next(s for s in doc["steps"] if s["id"] == "dehanches")
109
+ clip_ref = next(a for a in block["artifacts"] if a["role"] == "clip")
110
+ assert clip_ref["uri"] == "media/dehanches.mp4"
111
+ assert float(Fraction(clip_ref["durationS"])) == pytest.approx(4 * spu, abs=0.05)
112
+
113
+ # 5. render: the card shows the looping clip
114
+ page_path = tmp_path / "page.html"
115
+ tools.render(str(doc_path), output=str(page_path))
116
+ page = page_path.read_text()
117
+ assert '<source src="media/dehanches.mp4"' in page
118
+ assert "<video controls loop muted playsinline" in page
119
+ assert 'poster="media/dehanches.jpg"' in page
120
+ assert "Déhanchés" in page
121
+
122
+ # 6. the caption is editorial content and arrives as a hand EDIT (which
123
+ # writes a Lock) — the POC card's caption path, exercised the house way
124
+ caption = "Jambes tendues, le bassin pulse"
125
+ tools.edit(
126
+ str(doc_path),
127
+ [
128
+ {
129
+ "op": "set",
130
+ "path": "/steps/dehanches/spans/0/caption",
131
+ "value": caption,
132
+ }
133
+ ],
134
+ by="user:test",
135
+ output=str(doc_path),
136
+ )
137
+
138
+ # 7. the crop is hand-tuned via the recipes sidecar (ADR-0005 §3's
139
+ # documented flow: edit box, set locked) — and the digest gate re-cuts
140
+ from paces.derivation import load_recipes, save_recipes
141
+
142
+ sidecar = tmp_path / "document.recipes.json"
143
+ recipes = load_recipes(sidecar)
144
+ span_start = block["spans"][0]["start"]
145
+ key = f"dehanches/rt/performance/{span_start}"
146
+ recipes.entries[key].box = (60, 40, 100, 80)
147
+ recipes.entries[key].locked = True
148
+ save_recipes(recipes, sidecar)
149
+ tools.derive(str(doc_path), media=str(video))
150
+ assert tuple(mixing.get_video_dimensions(str(clip_path))) == (100, 80), (
151
+ "the locked hand box must re-cut the existing clip"
152
+ )
153
+
154
+ tools.render(str(doc_path), output=str(page_path))
155
+ page = page_path.read_text()
156
+ assert caption in page # the card carries the caption
157
+ assert '<source src="media/dehanches.mp4"' in page # and still the clip
158
+
159
+
160
+ def test_the_slice_via_the_cli(tmp_path, av):
161
+ """The kickoff's step 4 as literal shell commands — every seam on its
162
+ default, through ``python -m paces`` exactly as a user would type it."""
163
+ video, _truth = av
164
+ (tmp_path / "steps.json").write_text(
165
+ json.dumps(STEPS, ensure_ascii=False), encoding="utf-8"
166
+ )
167
+
168
+ def run(*args: str) -> None:
169
+ result = subprocess.run(
170
+ [sys.executable, "-m", "paces", *args],
171
+ cwd=tmp_path,
172
+ capture_output=True,
173
+ text=True,
174
+ )
175
+ assert result.returncode == 0, result.stderr
176
+
177
+ run("segment", str(video), "--steps", "steps.json", "--output", "seg.json")
178
+ run(
179
+ "to-document",
180
+ "seg.json",
181
+ "--source",
182
+ json.dumps(SOURCE),
183
+ "--doc-id",
184
+ "routine",
185
+ "--title",
186
+ "Routine",
187
+ "--output",
188
+ "document.json",
189
+ )
190
+ run("suggest-excerpts", "document.json", "--output", "document.json")
191
+ run("derive", "document.json", "--media", str(video))
192
+ run("render", "document.json", "--output", "page.html")
193
+
194
+ assert (tmp_path / "media" / "dehanches.mp4").is_file()
195
+ assert (tmp_path / "media" / "dehanches.gif").is_file()
196
+ assert (tmp_path / "document.recipes.json").is_file()
197
+ page = (tmp_path / "page.html").read_text()
198
+ assert '<source src="media/dehanches.mp4"' in page
199
+
200
+
201
+ def test_measure_decodes_mp4_without_librosas_deprecated_fallback(av, monkeypatch):
202
+ """CI regression: librosa reads only soundfile formats and silently leans
203
+ on its deprecated audioread fallback when that package happens to be
204
+ installed (dev machines) — and raises where it is not (CI). Simulate the
205
+ CI condition and prove the pydub decode path carries the mp4."""
206
+ import librosa
207
+
208
+ from paces import measure
209
+
210
+ video, truth = av
211
+ real_load = librosa.load
212
+
213
+ def no_fallback_load(path, **kwargs):
214
+ if str(path).endswith(".mp4"):
215
+ raise RuntimeError("simulated: soundfile cannot open mp4")
216
+ return real_load(path, **kwargs)
217
+
218
+ monkeypatch.setattr(librosa, "load", no_fallback_load)
219
+ samples, rate = measure._load_audio(video, sample_rate=22050)
220
+ assert rate == 22050 and samples.size > 20 * 22050 # ~41 s of audio
221
+ # and the full measurement still lands on the truth through this path
222
+ measurement = measure.measure_grid(str(video))
223
+ assert measurement.grid.tempo_bpm is not None
224
+ assert float(measurement.grid.tempo_bpm) == pytest.approx(truth["bpm"], abs=0.5)
@@ -0,0 +1,74 @@
1
+ """Deterministic synthetic practice video for derivation and slice tests.
2
+
3
+ A static green rectangle at a known, asymmetric position (x != y) on a blue
4
+ background — spatially non-uniform on purpose: a solid-color clip cannot
5
+ distinguish a correct crop origin from an x/y-swapped one. Written with
6
+ moviepy, whose writer is the pip-bundled imageio-ffmpeg binary, so building
7
+ these fixtures needs no system ffmpeg (ADR-0005 §2) — though READING the
8
+ audio back out of :func:`practice_av`'s mp4 (what ``measure_grid`` does) is
9
+ the system-ffmpeg channel.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ #: The subject's pixel box (x, y, w, h) and the frame size.
17
+ RECT_XYWH = (60, 40, 100, 80)
18
+ FRAME_SIZE = (320, 240)
19
+
20
+
21
+ def _rect_frame():
22
+ import numpy as np
23
+
24
+ width, height = FRAME_SIZE
25
+ x, y, w, h = RECT_XYWH
26
+ frame = np.zeros((height, width, 3), dtype=np.uint8)
27
+ frame[:, :] = (0, 0, 255) # blue background (RGB)
28
+ frame[y : y + h, x : x + w] = (0, 255, 0) # green subject
29
+ return frame
30
+
31
+
32
+ def practice_video(path: str | Path, *, duration_s: float = 2.0, fps: int = 24) -> Path:
33
+ """Write the (silent) fixture mp4 to ``path`` and return it."""
34
+ from moviepy.video.io.ImageSequenceClip import ImageSequenceClip
35
+
36
+ clip = ImageSequenceClip([_rect_frame()] * int(duration_s * fps), fps=fps)
37
+ clip.write_videofile(str(path), codec="libx264", audio=False, logger=None)
38
+ clip.close()
39
+ return Path(path)
40
+
41
+
42
+ def practice_av(
43
+ path: str | Path,
44
+ *,
45
+ bpm: float = 129.2,
46
+ speech_s: float = 8.0,
47
+ silence_s: float = 1.5,
48
+ music_s: float = 30.0,
49
+ fps: int = 12,
50
+ seed: int = 0,
51
+ ) -> tuple[Path, dict]:
52
+ """One audio-bearing practice VIDEO — the vertical slice's raw input.
53
+
54
+ The audio track is :func:`audio_synth.practice_audio` (speech, a pause,
55
+ then kick-driven music at *bpm* — the POC's macro-structure), muxed onto
56
+ the rect visual. Returns ``(path, truth)`` with the synthesis ground
57
+ truth (music start/end, bpm).
58
+ """
59
+ import numpy as np
60
+ from moviepy.audio.AudioClip import AudioArrayClip
61
+ from moviepy.video.io.ImageSequenceClip import ImageSequenceClip
62
+
63
+ from audio_synth import practice_audio
64
+
65
+ samples, truth = practice_audio(
66
+ bpm=bpm, speech_s=speech_s, silence_s=silence_s, music_s=music_s, seed=seed
67
+ )
68
+ total_s = samples.size / truth["sample_rate"]
69
+ clip = ImageSequenceClip([_rect_frame()] * int(total_s * fps), fps=fps)
70
+ stereo = np.column_stack([samples, samples])
71
+ clip = clip.with_audio(AudioArrayClip(stereo, fps=truth["sample_rate"]))
72
+ clip.write_videofile(str(path), codec="libx264", audio_codec="aac", logger=None)
73
+ clip.close()
74
+ return Path(path), truth
@@ -1,32 +0,0 @@
1
- """Deterministic synthetic practice video for derivation tests.
2
-
3
- A static green rectangle at a known, asymmetric position (x != y) on a blue
4
- background — spatially non-uniform on purpose: a solid-color clip cannot
5
- distinguish a correct crop origin from an x/y-swapped one. Written with
6
- moviepy, whose writer is the pip-bundled imageio-ffmpeg binary, so building
7
- this fixture needs no system ffmpeg (ADR-0005 §2).
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- from pathlib import Path
13
-
14
- #: The subject's pixel box (x, y, w, h) and the frame size.
15
- RECT_XYWH = (60, 40, 100, 80)
16
- FRAME_SIZE = (320, 240)
17
-
18
-
19
- def practice_video(path: str | Path, *, duration_s: float = 2.0, fps: int = 24) -> Path:
20
- """Write the fixture mp4 to ``path`` and return it."""
21
- import numpy as np
22
- from moviepy.video.io.ImageSequenceClip import ImageSequenceClip
23
-
24
- width, height = FRAME_SIZE
25
- x, y, w, h = RECT_XYWH
26
- frame = np.zeros((height, width, 3), dtype=np.uint8)
27
- frame[:, :] = (0, 0, 255) # blue background (RGB)
28
- frame[y : y + h, x : x + w] = (0, 255, 0) # green subject
29
- clip = ImageSequenceClip([frame] * int(duration_s * fps), fps=fps)
30
- clip.write_videofile(str(path), codec="libx264", audio=False, logger=None)
31
- clip.close()
32
- return Path(path)
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