AndrewRqy commited on
Commit
079fead
·
1 Parent(s): 4f21988

Memoize hot HTML helpers and lazy-load image module sprites for faster transitions

Browse files
Files changed (2) hide show
  1. app.py +48 -35
  2. 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
- def _banner_html(state: Optional[GameState] = None) -> str:
750
- """Top-of-page parallax banner with the game title overlaid.
 
 
 
 
 
 
 
 
 
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
- The title and subtitle come from the active theme so each world reskins
756
- the banner. Falls back to fantasy defaults when no state is provided
757
- (initial render before any theme has been picked).
758
 
759
- Once the player leaves the inscribe phase (mode == 'trial' or
760
- 'epilogue'), the banner collapses to a compact strip via the
761
- `oracle-banner-compact` class so the trial visual gets that vertical
762
- real estate. The banner re-renders on every UI bundle so the swap is
763
- automatic."""
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
- return (
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
- # Serve the parallax PNG via Gradio's file route instead of inlining
800
- # ~200KB of base64 into every banner render. _banner_html() runs on
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
- return (
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
- # Scope to :root so the same vars used elsewhere in the stylesheet cascade.
940
- return f"<style data-theme='{theme.key}'>\n:root {{\n {decls}\n}}\n</style>"
 
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
- return (
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
- _load_sprite_data_uris()
111
- _load_scene_texture_data_uris()
112
- _load_scene_decor_data_uris()
 
 
 
 
 
 
 
 
 
 
 
 
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}"