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/video.py ADDED
@@ -0,0 +1,730 @@
1
+ """Video export — deterministic frame-stepping over the interactive manifest.
2
+
3
+ Steppy does not screen-record anything. The interactive guide manifest already
4
+ carries every screenshot, cursor bbox and narration string; ``packages/player``
5
+ turns that into a timeline whose state at any instant is a pure function of the
6
+ inputs. This module walks that timeline frame by frame in headless Chromium and
7
+ pipes the stills to ffmpeg, so the same guide renders to the same video on a
8
+ laptop and on a throttled CI runner — which is the whole claim behind
9
+ "regenerated in CI on every release".
10
+
11
+ A **leaf module**: stdlib + ``models`` + ``_player_bundle`` only, and
12
+ ``playwright`` is imported inside :func:`render_videos` so importing
13
+ ``steppy.cli`` stays cheap (guard:
14
+ ``tests/test_no_video_import_on_default_path.py``). It also never imports
15
+ ``steppy.tts`` — narration arrives as durations and a finished WAV, computed
16
+ before the browser opens.
17
+
18
+ Four invariants worth keeping if you touch this:
19
+
20
+ * **The harness is written beside the page it belongs to.** Screenshot URLs in
21
+ the manifest are relative, and Chromium resolves them against the document —
22
+ so the harness lands in the same directory as the guide's own HTML, exactly
23
+ as the PDF exporter loads over ``file://`` for the same reason.
24
+ * **The manifest is inlined, never fetched.** ``fetch`` on a ``file://`` page is
25
+ blocked by the opaque-origin rule, so there is no version of this that reads
26
+ ``guide.json`` over the wire.
27
+ * **ffmpeg's stderr goes to a file, never a pipe.** An undrained stderr pipe
28
+ fills, ffmpeg blocks writing to it, stops reading stdin, and the frame loop
29
+ deadlocks with no output and no error.
30
+ * **``reduced_motion`` is pinned.** The player's stylesheet disables every
31
+ animation under ``prefers-reduced-motion``; a render host that emulates it
32
+ would silently produce cursor-less video with nothing failing.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import hashlib
38
+ import json
39
+ import os
40
+ import shutil
41
+ import subprocess
42
+ import sys
43
+ import tempfile
44
+ from dataclasses import dataclass, field
45
+ from pathlib import Path
46
+ from typing import Callable, Optional, Sequence
47
+
48
+ from ._player_bundle import get_video_iife
49
+ from .models import VideoConfig
50
+
51
+
52
+ class VideoRenderError(Exception):
53
+ """Fatal video-export failure — playwright, Chromium or ffmpeg unusable.
54
+
55
+ Per-guide failures are *not* this: they land in a :class:`VideoResult` so
56
+ one broken guide can't cost the other forty.
57
+ """
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class VideoJob:
62
+ """One (guide, locale, layout) video to render."""
63
+
64
+ manifest: dict
65
+ # Directory the harness is written to; relative screenshot URLs resolve here.
66
+ page_dir: Path
67
+ out: Path
68
+ locale: str
69
+ layout: Optional[str] = None
70
+ title: str = ""
71
+ subtitle: str = ""
72
+ outro: str = ""
73
+ stamp: str = ""
74
+ logo_data_uri: str = ""
75
+ # Narration: how long each step's voiceover runs (the timeline needs this up
76
+ # front, to stretch the step so nothing is cut off) and where each clip is.
77
+ # They are assembled into one track only once the timeline has decided where
78
+ # each step actually starts — see ``assemble_audio`` on ``render_videos``.
79
+ narration_ms: dict[str, int] = field(default_factory=dict)
80
+ narration_clips: dict[str, Path] = field(default_factory=dict)
81
+
82
+ @property
83
+ def vtt(self) -> Path:
84
+ return self.out.with_suffix(".vtt")
85
+
86
+ @property
87
+ def poster(self) -> Path:
88
+ return self.out.with_suffix(".jpg")
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class VideoResult:
93
+ job: VideoJob
94
+ ok: bool
95
+ error: Optional[str] = None
96
+ duration_ms: int = 0
97
+ frames: int = 0
98
+ # Frames actually screenshotted; the rest reused the previous still.
99
+ rendered_frames: int = 0
100
+ cached: bool = False
101
+
102
+
103
+ _FFMPEG_HINT = (
104
+ "ffmpeg is required for video export (even with tts.provider: none — a\n"
105
+ "silent captioned video still has to be encoded). Install it with:\n"
106
+ " Windows: winget install Gyan.FFmpeg\n"
107
+ " macOS: brew install ffmpeg\n"
108
+ " Debian: sudo apt-get install ffmpeg\n"
109
+ "or point config.yaml's video.ffmpeg_path at an existing binary."
110
+ )
111
+
112
+ _BROWSER_HINT = "Chromium is required for video export. Install it with:\n python -m playwright install chromium"
113
+
114
+ # Placeholders are substituted with str.replace, not str.format: the template
115
+ # carries CSS and JS, and brace-escaping a 40 KB bundle into a format string is
116
+ # a footgun with no upside.
117
+ _HARNESS = """<!doctype html>
118
+ <html><head><meta charset="utf-8"><title>steppy video</title>
119
+ <style>
120
+ html, body { margin: 0; padding: 0; background: #f4f1ea; overflow: hidden; }
121
+ #steppy-video { display: block; }
122
+ </style>
123
+ </head><body>
124
+ <div id="steppy-video"></div>
125
+ <script type="application/json" id="steppy-manifest">/*__STEPPY_MANIFEST__*/</script>
126
+ <script type="application/json" id="steppy-options">/*__STEPPY_OPTIONS__*/</script>
127
+ <script>/*__STEPPY_BUNDLE__*/</script>
128
+ <script>
129
+ window.__steppyReady = (async () => {
130
+ const manifest = JSON.parse(document.getElementById('steppy-manifest').textContent);
131
+ const options = JSON.parse(document.getElementById('steppy-options').textContent);
132
+ const stage = window.__STEPPY_VIDEO__.mount(manifest, options);
133
+ await stage.ready();
134
+ return {
135
+ size: stage.size(),
136
+ frameCount: stage.frameCount,
137
+ durationMs: stage.durationMs,
138
+ cues: stage.cues(),
139
+ missing: stage.missingShots(),
140
+ };
141
+ })();
142
+ </script>
143
+ </body></html>
144
+ """
145
+
146
+
147
+ def find_ffmpeg(options: VideoConfig) -> str:
148
+ """Locate the ffmpeg binary, or explain how to get one.
149
+
150
+ Deliberately *not* falling back to the ffmpeg Playwright bundles for its own
151
+ webm recording: that build has no libx264, so using it would produce a file
152
+ named ``.mp4`` that nothing can play — a worse failure than this one.
153
+ """
154
+ candidate = options.ffmpeg_path or "ffmpeg"
155
+ found = shutil.which(candidate)
156
+ if found:
157
+ return found
158
+ if os.path.isfile(candidate):
159
+ return candidate
160
+ raise VideoRenderError(f"ffmpeg not found (looked for {candidate!r}).\n{_FFMPEG_HINT}")
161
+
162
+
163
+ def ffmpeg_args(
164
+ options: VideoConfig,
165
+ *,
166
+ ffmpeg: str,
167
+ width: int,
168
+ height: int,
169
+ audio: Optional[Path],
170
+ out: Path,
171
+ ) -> list[str]:
172
+ """Build the encoder command line.
173
+
174
+ Split out from :func:`render_videos` so the mapping is testable without
175
+ ffmpeg — the same reason ``pdf.pdf_options`` is its own function.
176
+ """
177
+ args = [
178
+ ffmpeg,
179
+ "-y",
180
+ "-hide_banner",
181
+ "-nostats",
182
+ "-loglevel",
183
+ "error",
184
+ "-f",
185
+ "image2pipe",
186
+ "-framerate",
187
+ str(options.fps),
188
+ "-i",
189
+ "-",
190
+ ]
191
+ if audio is not None:
192
+ args += ["-i", str(audio)]
193
+ args += [
194
+ "-c:v",
195
+ "libx264",
196
+ "-pix_fmt",
197
+ "yuv420p",
198
+ "-preset",
199
+ options.preset,
200
+ "-crf",
201
+ str(options.crf),
202
+ # The stage already reports even dimensions; this is belt-and-braces for
203
+ # libx264, which refuses odd sizes outright under yuv420p.
204
+ "-vf",
205
+ f"scale={width}:{height},pad=ceil(iw/2)*2:ceil(ih/2)*2",
206
+ ]
207
+ if audio is not None:
208
+ args += ["-c:a", "aac", "-b:a", "128k", "-shortest"]
209
+ args += ["-movflags", "+faststart", str(out)]
210
+ return args
211
+
212
+
213
+ def _vtt_timestamp(ms: int) -> str:
214
+ ms = max(0, int(ms))
215
+ hours, rem = divmod(ms, 3_600_000)
216
+ minutes, rem = divmod(rem, 60_000)
217
+ seconds, millis = divmod(rem, 1000)
218
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}"
219
+
220
+
221
+ def build_vtt(cues: Sequence[dict]) -> str:
222
+ """WebVTT captions from the timeline's cues.
223
+
224
+ Nearly free once the timeline exists, and the difference between a video and
225
+ an accessible, indexable, uploadable one.
226
+ """
227
+ lines = ["WEBVTT", ""]
228
+ for i, cue in enumerate(cues, start=1):
229
+ text = (cue.get("text") or "").strip()
230
+ if not text:
231
+ continue
232
+ lines.append(str(i))
233
+ lines.append(f"{_vtt_timestamp(cue['startMs'])} --> {_vtt_timestamp(cue['endMs'])}")
234
+ lines.append(text)
235
+ lines.append("")
236
+ return "\n".join(lines)
237
+
238
+
239
+ # Config fields that cannot change a single pixel. Excluded from the render
240
+ # fingerprint so that setting an API key, moving the cache, or bumping a timeout
241
+ # does not re-encode an entire corpus. A **denylist** rather than an allowlist:
242
+ # a field added later is invalidating by default, which is the safe direction.
243
+ _NON_VISUAL_OPTIONS = frozenset({"tts", "cache", "cache_dir", "ffmpeg_path", "timeout"})
244
+
245
+
246
+ def job_fingerprint(
247
+ job: VideoJob,
248
+ options: VideoConfig,
249
+ bundle_hash: str,
250
+ hashes: Optional[dict[Path, str]] = None,
251
+ ) -> str:
252
+ """Content address for a render: same inputs ⇒ same video ⇒ skip the work.
253
+
254
+ Includes the player bundle's hash, so a change to the animation code
255
+ invalidates every cached video rather than silently shipping the old motion,
256
+ and the **bytes** of every screenshot — the manifest only carries their URLs,
257
+ so a recapture that leaves the target element where it was would otherwise
258
+ reuse a video of the old UI.
259
+
260
+ ``hashes`` memoizes file digests for the lifetime of one batch; a corpus is
261
+ guides x locales x layouts over a shared pool of PNGs, so without it each
262
+ file is read many times over.
263
+ """
264
+ if hashes is None:
265
+ hashes = {}
266
+ payload = {
267
+ "manifest": job.manifest,
268
+ "locale": job.locale,
269
+ "layout": job.layout,
270
+ "title": job.title,
271
+ "subtitle": job.subtitle,
272
+ "outro": job.outro,
273
+ "stamp": job.stamp,
274
+ "logo": hashlib.sha256(job.logo_data_uri.encode("utf-8")).hexdigest() if job.logo_data_uri else "",
275
+ "narration": job.narration_ms,
276
+ "audio": sorted((step, _file_hash(path, hashes)) for step, path in job.narration_clips.items()),
277
+ "screenshots": _screenshot_hashes(job, hashes),
278
+ "options": options.model_dump(mode="json", exclude=set(_NON_VISUAL_OPTIONS)),
279
+ "bundle": bundle_hash,
280
+ }
281
+ blob = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str)
282
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
283
+
284
+
285
+ def screenshot_urls(manifest: dict) -> list[str]:
286
+ """Every screenshot the manifest references, in stable order.
287
+
288
+ The shape is ``steps[].captures[locale][layout].frames[].screenshot`` (see
289
+ ``guide.prepare_interactive_manifest``); a video manifest holds exactly one
290
+ locale and one layout, but walking the whole structure costs nothing and
291
+ keeps this honest if that ever changes.
292
+ """
293
+ urls: set[str] = set()
294
+ for step in manifest.get("steps") or []:
295
+ for by_layout in (step.get("captures") or {}).values():
296
+ for capture in (by_layout or {}).values():
297
+ for frame in (capture or {}).get("frames") or []:
298
+ url = frame.get("screenshot")
299
+ if url:
300
+ urls.add(str(url))
301
+ return sorted(urls)
302
+
303
+
304
+ def _screenshot_hashes(job: VideoJob, hashes: dict[Path, str]) -> list[list[str]]:
305
+ """Pair each screenshot URL with the digest of the file it resolves to.
306
+
307
+ URLs in a video manifest are relative to ``page_dir`` by construction — that
308
+ is the same property the harness relies on to load them over ``file://``.
309
+ """
310
+ out: list[list[str]] = []
311
+ for url in screenshot_urls(job.manifest):
312
+ out.append([url, _file_hash(_resolve_media(job.page_dir, url), hashes)])
313
+ return out
314
+
315
+
316
+ def _resolve_media(page_dir: Path, url: str) -> Optional[Path]:
317
+ if not url or "://" in url:
318
+ return None
319
+ try:
320
+ return (page_dir / url.split("?", 1)[0].split("#", 1)[0]).resolve()
321
+ except OSError:
322
+ return None
323
+
324
+
325
+ def _file_hash(path: Optional[Path], hashes: Optional[dict[Path, str]] = None) -> str:
326
+ if not path:
327
+ return ""
328
+ if hashes is not None and path in hashes:
329
+ return hashes[path]
330
+ digest = ""
331
+ if path.is_file():
332
+ acc = hashlib.sha256()
333
+ with path.open("rb") as fh:
334
+ for chunk in iter(lambda: fh.read(65536), b""):
335
+ acc.update(chunk)
336
+ digest = acc.hexdigest()
337
+ if hashes is not None:
338
+ hashes[path] = digest
339
+ return digest
340
+
341
+
342
+ def _stage_options(job: VideoJob, options: VideoConfig) -> dict:
343
+ return {
344
+ "locale": job.locale,
345
+ "layout": job.layout,
346
+ "fps": options.fps,
347
+ "width": options.width,
348
+ "maxHeight": options.max_height,
349
+ "captions": options.captions,
350
+ "holdMs": options.hold_ms,
351
+ "minStepMs": options.min_step_ms,
352
+ "narrationPadMs": options.narration_pad_ms,
353
+ "narrationMs": job.narration_ms,
354
+ "introMs": options.intro_ms,
355
+ "outroMs": options.outro_ms,
356
+ "title": job.title,
357
+ "subtitle": job.subtitle,
358
+ "outroText": job.outro,
359
+ "stamp": job.stamp,
360
+ "logo": job.logo_data_uri or None,
361
+ "accent": options.branding.accent,
362
+ "fontFamily": options.branding.font_family,
363
+ "imageTimeoutMs": min(options.timeout, 30000),
364
+ }
365
+
366
+
367
+ def _default_warn(message: str) -> None:
368
+ print(f"WARNING: {message}", file=sys.stderr)
369
+
370
+
371
+ def render_videos(
372
+ jobs: Sequence[VideoJob],
373
+ options: Optional[VideoConfig] = None,
374
+ *,
375
+ assemble_audio: Optional[Callable[[VideoJob, list, int, Path], Optional[Path]]] = None,
376
+ cache_root: Optional[Path] = None,
377
+ on_done: Optional[Callable[[VideoResult], None]] = None,
378
+ on_progress: Optional[Callable[[VideoJob, int, int], None]] = None,
379
+ on_warning: Optional[Callable[[str], None]] = None,
380
+ ) -> list[VideoResult]:
381
+ """Render every job, reusing one browser and one page for the batch.
382
+
383
+ A guide project is a cartesian of guides x locales x layouts, so the batch is
384
+ routinely in the dozens; a browser launch per video would dominate an already
385
+ expensive operation.
386
+
387
+ ``assemble_audio(job, cues, total_ms, dest)`` is called once the page has
388
+ reported its timeline, and returns the narration track to mux (or None). It
389
+ is injected rather than imported because this module must not depend on
390
+ ``steppy.tts`` — and because the cue offsets it needs only exist after the
391
+ browser has measured the stage.
392
+
393
+ ``cache_root`` overrides ``options.cache_dir``, which is a bare relative path
394
+ and so resolves against the process CWD. Callers pass it already resolved
395
+ against the project root, the same rule ``config.output.*`` follows, so one
396
+ project has one cache however the command was launched.
397
+
398
+ ``on_done`` fires after each job and ``on_progress`` during one, so callers
399
+ can stream progress. Returns one :class:`VideoResult` per job, in order.
400
+ Raises :class:`VideoRenderError` only when the toolchain itself is unusable.
401
+ """
402
+ if not jobs:
403
+ return []
404
+
405
+ options = options or VideoConfig()
406
+ warn = on_warning or _default_warn
407
+ ffmpeg = find_ffmpeg(options)
408
+ cache_dir = Path(cache_root) if cache_root else Path(options.cache_dir)
409
+
410
+ try:
411
+ from playwright.sync_api import sync_playwright
412
+ except ImportError as exc: # pragma: no cover - playwright is a hard dep
413
+ raise VideoRenderError(f"playwright is not installed: {exc}") from exc
414
+
415
+ bundle = get_video_iife()
416
+ bundle_hash = hashlib.sha256(bundle.encode("utf-8")).hexdigest()
417
+ # One digest per file for the whole batch: a corpus is guides x locales x
418
+ # layouts over a shared pool of screenshots.
419
+ hashes: dict[Path, str] = {}
420
+
421
+ results: list[VideoResult] = []
422
+ try:
423
+ with sync_playwright() as p:
424
+ try:
425
+ browser = p.chromium.launch()
426
+ except Exception as exc:
427
+ raise VideoRenderError(f"Could not launch Chromium: {exc}\n{_BROWSER_HINT}") from exc
428
+ try:
429
+ # The player's stylesheet kills every animation under
430
+ # prefers-reduced-motion. A host that emulates it would produce
431
+ # cursor-less video with nothing failing anywhere.
432
+ context = browser.new_context(reduced_motion="no-preference")
433
+ page = context.new_page()
434
+ for job in jobs:
435
+ result = _render_one(
436
+ page,
437
+ job,
438
+ options,
439
+ ffmpeg,
440
+ bundle,
441
+ bundle_hash,
442
+ warn,
443
+ on_progress,
444
+ assemble_audio,
445
+ cache_dir,
446
+ hashes,
447
+ )
448
+ results.append(result)
449
+ if on_done:
450
+ on_done(result)
451
+ finally:
452
+ browser.close()
453
+ except VideoRenderError:
454
+ raise
455
+ except Exception as exc: # driver startup / teardown failures
456
+ raise VideoRenderError(f"Video export failed: {exc}\n{_BROWSER_HINT}") from exc
457
+
458
+ return results
459
+
460
+
461
+ def _render_one(
462
+ page,
463
+ job: VideoJob,
464
+ options: VideoConfig,
465
+ ffmpeg: str,
466
+ bundle: str,
467
+ bundle_hash: str,
468
+ warn: Callable[[str], None],
469
+ on_progress: Optional[Callable[[VideoJob, int, int], None]],
470
+ assemble_audio: Optional[Callable[[VideoJob, list, int, Path], Optional[Path]]] = None,
471
+ cache_dir: Optional[Path] = None,
472
+ hashes: Optional[dict[Path, str]] = None,
473
+ ) -> VideoResult:
474
+ """Render a single job. Never raises — failures come back as a result."""
475
+ harness: Optional[Path] = None
476
+ audio_dir: Optional[tempfile.TemporaryDirectory] = None
477
+ staging: Optional[Path] = None
478
+ try:
479
+ job.out.parent.mkdir(parents=True, exist_ok=True)
480
+
481
+ root = Path(cache_dir) if cache_dir is not None else Path(options.cache_dir)
482
+ cache_key = job_fingerprint(job, options, bundle_hash, hashes) if options.cache else ""
483
+ cached = (root / f"{cache_key}.mp4") if cache_key else None
484
+ if cached and _cache_entry_is_usable(cached):
485
+ _touch_entry(cached)
486
+ shutil.copyfile(cached, job.out)
487
+ _copy_sidecars(cached, job)
488
+ return VideoResult(job=job, ok=True, cached=True)
489
+
490
+ job.page_dir.mkdir(parents=True, exist_ok=True)
491
+ harness = _write_harness(job, options, bundle)
492
+
493
+ page.goto(harness.resolve().as_uri(), wait_until="load", timeout=options.timeout)
494
+ info = page.evaluate("() => window.__steppyReady")
495
+
496
+ missing = info.get("missing") or []
497
+ if missing:
498
+ # A gappy video still beats no video, and naming the files is what
499
+ # turns "the middle is blank" into a one-line fix.
500
+ warn(
501
+ f"{job.out.name}: {len(missing)} screenshot(s) did not load "
502
+ f"(first: {missing[0]}). The video will show gaps."
503
+ )
504
+
505
+ size = info["size"]
506
+ width, height = int(size["width"]), int(size["height"])
507
+ frame_count = int(info["frameCount"])
508
+ duration_ms = int(info.get("durationMs") or 0)
509
+ cues = info.get("cues") or []
510
+ page.set_viewport_size({"width": width, "height": height})
511
+
512
+ # Now — and only now — the timeline has said where each step starts, so
513
+ # the narration can be laid against it.
514
+ audio: Optional[Path] = None
515
+ if assemble_audio is not None and job.narration_clips:
516
+ audio_dir = tempfile.TemporaryDirectory(prefix="steppy-audio-")
517
+ audio = assemble_audio(job, cues, duration_ms, Path(audio_dir.name) / "narration.wav")
518
+
519
+ # ffmpeg never writes the final path directly. A killed encode would
520
+ # otherwise leave a truncated file *at the cache key*, and every later
521
+ # run would copy it out and report a hit — the failure is silent, and
522
+ # permanent. Stage beside the target, then replace in one step.
523
+ target = cached if cached else job.out
524
+ target.parent.mkdir(parents=True, exist_ok=True)
525
+ staging = _staging_path(target)
526
+ rendered = _pipe_frames(page, job, options, ffmpeg, width, height, frame_count, staging, on_progress, audio)
527
+ os.replace(str(staging), str(target))
528
+ staging = None
529
+
530
+ # Always write the track, even when every cue is empty. The page that
531
+ # embeds this video advertises a ``<track src=...>`` decided at build
532
+ # time, long before the render knows whether there is anything to say —
533
+ # so "no captions" has to be an empty track, not a missing file.
534
+ job.vtt.write_text(build_vtt(cues), encoding="utf-8")
535
+
536
+ if cached:
537
+ shutil.copyfile(cached, job.out)
538
+ # Seed the sidecars too, so a later cache hit restores the whole set
539
+ # rather than an .mp4 with no captions and no poster.
540
+ seeded = []
541
+ if job.vtt.is_file():
542
+ _cache_sidecar(cached, ".vtt").write_text(job.vtt.read_text(encoding="utf-8"), encoding="utf-8")
543
+ seeded.append(".vtt")
544
+ if job.poster.is_file():
545
+ _cache_sidecar(cached, ".jpg").write_bytes(job.poster.read_bytes())
546
+ seeded.append(".jpg")
547
+ # Record what the entry is supposed to carry. Without it, an entry
548
+ # whose sidecars were lost is indistinguishable from one that never
549
+ # had them — and the first case must re-render, not ship silently.
550
+ _cache_sidecar(cached, ".meta").write_text(
551
+ json.dumps({"sidecars": seeded}) + "\n",
552
+ encoding="utf-8",
553
+ )
554
+
555
+ return VideoResult(
556
+ job=job,
557
+ ok=True,
558
+ duration_ms=duration_ms,
559
+ frames=frame_count,
560
+ rendered_frames=rendered,
561
+ )
562
+ except Exception as exc:
563
+ return VideoResult(job=job, ok=False, error=str(exc))
564
+ finally:
565
+ if harness is not None:
566
+ harness.unlink(missing_ok=True)
567
+ if audio_dir is not None:
568
+ audio_dir.cleanup()
569
+ if staging is not None:
570
+ staging.unlink(missing_ok=True)
571
+
572
+
573
+ def _pipe_frames(
574
+ page,
575
+ job: VideoJob,
576
+ options: VideoConfig,
577
+ ffmpeg: str,
578
+ width: int,
579
+ height: int,
580
+ frame_count: int,
581
+ out: Path,
582
+ on_progress: Optional[Callable[[VideoJob, int, int], None]],
583
+ audio: Optional[Path] = None,
584
+ ) -> int:
585
+ """Step every frame into ffmpeg's stdin. Returns the count actually captured.
586
+
587
+ Frames are JPEG rather than PNG: the output is lossy H.264 either way, so PNG
588
+ buys nothing and costs several times more in encode and CDP transfer. A seek
589
+ that reports no pixel change reuses the previous buffer instead of taking
590
+ another screenshot — holds and voiceover pauses dominate a narrated video, so
591
+ this is the difference between minutes and hours across a real corpus.
592
+ """
593
+ args = ffmpeg_args(options, ffmpeg=ffmpeg, width=width, height=height, audio=audio, out=out)
594
+
595
+ # stderr to a temp file, never a pipe: an undrained pipe fills, ffmpeg blocks
596
+ # writing to it, stops reading stdin, and this loop hangs forever.
597
+ with tempfile.TemporaryFile() as errfile:
598
+ proc = subprocess.Popen(
599
+ args,
600
+ stdin=subprocess.PIPE,
601
+ stdout=subprocess.DEVNULL,
602
+ stderr=errfile,
603
+ )
604
+ rendered = 0
605
+ last: bytes = b""
606
+ # Poster = the first frame past the intro card. Grabbing frame 0 would
607
+ # make every thumbnail on the site the same title screen.
608
+ poster_at = min(frame_count - 1, -(-options.intro_ms * options.fps // 1000))
609
+ try:
610
+ assert proc.stdin is not None
611
+ for index in range(frame_count):
612
+ dirty = page.evaluate("(i) => window.__STEPPY_VIDEO__.stage.seek(i)", index)
613
+ if dirty or not last:
614
+ last = page.screenshot(
615
+ type="jpeg",
616
+ quality=options.jpeg_quality,
617
+ animations="disabled",
618
+ )
619
+ rendered += 1
620
+ if index == poster_at and last:
621
+ job.poster.parent.mkdir(parents=True, exist_ok=True)
622
+ job.poster.write_bytes(last)
623
+ proc.stdin.write(last)
624
+ if on_progress:
625
+ on_progress(job, index + 1, frame_count)
626
+ proc.stdin.close()
627
+ code = proc.wait(timeout=max(60, options.timeout // 1000))
628
+ except BaseException:
629
+ # An orphaned ffmpeg keeps the output file open, and the next build's
630
+ # rmtree of output/ then fails on Windows.
631
+ proc.kill()
632
+ proc.wait(timeout=10)
633
+ raise
634
+ if code != 0:
635
+ errfile.seek(0)
636
+ tail = errfile.read().decode("utf-8", "replace").strip().splitlines()[-8:]
637
+ raise RuntimeError(f"ffmpeg exited {code}: " + " / ".join(tail))
638
+ return rendered
639
+
640
+
641
+ def _write_harness(job: VideoJob, options: VideoConfig, bundle: str) -> Path:
642
+ """Write the throwaway render page next to the guide it belongs to.
643
+
644
+ It must live in ``page_dir``: the manifest's screenshot URLs are relative to
645
+ the guide's own HTML, and Chromium resolves them against the document.
646
+ """
647
+ html = (
648
+ _HARNESS.replace("/*__STEPPY_MANIFEST__*/", json.dumps(job.manifest, ensure_ascii=False))
649
+ .replace("/*__STEPPY_OPTIONS__*/", json.dumps(_stage_options(job, options), ensure_ascii=False))
650
+ .replace("/*__STEPPY_BUNDLE__*/", bundle)
651
+ )
652
+ fd, name = tempfile.mkstemp(prefix=".steppy-video-", suffix=".html", dir=str(job.page_dir))
653
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
654
+ fh.write(html)
655
+ return Path(name)
656
+
657
+
658
+ def _cache_path(options: VideoConfig, key: str) -> Path:
659
+ """Cached renders live under ``.steppy/``, never ``output/``.
660
+
661
+ ``steppy build`` wipes its output directory on every run, so a sidecar next
662
+ to the artifact would be gone before it could ever be a hit.
663
+ """
664
+ return Path(options.cache_dir) / f"{key}.mp4"
665
+
666
+
667
+ def _staging_path(target: Path) -> Path:
668
+ """A temp path beside *target*.
669
+
670
+ ``os.replace`` is only atomic within one filesystem, so the system temp dir
671
+ will not do. Pid-suffixed so two builds sharing a cache stage into different
672
+ files — and it **keeps the real extension**, because ffmpeg picks its muxer
673
+ from the output filename and would refuse a bare ``.tmp``.
674
+ """
675
+ return target.with_name(f".{target.stem}.{os.getpid()}.tmp{target.suffix}")
676
+
677
+
678
+ def _cache_entry_is_usable(cached: Path) -> bool:
679
+ """Is this entry a real hit, or the wreckage of a killed run?
680
+
681
+ Cheap checks only — the writes are atomic now, so this is a backstop for
682
+ entries left by an older steppy and for a sidecar set that went missing.
683
+ Serving a cache entry whose captions vanished is worse than re-rendering:
684
+ the video ships with a ``<track>`` pointing at nothing and nobody is told.
685
+ """
686
+ try:
687
+ if not cached.is_file() or cached.stat().st_size == 0:
688
+ return False
689
+ except OSError:
690
+ return False
691
+
692
+ meta = _cache_sidecar(cached, ".meta")
693
+ if not meta.is_file():
694
+ # Written by an older steppy: no record of what the entry should carry,
695
+ # so accept it rather than invalidate every cache in the wild.
696
+ return True
697
+ try:
698
+ expected = json.loads(meta.read_text(encoding="utf-8")).get("sidecars") or []
699
+ except (ValueError, OSError):
700
+ return True
701
+ return all(_cache_sidecar(cached, suffix).is_file() for suffix in expected)
702
+
703
+
704
+ def _touch_entry(cached: Path) -> None:
705
+ """Mark an entry as used, so ``steppy video cache`` can evict by LRU.
706
+
707
+ Without it a video rebuilt into every release looks exactly as stale as one
708
+ whose guide was deleted a year ago. Best-effort — a read-only cache is
709
+ still a usable one.
710
+ """
711
+ for suffix in (".mp4", ".vtt", ".jpg", ".meta"):
712
+ path = _cache_sidecar(cached, suffix)
713
+ try:
714
+ if path.is_file():
715
+ path.touch()
716
+ except OSError:
717
+ pass
718
+
719
+
720
+ def _cache_sidecar(cached: Path, suffix: str) -> Path:
721
+ return cached.with_suffix(suffix)
722
+
723
+
724
+ def _copy_sidecars(cached: Path, job: VideoJob) -> None:
725
+ vtt = _cache_sidecar(cached, ".vtt")
726
+ if vtt.is_file():
727
+ job.vtt.write_text(vtt.read_text(encoding="utf-8"), encoding="utf-8")
728
+ poster = _cache_sidecar(cached, ".jpg")
729
+ if poster.is_file():
730
+ job.poster.write_bytes(poster.read_bytes())