Codex Codex commited on
Commit
56412a3
Β·
1 Parent(s): 5504ddd

Add Bedtime Voice tab: Kannada narration, voice cloning, richer story engine

Browse files

- ui/layout.py: full two-tab layout (Storybook + Bedtime Voice) with
night-sky dark palette, kid-friendly sparkle/star animations, and
moon-gold CSS for the bedtime panel
- app.py: BEDTIME_TEMPLATES expanded to 3 random arc variants per genre
(6 themes x 3 arcs), richer few-shot exemplars that enforce character
consistency, temperature sampling (0.85/0.92) for story variety,
generate_bedtime_story_gpu / generate_tts_cloned_gpu / generate_kannada_gpu,
create_bedtime generator wired to UI
- config.py: TRANSLATION_MODEL, KANNADA_TTS_MODEL, KANNADA_FINETUNE,
BEDTIME_GENRES, BEDTIME_MOODS added
- requirements.txt: librosa, IndicTransToolkit added
- audio_postprocess.py, audio_utils.py: voice reference prep helpers
- indic_text.py: English→Kannada via IndicTrans2
- indic_tts.py: Kannada narration via IndicF5 + fine-tuned bedtime checkpoint
- README.md: ai4bharat/IndicF5 and indictrans2-en-indic-1B added to models

Co-authored-by: Codex <noreply@codex.ai>

Files changed (9) hide show
  1. README.md +2 -0
  2. app.py +590 -31
  3. audio_postprocess.py +11 -0
  4. audio_utils.py +50 -0
  5. config.py +25 -0
  6. indic_text.py +71 -0
  7. indic_tts.py +96 -0
  8. requirements.txt +4 -0
  9. ui/layout.py +592 -493
README.md CHANGED
@@ -21,6 +21,8 @@ models:
21
  - black-forest-labs/FLUX.2-klein-4B
22
  - openbmb/MiniCPM5-1B
23
  - openbmb/VoxCPM2
 
 
24
  ---
25
 
26
  # DoodleBook πŸ“šπŸ–οΈ
 
21
  - black-forest-labs/FLUX.2-klein-4B
22
  - openbmb/MiniCPM5-1B
23
  - openbmb/VoxCPM2
24
+ - ai4bharat/IndicF5
25
+ - ai4bharat/indictrans2-en-indic-1B
26
  ---
27
 
28
  # DoodleBook πŸ“šπŸ–οΈ
app.py CHANGED
@@ -36,6 +36,7 @@ from config import (
36
  FLUX_MODEL, STORY_MODEL, TTS_MODEL,
37
  GENERATION_PARAMS, SAMPLE_BOOK_PATH, BASE_SEED, page_seed,
38
  DEFAULT_VOICE, voice_design,
 
39
  )
