"""
StoryForge β Gradio app.
Output tuple (19 elements, index-stable across all handlers):
0 story_state
1 setup_col
2 story_col
3 ending_col
4 beat_display
5 status_html (kept for index stability)
6 progress_html
7-12 option_btns[0..5]
13 full_story_md
14 image_placeholder (shimmer skeleton while the image generates)
15 beat_image
16 beat_audio (TTS narration, edge-tts MP3)
17 ambient_audio (background music, numpy WAV)
18 pdf_file (downloadable PDF at story end)
"""
import html as _html
import io
import threading
import traceback
import gradio as gr
# Patch gradio Blocks.get_api_info β gradio-client 1.3.0 crashes on bool
# additionalProperties in pydantic schemas (APIInfoParseError). Non-critical:
# only affects the /info endpoint used by gradio Python client, not the UI.
try:
from gradio_client.utils import APIInfoParseError as _APIInfoParseError
import gradio.blocks as _gb
_orig_get_api_info = _gb.Blocks.get_api_info
def _safe_get_api_info(self):
try:
return _orig_get_api_info(self)
except (_APIInfoParseError, TypeError, Exception):
return {"named_endpoints": {}, "unnamed_endpoints": {}}
_gb.Blocks.get_api_info = _safe_get_api_info
except Exception:
pass
import ambient
import pdf_export
import stt
import tts
from engine import (
StoryState,
apply_turn,
build_image_prompt,
build_prompt,
extract_partial_beat,
parse_response,
system_prompt,
)
import image_model as img_model
import model as story_model
THEMES = [
("π¦", "A brave little fox", "courage & friendship"),
("π", "A trip to a sleepy moon", "wonder & exploration"),
("π", "The friendly sea monster", "kindness & the deep"),
("π³", "The whispering forest", "nature & mystery"),
("π", "The runaway balloon", "adventure & freedom"),
("π", "The shy dragon", "belonging & bravery"),
]
MAX_OPTIONS = 6
LANGUAGES = ["English", "PortuguΓͺs"]
_WHISPER_LANG = {"English": "en", "PortuguΓͺs": "pt"}
# ββ HTML helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _beat_html(text: str, streaming: bool = False) -> str:
esc = _html.escape(text)
if streaming:
# No beat-visible class: the fade-in animation would restart on every
# streamed chunk. The cursor blinks via CSS instead.
return (
'
'
)
return f''
_SHIMMER_HTML = ''
def _error_html(msg: str) -> str:
return f'✨ {_html.escape(msg)}
'
def _loading_html() -> str:
return (
''
'✏️ Writing your story'
'...'
"
"
)
def _progress_html(moment: int, total: int) -> str:
dots = "".join(
f''
for i in range(1, total + 1)
)
return (
f'{dots}'
f'Moment {moment} of {total}
'
)
def _end_html(beat: str) -> str:
esc = _html.escape(beat)
return (
'✨
'
f''
)
# ββ Screen helpers β every handler yields exactly these 19 values βββββββββββββ
def _opt(options: list, disabled: bool = False) -> list:
out = []
for i in range(MAX_OPTIONS):
if i < len(options):
out.append(gr.update(visible=True, value=options[i], interactive=not disabled))
else:
out.append(gr.update(visible=False, value="", interactive=False))
return out
def _setup_screen(state, status=""):
return (
state,
gr.update(visible=True),
gr.update(visible=False),
gr.update(visible=False),
"", status, "",
*_opt([]),
"",
"", # image_placeholder
gr.update(value=None, visible=False), # beat_image
gr.update(value=None, visible=False), # beat_audio
gr.update(value=None, visible=False), # ambient_audio
gr.update(visible=False), # pdf_file
)
def _story_screen(
state, beat, options, moment, total,
*,
loading=False,
streaming=False,
image=None,
audio=None,
ambient_val=None,
shimmer=False,
status="",
):
return (
state,
gr.update(visible=False),
gr.update(visible=True),
gr.update(visible=False),
_loading_html() if loading else _beat_html(beat, streaming=streaming),
status,
_progress_html(moment, total),
*_opt([] if (loading or streaming) else options),
"",
_SHIMMER_HTML if shimmer else "",
gr.update() if image is None else gr.update(value=image, visible=True),
gr.update() if audio is None else gr.update(value=audio, autoplay=True, visible=True),
gr.update() if ambient_val is None else gr.update(value=ambient_val, autoplay=True, visible=True),
gr.update(visible=False),
)
def _ending_screen(state, beat, full_story, total, *, image=None, audio=None, pdf=None):
return (
state,
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=True),
_end_html(beat),
"",
_progress_html(total, total),
*_opt([]),
full_story,
"", # image_placeholder
gr.update() if image is None else gr.update(value=image, visible=True),
gr.update() if audio is None else gr.update(value=audio, autoplay=True, visible=True),
gr.update(), # ambient_audio β keep playing
gr.update() if pdf is None else gr.update(value=pdf, visible=True),
)
# ββ State helper ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _state_from(d: dict) -> StoryState:
return StoryState.from_dict({k: v for k, v in d.items() if not k.startswith("_")})
# ββ Core generation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
_STREAM_STEP_CHARS = 12
def _generate_beat_events(s: StoryState):
"""Yield ("partial", beat_text) while streaming, then ("done", (s, data))."""
system = system_prompt(s.language)
prompt = build_prompt(s)
is_final = (s.moment + 1) >= s.total_moments
raw = ""
shown = 0
for acc in story_model.generate_stream(
system, prompt, max_tokens=1024 if is_final else 512
):
raw = acc
partial = extract_partial_beat(acc)
if partial and len(partial) - shown >= _STREAM_STEP_CHARS:
shown = len(partial)
yield "partial", partial
data = parse_response(raw)
if not is_final and not data.get("options"):
raw2 = story_model.generate(system, prompt, max_tokens=512)
data2 = parse_response(raw2)
data = data2 if data2.get("options") else {**data, "options": ["Continue the adventure"]}
s = apply_turn(s, data)
yield "done", (s, data)
def _generate_image_bytes(beat: str, hero: str, world: str, ref: bytes = None, scene: str = "") -> bytes | None:
try:
prompt = build_image_prompt(beat, hero, world, scene)
return img_model.generate_image(prompt, ref)
except Exception:
print("[app] image generation failed:")
traceback.print_exc()
return None
def _pil_from_bytes(b: bytes):
from PIL import Image
return Image.open(io.BytesIO(b))
def _parallel_image_and_audio(beat, hero, world, ref_bytes=None, muted=False,
language="English", scene=""):
"""Run image gen + TTS in parallel threads; returns (img_bytes, audio_path).
When muted, TTS is skipped entirely (saves a network round-trip too).
"""
img_result: list = [None]
aud_result: list = [None]
def _img():
img_result[0] = _generate_image_bytes(beat, hero, world, ref_bytes, scene)
def _aud():
aud_result[0] = tts.generate_speech(beat, language)
threads = [threading.Thread(target=_img, daemon=True)]
if not muted:
threads.append(threading.Thread(target=_aud, daemon=True))
for t in threads:
t.start()
for t in threads:
t.join()
return img_result[0], aud_result[0]
# ββ Event handlers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def start_story(theme, total_moments, num_options, custom_hero, language, state):
muted = bool(isinstance(state, dict) and state.get("_muted"))
total, num = int(total_moments), int(num_options)
language = language if language in LANGUAGES else "English"
s = StoryState(theme=theme, total_moments=total, num_options=num, moment=0,
language=language)
if custom_hero and custom_hero.strip():
s.hero = custom_hero.strip()
amb = None if muted else ambient.generate_ambient(theme)
# β Loading screen with ambient music
yield _story_screen(state, "", [], 1, total, loading=True, ambient_val=amb)
# β‘ Stream the beat text as the model writes it
beat, options, scene = "", [], ""
try:
for kind, payload in _generate_beat_events(s):
if kind == "partial":
yield _story_screen(state, payload, [], 1, total, streaming=True)
else:
s, data = payload
beat = data["beat"]
options = data.get("options", [])
scene = data.get("scene", "")
except Exception:
traceback.print_exc()
yield _setup_screen(
state if isinstance(state, dict) else {},
status=_error_html(
"The storyteller lost the thread of the tale β pick a theme to try again!"
),
)
return
sd = s.to_dict()
sd["_muted"] = muted
sd["_options"] = options
sd["_current_beat"] = beat
sd["_beat_images"] = []
# β’ Full text + choices, shimmer where the illustration will appear
yield _story_screen(sd, beat, options, 1, total, shimmer=True)
# β£ Image + TTS in parallel
img_bytes, audio_path = _parallel_image_and_audio(
beat, s.hero, s.world, muted=muted, language=s.language, scene=scene)
if img_bytes:
sd["_reference_image"] = img_bytes
sd["_beat_images"] = [img_bytes]
yield _story_screen(
sd, beat, options, 1, total,
image=_pil_from_bytes(img_bytes) if img_bytes else None,
audio=audio_path,
)
def on_theme_selected(theme_val, total_moments, num_options, custom_hero, language, state):
if not theme_val:
return
yield from start_story(theme_val, total_moments, num_options, custom_hero, language, state)
def choose_option(choice_idx: int, state: dict):
s = _state_from(state)
muted = bool(state.get("_muted"))
options = state.get("_options", [])
current_beat = state.get("_current_beat", "")
chosen = options[choice_idx] if choice_idx < len(options) else "Continue"
s.history = list(s.history) + [{"beat": current_beat, "choice": chosen}]
s.moment += 1
# Keep accumulated images across beats
beat_images = list(state.get("_beat_images", []))
ref_bytes = state.get("_reference_image")
# β Loading (keep previous image)
yield _story_screen(state, current_beat, options, s.moment, s.total_moments, loading=True)
# β‘ Stream the next beat
beat, new_options, scene = "", [], ""
try:
for kind, payload in _generate_beat_events(s):
if kind == "partial":
yield _story_screen(state, payload, [], s.moment + 1, s.total_moments, streaming=True)
else:
s, data = payload
beat = data["beat"]
new_options = data.get("options", [])
scene = data.get("scene", "")
except Exception:
traceback.print_exc()
# Restore the pre-click view (stored state was never advanced) so the
# same option can simply be clicked again.
yield _story_screen(
state, current_beat, options, s.moment, s.total_moments,
status=_error_html("The magic fizzled for a moment β try that choice again!"),
)
return
is_final = s.moment >= s.total_moments - 1
sd = s.to_dict()
sd["_muted"] = muted
sd["_options"] = new_options
sd["_current_beat"] = beat
sd["_reference_image"] = ref_bytes
sd["_beat_images"] = beat_images
if is_final or not new_options:
s.finished = True
sd["finished"] = True
full_beats = [h["beat"] for h in s.history] + [beat]
full_story = "\n\n".join(f"**Moment {i+1}:** {b}" for i, b in enumerate(full_beats))
# β’ Ending text
yield _ending_screen(sd, beat, full_story, s.total_moments)
# β£ Image + TTS
img_bytes, audio_path = _parallel_image_and_audio(
beat, s.hero, s.world, ref_bytes, muted=muted, language=s.language, scene=scene)
if img_bytes:
beat_images.append(img_bytes)
sd["_beat_images"] = beat_images
yield _ending_screen(
sd, beat, full_story, s.total_moments,
image=_pil_from_bytes(img_bytes) if img_bytes else None,
audio=audio_path,
)
# β€ PDF
try:
pdf_path = pdf_export.build_pdf(
theme=s.theme,
hero=s.hero,
world=s.world,
beats=full_beats,
images=sd["_beat_images"],
)
yield _ending_screen(sd, beat, full_story, s.total_moments, pdf=pdf_path)
except Exception:
print("[app] PDF export failed:")
traceback.print_exc()
else:
# β’ Text + choices β shimmer only while there is no illustration yet
yield _story_screen(sd, beat, new_options, s.moment + 1, s.total_moments,
shimmer=not beat_images)
# β£ Image + TTS
img_bytes, audio_path = _parallel_image_and_audio(
beat, s.hero, s.world, ref_bytes, muted=muted, language=s.language, scene=scene)
if img_bytes:
beat_images.append(img_bytes)
sd["_beat_images"] = beat_images
yield _story_screen(
sd, beat, new_options, s.moment + 1, s.total_moments,
image=_pil_from_bytes(img_bytes) if img_bytes else None,
audio=audio_path,
)
def on_voice_input(audio_tuple, state: dict):
if audio_tuple is None:
return
options = state.get("_options", [])
if not options:
return
lang = _WHISPER_LANG.get(state.get("language", "English"), "en")
text = stt.transcribe(audio_tuple, language=lang)
if text:
idx = stt.match_option(text, options)
if idx is not None:
yield from choose_option(idx, state)
def reset_story(state):
keep = {}
if isinstance(state, dict) and state.get("_muted"):
keep["_muted"] = True
return _setup_screen(keep)
def toggle_sound(enabled, state):
"""Mute/unmute both audio players; restart ambient music when re-enabled mid-story."""
state = dict(state) if isinstance(state, dict) else {}
state["_muted"] = not enabled
if not enabled:
return (
state,
gr.update(value=None, visible=False),
gr.update(value=None, visible=False),
)
s = _state_from(state)
if s.theme and not s.finished:
amb = ambient.generate_ambient(s.theme)
return state, gr.update(), gr.update(value=amb, autoplay=True, visible=True)
return state, gr.update(), gr.update()
# ββ UI ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
with open("styles.css", encoding="utf-8") as _f:
_CSS = _f.read()
with gr.Blocks(title="StoryForge", css=_CSS) as demo:
story_state = gr.State({})
gr.HTML('📖 StoryForge
')
gr.HTML('A magical branching adventure β just for you
')
# ββ Setup ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
with gr.Column(elem_id="setup-section") as setup_col:
with gr.Row(elem_id="sliders-row"):
moments_slider = gr.Slider(3, 15, value=5, step=1,
label="How many moments?",
info="More moments = longer story")
options_slider = gr.Slider(2, 6, value=5, step=1,
label="How many choices each turn?")
lang_dropdown = gr.Dropdown(LANGUAGES, value="English",
label="Story language",
elem_id="lang-dropdown")
hero_input = gr.Textbox(
value="",
placeholder="e.g. Luna, a small girl with silver hair and a red cape",
label="Your hero (optional β leave blank to let the story invent one)",
elem_id="hero-input",
max_lines=1,
)
gr.HTML('Choose your adventure:
')
theme_bus = gr.Textbox(value="", visible=True, elem_id="theme-bus", label="")
cards_html = ''
for emoji, title, subtitle in THEMES:
tv = f"{emoji} {title}"
cards_html += (
f'
'
f'{emoji}'
f'{title}'
f'{subtitle}'
f'
'
)
cards_html += "
"
gr.HTML(cards_html)
# ββ Shared content βββββββββββββββββββββββββββββββββββββββββββββββββββββ
with gr.Row(elem_id="sound-row"):
sound_toggle = gr.Checkbox(
value=True, label="π Sound", elem_id="sound-toggle", container=False,
)
progress_html = gr.HTML("", elem_id="progress-text")
status_html = gr.HTML("", elem_id="status-text")
beat_display = gr.HTML("", elem_id="beat-display")
image_placeholder = gr.HTML("", elem_id="image-placeholder-slot")
beat_image = gr.Image(
value=None, visible=False, show_label=False,
type="pil", interactive=False,
elem_id="beat-image-wrap",
)
# TTS narration β auto-plays per beat, hidden player
beat_audio = gr.Audio(
value=None, visible=False,
label="Story narration", autoplay=True,
elem_id="beat-audio",
)
# Ambient background music β set once per story
ambient_audio = gr.Audio(
value=None, visible=False,
label="Ambient music", autoplay=True,
elem_id="ambient-audio",
)
# ββ Story section βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
with gr.Column(elem_id="story-section", visible=False) as story_col:
option_btns = [
gr.Button(f"Option {i+1}", visible=False, elem_classes=["option-btn"])
for i in range(MAX_OPTIONS)
]
with gr.Row(elem_id="voice-row"):
mic_input = gr.Audio(
sources=["microphone"],
type="numpy",
label="ποΈ Or speak your choice",
elem_id="mic-input",
visible=True,
)
reset_btn_story = gr.Button("Start over", elem_classes=["reset-btn"], size="sm")
# ββ Ending section ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
with gr.Column(elem_id="ending-section", visible=False) as ending_col:
gr.HTML('✨ The End ✨
')
with gr.Accordion("Read the whole story", open=False):
full_story_text = gr.Markdown("")
pdf_file = gr.File(
value=None, visible=False,
label="π Download your storybook (PDF)",
elem_id="pdf-download",
)
reset_btn_end = gr.Button("Start a new adventure", elem_id="restart-big")
# ββ Output list (19 elements) βββββββββββββββββββββββββββββββββββββββββββ
ALL_OUTPUTS = (
[story_state, setup_col, story_col, ending_col,
beat_display, status_html, progress_html]
+ option_btns
+ [full_story_text, image_placeholder, beat_image,
beat_audio, ambient_audio, pdf_file]
)
# ββ Wire theme bus ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
theme_bus.change(
fn=on_theme_selected,
inputs=[theme_bus, moments_slider, options_slider, hero_input,
lang_dropdown, story_state],
outputs=ALL_OUTPUTS,
)
# ββ Wire option buttons βββββββββββββββββββββββββββββββββββββββββββββββββ
for i, btn in enumerate(option_btns):
def _choice(state, _i=i):
yield from choose_option(_i, state)
btn.click(fn=_choice, inputs=[story_state], outputs=ALL_OUTPUTS)
# ββ Wire microphone βββββββββββββββββββββββββββββββββββββββββββββββββββββ
mic_input.stop_recording(
fn=on_voice_input,
inputs=[mic_input, story_state],
outputs=ALL_OUTPUTS,
)
# ββ Wire sound toggle βββββββββββββββββββββββββββββββββββββββββββββββββββ
sound_toggle.change(
fn=toggle_sound,
inputs=[sound_toggle, story_state],
outputs=[story_state, beat_audio, ambient_audio],
)
# ββ Wire reset ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
reset_btn_story.click(fn=reset_story, inputs=[story_state], outputs=ALL_OUTPUTS)
reset_btn_end.click(fn=reset_story, inputs=[story_state], outputs=ALL_OUTPUTS)
# ββ Re-inject styles (beats StreamingBar override) ββββββββββββββββββββββ
with open("styles.css", encoding="utf-8") as _sf:
gr.HTML(f"")
if __name__ == "__main__":
demo.queue()
demo.launch(server_name="0.0.0.0")