Spaces:
Runtime error
Runtime error
| """ | |
| 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'<p style="padding:1em;color:#888;">Image not found: {path.name}</p>' | |
| if not path.is_file(): | |
| return f'<p style="padding:1em;color:#888;">Not a file: {path.name}</p>' | |
| js_content, css_content = _fetch_pannellum_assets() | |
| if not js_content or not css_content: | |
| return ( | |
| '<p style="padding:1em;color:#c66;">Viewer unavailable: could not load Pannellum. ' | |
| "Check network or add scripts/panorama_assets/pannellum.js and pannellum.css.</p>" | |
| ) | |
| try: | |
| data_url, _ = _resize_and_encode(path) | |
| except Exception: | |
| return '<p style="padding:1em;color:#c66;">Could not load image (corrupted or invalid format).</p>' | |
| data_url_escaped = data_url.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "") | |
| # Inline script: escape </script> so it does not close our tag | |
| js_safe = js_content.replace("</script>", "<\\/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""" | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <style> | |
| {size_style} | |
| .pnlm-container {{ border-radius: 0; }} | |
| .pnlm-error {{ color: #ccc; padding: 1em; }} | |
| </style> | |
| <style>{css_content}</style> | |
| </head> | |
| <body> | |
| <div id="panorama"></div> | |
| <script>{js_safe}</script> | |
| <script> | |
| (function() {{ | |
| var panoramaUrl = "{data_url_escaped}"; | |
| try {{ | |
| pannellum.viewer('panorama', {{ | |
| type: 'equirectangular', | |
| panorama: panoramaUrl, | |
| autoLoad: true, | |
| showControls: true, | |
| compass: true, | |
| mouseZoom: true, | |
| draggable: true, | |
| showZoomCtrl: true, | |
| showFullscreenCtrl: true, | |
| hfov: 100, | |
| minHfov: 50, | |
| maxHfov: 120 | |
| }}); | |
| }} catch (e) {{ | |
| document.getElementById('panorama').innerHTML = '<p class="pnlm-error">Viewer error: ' + e.message + '</p>'; | |
| }} | |
| }})(); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |