Codex commited on
Commit
0babffc
·
1 Parent(s): 18d8424

Restore Codex zerogpu-space-copy: model caching, theme templates, parallel TTS, heartbeat

Browse files
Files changed (2) hide show
  1. app.py +726 -1
  2. book_builder.py +0 -23
app.py CHANGED
@@ -1 +1,726 @@
1
- """DoodleBook ΓÇö HF ZeroGPU VersionFree T4 GPU on Hugging Face Spaces!No Modal needed."""import gradio as grimport osimport sysimport torchtry: import spacesexcept ModuleNotFoundError: # `spaces` only exists on HF ZeroGPU. Off-HF (local/dev) provide a no-op so # the app still runs; generation then uses whatever local GPU/CPU exists. class _SpacesShim: @staticmethod def GPU(*args, **kwargs): if args and callable(args[0]): # bare @spaces.GPU return args[0] def deco(fn): # @spaces.GPU(duration=...) return fn return deco spaces = _SpacesShim()import jsonimport timeimport tempfileimport loggingimport structimport resys.path.insert(0, os.path.dirname(__file__))from config import ( FLUX_MODEL, STORY_MODEL, TTS_MODEL, GENERATION_PARAMS, SAMPLE_BOOK_PATH, BASE_SEED, page_seed, DEFAULT_VOICE, voice_design,)from book_builder import ( build_book_html, export_pdf, magic_loader_html, build_coloring_html, export_coloring_pdf,)from ui.layout import create_layoutlogging.basicConfig(level=logging.INFO)logger = logging.getLogger(__name__)_STORY_MODEL = None_STORY_TOKENIZER = None_IMAGE_PIPE = None_IMAGE_PIPE_KIND = None_TTS_MODEL = NoneCOLOR_ART_STYLE = ( "children's crayon storybook illustration, bold black outlines, " "flat bright colors, simple shapes")COLOR_PAGE_SUFFIX = "full colorful background scene, the character clearly visible."LINE_ART_STYLE = ( "children's coloring book page, pure black ink outlines on pure white paper, " "clean contour lines, no color, no gray, no shading, no texture, " "no hatching, no pencil marks, open spaces to color")LINE_ART_SUFFIX = ( "simple clean background shapes, same composition, thick readable outlines, " "no filled black areas, no extra sketch marks.")THEME_TEMPLATES = { "brave adventure": [ ("{hero} loved exploring new places.", "{hero} standing at the start of a bright adventure trail"), ("One morning, {hero} discovered something glowing nearby.", "{hero} spotting a magical glow in the distance"), ("Taking a deep breath, {hero} bravely went closer.", "{hero} walking forward with courage"), ("There, a new friend needed help.", "{hero} finding a small friend in trouble"), ("{hero} helped with kindness and a clever idea.", "{hero} helping the friend together"), ("Everyone cheered, and {hero} felt proud and brave.", "{hero} celebrating at sunset with the new friend"), ], "making a new friend": [ ("{hero} was playing alone in a sunny place.", "{hero} playing under a bright sky"), ("Then {hero} noticed someone shy nearby.", "{hero} seeing a shy new friend nearby"), ("{hero} smiled and said hello.", "{hero} waving with a friendly smile"), ("Soon they were sharing stories and laughs.", "{hero} and the new friend laughing together"), ("They played games all afternoon.", "{hero} and the new friend playing together"), ("By sunset, {hero} had made a wonderful new friend.", "{hero} and the new friend smiling together at sunset"), ],}FEW_SHOT_EXEMPLAR = """Write a 6-page children's storybook for age 5 about Luna the cat with theme: brave adventure.Return ONLY valid JSON:{ "title": "Luna's Brave Adventure", "character_description": "A small orange tabby cat named Luna with big green eyes, whiskers, and a tiny red scarf", "pages": [ {"page": 1, "text": "Luna was a small orange cat who loved to explore.", "scene": "Luna sitting by the window looking outside"}, {"page": 2, "text": "One sunny morning, Luna saw something sparkling in the forest.", "scene": "Luna spotting a glow in the trees"}, {"page": 3, "text": "Bravely, Luna crept into the forest to investigate.", "scene": "Luna walking cautiously through trees"}, {"page": 4, "text": "It was a tiny fairy stuck in a spider web!", "scene": "Luna discovering a fairy in trouble"}, {"page": 5, "text": "Luna gently freed the fairy with her paw.", "scene": "Luna carefully helping the fairy"}, {"page": 6, "text": "The fairy thanked Luna and they became friends forever.", "scene": "Luna and fairy playing together at sunset"} ]}"""def build_story_prompt(hero_name: str, theme: str, age: int) -> str: return f"""{FEW_SHOT_EXEMPLAR}Write a 6-page children's storybook for age {age} about {hero_name} with theme: {theme}.Return ONLY valid JSON:"""def _validate_story_structure(story: dict) -> bool: required_keys = ["title", "character_description", "pages"] if not all(k in story for k in required_keys): return False pages = story.get("pages", []) if not isinstance(pages, list) or len(pages) < 1: return False first_page = pages[0] return all(k in first_page for k in ["page", "text", "scene"])def _repair_json(json_str: str) -> str: json_str = re.sub(r',\s*([}\]])', r'\1', json_str) json_str = re.sub(r'//.*?$', '', json_str, flags=re.MULTILINE) json_str = re.sub(r'/\*[\s\S]*?\*/', '', json_str) json_str = re.sub(r'(?<=")\n(?=")', '\\n', json_str) json_str = re.sub(r'(\s)(\w+)(\s*:)', r'\1"\2"\3', json_str) return json_strdef parse_story_json(raw_output: str) -> dict | None: match = re.search(r'\{[\s\S]*\}', raw_output or "") if not match: return None raw_json = match.group(0) for candidate in (raw_json, _repair_json(raw_json)): try: story = json.loads(candidate) if _validate_story_structure(story): return story except Exception: continue return Nonedef _normalize_story(story: dict) -> dict: pages = list(story.get("pages", []))[:6] while len(pages) < 6: pages.append({ "page": len(pages) + 1, "text": "And the adventure continued happily.", "scene": "Continuing adventure", }) story["pages"] = pages story.setdefault("title", "A Wonderful Adventure") story.setdefault( "character_description", "A friendly children's storybook hero with bright colors and cheerful features", ) return storydef build_story_locally(hero_name: str, theme: str) -> dict: """Fast, deterministic fallback story that avoids any Modal dependency.""" hero = (hero_name or "Little Hero").strip() or "Little Hero" beats = THEME_TEMPLATES.get(theme, THEME_TEMPLATES["brave adventure"]) pages = [ {"page": i + 1, "text": text.format(hero=hero), "scene": scene.format(hero=hero)} for i, (text, scene) in enumerate(beats) ] return { "title": f"{hero}'s Storybook Adventure", "character_description": ( f"{hero}, a friendly children's storybook hero with bright colors, " "bold outlines, and a cheerful expressive face" ), "pages": pages, }def silent_wav_bytes(duration_seconds: int = 2, sample_rate: int = 24000) -> bytes: """Return a short silent WAV so the UI remains stable if TTS is unavailable.""" num_samples = sample_rate * duration_seconds data_size = num_samples * 2 header = struct.pack( "<4sI4s4sIHHIIHH4sI", b"RIFF", 36 + data_size, b"WAVE", b"fmt ", 16, 1, 1, sample_rate, sample_rate * 2, 2, 16, b"data", data_size, ) return header + (b"\x00" * data_size)def _with_heartbeat(blocking_fn, frame_fn, poll=4.0): import threading box = {} def _run(): try: box["val"] = blocking_fn() except BaseException as e: box["err"] = e th = threading.Thread(target=_run, daemon=True) th.start() t0 = time.time() while th.is_alive(): th.join(timeout=poll) if th.is_alive(): yield ("hb", frame_fn(int(time.time() - t0))) if "err" in box: raise box["err"] yield ("done", box["val"])# ============================================================================# SAMPLE BOOK (loads instantly, no GPU needed)# ============================================================================SAMPLE_BOOK_HTML = Nonedef load_sample_book() -> str: """Load pre-generated sample book (C3: always ship sample).""" global SAMPLE_BOOK_HTML if SAMPLE_BOOK_HTML: return SAMPLE_BOOK_HTML sample_path = os.path.join(SAMPLE_BOOK_PATH, "sample.html") if os.path.exists(sample_path): with open(sample_path, "r", encoding="utf-8") as f: SAMPLE_BOOK_HTML = f.read() return SAMPLE_BOOK_HTML return "<div class='page-loading'>Loading sample book...</div>"# ============================================================================# ZEROGPU INFERENCE FUNCTIONS# ============================================================================@spaces.GPU(duration=60)def generate_story_gpu(hero_name: str, theme: str, age: int = 5) -> dict: """Generate a story on ZeroGPU, falling back to a deterministic local story.""" global _STORY_MODEL, _STORY_TOKENIZER try: from transformers import AutoTokenizer, AutoModelForCausalLM if _STORY_MODEL is None or _STORY_TOKENIZER is None: logger.info(f"Loading story model: {STORY_MODEL.hub_id}") _STORY_TOKENIZER = AutoTokenizer.from_pretrained(STORY_MODEL.hub_id, trust_remote_code=True) _STORY_MODEL = AutoModelForCausalLM.from_pretrained( STORY_MODEL.hub_id, torch_dtype=torch.float16, trust_remote_code=True, ).cuda().eval() prompt = build_story_prompt(hero_name, theme, age) inputs = _STORY_TOKENIZER.apply_chat_template( [{"role": "user", "content": prompt}], add_generation_prompt=True, enable_thinking=False, return_dict=True, return_tensors="pt", ).to("cuda") with torch.no_grad(): out = _STORY_MODEL.generate( **inputs, max_new_tokens=GENERATION_PARAMS.max_story_tokens, do_sample=False, ) response = _STORY_TOKENIZER.decode( out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True, ) parsed = parse_story_json(response) if parsed: return _normalize_story(parsed) logger.warning("Story parser failed; using deterministic local fallback") except Exception as e: logger.warning(f"ZeroGPU story generation failed: {e}") return _normalize_story(build_story_locally(hero_name, theme))def _get_image_pipe(tiny: bool): global _IMAGE_PIPE, _IMAGE_PIPE_KIND desired = "tiny" if tiny else "flux" if _IMAGE_PIPE is not None and _IMAGE_PIPE_KIND == desired: return _IMAGE_PIPE if tiny: from diffusers import AutoPipelineForText2Image pipe = AutoPipelineForText2Image.from_pretrained( "stabilityai/sd-turbo", torch_dtype=torch.float16, ).cuda() else: from diffusers import Flux2KleinPipeline pipe = Flux2KleinPipeline.from_pretrained( FLUX_MODEL.hub_id, torch_dtype=torch.bfloat16, ).cuda() pipe.enable_model_cpu_offload() _IMAGE_PIPE = pipe _IMAGE_PIPE_KIND = desired return pipe@spaces.GPU(duration=120)def generate_images_gpu( character_desc: str, scenes: list, doodle_bytes: bytes = None, seed: int = 42, tiny: bool = False) -> list: """Generate all 6 images using FLUX on ZeroGPU.""" import io from PIL import Image pipe = _get_image_pipe(tiny) if tiny: num_steps = 4 guidance = 0.0 else: num_steps = 6 guidance = 1.0 canonical = None if doodle_bytes: try: ref = Image.open(io.BytesIO(doodle_bytes)).convert("RGB") kw = dict( prompt=(f"Turn this child's drawing into a clean, friendly, full-body cartoon " f"character for a children's storybook. Keep the EXACT same creature, " f"face, and features as the drawing. {COLOR_ART_STYLE}, " f"plain white background, full character visible, centered."), height=768, width=768, guidance_scale=guidance, num_inference_steps=num_steps, generator=torch.Generator("cuda").manual_seed(seed) ) if tiny: kw["prompt"] = f"A friendly cartoon character, {COLOR_ART_STYLE}" else: kw["image"] = ref canonical = pipe(**kw).images[0] logger.info("Canonical character built from doodle") except Exception as e: logger.warning(f"Canonical build failed ({e}); text2img fallback") canonical = None images = [] for i, scene in enumerate(scenes): if canonical is not None and not tiny: prompt = f"The same character. {scene}. {COLOR_ART_STYLE}, {COLOR_PAGE_SUFFIX}" kw = dict(image=canonical, prompt=prompt) else: prompt = ( f"{character_desc}. Scene: {scene}. {COLOR_ART_STYLE}, " f"white background, centered, full character visible" ) kw = dict(prompt=prompt) kw.update(dict( height=768, width=768, guidance_scale=guidance, num_inference_steps=num_steps, generator=torch.Generator("cuda").manual_seed(seed + i + 1) )) image = pipe(**kw).images[0] images.append(image) logger.info(f"Generated page {i+1}/6") return images@spaces.GPU(duration=120)def generate_coloring_images_gpu( character_desc: str, scenes: list, doodle_bytes: bytes = None, seed: int = 42, tiny: bool = False) -> list: """Generate coloring pages directly with FLUX instead of tracing color pages.""" import io from PIL import Image pipe = _get_image_pipe(tiny) if tiny: num_steps = 4 guidance = 0.0 else: num_steps = 6 guidance = 1.0 canonical = None if doodle_bytes: try: ref = Image.open(io.BytesIO(doodle_bytes)).convert("RGB") kw = dict( prompt=(f"Turn this child's drawing into a clean, friendly, full-body cartoon " f"character for a children's coloring book. Keep the EXACT same creature, " f"face, and features as the drawing. {LINE_ART_STYLE}, " f"plain white background, full character visible, centered."), height=768, width=768, guidance_scale=guidance, num_inference_steps=num_steps, generator=torch.Generator('cuda').manual_seed(seed) ) if tiny: kw["prompt"] = f"A friendly cartoon character, {LINE_ART_STYLE}" else: kw["image"] = ref canonical = pipe(**kw).images[0] logger.info("Line-art canonical character built from doodle") except Exception as e: logger.warning(f"Line-art canonical build failed ({e}); text2img fallback") canonical = None images = [] for i, scene in enumerate(scenes): if canonical is not None and not tiny: prompt = f"The same character. {scene}. {LINE_ART_STYLE}, {LINE_ART_SUFFIX}" kw = dict(image=canonical, prompt=prompt) else: prompt = ( f"{character_desc}. Scene: {scene}. {LINE_ART_STYLE}, " f"white background, centered, full character visible" ) kw = dict(prompt=prompt) kw.update(dict( height=768, width=768, guidance_scale=guidance, num_inference_steps=num_steps, generator=torch.Generator("cuda").manual_seed(seed + i + 101) )) image = pipe(**kw).images[0] images.append(image) logger.info(f"Generated coloring page {i+1}/6") return images@spaces.GPU(duration=30)def generate_tts_gpu(text: str, voice: str = DEFAULT_VOICE) -> bytes: """Generate TTS when available; otherwise return a tiny silent WAV.""" global _TTS_MODEL import io import numpy as np try: from voxcpm import VoxCPM if _TTS_MODEL is None: logger.info(f"Loading TTS model: {TTS_MODEL.hub_id}") _TTS_MODEL = VoxCPM.from_pretrained(TTS_MODEL.hub_id, load_denoiser=False) model = _TTS_MODEL design = voice_design(voice) import re chunks = [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()] if not chunks: chunks = [text.strip() or "The end."] sr = model.tts_model.sample_rate pause = np.zeros(int(sr * 0.35), dtype=np.float32) pieces = [] for i, sentence in enumerate(chunks): wav = model.generate( text=f"{design} {sentence}", cfg_value=2.0, inference_timesteps=10, ) pieces.append(np.asarray(wav, dtype=np.float32)) if i < len(chunks) - 1: pieces.append(pause) audio = np.concatenate(pieces) import soundfile as sf buf = io.BytesIO() sf.write(buf, audio, sr, format="WAV") return buf.getvalue() except Exception as e: logger.warning(f"TTS unavailable on Space ({e}); returning silent fallback") return silent_wav_bytes()# ============================================================================# MAIN BOOK CREATION (Generator for streaming)# ============================================================================def create_book(doodle_image, character_name, theme, hero_name, tiny_mode=False, voice=DEFAULT_VOICE, make_coloring=False): """ZeroGPU copy of the local app flow with heartbeats, timing, and coloring support.""" t_total = time.perf_counter() character_name = (character_name or "").strip() or "Little Hero" hero_name = (hero_name or "").strip() or character_name trace_data = { "backend": "zerogpu", "hero_name": hero_name, "theme": theme, "tiny_mode": tiny_mode, "voice": voice, "make_coloring": make_coloring, "seed": BASE_SEED, "timestamp": time.strftime("%Y-%m-%d %H:%M:%S") } _no = gr.update(visible=False) _keep = gr.update() yield ( magic_loader_html("story", hero_name), "Writing the storyΓǪ", None, _keep, {}, "", json.dumps(trace_data, indent=2), _no, _keep, ) t_story = time.perf_counter() try: story = generate_story_gpu(hero_name, theme) except Exception as e: logger.error(f"Story generation failed: {e}") yield ( f"<div class='page-loading'>Error: {e}</div>", f"Error: {e}", None, _keep, {}, "", "", _no, _keep, ) return trace_data["story_sec"] = round(time.perf_counter() - t_story, 2) pages = story.get("pages", []) char_desc = story.get("character_description", "") title = story.get("title", "Untitled Story") page_texts = [p.get("text", "") for p in pages] scenes = [p.get("scene", "") for p in pages] trace_data["title"] = title trace_data["character_description"] = char_desc yield ( magic_loader_html("images", hero_name), f"{title} ΓÇö illustrating on ZeroGPUΓǪ", None, _keep, story, "", json.dumps(trace_data, indent=2), _no, _keep, ) doodle_bytes = None if doodle_image is not None: import io from PIL import Image img = Image.fromarray(doodle_image) buf = io.BytesIO() img.save(buf, format="PNG") doodle_bytes = buf.getvalue() import threading voice_box = {} full_text = f"{title}. {' '.join(page_texts)}" t_tts = time.perf_counter() def _do_voice(): try: voice_box["bytes"] = generate_tts_gpu(full_text, voice) except Exception as e: voice_box["err"] = e voice_thread = threading.Thread(target=_do_voice, daemon=True) voice_thread.start() img_bytes, engine = None, "sketch" t_images = time.perf_counter() try: for kind, payload in _with_heartbeat( lambda: generate_images_gpu(char_desc, scenes, doodle_bytes, BASE_SEED, tiny_mode), lambda s: ( magic_loader_html("images", hero_name), f"{title} ΓÇö illustratingΓǪ {s}s (voice recording in parallel)", None, _keep, story, "", json.dumps(trace_data, indent=2), _no, _keep, ), ): if kind == "hb": yield payload else: images = payload import io img_bytes = [] for img in images: buf = io.BytesIO() img.save(buf, format="PNG") img_bytes.append(buf.getvalue()) engine = "flux" except Exception as e: logger.error(f"Image generation failed: {e}") from services.images import generate_placeholder_images img_bytes = generate_placeholder_images(char_desc, scenes, doodle_bytes) engine = "sketch" trace_data["images_sec"] = round(time.perf_counter() - t_images, 2) trace_data["engine"] = engine book_html = build_book_html(img_bytes, page_texts, title, engine) while voice_thread.is_alive(): voice_thread.join(timeout=4) if voice_thread.is_alive(): yield ( book_html, f"{title} ΓÇö finishing narrationΓǪ", None, _keep, story, "", json.dumps(trace_data, indent=2), _no, _keep, ) audio_path = None trace_data["tts_sec"] = round(time.perf_counter() - t_tts, 2) if voice_box.get("bytes"): try: with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: tmp.write(voice_box["bytes"]) audio_path = tmp.name except Exception as e: logger.warning(f"writing audio failed: {e}") elif "err" in voice_box: logger.warning(f"TTS failed: {voice_box['err']}") pdf_path = None t_pdf = time.perf_counter() try: with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: pdf_path = export_pdf(img_bytes, page_texts, title, tmp.name) except Exception as e: logger.warning(f"PDF failed: {e}") trace_data["pdf_sec"] = round(time.perf_counter() - t_pdf, 2) coloring_html = "" coloring_pdf_path = None if make_coloring: t_coloring = time.perf_counter() try: from services.coloring import _crispen for kind, payload in _with_heartbeat( lambda: generate_coloring_images_gpu(char_desc, scenes, doodle_bytes, BASE_SEED, tiny_mode), lambda s: ( book_html, f"{title} ΓÇö building coloring bookΓǪ {s}s", audio_path, _keep, story, "", json.dumps(trace_data, indent=2), _no, _keep, ), ): if kind == "hb": yield payload else: coloring_images = payload import io outlines = [] for img in coloring_images: buf = io.BytesIO() img.save(buf, format="PNG") outlines.append(_crispen(buf.getvalue())) coloring_html = build_coloring_html(outlines, page_texts, title) with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: coloring_pdf_path = export_coloring_pdf(outlines, page_texts, title, tmp.name) trace_data["coloring_book"] = True trace_data["coloring_engine"] = "flux-direct-lineart" except Exception as e: logger.warning(f"Direct FLUX coloring book failed ({e}); using traced fallback") try: from services.coloring import derive_coloring_pages outlines = derive_coloring_pages(img_bytes) coloring_html = build_coloring_html(outlines, page_texts, title) with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: coloring_pdf_path = export_coloring_pdf(outlines, page_texts, title, tmp.name) trace_data["coloring_book"] = True trace_data["coloring_engine"] = "trace-fallback" except Exception as e2: logger.warning(f"Coloring book fallback failed: {e2}") trace_data["coloring_sec"] = round(time.perf_counter() - t_coloring, 2) trace_data["completed"] = True trace_data["pages_generated"] = len(img_bytes) trace_data["total_sec"] = round(time.perf_counter() - t_total, 2) pdf_update = gr.update(value=pdf_path) if pdf_path else _keep coloring_pdf_update = gr.update(value=coloring_pdf_path) if coloring_pdf_path else _keep coloring_display_update = (gr.update(visible=True, value=coloring_html) if coloring_html else _no) yield ( book_html, f"Complete: {title} ΓÇö {len(img_bytes)} pages ┬╖ {'FLUX (ZeroGPU)' if engine == 'flux' else 'local sketch fallback'} ┬╖ voice: {voice} ┬╖ total {trace_data['total_sec']}s", audio_path, pdf_update, story, f"Pages: {len(img_bytes)} | Seed: {BASE_SEED} | Mode: {'Tiny' if tiny_mode else 'Standard'} | Engine: {engine} | Story {trace_data.get('story_sec', 0)}s | Images {trace_data.get('images_sec', 0)}s | PDF {trace_data.get('pdf_sec', 0)}s | Coloring {trace_data.get('coloring_sec', 0)}s", json.dumps(trace_data, indent=2), coloring_display_update, coloring_pdf_update, )# ============================================================================# MAIN# ============================================================================if __name__ == "__main__": demo = create_layout( load_sample_fn=load_sample_book, create_book_fn=create_book, ) demo.queue(default_concurrency_limit=2, max_size=8) demo.launch(share=False, allowed_paths=[tempfile.gettempdir()])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DoodleBook — HF ZeroGPU Version
3
+
4
+ Free T4 GPU on Hugging Face Spaces!
5
+ No Modal needed.
6
+ """
7
+
8
+ import gradio as gr
9
+ import os
10
+ import sys
11
+ import torch
12
+ try:
13
+ import spaces
14
+ except ModuleNotFoundError:
15
+ # `spaces` only exists on HF ZeroGPU. Off-HF (local/dev) provide a no-op so
16
+ # the app still runs; generation then uses whatever local GPU/CPU exists.
17
+ class _SpacesShim:
18
+ @staticmethod
19
+ def GPU(*args, **kwargs):
20
+ if args and callable(args[0]): # bare @spaces.GPU
21
+ return args[0]
22
+ def deco(fn): # @spaces.GPU(duration=...)
23
+ return fn
24
+ return deco
25
+ spaces = _SpacesShim()
26
+ import json
27
+ import time
28
+ import tempfile
29
+ import logging
30
+ import struct
31
+ import re
32
+
33
+ sys.path.insert(0, os.path.dirname(__file__))
34
+
35
+ 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,
42
+ build_coloring_html, export_coloring_pdf,
43
+ )
44
+ from ui.layout import create_layout
45
+
46
+ logging.basicConfig(level=logging.INFO)
47
+ logger = logging.getLogger(__name__)
48
+
49
+ _STORY_MODEL = None
50
+ _STORY_TOKENIZER = None
51
+ _IMAGE_PIPE = None
52
+ _IMAGE_PIPE_KIND = None
53
+ _TTS_MODEL = None
54
+
55
+ COLOR_ART_STYLE = (
56
+ "children's crayon storybook illustration, bold black outlines, "
57
+ "flat bright colors, simple shapes"
58
+ )
59
+ COLOR_PAGE_SUFFIX = "full colorful background scene, the character clearly visible."
60
+ LINE_ART_STYLE = (
61
+ "children's coloring book page, pure black ink outlines on pure white paper, "
62
+ "clean contour lines, no color, no gray, no shading, no texture, "
63
+ "no hatching, no pencil marks, open spaces to color"
64
+ )
65
+ LINE_ART_SUFFIX = (
66
+ "simple clean background shapes, same composition, thick readable outlines, "
67
+ "no filled black areas, no extra sketch marks."
68
+ )
69
+
70
+ THEME_TEMPLATES = {
71
+ "brave adventure": [
72
+ ("{hero} loved exploring new places.", "{hero} standing at the start of a bright adventure trail"),
73
+ ("One morning, {hero} discovered something glowing nearby.", "{hero} spotting a magical glow in the distance"),
74
+ ("Taking a deep breath, {hero} bravely went closer.", "{hero} walking forward with courage"),
75
+ ("There, a new friend needed help.", "{hero} finding a small friend in trouble"),
76
+ ("{hero} helped with kindness and a clever idea.", "{hero} helping the friend together"),
77
+ ("Everyone cheered, and {hero} felt proud and brave.", "{hero} celebrating at sunset with the new friend"),
78
+ ],
79
+ "making a new friend": [
80
+ ("{hero} was playing alone in a sunny place.", "{hero} playing under a bright sky"),
81
+ ("Then {hero} noticed someone shy nearby.", "{hero} seeing a shy new friend nearby"),
82
+ ("{hero} smiled and said hello.", "{hero} waving with a friendly smile"),
83
+ ("Soon they were sharing stories and laughs.", "{hero} and the new friend laughing together"),
84
+ ("They played games all afternoon.", "{hero} and the new friend playing together"),
85
+ ("By sunset, {hero} had made a wonderful new friend.", "{hero} and the new friend smiling together at sunset"),
86
+ ],
87
+ }
88
+
89
+ FEW_SHOT_EXEMPLAR = """
90
+ Write a 6-page children's storybook for age 5 about Luna the cat with theme: brave adventure.
91
+
92
+ Return ONLY valid JSON:
93
+ {
94
+ "title": "Luna's Brave Adventure",
95
+ "character_description": "A small orange tabby cat named Luna with big green eyes, whiskers, and a tiny red scarf",
96
+ "pages": [
97
+ {"page": 1, "text": "Luna was a small orange cat who loved to explore.", "scene": "Luna sitting by the window looking outside"},
98
+ {"page": 2, "text": "One sunny morning, Luna saw something sparkling in the forest.", "scene": "Luna spotting a glow in the trees"},
99
+ {"page": 3, "text": "Bravely, Luna crept into the forest to investigate.", "scene": "Luna walking cautiously through trees"},
100
+ {"page": 4, "text": "It was a tiny fairy stuck in a spider web!", "scene": "Luna discovering a fairy in trouble"},
101
+ {"page": 5, "text": "Luna gently freed the fairy with her paw.", "scene": "Luna carefully helping the fairy"},
102
+ {"page": 6, "text": "The fairy thanked Luna and they became friends forever.", "scene": "Luna and fairy playing together at sunset"}
103
+ ]
104
+ }
105
+ """
106
+
107
+
108
+ def build_story_prompt(hero_name: str, theme: str, age: int) -> str:
109
+ return f"""{FEW_SHOT_EXEMPLAR}
110
+
111
+ Write a 6-page children's storybook for age {age} about {hero_name} with theme: {theme}.
112
+
113
+ Return ONLY valid JSON:
114
+ """
115
+
116
+
117
+ def _validate_story_structure(story: dict) -> bool:
118
+ required_keys = ["title", "character_description", "pages"]
119
+ if not all(k in story for k in required_keys):
120
+ return False
121
+ pages = story.get("pages", [])
122
+ if not isinstance(pages, list) or len(pages) < 1:
123
+ return False
124
+ first_page = pages[0]
125
+ return all(k in first_page for k in ["page", "text", "scene"])
126
+
127
+
128
+ def _repair_json(json_str: str) -> str:
129
+ json_str = re.sub(r',\s*([}\]])', r'\1', json_str)
130
+ json_str = re.sub(r'//.*?$', '', json_str, flags=re.MULTILINE)
131
+ json_str = re.sub(r'/\*[\s\S]*?\*/', '', json_str)
132
+ json_str = re.sub(r'(?<=")\n(?=")', '\\n', json_str)
133
+ json_str = re.sub(r'(\s)(\w+)(\s*:)', r'\1"\2"\3', json_str)
134
+ return json_str
135
+
136
+
137
+ def parse_story_json(raw_output: str) -> dict | None:
138
+ match = re.search(r'\{[\s\S]*\}', raw_output or "")
139
+ if not match:
140
+ return None
141
+ raw_json = match.group(0)
142
+ for candidate in (raw_json, _repair_json(raw_json)):
143
+ try:
144
+ story = json.loads(candidate)
145
+ if _validate_story_structure(story):
146
+ return story
147
+ except Exception:
148
+ continue
149
+ return None
150
+
151
+
152
+ def _normalize_story(story: dict) -> dict:
153
+ pages = list(story.get("pages", []))[:6]
154
+ while len(pages) < 6:
155
+ pages.append({
156
+ "page": len(pages) + 1,
157
+ "text": "And the adventure continued happily.",
158
+ "scene": "Continuing adventure",
159
+ })
160
+ story["pages"] = pages
161
+ story.setdefault("title", "A Wonderful Adventure")
162
+ story.setdefault(
163
+ "character_description",
164
+ "A friendly children's storybook hero with bright colors and cheerful features",
165
+ )
166
+ return story
167
+
168
+
169
+ def build_story_locally(hero_name: str, theme: str) -> dict:
170
+ """Fast, deterministic fallback story that avoids any Modal dependency."""
171
+ hero = (hero_name or "Little Hero").strip() or "Little Hero"
172
+ beats = THEME_TEMPLATES.get(theme, THEME_TEMPLATES["brave adventure"])
173
+ pages = [
174
+ {"page": i + 1, "text": text.format(hero=hero), "scene": scene.format(hero=hero)}
175
+ for i, (text, scene) in enumerate(beats)
176
+ ]
177
+ return {
178
+ "title": f"{hero}'s Storybook Adventure",
179
+ "character_description": (
180
+ f"{hero}, a friendly children's storybook hero with bright colors, "
181
+ "bold outlines, and a cheerful expressive face"
182
+ ),
183
+ "pages": pages,
184
+ }
185
+
186
+
187
+ def silent_wav_bytes(duration_seconds: int = 2, sample_rate: int = 24000) -> bytes:
188
+ """Return a short silent WAV so the UI remains stable if TTS is unavailable."""
189
+ num_samples = sample_rate * duration_seconds
190
+ data_size = num_samples * 2
191
+ header = struct.pack(
192
+ "<4sI4s4sIHHIIHH4sI",
193
+ b"RIFF", 36 + data_size, b"WAVE",
194
+ b"fmt ", 16, 1, 1, sample_rate, sample_rate * 2, 2, 16,
195
+ b"data", data_size,
196
+ )
197
+ return header + (b"\x00" * data_size)
198
+
199
+
200
+ def _with_heartbeat(blocking_fn, frame_fn, poll=4.0):
201
+ import threading
202
+
203
+ box = {}
204
+
205
+ def _run():
206
+ try:
207
+ box["val"] = blocking_fn()
208
+ except BaseException as e:
209
+ box["err"] = e
210
+
211
+ th = threading.Thread(target=_run, daemon=True)
212
+ th.start()
213
+ t0 = time.time()
214
+ while th.is_alive():
215
+ th.join(timeout=poll)
216
+ if th.is_alive():
217
+ yield ("hb", frame_fn(int(time.time() - t0)))
218
+ if "err" in box:
219
+ raise box["err"]
220
+ yield ("done", box["val"])
221
+
222
+
223
+ # ============================================================================
224
+ # SAMPLE BOOK (loads instantly, no GPU needed)
225
+ # ============================================================================
226
+
227
+ SAMPLE_BOOK_HTML = None
228
+
229
+ def load_sample_book() -> str:
230
+ """Load pre-generated sample book (C3: always ship sample)."""
231
+ global SAMPLE_BOOK_HTML
232
+ if SAMPLE_BOOK_HTML:
233
+ return SAMPLE_BOOK_HTML
234
+
235
+ sample_path = os.path.join(SAMPLE_BOOK_PATH, "sample.html")
236
+ if os.path.exists(sample_path):
237
+ with open(sample_path, "r", encoding="utf-8") as f:
238
+ SAMPLE_BOOK_HTML = f.read()
239
+ return SAMPLE_BOOK_HTML
240
+
241
+ return "<div class='page-loading'>Loading sample book...</div>"
242
+
243
+
244
+ # ============================================================================
245
+ # ZEROGPU INFERENCE FUNCTIONS
246
+ # ============================================================================
247
+
248
+ @spaces.GPU(duration=60)
249
+ def generate_story_gpu(hero_name: str, theme: str, age: int = 5) -> dict:
250
+ """Generate a story on ZeroGPU, falling back to a deterministic local story."""
251
+ global _STORY_MODEL, _STORY_TOKENIZER
252
+ try:
253
+ from transformers import AutoTokenizer, AutoModelForCausalLM
254
+
255
+ if _STORY_MODEL is None or _STORY_TOKENIZER is None:
256
+ logger.info(f"Loading story model: {STORY_MODEL.hub_id}")
257
+ _STORY_TOKENIZER = AutoTokenizer.from_pretrained(STORY_MODEL.hub_id, trust_remote_code=True)
258
+ _STORY_MODEL = AutoModelForCausalLM.from_pretrained(
259
+ STORY_MODEL.hub_id,
260
+ torch_dtype=torch.float16,
261
+ trust_remote_code=True,
262
+ ).cuda().eval()
263
+
264
+ prompt = build_story_prompt(hero_name, theme, age)
265
+ inputs = _STORY_TOKENIZER.apply_chat_template(
266
+ [{"role": "user", "content": prompt}],
267
+ add_generation_prompt=True,
268
+ enable_thinking=False,
269
+ return_dict=True,
270
+ return_tensors="pt",
271
+ ).to("cuda")
272
+ with torch.no_grad():
273
+ out = _STORY_MODEL.generate(
274
+ **inputs,
275
+ max_new_tokens=GENERATION_PARAMS.max_story_tokens,
276
+ do_sample=False,
277
+ )
278
+ response = _STORY_TOKENIZER.decode(
279
+ out[0][inputs["input_ids"].shape[1]:],
280
+ skip_special_tokens=True,
281
+ )
282
+ parsed = parse_story_json(response)
283
+ if parsed:
284
+ return _normalize_story(parsed)
285
+ logger.warning("Story parser failed; using deterministic local fallback")
286
+ except Exception as e:
287
+ logger.warning(f"ZeroGPU story generation failed: {e}")
288
+ return _normalize_story(build_story_locally(hero_name, theme))
289
+
290
+
291
+ def _get_image_pipe(tiny: bool):
292
+ global _IMAGE_PIPE, _IMAGE_PIPE_KIND
293
+ desired = "tiny" if tiny else "flux"
294
+ if _IMAGE_PIPE is not None and _IMAGE_PIPE_KIND == desired:
295
+ return _IMAGE_PIPE
296
+
297
+ if tiny:
298
+ from diffusers import AutoPipelineForText2Image
299
+ pipe = AutoPipelineForText2Image.from_pretrained(
300
+ "stabilityai/sd-turbo",
301
+ torch_dtype=torch.float16,
302
+ ).cuda()
303
+ else:
304
+ from diffusers import Flux2KleinPipeline
305
+ pipe = Flux2KleinPipeline.from_pretrained(
306
+ FLUX_MODEL.hub_id,
307
+ torch_dtype=torch.bfloat16,
308
+ ).cuda()
309
+ pipe.enable_model_cpu_offload()
310
+
311
+ _IMAGE_PIPE = pipe
312
+ _IMAGE_PIPE_KIND = desired
313
+ return pipe
314
+
315
+
316
+ @spaces.GPU(duration=120)
317
+ def generate_images_gpu(
318
+ character_desc: str,
319
+ scenes: list,
320
+ doodle_bytes: bytes = None,
321
+ seed: int = 42,
322
+ tiny: bool = False
323
+ ) -> list:
324
+ """Generate all 6 images using FLUX on ZeroGPU."""
325
+ import io
326
+ from PIL import Image
327
+
328
+ pipe = _get_image_pipe(tiny)
329
+ if tiny:
330
+ num_steps = 4
331
+ guidance = 0.0
332
+ else:
333
+ num_steps = 6
334
+ guidance = 1.0
335
+
336
+ canonical = None
337
+ if doodle_bytes:
338
+ try:
339
+ ref = Image.open(io.BytesIO(doodle_bytes)).convert("RGB")
340
+ kw = dict(
341
+ prompt=(f"Turn this child's drawing into a clean, friendly, full-body cartoon "
342
+ f"character for a children's storybook. Keep the EXACT same creature, "
343
+ f"face, and features as the drawing. {COLOR_ART_STYLE}, "
344
+ f"plain white background, full character visible, centered."),
345
+ height=768, width=768, guidance_scale=guidance,
346
+ num_inference_steps=num_steps,
347
+ generator=torch.Generator("cuda").manual_seed(seed)
348
+ )
349
+ if tiny:
350
+ kw["prompt"] = f"A friendly cartoon character, {COLOR_ART_STYLE}"
351
+ else:
352
+ kw["image"] = ref
353
+ canonical = pipe(**kw).images[0]
354
+ logger.info("Canonical character built from doodle")
355
+ except Exception as e:
356
+ logger.warning(f"Canonical build failed ({e}); text2img fallback")
357
+ canonical = None
358
+
359
+ images = []
360
+ for i, scene in enumerate(scenes):
361
+ if canonical is not None and not tiny:
362
+ prompt = f"The same character. {scene}. {COLOR_ART_STYLE}, {COLOR_PAGE_SUFFIX}"
363
+ kw = dict(image=canonical, prompt=prompt)
364
+ else:
365
+ prompt = (
366
+ f"{character_desc}. Scene: {scene}. {COLOR_ART_STYLE}, "
367
+ f"white background, centered, full character visible"
368
+ )
369
+ kw = dict(prompt=prompt)
370
+
371
+ kw.update(dict(
372
+ height=768, width=768, guidance_scale=guidance,
373
+ num_inference_steps=num_steps,
374
+ generator=torch.Generator("cuda").manual_seed(seed + i + 1)
375
+ ))
376
+
377
+ image = pipe(**kw).images[0]
378
+ images.append(image)
379
+ logger.info(f"Generated page {i+1}/6")
380
+
381
+ return images
382
+
383
+
384
+ @spaces.GPU(duration=120)
385
+ def generate_coloring_images_gpu(
386
+ character_desc: str,
387
+ scenes: list,
388
+ doodle_bytes: bytes = None,
389
+ seed: int = 42,
390
+ tiny: bool = False
391
+ ) -> list:
392
+ """Generate coloring pages directly with FLUX instead of tracing color pages."""
393
+ import io
394
+ from PIL import Image
395
+
396
+ pipe = _get_image_pipe(tiny)
397
+ if tiny:
398
+ num_steps = 4
399
+ guidance = 0.0
400
+ else:
401
+ num_steps = 6
402
+ guidance = 1.0
403
+
404
+ canonical = None
405
+ if doodle_bytes:
406
+ try:
407
+ ref = Image.open(io.BytesIO(doodle_bytes)).convert("RGB")
408
+ kw = dict(
409
+ prompt=(f"Turn this child's drawing into a clean, friendly, full-body cartoon "
410
+ f"character for a children's coloring book. Keep the EXACT same creature, "
411
+ f"face, and features as the drawing. {LINE_ART_STYLE}, "
412
+ f"plain white background, full character visible, centered."),
413
+ height=768, width=768, guidance_scale=guidance,
414
+ num_inference_steps=num_steps,
415
+ generator=torch.Generator('cuda').manual_seed(seed)
416
+ )
417
+ if tiny:
418
+ kw["prompt"] = f"A friendly cartoon character, {LINE_ART_STYLE}"
419
+ else:
420
+ kw["image"] = ref
421
+ canonical = pipe(**kw).images[0]
422
+ logger.info("Line-art canonical character built from doodle")
423
+ except Exception as e:
424
+ logger.warning(f"Line-art canonical build failed ({e}); text2img fallback")
425
+ canonical = None
426
+
427
+ images = []
428
+ for i, scene in enumerate(scenes):
429
+ if canonical is not None and not tiny:
430
+ prompt = f"The same character. {scene}. {LINE_ART_STYLE}, {LINE_ART_SUFFIX}"
431
+ kw = dict(image=canonical, prompt=prompt)
432
+ else:
433
+ prompt = (
434
+ f"{character_desc}. Scene: {scene}. {LINE_ART_STYLE}, "
435
+ f"white background, centered, full character visible"
436
+ )
437
+ kw = dict(prompt=prompt)
438
+
439
+ kw.update(dict(
440
+ height=768, width=768, guidance_scale=guidance,
441
+ num_inference_steps=num_steps,
442
+ generator=torch.Generator("cuda").manual_seed(seed + i + 101)
443
+ ))
444
+
445
+ image = pipe(**kw).images[0]
446
+ images.append(image)
447
+ logger.info(f"Generated coloring page {i+1}/6")
448
+
449
+ return images
450
+
451
+
452
+ @spaces.GPU(duration=30)
453
+ def generate_tts_gpu(text: str, voice: str = DEFAULT_VOICE) -> bytes:
454
+ """Generate TTS when available; otherwise return a tiny silent WAV."""
455
+ global _TTS_MODEL
456
+ import io
457
+ import numpy as np
458
+
459
+ try:
460
+ from voxcpm import VoxCPM
461
+ if _TTS_MODEL is None:
462
+ logger.info(f"Loading TTS model: {TTS_MODEL.hub_id}")
463
+ _TTS_MODEL = VoxCPM.from_pretrained(TTS_MODEL.hub_id, load_denoiser=False)
464
+ model = _TTS_MODEL
465
+
466
+ design = voice_design(voice)
467
+
468
+ import re
469
+ chunks = [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()]
470
+ if not chunks:
471
+ chunks = [text.strip() or "The end."]
472
+
473
+ sr = model.tts_model.sample_rate
474
+ pause = np.zeros(int(sr * 0.35), dtype=np.float32)
475
+ pieces = []
476
+
477
+ for i, sentence in enumerate(chunks):
478
+ wav = model.generate(
479
+ text=f"{design} {sentence}",
480
+ cfg_value=2.0,
481
+ inference_timesteps=10,
482
+ )
483
+ pieces.append(np.asarray(wav, dtype=np.float32))
484
+ if i < len(chunks) - 1:
485
+ pieces.append(pause)
486
+
487
+ audio = np.concatenate(pieces)
488
+ import soundfile as sf
489
+ buf = io.BytesIO()
490
+ sf.write(buf, audio, sr, format="WAV")
491
+ return buf.getvalue()
492
+
493
+ except Exception as e:
494
+ logger.warning(f"TTS unavailable on Space ({e}); returning silent fallback")
495
+ return silent_wav_bytes()
496
+
497
+
498
+ # ============================================================================
499
+ # MAIN BOOK CREATION (Generator for streaming)
500
+ # ============================================================================
501
+
502
+ def create_book(doodle_image, character_name, theme, hero_name, tiny_mode=False, voice=DEFAULT_VOICE, make_coloring=False):
503
+ """ZeroGPU copy of the local app flow with heartbeats, timing, and coloring support."""
504
+ t_total = time.perf_counter()
505
+ character_name = (character_name or "").strip() or "Little Hero"
506
+ hero_name = (hero_name or "").strip() or character_name
507
+
508
+ trace_data = {
509
+ "backend": "zerogpu",
510
+ "hero_name": hero_name,
511
+ "theme": theme,
512
+ "tiny_mode": tiny_mode,
513
+ "voice": voice,
514
+ "make_coloring": make_coloring,
515
+ "seed": BASE_SEED,
516
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
517
+ }
518
+
519
+ _no = gr.update(visible=False)
520
+ _keep = gr.update()
521
+
522
+ yield (
523
+ magic_loader_html("story", hero_name),
524
+ "Writing the story…",
525
+ None, _keep, {}, "", json.dumps(trace_data, indent=2),
526
+ _no, _keep,
527
+ )
528
+
529
+ t_story = time.perf_counter()
530
+ try:
531
+ story = generate_story_gpu(hero_name, theme)
532
+ except Exception as e:
533
+ logger.error(f"Story generation failed: {e}")
534
+ yield (
535
+ f"<div class='page-loading'>Error: {e}</div>",
536
+ f"Error: {e}",
537
+ None, _keep, {}, "", "",
538
+ _no, _keep,
539
+ )
540
+ return
541
+ trace_data["story_sec"] = round(time.perf_counter() - t_story, 2)
542
+
543
+ pages = story.get("pages", [])
544
+ char_desc = story.get("character_description", "")
545
+ title = story.get("title", "Untitled Story")
546
+ page_texts = [p.get("text", "") for p in pages]
547
+ scenes = [p.get("scene", "") for p in pages]
548
+
549
+ trace_data["title"] = title
550
+ trace_data["character_description"] = char_desc
551
+
552
+ yield (
553
+ magic_loader_html("images", hero_name),
554
+ f"{title} — illustrating on ZeroGPU…",
555
+ None, _keep, story, "", json.dumps(trace_data, indent=2),
556
+ _no, _keep,
557
+ )
558
+
559
+ doodle_bytes = None
560
+ if doodle_image is not None:
561
+ import io
562
+ from PIL import Image
563
+ img = Image.fromarray(doodle_image)
564
+ buf = io.BytesIO()
565
+ img.save(buf, format="PNG")
566
+ doodle_bytes = buf.getvalue()
567
+
568
+ import threading
569
+ voice_box = {}
570
+ full_text = f"{title}. {' '.join(page_texts)}"
571
+ t_tts = time.perf_counter()
572
+
573
+ def _do_voice():
574
+ try:
575
+ voice_box["bytes"] = generate_tts_gpu(full_text, voice)
576
+ except Exception as e:
577
+ voice_box["err"] = e
578
+
579
+ voice_thread = threading.Thread(target=_do_voice, daemon=True)
580
+ voice_thread.start()
581
+
582
+ img_bytes, engine = None, "sketch"
583
+ t_images = time.perf_counter()
584
+ try:
585
+ for kind, payload in _with_heartbeat(
586
+ lambda: generate_images_gpu(char_desc, scenes, doodle_bytes, BASE_SEED, tiny_mode),
587
+ lambda s: (
588
+ magic_loader_html("images", hero_name),
589
+ f"{title} — illustrating… {s}s (voice recording in parallel)",
590
+ None, _keep, story, "", json.dumps(trace_data, indent=2), _no, _keep,
591
+ ),
592
+ ):
593
+ if kind == "hb":
594
+ yield payload
595
+ else:
596
+ images = payload
597
+ import io
598
+ img_bytes = []
599
+ for img in images:
600
+ buf = io.BytesIO()
601
+ img.save(buf, format="PNG")
602
+ img_bytes.append(buf.getvalue())
603
+ engine = "flux"
604
+ except Exception as e:
605
+ logger.error(f"Image generation failed: {e}")
606
+ from services.images import generate_placeholder_images
607
+ img_bytes = generate_placeholder_images(char_desc, scenes, doodle_bytes)
608
+ engine = "sketch"
609
+ trace_data["images_sec"] = round(time.perf_counter() - t_images, 2)
610
+ trace_data["engine"] = engine
611
+
612
+ book_html = build_book_html(img_bytes, page_texts, title, engine)
613
+
614
+ while voice_thread.is_alive():
615
+ voice_thread.join(timeout=4)
616
+ if voice_thread.is_alive():
617
+ yield (
618
+ book_html,
619
+ f"{title} — finishing narration…",
620
+ None, _keep, story, "", json.dumps(trace_data, indent=2),
621
+ _no, _keep,
622
+ )
623
+
624
+ audio_path = None
625
+ trace_data["tts_sec"] = round(time.perf_counter() - t_tts, 2)
626
+ if voice_box.get("bytes"):
627
+ try:
628
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
629
+ tmp.write(voice_box["bytes"])
630
+ audio_path = tmp.name
631
+ except Exception as e:
632
+ logger.warning(f"writing audio failed: {e}")
633
+ elif "err" in voice_box:
634
+ logger.warning(f"TTS failed: {voice_box['err']}")
635
+
636
+ pdf_path = None
637
+ t_pdf = time.perf_counter()
638
+ try:
639
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
640
+ pdf_path = export_pdf(img_bytes, page_texts, title, tmp.name)
641
+ except Exception as e:
642
+ logger.warning(f"PDF failed: {e}")
643
+ trace_data["pdf_sec"] = round(time.perf_counter() - t_pdf, 2)
644
+
645
+ coloring_html = ""
646
+ coloring_pdf_path = None
647
+ if make_coloring:
648
+ t_coloring = time.perf_counter()
649
+ try:
650
+ from services.coloring import _crispen
651
+ for kind, payload in _with_heartbeat(
652
+ lambda: generate_coloring_images_gpu(char_desc, scenes, doodle_bytes, BASE_SEED, tiny_mode),
653
+ lambda s: (
654
+ book_html,
655
+ f"{title} — building coloring book… {s}s",
656
+ audio_path,
657
+ _keep,
658
+ story,
659
+ "",
660
+ json.dumps(trace_data, indent=2),
661
+ _no,
662
+ _keep,
663
+ ),
664
+ ):
665
+ if kind == "hb":
666
+ yield payload
667
+ else:
668
+ coloring_images = payload
669
+ import io
670
+ outlines = []
671
+ for img in coloring_images:
672
+ buf = io.BytesIO()
673
+ img.save(buf, format="PNG")
674
+ outlines.append(_crispen(buf.getvalue()))
675
+ coloring_html = build_coloring_html(outlines, page_texts, title)
676
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
677
+ coloring_pdf_path = export_coloring_pdf(outlines, page_texts, title, tmp.name)
678
+ trace_data["coloring_book"] = True
679
+ trace_data["coloring_engine"] = "flux-direct-lineart"
680
+ except Exception as e:
681
+ logger.warning(f"Direct FLUX coloring book failed ({e}); using traced fallback")
682
+ try:
683
+ from services.coloring import derive_coloring_pages
684
+ outlines = derive_coloring_pages(img_bytes)
685
+ coloring_html = build_coloring_html(outlines, page_texts, title)
686
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
687
+ coloring_pdf_path = export_coloring_pdf(outlines, page_texts, title, tmp.name)
688
+ trace_data["coloring_book"] = True
689
+ trace_data["coloring_engine"] = "trace-fallback"
690
+ except Exception as e2:
691
+ logger.warning(f"Coloring book fallback failed: {e2}")
692
+ trace_data["coloring_sec"] = round(time.perf_counter() - t_coloring, 2)
693
+
694
+ trace_data["completed"] = True
695
+ trace_data["pages_generated"] = len(img_bytes)
696
+ trace_data["total_sec"] = round(time.perf_counter() - t_total, 2)
697
+
698
+ pdf_update = gr.update(value=pdf_path) if pdf_path else _keep
699
+ coloring_pdf_update = gr.update(value=coloring_pdf_path) if coloring_pdf_path else _keep
700
+ coloring_display_update = (gr.update(visible=True, value=coloring_html) if coloring_html
701
+ else _no)
702
+
703
+ yield (
704
+ book_html,
705
+ f"Complete: {title} — {len(img_bytes)} pages · {'FLUX (ZeroGPU)' if engine == 'flux' else 'local sketch fallback'} · voice: {voice} · total {trace_data['total_sec']}s",
706
+ audio_path,
707
+ pdf_update,
708
+ story,
709
+ f"Pages: {len(img_bytes)} | Seed: {BASE_SEED} | Mode: {'Tiny' if tiny_mode else 'Standard'} | Engine: {engine} | Story {trace_data.get('story_sec', 0)}s | Images {trace_data.get('images_sec', 0)}s | PDF {trace_data.get('pdf_sec', 0)}s | Coloring {trace_data.get('coloring_sec', 0)}s",
710
+ json.dumps(trace_data, indent=2),
711
+ coloring_display_update,
712
+ coloring_pdf_update,
713
+ )
714
+
715
+
716
+ # ============================================================================
717
+ # MAIN
718
+ # ============================================================================
719
+
720
+ if __name__ == "__main__":
721
+ demo = create_layout(
722
+ load_sample_fn=load_sample_book,
723
+ create_book_fn=create_book,
724
+ )
725
+ demo.queue(default_concurrency_limit=2, max_size=8)
726
+ demo.launch(share=False, allowed_paths=[tempfile.gettempdir()])
book_builder.py CHANGED
@@ -26,29 +26,6 @@ FONT_GAEGU = os.path.join(FONTS_DIR, "Gaegu-Bold.ttf")
26
  FONT_CAVEAT = os.path.join(FONTS_DIR, "Caveat.ttf")
27
 
28
 
29
- def _ensure_fonts():
30
- """Download fonts from Google Fonts if not present locally."""
31
- os.makedirs(FONTS_DIR, exist_ok=True)
32
- fonts = {
33
- FONT_GAEGU: "https://github.com/google/fonts/raw/main/ofl/gaegu/Gaegu-Bold.ttf",
34
- FONT_CAVEAT: "https://github.com/google/fonts/raw/main/ofl/caveat/Caveat%5Bwght%5D.ttf",
35
- }
36
- import requests
37
- for path, url in fonts.items():
38
- if not os.path.exists(path):
39
- try:
40
- r = requests.get(url, timeout=30)
41
- r.raise_for_status()
42
- with open(path, "wb") as f:
43
- f.write(r.content)
44
- logger.info(f"Downloaded font: {os.path.basename(path)}")
45
- except Exception as e:
46
- logger.warning(f"Could not download font {os.path.basename(path)}: {e}")
47
-
48
-
49
- _ensure_fonts()
50
-
51
-
52
  # ============================================================================
53
  # STORYBOOK HTML
54
  # ============================================================================
 
26
  FONT_CAVEAT = os.path.join(FONTS_DIR, "Caveat.ttf")
27
 
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  # ============================================================================
30
  # STORYBOOK HTML
31
  # ============================================================================