AndrewRqy commited on
Commit ·
079fead
1
Parent(s): 4f21988
Memoize hot HTML helpers and lazy-load image module sprites for faster transitions
Browse files- app.py +48 -35
- oracles/images.py +21 -3
app.py
CHANGED
|
@@ -746,28 +746,36 @@ def _inscribe_decoration_html(state: Optional[GameState] = None) -> str:
|
|
| 746 |
)
|
| 747 |
|
| 748 |
|
| 749 |
-
|
| 750 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 751 |
|
| 752 |
-
Reads the cozy-forest near.png and embeds it as a data URI so the HTML
|
| 753 |
-
is fully self-contained (no static-file routing dependency).
|
| 754 |
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
(initial render before any theme has been picked).
|
| 758 |
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
# Pull theme-driven title + subtitle.
|
| 765 |
-
from oracles.themes import get_theme as _get_theme
|
| 766 |
theme_key = getattr(state, "theme", "fantasy") if state else "fantasy"
|
| 767 |
-
theme = _get_theme(theme_key)
|
| 768 |
lang = _tlang(state)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 769 |
# Game-wide title — every theme is a chapter under the same banner.
|
| 770 |
-
# The theme's display_name becomes a subtitle (smaller, italic).
|
| 771 |
game_title = "弟子" if lang == "zh" else "THE APPRENTICE"
|
| 772 |
theme_label = theme.localized_field("display_name", lang) or "The Apprentice"
|
| 773 |
if lang != "zh":
|
|
@@ -777,18 +785,15 @@ def _banner_html(state: Optional[GameState] = None) -> str:
|
|
| 777 |
theme.localized_field("banner_subtitle", lang)
|
| 778 |
or _t("banner_default_subtitle", lang)
|
| 779 |
)
|
| 780 |
-
# Prepend the theme name to the subtitle so the player still sees which
|
| 781 |
-
# chapter / world they're in even though the title is now universal.
|
| 782 |
subtitle = _md_safe(f"{theme_label} — {subtitle_raw}")
|
| 783 |
|
| 784 |
# Decide tall vs compact based on phase. Inscribe = tall (intro vibe).
|
| 785 |
# Trial/epilogue = compact (give screen real estate to the visuals).
|
| 786 |
-
mode = getattr(state, "mode", "inscribe") if state else "inscribe"
|
| 787 |
is_compact = mode in ("trial", "epilogue", "send_off")
|
| 788 |
extra_cls = " oracle-banner-compact" if is_compact else ""
|
| 789 |
|
| 790 |
if not _PARALLAX_PNG.exists():
|
| 791 |
-
|
| 792 |
f"<div class='oracle-banner{extra_cls}' style='background:"
|
| 793 |
"linear-gradient(180deg,var(--w-panel) 0%, var(--w-night) 100%);'>"
|
| 794 |
"<div class='oracle-banner-overlay'>"
|
|
@@ -796,14 +801,10 @@ def _banner_html(state: Optional[GameState] = None) -> str:
|
|
| 796 |
f"<div class='oracle-banner-sub'>{subtitle}</div>"
|
| 797 |
"</div></div>"
|
| 798 |
)
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
# every click handler (banner_html is in every outputs bundle), so the
|
| 802 |
-
# base64 work was repeated per click and inflated each response by
|
| 803 |
-
# ~270KB. Now the URL is a tiny constant string and the browser
|
| 804 |
-
# caches the PNG after the first request.
|
| 805 |
src = f"/gradio_api/file={_PARALLAX_PNG.as_posix()}"
|
| 806 |
-
|
| 807 |
f"<div class='oracle-banner{extra_cls}'>"
|
| 808 |
f"<img src='{src}' alt='banner'/>"
|
| 809 |
"<div class='oracle-banner-overlay'>"
|
|
@@ -811,6 +812,8 @@ def _banner_html(state: Optional[GameState] = None) -> str:
|
|
| 811 |
f"<div class='oracle-banner-sub'>{subtitle}</div>"
|
| 812 |
"</div></div>"
|
| 813 |
)
|
|
|
|
|
|
|
| 814 |
|
| 815 |
|
| 816 |
def _processing_overlay_html(state: Optional[GameState] = None) -> str:
|
|
@@ -926,18 +929,21 @@ def _theme_style_overrides_html(state: Optional[GameState] = None) -> str:
|
|
| 926 |
(fantasy uses the baseline). The block is injected into a gr.HTML slot
|
| 927 |
at the top of the layout that re-renders on every event.
|
| 928 |
"""
|
| 929 |
-
from oracles.themes import get_theme as _get_theme
|
| 930 |
theme_key = getattr(state, "theme", "fantasy") if state else "fantasy"
|
|
|
|
|
|
|
|
|
|
| 931 |
theme = _get_theme(theme_key)
|
| 932 |
if not theme.palette:
|
|
|
|
| 933 |
return ""
|
| 934 |
-
# Build "--name: value;" lines for each variable.
|
| 935 |
decls = "\n ".join(
|
| 936 |
f"--{name}: {value} !important;"
|
| 937 |
for name, value in theme.palette.items()
|
| 938 |
)
|
| 939 |
-
|
| 940 |
-
|
|
|
|
| 941 |
|
| 942 |
|
| 943 |
# ---------------------------------------------------------------------------
|
|
@@ -3684,21 +3690,26 @@ def _localized_chrome_updates(state: GameState):
|
|
| 3684 |
don't clobber values the user has just typed in.
|
| 3685 |
"""
|
| 3686 |
lang = _tlang(state)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3687 |
from oracles.state import NARRATION_LENGTHS as _NL
|
| 3688 |
nl_choices = [v[0][lang] if isinstance(v[0], dict) else v[0]
|
| 3689 |
for v in _NL.values()]
|
| 3690 |
nl_default_label = _NL["medium"][0]
|
| 3691 |
nl_default = (nl_default_label[lang] if isinstance(nl_default_label, dict)
|
| 3692 |
else nl_default_label)
|
| 3693 |
-
# Map the current state.narration_length key back to its localized label
|
| 3694 |
-
# so the dropdown's `value` stays in sync after a language flip.
|
| 3695 |
-
nl_current_key = getattr(state, "narration_length", "medium") or "medium"
|
| 3696 |
nl_current_entry = _NL.get(nl_current_key, _NL["medium"])
|
| 3697 |
nl_current_label = nl_current_entry[0]
|
| 3698 |
nl_current = (nl_current_label[lang] if isinstance(nl_current_label, dict)
|
| 3699 |
else nl_current_label)
|
| 3700 |
|
| 3701 |
-
|
| 3702 |
gr.update(
|
| 3703 |
label=_t("label_narration_length", lang),
|
| 3704 |
info=_t("info_narration_length", lang),
|
|
@@ -3741,6 +3752,8 @@ def _localized_chrome_updates(state: GameState):
|
|
| 3741 |
_theme_dropdown_update(state, lang),
|
| 3742 |
gr.update(label=_t("label_chronicle_accordion", lang)),
|
| 3743 |
)
|
|
|
|
|
|
|
| 3744 |
|
| 3745 |
|
| 3746 |
def _theme_dropdown_update(state: GameState, lang: str):
|
|
|
|
| 746 |
)
|
| 747 |
|
| 748 |
|
| 749 |
+
# ---------------------------------------------------------------------------
|
| 750 |
+
# Memoization caches for HTML helpers that are recomputed on every click.
|
| 751 |
+
# Each helper is a pure function of a small slice of state (theme + lang +
|
| 752 |
+
# mode at most). Caching by the relevant tuple turns repeated identical
|
| 753 |
+
# calls into a dict lookup. On a 6-theme × 2-lang × 5-mode app, the caches
|
| 754 |
+
# saturate at ~60 entries — bounded, no eviction needed.
|
| 755 |
+
# ---------------------------------------------------------------------------
|
| 756 |
+
_BANNER_HTML_CACHE: dict = {}
|
| 757 |
+
_THEME_STYLE_CACHE: dict = {}
|
| 758 |
+
_INSCRIBE_DECORATION_CACHE: dict = {}
|
| 759 |
+
_LOCALIZED_CHROME_CACHE: dict = {}
|
| 760 |
|
|
|
|
|
|
|
| 761 |
|
| 762 |
+
def _banner_html(state: Optional[GameState] = None) -> str:
|
| 763 |
+
"""Top-of-page parallax banner with the game title overlaid.
|
|
|
|
| 764 |
|
| 765 |
+
Cached by (theme, lang, mode) — these are the only fields the rendered
|
| 766 |
+
HTML depends on. The bundle re-runs this helper on every click; without
|
| 767 |
+
caching, each call rebuilds the same string. Cache hit is a dict
|
| 768 |
+
lookup; cache miss recomputes.
|
| 769 |
+
"""
|
|
|
|
|
|
|
| 770 |
theme_key = getattr(state, "theme", "fantasy") if state else "fantasy"
|
|
|
|
| 771 |
lang = _tlang(state)
|
| 772 |
+
mode = getattr(state, "mode", "inscribe") if state else "inscribe"
|
| 773 |
+
cache_key = (theme_key, lang, mode)
|
| 774 |
+
if cache_key in _BANNER_HTML_CACHE:
|
| 775 |
+
return _BANNER_HTML_CACHE[cache_key]
|
| 776 |
+
from oracles.themes import get_theme as _get_theme
|
| 777 |
+
theme = _get_theme(theme_key)
|
| 778 |
# Game-wide title — every theme is a chapter under the same banner.
|
|
|
|
| 779 |
game_title = "弟子" if lang == "zh" else "THE APPRENTICE"
|
| 780 |
theme_label = theme.localized_field("display_name", lang) or "The Apprentice"
|
| 781 |
if lang != "zh":
|
|
|
|
| 785 |
theme.localized_field("banner_subtitle", lang)
|
| 786 |
or _t("banner_default_subtitle", lang)
|
| 787 |
)
|
|
|
|
|
|
|
| 788 |
subtitle = _md_safe(f"{theme_label} — {subtitle_raw}")
|
| 789 |
|
| 790 |
# Decide tall vs compact based on phase. Inscribe = tall (intro vibe).
|
| 791 |
# Trial/epilogue = compact (give screen real estate to the visuals).
|
|
|
|
| 792 |
is_compact = mode in ("trial", "epilogue", "send_off")
|
| 793 |
extra_cls = " oracle-banner-compact" if is_compact else ""
|
| 794 |
|
| 795 |
if not _PARALLAX_PNG.exists():
|
| 796 |
+
result = (
|
| 797 |
f"<div class='oracle-banner{extra_cls}' style='background:"
|
| 798 |
"linear-gradient(180deg,var(--w-panel) 0%, var(--w-night) 100%);'>"
|
| 799 |
"<div class='oracle-banner-overlay'>"
|
|
|
|
| 801 |
f"<div class='oracle-banner-sub'>{subtitle}</div>"
|
| 802 |
"</div></div>"
|
| 803 |
)
|
| 804 |
+
_BANNER_HTML_CACHE[cache_key] = result
|
| 805 |
+
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
| 806 |
src = f"/gradio_api/file={_PARALLAX_PNG.as_posix()}"
|
| 807 |
+
result = (
|
| 808 |
f"<div class='oracle-banner{extra_cls}'>"
|
| 809 |
f"<img src='{src}' alt='banner'/>"
|
| 810 |
"<div class='oracle-banner-overlay'>"
|
|
|
|
| 812 |
f"<div class='oracle-banner-sub'>{subtitle}</div>"
|
| 813 |
"</div></div>"
|
| 814 |
)
|
| 815 |
+
_BANNER_HTML_CACHE[cache_key] = result
|
| 816 |
+
return result
|
| 817 |
|
| 818 |
|
| 819 |
def _processing_overlay_html(state: Optional[GameState] = None) -> str:
|
|
|
|
| 929 |
(fantasy uses the baseline). The block is injected into a gr.HTML slot
|
| 930 |
at the top of the layout that re-renders on every event.
|
| 931 |
"""
|
|
|
|
| 932 |
theme_key = getattr(state, "theme", "fantasy") if state else "fantasy"
|
| 933 |
+
if theme_key in _THEME_STYLE_CACHE:
|
| 934 |
+
return _THEME_STYLE_CACHE[theme_key]
|
| 935 |
+
from oracles.themes import get_theme as _get_theme
|
| 936 |
theme = _get_theme(theme_key)
|
| 937 |
if not theme.palette:
|
| 938 |
+
_THEME_STYLE_CACHE[theme_key] = ""
|
| 939 |
return ""
|
|
|
|
| 940 |
decls = "\n ".join(
|
| 941 |
f"--{name}: {value} !important;"
|
| 942 |
for name, value in theme.palette.items()
|
| 943 |
)
|
| 944 |
+
result = f"<style data-theme='{theme.key}'>\n:root {{\n {decls}\n}}\n</style>"
|
| 945 |
+
_THEME_STYLE_CACHE[theme_key] = result
|
| 946 |
+
return result
|
| 947 |
|
| 948 |
|
| 949 |
# ---------------------------------------------------------------------------
|
|
|
|
| 3690 |
don't clobber values the user has just typed in.
|
| 3691 |
"""
|
| 3692 |
lang = _tlang(state)
|
| 3693 |
+
nl_current_key = getattr(state, "narration_length", "medium") or "medium"
|
| 3694 |
+
theme_key = getattr(state, "theme", "fantasy") or "fantasy"
|
| 3695 |
+
# Cache by (lang, narration-length key, theme key) — the only fields
|
| 3696 |
+
# the returned tuple of gr.update() objects actually depends on. The
|
| 3697 |
+
# theme is in the key because _theme_dropdown_update inspects state.theme.
|
| 3698 |
+
cache_key = (lang, nl_current_key, theme_key)
|
| 3699 |
+
if cache_key in _LOCALIZED_CHROME_CACHE:
|
| 3700 |
+
return _LOCALIZED_CHROME_CACHE[cache_key]
|
| 3701 |
from oracles.state import NARRATION_LENGTHS as _NL
|
| 3702 |
nl_choices = [v[0][lang] if isinstance(v[0], dict) else v[0]
|
| 3703 |
for v in _NL.values()]
|
| 3704 |
nl_default_label = _NL["medium"][0]
|
| 3705 |
nl_default = (nl_default_label[lang] if isinstance(nl_default_label, dict)
|
| 3706 |
else nl_default_label)
|
|
|
|
|
|
|
|
|
|
| 3707 |
nl_current_entry = _NL.get(nl_current_key, _NL["medium"])
|
| 3708 |
nl_current_label = nl_current_entry[0]
|
| 3709 |
nl_current = (nl_current_label[lang] if isinstance(nl_current_label, dict)
|
| 3710 |
else nl_current_label)
|
| 3711 |
|
| 3712 |
+
result = (
|
| 3713 |
gr.update(
|
| 3714 |
label=_t("label_narration_length", lang),
|
| 3715 |
info=_t("info_narration_length", lang),
|
|
|
|
| 3752 |
_theme_dropdown_update(state, lang),
|
| 3753 |
gr.update(label=_t("label_chronicle_accordion", lang)),
|
| 3754 |
)
|
| 3755 |
+
_LOCALIZED_CHROME_CACHE[cache_key] = result
|
| 3756 |
+
return result
|
| 3757 |
|
| 3758 |
|
| 3759 |
def _theme_dropdown_update(state: GameState, lang: str):
|
oracles/images.py
CHANGED
|
@@ -107,13 +107,26 @@ def _load_scene_decor_data_uris() -> None:
|
|
| 107 |
_SCENE_DECOR_DATA_URIS[name] = uri
|
| 108 |
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
|
| 115 |
def sprite_data_uri(name: str) -> Optional[str]:
|
| 116 |
"""Return a data URI for a Klein sprite, or None if it doesn't exist."""
|
|
|
|
| 117 |
return _SPRITE_DATA_URIS.get(name)
|
| 118 |
|
| 119 |
|
|
@@ -736,10 +749,15 @@ def generate_scene_image(
|
|
| 736 |
) -> ImageResult:
|
| 737 |
"""Generate a scene image for `resolution`.
|
| 738 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 739 |
MOCK path (default): always returns an SVG (or PNG via cairosvg) card.
|
| 740 |
LIVE path: try Klein endpoint; on any failure, silently fall back to mock.
|
| 741 |
Caption is always populated even if file writes fail.
|
| 742 |
"""
|
|
|
|
| 743 |
os.makedirs(out_dir, exist_ok=True)
|
| 744 |
|
| 745 |
caption = f"Trial {resolution.trial_index}: {resolution.tactic}"
|
|
|
|
| 107 |
_SCENE_DECOR_DATA_URIS[name] = uri
|
| 108 |
|
| 109 |
|
| 110 |
+
# Lazy initialization — these loaders read 15 PNGs and base64-encode them
|
| 111 |
+
# into RAM. Doing that at module import time (the old behavior) added 1-3s
|
| 112 |
+
# to every cold start on HF Spaces' shared CPU. The accessors below call
|
| 113 |
+
# ``_ensure_loaded()`` on first use instead.
|
| 114 |
+
_LOADED = False
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _ensure_loaded() -> None:
|
| 118 |
+
global _LOADED
|
| 119 |
+
if _LOADED:
|
| 120 |
+
return
|
| 121 |
+
_load_sprite_data_uris()
|
| 122 |
+
_load_scene_texture_data_uris()
|
| 123 |
+
_load_scene_decor_data_uris()
|
| 124 |
+
_LOADED = True
|
| 125 |
|
| 126 |
|
| 127 |
def sprite_data_uri(name: str) -> Optional[str]:
|
| 128 |
"""Return a data URI for a Klein sprite, or None if it doesn't exist."""
|
| 129 |
+
_ensure_loaded()
|
| 130 |
return _SPRITE_DATA_URIS.get(name)
|
| 131 |
|
| 132 |
|
|
|
|
| 749 |
) -> ImageResult:
|
| 750 |
"""Generate a scene image for `resolution`.
|
| 751 |
|
| 752 |
+
Triggers the lazy sprite/texture/decor data-uri load on first call so
|
| 753 |
+
cold start doesn't pay for it. After this returns once, the dicts stay
|
| 754 |
+
populated for subsequent calls.
|
| 755 |
+
|
| 756 |
MOCK path (default): always returns an SVG (or PNG via cairosvg) card.
|
| 757 |
LIVE path: try Klein endpoint; on any failure, silently fall back to mock.
|
| 758 |
Caption is always populated even if file writes fail.
|
| 759 |
"""
|
| 760 |
+
_ensure_loaded()
|
| 761 |
os.makedirs(out_dir, exist_ok=True)
|
| 762 |
|
| 763 |
caption = f"Trial {resolution.trial_index}: {resolution.tactic}"
|