Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


tools/fetch_vendor.py (5399 bytes)

1 """Download the browser-side libraries and the speech model into frontend/vendor/.
2 
3 Processing happens in the visitor's browser, which needs four things that are
4 far too big to commit: ffmpeg compiled to WebAssembly (reads any audio format,
5 muxes the video), the ONNX runtime, transformers.js to drive Whisper on it, and
6 the Whisper-tiny weights themselves. They are pinned here by exact version or
7 hash, taken from the npm registry and the Hugging Face hub, then served from
8 our own origin - no CDN and no third-party host in the page, so nothing outside
9 has to be trusted or kept alive, and cross-origin isolation stays simple.
10 
11     python tools/fetch_vendor.py          # idempotent; run on every deploy
12 
13 Standard library only, on purpose: this runs on the server before anything else
14 is installed.
15 """
16 
17 from __future__ import annotations
18 
19 import hashlib
20 import io
21 import json
22 import sys
23 import tarfile
24 import urllib.request
25 from pathlib import Path
26 
27 VENDOR = Path(__file__).resolve().parent.parent / "frontend" / "vendor"
28 
29 # package, version, {path inside the tarball: path under vendor/}
30 PACKAGES = [
31     ("@ffmpeg/ffmpeg", "0.12.15", {
32         f"package/dist/esm/{name}": f"ffmpeg/{name}"
33         for name in ("index.js", "classes.js", "const.js", "errors.js", "types.js", "utils.js", "worker.js")
34     }),
35     ("@ffmpeg/core", "0.12.10", {
36         "package/dist/esm/ffmpeg-core.js": "ffmpeg/ffmpeg-core.js",
37         "package/dist/esm/ffmpeg-core.wasm": "ffmpeg/ffmpeg-core.wasm",
38     }),
39     ("@huggingface/transformers", "4.3.0", {
40         "package/dist/transformers.min.js": "transformers/transformers.min.js",
41     }),
42     # Must be exactly the build transformers.js was made against.
43     ("onnxruntime-web", "1.31.0-dev.20260914-8d85527a0", {
44         f"package/dist/{name}": f"transformers/{name}"
45         for name in ("ort.webgpu.bundle.min.mjs", "ort-wasm-simd-threaded.asyncify.mjs",
46                      "ort-wasm-simd-threaded.asyncify.wasm")
47     }),
48     # Reads Kindle books (MOBI and KF8) in the browser. One file, no imports.
49     ("foliate-js", "1.0.1", {
50         "package/mobi.js": "foliate/mobi.js",
51     }),
52 ]
53 
54 
55 # The speech model, as transformers.js loads it: the config and tokenizer files,
56 # the full-precision encoder, and one decoder for each device (4-bit on WebGPU,
57 # 8-bit on WebAssembly). The hash is the sha256 of the file on the hub, so a
58 # changed or truncated download stops here.
59 MODEL_REPO = "onnx-community/whisper-tiny"
60 MODEL_REVISION = "main"
61 MODEL_DIR = "models/whisper-tiny"
62 MODEL_FILES = {
63     "config.json": None,
64     "preprocessor_config.json": None,
65     "tokenizer_config.json": None,
66     "generation_config.json": None,
67     "tokenizer.json": None,
68     "onnx/encoder_model.onnx": "6642befb640f950d",
69     "onnx/decoder_model_merged_q4.onnx": "a7573efde84f7d01",
70     "onnx/decoder_model_merged_quantized.onnx": "25e807a962b63493",
71 }
72 
73 
74 def fetch(package: str, version: str) -> bytes:
75     meta_url = f"https://registry.npmjs.org/{package.replace('/', '%2F')}/{version}"
76     with urllib.request.urlopen(meta_url, timeout=60) as r:
77         dist = json.load(r)["dist"]
78     with urllib.request.urlopen(dist["tarball"], timeout=600) as r:
79         blob = r.read()
80     # The registry's own checksum: a tampered or truncated download stops here.
81     if hashlib.sha1(blob).hexdigest() != dist["shasum"]:
82         sys.exit(f"{package}@{version}: checksum mismatch")
83     return blob
84 
85 
86 def fetch_model() -> None:
87     """The Whisper weights, once. A file whose hash matches is not fetched again."""
88     for name, prefix in MODEL_FILES.items():
89         out = VENDOR / MODEL_DIR / name
90         if out.exists() and (prefix is None or hashlib.sha256(out.read_bytes()).hexdigest().startswith(prefix)):
91             print(f"ok       {MODEL_REPO}/{name}")
92             continue
93         print(f"fetching {MODEL_REPO}/{name}")
94         url = f"https://huggingface.co/{MODEL_REPO}/resolve/{MODEL_REVISION}/{name}"
95         with urllib.request.urlopen(url, timeout=600) as r:
96             blob = r.read()
97         if prefix is not None and not hashlib.sha256(blob).hexdigest().startswith(prefix):
98             sys.exit(f"{MODEL_REPO}/{name}: checksum mismatch")
99         out.parent.mkdir(parents=True, exist_ok=True)
100         out.write_bytes(blob)
101 
102 
103 def main() -> None:
104     stamp = VENDOR / "versions.json"
105     want = {p: v for p, v, _ in PACKAGES}
106     have = json.loads(stamp.read_text()) if stamp.exists() else {}
107 
108     for package, version, files in PACKAGES:
109         targets = [VENDOR / dest for dest in files.values()]
110         if have.get(package) == version and all(t.exists() for t in targets):
111             print(f"ok       {package}@{version}")
112             continue
113         print(f"fetching {package}@{version}")
114         with tarfile.open(fileobj=io.BytesIO(fetch(package, version)), mode="r:gz") as tar:
115             for src, dest in files.items():
116                 member = tar.extractfile(src)
117                 if member is None:
118                     sys.exit(f"{package}@{version}: {src} is not in the package")
119                 out = VENDOR / dest
120                 out.parent.mkdir(parents=True, exist_ok=True)
121                 out.write_bytes(member.read())
122 
123     stamp.write_text(json.dumps(want, indent=2))
124     fetch_model()
125     total = sum(f.stat().st_size for f in VENDOR.rglob("*") if f.is_file())
126     print(f"vendor/ is {total / 1e6:.0f} MB")
127 
128 
129 if __name__ == "__main__":
130     main()