← Files QuestforgeARCHIVED FILE
scripts/panorama_viewer.py
7.74 KB · Sep 30, 2026 · 23:13 UTC
"""Generate standalone Questforge 360 panorama viewer HTML files."""
from __future__ import annotations
import argparse
import base64
import json
import re
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
TEMPLATE_PATH = PLUGIN_ROOT / "templates" / "panorama-viewer.html"
SLUG_PATTERN = re.compile(r"[^a-z0-9]+")
SUPPORTED_AUDIO_MIME_TYPES = {
".aac": "audio/aac",
".m4a": "audio/mp4",
".mp3": "audio/mpeg",
".mpeg": "audio/mpeg",
".oga": "audio/ogg",
".ogg": "audio/ogg",
".wav": "audio/wav",
".webm": "audio/webm",
}
@dataclass(frozen=True)
class PanoramaViewerResult:
"""Created 360 viewer artifact."""
viewer_path: str
viewer_url: str
image_path: str
title: str
narration: str
initial_zoom_level: int
audio_path: str | None
audio_title: str
audio_volume: float
html_bytes: int
def slugify(value: str) -> str:
normalized = value.strip().lower()
slug = SLUG_PATTERN.sub("-", normalized).strip("-")
return slug or "panorama"
def infer_mime_type(image_path: Path) -> str:
suffix = image_path.suffix.lower()
if suffix == ".png":
return "image/png"
if suffix in {".jpg", ".jpeg"}:
return "image/jpeg"
if suffix == ".webp":
return "image/webp"
raise ValueError(
"Unsupported panorama image type. Use PNG, JPEG, or WebP."
)
def infer_audio_mime_type(audio_path: Path) -> str:
suffix = audio_path.suffix.lower()
try:
return SUPPORTED_AUDIO_MIME_TYPES[suffix]
except KeyError as error:
raise ValueError(
"Unsupported audio type. Use MP3, M4A, AAC, OGG, WAV, or WebM."
) from error
def create_panorama_viewer(
image_path: Path,
output_path: Path | None = None,
title: str = "",
narration: str = "",
initial_zoom_level: int = 14,
audio_path: Path | None = None,
audio_title: str = "",
audio_volume: float = 0.28,
) -> PanoramaViewerResult:
"""Create a local HTML viewer with the panorama embedded as a data URI."""
image_path = image_path.expanduser().resolve()
if not image_path.exists():
raise FileNotFoundError(f"Missing panorama image: {image_path}")
if not image_path.is_file():
raise ValueError(f"Panorama path is not a file: {image_path}")
viewer_title = title.strip() or image_path.stem
viewer_narration = narration.strip() or viewer_title
validate_initial_zoom_level(initial_zoom_level)
validate_audio_volume(audio_volume)
if output_path is None:
output_path = image_path.with_name(f"{slugify(viewer_title)}-360.html")
output_path = output_path.expanduser().resolve()
image_data_uri = encode_data_uri(image_path, infer_mime_type(image_path))
viewer_audio_path = resolve_optional_file(audio_path, "audio")
if viewer_audio_path is None:
audio_data_uri = ""
viewer_audio_title = ""
else:
audio_data_uri = encode_data_uri(
viewer_audio_path,
infer_audio_mime_type(viewer_audio_path),
)
viewer_audio_title = audio_title.strip() or viewer_audio_path.stem
html = render_template(
title=viewer_title,
narration=viewer_narration,
initial_zoom_level=initial_zoom_level,
image_data_uri=image_data_uri,
audio_data_uri=audio_data_uri,
audio_title=viewer_audio_title,
audio_volume=audio_volume,
)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(html, encoding="utf-8", newline="\n")
return PanoramaViewerResult(
viewer_path=str(output_path),
viewer_url=output_path.as_uri(),
image_path=str(image_path),
title=viewer_title,
narration=viewer_narration,
initial_zoom_level=initial_zoom_level,
audio_path=str(viewer_audio_path) if viewer_audio_path else None,
audio_title=viewer_audio_title,
audio_volume=audio_volume,
html_bytes=output_path.stat().st_size,
)
def validate_initial_zoom_level(level: int) -> None:
if level < 1 or level > 22:
raise ValueError("Initial zoom level must be between 1 and 22.")
def validate_audio_volume(volume: float) -> None:
if volume < 0 or volume > 1:
raise ValueError("Audio volume must be between 0 and 1.")
def resolve_optional_file(path: Path | None, label: str) -> Path | None:
if path is None:
return None
resolved_path = path.expanduser().resolve()
if not resolved_path.exists():
raise FileNotFoundError(f"Missing {label} file: {resolved_path}")
if not resolved_path.is_file():
raise ValueError(
f"{label.title()} path is not a file: {resolved_path}"
)
return resolved_path
def encode_data_uri(path: Path, mime_type: str) -> str:
return f"data:{mime_type};base64," + base64.b64encode(
path.read_bytes()
).decode("ascii")
def render_template(
title: str,
narration: str,
initial_zoom_level: int,
image_data_uri: str,
audio_data_uri: str,
audio_title: str,
audio_volume: float,
) -> str:
template = TEMPLATE_PATH.read_text(encoding="utf-8")
return (
template.replace("__TITLE_TEXT__", escape_html_text(title))
.replace("__TITLE_JSON__", json.dumps(title, ensure_ascii=False))
.replace(
"__NARRATION_JSON__",
json.dumps(narration, ensure_ascii=False),
)
.replace(
"__INITIAL_ZOOM_LEVEL_JSON__",
json.dumps(initial_zoom_level),
)
.replace(
"__IMAGE_DATA_URI_JSON__",
json.dumps(image_data_uri, ensure_ascii=False),
)
.replace(
"__AUDIO_DATA_URI_JSON__",
json.dumps(audio_data_uri, ensure_ascii=False),
)
.replace("__AUDIO_TITLE_JSON__", json.dumps(audio_title))
.replace("__AUDIO_VOLUME_JSON__", json.dumps(audio_volume))
)
def escape_html_text(value: str) -> str:
return (
value.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Create a standalone Questforge 360 panorama viewer."
)
parser.add_argument("--image", required=True, type=Path)
parser.add_argument("--output", type=Path)
parser.add_argument("--title", default="")
parser.add_argument("--narration", default="")
parser.add_argument("--audio", type=Path)
parser.add_argument("--audio-title", default="")
parser.add_argument(
"--audio-volume",
type=float,
default=0.28,
help="Ambient loop volume from 0 to 1. Defaults to 0.28.",
)
parser.add_argument(
"--initial-zoom-level",
type=int,
default=14,
help=(
"Initial zoom level from 1 to 22. Higher values start wider. "
"Defaults to 14."
),
)
return parser
def main(arguments: Iterable[str] | None = None) -> int:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
parsed_arguments = build_parser().parse_args(arguments)
result = create_panorama_viewer(
image_path=parsed_arguments.image,
output_path=parsed_arguments.output,
title=parsed_arguments.title,
narration=parsed_arguments.narration,
initial_zoom_level=parsed_arguments.initial_zoom_level,
audio_path=parsed_arguments.audio,
audio_title=parsed_arguments.audio_title,
audio_volume=parsed_arguments.audio_volume,
)
print(json.dumps(asdict(result), indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: fbfb90f8a98ec7653f044475157fe995d1201d3287c1e3c5e993458d8a7f68b5