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/video_cache.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Inspect and reclaim the two render caches — a **stdlib-only leaf**.
|
|
2
|
+
|
|
3
|
+
``.steppy/tts-cache/`` and ``.steppy/video-cache/`` are content-addressed, which
|
|
4
|
+
makes them correct and makes them grow forever: every edited sentence and every
|
|
5
|
+
re-render leaves its predecessor behind, and a Piper upgrade orphans every clip
|
|
6
|
+
at once because the engine version is in the key. Nothing ever collected them,
|
|
7
|
+
and an MP4 corpus is measured in gigabytes.
|
|
8
|
+
|
|
9
|
+
Two rules this module exists to keep:
|
|
10
|
+
|
|
11
|
+
* **An entry is its primary file plus its sidecars.** Dropping a ``.mp4`` and
|
|
12
|
+
leaving its ``.vtt`` behind would leave the directory looking populated while
|
|
13
|
+
every lookup misses, and the orphan would never be collected by anything.
|
|
14
|
+
* **Recency means last *use*, not last write.** The lookup paths touch an entry
|
|
15
|
+
on every hit (``ClipCache.get``, ``video._touch_entry``), so a clip rebuilt
|
|
16
|
+
into every release stays young. Without that, LRU here would evict exactly the
|
|
17
|
+
entries that are earning their keep.
|
|
18
|
+
|
|
19
|
+
Imports nothing from ``steppy`` — ``video_cli`` must stay cheap enough to sit on
|
|
20
|
+
the eager CLI path (guard: ``tests/test_no_video_import_on_default_path.py``).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import shutil
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Iterable, Optional
|
|
30
|
+
|
|
31
|
+
# An entry's primary artifact, and the companions that travel with it.
|
|
32
|
+
SIDECARS: dict[str, tuple[str, ...]] = {
|
|
33
|
+
".mp4": (".vtt", ".jpg", ".meta"),
|
|
34
|
+
".wav": (".json",),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# A staging file younger than this may belong to a build running right now.
|
|
38
|
+
SCRATCH_GRACE_SECONDS = 24 * 3600
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Entry:
|
|
43
|
+
key: str
|
|
44
|
+
primary: Path
|
|
45
|
+
sidecars: tuple[Path, ...]
|
|
46
|
+
size: int
|
|
47
|
+
used_at: float
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def paths(self) -> tuple[Path, ...]:
|
|
51
|
+
return (self.primary, *self.sidecars)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Stats:
|
|
56
|
+
root: Path
|
|
57
|
+
exists: bool
|
|
58
|
+
entries: int
|
|
59
|
+
size: int
|
|
60
|
+
oldest: Optional[float]
|
|
61
|
+
newest: Optional[float]
|
|
62
|
+
scratch_files: int
|
|
63
|
+
scratch_size: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class Reclaimed:
|
|
68
|
+
entries: int
|
|
69
|
+
size: int
|
|
70
|
+
|
|
71
|
+
def __add__(self, other: "Reclaimed") -> "Reclaimed":
|
|
72
|
+
return Reclaimed(self.entries + other.entries, self.size + other.size)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def scan(root: Path, suffix: str) -> list[Entry]:
|
|
76
|
+
"""Every complete entry under *root*, newest use first."""
|
|
77
|
+
root = Path(root)
|
|
78
|
+
if not root.is_dir():
|
|
79
|
+
return []
|
|
80
|
+
entries: list[Entry] = []
|
|
81
|
+
for primary in sorted(root.glob(f"*{suffix}")):
|
|
82
|
+
if primary.name.startswith("."): # staging file, not an entry
|
|
83
|
+
continue
|
|
84
|
+
sidecars = tuple(p for p in (primary.with_suffix(s) for s in SIDECARS.get(suffix, ())) if p.is_file())
|
|
85
|
+
size = _size(primary) + sum(_size(p) for p in sidecars)
|
|
86
|
+
entries.append(
|
|
87
|
+
Entry(
|
|
88
|
+
key=primary.stem,
|
|
89
|
+
primary=primary,
|
|
90
|
+
sidecars=sidecars,
|
|
91
|
+
size=size,
|
|
92
|
+
used_at=_used_at(primary, sidecars),
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
entries.sort(key=lambda e: e.used_at, reverse=True)
|
|
96
|
+
return entries
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def stats(root: Path, suffix: str) -> Stats:
|
|
100
|
+
root = Path(root)
|
|
101
|
+
entries = scan(root, suffix)
|
|
102
|
+
scratch = list(scratch_files(root))
|
|
103
|
+
used = [e.used_at for e in entries]
|
|
104
|
+
return Stats(
|
|
105
|
+
root=root,
|
|
106
|
+
exists=root.is_dir(),
|
|
107
|
+
entries=len(entries),
|
|
108
|
+
size=sum(e.size for e in entries),
|
|
109
|
+
oldest=min(used) if used else None,
|
|
110
|
+
newest=max(used) if used else None,
|
|
111
|
+
scratch_files=len(scratch),
|
|
112
|
+
scratch_size=sum(_size(p) for p in scratch),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def scratch_files(root: Path) -> Iterable[Path]:
|
|
117
|
+
"""Staging leftovers and pre-transcode scratch — never an entry.
|
|
118
|
+
|
|
119
|
+
``tts-cache/work/`` holds the raw container a provider returned before it
|
|
120
|
+
was normalized to WAV; the normalized clip is the artifact, so anything left
|
|
121
|
+
here is the residue of a killed run.
|
|
122
|
+
"""
|
|
123
|
+
root = Path(root)
|
|
124
|
+
if not root.is_dir():
|
|
125
|
+
return []
|
|
126
|
+
found = [p for p in root.glob(".*.tmp*") if p.is_file()]
|
|
127
|
+
work = root / "work"
|
|
128
|
+
if work.is_dir():
|
|
129
|
+
found.extend(p for p in work.rglob("*") if p.is_file())
|
|
130
|
+
return found
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def prune(
|
|
134
|
+
root: Path,
|
|
135
|
+
suffix: str,
|
|
136
|
+
*,
|
|
137
|
+
older_than_days: Optional[float] = None,
|
|
138
|
+
max_bytes: Optional[int] = None,
|
|
139
|
+
now: Optional[float] = None,
|
|
140
|
+
) -> Reclaimed:
|
|
141
|
+
"""Drop entries by age, then by budget. Least recently *used* goes first."""
|
|
142
|
+
entries = scan(root, suffix)
|
|
143
|
+
now = time.time() if now is None else now
|
|
144
|
+
doomed: list[Entry] = []
|
|
145
|
+
|
|
146
|
+
if older_than_days is not None:
|
|
147
|
+
cutoff = now - older_than_days * 86400
|
|
148
|
+
doomed = [e for e in entries if e.used_at <= cutoff]
|
|
149
|
+
entries = [e for e in entries if e.used_at > cutoff]
|
|
150
|
+
|
|
151
|
+
if max_bytes is not None:
|
|
152
|
+
running = 0
|
|
153
|
+
keep: list[Entry] = []
|
|
154
|
+
for entry in entries: # already newest-first
|
|
155
|
+
if running + entry.size > max_bytes:
|
|
156
|
+
doomed.append(entry)
|
|
157
|
+
else:
|
|
158
|
+
running += entry.size
|
|
159
|
+
keep.append(entry)
|
|
160
|
+
entries = keep
|
|
161
|
+
|
|
162
|
+
freed = 0
|
|
163
|
+
removed = 0
|
|
164
|
+
for entry in doomed:
|
|
165
|
+
if _remove(entry.paths):
|
|
166
|
+
removed += 1
|
|
167
|
+
freed += entry.size
|
|
168
|
+
return Reclaimed(removed, freed)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def sweep_scratch(
|
|
172
|
+
root: Path, *, grace_seconds: float = SCRATCH_GRACE_SECONDS, now: Optional[float] = None
|
|
173
|
+
) -> Reclaimed:
|
|
174
|
+
"""Remove staging leftovers old enough that no live build can own them."""
|
|
175
|
+
now = time.time() if now is None else now
|
|
176
|
+
freed = 0
|
|
177
|
+
removed = 0
|
|
178
|
+
for path in scratch_files(root):
|
|
179
|
+
try:
|
|
180
|
+
if now - path.stat().st_mtime < grace_seconds:
|
|
181
|
+
continue
|
|
182
|
+
size = _size(path)
|
|
183
|
+
except OSError:
|
|
184
|
+
continue
|
|
185
|
+
if _remove((path,)):
|
|
186
|
+
removed += 1
|
|
187
|
+
freed += size
|
|
188
|
+
return Reclaimed(removed, freed)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def clear(root: Path, suffix: str) -> Reclaimed:
|
|
192
|
+
"""Drop every entry, plus the scratch dirs. The directory itself survives,
|
|
193
|
+
so a subsequent run does not have to reason about whether it exists."""
|
|
194
|
+
root = Path(root)
|
|
195
|
+
entries = scan(root, suffix)
|
|
196
|
+
freed = sum(e.size for e in entries)
|
|
197
|
+
removed = 0
|
|
198
|
+
for entry in entries:
|
|
199
|
+
if _remove(entry.paths):
|
|
200
|
+
removed += 1
|
|
201
|
+
for path in scratch_files(root):
|
|
202
|
+
freed += _size(path)
|
|
203
|
+
_remove((path,))
|
|
204
|
+
work = root / "work"
|
|
205
|
+
if work.is_dir():
|
|
206
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
207
|
+
return Reclaimed(removed, freed)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _used_at(primary: Path, sidecars: tuple[Path, ...]) -> float:
|
|
211
|
+
"""Newest mtime across the entry — a hit touches whatever it reads, and a
|
|
212
|
+
sidecar-only touch still counts as the entry having been used."""
|
|
213
|
+
times = []
|
|
214
|
+
for path in (primary, *sidecars):
|
|
215
|
+
try:
|
|
216
|
+
times.append(path.stat().st_mtime)
|
|
217
|
+
except OSError:
|
|
218
|
+
continue
|
|
219
|
+
return max(times) if times else 0.0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _size(path: Path) -> int:
|
|
223
|
+
try:
|
|
224
|
+
return path.stat().st_size
|
|
225
|
+
except OSError:
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _remove(paths: tuple[Path, ...]) -> bool:
|
|
230
|
+
"""Best-effort delete. A file held open by another process (Windows) is
|
|
231
|
+
skipped rather than fatal — the next prune will get it."""
|
|
232
|
+
ok = False
|
|
233
|
+
for path in paths:
|
|
234
|
+
try:
|
|
235
|
+
path.unlink(missing_ok=True)
|
|
236
|
+
ok = True
|
|
237
|
+
except OSError:
|
|
238
|
+
pass
|
|
239
|
+
return ok
|
steppy/video_cli.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""``steppy video check`` — one answer instead of three mid-render failures.
|
|
2
|
+
|
|
3
|
+
Video export has more external moving parts than anything else in Steppy:
|
|
4
|
+
Chromium, ffmpeg, ffprobe, a TTS provider, an API key that may or may not have
|
|
5
|
+
survived ``${VAR}`` interpolation, and possibly a voice model on disk. Each of
|
|
6
|
+
them fails at a different point in a render that takes minutes per guide, and
|
|
7
|
+
each failure looks unrelated to the others. This command checks all of them in
|
|
8
|
+
about five seconds.
|
|
9
|
+
|
|
10
|
+
A leaf module in the ``register_flaky`` mould: it imports ``_cli_common`` plus
|
|
11
|
+
stdlib at module scope and nothing else, so ``import steppy.cli`` stays free of
|
|
12
|
+
the video and TTS stacks (guard:
|
|
13
|
+
``tests/test_no_video_import_on_default_path.py``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
|
|
26
|
+
from ._cli_common import _cli_errors, _error, console
|
|
27
|
+
|
|
28
|
+
video_app = typer.Typer(help="Narrated video export utilities.", no_args_is_help=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def register_video(app: typer.Typer) -> None:
|
|
32
|
+
"""Attach the ``video`` command group to the root Typer app."""
|
|
33
|
+
app.add_typer(video_app, name="video")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@video_app.command("check")
|
|
37
|
+
def check(
|
|
38
|
+
config_file: Path = typer.Option(
|
|
39
|
+
Path("config.yaml"),
|
|
40
|
+
"--config",
|
|
41
|
+
"-c",
|
|
42
|
+
help="Path to config.yaml",
|
|
43
|
+
),
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Verify everything ``--video`` needs before spending minutes finding out."""
|
|
46
|
+
from .config import load_config
|
|
47
|
+
|
|
48
|
+
with _cli_errors():
|
|
49
|
+
console.print("\n[bold]Video export readiness[/bold]\n")
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
config = load_config(config_file)
|
|
53
|
+
options = config.video
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
_error(f"could not load {config_file}: {exc}")
|
|
56
|
+
raise typer.Exit(1)
|
|
57
|
+
|
|
58
|
+
problems: list[str] = []
|
|
59
|
+
_report("config", str(config_file), ok=True)
|
|
60
|
+
|
|
61
|
+
# ── Encoder ──────────────────────────────────────────────────────
|
|
62
|
+
from .video import VideoRenderError, find_ffmpeg
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
ffmpeg = find_ffmpeg(options)
|
|
66
|
+
_report("ffmpeg", f"{ffmpeg}{_version_suffix(ffmpeg)}", ok=True)
|
|
67
|
+
except VideoRenderError as exc:
|
|
68
|
+
_report("ffmpeg", "not found", ok=False)
|
|
69
|
+
problems.append(str(exc))
|
|
70
|
+
|
|
71
|
+
probe = shutil.which("ffprobe")
|
|
72
|
+
_report("ffprobe", probe or "not found (only needed for non-WAV narration)", ok=bool(probe))
|
|
73
|
+
|
|
74
|
+
# ── Browser ──────────────────────────────────────────────────────
|
|
75
|
+
ok, detail = _check_chromium()
|
|
76
|
+
_report("chromium", detail, ok=ok)
|
|
77
|
+
if not ok:
|
|
78
|
+
problems.append("Chromium is unavailable: python -m playwright install chromium")
|
|
79
|
+
|
|
80
|
+
# ── Narration ────────────────────────────────────────────────────
|
|
81
|
+
from .tts import describe, readiness
|
|
82
|
+
|
|
83
|
+
_report("tts provider", describe(options.tts), ok=True)
|
|
84
|
+
trouble = readiness(options.tts)
|
|
85
|
+
if trouble:
|
|
86
|
+
_report("tts readiness", "not ready", ok=False)
|
|
87
|
+
problems.append(trouble)
|
|
88
|
+
else:
|
|
89
|
+
voices = _voice_summary(options.tts, config.locales)
|
|
90
|
+
_report("tts readiness", voices, ok=True)
|
|
91
|
+
|
|
92
|
+
# ── Caches ───────────────────────────────────────────────────────
|
|
93
|
+
from .captures import resolve_output_dir
|
|
94
|
+
|
|
95
|
+
root = Path.cwd()
|
|
96
|
+
video_cache = resolve_output_dir(root, options.cache_dir, ".steppy/video-cache")
|
|
97
|
+
tts_cache = resolve_output_dir(root, options.tts.cache_dir, ".steppy/tts-cache")
|
|
98
|
+
_report("video cache", _cache_detail(video_cache, options.cache, ".mp4"), ok=True)
|
|
99
|
+
_report("tts cache", _cache_detail(tts_cache, options.tts.cache, ".wav"), ok=True)
|
|
100
|
+
|
|
101
|
+
console.print()
|
|
102
|
+
if problems:
|
|
103
|
+
for problem in problems:
|
|
104
|
+
_error(problem)
|
|
105
|
+
raise typer.Exit(1)
|
|
106
|
+
console.print("[green]OK[/green] ready to render — steppy build --video / steppy guide --video\n")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@video_app.command("cache")
|
|
110
|
+
def cache(
|
|
111
|
+
config_file: Path = typer.Option(Path("config.yaml"), "--config", "-c", help="Path to config.yaml"),
|
|
112
|
+
prune_older_than: Optional[float] = typer.Option(
|
|
113
|
+
None,
|
|
114
|
+
"--prune-older-than",
|
|
115
|
+
metavar="DAYS",
|
|
116
|
+
help="Drop entries not used in DAYS days.",
|
|
117
|
+
),
|
|
118
|
+
max_size: Optional[float] = typer.Option(
|
|
119
|
+
None,
|
|
120
|
+
"--max-size",
|
|
121
|
+
metavar="MB",
|
|
122
|
+
help="Evict least-recently-used entries until each cache fits in MB.",
|
|
123
|
+
),
|
|
124
|
+
clear_all: bool = typer.Option(False, "--clear", help="Remove every entry."),
|
|
125
|
+
tts_only: bool = typer.Option(False, "--tts", help="Only the narration clip cache."),
|
|
126
|
+
video_only: bool = typer.Option(False, "--video", help="Only the rendered video cache."),
|
|
127
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation for --clear."),
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Inspect or reclaim the render caches.
|
|
130
|
+
|
|
131
|
+
Both are content-addressed and therefore grow forever: every edited sentence
|
|
132
|
+
leaves its old clip behind, and a Piper upgrade orphans all of them at once.
|
|
133
|
+
With no flags this only reports.
|
|
134
|
+
"""
|
|
135
|
+
from .captures import resolve_output_dir
|
|
136
|
+
from .config import load_config
|
|
137
|
+
from .video_cache import Reclaimed, clear, prune, stats, sweep_scratch
|
|
138
|
+
|
|
139
|
+
with _cli_errors():
|
|
140
|
+
if tts_only and video_only:
|
|
141
|
+
_error("--tts and --video are mutually exclusive; omit both to act on each cache.")
|
|
142
|
+
raise typer.Exit(2)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
options = load_config(config_file).video
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
_error(f"could not load {config_file}: {exc}")
|
|
148
|
+
raise typer.Exit(1)
|
|
149
|
+
|
|
150
|
+
root = Path.cwd()
|
|
151
|
+
targets = []
|
|
152
|
+
if not tts_only:
|
|
153
|
+
targets.append(("video", resolve_output_dir(root, options.cache_dir, ".steppy/video-cache"), ".mp4"))
|
|
154
|
+
if not video_only:
|
|
155
|
+
targets.append(("tts", resolve_output_dir(root, options.tts.cache_dir, ".steppy/tts-cache"), ".wav"))
|
|
156
|
+
|
|
157
|
+
if clear_all and not yes:
|
|
158
|
+
names = " and ".join(name for name, _, _ in targets)
|
|
159
|
+
if not typer.confirm(f"Remove every entry from the {names} cache?"):
|
|
160
|
+
console.print("[yellow]Aborted[/yellow] — nothing was removed.")
|
|
161
|
+
raise typer.Exit(0)
|
|
162
|
+
|
|
163
|
+
console.print("\n[bold]Render caches[/bold]\n")
|
|
164
|
+
for name, path, suffix in targets:
|
|
165
|
+
# Scratch is always swept: a staging file a day old belongs to no
|
|
166
|
+
# live build, and leaving it costs disk while helping nobody.
|
|
167
|
+
swept = sweep_scratch(path)
|
|
168
|
+
if clear_all:
|
|
169
|
+
freed = clear(path, suffix)
|
|
170
|
+
elif prune_older_than is not None or max_size is not None:
|
|
171
|
+
freed = prune(
|
|
172
|
+
path,
|
|
173
|
+
suffix,
|
|
174
|
+
older_than_days=prune_older_than,
|
|
175
|
+
max_bytes=int(max_size * 1024 * 1024) if max_size is not None else None,
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
freed = Reclaimed(0, 0)
|
|
179
|
+
|
|
180
|
+
after = stats(path, suffix)
|
|
181
|
+
if not after.exists:
|
|
182
|
+
summary = "not created yet"
|
|
183
|
+
else:
|
|
184
|
+
summary = f"{_entries(after.entries)}, {_bytes(after.size)}"
|
|
185
|
+
if after.newest and after.oldest:
|
|
186
|
+
summary += f" [dim](last used {_ago(after.newest)} ago, oldest {_ago(after.oldest)})[/dim]"
|
|
187
|
+
_report(f"{name} cache", summary, ok=True)
|
|
188
|
+
console.print(f" [dim]{after.root}[/dim]")
|
|
189
|
+
if freed.entries:
|
|
190
|
+
console.print(f" [green]reclaimed[/green] {_entries(freed.entries)}, {_bytes(freed.size)}")
|
|
191
|
+
if swept.entries:
|
|
192
|
+
console.print(f" [dim]swept {swept.entries} scratch file(s), {_bytes(swept.size)}[/dim]")
|
|
193
|
+
if after.scratch_files:
|
|
194
|
+
console.print(
|
|
195
|
+
f" [dim]{after.scratch_files} scratch file(s) held back "
|
|
196
|
+
f"({_bytes(after.scratch_size)}) — a build may still own them[/dim]"
|
|
197
|
+
)
|
|
198
|
+
console.print()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _entries(count: int) -> str:
|
|
202
|
+
return f"{count} entry" if count == 1 else f"{count} entries"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _bytes(size: int) -> str:
|
|
206
|
+
value = float(size)
|
|
207
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
208
|
+
if value < 1024 or unit == "GB":
|
|
209
|
+
return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
|
|
210
|
+
value /= 1024
|
|
211
|
+
return f"{value:.1f} GB" # pragma: no cover - unreachable, the loop returns
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _ago(when: float) -> str:
|
|
215
|
+
seconds = max(0.0, time.time() - when)
|
|
216
|
+
for limit, div, unit in ((3600, 60, "min"), (86400, 3600, "h"), (float("inf"), 86400, "d")):
|
|
217
|
+
if seconds < limit:
|
|
218
|
+
return f"{seconds / div:.0f} {unit}"
|
|
219
|
+
return "?" # pragma: no cover - the last bucket is unbounded
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _report(label: str, detail: str, *, ok: bool) -> None:
|
|
223
|
+
mark = "[green]OK[/green] " if ok else "[red]FAIL[/red]"
|
|
224
|
+
console.print(f" {mark} [dim]{label:<14}[/dim] {detail}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _version_suffix(binary: str) -> str:
|
|
228
|
+
try:
|
|
229
|
+
proc = subprocess.run([binary, "-version"], capture_output=True, timeout=20)
|
|
230
|
+
first = proc.stdout.decode("utf-8", "replace").splitlines()[0]
|
|
231
|
+
return f" [dim]({first.split(' Copyright')[0].strip()})[/dim]"
|
|
232
|
+
except Exception:
|
|
233
|
+
return ""
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _check_chromium() -> tuple[bool, str]:
|
|
237
|
+
"""Launch and close Chromium — the only check that actually proves it works."""
|
|
238
|
+
try:
|
|
239
|
+
from playwright.sync_api import sync_playwright
|
|
240
|
+
except ImportError as exc:
|
|
241
|
+
return False, f"playwright not installed ({exc})"
|
|
242
|
+
try:
|
|
243
|
+
with sync_playwright() as p:
|
|
244
|
+
browser = p.chromium.launch()
|
|
245
|
+
version = browser.version
|
|
246
|
+
browser.close()
|
|
247
|
+
return True, f"launches [dim]({version})[/dim]"
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
return False, f"cannot launch ({str(exc).splitlines()[0]})"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _voice_summary(tts, locales: list[str]) -> str:
|
|
253
|
+
if tts.provider == "none":
|
|
254
|
+
return "captions only"
|
|
255
|
+
missing = [loc for loc in locales if not tts.voice_for(loc)]
|
|
256
|
+
if missing:
|
|
257
|
+
# Not a failure: a provider may have a usable default voice. But a
|
|
258
|
+
# multilingual project almost certainly meant to set one per locale.
|
|
259
|
+
return f"voices set for {len(locales) - len(missing)}/{len(locales)} locales (missing: {', '.join(missing)})"
|
|
260
|
+
return f"voices set for all {len(locales)} locale(s)"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _cache_detail(path: Path, enabled: bool, suffix: str) -> str:
|
|
264
|
+
if not enabled:
|
|
265
|
+
return "disabled — every run re-renders (and re-bills a paid provider)"
|
|
266
|
+
if not path.exists():
|
|
267
|
+
return "empty [dim](steppy video cache)[/dim]"
|
|
268
|
+
from .video_cache import stats
|
|
269
|
+
|
|
270
|
+
got = stats(path, suffix)
|
|
271
|
+
return f"{_entries(got.entries)}, {_bytes(got.size)} [dim](steppy video cache)[/dim]"
|