"""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