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.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())
|