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.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/tts/__init__.py ADDED
@@ -0,0 +1,280 @@
1
+ """Text-to-speech for video narration — provider-agnostic by construction.
2
+
3
+ The whole point of this package is that ``video.tts.provider`` is the only thing
4
+ that has to change to swap engines: ``none`` (the default: captions, no audio),
5
+ ``piper`` or ``espeak`` (free, offline), a vendor preset (``elevenlabs``,
6
+ ``openai``, ``azure``, ``google``), a hand-written REST shape under
7
+ ``video.tts.http``, or a third-party plugin registered through the
8
+ ``steppy.tts_providers`` entry point. No code path branches on vendor.
9
+
10
+ Narration is synthesized **before** the browser opens, because the video
11
+ timeline needs each clip's duration to know how long to hold its step. So this
12
+ package never imports ``steppy.video``, and ``steppy.video`` never imports this
13
+ one — they meet at a dict of durations and a finished WAV.
14
+
15
+ Synthesis is **per step**, and the cache is keyed on the text rather than on the
16
+ step's position, so editing one sentence re-voices one sentence: unchanged steps
17
+ hit, reordered steps hit, and the same sentence in two guides shares one clip.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Callable, Optional, Sequence
25
+
26
+ from .cache import ClipCache, cache_key
27
+ from .registry import available_providers, describe, readiness, resolve
28
+ from .types import SpeechClip, SpeechRequest, TtsError, TtsProvider
29
+
30
+ __all__ = [
31
+ "ClipCache",
32
+ "SpeechClip",
33
+ "SpeechRequest",
34
+ "TtsError",
35
+ "TtsProvider",
36
+ "Utterance",
37
+ "Narration",
38
+ "assemble_track",
39
+ "available_providers",
40
+ "cache_key",
41
+ "describe",
42
+ "normalize_narration",
43
+ "readiness",
44
+ "resolve",
45
+ "synthesize_steps",
46
+ "voice_jobs",
47
+ ]
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Utterance:
52
+ """One step's narration."""
53
+
54
+ step_id: str
55
+ text: str
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Narration:
60
+ """Per-step clips and durations, ready for the timeline and the muxer."""
61
+
62
+ durations_ms: dict[str, int]
63
+ clips: dict[str, Path]
64
+ provider: str
65
+
66
+ @property
67
+ def silent(self) -> bool:
68
+ return not self.clips
69
+
70
+
71
+ def normalize_narration(text: Optional[str]) -> str:
72
+ """Collapse authoring whitespace, so re-wrapping a line is not a re-bill.
73
+
74
+ ``guide-text`` is YAML, and a hard wrap moved by one word changes the string
75
+ without changing a syllable of the audio. Normalizing the *request* rather
76
+ than the *key* is deliberate: were only the key normalized, two differently
77
+ wrapped texts would collide on whichever clip synthesized first, and the
78
+ audio would no longer correspond to the text that was sent.
79
+ """
80
+ return " ".join((text or "").split())
81
+
82
+
83
+ def synthesize_steps(
84
+ utterances: Sequence[Utterance],
85
+ cfg,
86
+ *,
87
+ locale: str,
88
+ work_dir: Path,
89
+ ffmpeg: str,
90
+ cache_root: Optional[Path] = None,
91
+ memo: Optional[dict[str, tuple[Path, int]]] = None,
92
+ on_warning: Optional[Callable[[str], None]] = None,
93
+ ) -> Narration:
94
+ """Synthesize every step's narration for one locale.
95
+
96
+ Returns durations keyed by step id — which is what the video timeline needs
97
+ up front, to stretch each step so its voiceover is never cut off.
98
+
99
+ ``cache_root`` overrides ``cfg.cache_dir`` and should be an **absolute**
100
+ path resolved against the project root; the bare config value is relative to
101
+ the CWD, so a build launched from a subdirectory would otherwise miss a warm
102
+ cache and re-bill for it. ``memo`` is a process-local key→path map shared
103
+ across a batch, so N layouts of one guide synthesize once even when the
104
+ on-disk cache is disabled.
105
+
106
+ A provider failure is reported and downgraded to silence rather than raised:
107
+ losing the voiceover is a bad outcome, losing the release is a worse one.
108
+ """
109
+ from .track import normalize, wav_duration_ms
110
+
111
+ resolved = resolve(cfg)
112
+ if resolved.name == "none":
113
+ return Narration(durations_ms={}, clips={}, provider="none")
114
+
115
+ cache = ClipCache(Path(cache_root or cfg.cache_dir), enabled=cfg.cache)
116
+ version = _safe_version(resolved.provider)
117
+ durations: dict[str, int] = {}
118
+ clips: dict[str, Path] = {}
119
+
120
+ for utterance in utterances:
121
+ text = normalize_narration(utterance.text)
122
+ if not text:
123
+ continue
124
+ request = SpeechRequest(
125
+ text=text,
126
+ locale=locale,
127
+ voice=cfg.voice_for(locale),
128
+ model=cfg.model,
129
+ speed=cfg.speed,
130
+ fmt=cfg.format,
131
+ options=dict(cfg.options or {}),
132
+ )
133
+ key = cache_key(request, resolved.name, version)
134
+ try:
135
+ wav, duration = _clip_for(request, key, cache, resolved, work_dir, ffmpeg, normalize, memo)
136
+ except TtsError as exc:
137
+ if on_warning:
138
+ on_warning(f"narration for '{utterance.step_id}' ({locale}) failed: {exc}")
139
+ continue
140
+ if wav is None:
141
+ continue
142
+ if duration is None:
143
+ try:
144
+ duration = wav_duration_ms(wav)
145
+ except Exception as exc: # a clip we just produced and cannot read
146
+ if on_warning:
147
+ on_warning(f"narration for '{utterance.step_id}' ({locale}) is unreadable: {exc}")
148
+ continue
149
+ cache.set_duration(key, "wav", duration)
150
+ durations[utterance.step_id] = duration
151
+ clips[utterance.step_id] = wav
152
+ if memo is not None:
153
+ memo[key] = (wav, duration)
154
+
155
+ return Narration(durations_ms=durations, clips=clips, provider=resolved.name)
156
+
157
+
158
+ def _clip_for(request, key, cache, resolved, work_dir, ffmpeg, normalize, memo) -> tuple[Optional[Path], Optional[int]]:
159
+ """Fetch from the memo, then the cache, else synthesize.
160
+
161
+ Always returns normalized WAV, plus the clip's duration when it was already
162
+ known. A cached clip that will not open is treated as a **miss**: the entry
163
+ is dropped and re-synthesized, because a truncated WAV left by a killed run
164
+ is otherwise a permanent hit whose duration no longer matches the timeline
165
+ the video was rendered against.
166
+ """
167
+ from .track import is_intact_wav, wav_duration_ms
168
+
169
+ if memo is not None:
170
+ remembered = memo.get(key)
171
+ if remembered is not None and remembered[0].is_file():
172
+ return remembered
173
+
174
+ hit = cache.get(key, "wav")
175
+ if hit is not None:
176
+ if is_intact_wav(hit.path):
177
+ return hit.path, hit.duration_ms or wav_duration_ms(hit.path)
178
+ cache.discard(key, "wav")
179
+
180
+ clip = resolved.provider.synthesize(request)
181
+ if clip.empty:
182
+ return None, None
183
+
184
+ # ``.raw.`` keeps the pre-transcode container from ever colliding with the
185
+ # normalized artifact, which matters when the provider already returned WAV.
186
+ work_dir.mkdir(parents=True, exist_ok=True)
187
+ raw = work_dir / f"{key}.raw.{clip.fmt}"
188
+ raw.write_bytes(clip.audio)
189
+ try:
190
+ if not cache.enabled:
191
+ return normalize(raw, work_dir / f"{key}.wav", ffmpeg), None
192
+ staging = cache.staging_path(key, "wav")
193
+ try:
194
+ normalize(raw, staging, ffmpeg)
195
+ return cache.commit(key, staging, "wav"), None
196
+ except BaseException:
197
+ staging.unlink(missing_ok=True)
198
+ raise
199
+ finally:
200
+ # The normalized WAV is the artifact; the raw container is scratch, and
201
+ # leaving it behind is what made ``tts-cache/work/`` grow forever.
202
+ raw.unlink(missing_ok=True)
203
+
204
+
205
+ def _safe_version(provider) -> str:
206
+ try:
207
+ return str(provider.version())
208
+ except Exception: # pragma: no cover - a provider must not break on this
209
+ return "?"
210
+
211
+
212
+ # ── Job-level helpers, shared by ``steppy build`` and ``steppy guide`` ───────
213
+
214
+
215
+ def voice_jobs(
216
+ jobs: Sequence,
217
+ cfg,
218
+ *,
219
+ ffmpeg: str,
220
+ cache_root: Path,
221
+ work_dir: Path,
222
+ on_warning: Optional[Callable[[str], None]] = None,
223
+ ) -> list:
224
+ """Voice every job's steps before any browser opens.
225
+
226
+ The timeline needs each clip's *duration* up front, to stretch its step so
227
+ the voiceover is never cut off — which is why this cannot wait until render
228
+ time. Where each clip is *placed* is a separate question, decided by the
229
+ timeline and answered by :func:`assemble_track`.
230
+
231
+ One home for both entry points: ``steppy build --video`` and
232
+ ``steppy guide --video`` had byte-identical copies of this, and they had
233
+ already drifted apart on cache-path resolution. The batch shares one memo,
234
+ so the desktop and mobile jobs of one guide voice its steps once.
235
+
236
+ With ``tts.provider: none`` (the shipped default) this is a no-op.
237
+ """
238
+ from dataclasses import replace
239
+
240
+ if cfg.provider == "none":
241
+ return list(jobs)
242
+
243
+ memo: dict[str, tuple[Path, int]] = {}
244
+ voiced: list = []
245
+ for job in jobs:
246
+ narration = synthesize_steps(
247
+ [
248
+ Utterance(step_id=step["id"], text=(step.get("text") or {}).get(job.locale) or "")
249
+ for step in job.manifest["steps"]
250
+ ],
251
+ cfg,
252
+ locale=job.locale,
253
+ work_dir=work_dir,
254
+ ffmpeg=ffmpeg,
255
+ cache_root=cache_root,
256
+ memo=memo,
257
+ on_warning=on_warning,
258
+ )
259
+ voiced.append(
260
+ job
261
+ if narration.silent
262
+ else replace(job, narration_ms=narration.durations_ms, narration_clips=narration.clips)
263
+ )
264
+ return voiced
265
+
266
+
267
+ def assemble_track(job, cues: list, total_ms: int, dest: Path) -> Optional[Path]:
268
+ """Lay each step's clip at the offset the timeline gave that step.
269
+
270
+ Injected into ``render_videos`` rather than called from it: ``video.py`` is a
271
+ leaf module that must not import ``steppy.tts``, and the placement rule
272
+ genuinely belongs to whoever owns the narration.
273
+ """
274
+ if not job.narration_clips:
275
+ return None
276
+
277
+ from .track import assemble
278
+
279
+ placed = [(int(cue["startMs"]), job.narration_clips.get(cue["stepId"])) for cue in cues]
280
+ return assemble(placed, total_ms=total_ms, dest=dest)
steppy/tts/cache.py ADDED
@@ -0,0 +1,185 @@
1
+ """Content-addressed clip cache.
2
+
3
+ Not an optimisation — a correctness-of-cost feature. A cloud provider bills per
4
+ character, and a guide corpus re-synthesized on every CI run would bill for
5
+ text that has not changed since the last release. With the cache, only edited
6
+ narration costs anything.
7
+
8
+ Lives under ``.steppy/``, never ``output/``, for the same reason ``coverage.db``
9
+ and ``history.jsonl`` do: ``steppy build`` wipes its output directory on every
10
+ run, so a cache there would never survive to be a hit.
11
+
12
+ **Every write lands atomically.** A clip is written to a sibling temp file and
13
+ ``os.replace``d into position, because the failure mode otherwise is permanent:
14
+ a Ctrl-C or a full disk leaves a truncated WAV *at the final key path*, and a
15
+ lookup that only asks ``is_file()`` will serve it forever.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import tempfile
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Optional
27
+
28
+ from .types import SpeechClip, SpeechRequest
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class CachedClip:
33
+ path: Path
34
+ fmt: str
35
+ duration_ms: Optional[int]
36
+
37
+
38
+ def cache_key(request: SpeechRequest, provider: str, version: str) -> str:
39
+ """Everything that can change the audio, and nothing that cannot.
40
+
41
+ ``version`` is load-bearing for the local engines: espeak-ng and Piper
42
+ produce different audio across builds, so an unversioned key would serve a
43
+ stale clip whose duration no longer matches the timeline the video was
44
+ rendered against. For the HTTP engine it also carries a hash of the request
45
+ shape, so editing ``video.tts.http`` re-synthesizes rather than serving
46
+ audio the old body produced.
47
+
48
+ ``request.text`` is already whitespace-normalized by ``synthesize_steps``,
49
+ so re-wrapping a YAML block scalar is not a cache miss.
50
+ """
51
+ payload = {
52
+ "provider": provider,
53
+ "version": version,
54
+ "text": request.text,
55
+ "locale": request.locale,
56
+ "voice": request.voice or "",
57
+ "model": request.model or "",
58
+ "speed": round(float(request.speed), 4),
59
+ "fmt": request.fmt or "",
60
+ "options": request.options or {},
61
+ }
62
+ blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)
63
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
64
+
65
+
66
+ def atomic_replace(source: Path, dest: Path) -> Path:
67
+ """Move *source* onto *dest* in one indivisible step.
68
+
69
+ ``os.replace`` is atomic on NTFS and POSIX alike **as long as both paths sit
70
+ on the same filesystem** — which is why callers stage their temp file in
71
+ ``dest.parent`` rather than the system temp dir.
72
+ """
73
+ dest.parent.mkdir(parents=True, exist_ok=True)
74
+ os.replace(str(source), str(dest))
75
+ return dest
76
+
77
+
78
+ def atomic_write_bytes(dest: Path, data: bytes) -> Path:
79
+ return _atomic_write(dest, lambda handle: handle.write(data))
80
+
81
+
82
+ def atomic_write_text(dest: Path, text: str) -> Path:
83
+ return _atomic_write(dest, lambda handle: handle.write(text.encode("utf-8")))
84
+
85
+
86
+ def _atomic_write(dest: Path, write) -> Path:
87
+ dest.parent.mkdir(parents=True, exist_ok=True)
88
+ fd, name = tempfile.mkstemp(dir=str(dest.parent), prefix=f".{dest.name}.", suffix=".tmp")
89
+ try:
90
+ with os.fdopen(fd, "wb") as handle:
91
+ write(handle)
92
+ return atomic_replace(Path(name), dest)
93
+ except BaseException:
94
+ Path(name).unlink(missing_ok=True)
95
+ raise
96
+
97
+
98
+ def _touch(path: Path) -> None:
99
+ """Mark an entry as used, so LRU eviction means what it says.
100
+
101
+ Without this a clip reused daily for a year looks exactly as stale as one
102
+ abandoned on day two. Best-effort: a read-only cache is still a usable one.
103
+ """
104
+ try:
105
+ path.touch()
106
+ except OSError:
107
+ pass
108
+
109
+
110
+ class ClipCache:
111
+ def __init__(self, root: Path, enabled: bool = True) -> None:
112
+ self.root = Path(root)
113
+ self.enabled = enabled
114
+
115
+ def _paths(self, key: str, fmt: str) -> tuple[Path, Path]:
116
+ return self.root / f"{key}.{fmt}", self.root / f"{key}.json"
117
+
118
+ def get(self, key: str, fmt: str) -> Optional[CachedClip]:
119
+ if not self.enabled:
120
+ return None
121
+ audio, meta = self._paths(key, fmt)
122
+ if not audio.is_file():
123
+ return None
124
+ duration = None
125
+ if meta.is_file():
126
+ try:
127
+ duration = json.loads(meta.read_text(encoding="utf-8")).get("duration_ms")
128
+ except (ValueError, OSError):
129
+ # A torn sidecar just means we re-measure; the audio is still good.
130
+ duration = None
131
+ _touch(meta)
132
+ _touch(audio)
133
+ return CachedClip(path=audio, fmt=fmt, duration_ms=duration)
134
+
135
+ def put(self, key: str, clip: SpeechClip, duration_ms: Optional[int] = None) -> Optional[CachedClip]:
136
+ """Store raw provider bytes. Returns None when the cache is disabled —
137
+ there is no path to hand back, because nothing was written."""
138
+ if not self.enabled:
139
+ return None
140
+ audio, _ = self._paths(key, clip.fmt)
141
+ atomic_write_bytes(audio, clip.audio)
142
+ self.set_duration(key, clip.fmt, duration_ms)
143
+ return CachedClip(path=audio, fmt=clip.fmt, duration_ms=duration_ms)
144
+
145
+ def commit(self, key: str, source: Path, fmt: str = "wav") -> Path:
146
+ """Adopt an already-produced file as this key's cache entry.
147
+
148
+ The normalize step writes into the cache directory directly (so the
149
+ transcode is not paid twice), then hands the temp file here to be moved
150
+ into position — the move being the only moment the entry becomes
151
+ visible to a lookup.
152
+ """
153
+ if not self.enabled:
154
+ return source
155
+ audio, _ = self._paths(key, fmt)
156
+ if source == audio:
157
+ return audio
158
+ return atomic_replace(source, audio)
159
+
160
+ def set_duration(self, key: str, fmt: str, duration_ms: Optional[int]) -> None:
161
+ if not self.enabled:
162
+ return
163
+ _, meta = self._paths(key, fmt)
164
+ atomic_write_text(
165
+ meta,
166
+ json.dumps({"duration_ms": duration_ms, "format": fmt}, ensure_ascii=False) + "\n",
167
+ )
168
+
169
+ def discard(self, key: str, fmt: str) -> None:
170
+ """Drop a poisoned entry so the next lookup is an honest miss."""
171
+ for path in self._paths(key, fmt):
172
+ try:
173
+ path.unlink(missing_ok=True)
174
+ except OSError:
175
+ pass
176
+
177
+ def staging_path(self, key: str, fmt: str) -> Path:
178
+ """Where a producer should write before calling :meth:`commit`.
179
+
180
+ Same directory as the final entry, so ``os.replace`` stays atomic; and
181
+ pid-suffixed, so two concurrent builds sharing one cache stage into
182
+ different files and the loser's replace is merely redundant.
183
+ """
184
+ self.root.mkdir(parents=True, exist_ok=True)
185
+ return self.root / f".{key}.{fmt}.{os.getpid()}.tmp"
steppy/tts/presets.py ADDED
@@ -0,0 +1,125 @@
1
+ """Vendor TTS presets — pure data, no logic.
2
+
3
+ Every cloud text-to-speech API is the same shape: POST some JSON, get audio
4
+ back. So Steppy has one generic HTTP provider and this table of vendor shapes,
5
+ rather than a class per vendor. Adding ElevenLabs' next competitor is a dict
6
+ entry; adding one Steppy has never heard of needs no code at all, just
7
+ ``video.tts.http`` filled in directly. That is what makes the promise —
8
+ "change the provider by changing config" — literally true.
9
+
10
+ Mirrors ``steppy/presets.py`` (the UI-kit selector packs) in both spirit and
11
+ mechanics: a data registry plus a resolver that lets explicitly-set fields win.
12
+
13
+ **Keep this module free of ``httpx``.** It sits on an import path that reaches
14
+ the default CLI path, and the negative-import guard enforces it.
15
+
16
+ Placeholders available in any string leaf of ``headers`` / ``json`` / ``body``:
17
+
18
+ {text} {voice} {locale} {model} {speed} {api_key} {region}
19
+
20
+ plus every key of ``video.tts.options``. Substitution happens on the parsed
21
+ structure, never on serialized JSON, so narration containing a brace is safe.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any
27
+
28
+ # ``format`` is what the provider returns, not what the user asks for; the audio
29
+ # assembler transcodes to WAV as needed. ``defaults`` fills fields the user left
30
+ # unset — an explicit ``video.tts.model`` always wins.
31
+ TTS_PRESETS: dict[str, dict[str, Any]] = {
32
+ "elevenlabs": {
33
+ "url": "https://api.elevenlabs.io/v1/text-to-speech/{voice}",
34
+ "method": "POST",
35
+ "headers": {
36
+ "xi-api-key": "{api_key}",
37
+ "accept": "audio/mpeg",
38
+ "content-type": "application/json",
39
+ },
40
+ "json": {
41
+ "text": "{text}",
42
+ "model_id": "{model}",
43
+ },
44
+ "response": "binary",
45
+ "encoding": "raw",
46
+ "format": "mp3",
47
+ "defaults": {
48
+ # Multilingual by default: a per-locale guide is the normal case here.
49
+ "model": "eleven_multilingual_v2",
50
+ },
51
+ "requires": ["api_key", "voice"],
52
+ "docs": "https://elevenlabs.io/docs/api-reference/text-to-speech",
53
+ },
54
+ "openai": {
55
+ "url": "{base_url}/audio/speech",
56
+ "method": "POST",
57
+ "headers": {
58
+ "Authorization": "Bearer {api_key}",
59
+ "content-type": "application/json",
60
+ },
61
+ "json": {
62
+ "model": "{model}",
63
+ "input": "{text}",
64
+ "voice": "{voice}",
65
+ "speed": "{speed}",
66
+ # WAV keeps the assembler on the stdlib path (no transcode).
67
+ "response_format": "wav",
68
+ },
69
+ "response": "binary",
70
+ "encoding": "raw",
71
+ "format": "wav",
72
+ "defaults": {
73
+ "model": "gpt-4o-mini-tts",
74
+ "voice": "alloy",
75
+ "base_url": "https://api.openai.com/v1",
76
+ },
77
+ "requires": ["api_key"],
78
+ "docs": "https://platform.openai.com/docs/api-reference/audio/createSpeech",
79
+ },
80
+ "azure": {
81
+ "url": "https://{region}.tts.speech.microsoft.com/cognitiveservices/v1",
82
+ "method": "POST",
83
+ "headers": {
84
+ "Ocp-Apim-Subscription-Key": "{api_key}",
85
+ "Content-Type": "application/ssml+xml",
86
+ "X-Microsoft-OutputFormat": "riff-24khz-16bit-mono-pcm",
87
+ },
88
+ # Azure speaks SSML, not JSON — which is exactly why the engine supports
89
+ # a raw body as well as a JSON one.
90
+ "body": (
91
+ "<speak version='1.0' xml:lang='{locale}'>"
92
+ "<voice xml:lang='{locale}' name='{voice}'>"
93
+ "<prosody rate='{speed}'>{text}</prosody>"
94
+ "</voice></speak>"
95
+ ),
96
+ "response": "binary",
97
+ "encoding": "raw",
98
+ "format": "wav",
99
+ "defaults": {},
100
+ "requires": ["api_key", "region", "voice"],
101
+ "docs": "https://learn.microsoft.com/azure/ai-services/speech-service/rest-text-to-speech",
102
+ },
103
+ "google": {
104
+ "url": "https://texttospeech.googleapis.com/v1/text:synthesize?key={api_key}",
105
+ "method": "POST",
106
+ "headers": {"content-type": "application/json"},
107
+ "json": {
108
+ "input": {"text": "{text}"},
109
+ "voice": {"languageCode": "{locale}", "name": "{voice}"},
110
+ "audioConfig": {"audioEncoding": "LINEAR16", "speakingRate": "{speed}"},
111
+ },
112
+ # Google wraps the audio in JSON as base64 rather than returning it raw.
113
+ "response": "json",
114
+ "audio_path": "audioContent",
115
+ "encoding": "base64",
116
+ "format": "wav",
117
+ "defaults": {},
118
+ "requires": ["api_key", "voice"],
119
+ "docs": "https://cloud.google.com/text-to-speech/docs/reference/rest/v1/text/synthesize",
120
+ },
121
+ }
122
+
123
+
124
+ def available_presets() -> list[str]:
125
+ return sorted(TTS_PRESETS)
@@ -0,0 +1,6 @@
1
+ """Built-in TTS provider implementations.
2
+
3
+ Imported lazily by ``registry._build_builtin`` so that naming a provider in
4
+ config costs nothing until a video is actually rendered — and so ``httpx`` stays
5
+ off the default CLI path.
6
+ """