File size: 5,815 Bytes
a0e2b41
 
0de805c
a0e2b41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
08572f5
 
a0e2b41
 
 
 
 
 
 
 
08572f5
 
 
 
a0e2b41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
"""
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>
"""