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