steppy-dev 0.3.0__py3-none-any.whl
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.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/tts/track.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Narration track assembly — per-step clips into one WAV, on the stdlib.
|
|
2
|
+
|
|
3
|
+
The video timeline decides when each step starts; this lays the matching clip
|
|
4
|
+
at that offset and pads the gaps with silence, so the voiceover stays locked to
|
|
5
|
+
the cursor no matter how the timeline stretched to accommodate it.
|
|
6
|
+
|
|
7
|
+
Assembly uses the stdlib ``wave`` module rather than an ffmpeg filter graph:
|
|
8
|
+
concatenating PCM is arithmetic, it is deterministic, and it unit-tests without
|
|
9
|
+
a subprocess. ffmpeg is still used for the one thing it is needed for —
|
|
10
|
+
normalising whatever container a cloud provider returned (usually MP3) into the
|
|
11
|
+
PCM this works on.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
18
|
+
import tempfile
|
|
19
|
+
import wave
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional, Sequence
|
|
22
|
+
|
|
23
|
+
from .cache import atomic_replace, atomic_write_bytes
|
|
24
|
+
from .types import TtsError
|
|
25
|
+
|
|
26
|
+
# One target format for everything, so concatenation is a byte-copy.
|
|
27
|
+
SAMPLE_RATE = 48000
|
|
28
|
+
CHANNELS = 1
|
|
29
|
+
SAMPLE_WIDTH = 2 # 16-bit
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def wav_duration_ms(path: Path) -> int:
|
|
33
|
+
"""Length of a WAV file, from its own header."""
|
|
34
|
+
with wave.open(str(path), "rb") as handle:
|
|
35
|
+
frames = handle.getnframes()
|
|
36
|
+
rate = handle.getframerate() or SAMPLE_RATE
|
|
37
|
+
return int(round(frames * 1000 / rate))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_intact_wav(path: Path) -> bool:
|
|
41
|
+
"""Does this file actually contain the audio its header promises?
|
|
42
|
+
|
|
43
|
+
A header read alone is not enough to trust a cached clip: a truncated write
|
|
44
|
+
usually leaves the 44-byte header intact, so ``getnframes()`` still reports
|
|
45
|
+
the *intended* length and the timeline gets sized against audio that is not
|
|
46
|
+
there. Comparing the declared payload against the bytes on disk is what
|
|
47
|
+
turns a poisoned entry into an honest miss.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
with wave.open(str(path), "rb") as handle:
|
|
51
|
+
declared = handle.getnframes() * handle.getnchannels() * handle.getsampwidth()
|
|
52
|
+
# A zero-frame clip is intact, just useless. Rejecting it would make an
|
|
53
|
+
# engine that returns silence for one line re-bill for it every run.
|
|
54
|
+
return path.stat().st_size >= declared
|
|
55
|
+
except (wave.Error, OSError, EOFError):
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def normalize(source: Path, dest: Path, ffmpeg: str) -> Path:
|
|
60
|
+
"""Transcode any clip into 48 kHz mono 16-bit PCM WAV.
|
|
61
|
+
|
|
62
|
+
Piper and espeak already emit WAV, so the free path usually skips this
|
|
63
|
+
entirely — but a cloud provider's MP3 has to become PCM before it can be
|
|
64
|
+
laid onto a timeline by arithmetic.
|
|
65
|
+
|
|
66
|
+
``dest`` only ever appears complete. ffmpeg writes a sibling temp file that
|
|
67
|
+
is ``os.replace``d into position on success, because *dest* is routinely a
|
|
68
|
+
cache path: a half-written file there is not a slow build, it is a
|
|
69
|
+
permanently poisoned entry that every later run happily serves.
|
|
70
|
+
"""
|
|
71
|
+
if _is_target_wav(source):
|
|
72
|
+
if source != dest:
|
|
73
|
+
atomic_write_bytes(dest, source.read_bytes())
|
|
74
|
+
return dest
|
|
75
|
+
|
|
76
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
fd, staging = tempfile.mkstemp(dir=str(dest.parent), prefix=f".{dest.name}.", suffix=".wav")
|
|
78
|
+
os.close(fd)
|
|
79
|
+
args = [
|
|
80
|
+
ffmpeg,
|
|
81
|
+
"-y",
|
|
82
|
+
"-hide_banner",
|
|
83
|
+
"-nostats",
|
|
84
|
+
"-loglevel",
|
|
85
|
+
"error",
|
|
86
|
+
"-i",
|
|
87
|
+
str(source),
|
|
88
|
+
"-ar",
|
|
89
|
+
str(SAMPLE_RATE),
|
|
90
|
+
"-ac",
|
|
91
|
+
str(CHANNELS),
|
|
92
|
+
"-sample_fmt",
|
|
93
|
+
"s16",
|
|
94
|
+
staging,
|
|
95
|
+
]
|
|
96
|
+
try:
|
|
97
|
+
proc = subprocess.run(args, capture_output=True)
|
|
98
|
+
if proc.returncode != 0:
|
|
99
|
+
tail = (proc.stderr or b"").decode("utf-8", "replace").strip().splitlines()[-4:]
|
|
100
|
+
raise TtsError(f"could not convert {source.name} to WAV: " + " / ".join(tail))
|
|
101
|
+
return atomic_replace(Path(staging), dest)
|
|
102
|
+
except BaseException:
|
|
103
|
+
Path(staging).unlink(missing_ok=True)
|
|
104
|
+
raise
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_target_wav(path: Path) -> bool:
|
|
108
|
+
try:
|
|
109
|
+
with wave.open(str(path), "rb") as handle:
|
|
110
|
+
return (
|
|
111
|
+
handle.getframerate() == SAMPLE_RATE
|
|
112
|
+
and handle.getnchannels() == CHANNELS
|
|
113
|
+
and handle.getsampwidth() == SAMPLE_WIDTH
|
|
114
|
+
)
|
|
115
|
+
except (wave.Error, OSError):
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def silence(duration_ms: int) -> bytes:
|
|
120
|
+
frames = max(0, int(round(duration_ms * SAMPLE_RATE / 1000)))
|
|
121
|
+
return b"\x00" * (frames * CHANNELS * SAMPLE_WIDTH)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def assemble(clips: Sequence[tuple[int, Optional[Path]]], total_ms: int, dest: Path) -> Optional[Path]:
|
|
125
|
+
"""Lay each clip at its offset and pad the rest with silence.
|
|
126
|
+
|
|
127
|
+
``clips`` is ``(start_ms, wav_path | None)`` in timeline order. Returns None
|
|
128
|
+
when there is nothing to say — the caller then renders a silent video rather
|
|
129
|
+
than muxing an empty track.
|
|
130
|
+
|
|
131
|
+
A clip that would overrun the next one is **truncated, not dropped**: the
|
|
132
|
+
timeline already stretched each step to fit its narration, so an overrun
|
|
133
|
+
means something upstream disagreed, and a clipped last syllable is a far
|
|
134
|
+
better failure than losing a whole step's voiceover.
|
|
135
|
+
"""
|
|
136
|
+
usable = [(start, path) for start, path in clips if path and path.is_file()]
|
|
137
|
+
if not usable:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
with wave.open(str(dest), "wb") as out:
|
|
142
|
+
out.setnchannels(CHANNELS)
|
|
143
|
+
out.setsampwidth(SAMPLE_WIDTH)
|
|
144
|
+
out.setframerate(SAMPLE_RATE)
|
|
145
|
+
|
|
146
|
+
cursor_ms = 0
|
|
147
|
+
for start_ms, path in sorted(usable, key=lambda item: item[0]):
|
|
148
|
+
if start_ms > cursor_ms:
|
|
149
|
+
out.writeframes(silence(start_ms - cursor_ms))
|
|
150
|
+
cursor_ms = start_ms
|
|
151
|
+
with wave.open(str(path), "rb") as clip:
|
|
152
|
+
frames = clip.readframes(clip.getnframes())
|
|
153
|
+
out.writeframes(frames)
|
|
154
|
+
cursor_ms += int(round(len(frames) / (CHANNELS * SAMPLE_WIDTH) * 1000 / SAMPLE_RATE))
|
|
155
|
+
|
|
156
|
+
if total_ms > cursor_ms:
|
|
157
|
+
out.writeframes(silence(total_ms - cursor_ms))
|
|
158
|
+
return dest
|
steppy/tts/types.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Core TTS types — the contract every provider implements.
|
|
2
|
+
|
|
3
|
+
Stdlib only, deliberately. This module sits on the import path of the registry
|
|
4
|
+
and every provider, so anything heavier here would leak into ``steppy.models``
|
|
5
|
+
and back onto the default CLI path (guard:
|
|
6
|
+
``tests/test_no_video_import_on_default_path.py``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Optional, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
# Same grammar config.py resolves at load time. Repeated (not imported) to keep
|
|
16
|
+
# this module free of steppy imports beyond models.
|
|
17
|
+
_ENV_REF = re.compile(r"\$\{([A-Za-z_]\w*)(?::-([^}]*))?\}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TtsError(Exception):
|
|
21
|
+
"""Narration could not be synthesized.
|
|
22
|
+
|
|
23
|
+
Never fatal to a build on its own: the caller falls back to a silent,
|
|
24
|
+
captioned video and reports why, because losing the voiceover is better than
|
|
25
|
+
losing the release.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class SpeechRequest:
|
|
31
|
+
"""One utterance to synthesize — normally one guide step's ``guide-text``."""
|
|
32
|
+
|
|
33
|
+
text: str
|
|
34
|
+
locale: str
|
|
35
|
+
voice: Optional[str] = None
|
|
36
|
+
model: Optional[str] = None
|
|
37
|
+
speed: float = 1.0
|
|
38
|
+
fmt: Optional[str] = None
|
|
39
|
+
options: dict = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class SpeechClip:
|
|
44
|
+
"""Synthesized audio, in whatever container the provider produced."""
|
|
45
|
+
|
|
46
|
+
audio: bytes
|
|
47
|
+
fmt: str = "wav"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def empty(self) -> bool:
|
|
51
|
+
return not self.audio
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@runtime_checkable
|
|
55
|
+
class TtsProvider(Protocol):
|
|
56
|
+
"""What a provider must offer. Deliberately one method.
|
|
57
|
+
|
|
58
|
+
Third parties register implementations through the ``steppy.tts_providers``
|
|
59
|
+
entry-point group, so this signature is a public contract: widen it only
|
|
60
|
+
additively.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
name: str
|
|
64
|
+
|
|
65
|
+
def synthesize(self, request: SpeechRequest) -> SpeechClip: # pragma: no cover - protocol
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
def version(self) -> str: # pragma: no cover - protocol
|
|
69
|
+
"""Identifier folded into the cache key.
|
|
70
|
+
|
|
71
|
+
Load-bearing for the local engines: espeak-ng and Piper produce
|
|
72
|
+
different audio across binary versions, so clip durations shift and the
|
|
73
|
+
whole video timeline moves — silently, unless the version is in the key.
|
|
74
|
+
"""
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def check_resolved(value: Optional[str], field_name: str) -> Optional[str]:
|
|
79
|
+
"""Reject a config value that is still an unexpanded ``${VAR}`` reference.
|
|
80
|
+
|
|
81
|
+
``config._interpolate_env`` leaves the literal in place and only warns when
|
|
82
|
+
the variable is unset, so without this the user's first symptom is a 401
|
|
83
|
+
from a vendor rather than a message about their own configuration.
|
|
84
|
+
"""
|
|
85
|
+
if not value:
|
|
86
|
+
return value
|
|
87
|
+
match = _ENV_REF.search(value)
|
|
88
|
+
if match:
|
|
89
|
+
raise TtsError(
|
|
90
|
+
f"{field_name} is still the literal {match.group(0)!r} — the environment "
|
|
91
|
+
f"variable {match.group(1)} is not set. Export it, put the value in the "
|
|
92
|
+
f"gitignored credentials.yaml overlay, or use ${{{match.group(1)}:-default}}."
|
|
93
|
+
)
|
|
94
|
+
return value
|