""" 360° panorama viewer for equirectangular (2:1) skybox images. Returns HTML for use with st.components.v1.html(). Resizes image to keep data URL small so it loads reliably in iframes. Fetches Pannellum script server-side and inlines it so the client does not load from CDN (avoids CSP/network block). """ import base64 import io import urllib.request from pathlib import Path # Max width for viewer image (keeps data URL under ~1MB for reliable loading) MAX_VIEWER_WIDTH = 1024 # CDN URLs for Pannellum (fetched server-side and inlined) PANNELLUM_JS_URL = "https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.js" PANNELLUM_CSS_URL = "https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.css" # Optional local fallback (if CDN is blocked on server) _SCRIPT_DIR = Path(__file__).resolve().parent _PANNELLUM_ASSETS = _SCRIPT_DIR / "panorama_assets" def _resize_and_encode(image_path: Path) -> tuple[str, str]: """Load image, resize to max width (keep 2:1), return (data_url, mime).""" from PIL import Image img = Image.open(image_path).convert("RGB") w, h = img.size if w > MAX_VIEWER_WIDTH: new_w = MAX_VIEWER_WIDTH new_h = max(256, (new_w * h) // w) img = img.resize((new_w, new_h), Image.Resampling.LANCZOS) buf = io.BytesIO() img.save(buf, format="JPEG", quality=85) b64 = base64.b64encode(buf.getvalue()).decode("utf-8") return f"data:image/jpeg;base64,{b64}", "image/jpeg" def _fetch_pannellum_assets() -> tuple[str | None, str | None]: """Fetch or read Pannellum JS and CSS. Returns (js_content, css_content) or (None, None) on failure.""" js_content, css_content = None, None # Try local assets first (no network) js_file = _PANNELLUM_ASSETS / "pannellum.js" css_file = _PANNELLUM_ASSETS / "pannellum.css" if js_file.exists() and css_file.exists(): js_content = js_file.read_text(encoding="utf-8", errors="replace") css_content = css_file.read_text(encoding="utf-8", errors="replace") return js_content, css_content # Fetch from CDN (server-side) try: req = urllib.request.Request(PANNELLUM_JS_URL, headers={"User-Agent": "Mozilla/5.0"}) with urllib.request.urlopen(req, timeout=10) as r: js_content = r.read().decode("utf-8", errors="replace") except Exception: js_content = None try: req = urllib.request.Request(PANNELLUM_CSS_URL, headers={"User-Agent": "Mozilla/5.0"}) with urllib.request.urlopen(req, timeout=10) as r: css_content = r.read().decode("utf-8", errors="replace") except Exception: css_content = None return js_content, css_content def image_to_data_url(image_path: str | Path) -> str: """Read image file, resize if needed, return a data URL (base64).""" path = Path(image_path) if not path.exists(): return "" data_url, _ = _resize_and_encode(path) return data_url def panorama_html( image_path: str | Path, height_px: int = 480, full_page_background: bool = False, ) -> str: """ Build HTML for an interactive 360° panorama viewer (Pannellum). image_path: path to equirectangular 2:1 image (e.g. skybox PNG). height_px: viewer height in pixels (ignored if full_page_background=True). full_page_background: if True, viewer fills 100% of container (use as page background). Pannellum JS/CSS are fetched server-side and inlined so the client does not load from CDN. """ path = Path(image_path) if not path.exists(): return f'
Image not found: {path.name}
' if not path.is_file(): return f'Not a file: {path.name}
' js_content, css_content = _fetch_pannellum_assets() if not js_content or not css_content: return ( 'Viewer unavailable: could not load Pannellum. ' "Check network or add scripts/panorama_assets/pannellum.js and pannellum.css.
" ) try: data_url, _ = _resize_and_encode(path) except Exception: return 'Could not load image (corrupted or invalid format).
' data_url_escaped = data_url.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "") # Inline script: escape so it does not close our tag js_safe = js_content.replace("", "<\\/script>") if full_page_background: size_style = "html, body { margin: 0; padding: 0; width: 100%; height: 100%; }\n #panorama { width: 100%; height: 100%; min-height: 100vh; }" else: size_style = f"body {{ margin: 0; }}\n #panorama {{ width: 100%; height: {height_px}px; }}" return f""" """