evoneural/evoneuralIn3D-app
0
1"""2360° panorama viewer for equirectangular (2:1) skybox images.3Returns HTML for use with st.components.v1.html().4Resizes image to keep data URL small so it loads reliably in iframes.5Fetches Pannellum script server-side and inlines it so the client does not load from CDN (avoids CSP/network block).6"""7 8import base649import io10import urllib.request11from pathlib import Path12 13# Max width for viewer image (keeps data URL under ~1MB for reliable loading)14MAX_VIEWER_WIDTH = 102415 16# CDN URLs for Pannellum (fetched server-side and inlined)17PANNELLUM_JS_URL = "https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.js"18PANNELLUM_CSS_URL = "https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.css"19 20# Optional local fallback (if CDN is blocked on server)21_SCRIPT_DIR = Path(__file__).resolve().parent22_PANNELLUM_ASSETS = _SCRIPT_DIR / "panorama_assets"23 24 25def _resize_and_encode(image_path: Path) -> tuple[str, str]:26 """Load image, resize to max width (keep 2:1), return (data_url, mime)."""27 from PIL import Image28 29 img = Image.open(image_path).convert("RGB")30 w, h = img.size31 if w > MAX_VIEWER_WIDTH:32 new_w = MAX_VIEWER_WIDTH33 new_h = max(256, (new_w * h) // w)34 img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)35 buf = io.BytesIO()36 img.save(buf, format="JPEG", quality=85)37 b64 = base64.b64encode(buf.getvalue()).decode("utf-8")38 return f"data:image/jpeg;base64,{b64}", "image/jpeg"39 40 41def _fetch_pannellum_assets() -> tuple[str | None, str | None]:42 """Fetch or read Pannellum JS and CSS. Returns (js_content, css_content) or (None, None) on failure."""43 js_content, css_content = None, None44 45 # Try local assets first (no network)46 js_file = _PANNELLUM_ASSETS / "pannellum.js"47 css_file = _PANNELLUM_ASSETS / "pannellum.css"48 if js_file.exists() and css_file.exists():49 js_content = js_file.read_text(encoding="utf-8", errors="replace")50 css_content = css_file.read_text(encoding="utf-8", errors="replace")51 return js_content, css_content52 53 # Fetch from CDN (server-side)54 try:55 req = urllib.request.Request(PANNELLUM_JS_URL, headers={"User-Agent": "Mozilla/5.0"})56 with urllib.request.urlopen(req, timeout=10) as r:57 js_content = r.read().decode("utf-8", errors="replace")58 except Exception:59 js_content = None60 try:61 req = urllib.request.Request(PANNELLUM_CSS_URL, headers={"User-Agent": "Mozilla/5.0"})62 with urllib.request.urlopen(req, timeout=10) as r:63 css_content = r.read().decode("utf-8", errors="replace")64 except Exception:65 css_content = None66 67 return js_content, css_content68 69 70def image_to_data_url(image_path: str | Path) -> str:71 """Read image file, resize if needed, return a data URL (base64)."""72 path = Path(image_path)73 if not path.exists():74 return ""75 data_url, _ = _resize_and_encode(path)76 return data_url77 78 79def panorama_html(80 image_path: str | Path,81 height_px: int = 480,82 full_page_background: bool = False,83) -> str:84 """85 Build HTML for an interactive 360° panorama viewer (Pannellum).86 image_path: path to equirectangular 2:1 image (e.g. skybox PNG).87 height_px: viewer height in pixels (ignored if full_page_background=True).88 full_page_background: if True, viewer fills 100% of container (use as page background).89 Pannellum JS/CSS are fetched server-side and inlined so the client does not load from CDN.90 """91 path = Path(image_path)92 if not path.exists():93 return f'<p style="padding:1em;color:#888;">Image not found: {path.name}</p>'94 if not path.is_file():95 return f'<p style="padding:1em;color:#888;">Not a file: {path.name}</p>'96 97 js_content, css_content = _fetch_pannellum_assets()98 if not js_content or not css_content:99 return (100 '<p style="padding:1em;color:#c66;">Viewer unavailable: could not load Pannellum. '101 "Check network or add scripts/panorama_assets/pannellum.js and pannellum.css.</p>"102 )103 104 try:105 data_url, _ = _resize_and_encode(path)106 except Exception:107 return '<p style="padding:1em;color:#c66;">Could not load image (corrupted or invalid format).</p>'108 data_url_escaped = data_url.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "")109 110 # Inline script: escape </script> so it does not close our tag111 js_safe = js_content.replace("</script>", "<\\/script>")112 113 if full_page_background:114 size_style = "html, body { margin: 0; padding: 0; width: 100%; height: 100%; }\n #panorama { width: 100%; height: 100%; min-height: 100vh; }"115 else:116 size_style = f"body {{ margin: 0; }}\n #panorama {{ width: 100%; height: {height_px}px; }}"117 118 return f"""119<!DOCTYPE html>120<html>121<head>122 <meta charset="utf-8">123 <meta name="viewport" content="width=device-width, initial-scale=1">124 <style>125 {size_style}126 .pnlm-container {{ border-radius: 0; }}127 .pnlm-error {{ color: #ccc; padding: 1em; }}128 </style>129 <style>{css_content}</style>130</head>131<body>132 <div id="panorama"></div>133 <script>{js_safe}</script>134 <script>135 (function() {{136 var panoramaUrl = "{data_url_escaped}";137 try {{138 pannellum.viewer('panorama', {{139 type: 'equirectangular',140 panorama: panoramaUrl,141 autoLoad: true,142 showControls: true,143 compass: true,144 mouseZoom: true,145 draggable: true,146 showZoomCtrl: true,147 showFullscreenCtrl: true,148 hfov: 100,149 minHfov: 50,150 maxHfov: 120151 }});152 }} catch (e) {{153 document.getElementById('panorama').innerHTML = '<p class="pnlm-error">Viewer error: ' + e.message + '</p>';154 }}155 }})();156 </script>157</body>158</html>159"""160 