← Files QuestforgeARCHIVED FILE
scripts/visual_gallery.py
21.8 KB · Sep 30, 2026 · 23:13 UTC
"""Generate a lightweight local Questforge visual gallery."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable
try:
from .campaign_memory import (
VisualIndexEntry,
get_campaign_paths,
read_visual_index,
)
from .panorama_viewer import create_panorama_viewer, slugify
except ImportError: # pragma: no cover - direct script execution path
from campaign_memory import (
VisualIndexEntry,
get_campaign_paths,
read_visual_index,
)
from panorama_viewer import create_panorama_viewer, slugify
IMAGE_SUFFIXES = {".gif", ".jpeg", ".jpg", ".png", ".webp"}
HTML_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITLE_TEXT__</title>
<style>
:root {
color-scheme: dark;
--background: #11110f;
--surface: #1b1c19;
--surface-strong: #242720;
--border: #3a3d35;
--text: #f1eee5;
--muted: #aaa79b;
--accent: #d79b58;
--accent-cool: #80aeb4;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--background);
color: var(--text);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
}
a {
color: inherit;
}
.topbar {
align-items: center;
background: rgba(17, 17, 15, 0.94);
border-bottom: 1px solid var(--border);
display: flex;
gap: 16px;
justify-content: space-between;
min-height: 72px;
padding: 14px clamp(16px, 4vw, 40px);
position: sticky;
top: 0;
z-index: 10;
}
.eyebrow {
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: clamp(20px, 3vw, 34px);
font-weight: 750;
line-height: 1.08;
}
.count {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--muted);
font-size: 13px;
min-width: 84px;
padding: 8px 10px;
text-align: center;
white-space: nowrap;
}
.top-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
}
.top-link {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 13px;
font-weight: 650;
min-height: 36px;
padding: 8px 11px;
text-decoration: none;
}
.top-link.primary {
border-color: rgba(215, 155, 88, 0.72);
color: var(--accent);
}
.shell {
display: grid;
gap: 20px;
margin: 0 auto;
max-width: 1240px;
padding: 18px clamp(16px, 4vw, 40px) 28px;
}
.timeline {
display: grid;
gap: 0;
}
.entry {
border-bottom: 1px solid var(--border);
display: grid;
gap: 8px;
padding: 20px 0;
scroll-margin-top: 92px;
}
.entry:first-child {
padding-top: 0;
}
.entry-frame {
align-items: center;
aspect-ratio: 16 / 9;
background:
linear-gradient(135deg, rgba(128, 174, 180, 0.12), transparent),
#060606;
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
justify-content: center;
min-height: 280px;
overflow: hidden;
}
.entry-frame img {
display: block;
height: 100%;
object-fit: contain;
width: 100%;
}
.entry-frame iframe {
border: 0;
display: block;
height: 100%;
width: 100%;
}
.entry-frame.panorama {
min-height: 520px;
}
.entry-caption {
align-items: center;
display: flex;
gap: 12px;
justify-content: space-between;
min-width: 0;
padding: 4px 2px 10px;
}
.caption-main {
color: var(--muted);
font-size: 14px;
font-weight: 650;
line-height: 1.35;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.caption-index {
color: var(--accent);
font-weight: 760;
}
.caption-kind {
color: var(--accent-cool);
font-weight: 760;
text-transform: uppercase;
}
.caption-label {
color: var(--text);
}
.caption-link {
background: var(--surface-strong);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--accent);
flex: 0 0 auto;
font-size: 12px;
font-weight: 650;
padding: 5px 8px;
text-decoration: none;
}
.empty {
align-items: center;
border: 1px dashed var(--border);
border-radius: 8px;
color: var(--muted);
display: none;
min-height: 280px;
padding: 28px;
}
@media (max-width: 900px) {
.entry-frame {
min-height: 220px;
}
.entry-frame.panorama {
min-height: 360px;
}
}
@media (max-width: 560px) {
.topbar {
align-items: flex-start;
flex-direction: column;
}
.top-actions {
justify-content: flex-start;
}
.count {
text-align: left;
}
}
</style>
</head>
<body>
<header class="topbar">
<div>
<div class="eyebrow">Questforge</div>
<h1>__TITLE_TEXT__</h1>
</div>
<nav class="top-actions" aria-label="Gallery navigation">
<a class="top-link" href="#visual-1">Start</a>
<a class="top-link primary" href="#latest" id="latest-action">Latest</a>
<div class="count" id="count">0 images</div>
</nav>
</header>
<main class="shell">
<section class="empty" id="empty">
No registered images yet.
</section>
<section class="timeline" id="timeline" aria-label="Image history"></section>
</main>
<script>
const ITEMS = __ITEMS_JSON__;
const GALLERY_VERSION = __GALLERY_VERSION_JSON__;
const AUTO_REFRESH_MS = __AUTO_REFRESH_MS_JSON__;
const empty = document.getElementById("empty");
const count = document.getElementById("count");
const timeline = document.getElementById("timeline");
const latestAction = document.getElementById("latest-action");
function createCaptionPart(className, text) {
const part = document.createElement("span");
part.className = className;
part.textContent = text;
return part;
}
function appendSeparator(container) {
container.append(document.createTextNode(" \\u00b7 "));
}
function createCaptionLink(label, url) {
if (!url) {
return null;
}
const link = document.createElement("a");
link.className = "caption-link";
link.href = url;
link.target = "_blank";
link.rel = "noreferrer";
link.textContent = label;
return link;
}
function createEntry(item, index) {
const article = document.createElement("article");
article.className = "entry";
article.id = `visual-${index + 1}`;
if (index === ITEMS.length - 1) {
article.dataset.latest = "true";
}
const frame = document.createElement("div");
frame.className = "entry-frame";
if (item.viewerUrl) {
frame.classList.add("panorama");
const viewer = document.createElement("iframe");
viewer.src = item.viewerUrl;
viewer.title = `${item.label} 360 viewer`;
viewer.loading = index > 2 ? "lazy" : "eager";
viewer.allow = "autoplay";
frame.append(viewer);
} else {
const image = document.createElement("img");
image.src = item.assetUrl;
image.alt = item.label;
image.loading = index > 2 ? "lazy" : "eager";
frame.append(image);
}
const caption = document.createElement("div");
caption.className = "entry-caption";
const summary = document.createElement("div");
summary.className = "caption-main";
const summaryText = `#${index + 1} \\u00b7 ${item.kind} \\u00b7 ${item.label}`;
summary.title = summaryText;
summary.append(createCaptionPart("caption-index", `#${index + 1}`));
appendSeparator(summary);
summary.append(createCaptionPart("caption-kind", item.kind));
appendSeparator(summary);
summary.append(createCaptionPart("caption-label", item.label));
caption.append(summary);
const viewerLink = createCaptionLink("Open 360", item.viewerUrl);
if (viewerLink) {
caption.append(viewerLink);
}
article.append(frame, caption);
return article;
}
function scrollToHash() {
if (!ITEMS.length) {
return;
}
let target = null;
if (isFollowingLatest()) {
target = timeline.querySelector("[data-latest='true']");
} else if (/^#visual-\\d+$/.test(window.location.hash)) {
target = document.querySelector(window.location.hash);
}
if (target) {
target.scrollIntoView({ block: "start" });
}
}
function isFollowingLatest() {
return window.location.hash === "" || window.location.hash === "#latest";
}
function currentDocumentUrl() {
const url = new URL(window.location.href);
url.hash = "";
url.searchParams.set("questforgeGalleryCheck", String(Date.now()));
return url.href;
}
let failedRefreshChecks = 0;
async function checkForGalleryUpdate() {
if (!isFollowingLatest() || document.hidden) {
return;
}
try {
const response = await fetch(currentDocumentUrl(), {
cache: "no-store",
});
if (!response.ok) {
return;
}
const html = await response.text();
const match = html.match(/const GALLERY_VERSION = "([^"]+)";/);
if (match && match[1] !== GALLERY_VERSION) {
window.location.reload();
}
failedRefreshChecks = 0;
} catch (error) {
failedRefreshChecks += 1;
if (failedRefreshChecks >= 2) {
window.location.reload();
}
}
}
function render() {
count.textContent = `${ITEMS.length} image${ITEMS.length === 1 ? "" : "s"}`;
if (!ITEMS.length) {
empty.style.display = "flex";
timeline.style.display = "none";
latestAction.classList.add("hidden");
return;
}
empty.style.display = "none";
ITEMS.forEach((item, index) => {
timeline.append(createEntry(item, index));
});
requestAnimationFrame(scrollToHash);
}
window.addEventListener("hashchange", scrollToHash);
document.addEventListener("visibilitychange", checkForGalleryUpdate);
window.setInterval(checkForGalleryUpdate, AUTO_REFRESH_MS);
render();
</script>
</body>
</html>
"""
@dataclass(frozen=True)
class GalleryItem:
"""One visual asset rendered by the local gallery."""
kind: str
label: str
session_number: int
scene_number: int
status: str
asset_path: str
asset_url: str
prompt_path: str
prompt_url: str
viewer_path: str
viewer_url: str
source_anchors: str
continuity_tags: str
review_notes: str
@dataclass(frozen=True)
class VisualGalleryResult:
"""Created visual gallery artifact."""
gallery_path: str
gallery_url: str
campaign_root: str
title: str
item_count: int
skipped_missing_assets: int
def create_visual_gallery(
campaign_root: Path,
output_path: Path | None = None,
title: str = "",
include_rejected: bool = False,
viewer_roots: list[Path] | None = None,
) -> VisualGalleryResult:
"""Create or refresh the standalone local gallery for a campaign."""
campaign_root = campaign_root.expanduser().resolve()
paths = get_campaign_paths(campaign_root)
if not paths.root.exists():
raise FileNotFoundError(f"Missing campaign root: {paths.root}")
if not paths.visual_index.exists():
raise FileNotFoundError(f"Missing visual index: {paths.visual_index}")
if output_path is None:
output_path = paths.root / "images" / "visual-gallery.html"
output_path = output_path.expanduser().resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
items, skipped_missing_assets = build_gallery_items(
campaign_root=paths.root,
output_dir=output_path.parent,
include_rejected=include_rejected,
viewer_roots=viewer_roots,
)
gallery_title = title.strip() or f"{paths.root.name} visual gallery"
html = render_gallery_html(gallery_title, items)
output_path.write_text(html, encoding="utf-8", newline="\n")
return VisualGalleryResult(
gallery_path=str(output_path),
gallery_url=f"{output_path.as_uri()}#latest",
campaign_root=str(paths.root),
title=gallery_title,
item_count=len(items),
skipped_missing_assets=skipped_missing_assets,
)
def build_gallery_items(
campaign_root: Path,
output_dir: Path,
include_rejected: bool = False,
viewer_roots: list[Path] | None = None,
) -> tuple[list[GalleryItem], int]:
paths = get_campaign_paths(campaign_root)
resolved_viewer_roots = resolve_viewer_roots(campaign_root, viewer_roots)
entries = sorted(
read_visual_index(paths.visual_index),
key=lambda entry: (
entry.session_number,
entry.scene_number,
entry.prompt_path,
entry.label,
),
)
items: list[GalleryItem] = []
skipped_missing_assets = 0
for entry in entries:
if not entry.asset_path:
continue
if entry.status == "rejected" and not include_rejected:
continue
asset_path = resolve_index_path(campaign_root, entry.asset_path)
if not asset_path.exists() or not asset_path.is_file():
skipped_missing_assets += 1
continue
if asset_path.suffix.lower() not in IMAGE_SUFFIXES:
continue
prompt_path = resolve_index_path(campaign_root, entry.prompt_path)
viewer_path = find_panorama_viewer(
resolved_viewer_roots,
entry,
asset_path,
)
if viewer_path is None and looks_like_panorama(entry, asset_path):
viewer_path = create_default_panorama_viewer(
paths.root,
entry,
asset_path,
)
items.append(
GalleryItem(
kind=entry.kind,
label=entry.label,
session_number=entry.session_number,
scene_number=entry.scene_number,
status=entry.status,
asset_path=relative_or_file_url(asset_path, output_dir),
asset_url=relative_or_file_url(asset_path, output_dir),
prompt_path=(
relative_or_file_url(prompt_path, output_dir)
if prompt_path.exists()
else ""
),
prompt_url=(
relative_or_file_url(prompt_path, output_dir)
if prompt_path.exists()
else ""
),
viewer_path=(
relative_or_file_url(viewer_path, output_dir)
if viewer_path
else ""
),
viewer_url=(
relative_or_file_url(viewer_path, output_dir)
if viewer_path
else ""
),
source_anchors=entry.source_anchors,
continuity_tags=entry.continuity_tags,
review_notes=entry.review_notes,
)
)
return items, skipped_missing_assets
def create_default_panorama_viewer(
campaign_root: Path,
entry: VisualIndexEntry,
asset_path: Path,
) -> Path:
"""Create a default local viewer for a panorama-like visual asset."""
output_path = (
campaign_root
/ "images"
/ "viewers"
/ f"{slugify(entry.label)}-360.html"
)
create_panorama_viewer(
image_path=asset_path,
output_path=output_path,
title=entry.label,
narration=entry.label,
initial_zoom_level=14,
)
return output_path.resolve()
def resolve_viewer_roots(
campaign_root: Path,
viewer_roots: list[Path] | None = None,
) -> list[Path]:
roots = [campaign_root.resolve()]
for viewer_root in viewer_roots or []:
candidate = viewer_root.expanduser()
if not candidate.is_absolute():
candidate = campaign_root / candidate
if candidate.exists():
roots.append(candidate.resolve())
return roots
def resolve_index_path(campaign_root: Path, index_path: str) -> Path:
candidate = Path(index_path)
if candidate.is_absolute():
return candidate.resolve()
return (campaign_root / index_path).resolve()
def find_panorama_viewer(
search_roots: list[Path],
entry: VisualIndexEntry,
asset_path: Path,
) -> Path | None:
"""Find a matching local 360 viewer for a panorama asset, if one exists."""
target_slugs = {
slug_for_match(entry.label),
slug_for_match(asset_path.stem),
}
candidates: list[tuple[int, Path]] = []
for search_root in search_roots:
for viewer_path in search_root.rglob("*.html"):
viewer_slug = slug_for_match(viewer_path.stem)
if "visual-gallery" in viewer_slug:
continue
if not any(
marker in viewer_slug
for marker in ("360", "panorama", "viewer")
):
continue
score = best_viewer_score(viewer_slug, target_slugs)
if score:
candidates.append((score, viewer_path.resolve()))
if not candidates:
return None
candidates.sort(key=lambda item: (-item[0], str(item[1]).casefold()))
return candidates[0][1]
def looks_like_panorama(entry: VisualIndexEntry, asset_path: Path) -> bool:
marker_text = slug_for_match(
f"{entry.kind} {entry.label} {asset_path.stem}"
)
return any(
marker in marker_text
for marker in ("pov-360", "360", "panorama", "panoramico")
)
def slug_for_match(value: str) -> str:
return "".join(
character.lower() if character.isalnum() else "-"
for character in value
).strip("-")
def best_viewer_score(viewer_slug: str, target_slugs: set[str]) -> int:
score = 0
for target_slug in target_slugs:
if not target_slug:
continue
if target_slug in viewer_slug:
score = max(score, len(target_slug))
return score
def relative_or_file_url(path: Path, base_dir: Path) -> str:
try:
relative_path = Path(os.path.relpath(path, base_dir))
except ValueError:
return path.as_uri()
return relative_path.as_posix()
def render_gallery_html(title: str, items: list[GalleryItem]) -> str:
items_payload = [
{
"kind": item.kind,
"label": item.label,
"sessionNumber": item.session_number,
"sceneNumber": item.scene_number,
"status": item.status,
"assetPath": item.asset_path,
"assetUrl": item.asset_url,
"promptPath": item.prompt_path,
"promptUrl": item.prompt_url,
"viewerPath": item.viewer_path,
"viewerUrl": item.viewer_url,
"sourceAnchors": item.source_anchors,
"continuityTags": item.continuity_tags,
"reviewNotes": item.review_notes,
}
for item in items
]
return (
HTML_TEMPLATE.replace("__TITLE_TEXT__", escape_html_text(title))
.replace("__ITEMS_JSON__", safe_json_script(items_payload))
.replace(
"__GALLERY_VERSION_JSON__",
json.dumps(create_gallery_version(items_payload)),
)
.replace("__AUTO_REFRESH_MS_JSON__", json.dumps(8000))
)
def create_gallery_version(payload: object) -> str:
version_source = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
).encode("utf-8")
return hashlib.sha256(version_source).hexdigest()[:16]
def safe_json_script(payload: object) -> str:
return json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")
def escape_html_text(value: str) -> str:
return (
value.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Create or refresh a local Questforge visual gallery."
)
parser.add_argument("--campaign-root", required=True, type=Path)
parser.add_argument("--output", type=Path)
parser.add_argument("--title", default="")
parser.add_argument("--include-rejected", action="store_true")
parser.add_argument(
"--viewer-root",
action="append",
default=[],
type=Path,
help=(
"Optional extra folder to scan for matching 360 viewer HTML "
"files. Relative paths are resolved from the campaign root."
),
)
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_visual_gallery(
campaign_root=parsed_arguments.campaign_root,
output_path=parsed_arguments.output,
title=parsed_arguments.title,
include_rejected=parsed_arguments.include_rejected,
viewer_roots=parsed_arguments.viewer_root,
)
print(json.dumps(asdict(result), indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: c7f7fcfb7ebee359d25eb4179cafa8a21b3c2d847b62fd2d01f2b75a9afd2d70