40
  from book_builder import (
41
  build_book_html, export_pdf, magic_loader_html,
@@ -93,12 +94,28 @@ def load_tts():
93
  if _TTS_MODEL is None:
94
  from voxcpm import VoxCPM
95
  logger.info(f"Loading TTS model: {TTS_MODEL.hub_id}")
96
- _TTS_MODEL = VoxCPM.from_pretrained(TTS_MODEL.hub_id, load_denoiser=False)
 
97
  return _TTS_MODEL
98
 
99
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  if ON_ZEROGPU:
101
- for _name, _loader in (("flux", load_flux), ("story", load_story), ("tts", load_tts)):
 
 
 
102
  try:
103
  _loader()
104
  except Exception as _e: # keep the Space booting
@@ -121,39 +138,189 @@ LINE_ART_SUFFIX = (
121
  "no filled black areas, no extra sketch marks."
122
  )
123
 
 
 
 
124
  THEME_TEMPLATES = {
125
  "brave adventure": [
126
- ("{hero} loved exploring new places.", "{hero} standing at the start of a bright adventure trail"),
127
- ("One morning, {hero} discovered something glowing nearby.", "{hero} spotting a magical glow in the distance"),
128
- ("Taking a deep breath, {hero} bravely went closer.", "{hero} walking forward with courage"),
129
- ("There, a new friend needed help.", "{hero} finding a small friend in trouble"),
130
- ("{hero} helped with kindness and a clever idea.", "{hero} helping the friend together"),
131
- ("Everyone cheered, and {hero} felt proud and brave.", "{hero} celebrating at sunset with the new friend"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  ],
133
  "making a new friend": [
134
- ("{hero} was playing alone in a sunny place.", "{hero} playing under a bright sky"),
135
- ("Then {hero} noticed someone shy nearby.", "{hero} seeing a shy new friend nearby"),
136
- ("{hero} smiled and said hello.", "{hero} waving with a friendly smile"),
137
- ("Soon they were sharing stories and laughs.", "{hero} and the new friend laughing together"),
138
- ("They played games all afternoon.", "{hero} and the new friend playing together"),
139
- ("By sunset, {hero} had made a wonderful new friend.", "{hero} and the new friend smiling together at sunset"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  ],
141
  }
142
 
143
  FEW_SHOT_EXEMPLAR = """
144
- Write a 6-page children's storybook for age 5 about Luna the cat with theme: brave adventure.
 
 
 
 
 
 
 
 
145
 
146
- Return ONLY valid JSON:
147
  {
148
- "title": "Luna's Brave Adventure",
149
- "character_description": "A small orange tabby cat named Luna with big green eyes, whiskers, and a tiny red scarf",
150
  "pages": [
151
- {"page": 1, "text": "Luna was a small orange cat who loved to explore.", "scene": "Luna sitting by the window looking outside"},
152
- {"page": 2, "text": "One sunny morning, Luna saw something sparkling in the forest.", "scene": "Luna spotting a glow in the trees"},
153
- {"page": 3, "text": "Bravely, Luna crept into the forest to investigate.", "scene": "Luna walking cautiously through trees"},
154
- {"page": 4, "text": "It was a tiny fairy stuck in a spider web!", "scene": "Luna discovering a fairy in trouble"},
155
- {"page": 5, "text": "Luna gently freed the fairy with her paw.", "scene": "Luna carefully helping the fairy"},
156
- {"page": 6, "text": "The fairy thanked Luna and they became friends forever.", "scene": "Luna and fairy playing together at sunset"}
157
  ]
158
  }
159
  """
@@ -164,7 +331,13 @@ def build_story_prompt(hero_name: str, theme: str, age: int) -> str:
164
 
165
  Write a 6-page children's storybook for age {age} about {hero_name} with theme: {theme}.
166
 
167
- Return ONLY valid JSON:
 
 
 
 
 
 
168
  """
169
 
170
 
@@ -221,17 +394,29 @@ def _normalize_story(story: dict) -> dict:
221
 
222
 
223
  def build_story_locally(hero_name: str, theme: str) -> dict:
224
- """Fast, deterministic fallback story that avoids any Modal dependency."""
225
  hero = (hero_name or "Little Hero").strip() or "Little Hero"
226
- beats = THEME_TEMPLATES.get(theme, THEME_TEMPLATES["brave adventure"])
 
227
  pages = [
228
  {"page": i + 1, "text": text.format(hero=hero), "scene": scene.format(hero=hero)}
229
  for i, (text, scene) in enumerate(beats)
230
  ]
 
 
 
 
 
 
 
 
 
 
 
231
  return {
232
- "title": f"{hero}'s Storybook Adventure",
233
  "character_description": (
234
- f"{hero}, a friendly children's storybook hero with bright colors, "
235
  "bold outlines, and a cheerful expressive face"
236
  ),
237
  "pages": pages,
@@ -316,7 +501,10 @@ def generate_story_gpu(hero_name: str, theme: str, age: int = 5) -> dict:
316
  out = model.generate(
317
  **inputs,
318
  max_new_tokens=GENERATION_PARAMS.max_story_tokens,
319
- do_sample=False,
 
 
 
320
  )
321
  response = tok.decode(
322
  out[0][inputs["input_ids"].shape[1]:],
@@ -325,7 +513,7 @@ def generate_story_gpu(hero_name: str, theme: str, age: int = 5) -> dict:
325
  parsed = parse_story_json(response)
326
  if parsed:
327
  return _normalize_story(parsed)
328
- logger.warning("Story parser failed; using deterministic local fallback")
329
  except Exception as e:
330
  logger.warning(f"ZeroGPU story generation failed: {e}")
331
  return _normalize_story(build_story_locally(hero_name, theme))
@@ -683,6 +871,376 @@ def create_book(doodle_image, character_name, theme, hero_name, voice=DEFAULT_VO
683
  )
684
 
685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
686
  # ============================================================================
687
  # MAIN
688
  # ============================================================================
@@ -691,6 +1249,7 @@ if __name__ == "__main__":
691
  demo = create_layout(
692
  load_sample_fn=load_sample_book,
693
  create_book_fn=create_book,
 
694
  )
695
  demo.queue(default_concurrency_limit=2, max_size=8)
696
  # design_kwargs (theme/css/js/head) is non-empty on gradio 6 (moved to launch)
 
36
  FLUX_MODEL, STORY_MODEL, TTS_MODEL,
37
  GENERATION_PARAMS, SAMPLE_BOOK_PATH, BASE_SEED, page_seed,
38
  DEFAULT_VOICE, voice_design,
39
+ BEDTIME_GENRES, BEDTIME_MOODS,
40
  )
41
  from book_builder import (
42
  build_book_html, export_pdf, magic_loader_html,
 
94
  if _TTS_MODEL is None:
95
  from voxcpm import VoxCPM
96
  logger.info(f"Loading TTS model: {TTS_MODEL.hub_id}")
97
+ # load_denoiser=True enables voice cloning for the Bedtime Voice tab
98
+ _TTS_MODEL = VoxCPM.from_pretrained(TTS_MODEL.hub_id, load_denoiser=True)
99
  return _TTS_MODEL
100
 
101
 
102
+ def load_translation():
103
+ """Load IndicTrans2 translation model (used by Bedtime Voice tab)."""
104
+ from indic_text import _get_model
105
+ _get_model()
106
+
107
+
108
+ def load_kannada_tts():
109
+ """Load IndicF5 Kannada TTS model (used by Bedtime Voice tab)."""
110
+ from indic_tts import _get_model as _kn_get
111
+ _kn_get()
112
+
113
+
114
  if ON_ZEROGPU:
115
+ for _name, _loader in (
116
+ ("flux", load_flux), ("story", load_story), ("tts", load_tts),
117
+ ("translation", load_translation), ("kannada_tts", load_kannada_tts),
118
+ ):
119
  try:
120
  _loader()
121
  except Exception as _e: # keep the Space booting
 
138
  "no filled black areas, no extra sketch marks."
139
  )
140
 
141
+ import random as _random
142
+
143
+ # Multiple story arcs per theme (3 variants each). build_story_locally picks one at random.
144
  THEME_TEMPLATES = {
145
  "brave adventure": [
146
+ [ # arc A: overcoming fear of the dark
147
+ ("{hero} was afraid of the dark, but wanted to be brave.", "{hero} looking nervously at the dark forest path"),
148
+ ("One night, a baby bird cried from inside the dark trees.", "{hero} hearing a bird cry from the shadowy forest"),
149
+ ("{hero} took one small step forward, then another.", "{hero} stepping carefully into the dark with a tiny lantern"),
150
+ ("Inside the forest, moonlight made everything look magical.", "{hero} looking around in wonder at the moonlit trees"),
151
+ ("{hero} found the bird and gently carried it home.", "{hero} cradling a small bird carefully in both hands"),
152
+ ("Back safe, {hero} smiled β€” brave hearts face their fears.", "{hero} smiling proudly outside the forest, holding the bird"),
153
+ ],
154
+ [ # arc B: the broken bridge
155
+ ("{hero} set off to bring bread to grandmother across the river.", "{hero} walking cheerfully with a basket of bread"),
156
+ ("But the bridge had a big crack β€” too dangerous to cross!", "{hero} staring at a broken bridge over a rushing river"),
157
+ ("{hero} looked around and thought very hard.", "{hero} standing still, eyes closed, thinking carefully"),
158
+ ("Nearby, fallen logs could make a safe path!", "{hero} spotting logs and having a bright idea"),
159
+ ("{hero} rolled the logs into place, one by one.", "{hero} working hard rolling logs to build a crossing"),
160
+ ("Grandmother hugged {hero} tight β€” cleverness gets you there!", "{hero} and grandmother hugging warmly on the other side"),
161
+ ],
162
+ [ # arc C: the lost kite
163
+ ("{hero}'s favourite kite flew away in a big gust of wind.", "{hero} watching the kite soar away into the sky"),
164
+ ("It landed at the very top of the tallest oak tree.", "{hero} staring up at the kite caught high in a tree"),
165
+ ("{hero} asked the squirrels if they could help reach it.", "{hero} talking to two friendly squirrels on a branch"),
166
+ ("The squirrels agreed β€” if {hero} would share some acorns.", "{hero} nodding happily and handing over a bag of acorns"),
167
+ ("Together they climbed and tugged the kite free.", "{hero} and the squirrels cheering as the kite comes loose"),
168
+ ("{hero} learned that sharing always brings something back.", "{hero} flying the kite high with the squirrels watching"),
169
+ ],
170
  ],
171
  "making a new friend": [
172
+ [ # arc A: the new kid
173
+ ("{hero} was nervous β€” today was the first day at a new place.", "{hero} standing at a doorway looking shy and unsure"),
174
+ ("Everyone already seemed to know each other.", "{hero} watching other children play together"),
175
+ ("Then {hero} saw someone else sitting alone by the fence.", "{hero} noticing another child sitting quietly alone"),
176
+ ("{hero} walked over and said, 'Hi, I'm {hero}. Want to play?'", "{hero} walking towards the lonely child with a warm smile"),
177
+ ("The child's face lit up like sunshine.", "{hero} and the new friend laughing and playing together"),
178
+ ("{hero} learned β€” one small hello can change everything.", "{hero} and the new friend waving goodbye, happy and tired"),
179
+ ],
180
+ [ # arc B: the robot who didn't know how
181
+ ("{hero} met a little robot who had never played before.", "{hero} meeting a small friendly robot in a sunny park"),
182
+ ("The robot could count to a million but didn't know how to laugh.", "{hero} looking curiously at the robot"),
183
+ ("{hero} taught the robot how to blow bubbles.", "{hero} showing the robot how to blow a big soapy bubble"),
184
+ ("The first bubble the robot made was huge β€” they both gasped!", "{hero} and the robot staring at a giant rainbow bubble"),
185
+ ("Then {hero} taught the robot the best knock-knock joke.", "{hero} whispering a joke to the robot, who looks confused"),
186
+ ("The robot laughed for the very first time, and they both felt warm.", "{hero} and the robot laughing together in golden light"),
187
+ ],
188
+ [ # arc C: opposite neighbours
189
+ ("{hero} loved loud music; the neighbour's cat loved total silence.", "{hero} dancing loudly while a cat glares from the fence"),
190
+ ("They looked at each other across the fence every day.", "{hero} and the cat eyeing each other from opposite sides"),
191
+ ("One rainy afternoon, the cat got stuck in a puddle.", "{hero} seeing the cat splashing helplessly in the rain"),
192
+ ("{hero} rushed over with an umbrella and warm towel.", "{hero} wrapping the soggy cat in a big fluffy towel"),
193
+ ("The cat purred loudly β€” which sounded like music to {hero}.", "{hero} grinning as the cat purrs on their lap"),
194
+ ("{hero} learned that kindness finds harmony in differences.", "{hero} and the cat sharing headphones by the window"),
195
+ ],
196
+ ],
197
+ "overcoming a fear": [
198
+ [ # arc A: scared of thunder
199
+ ("{hero} hid under the blankets every time it thundered.", "{hero} buried under a pile of blankets during a storm"),
200
+ ("Grandpa sat beside {hero} and told a secret.", "{hero} peeking out from the blankets while grandpa smiles"),
201
+ ("'Thunder is just clouds bumping into each other,' grandpa said.", "{hero} listening with wide eyes to grandpa's story"),
202
+ ("{hero} imagined grumpy clouds saying sorry to each other.", "{hero} imagining funny fluffy clouds arguing in the sky"),
203
+ ("The next crash came β€” and {hero} laughed instead of hiding!", "{hero} laughing boldly at the window during the storm"),
204
+ ("{hero} learned that knowing why things happen makes them less scary.", "{hero} and grandpa watching lightning together, smiling"),
205
+ ],
206
+ [ # arc B: the deep swimming pool
207
+ ("{hero} loved watching others swim but wouldn't go past the steps.", "{hero} sitting on the pool steps, looking at the water"),
208
+ ("'What if I sink?' {hero} thought every single time.", "{hero} staring nervously at the deep end"),
209
+ ("A kind teacher floated a pool noodle close to {hero}.", "{hero} eyeing a colourful foam noodle in the water"),
210
+ ("{hero} grabbed it tightly and floated for the first time.", "{hero} floating on the noodle, surprised and delighted"),
211
+ ("Slowly {hero} kicked β€” and actually moved through the water!", "{hero} kicking their legs, moving through the pool"),
212
+ ("{hero} found that courage grows one tiny step at a time.", "{hero} smiling proudly in the water, arms wide open"),
213
+ ],
214
+ [ # arc C: the big dog next door
215
+ ("{hero} ran past the gate whenever the big dog barked.", "{hero} sprinting past a big dog behind a garden gate"),
216
+ ("One day the gate was open β€” and the dog sat very still.", "{hero} stopping in surprise to see the gate open"),
217
+ ("The dog had a thorn in its paw and looked at {hero} sadly.", "{hero} noticing the dog holding up a hurt paw"),
218
+ ("{hero} knelt down slowly and carefully pulled out the thorn.", "{hero} gently helping the dog with a very careful hand"),
219
+ ("The dog licked {hero}'s hand with a big warm tongue.", "{hero} giggling as the dog licks their face"),
220
+ ("{hero} learned that courage and kindness are the same thing.", "{hero} and the big dog walking happily side by side"),
221
+ ],
222
+ ],
223
+ "helping someone": [
224
+ [ # arc A: the broken toy shop
225
+ ("{hero} passed the old toy shop and saw the owner crying.", "{hero} stopping to look at a sad old toy-maker"),
226
+ ("A shelf had fallen and smashed twenty toys on the floor.", "{hero} seeing broken toys scattered across the floor"),
227
+ ("{hero} rolled up their sleeves without being asked.", "{hero} picking up toy pieces with determined eyes"),
228
+ ("Piece by piece, {hero} and the owner fixed each one.", "{hero} and the toy-maker gluing pieces together carefully"),
229
+ ("The last toy fixed was a little wooden bird that sang.", "{hero} and the owner listening to the bird sing again"),
230
+ ("{hero} learned that helping turns someone's worst day around.", "{hero} and the toy-maker shaking hands and smiling"),
231
+ ],
232
+ [ # arc B: the garden in winter
233
+ ("{hero} noticed old Mrs. Patel's garden was bare and sad.", "{hero} looking over a fence at a grey winter garden"),
234
+ ("Mrs. Patel was too sick to plant her spring seeds.", "{hero} seeing Mrs. Patel watching sadly from her window"),
235
+ ("{hero} made a plan and came back with a trowel.", "{hero} arriving at the garden gate with a basket of seeds"),
236
+ ("Row by row, {hero} pressed seeds into the cold soil.", "{hero} carefully planting seeds in the dark garden earth"),
237
+ ("Weeks later, the garden burst into pink and yellow flowers.", "{hero} and Mrs. Patel gasping at the blooming garden"),
238
+ ("{hero} learned that small acts of kindness bloom in their own time.", "{hero} and Mrs. Patel sitting together among the flowers"),
239
+ ],
240
+ [ # arc C: lost dog returns home
241
+ ("{hero} found a shaggy dog trembling alone in the rain.", "{hero} spotting a scared wet dog in the rain"),
242
+ ("The dog had a tag β€” but the address was smudged.", "{hero} squinting at a blurry metal tag on the dog's collar"),
243
+ ("{hero} drew the dog's face on a poster and put it everywhere.", "{hero} sticking handmade posters on poles and windows"),
244
+ ("A little girl called β€” she'd been crying for two days!", "{hero} on the phone, looking relieved and happy"),
245
+ ("{hero} walked the dog right to the girl's front door.", "{hero} arriving at a door with the shaggy dog on a lead"),
246
+ ("{hero} learned that caring for others feels better than anything.", "{hero} watching the girl and dog reunite with happy tears"),
247
+ ],
248
+ ],
249
+ "lost and found": [
250
+ [ # arc A: the missing story book
251
+ ("{hero}'s favourite storybook had vanished β€” gone without a trace!", "{hero} searching through a pile of scattered books"),
252
+ ("It wasn't under the bed, in the kitchen, or the garden shed.", "{hero} checking more places, hands on hips"),
253
+ ("{hero} retraced every step from the whole day.", "{hero} thinking hard with a finger on chin, eyes closed"),
254
+ ("At the library β€” someone had borrowed it by mistake!", "{hero} spotting the book on a shelf at the library"),
255
+ ("{hero} found the reader and they shared it together instead.", "{hero} and a new friend reading the book side by side"),
256
+ ("{hero} learned that some things are even better when shared.", "{hero} and the new friend high-fiving over the open book"),
257
+ ],
258
+ [ # arc B: the lost recipe
259
+ ("Granny had lost her secret recipe for the best cookies ever.", "{hero} watching granny search through messy recipe cards"),
260
+ ("{hero} promised to help find it before the big bake.", "{hero} putting on an apron and looking determined"),
261
+ ("They searched the kitchen, the attic, and every drawer.", "{hero} and granny checking every corner together"),
262
+ ("Inside an old hat box β€” a folded, faded piece of paper!", "{hero} gasping and holding up a crumpled piece of paper"),
263
+ ("The recipe was there: flour, love, and a pinch of laughter.", "{hero} and granny reading the recipe together with big smiles"),
264
+ ("{hero} learned that the best things are worth looking for.", "{hero} and granny pulling cookies from the oven together"),
265
+ ],
266
+ [ # arc C: lost in the market
267
+ ("{hero} looked up and all the grown-ups had disappeared.", "{hero} standing alone in a busy colourful market"),
268
+ ("The market seemed bigger and louder than ever before.", "{hero} turning around looking worried among tall market stalls"),
269
+ ("{hero} remembered: stay calm, find someone who helps.", "{hero} taking a deep breath with eyes closed"),
270
+ ("{hero} found a stall keeper with a kind face and asked for help.", "{hero} tapping a friendly shop-keeper on the shoulder"),
271
+ ("The family came running β€” laughing with relief.", "{hero} being scooped up in a big family hug"),
272
+ ("{hero} learned that staying calm helps you find the way home.", "{hero} and family walking home hand in hand"),
273
+ ],
274
+ ],
275
+ "learning something new": [
276
+ [ # arc A: first time on a bicycle
277
+ ("{hero} wanted to ride a bicycle but kept falling off.", "{hero} lying on the grass next to a toppled bicycle"),
278
+ ("Every try ended with a scraped knee and wobbly legs.", "{hero} wobbling badly on the bike, face determined"),
279
+ ("Papa said, 'Look where you want to go, not at the ground.'", "{hero} listening carefully to papa who points ahead"),
280
+ ("{hero} fixed their eyes on the garden gate and pedalled.", "{hero} cycling straight and steady for the first time"),
281
+ ("Zoom! The bicycle went all the way to the gate!", "{hero} shooting past the gate with arms raised in joy"),
282
+ ("{hero} learned that looking forward helps you move forward.", "{hero} cycling confidently down a sunny lane"),
283
+ ],
284
+ [ # arc B: cooking for the first time
285
+ ("{hero} decided to make soup all by themselves.", "{hero} standing on a step stool by the big kitchen pot"),
286
+ ("The first batch was way too salty β€” even the cat sneezed.", "{hero} and a sneezing cat looking at a steaming pot"),
287
+ ("{hero} didn't give up β€” just added less salt next time.", "{hero} carefully adding a tiny pinch of salt to the pot"),
288
+ ("The second batch smelled amazing β€” warm and rich.", "{hero} inhaling deeply with a big happy smile"),
289
+ ("The whole family asked for a second bowl.", "{hero} ladling soup into happy family bowls"),
290
+ ("{hero} learned that mistakes are just steps on the way to great.", "{hero} sitting proudly at the head of the dinner table"),
291
+ ],
292
+ [ # arc C: learning to read music
293
+ ("{hero} wanted to play piano but the notes looked like dots and sticks.", "{hero} staring at a sheet of music with a puzzled frown"),
294
+ ("Teacher showed that each line on the page had its own note.", "{hero} and a teacher pointing at a music book together"),
295
+ ("{hero} practised one tiny tune β€” just five notes β€” every day.", "{hero} pressing piano keys slowly and carefully"),
296
+ ("After a week, those five notes sounded like a real song!", "{hero} playing piano with eyes closed, smiling"),
297
+ ("At the recital, {hero} played for everyone β€” hands barely shaking.", "{hero} performing on stage in front of a small happy crowd"),
298
+ ("{hero} learned that big skills start with five tiny notes.", "{hero} taking a bow as the crowd claps"),
299
+ ],
300
  ],
301
  }
302
 
303
  FEW_SHOT_EXEMPLAR = """
304
+ Write a 6-page children's storybook for age 5 about Finn the red fox with theme: overcoming a fear.
305
+
306
+ Rules:
307
+ - Finn must appear by name in EVERY page text.
308
+ - No new characters introduced after page 3.
309
+ - Each page is 1–2 sentences, simple and vivid.
310
+ - Page 6 ends with a clear lesson Finn has learned.
311
+ - The scene describes only what can be drawn in one illustration.
312
+ - Return ONLY valid JSON (no extra text).
313
 
 
314
  {
315
+ "title": "Finn and the Thunderstorm",
316
+ "character_description": "A small red fox named Finn with big amber eyes, white-tipped ears, and a fluffy striped tail",
317
  "pages": [
318
+ {"page": 1, "text": "Finn the red fox loved sunny days, but hid under the sofa whenever thunder rumbled.", "scene": "Finn the red fox curled up under a blue sofa, looking scared"},
319
+ {"page": 2, "text": "One afternoon, a storm rolled in and Finn could hear thunder booming outside.", "scene": "Finn the red fox peering nervously through a rain-streaked window"},
320
+ {"page": 3, "text": "Grandma Fox sat beside Finn and whispered a secret.", "scene": "Grandma Fox sitting calmly next to Finn the red fox on a warm rug"},
321
+ {"page": 4, "text": "'Thunder is just clouds bumping together and saying sorry,' she told Finn.", "scene": "Finn the red fox imagining two fluffy cartoon clouds bumping and giggling"},
322
+ {"page": 5, "text": "The next rumble came β€” and Finn laughed instead of hiding!", "scene": "Finn the red fox laughing at the window while rain pours outside"},
323
+ {"page": 6, "text": "Finn learned that understanding why things happen makes them far less scary.", "scene": "Finn the red fox and Grandma Fox watching the storm together from a cozy armchair"}
324
  ]
325
  }
326
  """
 
331
 
332
  Write a 6-page children's storybook for age {age} about {hero_name} with theme: {theme}.
333
 
334
+ Rules:
335
+ - {hero_name} must appear by name in EVERY page text.
336
+ - Keep all characters consistent β€” do NOT introduce random new characters mid-story.
337
+ - Each page is 1–2 short, vivid sentences a {age}-year-old can understand.
338
+ - Page 6 ends with a gentle lesson {hero_name} has learned.
339
+ - Scene describes exactly what would appear in ONE illustration.
340
+ - Return ONLY valid JSON (no explanation, no markdown fences):
341
  """
342
 
343
 
 
394
 
395
 
396
  def build_story_locally(hero_name: str, theme: str) -> dict:
397
+ """Fast fallback β€” picks a random arc variant so kids get a different story each run."""
398
  hero = (hero_name or "Little Hero").strip() or "Little Hero"
399
+ arcs = THEME_TEMPLATES.get(theme, THEME_TEMPLATES["brave adventure"])
400
+ beats = _random.choice(arcs) # one of 3 arc variants, chosen at random
401
  pages = [
402
  {"page": i + 1, "text": text.format(hero=hero), "scene": scene.format(hero=hero)}
403
  for i, (text, scene) in enumerate(beats)
404
  ]
405
+ arc_titles = {
406
+ "brave adventure": [f"{hero}'s Big Brave Day", f"{hero} and the Broken Bridge", f"{hero}'s Flying Kite"],
407
+ "making a new friend": [f"{hero}'s First Hello", f"{hero} and the Laughing Robot", f"{hero} and the Quiet Cat"],
408
+ "overcoming a fear": [f"{hero} and the Thunder Secret", f"{hero} Takes the Plunge", f"{hero} and the Big Dog"],
409
+ "helping someone": [f"{hero} and the Broken Toys", f"{hero}'s Secret Garden", f"{hero} Finds the Way Home"],
410
+ "lost and found": [f"{hero}'s Missing Book", f"{hero} Finds Granny's Recipe", f"{hero} and the Big Market"],
411
+ "learning something new": [f"{hero} Rides!", f"{hero}'s Salty Soup", f"{hero}'s Five Notes"],
412
+ }
413
+ arc_index = THEME_TEMPLATES.get(theme, THEME_TEMPLATES["brave adventure"]).index(beats)
414
+ titles = arc_titles.get(theme, [f"{hero}'s Storybook Adventure"])
415
+ title = titles[arc_index] if arc_index < len(titles) else f"{hero}'s Storybook Adventure"
416
  return {
417
+ "title": title,
418
  "character_description": (
419
+ f"{hero}, a friendly children's storybook hero with bright colours, "
420
  "bold outlines, and a cheerful expressive face"
421
  ),
422
  "pages": pages,
 
501
  out = model.generate(
502
  **inputs,
503
  max_new_tokens=GENERATION_PARAMS.max_story_tokens,
504
+ do_sample=True,
505
+ temperature=0.85,
506
+ top_p=0.92,
507
+ repetition_penalty=1.1,
508
  )
509
  response = tok.decode(
510
  out[0][inputs["input_ids"].shape[1]:],
 
513
  parsed = parse_story_json(response)
514
  if parsed:
515
  return _normalize_story(parsed)
516
+ logger.warning("Story parser failed; using random-arc local fallback")
517
  except Exception as e:
518
  logger.warning(f"ZeroGPU story generation failed: {e}")
519
  return _normalize_story(build_story_locally(hero_name, theme))
 
871
  )
872
 
873
 
874
+ # ============================================================================
875
+ # BEDTIME VOICE TAB
876
+ # ============================================================================
877
+
878
+ BEDTIME_TEMPLATES = {
879
+ "Animals": [
880
+ [ # arc A: the lost firefly
881
+ ("{hero} loved sitting in the garden when the moon came out.", "{hero} sitting quietly in a moonlit garden"),
882
+ ("One night, {hero} heard a tiny buzzing near the lavender.", "{hero} tilting head to listen near purple flowers"),
883
+ ("A little firefly was lost and couldn't find its family.", "{hero} meeting a tiny glowing firefly in the dark"),
884
+ ("{hero} walked gently through the dark, and the firefly lit the way.", "{hero} walking softly with the firefly glowing beside"),
885
+ ("They found the firefly's home, glowing warm and bright.", "{hero} smiling as the firefly reunites with glowing family"),
886
+ ("{hero} yawned and curled up under the stars, at peace.", "{hero} sleeping peacefully in the moonlit garden"),
887
+ ],
888
+ [ # arc B: the sleepy hedgehog
889
+ ("{hero} found a little hedgehog shivering under a leaf.", "{hero} gently lifting a leaf to find a tiny hedgehog"),
890
+ ("The hedgehog had lost the path to its cosy winter burrow.", "{hero} and the hedgehog looking at the dark forest together"),
891
+ ("{hero} made a tiny lantern from a jar and a candle stub.", "{hero} carefully lighting a small lantern in the dark"),
892
+ ("Together {hero} and the hedgehog followed the lantern glow.", "{hero} leading the hedgehog along a moonlit path"),
893
+ ("They found the burrow, soft with dry leaves and moss.", "{hero} watching the hedgehog snuggle into its warm home"),
894
+ ("{hero} blew out the lantern and tiptoed home to bed.", "{hero} tiptoeing home under a glowing moon"),
895
+ ],
896
+ [ # arc C: the lonely cloud sheep
897
+ ("High above, a small cloud shaped like a sheep drifted away.", "{hero} watching a fluffy cloud drift across the night sky"),
898
+ ("{hero} watched it wander further from the other clouds.", "{hero} looking up concerned at the lone cloud-sheep"),
899
+ ("{hero} called out softly β€” 'Come back, little cloud!'", "{hero} cupping hands around mouth and calling up at the sky"),
900
+ ("A gentle breeze carried {hero}'s voice right up to the cloud.", "{hero} smiling as the cloud slowly turns around"),
901
+ ("The little cloud floated back to its soft, woolly family.", "{hero} watching all the clouds nuzzle together"),
902
+ ("{hero} closed their eyes and drifted to sleep like a cloud.", "{hero} sleeping peacefully, face soft and calm"),
903
+ ],
904
+ ],
905
+ "Dragons": [
906
+ [ # arc A: the dragon with no flame
907
+ ("{hero} lived near a mountain where a gentle dragon slept each night.", "{hero} looking up at a misty mountain in moonlight"),
908
+ ("One evening, the dragon sneezed and lost its tiny flame.", "{hero} watching the dragon sneeze sadly"),
909
+ ("{hero} brought the dragon warm soup and a thick blanket.", "{hero} carrying a steaming bowl to a sad-looking dragon"),
910
+ ("The dragon sipped the soup and felt a warm glow return.", "{hero} and the dragon sharing a cosy quiet moment"),
911
+ ("Together they lit the lanterns along the sleepy village path.", "{hero} and the dragon lighting lanterns in the still village"),
912
+ ("The dragon curled up small and {hero} tucked it in gently.", "{hero} tucking a sleepy dragon in with a smile"),
913
+ ],
914
+ [ # arc B: the dragon's lullaby
915
+ ("{hero} heard a sad humming sound coming from the cave on the hill.", "{hero} listening to a distant humming sound at night"),
916
+ ("Inside was a young dragon who couldn't fall asleep.", "{hero} finding a wide-awake baby dragon in a cosy cave"),
917
+ ("{hero} sat beside the dragon and hummed a soft tune.", "{hero} humming quietly with eyes half-closed"),
918
+ ("The dragon's eyes grew heavy and its tail curled up.", "{hero} watching the dragon's eyes slowly droop"),
919
+ ("{hero} sang the last line very, very softly.", "{hero} singing barely above a whisper to the drowsy dragon"),
920
+ ("They both fell asleep together in the warm cave.", "{hero} and the dragon sleeping side by side in the cave"),
921
+ ],
922
+ [ # arc C: the dragon who was afraid of the dark
923
+ ("A tiny dragon named Ember sat shaking outside its cave.", "{hero} finding a tiny trembling dragon at a cave entrance"),
924
+ ("{hero} saw that Ember was scared of the dark inside.", "{hero} kneeling beside the little dragon with a kind look"),
925
+ ("{hero} reached in and found a pile of smooth glowing stones.", "{hero} pulling out a handful of faintly glowing stones"),
926
+ ("Placed around the cave, they made it warm and beautiful.", "{hero} and Ember arranging glowing stones around the cave"),
927
+ ("Ember stepped inside, eyes wide with wonder.", "Ember the dragon stepping in slowly, amazed by the glow"),
928
+ ("{hero} waved goodnight as Ember curled up happy.", "{hero} waving at a cosy, sleepy Ember in the glowing cave"),
929
+ ],
930
+ ],
931
+ "Ocean": [
932
+ [ # arc A: the shell home
933
+ ("{hero} sat by the moonlit shore listening to the waves.", "{hero} sitting on the sand under a big silver moon"),
934
+ ("A little fish splashed up, its eyes wide and worried.", "{hero} seeing a small worried fish near the surface"),
935
+ ("A big wave had carried the fish's shell-home away.", "{hero} listening to the little fish with a gentle face"),
936
+ ("{hero} waded in gently and searched along the sandy floor.", "{hero} swimming softly along the moonlit ocean floor"),
937
+ ("There β€” half-buried β€” was the shell, pearly and perfect.", "{hero} finding a beautiful shell in the soft sand"),
938
+ ("{hero} set the shell back, and the sea turned calm.", "{hero} watching the fish swim happily into its shell home"),
939
+ ],
940
+ [ # arc B: the singing whale
941
+ ("{hero} heard a low, slow song drifting in from the sea.", "{hero} pressing ear to the cold night water listening"),
942
+ ("A whale was singing far away, all alone.", "{hero} imagining a large gentle whale singing in dark water"),
943
+ ("{hero} hummed back β€” a gentle, rising reply.", "{hero} sitting on a rock humming toward the ocean"),
944
+ ("The whale's song grew louder, warmer, closer.", "{hero} listening with eyes closed as the whale answers"),
945
+ ("Soon the whale found its pod, their songs weaving together.", "{hero} smiling at the sight of whales swimming together"),
946
+ ("{hero} fell asleep to the sound of the ocean choir.", "{hero} curled up on the soft sand, sleeping peacefully"),
947
+ ],
948
+ [ # arc C: the moonbeam path
949
+ ("{hero} wished the sea would show a safe path for the little boats.", "{hero} looking out at tiny boats bobbing in dark water"),
950
+ ("The moon heard and sent a silver path across the waves.", "{hero} watching a bright path of moonlight appear on the sea"),
951
+ ("Each little boat followed it safely into the harbour.", "{hero} smiling as boats glide along the moonbeam path"),
952
+ ("{hero} stood on the dock waving each boat home.", "{hero} waving warmly as boats arrive one by one"),
953
+ ("The last captain tossed {hero} a tiny glowing pearl.", "{hero} catching a small glowing pearl with cupped hands"),
954
+ ("{hero} held the pearl close and drifted off to sleep.", "{hero} lying in bed, pearl glowing softly on the pillow"),
955
+ ],
956
+ ],
957
+ "Forest": [
958
+ [ # arc A: the silent owl
959
+ ("{hero} walked into the whispering forest as the moon rose.", "{hero} stepping onto a silver moonlit forest path"),
960
+ ("The trees were worried β€” a wise old owl had lost its song.", "{hero} hearing the trees murmur about the silent owl"),
961
+ ("{hero} climbed a mossy rock and hummed a gentle tune.", "{hero} humming softly on a mossy rock under the moon"),
962
+ ("The owl tilted its head and slowly remembered the melody.", "{hero} watching the owl's eyes glow as memory returns"),
963
+ ("The whole forest filled with soft nighttime music.", "{hero} smiling as the forest glows with peaceful sound"),
964
+ ("{hero} yawned and drifted off to sleep among the roots.", "{hero} sleeping peacefully at the base of a great tree"),
965
+ ],
966
+ [ # arc B: the sleeping seeds
967
+ ("{hero} found that the forest was very quiet β€” too quiet.", "{hero} walking slowly through an unusually still forest"),
968
+ ("The seeds underground had forgotten when to sprout.", "{hero} pressing an ear to the ground and listening"),
969
+ ("{hero} sang a slow, low song about rain and warm sun.", "{hero} kneeling by the earth singing very quietly"),
970
+ ("Tiny green shoots appeared one by one between the roots.", "{hero} watching small green shoots push up through the soil"),
971
+ ("The whole floor sparkled with new leaves catching the moonlight.", "{hero} smiling at a carpet of tiny silver-green leaves"),
972
+ ("{hero} curled up between the roots, lullabied by the trees.", "{hero} sleeping among the roots as leaves drift down"),
973
+ ],
974
+ [ # arc C: the fox's lost path
975
+ ("{hero} met a small fox who had wandered very far from home.", "{hero} meeting a tired little fox sitting on a log"),
976
+ ("The fox didn't know which stars pointed north.", "{hero} and the fox both looking up at the starry sky"),
977
+ ("{hero} remembered β€” the Great Bear always faces north.", "{hero} pointing at a constellation with a sure hand"),
978
+ ("Together they followed the stars through the quiet trees.", "{hero} and the fox walking side by side in moonlight"),
979
+ ("At last the fox saw its familiar hill and bounded up.", "{hero} watching the fox race joyfully up the hill"),
980
+ ("{hero} turned home too, heart warm and full.", "{hero} walking home alone under the bright stars, smiling"),
981
+ ],
982
+ ],
983
+ "Space": [
984
+ [ # arc A: the falling star
985
+ ("{hero} loved counting stars from the garden every night.", "{hero} lying in the grass gazing at the starry sky"),
986
+ ("One night, one star blinked β€” and tumbled down!", "{hero} gasping as a little star falls from the sky"),
987
+ ("{hero} caught the star gently in a jar of moonlight.", "{hero} holding up a glowing jar with wide, careful hands"),
988
+ ("The little star whispered it was lost and didn't know home.", "{hero} listening closely to a tiny voice from the jar"),
989
+ ("{hero} climbed the tallest hill and opened the jar wide.", "{hero} on a hilltop, jar open, star ready to fly"),
990
+ ("The star zoomed home, and {hero} fell fast asleep smiling.", "{hero} smiling and drifting to sleep under the stars"),
991
+ ],
992
+ [ # arc B: the moon's blanket
993
+ ("The moon shivered β€” it had lost its soft cloud blanket.", "{hero} looking up at a pale, shivering moon"),
994
+ ("{hero} gathered the fluffiest clouds and knitted them together.", "{hero} floating and knitting clouds with silver thread"),
995
+ ("It took all night but the blanket grew soft and wide.", "{hero} holding up a huge fluffy white cloud blanket"),
996
+ ("{hero} floated up and draped it gently over the moon.", "{hero} tucking the cloud blanket around a grateful moon"),
997
+ ("The moon glowed warmer, and stars winked their thanks.", "{hero} floating back down as stars twinkle brightly"),
998
+ ("{hero} landed home just in time for bed.", "{hero} tiptoeing inside under a calm, warm, bright moon"),
999
+ ],
1000
+ [ # arc C: the astronaut bear
1001
+ ("{hero} met a small bear in a silver space suit near the rocket.", "{hero} waving at a small bear in a tiny astronaut suit"),
1002
+ ("The bear had come from a planet made entirely of honey.", "{hero} listening with eyes wide to the bear's story"),
1003
+ ("{hero} and the bear sat under the stars sharing honey cakes.", "{hero} and the bear sharing food under a huge starry sky"),
1004
+ ("The bear grew sleepy and asked how Earth children go to sleep.", "{hero} showing the bear how to close eyes slowly"),
1005
+ ("{hero} tucked the bear in inside the rocket pod.", "{hero} draping a blanket over the sleepy bear in the rocket"),
1006
+ ("{hero} waved as the rocket floated off towards the honey planet.", "{hero} waving at the rocket rising into the starry sky"),
1007
+ ],
1008
+ ],
1009
+ "Kingdom": [
1010
+ [ # arc A: the mouse and the crown
1011
+ ("In a cosy kingdom, {hero} was the kindest helper of all.", "{hero} standing cheerfully in a tiny fairy-tale village"),
1012
+ ("One sleepy evening, the king's golden crown had gone missing.", "{hero} seeing the king looking worried and bare-headed"),
1013
+ ("{hero} searched the royal garden softly by moonlight.", "{hero} tiptoeing through a moonlit garden"),
1014
+ ("A sleepy mouse had borrowed it for a bed!", "{hero} discovering a tiny mouse snoozing inside the crown"),
1015
+ ("{hero} found the mouse a proper bed of rose petals.", "{hero} carefully tucking the mouse into a petal bed"),
1016
+ ("The king smiled, and the whole kingdom slept in peace.", "{hero} and the king smiling under the quiet night sky"),
1017
+ ],
1018
+ [ # arc B: the baker's lost song
1019
+ ("The royal baker had forgotten the lullaby that made magic bread.", "{hero} finding the baker staring sadly at empty dough"),
1020
+ ("Without the song the bread wouldn't rise β€” and no breakfast!", "{hero} and the baker looking at flat, sad dough together"),
1021
+ ("{hero} sat quietly and hummed a slow, warm melody.", "{hero} sitting on a stool humming softly to the dough"),
1022
+ ("The dough trembled, puffed up, and filled the kitchen with warmth.", "{hero} watching the dough rise with wide delighted eyes"),
1023
+ ("By midnight, golden loaves lined every shelf in the kitchen.", "{hero} and the baker admiring rows of perfect golden loaves"),
1024
+ ("{hero} and the baker shared a tiny warm crust before sleep.", "{hero} and the baker smiling over a piece of warm bread"),
1025
+ ],
1026
+ [ # arc C: the princess who couldn't sleep
1027
+ ("The little princess had tried everything β€” she could not sleep.", "{hero} finding a princess sitting up wide-eyed in a big bed"),
1028
+ ("{hero} asked, 'Have you looked at the stars tonight, princess?'", "{hero} pointing to a window filled with stars"),
1029
+ ("They counted stars together, one by one.", "{hero} and the princess counting stars from the window"),
1030
+ ("After twelve stars the princess's eyes began to droop.", "{hero} smiling as the princess's head starts to nod"),
1031
+ ("{hero} pulled the curtain half-closed, leaving just a sliver of moonlight.", "{hero} quietly closing a curtain leaving a thin beam of moon"),
1032
+ ("The princess was asleep before {hero} could tiptoe away.", "{hero} tiptoeing out with a warm smile"),
1033
+ ],
1034
+ ],
1035
+ }
1036
+
1037
+ BEDTIME_FEW_SHOT = """
1038
+ Write a 6-page children's bedtime story for age 5 about Mia the bunny. Genre: Forest. Mood: Dreamy.
1039
+
1040
+ Rules:
1041
+ - Mia must appear by name in EVERY page text.
1042
+ - Use calm, soft language β€” words slow down and the child feels sleepy.
1043
+ - Each page is 1–2 short, gentle sentences.
1044
+ - Page 6 ends with Mia (or the child) falling peacefully asleep.
1045
+ - Scene describes exactly what appears in one illustration.
1046
+ - Return ONLY valid JSON (no markdown fences, no extra text).
1047
+
1048
+ {
1049
+ "title": "Mia and the Owl's Lullaby",
1050
+ "character_description": "A tiny white bunny named Mia with long soft ears, a pink nose, and a star-shaped patch on her back",
1051
+ "pages": [
1052
+ {"page": 1, "text": "Mia the bunny hopped into the forest as the moon rose above the trees.", "scene": "Mia the white bunny stepping onto a silver moonlit forest path"},
1053
+ {"page": 2, "text": "Everything was very quiet β€” the whole forest seemed to be waiting.", "scene": "Mia the white bunny standing still, ears up, listening"},
1054
+ {"page": 3, "text": "Then Mia heard a soft sound from high in the oak β€” an old owl humming.", "scene": "Mia the white bunny looking up at a sleepy owl on a branch"},
1055
+ {"page": 4, "text": "The owl's song was low and slow, like warm honey flowing.", "scene": "The owl humming softly, eyes half-closed, as Mia listens below"},
1056
+ {"page": 5, "text": "Mia's eyelids grew heavy, and she lay down on a bed of moss.", "scene": "Mia the white bunny curling up on soft green moss"},
1057
+ {"page": 6, "text": "The owl sang on, and Mia drifted into the sweetest dream.", "scene": "Mia the white bunny fast asleep in the moonlit forest, peaceful"}
1058
+ ]
1059
+ }
1060
+ """
1061
+
1062
+
1063
+ def _build_bedtime_locally(hero_name: str, genre: str) -> dict:
1064
+ hero = (hero_name or "Little One").strip() or "Little One"
1065
+ arcs = BEDTIME_TEMPLATES.get(genre, BEDTIME_TEMPLATES["Animals"])
1066
+ beats = _random.choice(arcs) # pick a random arc variant for variety
1067
+ pages = [
1068
+ {"page": i + 1, "text": t.format(hero=hero), "scene": s.format(hero=hero)}
1069
+ for i, (t, s) in enumerate(beats)
1070
+ ]
1071
+ return {
1072
+ "title": f"{hero}'s Bedtime Dream",
1073
+ "character_description": f"{hero}, a gentle, friendly storybook character with soft colours",
1074
+ "pages": pages,
1075
+ }
1076
+
1077
+
1078
+ def build_bedtime_html(title: str, pages: list) -> str:
1079
+ pages_html = "".join(
1080
+ f'<p class="bedtime-page">{p.get("text", "")}</p>'
1081
+ for p in pages
1082
+ )
1083
+ return (
1084
+ '<div class="bedtime-story">'
1085
+ f'<h2 class="bedtime-title">πŸŒ™ {title}</h2>'
1086
+ f'{pages_html}'
1087
+ '</div>'
1088
+ )
1089
+
1090
+
1091
+ @spaces.GPU(duration=60)
1092
+ def generate_bedtime_story_gpu(hero_name: str, genre: str, mood: str) -> dict:
1093
+ """Generate a gentle bedtime story with MiniCPM5-1B."""
1094
+ try:
1095
+ model, tok = load_story()
1096
+ prompt = (
1097
+ f"{BEDTIME_FEW_SHOT}\n\n"
1098
+ f"Write a 6-page children's bedtime story for age 5 about {hero_name}. "
1099
+ f"Genre: {genre}. Mood: {mood}.\n\n"
1100
+ f"Rules:\n"
1101
+ f"- {hero_name} must appear by name in EVERY page text.\n"
1102
+ f"- Use calm, slow, sleepy language throughout.\n"
1103
+ f"- Each page: 1–2 gentle sentences only.\n"
1104
+ f"- Page 6 must end with {hero_name} or the child falling peacefully asleep.\n"
1105
+ f"- Do NOT introduce random unrelated characters.\n"
1106
+ f"- Return ONLY valid JSON:\n"
1107
+ )
1108
+ inputs = tok.apply_chat_template(
1109
+ [{"role": "user", "content": prompt}],
1110
+ add_generation_prompt=True,
1111
+ enable_thinking=False,
1112
+ return_dict=True,
1113
+ return_tensors="pt",
1114
+ ).to("cuda")
1115
+ with torch.no_grad():
1116
+ out = model.generate(
1117
+ **inputs, max_new_tokens=800,
1118
+ do_sample=True, temperature=0.85, top_p=0.92, repetition_penalty=1.1,
1119
+ )
1120
+ response = tok.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
1121
+ parsed = parse_story_json(response)
1122
+ if parsed:
1123
+ return _normalize_story(parsed)
1124
+ logger.warning("Bedtime story parser failed; using local fallback")
1125
+ except Exception as e:
1126
+ logger.warning(f"Bedtime story GPU failed: {e}")
1127
+ return _normalize_story(_build_bedtime_locally(hero_name, genre))
1128
+
1129
+
1130
+ @spaces.GPU(duration=120)
1131
+ def generate_tts_cloned_gpu(text: str, ref_wav: str | None, mood: str = "calming") -> str:
1132
+ """VoxCPM2 bedtime narration β€” clones user's voice if ref_wav provided,
1133
+ otherwise uses Storyteller voice preset. Returns a temp WAV file path."""
1134
+ import re as _re
1135
+ import numpy as np
1136
+ import soundfile as sf
1137
+
1138
+ model = load_tts()
1139
+ sr = int(model.tts_model.sample_rate)
1140
+
1141
+ mood_styles = {
1142
+ "calming": "very slow, deep warm whisper, gentle bedtime voice, barely above a breath",
1143
+ "dreamy": "slow, soft, breathy whisper, lullaby-like rhythm, words dissolving into silence",
1144
+ "magical": "gentle, warm, wonder-filled, soft rising intonation on wonder words",
1145
+ "cozy": "warm, comfortable storytelling voice, like reading to a child tucked in bed",
1146
+ }
1147
+ style = mood_styles.get(mood.lower(), "gentle, warm, sleepy bedtime voice, slightly slow")
1148
+
1149
+ sentences = [s.strip() for s in _re.split(r"(?<=[.!?])\s+", text) if s.strip()]
1150
+ if not sentences:
1151
+ sentences = [text.strip() or "Sweet dreams."]
1152
+
1153
+ silence = np.zeros(int(0.65 * sr), dtype=np.float32)
1154
+ pieces = []
1155
+
1156
+ for sentence in sentences:
1157
+ kw = dict(text=f"({style}) {sentence}", cfg_value=2.0, inference_timesteps=10, normalize=True)
1158
+ if ref_wav and os.path.exists(str(ref_wav)):
1159
+ kw["reference_wav_path"] = ref_wav
1160
+ kw["denoise"] = True
1161
+ kw["retry_badcase"] = True
1162
+ kw["retry_badcase_max_times"] = 3
1163
+ wav = model.generate(**kw)
1164
+ wav = np.asarray(wav, dtype=np.float32)
1165
+ if wav.size:
1166
+ pieces.append(wav)
1167
+ pieces.append(silence)
1168
+
1169
+ if not pieces:
1170
+ raise RuntimeError("VoxCPM2 produced no audio.")
1171
+
1172
+ full = np.concatenate(pieces)
1173
+ peak = float(np.max(np.abs(full)))
1174
+ if peak > 0:
1175
+ full = full / peak * 0.92
1176
+
1177
+ fd, path = tempfile.mkstemp(prefix="bedtime_en_", suffix=".wav")
1178
+ os.close(fd)
1179
+ sf.write(path, full, sr)
1180
+ return path
1181
+
1182
+
1183
+ @spaces.GPU(duration=120)
1184
+ def generate_kannada_gpu(text: str, ref_wav: str, mood: str = "calming") -> str:
1185
+ """Translate English story to Kannada and narrate via IndicF5. Returns WAV path."""
1186
+ if not ref_wav or not os.path.exists(str(ref_wav)):
1187
+ raise ValueError("Voice clip required for Kannada narration.")
1188
+ from indic_text import translate_to_kannada
1189
+ from indic_tts import narrate_kannada
1190
+ kn_text = translate_to_kannada(text)
1191
+ return narrate_kannada(ref_wav, "ಇದು ನನ್ನ ಧ್ಡನಿ", kn_text, mood, 0.45)
1192
+
1193
+
1194
+ def create_bedtime(ref_audio, hero_name, bedtime_genre, bedtime_mood):
1195
+ """Bedtime Voice flow: story β†’ English cloned narration β†’ Kannada narration."""
1196
+ t0 = time.perf_counter()
1197
+ hero_name = (hero_name or "").strip() or "Little One"
1198
+ mood = (bedtime_mood or "calming").lower()
1199
+
1200
+ yield (
1201
+ '<div class="bedtime-empty">'
1202
+ '<span class="moon-icon">πŸŒ™</span>'
1203
+ '<p class="big">Writing your bedtime story…</p>'
1204
+ '</div>',
1205
+ "Writing your bedtime story…",
1206
+ None, None,
1207
+ )
1208
+
1209
+ story = generate_bedtime_story_gpu(hero_name, bedtime_genre, bedtime_mood)
1210
+ pages = story.get("pages", [])
1211
+ title = story.get("title", "A Bedtime Story")
1212
+ page_texts = [p.get("text", "") for p in pages]
1213
+ full_text = f"{title}. {' '.join(page_texts)}"
1214
+ story_html = build_bedtime_html(title, pages)
1215
+
1216
+ yield (story_html, f"{title} β€” recording English narration…", None, None)
1217
+
1218
+ en_audio_path = None
1219
+ try:
1220
+ en_audio_path = generate_tts_cloned_gpu(full_text, ref_audio, mood)
1221
+ except Exception as e:
1222
+ logger.warning(f"English TTS failed: {e}")
1223
+
1224
+ kn_note = "translating to Kannada…" if ref_audio else "record your voice for Kannada narration"
1225
+ yield (story_html, f"{title} β€” {kn_note}", en_audio_path, None)
1226
+
1227
+ kn_audio_path = None
1228
+ if ref_audio:
1229
+ try:
1230
+ kn_audio_path = generate_kannada_gpu(full_text, ref_audio, mood)
1231
+ except Exception as e:
1232
+ logger.warning(f"Kannada TTS failed: {e}")
1233
+
1234
+ total = round(time.perf_counter() - t0, 2)
1235
+ done_msg = f"Done: {title} Β· {total}s"
1236
+ if ref_audio and not kn_audio_path:
1237
+ done_msg += " Β· Kannada narration failed"
1238
+ elif not ref_audio:
1239
+ done_msg += " Β· record your voice to get Kannada narration"
1240
+
1241
+ yield (story_html, done_msg, en_audio_path, kn_audio_path)
1242
+
1243
+
1244
  # ============================================================================
1245
  # MAIN
1246
  # ============================================================================
 
1249
  demo = create_layout(
1250
  load_sample_fn=load_sample_book,
1251
  create_book_fn=create_book,
1252
+ create_bedtime_fn=create_bedtime,
1253
  )
1254
  demo.queue(default_concurrency_limit=2, max_size=8)
1255
  # design_kwargs (theme/css/js/head) is non-empty on gradio 6 (moved to launch)
audio_postprocess.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal audio post-processing for bedtime narration."""
2
+ import numpy as np
3
+
4
+
5
+ def postprocess(audio: np.ndarray, sr: int) -> np.ndarray:
6
+ """Normalize bedtime narration to a comfortable listening level."""
7
+ audio = np.asarray(audio, dtype=np.float32)
8
+ peak = float(np.max(np.abs(audio)))
9
+ if peak > 0:
10
+ audio = audio / peak * 0.92
11
+ return audio
audio_utils.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audio preparation helpers for voice reference clips."""
2
+ from __future__ import annotations
3
+ import os
4
+ import tempfile
5
+ import librosa
6
+ import numpy as np
7
+ import soundfile as sf
8
+
9
+ REFERENCE_SAMPLE_RATE = 16_000
10
+ MIN_REFERENCE_SECONDS = 5.0
11
+ MAX_REFERENCE_SECONDS = 60.0
12
+ MIN_RMS = 0.005
13
+
14
+
15
+ def prepare_reference(path: str) -> str:
16
+ """Clean and validate a voice reference clip for VoxCPM2.
17
+ Returns a temporary mono 16 kHz WAV path owned by the caller.
18
+ """
19
+ if not path or not os.path.exists(path):
20
+ raise ValueError("Please record or upload a voice clip first.")
21
+ try:
22
+ audio, _ = librosa.load(path, sr=REFERENCE_SAMPLE_RATE, mono=True)
23
+ except Exception as exc:
24
+ raise ValueError("Could not read that audio clip. Please try a WAV or MP3.") from exc
25
+
26
+ if audio.size == 0 or not np.isfinite(audio).all():
27
+ raise ValueError("Voice clip looks empty. Please record 5–60 seconds of clear speech.")
28
+
29
+ audio = np.asarray(audio, dtype=np.float32)
30
+ audio, _ = librosa.effects.trim(audio, top_db=35)
31
+
32
+ if audio.size == 0:
33
+ raise ValueError("Voice clip is too quiet. Please record closer to the microphone.")
34
+
35
+ peak = float(np.max(np.abs(audio)))
36
+ rms = float(np.sqrt(np.mean(np.square(audio))))
37
+ if peak <= 0.0 or rms < MIN_RMS:
38
+ raise ValueError("Voice clip is too quiet. Please record in a quiet room.")
39
+
40
+ duration = audio.size / REFERENCE_SAMPLE_RATE
41
+ if duration < MIN_REFERENCE_SECONDS:
42
+ raise ValueError("Please record at least 5 seconds of clear speech.")
43
+ if duration > MAX_REFERENCE_SECONDS:
44
+ audio = audio[:int(MAX_REFERENCE_SECONDS * REFERENCE_SAMPLE_RATE)]
45
+
46
+ audio = np.clip(audio, -1.0, 1.0)
47
+ fd, out_path = tempfile.mkstemp(prefix="bedvoice_ref_", suffix=".wav")
48
+ os.close(fd)
49
+ sf.write(out_path, audio, REFERENCE_SAMPLE_RATE, subtype="PCM_16")
50
+ return out_path
config.py CHANGED
@@ -117,6 +117,31 @@ TTS_MODEL = ModelConfig(
117
  modal_memory=8192,
118
  )
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
 
121
  # ============================================================================
122
  # VOICE PRESETS (VoxCPM2 "voice design")
 
117
  modal_memory=8192,
118
  )
119
 
120
+ # ── Bedtime Voice tab models ────────────────────────────────────────────────
121
+
122
+ TRANSLATION_MODEL = ModelConfig(
123
+ hub_id="ai4bharat/indictrans2-en-indic-1B",
124
+ params_b=1.0,
125
+ license=LicenseType.APACHE_2_0,
126
+ vram_gb=3.0,
127
+ modal_gpu="T4",
128
+ modal_memory=8192,
129
+ )
130
+
131
+ KANNADA_TTS_MODEL = ModelConfig(
132
+ hub_id="ai4bharat/IndicF5",
133
+ params_b=0.5,
134
+ license=LicenseType.APACHE_2_0,
135
+ vram_gb=2.0,
136
+ modal_gpu="T4",
137
+ modal_memory=8192,
138
+ )
139
+
140
+ KANNADA_FINETUNE: str = "mitvho09/IndicF5-Kannada-Bedtime-v2"
141
+
142
+ BEDTIME_GENRES: list = ["Animals", "Dragons", "Ocean", "Forest", "Space", "Kingdom"]
143
+ BEDTIME_MOODS: list = ["Calming", "Dreamy", "Magical", "Cozy"]
144
+
145
 
146
  # ============================================================================
147
  # VOICE PRESETS (VoxCPM2 "voice design")
indic_text.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """English β†’ Kannada translation via AI4Bharat IndicTrans2."""
2
+ from __future__ import annotations
3
+ import os
4
+ import re
5
+ import torch
6
+ from config import TRANSLATION_MODEL
7
+
8
+ _HUB_ID = TRANSLATION_MODEL.hub_id
9
+ _tok = None
10
+ _model = None
11
+
12
+
13
+ def _get_model():
14
+ global _tok, _model
15
+ if _tok is None or _model is None:
16
+ from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
17
+ token = os.environ.get("HF_TOKEN") or None
18
+ _tok = AutoTokenizer.from_pretrained(_HUB_ID, trust_remote_code=True, token=token)
19
+ _model = AutoModelForSeq2SeqLM.from_pretrained(_HUB_ID, trust_remote_code=True, token=token)
20
+ _model = _model.to("cuda").eval()
21
+ return _tok, _model
22
+
23
+
24
+ try:
25
+ _get_model()
26
+ except Exception:
27
+ pass
28
+
29
+
30
+ def _split_sentences(text: str, max_chars: int = 180):
31
+ parts = re.split(r"(?<=[.!?ΰ₯€])\s+|\n+", text.strip())
32
+ out = []
33
+ for p in parts:
34
+ p = p.strip()
35
+ if not p:
36
+ continue
37
+ while len(p) > max_chars:
38
+ cut = p.rfind(" ", 0, max_chars)
39
+ cut = cut if cut > 0 else max_chars
40
+ out.append(p[:cut].strip())
41
+ p = p[cut:].strip()
42
+ out.append(p)
43
+ return out or [text.strip()]
44
+
45
+
46
+ def translate_to_kannada(en_text: str) -> str:
47
+ """Translate English story text to Kannada script."""
48
+ text = (en_text or "").strip()
49
+ if not text:
50
+ raise ValueError("Nothing to translate.")
51
+
52
+ from IndicTransToolkit.processor import IndicProcessor
53
+ tok, model = _get_model()
54
+ ip = IndicProcessor(inference=True)
55
+
56
+ sents = _split_sentences(text, max_chars=180)
57
+ batch = ip.preprocess_batch(sents, src_lang="eng_Latn", tgt_lang="kan_Knda")
58
+ inputs = tok(batch, truncation=True, padding="longest", return_tensors="pt").to(model.device)
59
+
60
+ with torch.inference_mode():
61
+ generated = model.generate(
62
+ **inputs, max_length=512, num_beams=5,
63
+ num_return_sequences=1, length_penalty=1.0,
64
+ )
65
+ decoded = tok.batch_decode(generated, skip_special_tokens=True)
66
+ translations = ip.postprocess_batch(decoded, lang="kan_Knda")
67
+
68
+ kn = " ".join(t.strip() for t in translations if t.strip())
69
+ if not kn:
70
+ raise RuntimeError("Translation returned empty Kannada text.")
71
+ return kn
indic_tts.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Kannada narration via AI4Bharat IndicF5 with fine-tuned bedtime checkpoint."""
2
+ from __future__ import annotations
3
+ import os
4
+ import re
5
+ import tempfile
6
+ import numpy as np
7
+ import torch
8
+ from config import KANNADA_TTS_MODEL, KANNADA_FINETUNE
9
+
10
+ _BASE_HUB_ID = KANNADA_TTS_MODEL.hub_id
11
+ _CHECKPOINT = KANNADA_FINETUNE
12
+ INDICF5_SR = 24_000
13
+ _model = None
14
+
15
+
16
+ def _get_model():
17
+ global _model
18
+ if _model is None:
19
+ from transformers import AutoModel
20
+ token = os.environ.get("HF_TOKEN") or None
21
+ _model = AutoModel.from_pretrained(_BASE_HUB_ID, trust_remote_code=True, token=token)
22
+ try:
23
+ from huggingface_hub import hf_hub_download
24
+ cfm_path = hf_hub_download(repo_id=_CHECKPOINT, filename="cfm.pt", token=token)
25
+ cfm_state = torch.load(cfm_path, map_location="cpu", weights_only=True)
26
+ _model.ema_model.load_state_dict(cfm_state)
27
+ except Exception as e:
28
+ pass # fall back to base checkpoint silently
29
+ _model = _model.to("cuda").eval()
30
+ return _model
31
+
32
+
33
+ try:
34
+ _get_model()
35
+ except Exception:
36
+ pass
37
+
38
+
39
+ def _split_sentences(text: str, max_chars: int = 200):
40
+ parts = re.split(r"(?<=[.!?ΰ₯€])\s+|\n+", text.strip())
41
+ out = []
42
+ for p in parts:
43
+ p = p.strip()
44
+ if not p:
45
+ continue
46
+ while len(p) > max_chars:
47
+ cut = p.rfind(" ", 0, max_chars)
48
+ cut = cut if cut > 0 else max_chars
49
+ out.append(p[:cut].strip())
50
+ p = p[cut:].strip()
51
+ out.append(p)
52
+ return out or [text.strip()]
53
+
54
+
55
+ def _pause_for(mood: str, energy: float = 0.45) -> float:
56
+ energy = max(0.0, min(1.0, float(energy)))
57
+ base = 0.45 if mood in ("funny", "magical") else 0.65
58
+ return round(base + (0.85 - base) * (1.0 - energy), 3)
59
+
60
+
61
+ def narrate_kannada(ref_wav: str, ref_text: str, kannada_text: str,
62
+ mood: str = "", energy: float = 0.45) -> str:
63
+ """Clone the user's voice and narrate Kannada text. Returns a temp WAV path."""
64
+ if not ref_wav or not os.path.exists(ref_wav):
65
+ raise ValueError("Please provide a voice reference WAV.")
66
+ if not (ref_text or "").strip():
67
+ raise ValueError("Kannada reference transcript is required.")
68
+ if not (kannada_text or "").strip():
69
+ raise ValueError("Please provide Kannada text to narrate.")
70
+
71
+ model = _get_model()
72
+ pause = _pause_for(mood, energy) * 1.3
73
+ silence = np.zeros(int(pause * INDICF5_SR), dtype=np.float32)
74
+
75
+ chunks = []
76
+ for sentence in _split_sentences(kannada_text, max_chars=200):
77
+ audio = model(sentence, ref_audio_path=ref_wav, ref_text=ref_text.strip())
78
+ audio = np.asarray(audio, dtype=np.float32)
79
+ if audio.size and float(np.max(np.abs(audio))) > 1.0:
80
+ audio = audio / 32768.0
81
+ if audio.size:
82
+ chunks.append(audio)
83
+ chunks.append(silence)
84
+
85
+ if not chunks:
86
+ raise RuntimeError("IndicF5 produced no audio.")
87
+
88
+ full = np.concatenate(chunks)
89
+ from audio_postprocess import postprocess
90
+ full = postprocess(full, INDICF5_SR)
91
+
92
+ import soundfile as sf
93
+ fd, out_path = tempfile.mkstemp(prefix="bedtime_kn_", suffix=".wav")
94
+ os.close(fd)
95
+ sf.write(out_path, full, INDICF5_SR)
96
+ return out_path
requirements.txt CHANGED
@@ -26,3 +26,7 @@ fpdf2
26
  requests
27
  huggingface_hub
28
  soundfile
 
 
 
 
 
26
  requests
27
  huggingface_hub
28
  soundfile
29
+ librosa>=0.10.0
30
+
31
+ # Bedtime Voice tab (Kannada support)
32
+ IndicTransToolkit @ git+https://github.com/VarunGumma/IndicTransToolkit.git
ui/layout.py CHANGED
@@ -1,21 +1,13 @@
1
  """
2
- UI layout β€” CUSTOM Gradio Blocks layout for DoodleBook.
3
-
4
- Aesthetic: "Construction-paper scrapbook" β€” a handmade keepsake, not a dashboard.
5
- Signature details:
6
- - Warm construction-paper grain background (inline SVG fractal noise)
7
- - Wobbly hand-drawn borders via an SVG displacement filter (#wobble)
8
- - Washi-tape strips, slightly rotated cards, polaroid-pinned doodle
9
- - Crayon-textured hand-lettered headings (Gaegu / Caveat) + legible body (Nunito)
10
-
11
- Off-Brand Badge: zero Gradio defaults. Every styled element uses elem_classes so the
12
- CSS actually targets Gradio 5's DOM (the previous build targeted Gradio 3/4 `.gr-*`
13
- classes that no longer exist, so almost nothing applied).
14
  """
15
 
16
  import gradio as gr
17
-
18
- from config import VOICE_CHOICES, DEFAULT_VOICE
19
 
20
  THEMES = [
21
  "brave adventure",
@@ -26,9 +18,6 @@ THEMES = [
26
  "learning something new",
27
  ]
28
 
29
- # ---------------------------------------------------------------------------
30
- # Fonts: injected via <head> (reliable) instead of CSS @import (often stripped).
31
- # ---------------------------------------------------------------------------
32
  HEAD = """
33
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0">
34
  <link rel="preconnect" href="https://fonts.googleapis.com">
@@ -36,10 +25,6 @@ HEAD = """
36
  <link href="https://fonts.googleapis.com/css2?family=Gaegu:wght@400;700&family=Caveat:wght@500;700&family=Nunito:wght@400;600;700;800&display=swap" rel="stylesheet">
37
  """
38
 
39
- # ---------------------------------------------------------------------------
40
- # Hidden SVG defs: the hand-drawn "wobble" filter + a crayon underline squiggle.
41
- # Applied to pseudo-element borders so text stays crisp while frames go wavy.
42
- # ---------------------------------------------------------------------------
43
  SVG_DEFS = """
44
  <svg width="0" height="0" aria-hidden="true" style="position:absolute">
45
  <filter id="wobble">
@@ -58,13 +43,13 @@ SVG_DEFS = """
58
 
59
  CSS = r"""
60
  /* ============================================================================
61
- DOODLEBOOK β€” CONSTRUCTION-PAPER SCRAPBOOK
62
  ============================================================================ */
63
 
64
  :root {
65
- --paper: #f6ecd4; /* warm construction-paper base */
66
  --paper-2: #efe0c2;
67
- --ink: #2e2a26; /* soft black, like a felt pen */
68
  --ink-soft: #6b5d4f;
69
  --crayon-orange: #ef6a3a;
70
  --crayon-teal: #2ba39a;
@@ -73,9 +58,17 @@ CSS = r"""
73
  --crayon-sky: #4a9fd6;
74
  --crayon-leaf: #74b85a;
75
  --tape: rgba(244, 198, 74, 0.55);
 
 
 
 
 
 
 
 
 
76
  }
77
 
78
- /* --- Neutralize the Gradio theme so OUR styles win (was: Soft theme bleed) --- */
79
  .gradio-container,
80
  .gradio-container *:not(svg):not(path) {
81
  --block-background-fill: transparent;
@@ -94,7 +87,6 @@ CSS = r"""
94
  color: var(--ink);
95
  }
96
 
97
- /* Paper-grain backdrop: layered fractal noise + soft color blooms */
98
  body, gradio-app {
99
  background-color: var(--paper) !important;
100
  background-image:
@@ -105,29 +97,51 @@ body, gradio-app {
105
  background-attachment: fixed !important;
106
  }
107
 
108
- /* Reusable hand-drawn frame: a wobbly inked border that doesn't disturb content */
109
- .framed { position: relative; }
110
- .framed::after {
111
- content: "";
112
- position: absolute; inset: 7px;
113
- border: 2.5px solid var(--ink);
114
- border-radius: 16px;
115
- filter: url(#wobble);
116
- pointer-events: none;
117
- opacity: 0.85;
118
- }
119
-
120
- /* Washi-tape strip helper */
121
- .taped::before {
122
- content: "";
123
- position: absolute; top: -12px; left: 50%;
124
- width: 110px; height: 26px;
125
- transform: translateX(-50%) rotate(-3deg);
126
- background:
127
- repeating-linear-gradient(45deg, rgba(255,255,255,.35) 0 6px, transparent 6px 12px),
128
- var(--tape);
129
- box-shadow: 0 2px 5px rgba(0,0,0,.12);
130
- z-index: 3;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  }
132
 
133
  /* ============================== HEADER ============================== */
@@ -143,10 +157,7 @@ body, gradio-app {
143
  line-height: 0.95 !important;
144
  color: var(--ink) !important;
145
  margin: 0 !important;
146
- letter-spacing: 1px;
147
- text-shadow:
148
- 2px 2px 0 var(--crayon-sun),
149
- 4px 4px 0 rgba(239,106,58,.35);
150
  }
151
  .app-title .doodle-o { color: var(--crayon-orange); display: inline-block; transform: rotate(-6deg); }
152
  .app-subtitle {
@@ -155,14 +166,13 @@ body, gradio-app {
155
  color: var(--ink-soft) !important;
156
  margin-top: 6px !important;
157
  }
158
- /* hand-drawn crayon underline under the title */
159
  .title-squiggle { display:block; margin: 6px auto 0; width: min(360px, 70%); height: 16px; }
160
  .title-squiggle path {
161
  stroke: var(--crayon-teal); stroke-width: 5; fill: none;
162
  stroke-linecap: round; filter: url(#wobble-strong);
163
  }
164
 
165
- /* ============================== CARDS ============================== */
166
  .input-card, .output-card {
167
  position: relative;
168
  background: #fffdf6 !important;
@@ -175,269 +185,360 @@ body, gradio-app {
175
  .output-card { transform: rotate(0.5deg); }
176
  .input-card:hover, .output-card:hover { transform: rotate(0deg); transition: transform .35s ease; }
177
 
178
- /* section labels above fields */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  .card-eyebrow {
180
  font-family: 'Gaegu', cursive; font-weight: 700;
181
  font-size: 22px; color: var(--crayon-orange);
182
- margin: 0 0 10px; transform: rotate(-1.5deg);
183
  }
184
 
185
- /* ============================== FIELDS ============================== */
186
- .field label span, .doodle-input label span, .tiny-toggle label span {
187
- font-family: 'Gaegu', cursive !important;
188
- font-weight: 700 !important;
189
- font-size: 19px !important;
190
- color: var(--ink) !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  }
192
- .field textarea,
193
- .field input[type="text"],
194
- .field input:not([type]),
195
- .field .wrap .secondary-wrap input,
196
- .field [data-testid="textbox"],
197
- .field input[role="listbox"],
198
- .field .secondary-wrap,
199
- .field [class*="dropdown"] input {
200
- font-family: 'Nunito', sans-serif !important;
201
- font-size: 17px !important;
202
- color: var(--ink) !important;
203
- background: #fffdf6 !important;
204
- border: 2.5px solid var(--ink) !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  border-radius: 12px !important;
206
  padding: 11px 14px !important;
207
- box-shadow: 2px 3px 0 rgba(46,42,38,.10) !important;
208
  }
209
- .field textarea:focus,
210
- .field input:focus {
211
- border-color: var(--crayon-orange) !important;
212
- box-shadow: 2px 3px 0 rgba(239,106,58,.30) !important;
213
  outline: none !important;
214
  }
215
 
216
- /* doodle image as a pinned polaroid */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  .doodle-input { position: relative; }
218
- .doodle-input .image-container,
219
- .doodle-input [data-testid="image"] {
220
- background: #ffffff !important;
221
- border: none !important;
222
- border-radius: 4px !important;
223
- padding: 12px 12px 34px !important; /* polaroid bottom lip */
224
- box-shadow: 0 8px 18px rgba(46,42,38,.18) !important;
225
- transform: rotate(-2deg);
226
  }
227
  .doodle-input img { border-radius: 2px !important; }
228
- .doodle-input .upload-container,
229
- .doodle-input [data-testid="image"] .wrap {
230
- border: 3px dashed var(--crayon-sky) !important;
231
- border-radius: 8px !important;
232
- background: #f3f9ff !important;
233
- color: var(--ink-soft) !important;
234
  }
235
 
236
  /* ============================== BUTTONS ============================== */
237
- /* elem_classes land on the <button> itself in Gradio 5, so target both the
238
- button directly AND a possible inner <button> wrapper to be safe. */
239
  .btn-make, .btn-make button {
240
- font-family: 'Gaegu', cursive !important;
241
- font-weight: 700 !important;
242
- font-size: 26px !important;
243
- letter-spacing: .5px !important;
244
- color: #fff !important;
245
- background: var(--crayon-orange) !important;
246
- border: 2.5px solid var(--ink) !important;
247
- border-radius: 16px !important;
248
- padding: 14px 26px !important;
249
- width: 100% !important;
250
- transform: rotate(-1deg);
251
- box-shadow: 4px 5px 0 var(--ink) !important;
252
  transition: transform .12s ease, box-shadow .12s ease !important;
253
  }
254
  .btn-make:hover, .btn-make button:hover {
255
  transform: rotate(-1deg) translate(-2px,-2px);
256
- box-shadow: 6px 7px 0 var(--ink) !important;
257
- background: #f5764a !important;
258
- }
259
- .btn-make:active, .btn-make button:active {
260
- transform: rotate(-1deg) translate(2px,2px);
261
- box-shadow: 1px 2px 0 var(--ink) !important;
262
  }
263
 
264
  .btn-pdf, .btn-pdf button {
265
- font-family: 'Gaegu', cursive !important;
266
- font-weight: 700 !important;
267
- font-size: 19px !important;
268
- color: var(--ink) !important;
269
- background: var(--crayon-sun) !important;
270
- border: 2.5px solid var(--ink) !important;
271
- border-radius: 14px !important;
272
- box-shadow: 3px 4px 0 var(--ink) !important;
273
  transform: rotate(0.8deg);
274
  }
275
  .btn-pdf:hover, .btn-pdf button:hover { background: #f8d066 !important; }
276
 
277
- .download-row {
278
- margin-top: 8px !important;
279
- gap: 10px !important;
280
- }
281
- .download-row > div {
282
- flex: 1 1 0 !important;
283
- }
284
-
285
- /* tiny-mode toggle */
286
  .tiny-toggle { transform: rotate(-0.6deg); }
287
 
288
- /* === STORY THEME picker: visible crayon chips (was a dark, easy-to-miss dropdown) === */
289
- .theme-pick .wrap,
290
- .theme-pick [role="radiogroup"] {
291
- display: flex !important;
292
- flex-wrap: wrap !important;
293
- gap: 8px !important;
294
  }
295
  .theme-pick label {
296
- background: #fffdf6 !important;
297
- border: 2.5px solid var(--ink) !important;
298
- border-radius: 14px !important;
299
- padding: 8px 14px !important;
300
- margin: 0 !important;
301
- cursor: pointer !important;
302
- font-family: 'Gaegu', cursive !important;
303
- font-size: 16px !important;
304
- color: var(--ink) !important;
305
  box-shadow: 2px 3px 0 rgba(46,42,38,.12) !important;
306
  transition: transform .1s ease, background .1s ease !important;
307
  }
308
  .theme-pick label:hover { transform: translateY(-1px); background: #fff3e0 !important; }
309
- /* selected chip = crayon orange */
310
  .theme-pick label:has(input:checked) {
311
- background: var(--crayon-orange) !important;
312
- color: #fff !important;
313
  box-shadow: 2px 3px 0 var(--ink) !important;
314
  }
315
  .theme-pick input[type="radio"] { accent-color: var(--crayon-orange); margin-right: 6px; }
316
 
317
  /* ============================== STATUS ============================== */
318
  .status-display textarea {
319
- font-family: 'Caveat', cursive !important;
320
- font-size: 20px !important;
321
- color: var(--crayon-teal) !important;
322
- background: #f0faf8 !important;
323
- border: 2.5px dashed var(--crayon-teal) !important;
324
- border-radius: 12px !important;
325
  text-align: center !important;
326
  }
 
 
 
 
327
 
328
  /* ============================== AUDIO ============================== */
329
  .audio-player {
330
- position: relative;
331
- background: #f0faf8 !important;
332
- border: 2.5px solid var(--ink) !important;
333
- border-radius: 14px !important;
334
- padding: 10px 12px !important;
335
  box-shadow: 3px 4px 0 rgba(46,42,38,.12) !important;
336
  }
337
  .audio-player label span { font-family: 'Gaegu', cursive !important; font-size: 18px !important; }
338
 
339
  /* ============================== THE BOOK ============================== */
340
  .book-stage { min-height: 220px; }
341
-
342
  .book-container { max-width: 100%; padding: 4px; }
343
-
344
  .book-title {
345
- font-family: 'Gaegu', cursive !important;
346
- font-weight: 700 !important;
347
- font-size: clamp(30px, 4.5vw, 46px) !important;
348
- text-align: center !important;
349
- color: var(--ink) !important;
350
- margin: 6px 0 22px !important;
351
  text-shadow: 2px 2px 0 var(--crayon-sun);
352
  }
353
-
354
- /* the front cover */
355
  .book-cover {
356
- position: relative;
357
- text-align: center;
358
- padding: 34px 22px 30px;
359
- margin: 10px auto 26px;
360
- background:
361
- radial-gradient(circle at 30% 20%, rgba(244,198,74,.25), transparent 55%),
362
- #fff8e6;
363
- border-radius: 18px;
364
- box-shadow: 0 12px 28px rgba(46,42,38,.16);
365
- transform: rotate(-1deg);
366
- }
367
- .book-cover .cover-kicker {
368
- font-family: 'Caveat', cursive; font-size: 22px; color: var(--crayon-berry);
369
  }
 
370
  .book-cover .cover-title {
371
  font-family: 'Gaegu', cursive; font-weight: 700;
372
  font-size: clamp(34px, 5vw, 52px); color: var(--ink); margin: 4px 0 2px;
373
  text-shadow: 2px 2px 0 var(--crayon-sun);
374
  }
375
-
376
- /* each page = a taped-down scrapbook entry, alternating tilt */
377
  .book-page {
378
- position: relative;
379
- background: #fffdf6;
380
- border-radius: 14px;
381
- padding: 22px 22px 26px;
382
- margin: 30px auto;
383
- max-width: 640px;
384
  box-shadow: 0 8px 20px rgba(46,42,38,.13);
385
  }
386
  .book-page:nth-child(even) { transform: rotate(0.9deg); }
387
  .book-page:nth-child(odd) { transform: rotate(-0.9deg); }
388
  .book-page:hover { transform: rotate(0deg) translateY(-3px); transition: transform .3s ease; }
389
- /* washi tape on every page */
390
  .book-page::before {
391
- content: "";
392
- position: absolute; top: -11px; left: 50%;
393
- width: 96px; height: 24px;
394
- transform: translateX(-50%) rotate(-2.5deg);
395
  background: repeating-linear-gradient(45deg, rgba(255,255,255,.4) 0 5px, transparent 5px 10px), var(--tape);
396
  box-shadow: 0 2px 5px rgba(0,0,0,.12);
397
  }
398
-
399
- /* hand-drawn frame around the illustration */
400
  .book-page .page-art { position: relative; }
401
  .book-page .page-art::after {
402
  content: ""; position: absolute; inset: 4px;
403
  border: 3px solid var(--ink); border-radius: 12px;
404
  filter: url(#wobble); pointer-events: none;
405
  }
406
- .book-page img {
407
- display: block; width: 100%;
408
- border-radius: 10px;
409
- }
410
-
411
  .page-text {
412
- font-family: 'Caveat', cursive !important;
413
- font-size: clamp(22px, 3vw, 30px) !important;
414
- line-height: 1.45 !important;
415
- color: var(--ink) !important;
416
- text-align: center !important;
417
- margin: 18px 6px 4px !important;
418
  }
419
  .page-num {
420
  display: block; text-align: center; margin-top: 8px;
421
- font-family: 'Gaegu', cursive; font-weight: 700;
422
- font-size: 16px; color: var(--ink-soft);
423
  }
424
-
425
- /* loading / empty states */
426
  .page-loading {
427
  text-align: center; padding: 40px 20px;
428
- font-family: 'Gaegu', cursive; font-weight: 700;
429
- font-size: 22px; color: var(--ink-soft);
430
  animation: bob 1.6s ease-in-out infinite;
431
  }
432
- .book-empty {
433
- text-align: center; padding: 54px 24px;
434
- font-family: 'Gaegu', cursive; color: var(--ink-soft);
435
- }
436
  .book-empty .big { font-size: 30px; color: var(--ink); }
437
  .book-empty .arrow { font-size: 42px; display:block; margin-bottom: 8px; animation: bob 1.6s ease-in-out infinite; }
438
  @keyframes bob { 0%,100% { transform: translateY(0) } 50% { transform: translateY(-7px) } }
439
-
440
- /* engine badge so you ALWAYS know FLUX vs placeholder */
441
  .engine-badge {
442
  display:inline-block; margin: 0 auto 4px; padding: 3px 12px;
443
  font-family:'Gaegu',cursive; font-weight:700; font-size:14px;
@@ -448,18 +549,13 @@ body, gradio-app {
448
 
449
  /* ============================== ACCORDION ============================== */
450
  .behind-magic {
451
- position: relative;
452
- margin: 36px 8px 0 !important;
453
- background: #fff8ef !important;
454
- border: 2.5px dashed var(--crayon-berry) !important;
455
- border-radius: 16px !important;
456
- padding: 6px 14px !important;
457
- transform: rotate(-0.4deg);
458
  }
459
  .behind-magic span, .behind-magic button {
460
- font-family: 'Gaegu', cursive !important;
461
- font-weight: 700 !important; color: var(--crayon-berry) !important;
462
- font-size: 20px !important;
463
  }
464
 
465
  /* ============================== FOOTER ============================== */
@@ -469,81 +565,7 @@ body, gradio-app {
469
  }
470
  .app-footer .badges { font-family:'Gaegu',cursive; font-weight:700; color: var(--ink); }
471
 
472
- /* ============================== RESPONSIVE ============================== */
473
- @media (max-width: 820px) {
474
- /* Fixed backgrounds kill touch-scroll on iOS Safari */
475
- body, gradio-app {
476
- background-attachment: scroll !important;
477
- }
478
-
479
- /* Allow the page to scroll vertically, not overflow horizontally */
480
- html, body {
481
- height: auto !important;
482
- overflow-y: auto !important;
483
- overflow-x: hidden !important;
484
- -webkit-overflow-scrolling: touch !important;
485
- }
486
-
487
- gradio-app {
488
- height: auto !important;
489
- min-height: 100vh !important;
490
- overflow-y: visible !important;
491
- overflow-x: hidden !important;
492
- }
493
-
494
- /* Keep container within viewport width */
495
- .gradio-container {
496
- max-width: 100vw !important;
497
- overflow-x: hidden !important;
498
- padding: 0 !important;
499
- }
500
-
501
- /* Stack the two main columns: allow flex-wrap, then make each card full-width */
502
- .gradio-container .gap {
503
- flex-wrap: wrap !important;
504
- }
505
-
506
- .input-card, .output-card {
507
- transform: none !important;
508
- flex: 1 1 100% !important;
509
- width: 100% !important;
510
- max-width: 100% !important;
511
- min-width: 0 !important;
512
- margin: 8px 0 !important;
513
- box-sizing: border-box !important;
514
- }
515
-
516
- /* Slightly smaller title on phones */
517
- .app-title {
518
- font-size: clamp(32px, 10vw, 52px) !important;
519
- }
520
-
521
- /* Story-book pages: full width, smaller margin */
522
- .book-page {
523
- max-width: 100%;
524
- margin: 20px auto;
525
- }
526
-
527
- /* Theme / voice chips: tighter on small screens */
528
- .theme-pick label {
529
- font-size: 14px !important;
530
- padding: 6px 10px !important;
531
- }
532
- }
533
-
534
- /* ============================== ACCESSIBILITY ============================== */
535
- @media (prefers-reduced-motion: reduce) {
536
- * { animation: none !important; transition: none !important; }
537
- .input-card, .output-card, .book-page, .book-cover, .btn-make, .btn-make button { transform: none !important; }
538
- }
539
-
540
- /* ============================================================================
541
- LIGHT-MODE LOCK β€” the scrapbook design is light-only. Gradio's dark theme
542
- ('.dark .block', etc.) has higher specificity than our single-class rules and
543
- was painting the cards/examples/status dark with invisible (dark-on-dark)
544
- text. We also force light mode via JS (see create_layout), but lock the colors
545
- here too so it looks right even before/if that doesn't run.
546
- ============================================================================ */
547
  .gradio-container, .gradio-container *:not(svg):not(path) {
548
  --background-fill-primary: transparent;
549
  --background-fill-secondary: transparent;
@@ -555,224 +577,296 @@ body, gradio-app {
555
  color-scheme: light;
556
  }
557
 
558
- /* The cards are cream; EVERY Gradio wrapper div inside them must be see-through
559
- so the cream shows. Gradio groups the stacked fields into a .form/.block
560
- wrapper that stayed dark β€” this is what made Section 2 a dark rectangle. Leaf
561
- controls (input/textarea/label/button) keep their own backgrounds because they
562
- are not divs and are styled separately above. */
563
- .input-card .form, .input-card .block, .input-card .panel,
564
- .input-card .wrap, .input-card .gap, .input-card .styler,
565
- .output-card .form, .output-card .block, .output-card .panel,
566
- .output-card .gap, .output-card .styler {
567
- background: transparent !important;
568
- border-color: transparent !important;
569
- box-shadow: none !important;
570
- }
571
-
572
- /* Examples / Dataset: render as a light scrapbook strip, never the dark default */
573
- .input-card .dataset,
574
- .input-card [data-testid="dataset"],
575
- .input-card .gr-samples-table,
576
- .input-card table,
577
- .input-card thead,
578
- .input-card tbody,
579
- .input-card tr,
580
- .input-card th,
581
- .input-card td {
582
- background: #fffdf6 !important;
583
- color: var(--ink) !important;
584
- border-color: var(--ink-soft) !important;
585
- }
586
- .input-card .dataset *, .input-card [data-testid="dataset"] * {
587
- color: var(--ink) !important;
588
  }
589
 
590
- /* Status: the styled teal textarea stays; its wrappers go transparent over cream */
591
- .status-display, .status-display .block, .status-display .form,
592
- .status-display .container, .status-display label span {
593
- background: transparent !important;
594
- color: var(--crayon-teal) !important;
595
  }
596
  """
597
 
598
-
599
- # Force the app into LIGHT mode (the scrapbook design is light-only). Without
600
- # this, an OS/browser in dark mode makes Gradio paint cards dark with invisible
601
- # text. Adds ?__theme=light and reloads once if it isn't already set.
602
- FORCE_LIGHT_JS = """
603
  () => {
 
604
  const u = new URL(window.location.href);
605
  if (u.searchParams.get('__theme') !== 'light') {
606
  u.searchParams.set('__theme', 'light');
607
  window.location.replace(u.toString());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
608
  }
 
609
  }
610
  """
611
 
612
 
613
- def create_layout(load_sample_fn=None, create_book_fn=None):
614
- """Build the scrapbook-styled Gradio Blocks layout."""
615
 
616
- # Gradio 6 moved theme/css/js/head from Blocks() to launch(). Keep the
617
- # scrapbook design working on BOTH: pass them to Blocks on gradio 5, and
618
- # stash them on the returned demo so the caller hands them to launch() on 6.
619
  _gr_major = int(gr.__version__.split(".")[0])
620
  design_kwargs = dict(
621
  css=CSS,
622
  head=HEAD,
623
- js=FORCE_LIGHT_JS, # lock to light mode (scrapbook design is light-only)
624
- theme=gr.themes.Base(), # Base, not Soft β€” we own the styling
625
  )
626
  blocks_kwargs = dict(title="DoodleBook")
627
  if _gr_major < 6:
628
  blocks_kwargs.update(design_kwargs)
629
 
630
  with gr.Blocks(**blocks_kwargs) as demo:
631
- # hidden SVG filters used by the hand-drawn frames
632
  gr.HTML(SVG_DEFS)
633
 
634
- # ---- HEADER ----
635
- gr.HTML(
636
- """
637
- <div class="app-header">
638
- <h1 class="app-title">D<span class="doodle-o">oo</span>dleBook</h1>
639
- <svg class="title-squiggle" viewBox="0 0 360 16" preserveAspectRatio="none">
640
- <path d="M2,11 C40,3 70,15 110,8 S190,2 230,9 320,14 358,5"/>
641
- </svg>
642
- <p class="app-subtitle">draw a character &middot; get a storybook &middot; hear it read aloud</p>
643
- </div>
644
- """
645
- )
646
-
647
- with gr.Row(equal_height=False):
648
- # ---- INPUT CARD (real wrapper via elem_classes, not stray HTML tags) ----
649
- with gr.Column(scale=1, elem_classes=["input-card"]):
650
- gr.HTML('<p class="card-eyebrow">1 &middot; your character</p>')
651
- doodle = gr.Image(
652
- sources=["upload", "webcam"], # upload first so it's the default
653
- label="Upload or snap the drawing",
654
- type="numpy",
655
- height=240,
656
- elem_classes=["doodle-input"],
657
- )
658
- gr.HTML('<p class="card-eyebrow">2 &middot; the details</p>')
659
- char_name = gr.Textbox(
660
- label="Character name",
661
- placeholder="Ziggy the robot",
662
- elem_classes=["field"],
663
- )
664
- hero_name = gr.Textbox(
665
- label="Hero's name in the story",
666
- placeholder="Ziggy",
667
- elem_classes=["field"],
668
- )
669
- theme = gr.Radio(
670
- choices=THEMES,
671
- value=THEMES[0],
672
- label="Story theme (pick one)",
673
- elem_classes=["field", "theme-pick"],
674
- )
675
- voice = gr.Radio(
676
- choices=VOICE_CHOICES,
677
- value=DEFAULT_VOICE,
678
- label="Narrator voice",
679
- elem_classes=["field", "theme-pick"],
680
- )
681
- make_coloring = gr.Checkbox(
682
- label="Also make a coloring book",
683
- value=False,
684
- elem_classes=["tiny-toggle"],
685
- )
686
- make_btn = gr.Button(
687
- "Make my book!",
688
- variant="primary",
689
- elem_classes=["btn-make"],
690
- )
691
- status = gr.Textbox(
692
- label="Status",
693
- interactive=False,
694
- elem_classes=["status-display"],
695
- value="Ready when you are! ✏️",
696
- )
697
- # Fixed downloads right under the status β€” always here, no scrolling
698
- # to a button at the bottom. They light up the moment the book is done.
699
- with gr.Row(elem_classes=["download-row"]):
700
- pdf_download = gr.DownloadButton(
701
- "⬇ Story PDF",
702
- visible=False,
703
- elem_classes=["btn-pdf"],
704
- )
705
- coloring_pdf_download = gr.DownloadButton(
706
- "⬇ Coloring PDF",
707
- visible=False,
708
- elem_classes=["btn-pdf"],
709
- )
710
- gr.Examples(
711
- examples=[["assets/sample_doodle.jpg", "Ziggy", "Ziggy", "brave adventure"]],
712
- inputs=[doodle, char_name, hero_name, theme],
713
- label="Try an example",
714
- )
715
-
716
- # ---- OUTPUT CARD ----
717
- with gr.Column(scale=2, elem_classes=["output-card"]):
718
- audio_narration = gr.Audio(
719
- label="Listen to your story",
720
- autoplay=False,
721
- elem_classes=["audio-player"],
722
- )
723
- book_display = gr.HTML(
724
- elem_classes=["book-stage"],
725
- value="""
726
- <div class="book-empty">
727
- <span class="arrow">↑</span>
728
- <p class="big">Your storybook appears here</p>
729
- <p>Add a drawing, pick a theme, and tap <b>Make my book!</b></p>
730
- </div>
731
- """,
732
- )
733
- coloring_display = gr.HTML(
734
- visible=False,
735
- elem_classes=["book-stage"],
736
- )
737
-
738
- # ---- BEHIND THE MAGIC ----
739
- with gr.Accordion("Behind the magic ✨", open=False, elem_classes=["behind-magic"]):
740
- with gr.Tabs():
741
- with gr.Tab("Story"):
742
- story_info = gr.JSON(label="Generated story structure")
743
- with gr.Tab("Images"):
744
- image_info = gr.Textbox(label="Illustration details", interactive=False, lines=5)
745
- with gr.Tab("Models"):
746
- gr.Markdown(
747
- """
748
  | Model | Role | Size |
749
  |---|---|---|
750
  | **MiniCPM5-1B** | Story writer | 1B |
751
  | **VoxCPM2** | Voice narrator | 2B |
752
  | **FLUX.2-klein** | Illustrator | 4B |
753
 
754
- The *brain* of DoodleBook β€” the story + the voice β€” is a **3B small-model stack**.
755
- FLUX is the printer. **Tiny Titan.**
756
- """
757
- )
758
- with gr.Tab("Trace"):
759
- # show_copy_button was removed from Textbox in gradio 6
760
- _tb_kwargs = dict(label="Generation trace (Open Trace)",
761
- interactive=False, lines=8)
762
- if _gr_major < 6:
763
- _tb_kwargs["show_copy_button"] = True
764
- trace_info = gr.Textbox(**_tb_kwargs)
765
-
766
- gr.HTML(
767
- """
768
- <div class="app-footer">
769
- <p>stitched together with crayons &amp; code for the Build Small Hackathon 2026</p>
770
- <p class="badges">Well-Tuned &middot; Off-Brand &middot; Field Notes &middot; Open Trace</p>
771
- </div>
772
- """
773
- )
774
-
775
- # ---- WIRING ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
776
  if create_book_fn:
777
  make_btn.click(
778
  fn=create_book_fn,
@@ -783,7 +877,12 @@ FLUX is the printer. **Tiny Titan.**
783
  )
784
  if load_sample_fn:
785
  demo.load(fn=load_sample_fn, outputs=[book_display])
 
 
 
 
 
 
786
 
787
- # On gradio 6 the design params go to launch(); expose them for the caller.
788
  demo.design_kwargs = design_kwargs if _gr_major >= 6 else {}
789
  return demo
 
1
  """
2
+ UI layout β€” DoodleBook with two tabs: Storybook + Bedtime Voice.
3
+
4
+ Storybook tab: construction-paper scrapbook (unchanged aesthetic).
5
+ Bedtime Voice tab: moonlit night-sky palette, voice cloning, English + Kannada audio.
6
+ Both tabs share the same fonts, header, and footer.
 
 
 
 
 
 
 
7
  """
8
 
9
  import gradio as gr
10
+ from config import VOICE_CHOICES, DEFAULT_VOICE, BEDTIME_GENRES, BEDTIME_MOODS
 
11
 
12
  THEMES = [
13
  "brave adventure",
 
18
  "learning something new",
19
  ]
20
 
 
 
 
21
  HEAD = """
22
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0">
23
  <link rel="preconnect" href="https://fonts.googleapis.com">
 
25
  <link href="https://fonts.googleapis.com/css2?family=Gaegu:wght@400;700&family=Caveat:wght@500;700&family=Nunito:wght@400;600;700;800&display=swap" rel="stylesheet">
26
  """
27
 
 
 
 
 
28
  SVG_DEFS = """
29
  <svg width="0" height="0" aria-hidden="true" style="position:absolute">
30
  <filter id="wobble">
 
43
 
44
  CSS = r"""
45
  /* ============================================================================
46
+ DOODLEBOOK β€” CONSTRUCTION-PAPER SCRAPBOOK + BEDTIME VOICE
47
  ============================================================================ */
48
 
49
  :root {
50
+ --paper: #f6ecd4;
51
  --paper-2: #efe0c2;
52
+ --ink: #2e2a26;
53
  --ink-soft: #6b5d4f;
54
  --crayon-orange: #ef6a3a;
55
  --crayon-teal: #2ba39a;
 
58
  --crayon-sky: #4a9fd6;
59
  --crayon-leaf: #74b85a;
60
  --tape: rgba(244, 198, 74, 0.55);
61
+ /* Bedtime palette */
62
+ --night: #0e0e2e;
63
+ --night-card: #14143a;
64
+ --night-2: #1a1a4a;
65
+ --moon: #c8a83c;
66
+ --moon-glow: #e8c84a;
67
+ --starlight: #ddd8ff;
68
+ --star-soft: #9090c0;
69
+ --night-border: #4040a0;
70
  }
71
 
 
72
  .gradio-container,
73
  .gradio-container *:not(svg):not(path) {
74
  --block-background-fill: transparent;
 
87
  color: var(--ink);
88
  }
89
 
 
90
  body, gradio-app {
91
  background-color: var(--paper) !important;
92
  background-image:
 
97
  background-attachment: fixed !important;
98
  }
99
 
100
+ /* ============================== KID ANIMATIONS ============================== */
101
+ .db-spark {
102
+ position: absolute;
103
+ border-radius: 50%;
104
+ opacity: 0;
105
+ animation: db-sparkle-float linear infinite;
106
+ }
107
+ @keyframes db-sparkle-float {
108
+ 0% { transform: translateY(0) scale(0.6) rotate(0deg); opacity: 0; }
109
+ 15% { opacity: 0.55; }
110
+ 85% { opacity: 0.3; }
111
+ 100% { transform: translateY(-110px) scale(1.1) rotate(200deg); opacity: 0; }
112
+ }
113
+
114
+ .db-star {
115
+ position: absolute;
116
+ border-radius: 50%;
117
+ background: rgba(255, 255, 255, 0.9);
118
+ animation: db-twinkle ease-in-out infinite;
119
+ }
120
+ @keyframes db-twinkle {
121
+ 0%,100% { opacity: 0.04; transform: scale(0.7); }
122
+ 50% { opacity: 0.95; transform: scale(1.35); }
123
+ }
124
+
125
+ /* ============================== TABS ============================== */
126
+ .gradio-container [role="tab"] {
127
+ font-family: 'Gaegu', cursive !important;
128
+ font-size: 20px !important;
129
+ font-weight: 700 !important;
130
+ border-radius: 14px 14px 0 0 !important;
131
+ border: 2.5px solid var(--ink) !important;
132
+ border-bottom: none !important;
133
+ background: #ede0c8 !important;
134
+ color: var(--ink-soft) !important;
135
+ padding: 10px 24px !important;
136
+ margin-right: 4px !important;
137
+ transition: background .15s !important;
138
+ }
139
+ .gradio-container [role="tab"][aria-selected="true"] {
140
+ background: #fffdf6 !important;
141
+ color: var(--ink) !important;
142
+ }
143
+ .gradio-container [role="tab"]:hover:not([aria-selected="true"]) {
144
+ background: #f5e8cf !important;
145
  }
146
 
147
  /* ============================== HEADER ============================== */
 
157
  line-height: 0.95 !important;
158
  color: var(--ink) !important;
159
  margin: 0 !important;
160
+ text-shadow: 2px 2px 0 var(--crayon-sun), 4px 4px 0 rgba(239,106,58,.35);
 
 
 
161
  }
162
  .app-title .doodle-o { color: var(--crayon-orange); display: inline-block; transform: rotate(-6deg); }
163
  .app-subtitle {
 
166
  color: var(--ink-soft) !important;
167
  margin-top: 6px !important;
168
  }
 
169
  .title-squiggle { display:block; margin: 6px auto 0; width: min(360px, 70%); height: 16px; }
170
  .title-squiggle path {
171
  stroke: var(--crayon-teal); stroke-width: 5; fill: none;
172
  stroke-linecap: round; filter: url(#wobble-strong);
173
  }
174
 
175
+ /* ============================== STORYBOOK CARDS ============================== */
176
  .input-card, .output-card {
177
  position: relative;
178
  background: #fffdf6 !important;
 
185
  .output-card { transform: rotate(0.5deg); }
186
  .input-card:hover, .output-card:hover { transform: rotate(0deg); transition: transform .35s ease; }
187
 
188
+ .input-card .form, .input-card .block, .input-card .panel,
189
+ .input-card .wrap, .input-card .gap, .input-card .styler,
190
+ .output-card .form, .output-card .block, .output-card .panel,
191
+ .output-card .gap, .output-card .styler {
192
+ background: transparent !important;
193
+ border-color: transparent !important;
194
+ box-shadow: none !important;
195
+ }
196
+ .input-card .dataset, .input-card [data-testid="dataset"],
197
+ .input-card table, .input-card thead, .input-card tbody,
198
+ .input-card tr, .input-card th, .input-card td {
199
+ background: #fffdf6 !important;
200
+ color: var(--ink) !important;
201
+ border-color: var(--ink-soft) !important;
202
+ }
203
+ .input-card .dataset * { color: var(--ink) !important; }
204
+
205
  .card-eyebrow {
206
  font-family: 'Gaegu', cursive; font-weight: 700;
207
  font-size: 22px; color: var(--crayon-orange);
208
+ margin: 0 0 10px; transform: rotate(-1.5deg); display: block;
209
  }
210
 
211
+ /* ============================== BEDTIME VOICE CARDS ============================== */
212
+ .bedtime-input-card {
213
+ position: relative;
214
+ background: linear-gradient(160deg, var(--night-card) 0%, var(--night) 100%) !important;
215
+ border-radius: 18px !important;
216
+ padding: 30px 26px 26px !important;
217
+ margin: 8px !important;
218
+ box-shadow: 0 10px 26px rgba(0,0,0,.5), 0 0 50px rgba(80,80,180,.1) !important;
219
+ transform: rotate(-0.5deg);
220
+ color: var(--starlight) !important;
221
+ }
222
+ .bedtime-input-card:hover { transform: rotate(0deg); transition: transform .35s ease; }
223
+ .bedtime-output-card {
224
+ position: relative;
225
+ background: linear-gradient(160deg, var(--night-2) 0%, var(--night) 100%) !important;
226
+ border-radius: 18px !important;
227
+ padding: 30px 26px 26px !important;
228
+ margin: 8px !important;
229
+ box-shadow: 0 10px 26px rgba(0,0,0,.5) !important;
230
+ transform: rotate(0.4deg);
231
+ }
232
+ .bedtime-output-card:hover { transform: rotate(0deg); transition: transform .35s ease; }
233
+
234
+ .bedtime-input-card .form, .bedtime-input-card .block,
235
+ .bedtime-input-card .panel, .bedtime-input-card .wrap,
236
+ .bedtime-input-card .gap, .bedtime-input-card .styler {
237
+ background: transparent !important;
238
+ border-color: rgba(64,64,160,.4) !important;
239
+ box-shadow: none !important;
240
  }
241
+ .bedtime-output-card .form, .bedtime-output-card .block,
242
+ .bedtime-output-card .panel, .bedtime-output-card .wrap,
243
+ .bedtime-output-card .gap, .bedtime-output-card .styler {
244
+ background: transparent !important;
245
+ border-color: transparent !important;
246
+ box-shadow: none !important;
247
+ }
248
+
249
+ .card-eyebrow-moon {
250
+ font-family: 'Gaegu', cursive; font-weight: 700;
251
+ font-size: 22px; color: var(--moon);
252
+ margin: 0 0 10px; transform: rotate(-1.5deg); display: block;
253
+ }
254
+
255
+ .moon-orb {
256
+ width: 64px; height: 64px;
257
+ background: radial-gradient(circle at 38% 38%, #f5e87a, #c8a83c 60%, #8a6a10);
258
+ border-radius: 50%;
259
+ box-shadow: 0 0 30px rgba(200,168,60,.5), 0 0 60px rgba(200,168,60,.2);
260
+ margin: 0 auto 16px;
261
+ animation: moon-glow 4s ease-in-out infinite;
262
+ }
263
+ @keyframes moon-glow {
264
+ 0%,100% { box-shadow: 0 0 30px rgba(200,168,60,.4), 0 0 60px rgba(200,168,60,.2); }
265
+ 50% { box-shadow: 0 0 50px rgba(200,168,60,.8), 0 0 90px rgba(200,168,60,.35); }
266
+ }
267
+
268
+ /* Night radio chips */
269
+ .theme-pick-night .wrap,
270
+ .theme-pick-night [role="radiogroup"] {
271
+ display: flex !important; flex-wrap: wrap !important; gap: 8px !important;
272
+ }
273
+ .theme-pick-night label {
274
+ background: rgba(80,80,180,.18) !important;
275
+ border: 2px solid var(--night-border) !important;
276
+ border-radius: 14px !important;
277
+ padding: 8px 14px !important; margin: 0 !important; cursor: pointer !important;
278
+ font-family: 'Gaegu', cursive !important; font-size: 16px !important;
279
+ color: var(--starlight) !important;
280
+ transition: transform .1s ease, background .1s ease !important;
281
+ }
282
+ .theme-pick-night label:hover { transform: translateY(-1px); background: rgba(100,100,220,.3) !important; }
283
+ .theme-pick-night label:has(input:checked) {
284
+ background: var(--moon) !important;
285
+ color: var(--night) !important;
286
+ border-color: var(--moon-glow) !important;
287
+ box-shadow: 0 0 8px rgba(200,168,60,.4) !important;
288
+ }
289
+ .theme-pick-night input[type="radio"] { accent-color: var(--moon); margin-right: 6px; }
290
+
291
+ /* Night text fields */
292
+ .field-night label span {
293
+ font-family: 'Gaegu', cursive !important; font-weight: 700 !important;
294
+ font-size: 19px !important; color: var(--moon) !important;
295
+ }
296
+ .field-night textarea,
297
+ .field-night input[type="text"],
298
+ .field-night input:not([type]) {
299
+ font-family: 'Nunito', sans-serif !important; font-size: 17px !important;
300
+ color: var(--starlight) !important;
301
+ background: rgba(100,100,200,.15) !important;
302
+ border: 2px solid var(--night-border) !important;
303
  border-radius: 12px !important;
304
  padding: 11px 14px !important;
 
305
  }
306
+ .field-night textarea:focus, .field-night input:focus {
307
+ border-color: var(--moon) !important;
308
+ box-shadow: 0 0 8px rgba(200,168,60,.3) !important;
 
309
  outline: none !important;
310
  }
311
 
312
+ /* Night audio recording input */
313
+ .bedtime-audio label span {
314
+ font-family: 'Gaegu', cursive !important; font-weight: 700 !important;
315
+ font-size: 19px !important; color: var(--moon) !important;
316
+ }
317
+ .bedtime-audio [data-testid="audio"],
318
+ .bedtime-audio .audio {
319
+ background: rgba(60,60,160,.2) !important;
320
+ border: 2px solid var(--night-border) !important;
321
+ border-radius: 12px !important;
322
+ }
323
+ .bedtime-audio .upload-container,
324
+ .bedtime-audio .wrap { background: transparent !important; }
325
+
326
+ /* Bedtime button */
327
+ .btn-bedtime, .btn-bedtime button {
328
+ font-family: 'Gaegu', cursive !important; font-weight: 700 !important;
329
+ font-size: 24px !important; color: var(--night) !important;
330
+ background: var(--moon) !important;
331
+ border: 2.5px solid #8a6a10 !important; border-radius: 16px !important;
332
+ padding: 13px 24px !important; width: 100% !important;
333
+ transform: rotate(-0.8deg);
334
+ box-shadow: 4px 5px 0 rgba(0,0,0,.5) !important;
335
+ transition: transform .12s ease, box-shadow .12s ease !important;
336
+ }
337
+ .btn-bedtime:hover, .btn-bedtime button:hover {
338
+ transform: rotate(-0.8deg) translate(-2px,-2px);
339
+ box-shadow: 6px 7px 0 rgba(0,0,0,.5) !important;
340
+ background: var(--moon-glow) !important;
341
+ }
342
+
343
+ /* Bedtime status */
344
+ .bedtime-status textarea {
345
+ font-family: 'Caveat', cursive !important; font-size: 20px !important;
346
+ color: var(--moon) !important;
347
+ background: rgba(80,80,180,.12) !important;
348
+ border: 2px dashed var(--night-border) !important;
349
+ border-radius: 12px !important; text-align: center !important;
350
+ }
351
+ .bedtime-status, .bedtime-status .block, .bedtime-status .form,
352
+ .bedtime-status label span { background: transparent !important; color: var(--moon) !important; }
353
+
354
+ /* Night audio players */
355
+ .audio-player-night {
356
+ background: rgba(60,60,160,.18) !important;
357
+ border: 2px solid var(--night-border) !important;
358
+ border-radius: 14px !important;
359
+ padding: 10px 12px !important;
360
+ }
361
+ .audio-player-night label span {
362
+ font-family: 'Gaegu', cursive !important; color: var(--moon) !important; font-size: 18px !important;
363
+ }
364
+
365
+ /* Bedtime story text display */
366
+ .bedtime-title {
367
+ font-family: 'Gaegu', cursive; font-weight: 700;
368
+ font-size: clamp(22px, 3.5vw, 36px); color: var(--moon);
369
+ text-align: center; margin-bottom: 18px;
370
+ text-shadow: 0 0 20px rgba(200,168,60,.3);
371
+ }
372
+ .bedtime-page {
373
+ font-family: 'Caveat', cursive; font-size: clamp(18px, 2.5vw, 26px);
374
+ line-height: 1.6; color: var(--starlight);
375
+ text-align: center; padding: 14px 18px; margin: 10px 0;
376
+ background: rgba(255,255,255,.05); border-radius: 12px;
377
+ border-left: 3px solid rgba(200,168,60,.3);
378
+ }
379
+ .bedtime-empty {
380
+ text-align: center; padding: 60px 24px;
381
+ font-family: 'Gaegu', cursive; color: var(--star-soft);
382
+ }
383
+ .bedtime-empty .moon-icon { font-size: 48px; display: block; margin-bottom: 12px; animation: bob 2.2s ease-in-out infinite; }
384
+ .bedtime-empty .big { font-size: 26px; color: var(--starlight); margin-bottom: 6px; }
385
+
386
+ /* ============================== STORYBOOK FIELDS ============================== */
387
+ .field label span, .doodle-input label span, .tiny-toggle label span {
388
+ font-family: 'Gaegu', cursive !important; font-weight: 700 !important;
389
+ font-size: 19px !important; color: var(--ink) !important;
390
+ }
391
+ .field textarea, .field input[type="text"], .field input:not([type]),
392
+ .field .wrap .secondary-wrap input, .field [data-testid="textbox"],
393
+ .field input[role="listbox"], .field .secondary-wrap, .field [class*="dropdown"] input {
394
+ font-family: 'Nunito', sans-serif !important; font-size: 17px !important;
395
+ color: var(--ink) !important; background: #fffdf6 !important;
396
+ border: 2.5px solid var(--ink) !important; border-radius: 12px !important;
397
+ padding: 11px 14px !important; box-shadow: 2px 3px 0 rgba(46,42,38,.10) !important;
398
+ }
399
+ .field textarea:focus, .field input:focus {
400
+ border-color: var(--crayon-orange) !important;
401
+ box-shadow: 2px 3px 0 rgba(239,106,58,.30) !important; outline: none !important;
402
+ }
403
+
404
  .doodle-input { position: relative; }
405
+ .doodle-input .image-container, .doodle-input [data-testid="image"] {
406
+ background: #ffffff !important; border: none !important; border-radius: 4px !important;
407
+ padding: 12px 12px 34px !important;
408
+ box-shadow: 0 8px 18px rgba(46,42,38,.18) !important; transform: rotate(-2deg);
 
 
 
 
409
  }
410
  .doodle-input img { border-radius: 2px !important; }
411
+ .doodle-input .upload-container, .doodle-input [data-testid="image"] .wrap {
412
+ border: 3px dashed var(--crayon-sky) !important; border-radius: 8px !important;
413
+ background: #f3f9ff !important; color: var(--ink-soft) !important;
 
 
 
414
  }
415
 
416
  /* ============================== BUTTONS ============================== */
 
 
417
  .btn-make, .btn-make button {
418
+ font-family: 'Gaegu', cursive !important; font-weight: 700 !important;
419
+ font-size: 26px !important; letter-spacing: .5px !important;
420
+ color: #fff !important; background: var(--crayon-orange) !important;
421
+ border: 2.5px solid var(--ink) !important; border-radius: 16px !important;
422
+ padding: 14px 26px !important; width: 100% !important;
423
+ transform: rotate(-1deg); box-shadow: 4px 5px 0 var(--ink) !important;
 
 
 
 
 
 
424
  transition: transform .12s ease, box-shadow .12s ease !important;
425
  }
426
  .btn-make:hover, .btn-make button:hover {
427
  transform: rotate(-1deg) translate(-2px,-2px);
428
+ box-shadow: 6px 7px 0 var(--ink) !important; background: #f5764a !important;
 
 
 
 
 
429
  }
430
 
431
  .btn-pdf, .btn-pdf button {
432
+ font-family: 'Gaegu', cursive !important; font-weight: 700 !important;
433
+ font-size: 19px !important; color: var(--ink) !important;
434
+ background: var(--crayon-sun) !important; border: 2.5px solid var(--ink) !important;
435
+ border-radius: 14px !important; box-shadow: 3px 4px 0 var(--ink) !important;
 
 
 
 
436
  transform: rotate(0.8deg);
437
  }
438
  .btn-pdf:hover, .btn-pdf button:hover { background: #f8d066 !important; }
439
 
440
+ .download-row { margin-top: 8px !important; gap: 10px !important; }
441
+ .download-row > div { flex: 1 1 0 !important; }
 
 
 
 
 
 
 
442
  .tiny-toggle { transform: rotate(-0.6deg); }
443
 
444
+ /* ============================== THEME/VOICE CHIPS ============================== */
445
+ .theme-pick .wrap, .theme-pick [role="radiogroup"] {
446
+ display: flex !important; flex-wrap: wrap !important; gap: 8px !important;
 
 
 
447
  }
448
  .theme-pick label {
449
+ background: #fffdf6 !important; border: 2.5px solid var(--ink) !important;
450
+ border-radius: 14px !important; padding: 8px 14px !important; margin: 0 !important;
451
+ cursor: pointer !important; font-family: 'Gaegu', cursive !important;
452
+ font-size: 16px !important; color: var(--ink) !important;
 
 
 
 
 
453
  box-shadow: 2px 3px 0 rgba(46,42,38,.12) !important;
454
  transition: transform .1s ease, background .1s ease !important;
455
  }
456
  .theme-pick label:hover { transform: translateY(-1px); background: #fff3e0 !important; }
 
457
  .theme-pick label:has(input:checked) {
458
+ background: var(--crayon-orange) !important; color: #fff !important;
 
459
  box-shadow: 2px 3px 0 var(--ink) !important;
460
  }
461
  .theme-pick input[type="radio"] { accent-color: var(--crayon-orange); margin-right: 6px; }
462
 
463
  /* ============================== STATUS ============================== */
464
  .status-display textarea {
465
+ font-family: 'Caveat', cursive !important; font-size: 20px !important;
466
+ color: var(--crayon-teal) !important; background: #f0faf8 !important;
467
+ border: 2.5px dashed var(--crayon-teal) !important; border-radius: 12px !important;
 
 
 
468
  text-align: center !important;
469
  }
470
+ .status-display, .status-display .block, .status-display .form,
471
+ .status-display .container, .status-display label span {
472
+ background: transparent !important; color: var(--crayon-teal) !important;
473
+ }
474
 
475
  /* ============================== AUDIO ============================== */
476
  .audio-player {
477
+ background: #f0faf8 !important; border: 2.5px solid var(--ink) !important;
478
+ border-radius: 14px !important; padding: 10px 12px !important;
 
 
 
479
  box-shadow: 3px 4px 0 rgba(46,42,38,.12) !important;
480
  }
481
  .audio-player label span { font-family: 'Gaegu', cursive !important; font-size: 18px !important; }
482
 
483
  /* ============================== THE BOOK ============================== */
484
  .book-stage { min-height: 220px; }
 
485
  .book-container { max-width: 100%; padding: 4px; }
 
486
  .book-title {
487
+ font-family: 'Gaegu', cursive !important; font-weight: 700 !important;
488
+ font-size: clamp(30px, 4.5vw, 46px) !important; text-align: center !important;
489
+ color: var(--ink) !important; margin: 6px 0 22px !important;
 
 
 
490
  text-shadow: 2px 2px 0 var(--crayon-sun);
491
  }
 
 
492
  .book-cover {
493
+ text-align: center; padding: 34px 22px 30px; margin: 10px auto 26px;
494
+ background: radial-gradient(circle at 30% 20%, rgba(244,198,74,.25), transparent 55%), #fff8e6;
495
+ border-radius: 18px; box-shadow: 0 12px 28px rgba(46,42,38,.16); transform: rotate(-1deg);
 
 
 
 
 
 
 
 
 
 
496
  }
497
+ .book-cover .cover-kicker { font-family: 'Caveat', cursive; font-size: 22px; color: var(--crayon-berry); }
498
  .book-cover .cover-title {
499
  font-family: 'Gaegu', cursive; font-weight: 700;
500
  font-size: clamp(34px, 5vw, 52px); color: var(--ink); margin: 4px 0 2px;
501
  text-shadow: 2px 2px 0 var(--crayon-sun);
502
  }
 
 
503
  .book-page {
504
+ position: relative; background: #fffdf6; border-radius: 14px;
505
+ padding: 22px 22px 26px; margin: 30px auto; max-width: 640px;
 
 
 
 
506
  box-shadow: 0 8px 20px rgba(46,42,38,.13);
507
  }
508
  .book-page:nth-child(even) { transform: rotate(0.9deg); }
509
  .book-page:nth-child(odd) { transform: rotate(-0.9deg); }
510
  .book-page:hover { transform: rotate(0deg) translateY(-3px); transition: transform .3s ease; }
 
511
  .book-page::before {
512
+ content: ""; position: absolute; top: -11px; left: 50%;
513
+ width: 96px; height: 24px; transform: translateX(-50%) rotate(-2.5deg);
 
 
514
  background: repeating-linear-gradient(45deg, rgba(255,255,255,.4) 0 5px, transparent 5px 10px), var(--tape);
515
  box-shadow: 0 2px 5px rgba(0,0,0,.12);
516
  }
 
 
517
  .book-page .page-art { position: relative; }
518
  .book-page .page-art::after {
519
  content: ""; position: absolute; inset: 4px;
520
  border: 3px solid var(--ink); border-radius: 12px;
521
  filter: url(#wobble); pointer-events: none;
522
  }
523
+ .book-page img { display: block; width: 100%; border-radius: 10px; }
 
 
 
 
524
  .page-text {
525
+ font-family: 'Caveat', cursive !important; font-size: clamp(22px, 3vw, 30px) !important;
526
+ line-height: 1.45 !important; color: var(--ink) !important;
527
+ text-align: center !important; margin: 18px 6px 4px !important;
 
 
 
528
  }
529
  .page-num {
530
  display: block; text-align: center; margin-top: 8px;
531
+ font-family: 'Gaegu', cursive; font-weight: 700; font-size: 16px; color: var(--ink-soft);
 
532
  }
 
 
533
  .page-loading {
534
  text-align: center; padding: 40px 20px;
535
+ font-family: 'Gaegu', cursive; font-weight: 700; font-size: 22px; color: var(--ink-soft);
 
536
  animation: bob 1.6s ease-in-out infinite;
537
  }
538
+ .book-empty { text-align: center; padding: 54px 24px; font-family: 'Gaegu', cursive; color: var(--ink-soft); }
 
 
 
539
  .book-empty .big { font-size: 30px; color: var(--ink); }
540
  .book-empty .arrow { font-size: 42px; display:block; margin-bottom: 8px; animation: bob 1.6s ease-in-out infinite; }
541
  @keyframes bob { 0%,100% { transform: translateY(0) } 50% { transform: translateY(-7px) } }
 
 
542
  .engine-badge {
543
  display:inline-block; margin: 0 auto 4px; padding: 3px 12px;
544
  font-family:'Gaegu',cursive; font-weight:700; font-size:14px;
 
549
 
550
  /* ============================== ACCORDION ============================== */
551
  .behind-magic {
552
+ position: relative; margin: 16px 8px 0 !important;
553
+ background: #fff8ef !important; border: 2.5px dashed var(--crayon-berry) !important;
554
+ border-radius: 16px !important; padding: 6px 14px !important; transform: rotate(-0.4deg);
 
 
 
 
555
  }
556
  .behind-magic span, .behind-magic button {
557
+ font-family: 'Gaegu', cursive !important; font-weight: 700 !important;
558
+ color: var(--crayon-berry) !important; font-size: 20px !important;
 
559
  }
560
 
561
  /* ============================== FOOTER ============================== */
 
565
  }
566
  .app-footer .badges { font-family:'Gaegu',cursive; font-weight:700; color: var(--ink); }
567
 
568
+ /* ============================== LIGHT-MODE LOCK ============================== */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
569
  .gradio-container, .gradio-container *:not(svg):not(path) {
570
  --background-fill-primary: transparent;
571
  --background-fill-secondary: transparent;
 
577
  color-scheme: light;
578
  }
579
 
580
+ /* ============================== RESPONSIVE ============================== */
581
+ @media (max-width: 820px) {
582
+ body, gradio-app { background-attachment: scroll !important; }
583
+ html, body { height: auto !important; overflow-y: auto !important; overflow-x: hidden !important; -webkit-overflow-scrolling: touch !important; }
584
+ gradio-app { height: auto !important; min-height: 100vh !important; overflow-y: visible !important; overflow-x: hidden !important; }
585
+ .gradio-container { max-width: 100vw !important; overflow-x: hidden !important; padding: 0 !important; }
586
+ .gradio-container .gap { flex-wrap: wrap !important; }
587
+ .input-card, .output-card,
588
+ .bedtime-input-card, .bedtime-output-card {
589
+ transform: none !important; flex: 1 1 100% !important; width: 100% !important;
590
+ max-width: 100% !important; min-width: 0 !important; margin: 8px 0 !important;
591
+ box-sizing: border-box !important;
592
+ }
593
+ .app-title { font-size: clamp(32px, 10vw, 52px) !important; }
594
+ .book-page { max-width: 100%; margin: 20px auto; }
595
+ .theme-pick label, .theme-pick-night label { font-size: 14px !important; padding: 6px 10px !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
596
  }
597
 
598
+ @media (prefers-reduced-motion: reduce) {
599
+ * { animation: none !important; transition: none !important; }
600
+ .input-card, .output-card, .bedtime-input-card, .bedtime-output-card,
601
+ .book-page, .book-cover, .btn-make, .btn-make button, .btn-bedtime, .btn-bedtime button,
602
+ .moon-orb { transform: none !important; }
603
  }
604
  """
605
 
606
+ # Combined JS: light-mode lock + kid-friendly floating animations
607
+ COMBINED_JS = """
 
 
 
608
  () => {
609
+ // Lock to light mode first
610
  const u = new URL(window.location.href);
611
  if (u.searchParams.get('__theme') !== 'light') {
612
  u.searchParams.set('__theme', 'light');
613
  window.location.replace(u.toString());
614
+ return;
615
+ }
616
+
617
+ // Floating coloured sparkles (kids love these)
618
+ const sparkColors = ['#ef6a3a','#f4c64a','#2ba39a','#4a9fd6','#d6517a','#74b85a'];
619
+ const spWrap = document.createElement('div');
620
+ spWrap.id = 'db-sparkles';
621
+ spWrap.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;overflow:hidden;';
622
+ for (let i = 0; i < 18; i++) {
623
+ const s = document.createElement('span');
624
+ s.className = 'db-spark';
625
+ s.style.cssText = `left:${Math.random()*100}%;top:${10+Math.random()*80}%;`
626
+ + `width:${5+Math.random()*6}px;height:${5+Math.random()*6}px;`
627
+ + `background:${sparkColors[i % sparkColors.length]};`
628
+ + `animation-delay:${Math.random()*9}s;animation-duration:${6+Math.random()*5}s;`;
629
+ spWrap.appendChild(s);
630
+ }
631
+ document.body.prepend(spWrap);
632
+
633
+ // Twinkling stars (subtle on paper, vivid on night-sky bedtime cards)
634
+ const stWrap = document.createElement('div');
635
+ stWrap.id = 'db-stars';
636
+ stWrap.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;overflow:hidden;';
637
+ for (let i = 0; i < 32; i++) {
638
+ const s = document.createElement('span');
639
+ s.className = 'db-star';
640
+ s.style.cssText = `left:${Math.random()*100}%;top:${Math.random()*100}%;`
641
+ + `width:${2+Math.random()*3}px;height:${2+Math.random()*3}px;`
642
+ + `animation-delay:${Math.random()*6}s;animation-duration:${2+Math.random()*3}s;`;
643
+ stWrap.appendChild(s);
644
  }
645
+ document.body.prepend(stWrap);
646
  }
647
  """
648
 
649
 
650
+ def create_layout(load_sample_fn=None, create_book_fn=None, create_bedtime_fn=None):
651
+ """Build the DoodleBook Gradio Blocks layout with Storybook + Bedtime Voice tabs."""
652
 
 
 
 
653
  _gr_major = int(gr.__version__.split(".")[0])
654
  design_kwargs = dict(
655
  css=CSS,
656
  head=HEAD,
657
+ js=COMBINED_JS,
658
+ theme=gr.themes.Base(),
659
  )
660
  blocks_kwargs = dict(title="DoodleBook")
661
  if _gr_major < 6:
662
  blocks_kwargs.update(design_kwargs)
663
 
664
  with gr.Blocks(**blocks_kwargs) as demo:
 
665
  gr.HTML(SVG_DEFS)
666
 
667
+ # ── HEADER (shared) ───────────────────────────────────────────────
668
+ gr.HTML("""
669
+ <div class="app-header">
670
+ <h1 class="app-title">D<span class="doodle-o">oo</span>dleBook</h1>
671
+ <svg class="title-squiggle" viewBox="0 0 360 16" preserveAspectRatio="none">
672
+ <path d="M2,11 C40,3 70,15 110,8 S190,2 230,9 320,14 358,5"/>
673
+ </svg>
674
+ <p class="app-subtitle">draw a character &middot; get a storybook &middot; hear it read aloud</p>
675
+ </div>
676
+ """)
677
+
678
+ # ── TABS ─────────────────────────────────────────────────────────
679
+ with gr.Tabs():
680
+
681
+ # ================================================================
682
+ # TAB 1 β€” STORYBOOK
683
+ # ================================================================
684
+ with gr.Tab("πŸ“– Storybook"):
685
+ with gr.Row(equal_height=False):
686
+ # INPUT CARD
687
+ with gr.Column(scale=1, elem_classes=["input-card"]):
688
+ gr.HTML('<p class="card-eyebrow">1 &middot; your character</p>')
689
+ doodle = gr.Image(
690
+ sources=["upload", "webcam"],
691
+ label="Upload or snap the drawing",
692
+ type="numpy", height=240,
693
+ elem_classes=["doodle-input"],
694
+ )
695
+ gr.HTML('<p class="card-eyebrow">2 &middot; the details</p>')
696
+ char_name = gr.Textbox(
697
+ label="Character name",
698
+ placeholder="Ziggy the robot",
699
+ elem_classes=["field"],
700
+ )
701
+ hero_name = gr.Textbox(
702
+ label="Hero's name in the story",
703
+ placeholder="Ziggy",
704
+ elem_classes=["field"],
705
+ )
706
+ theme = gr.Radio(
707
+ choices=THEMES, value=THEMES[0],
708
+ label="Story theme (pick one)",
709
+ elem_classes=["field", "theme-pick"],
710
+ )
711
+ voice = gr.Radio(
712
+ choices=VOICE_CHOICES, value=DEFAULT_VOICE,
713
+ label="Narrator voice",
714
+ elem_classes=["field", "theme-pick"],
715
+ )
716
+ make_coloring = gr.Checkbox(
717
+ label="Also make a coloring book",
718
+ value=False, elem_classes=["tiny-toggle"],
719
+ )
720
+ make_btn = gr.Button(
721
+ "Make my book!",
722
+ variant="primary",
723
+ elem_classes=["btn-make"],
724
+ )
725
+ status = gr.Textbox(
726
+ label="Status", interactive=False,
727
+ elem_classes=["status-display"],
728
+ value="Ready when you are! ✏️",
729
+ )
730
+ with gr.Row(elem_classes=["download-row"]):
731
+ pdf_download = gr.DownloadButton(
732
+ "⬇ Story PDF", visible=False, elem_classes=["btn-pdf"],
733
+ )
734
+ coloring_pdf_download = gr.DownloadButton(
735
+ "⬇ Coloring PDF", visible=False, elem_classes=["btn-pdf"],
736
+ )
737
+ gr.Examples(
738
+ examples=[["assets/sample_doodle.jpg", "Ziggy", "Ziggy", "brave adventure"]],
739
+ inputs=[doodle, char_name, hero_name, theme],
740
+ label="Try an example",
741
+ )
742
+
743
+ # OUTPUT CARD
744
+ with gr.Column(scale=2, elem_classes=["output-card"]):
745
+ audio_narration = gr.Audio(
746
+ label="Listen to your story",
747
+ autoplay=False,
748
+ elem_classes=["audio-player"],
749
+ )
750
+ book_display = gr.HTML(
751
+ elem_classes=["book-stage"],
752
+ value="""
753
+ <div class="book-empty">
754
+ <span class="arrow">↑</span>
755
+ <p class="big">Your storybook appears here</p>
756
+ <p>Add a drawing, pick a theme, and tap <b>Make my book!</b></p>
757
+ </div>
758
+ """,
759
+ )
760
+ coloring_display = gr.HTML(visible=False, elem_classes=["book-stage"])
761
+
762
+ # Behind the magic accordion (inside Storybook tab)
763
+ with gr.Accordion("Behind the magic ✨", open=False, elem_classes=["behind-magic"]):
764
+ with gr.Tabs():
765
+ with gr.Tab("Story"):
766
+ story_info = gr.JSON(label="Generated story structure")
767
+ with gr.Tab("Images"):
768
+ image_info = gr.Textbox(label="Illustration details", interactive=False, lines=5)
769
+ with gr.Tab("Models"):
770
+ gr.Markdown(
771
+ """
 
 
 
 
 
 
 
 
 
772
  | Model | Role | Size |
773
  |---|---|---|
774
  | **MiniCPM5-1B** | Story writer | 1B |
775
  | **VoxCPM2** | Voice narrator | 2B |
776
  | **FLUX.2-klein** | Illustrator | 4B |
777
 
778
+ The *brain* of DoodleBook β€” the story + the voice β€” is a **3B small-model stack**. FLUX is the printer. **Tiny Titan.**
779
+ """
780
+ )
781
+ with gr.Tab("Trace"):
782
+ _tb_kwargs = dict(label="Generation trace (Open Trace)", interactive=False, lines=8)
783
+ if _gr_major < 6:
784
+ _tb_kwargs["show_copy_button"] = True
785
+ trace_info = gr.Textbox(**_tb_kwargs)
786
+
787
+ # ================================================================
788
+ # TAB 2 β€” BEDTIME VOICE
789
+ # ================================================================
790
+ with gr.Tab("πŸŒ™ Bedtime Voice"):
791
+ gr.HTML("""
792
+ <div style="text-align:center;padding:20px 0 4px;">
793
+ <div class="moon-orb"></div>
794
+ <p style="font-family:'Caveat',cursive;font-size:22px;color:#9090c0;margin-top:4px;">
795
+ record your voice &middot; get a bedtime story narrated in it &middot; English + Kannada
796
+ </p>
797
+ </div>
798
+ """)
799
+
800
+ with gr.Row(equal_height=False):
801
+ # BEDTIME INPUT CARD
802
+ with gr.Column(scale=1, elem_classes=["bedtime-input-card"]):
803
+ gr.HTML('<p class="card-eyebrow-moon">1 &middot; tonight\'s adventure</p>')
804
+ bedtime_genre = gr.Radio(
805
+ choices=BEDTIME_GENRES, value=BEDTIME_GENRES[0],
806
+ label="Genre",
807
+ elem_classes=["field-night", "theme-pick-night"],
808
+ )
809
+ gr.HTML('<p class="card-eyebrow-moon" style="margin-top:14px">2 &middot; the mood</p>')
810
+ bedtime_mood = gr.Radio(
811
+ choices=BEDTIME_MOODS, value=BEDTIME_MOODS[0],
812
+ label="Mood",
813
+ elem_classes=["field-night", "theme-pick-night"],
814
+ )
815
+ gr.HTML('<p class="card-eyebrow-moon" style="margin-top:14px">3 &middot; the hero</p>')
816
+ bedtime_hero = gr.Textbox(
817
+ label="Hero's name (optional)",
818
+ placeholder="Finn, Lily, little one…",
819
+ elem_classes=["field-night"],
820
+ )
821
+ gr.HTML('<p class="card-eyebrow-moon" style="margin-top:14px">4 &middot; your voice</p>')
822
+ bedtime_voice = gr.Audio(
823
+ sources=["microphone", "upload"],
824
+ type="filepath",
825
+ label="Record or upload 5–60 s of clear speech",
826
+ elem_classes=["bedtime-audio"],
827
+ )
828
+ bedtime_btn = gr.Button(
829
+ "Tuck in with a story πŸŒ™",
830
+ variant="primary",
831
+ elem_classes=["btn-bedtime"],
832
+ )
833
+ bedtime_status = gr.Textbox(
834
+ label="Status", interactive=False,
835
+ elem_classes=["bedtime-status"],
836
+ value="Ready for bedtime… πŸŒ™",
837
+ )
838
+
839
+ # BEDTIME OUTPUT CARD
840
+ with gr.Column(scale=2, elem_classes=["bedtime-output-card"]):
841
+ bedtime_en_audio = gr.Audio(
842
+ label="πŸŒ™ English narration",
843
+ autoplay=False,
844
+ elem_classes=["audio-player-night"],
845
+ )
846
+ bedtime_kn_audio = gr.Audio(
847
+ label="πŸŒ™ ಕನ್ನ಑ ಕΰ²₯ೆ (Kannada story)",
848
+ autoplay=False,
849
+ elem_classes=["audio-player-night"],
850
+ )
851
+ bedtime_display = gr.HTML(
852
+ value="""
853
+ <div class="bedtime-empty">
854
+ <span class="moon-icon">πŸŒ™</span>
855
+ <p class="big">Your bedtime story appears here</p>
856
+ <p style="color:#9090c0">Record your voice, pick a genre, and tap <b>Tuck in with a story πŸŒ™</b></p>
857
+ </div>
858
+ """,
859
+ )
860
+
861
+ # ── FOOTER (shared) ────────────────────���──────────────────────────
862
+ gr.HTML("""
863
+ <div class="app-footer">
864
+ <p>stitched together with crayons &amp; code for the Build Small Hackathon 2026</p>
865
+ <p class="badges">Well-Tuned &middot; Off-Brand &middot; Field Notes &middot; Open Trace</p>
866
+ </div>
867
+ """)
868
+
869
+ # ── WIRING ───────────────────────────────────────────────────────
870
  if create_book_fn:
871
  make_btn.click(
872
  fn=create_book_fn,
 
877
  )
878
  if load_sample_fn:
879
  demo.load(fn=load_sample_fn, outputs=[book_display])
880
+ if create_bedtime_fn:
881
+ bedtime_btn.click(
882
+ fn=create_bedtime_fn,
883
+ inputs=[bedtime_voice, bedtime_hero, bedtime_genre, bedtime_mood],
884
+ outputs=[bedtime_display, bedtime_status, bedtime_en_audio, bedtime_kn_audio],
885
+ )
886
 
 
887
  demo.design_kwargs = design_kwargs if _gr_major >= 6 else {}
888
  return demo