Add museum map reveal, artifact path puzzle, and homelab media tooling.

Puzzle 1 crossfades to the stitched map with a link to /museum-path; puzzle 2 is a touch-friendly drag-and-order challenge with PDF extract scripts for map and artifact assets.
This commit is contained in:
2026-06-11 09:57:23 -04:00
parent c810efd8e5
commit 7c68aa3bd3
20 changed files with 1179 additions and 29 deletions
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Extract the 16 museum-map artifact images from EgyptEscapeRoomPrintables.pdf."""
from __future__ import annotations
import argparse
import io
import json
import sys
from pathlib import Path
try:
import fitz # PyMuPDF
except ImportError:
print("PyMuPDF is required: pip install pymupdf", file=sys.stderr)
raise SystemExit(1) from None
from PIL import Image
# Identified by xref on pages 910 (puzzle 5 map). Slugs match the answer sheet.
ARTIFACTS: list[dict[str, int | str]] = [
{"page": 9, "xref": 56, "slug": "double-crown", "name": "Double Crown"},
{"page": 9, "xref": 57, "slug": "obelisk", "name": "Obelisk"},
{"page": 9, "xref": 63, "slug": "goddess-isis", "name": "Goddess Isis"},
{"page": 9, "xref": 53, "slug": "canopic-jars", "name": "Canopic Jars"},
{"page": 9, "xref": 61, "slug": "howard-carter-excavating-king-tut", "name": "Howard Carter excavating King Tut"},
{"page": 9, "xref": 54, "slug": "anubis", "name": "Anubis"},
{"page": 9, "xref": 58, "slug": "bust-of-nefertiti", "name": "Bust of Nefertiti"},
{"page": 9, "xref": 60, "slug": "god-osiris", "name": "God Osiris"},
{"page": 10, "xref": 68, "slug": "pyramid-of-giza", "name": "Pyramid of Giza"},
{"page": 10, "xref": 75, "slug": "temple-of-hatshepsut", "name": "Temple of Hatshepsut"},
{"page": 10, "xref": 72, "slug": "map-of-the-nile", "name": "Map of the Nile"},
{"page": 10, "xref": 67, "slug": "egyptian-painting", "name": "Egyptian painting"},
{"page": 10, "xref": 74, "slug": "sarcophagus", "name": "Sarcophagus"},
{"page": 10, "xref": 70, "slug": "statue-of-hatshepsut", "name": "Statue of Hatshepsut"},
{"page": 10, "xref": 76, "slug": "step-pyramid", "name": "Step Pyramid"},
{"page": 10, "xref": 71, "slug": "mastaba", "name": "Mastaba"},
]
def find_image_info(page: fitz.Page, xref: int) -> dict:
for info in page.get_image_info(xrefs=True):
if info["xref"] == xref:
return info
raise ValueError(f"Image xref {xref} not found on page {page.number + 1}")
def render_clip(page: fitz.Page, bbox: tuple[float, float, float, float], dpi: float) -> Image.Image:
matrix = fitz.Matrix(dpi / 72.0, dpi / 72.0)
pixmap = page.get_pixmap(matrix=matrix, clip=fitz.Rect(bbox), alpha=False)
return Image.open(io.BytesIO(pixmap.tobytes("png")))
def extract_embedded(doc: fitz.Document, xref: int) -> tuple[Image.Image, str]:
data = doc.extract_image(xref)
ext = data["ext"]
image = Image.open(io.BytesIO(data["image"]))
smask = data.get("smask")
if smask:
mask_data = doc.extract_image(smask)
mask = Image.open(io.BytesIO(mask_data["image"])).convert("L")
if mask.size != image.size:
mask = mask.resize(image.size, Image.Resampling.LANCZOS)
rgba = image.convert("RGBA")
rgba.putalpha(mask)
background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
image = Image.alpha_composite(background, rgba).convert("RGB")
ext = "png"
return image, ext
def extract_artifacts(
pdf_path: str,
output_dir: Path,
*,
dpi: float = 300,
mode: str = "render",
) -> list[dict[str, object]]:
doc = fitz.open(pdf_path)
output_dir.mkdir(parents=True, exist_ok=True)
manifest: list[dict[str, object]] = []
try:
for artifact in ARTIFACTS:
page_number = int(artifact["page"])
xref = int(artifact["xref"])
slug = str(artifact["slug"])
name = str(artifact["name"])
page = doc[page_number - 1]
info = find_image_info(page, xref)
if mode == "render":
image = render_clip(page, info["bbox"], dpi)
filename = f"{slug}.png"
image.save(output_dir / filename, optimize=True)
else:
image, ext = extract_embedded(doc, xref)
filename = f"{slug}.{ext}"
if ext == "jpeg":
image.save(output_dir / filename, quality=92, optimize=True)
else:
image.save(output_dir / filename, optimize=True)
manifest.append(
{
"slug": slug,
"name": name,
"page": page_number,
"xref": xref,
"file": filename,
"width": image.width,
"height": image.height,
"bbox": info["bbox"],
}
)
print(f"Wrote {filename} ({image.width}x{image.height})")
finally:
doc.close()
manifest_path = output_dir / "manifest.json"
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
print(f"Wrote {manifest_path}")
return manifest
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pdf", help="Path to EgyptEscapeRoomPrintables.pdf")
parser.add_argument(
"output_dir",
nargs="?",
default="public/media/artifacts",
help="Output directory (default: public/media/artifacts)",
)
parser.add_argument("--dpi", type=float, default=300, help="Render DPI for --mode render (default: 300)")
parser.add_argument(
"--mode",
choices=("render", "embedded"),
default="render",
help="render: clip from page (default, best for illustrations); embedded: raw image bytes",
)
args = parser.parse_args()
extract_artifacts(args.pdf, Path(args.output_dir), dpi=args.dpi, mode=args.mode)
if __name__ == "__main__":
main()
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Extract puzzle 5 map pages from the printables PDF and stitch into one image."""
from __future__ import annotations
import argparse
import io
import sys
try:
import fitz # PyMuPDF
except ImportError:
print("PyMuPDF is required: pip install pymupdf", file=sys.stderr)
raise SystemExit(1) from None
from PIL import Image, ImageOps
def render_page(doc: fitz.Document, page_number: int, dpi: float) -> Image.Image:
page = doc[page_number - 1]
zoom = dpi / 72.0
matrix = fitz.Matrix(zoom, zoom)
pixmap = page.get_pixmap(matrix=matrix, alpha=False)
return Image.open(io.BytesIO(pixmap.tobytes("png")))
def trim_white(image: Image.Image, *, pad: int = 8, threshold: int = 245) -> Image.Image:
gray = image.convert("L")
mask = gray.point(lambda value: 255 if value > threshold else 0)
bbox = mask.getbbox()
if bbox is None:
return image
left, top, right, bottom = bbox
left = max(0, left - pad)
top = max(0, top - pad)
right = min(image.width, right + pad)
bottom = min(image.height, bottom + pad)
return image.crop((left, top, right, bottom))
def match_height(left: Image.Image, right: Image.Image) -> tuple[Image.Image, Image.Image]:
height = max(left.height, right.height)
if left.height != height:
canvas = Image.new("RGB", (left.width, height), "white")
canvas.paste(left, (0, 0))
left = canvas
if right.height != height:
canvas = Image.new("RGB", (right.width, height), "white")
canvas.paste(right, (0, 0))
right = canvas
return left, right
def stitch_map(
pdf_path: str,
*,
left_page: int = 9,
right_page: int = 10,
dpi: float = 200,
) -> Image.Image:
doc = fitz.open(pdf_path)
try:
if doc.page_count < max(left_page, right_page):
raise ValueError(
f"PDF has {doc.page_count} pages; need at least page {max(left_page, right_page)}"
)
left = trim_white(render_page(doc, left_page, dpi))
right = trim_white(render_page(doc, right_page, dpi))
left, right = match_height(left, right)
combined = Image.new("RGB", (left.width + right.width, left.height), "white")
combined.paste(left, (0, 0))
combined.paste(right, (left.width, 0))
return combined
finally:
doc.close()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pdf", help="Path to EgyptEscapeRoomPrintables.pdf")
parser.add_argument("output", help="Output image path (.jpg or .png)")
parser.add_argument("--left-page", type=int, default=9, help="Left half page number (default: 9)")
parser.add_argument("--right-page", type=int, default=10, help="Right half page number (default: 10)")
parser.add_argument("--dpi", type=float, default=200, help="Render DPI (default: 200)")
args = parser.parse_args()
image = stitch_map(
args.pdf,
left_page=args.left_page,
right_page=args.right_page,
dpi=args.dpi,
)
output = args.output.lower()
if output.endswith(".png"):
image.save(args.output, optimize=True)
else:
image.save(args.output, quality=90, optimize=True)
print(f"Wrote {args.output} ({image.width}x{image.height})")
if __name__ == "__main__":
main()
+2
View File
@@ -0,0 +1,2 @@
pymupdf>=1.24
Pillow>=10.0
+4 -1
View File
@@ -3,6 +3,7 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SOURCE_DIR="${SOURCE_DIR:-/mnt/lockbox/storage/Documents/Home School Projects/Egyptian Escape Room}"
PDF_SOURCE="${PDF_SOURCE:-$SOURCE_DIR/EgyptEscapeRoomPrintables.pdf}"
VIDEO_SOURCE="${VIDEO_SOURCE:-$SOURCE_DIR/Ted Escape Room 1-5.mp4}"
POP_SOURCE="${POP_SOURCE:-$SOURCE_DIR/celebration-pop.mp3}"
HORN_SOURCE="${HORN_SOURCE:-$SOURCE_DIR/celebration-horn.mp3}"
@@ -10,7 +11,7 @@ INCORRECT_SOURCE="${INCORRECT_SOURCE:-$SOURCE_DIR/incorrect.mp3}"
CONFIRM_SOURCE="${CONFIRM_SOURCE:-$SOURCE_DIR/unlock-confirm.mp3}"
DEST_DIR="$ROOT/public/media"
for f in "$VIDEO_SOURCE" "$POP_SOURCE" "$HORN_SOURCE" "$INCORRECT_SOURCE" "$CONFIRM_SOURCE"; do
for f in "$PDF_SOURCE" "$VIDEO_SOURCE" "$POP_SOURCE" "$HORN_SOURCE" "$INCORRECT_SOURCE" "$CONFIRM_SOURCE"; do
if [[ ! -f "$f" ]]; then
echo "Media file not found: $f" >&2
exit 1
@@ -23,6 +24,8 @@ cp "$POP_SOURCE" "$DEST_DIR/celebration-pop.mp3"
cp "$HORN_SOURCE" "$DEST_DIR/celebration-horn.mp3"
cp "$INCORRECT_SOURCE" "$DEST_DIR/incorrect.mp3"
cp "$CONFIRM_SOURCE" "$DEST_DIR/unlock-confirm.mp3"
python3 "$ROOT/scripts/extract-artifact-room-map.py" "$PDF_SOURCE" "$DEST_DIR/artifact-room-map.jpg"
python3 "$ROOT/scripts/extract-artifact-images.py" "$PDF_SOURCE" "$DEST_DIR/artifacts"
cp "$ROOT/public/media/config.json.example" "$DEST_DIR/config.json"
echo "Copied media to $DEST_DIR/"
+6 -1
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SOURCE_DIR="${SOURCE_DIR:-/mnt/user/storage/Documents/Home School Projects/Egyptian Escape Room}"
PDF_SOURCE="${PDF_SOURCE:-$SOURCE_DIR/EgyptEscapeRoomPrintables.pdf}"
VIDEO_SOURCE="${VIDEO_SOURCE:-$SOURCE_DIR/Ted Escape Room 1-5.mp4}"
POP_SOURCE="${POP_SOURCE:-$SOURCE_DIR/celebration-pop.mp3}"
HORN_SOURCE="${HORN_SOURCE:-$SOURCE_DIR/celebration-horn.mp3}"
@@ -9,7 +11,7 @@ INCORRECT_SOURCE="${INCORRECT_SOURCE:-$SOURCE_DIR/incorrect.mp3}"
CONFIRM_SOURCE="${CONFIRM_SOURCE:-$SOURCE_DIR/unlock-confirm.mp3}"
DEST="${DEST:-/mnt/user/appdata/egyptian-escape-room/public}"
for f in "$VIDEO_SOURCE" "$POP_SOURCE" "$HORN_SOURCE" "$INCORRECT_SOURCE" "$CONFIRM_SOURCE"; do
for f in "$PDF_SOURCE" "$VIDEO_SOURCE" "$POP_SOURCE" "$HORN_SOURCE" "$INCORRECT_SOURCE" "$CONFIRM_SOURCE"; do
if [[ ! -f "$f" ]]; then
echo "Media file not found: $f" >&2
exit 1
@@ -22,9 +24,12 @@ cp "$POP_SOURCE" "$DEST/celebration-pop.mp3"
cp "$HORN_SOURCE" "$DEST/celebration-horn.mp3"
cp "$INCORRECT_SOURCE" "$DEST/incorrect.mp3"
cp "$CONFIRM_SOURCE" "$DEST/unlock-confirm.mp3"
python3 "$ROOT/scripts/extract-artifact-room-map.py" "$PDF_SOURCE" "$DEST/artifact-room-map.jpg"
python3 "$ROOT/scripts/extract-artifact-images.py" "$PDF_SOURCE" "$DEST/artifacts"
cat >"$DEST/config.json" <<'EOF'
{
"unlockVideoUrl": "/media/unlock.mp4",
"mapImageUrl": "/media/artifact-room-map.jpg",
"incorrectUrl": "/media/incorrect.mp3",
"unlockConfirmUrl": "/media/unlock-confirm.mp3",
"celebrationPopUrl": "/media/celebration-pop.mp3",