paces 0.0.5__tar.gz → 0.0.7__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.5 → paces-0.0.7}/PKG-INFO +4 -3
- {paces-0.0.5 → paces-0.0.7}/README.md +1 -0
- {paces-0.0.5 → paces-0.0.7}/paces/__init__.py +3 -0
- paces-0.0.7/paces/__main__.py +39 -0
- paces-0.0.7/paces/excerpts.py +154 -0
- {paces-0.0.5 → paces-0.0.7}/paces/measure.py +29 -1
- {paces-0.0.5 → paces-0.0.7}/paces/tools.py +35 -1
- {paces-0.0.5 → paces-0.0.7}/pyproject.toml +19 -4
- paces-0.0.7/tests/test_cli_surface.py +133 -0
- paces-0.0.7/tests/test_excerpts.py +276 -0
- paces-0.0.7/tests/test_vertical_slice.py +224 -0
- paces-0.0.7/tests/video_synth.py +74 -0
- paces-0.0.5/paces/__main__.py +0 -31
- paces-0.0.5/tests/video_synth.py +0 -32
- {paces-0.0.5 → paces-0.0.7}/.gitignore +0 -0
- {paces-0.0.5 → paces-0.0.7}/LICENSE +0 -0
- {paces-0.0.5 → paces-0.0.7}/docs/README.md +0 -0
- {paces-0.0.5 → paces-0.0.7}/docs/alignment/README.md +0 -0
- {paces-0.0.5 → paces-0.0.7}/docs/poc-reference/README.md +0 -0
- {paces-0.0.5 → paces-0.0.7}/docs/poc-reference/artifacts/clips.json +0 -0
- {paces-0.0.5 → paces-0.0.7}/paces/derivation.py +0 -0
- {paces-0.0.5 → paces-0.0.7}/paces/edits.py +0 -0
- {paces-0.0.5 → paces-0.0.7}/paces/model.py +0 -0
- {paces-0.0.5 → paces-0.0.7}/paces/projection.py +0 -0
- {paces-0.0.5 → paces-0.0.7}/paces/render.py +0 -0
- {paces-0.0.5 → paces-0.0.7}/paces/segmenters.py +0 -0
- {paces-0.0.5 → paces-0.0.7}/tests/audio_synth.py +0 -0
- {paces-0.0.5 → paces-0.0.7}/tests/data/routine.json +0 -0
- {paces-0.0.5 → paces-0.0.7}/tests/poc_fixture.py +0 -0
- {paces-0.0.5 → paces-0.0.7}/tests/test_chapters.py +0 -0
- {paces-0.0.5 → paces-0.0.7}/tests/test_derivation.py +0 -0
- {paces-0.0.5 → paces-0.0.7}/tests/test_edits.py +0 -0
- {paces-0.0.5 → paces-0.0.7}/tests/test_measure.py +0 -0
- {paces-0.0.5 → paces-0.0.7}/tests/test_render_media.py +0 -0
- {paces-0.0.5 → paces-0.0.7}/tests/test_roundtrip_poc.py +0 -0
- {paces-0.0.5 → paces-0.0.7}/tests/test_segment.py +0 -0
- {paces-0.0.5 → paces-0.0.7}/tests/test_smoke.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.7
|
|
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
|
|
@@ -17,9 +17,9 @@ Requires-Dist: mixing[audio,beats]>=0.0.36; extra == 'audio'
|
|
|
17
17
|
Requires-Dist: numba>=0.59; extra == 'audio'
|
|
18
18
|
Provides-Extra: cli
|
|
19
19
|
Requires-Dist: argcomplete>=3; extra == 'cli'
|
|
20
|
-
Requires-Dist:
|
|
20
|
+
Requires-Dist: cw<0.2,>=0.1.1; extra == 'cli'
|
|
21
21
|
Provides-Extra: dev
|
|
22
|
-
Requires-Dist:
|
|
22
|
+
Requires-Dist: cw<0.2,>=0.1.1; extra == 'dev'
|
|
23
23
|
Requires-Dist: mixing[audio,beats]>=0.0.39; extra == 'dev'
|
|
24
24
|
Requires-Dist: numba>=0.59; extra == 'dev'
|
|
25
25
|
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
@@ -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,39 @@
|
|
|
1
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
2
|
+
"""paces CLI entry point — ``python -m paces`` / ``paces`` (after install)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import dataclasses
|
|
7
|
+
|
|
8
|
+
import cw
|
|
9
|
+
|
|
10
|
+
from paces.tools import _dispatch_funcs
|
|
11
|
+
|
|
12
|
+
#: Positional args (with or without defaults) stay positional; the keyword-only args
|
|
13
|
+
#: every tool uses become ``--flags``.
|
|
14
|
+
#:
|
|
15
|
+
#: This is ``argh``'s post-0.30 ``BY_NAME_IF_KWONLY`` policy, which this CLI has always
|
|
16
|
+
#: run on and which cw spells the same way. It is **not** cw's default: ``cw.ARGH``
|
|
17
|
+
#: reproduces ``argh.dispatch_commands`` instead, under which a defaulted positional
|
|
18
|
+
#: becomes an option. ``segment(media=None, *, ...)`` is exactly that shape, so the
|
|
19
|
+
#: difference is the difference between ``paces segment video.mp4`` and
|
|
20
|
+
#: ``paces segment --media video.mp4``. The former is what the docs, the smoke test and
|
|
21
|
+
#: the vertical-slice test all type.
|
|
22
|
+
CONVENTION = dataclasses.replace(cw.ARGH, naming=cw.BY_NAME_IF_KWONLY)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> None:
|
|
26
|
+
"""Build the parser over the paces tools and dispatch.
|
|
27
|
+
|
|
28
|
+
``cw.run`` offers the parser to ``argcomplete`` before parsing, exactly where
|
|
29
|
+
``argh``'s dispatch did, so ``PYTHON_ARGCOMPLETE_OK`` above still applies with no
|
|
30
|
+
hand-written completion block here. It *returns* the exit code rather than raising
|
|
31
|
+
it, which is why the ``SystemExit`` is written out: without it every usage error
|
|
32
|
+
would exit 0, and the end-to-end tests all assert ``returncode == 0``.
|
|
33
|
+
"""
|
|
34
|
+
parser = cw.mk_parser(_dispatch_funcs, convention=CONVENTION)
|
|
35
|
+
raise SystemExit(cw.run(parser))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
main()
|
|
@@ -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 =
|
|
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__})"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Agent-callable tool surface over ``paces`` — plain functions with JSON-able
|
|
2
2
|
arguments returning JSON-ready values, deliberately CLI/MCP/HTTP-agnostic.
|
|
3
3
|
|
|
4
|
-
This module is the SSOT for "what paces can do": every surface (the ``
|
|
4
|
+
This module is the SSOT for "what paces can do": every surface (the ``cw``
|
|
5
5
|
CLI in ``__main__.py`` today; ``py2mcp``/``qh`` wrappers tomorrow) dispatches
|
|
6
6
|
over ``_dispatch_funcs`` and nothing else. Arguments that carry structure
|
|
7
7
|
(steps, grids, documents) accept a Python object, a JSON string, or a path to
|
|
@@ -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.
|
|
9
|
+
version = "0.0.7"
|
|
10
10
|
description = "Turn instructional media into structured, interactive learning material"
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
license = "MIT"
|
|
@@ -36,7 +36,7 @@ Documentation = "https://thorwhalen.github.io/paces"
|
|
|
36
36
|
paces = "paces.__main__:main"
|
|
37
37
|
|
|
38
38
|
[project.optional-dependencies]
|
|
39
|
-
cli = ["
|
|
39
|
+
cli = ["cw>=0.1.1,<0.2", "argcomplete>=3"]
|
|
40
40
|
audio = [
|
|
41
41
|
# Grid measurement (issue #2): the fleet's `mixing` owns speech/music
|
|
42
42
|
# segmentation and beat tracking; [beats] pulls librosa, [audio] pulls
|
|
@@ -63,9 +63,9 @@ dev = [
|
|
|
63
63
|
"pytest>=7.0",
|
|
64
64
|
"pytest-cov>=4.0",
|
|
65
65
|
"ruff>=0.1.0",
|
|
66
|
-
# The CLI smoke test runs `python -m paces ...`; without
|
|
66
|
+
# The CLI smoke test runs `python -m paces ...`; without cw in dev it
|
|
67
67
|
# would fail rather than silently skip — keep it mirrored with [cli].
|
|
68
|
-
"
|
|
68
|
+
"cw>=0.1.1,<0.2",
|
|
69
69
|
# The grid-measurement tests exercise the real mixing/librosa path on
|
|
70
70
|
# synthesized audio; mirrored with [audio] so they run rather than skip.
|
|
71
71
|
# The derivation tests do the same on synthetic video — mirrored with
|
|
@@ -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,133 @@
|
|
|
1
|
+
"""The command line itself: which argv spellings `paces` accepts, and what they exit.
|
|
2
|
+
|
|
3
|
+
``tests/test_smoke.py`` and ``tests/test_vertical_slice.py`` already drive the CLI
|
|
4
|
+
end to end — but both only ever pass argv that works, and both assert
|
|
5
|
+
``returncode == 0``, so a CLI that had stopped reporting failure at all would sail
|
|
6
|
+
through them. This file pins the shape of the surface instead: the naming policy in
|
|
7
|
+
both directions, and the exit codes.
|
|
8
|
+
|
|
9
|
+
Recorded from the ``argh`` implementation before the ``cw`` migration and replayed
|
|
10
|
+
after: 20 argv vectors compared byte-for-byte on stdout, stderr and exit code, plus
|
|
11
|
+
four vectors whose stderr carries a traceback (compared on exit code and the
|
|
12
|
+
exception's own line, since a traceback names line numbers that the edit moved).
|
|
13
|
+
No diffs. That full-body diff cannot live in CI, because CPython rewrites argparse's
|
|
14
|
+
own option column between versions, so what is asserted here is the grammar.
|
|
15
|
+
|
|
16
|
+
**The naming policy is the load-bearing part.** `paces` runs on argh's post-0.30
|
|
17
|
+
``BY_NAME_IF_KWONLY``: positional parameters stay positional, keyword-only parameters
|
|
18
|
+
become ``--flags``. That is *not* cw's default — ``cw.ARGH`` reproduces
|
|
19
|
+
``argh.dispatch_commands``, under which a parameter with a default becomes an option.
|
|
20
|
+
``segment(media=None, *, ...)`` is exactly that shape, so under the default
|
|
21
|
+
``paces segment video.mp4`` would become ``paces segment --media video.mp4``: the
|
|
22
|
+
form every doc, the smoke test and the vertical slice actually type would stop
|
|
23
|
+
parsing. So both halves are asserted — the positional is accepted AND the option
|
|
24
|
+
spelling is rejected.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
import pytest
|
|
34
|
+
|
|
35
|
+
from paces.__main__ import CONVENTION
|
|
36
|
+
from paces.tools import _dispatch_funcs
|
|
37
|
+
|
|
38
|
+
_CLI_TIMEOUT = 120
|
|
39
|
+
|
|
40
|
+
#: Every verb the CLI exposes, spelled as the command line spells it.
|
|
41
|
+
COMMAND_NAMES = [f.__name__.replace("_", "-") for f in _dispatch_funcs]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_cli(*argv, cwd):
|
|
45
|
+
"""Run ``python -m paces <argv>``, which is how both existing CLI tests enter."""
|
|
46
|
+
return subprocess.run(
|
|
47
|
+
[sys.executable, "-m", "paces", *argv],
|
|
48
|
+
cwd=str(cwd),
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
timeout=_CLI_TIMEOUT,
|
|
52
|
+
env={**os.environ, "COLUMNS": "80"},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_every_tool_is_reachable_and_documents_itself(tmp_path):
|
|
57
|
+
"""``_dispatch_funcs`` is the SSOT; every name in it is a subcommand."""
|
|
58
|
+
top = run_cli("--help", cwd=tmp_path)
|
|
59
|
+
assert top.returncode == 0
|
|
60
|
+
for name in COMMAND_NAMES:
|
|
61
|
+
assert name in top.stdout, f"{name} missing from `paces --help`"
|
|
62
|
+
sub = run_cli(name, "--help", cwd=tmp_path)
|
|
63
|
+
assert sub.returncode == 0, sub.stderr
|
|
64
|
+
assert "usage: " in sub.stdout
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_media_is_a_positional_not_an_option(tmp_path):
|
|
68
|
+
"""The naming policy, in both directions. See the module docstring."""
|
|
69
|
+
help_ = run_cli("segment", "--help", cwd=tmp_path)
|
|
70
|
+
assert "[media]" in help_.stdout
|
|
71
|
+
assert "--media" not in help_.stdout
|
|
72
|
+
|
|
73
|
+
# Accepted as a bare word — the form the smoke test and the docs type.
|
|
74
|
+
positional = run_cli("segment", "media.mp4", cwd=tmp_path)
|
|
75
|
+
assert positional.returncode == 0
|
|
76
|
+
|
|
77
|
+
# And the option spelling does not exist. Under cw's default convention this
|
|
78
|
+
# would be the *only* spelling that worked.
|
|
79
|
+
optional = run_cli("segment", "--media", "media.mp4", cwd=tmp_path)
|
|
80
|
+
assert optional.returncode == 2
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_keyword_only_parameters_are_flags(tmp_path):
|
|
84
|
+
"""The other half of BY_NAME_IF_KWONLY: every kwonly arg is a ``--flag``."""
|
|
85
|
+
help_ = run_cli("to-document", "--help", cwd=tmp_path)
|
|
86
|
+
for flag in ("--doc-id", "--title", "--source", "--domain", "--lang", "--output"):
|
|
87
|
+
assert flag in help_.stdout
|
|
88
|
+
# ...and the one positional parameter is still a bare word in the usage line.
|
|
89
|
+
usage = help_.stdout.split("\n\n", 1)[0]
|
|
90
|
+
assert "segmentation" in usage
|
|
91
|
+
assert "--segmentation" not in help_.stdout
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_the_convention_is_the_one_the_cli_was_built_on():
|
|
95
|
+
"""A guard on the constant itself, so a refactor cannot quietly drop it."""
|
|
96
|
+
import cw
|
|
97
|
+
|
|
98
|
+
assert CONVENTION.naming is cw.BY_NAME_IF_KWONLY
|
|
99
|
+
assert CONVENTION.naming is not cw.ARGH.naming
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.mark.parametrize(
|
|
103
|
+
"argv",
|
|
104
|
+
[
|
|
105
|
+
("no-such-command",),
|
|
106
|
+
("segment", "--no-such-flag"),
|
|
107
|
+
("render",), # a required positional, omitted
|
|
108
|
+
("segment", "extra1", "extra2"),
|
|
109
|
+
("edit", "doc"), # `edits` and the required --by are both missing
|
|
110
|
+
],
|
|
111
|
+
)
|
|
112
|
+
def test_usage_errors_exit_two(argv, tmp_path):
|
|
113
|
+
"""``cw.run`` returns the code, so ``main`` must ``raise SystemExit`` on it.
|
|
114
|
+
|
|
115
|
+
Both existing end-to-end CLI tests assert ``returncode == 0``; without this,
|
|
116
|
+
a CLI that had stopped reporting failure entirely would still pass them.
|
|
117
|
+
"""
|
|
118
|
+
assert run_cli(*argv, cwd=tmp_path).returncode == 2
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_a_failing_command_exits_nonzero(tmp_path):
|
|
122
|
+
"""An unknown segmenter is a real failure, and the shell has to hear about it."""
|
|
123
|
+
result = run_cli("segment", "--segmenter", "nope", cwd=tmp_path)
|
|
124
|
+
assert result.returncode != 0
|
|
125
|
+
assert "unknown segmenter" in result.stderr
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_no_arguments_prints_usage_to_stdout_and_exits_zero(tmp_path):
|
|
129
|
+
"""argh's behaviour; plain argparse with a required subparser does NOT do this."""
|
|
130
|
+
result = run_cli(cwd=tmp_path)
|
|
131
|
+
assert result.returncode == 0
|
|
132
|
+
assert result.stdout.startswith("usage: ")
|
|
133
|
+
assert result.stderr == ""
|
|
@@ -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
|
paces-0.0.5/paces/__main__.py
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# PYTHON_ARGCOMPLETE_OK
|
|
2
|
-
"""paces CLI entry point — ``python -m paces`` / ``paces`` (after install)."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import argh
|
|
7
|
-
|
|
8
|
-
from paces.tools import _dispatch_funcs
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def main() -> None:
|
|
12
|
-
"""Build the argh parser over the paces tools and dispatch."""
|
|
13
|
-
from argh.assembling import NameMappingPolicy
|
|
14
|
-
|
|
15
|
-
parser = argh.ArghParser()
|
|
16
|
-
# Positional args (with or without defaults) stay positional; the
|
|
17
|
-
# keyword-only args every tool uses become --flags.
|
|
18
|
-
parser.add_commands(
|
|
19
|
-
_dispatch_funcs, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_KWONLY
|
|
20
|
-
)
|
|
21
|
-
try:
|
|
22
|
-
import argcomplete
|
|
23
|
-
|
|
24
|
-
argcomplete.autocomplete(parser)
|
|
25
|
-
except ImportError:
|
|
26
|
-
pass
|
|
27
|
-
parser.dispatch()
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if __name__ == "__main__":
|
|
31
|
-
main()
|
paces-0.0.5/tests/video_synth.py
DELETED
|
@@ -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
|
|
File without changes
|