"""Memory capsule: zero-backend shareable export (spec M17, lane G). Contract: :func:`build_capsule` writes a self-contained bundle to ``out_dir`` (default ``config.CAPSULE_DIR``): - copy ``frontend/dist`` (error clearly if missing — tell the user to ``npm run build``); - copy media from ``config.RAW_DIR``; - bake every read-only API response to files at the *same relative paths* (``api/manifest``, ``api/events``, ``api/anchors``, ``api/beats``, ``api/videos/{id}/poses``, ``api/pointcloud``, ``api/splat``, ``api/audio/{id}`` — extracting audio via ``ingest.extract_audio_hq`` if not yet cached); - inject ```` into the copied ``index.html``; - set ``"capsule": true`` in the baked manifest (the frontend hides all write UI); - ship a stdlib ``serve.py`` in the bundle root that serves with HTTP Range support (video seeking needs 206s; plain ``http.server`` can't — pitfall #4). Skip what doesn't exist (no splat -> no ``api/splat``) — degrade, don't block. Returns a summary dict (bundle path, file count, bytes) for the CLI log. Layout mirror (why paths look the way they do): the frontend resolves every URL as ``API_BASE + path`` with API_BASE == "" in a capsule, so requests hit the bundle root: ``fetch("/api/manifest")`` (no extension — bake the file extension-less; serve.py maps extension-less ``api/*`` files to a JSON content type), ``video.src = "/media/"`` (manifest ``url`` fields are ``/media/{filename}`` — media files are copied under ``media/``), PLY loader hits ``/api/pointcloud``, the WebAudio clock fetches ``/api/audio/{id}``. Saved camera paths (M16) are read-only in a capsule, so ``api/paths`` + ``api/paths/{id}`` are baked too — the load dropdown keeps working. """ from __future__ import annotations import json import logging import shutil from pathlib import Path from festival4d import config, db log = logging.getLogger("festival4d.capsule") # Injected verbatim (phase-5 contract #5): a runtime override that beats the build-time # VITE_API_BASE, turning the same dist bundle zero-backend. Must land in index.html before # the app module reads it (modules are deferred, but we inject ahead of the first ' # --------------------------------------------------------------------------- # serve.py shipped in the bundle root (stdlib-only, Range-capable — pitfall #4) # --------------------------------------------------------------------------- SERVE_PY = r'''#!/usr/bin/env python3 """Serve this Festival 4D memory capsule locally. Plain `python -m http.server` cannot answer HTTP Range requests, and