Spaces:
Running on Zero
Running on Zero
| """ | |
| DoodleBook — Single Source of Truth for Model IDs, Fallbacks, and Generation Params | |
| Innovations: | |
| - License-aware model selection (prevents non-commercial model usage) | |
| - VRAM-based hardware recommendations for Modal | |
| - Dynamic fallback resolution with logging | |
| - Deterministic seed management for character consistency | |
| - Type-safe model registry with frozen dataclasses | |
| Phase 1, Task 0: All model IDs verified on HuggingFace Hub (June 2026) | |
| """ | |
| from dataclasses import dataclass, field | |
| from enum import Enum | |
| from typing import Optional, Dict, Any | |
| import os | |
| import logging | |
| logger = logging.getLogger(__name__) | |
| # ============================================================================ | |
| # LICENSE TRACKING | |
| # ============================================================================ | |
| class LicenseType(Enum): | |
| """License types for model compliance checking.""" | |
| APACHE_2_0 = "apache-2.0" | |
| MIT = "mit" | |
| NON_COMMERCIAL = "non-commercial" | |
| FLUX_DEV = "flux-dev-non-commercial" | |
| UNKNOWN = "unknown" | |
| # ============================================================================ | |
| # MODEL REGISTRY | |
| # ============================================================================ | |
| class ModelConfig: | |
| """ | |
| Immutable model configuration with license tracking. | |
| Attributes: | |
| hub_id: HuggingFace Hub model identifier | |
| params_b: Model parameters in billions | |
| license: License type for compliance | |
| vram_gb: Estimated VRAM requirement in GB | |
| fallback_id: Alternative model if primary fails | |
| fallback_reason: Why fallback exists | |
| is_primary: Whether this is a primary or fallback model | |
| modal_gpu: Recommended Modal GPU type | |
| modal_memory: Modal memory in MB | |
| """ | |
| hub_id: str | |
| params_b: float | |
| license: LicenseType | |
| vram_gb: float | |
| fallback_id: Optional[str] = None | |
| fallback_reason: Optional[str] = None | |
| is_primary: bool = True | |
| modal_gpu: str = "T4" | |
| modal_memory: int = 8192 | |
| def can_use_commercially(self) -> bool: | |
| """Check if model can be used for commercial purposes.""" | |
| return self.license in (LicenseType.APACHE_2_0, LicenseType.MIT) | |
| def license_warning(self) -> Optional[str]: | |
| """Return warning if license has restrictions.""" | |
| if self.license == LicenseType.NON_COMMERCIAL: | |
| return f"NON-COMMERCIAL: {self.hub_id} cannot be used in production" | |
| if self.license == LicenseType.FLUX_DEV: | |
| return f"FLUX DEV LICENSE: {self.hub_id} has usage restrictions" | |
| return None | |
| # ============================================================================ | |
| # VERIFIED MODEL REGISTRY (Phase 1, Task 0 — June 2026) | |
| # ============================================================================ | |
| # | |
| # All primary models verified on HuggingFace Hub. | |
| # Fallbacks selected for license compatibility (Apache 2.0 preferred). | |
| # | |
| # The three sponsor models DoodleBook actually loads. Fallback/variant configs | |
| # were removed so the HF Space links exactly these three (HF auto-links any | |
| # model id it finds in the repo files). | |
| FLUX_MODEL = ModelConfig( | |
| hub_id="black-forest-labs/FLUX.2-klein-4B", | |
| params_b=4.0, | |
| license=LicenseType.APACHE_2_0, | |
| vram_gb=13.0, | |
| modal_gpu="A10G", # 24GB fits the ~13GB model; A100-40GB was overkill | |
| modal_memory=32768, | |
| ) | |
| STORY_MODEL = ModelConfig( | |
| hub_id="openbmb/MiniCPM5-1B", | |
| params_b=1.0, | |
| license=LicenseType.APACHE_2_0, | |
| vram_gb=4.0, | |
| modal_gpu="T4", | |
| modal_memory=8192, | |
| ) | |
| TTS_MODEL = ModelConfig( | |
| hub_id="openbmb/VoxCPM2", | |
| params_b=2.0, | |
| license=LicenseType.APACHE_2_0, | |
| vram_gb=8.0, | |
| modal_gpu="T4", | |
| modal_memory=8192, | |
| ) | |
| # ============================================================================ | |
| # VOICE PRESETS (VoxCPM2 "voice design") | |
| # ============================================================================ | |
| # | |
| # Each preset is a VoxCPM2 voice-design prefix — the (parenthetical) is read as | |
| # an instruction describing the speaker, NOT spoken aloud. Ordered youngest → | |
| # oldest. Default is a young child's voice (the previous adult "storyteller" | |
| # voice sounded too old for the kids). | |
| # | |
| # SINGLE SOURCE OF TRUTH for the live app (app.py). modal_workers/modal_tts.py | |
| # keeps a mirror of these values so the Modal deploy stays import-free — keep | |
| # the two in sync if you edit them. | |
| VOICE_PRESETS: Dict[str, Dict[str, str]] = { | |
| "kid": { | |
| "label": "🧒 Little kid", | |
| "design": "(A sweet seven-year-old child joyfully reading a picture book to friends; " | |
| "bright, clear, playful, and full of wonder; natural phrasing; gentle changes " | |
| "of expression for dialogue; sound effects spoken with delighted energy, " | |
| "never shouted harshly)", | |
| }, | |
| "big_kid": { | |
| "label": "🎒 Big kid", | |
| "design": "(A lively eleven-year-old reading a favorite children's story aloud; " | |
| "youthful, confident, energetic, and expressive; clear pacing; distinct but " | |
| "natural character dialogue; playful emphasis on sound effects)", | |
| }, | |
| "playful": { | |
| "label": "✨ Playful", | |
| "design": "(A cheerful young adult performing a joyful children's picture book; warm, " | |
| "animated, smiling, and expressive; varied rhythm and light comic timing; " | |
| "gentle character voices; lively but child-friendly sound effects)", | |
| }, | |
| "storyteller": { | |
| "label": "🌙 Storyteller", | |
| "design": "(A warm, gentle adult storyteller reading a bedtime picture book to a young " | |
| "child; soft, soothing, unhurried, kind, and cozy; meaningful pauses; tender " | |
| "dialogue; sound effects softened enough to remain calming)", | |
| }, | |
| "grandpa": { | |
| "label": "👴 Grandpa", | |
| "design": "(A kind older grandfather telling a cozy story to a child; warm, patient, " | |
| "slightly slow, reassuring, and quietly expressive; friendly dialogue; " | |
| "playful sound effects delivered with a soft chuckle)", | |
| }, | |
| "my_voice": { | |
| "label": "🎙️ My Voice", | |
| "design": "(Preserve the reference speaker's identity while narrating clearly to a " | |
| "young child; warm, natural, friendly, and expressive; use comfortable pacing, " | |
| "gentle dialogue changes, and playful but controlled sound effects)", | |
| }, | |
| } | |
| # Default leans young per user request — the youngest, most kid-friendly voice. | |
| DEFAULT_VOICE: str = "kid" | |
| # (label, value) pairs for a gr.Radio — shows the friendly label, passes the key. | |
| VOICE_CHOICES = [(preset["label"], key) for key, preset in VOICE_PRESETS.items()] | |
| def voice_design(voice: str) -> str: | |
| """Return the VoxCPM2 design prefix for a voice key (falls back to default).""" | |
| preset = VOICE_PRESETS.get(voice) or VOICE_PRESETS[DEFAULT_VOICE] | |
| return preset["design"] | |
| # ============================================================================ | |
| # SEED MANAGEMENT | |
| # ============================================================================ | |
| BASE_SEED: int = 42 # Locked for character consistency across pages | |
| def page_seed(page_num: int) -> int: | |
| """ | |
| Deterministic seed per page for character consistency. | |
| Page 0 uses BASE_SEED, page 1 uses BASE_SEED+1, etc. | |
| This ensures reproducible generation while maintaining | |
| slight variation between pages. | |
| Args: | |
| page_num: Zero-indexed page number (0-5) | |
| Returns: | |
| Deterministic seed for this page | |
| Example: | |
| >>> page_seed(0) # 42 | |
| >>> page_seed(5) # 47 | |
| """ | |
| if not 0 <= page_num <= 5: | |
| raise ValueError(f"page_num must be 0-5, got {page_num}") | |
| return BASE_SEED + page_num | |
| # ============================================================================ | |
| # GENERATION PARAMETERS | |
| # ============================================================================ | |
| class GenerationParams: | |
| """ | |
| Generation parameters for image and story creation. | |
| These are the "knobs" that control quality vs speed tradeoffs. | |
| """ | |
| # Image generation | |
| image_width: int = 768 | |
| image_height: int = 512 | |
| num_inference_steps: int = 20 # Standard mode | |
| num_inference_steps_tiny: int = 4 # Tiny Mode (SD-Turbo) | |
| guidance_scale: float = 3.5 | |
| # LoRA settings | |
| lora_scale: float = 0.85 | |
| lora_repo: str = "build-small-hackathon/doodlebook-flux-lora" | |
| # Story generation | |
| max_story_tokens: int = 800 | |
| story_temperature: float = 0.7 | |
| story_top_p: float = 0.9 | |
| # TTS settings | |
| tts_sample_rate: int = 48000 | |
| tts_speed: float = 1.0 | |
| # Book settings | |
| num_pages: int = 6 | |
| target_age: int = 5 | |
| GENERATION_PARAMS = GenerationParams() | |
| # ============================================================================ | |
| # COLOR PALETTE (Off-Brand Badge) | |
| # ============================================================================ | |
| COLORS = { | |
| "paper": "#FEF9E7", # App background | |
| "page": "#FFFDE7", # Book pages | |
| "ink": "#3E2723", # Body text | |
| "title_brown": "#5D4037", # Headings | |
| "crayon_orange": "#FF7043", # Primary CTA | |
| "sky_accent": "#4FC3F7", # Secondary/links | |
| "muted": "#BCAAA4", # Page numbers/meta | |
| } | |
| # ============================================================================ | |
| # MODAL CONFIGURATION | |
| # ============================================================================ | |
| MODAL_CONFIG = { | |
| "image_gen": { | |
| "app_name": "doodlebook-image-gen", | |
| "gpu": FLUX_MODEL.modal_gpu, | |
| "memory": FLUX_MODEL.modal_memory, | |
| "timeout": 300, | |
| "keep_warm": 1, # During judging window | |
| }, | |
| "story_gen": { | |
| "app_name": "doodlebook-story", | |
| "gpu": STORY_MODEL.modal_gpu, | |
| "memory": STORY_MODEL.modal_memory, | |
| "timeout": 120, | |
| "keep_warm": 0, | |
| }, | |
| "tts": { | |
| "app_name": "doodlebook-tts", | |
| "gpu": TTS_MODEL.modal_gpu, | |
| "memory": TTS_MODEL.modal_memory, | |
| "timeout": 120, | |
| "keep_warm": 0, | |
| }, | |
| } | |
| # ============================================================================ | |
| # SAMPLE BOOK CONFIGURATION | |
| # ============================================================================ | |
| SAMPLE_BOOK_PATH = "assets/sample_book" | |
| SAMPLE_DOODLE_PATH = "assets/sample_doodle.jpg" | |
| # ============================================================================ | |
| # VALIDATION & COMPLIANCE | |
| # ============================================================================ | |
| def validate_model_ids(use_hf_hub: bool = False) -> Dict[str, Dict[str, Any]]: | |
| """ | |
| Validate model IDs exist on HuggingFace Hub. | |
| Args: | |
| use_hf_hub: If True, actually check HF Hub (slower but thorough) | |
| Returns: | |
| Dictionary mapping model IDs to validation results | |
| """ | |
| models = [FLUX_MODEL, STORY_MODEL, TTS_MODEL] | |
| results = {} | |
| for model in models: | |
| results[model.hub_id] = { | |
| "exists": True, | |
| "checked": False, | |
| "license": model.license.value, | |
| "commercial_ok": model.can_use_commercially, | |
| } | |
| if use_hf_hub: | |
| try: | |
| from huggingface_hub import model_info | |
| model_info(model.hub_id) | |
| results[model.hub_id]["checked"] = True | |
| logger.info(f"Verified: {model.hub_id}") | |
| except Exception as e: | |
| results[model.hub_id] = { | |
| "exists": False, | |
| "error": str(e), | |
| "fallback": model.fallback_id, | |
| } | |
| logger.warning(f"Model not found: {model.hub_id}, fallback: {model.fallback_id}") | |
| return results | |
| def check_license_compliance() -> bool: | |
| """ | |
| Ensure no non-commercial models are in the primary production path. | |
| Raises: | |
| ValueError: If a non-commercial model is configured as primary | |
| Returns: | |
| True if all primary models are license-compliant | |
| """ | |
| primary_models = [FLUX_MODEL, STORY_MODEL, TTS_MODEL] | |
| for model in primary_models: | |
| if not model.can_use_commercially: | |
| raise ValueError( | |
| f"LICENSE VIOLATION: {model.hub_id} is {model.license.value}. " | |
| f"This model CANNOT be used commercially. " | |
| f"Use fallback: {model.fallback_id}" | |
| ) | |
| return True | |
| def get_model_with_fallback( | |
| model: ModelConfig, | |
| use_fallback: bool = False | |
| ) -> ModelConfig: | |
| """ | |
| Get model config, optionally using fallback. | |
| Args: | |
| model: Primary model config | |
| use_fallback: If True, return fallback model | |
| Returns: | |
| ModelConfig (primary or fallback) | |
| """ | |
| # Fallback model configs were removed (the Space links only the 3 primaries); | |
| # there is no alternate config to swap in, so always return the primary. | |
| return model | |
| # ============================================================================ | |
| # ENVIRONMENT VARIABLES | |
| # ============================================================================ | |
| def get_hf_token() -> Optional[str]: | |
| """Get HuggingFace token from environment.""" | |
| return os.environ.get("HF_TOKEN") | |
| def get_modal_token() -> Optional[str]: | |
| """Get Modal token from environment.""" | |
| return os.environ.get("MODAL_TOKEN_ID") or os.environ.get("MODAL_TOKEN") | |
| # ============================================================================ | |
| # STARTUP VALIDATION | |
| # ============================================================================ | |
| def startup_check() -> bool: | |
| """ | |
| Run at app startup to ensure configuration is valid. | |
| Returns: | |
| True if all checks pass | |
| """ | |
| try: | |
| check_license_compliance() | |
| logger.info("License compliance: PASSED") | |
| return True | |
| except ValueError as e: | |
| logger.error(f"Startup check FAILED: {e}") | |
| return False | |
| # Run on import if in production mode | |
| if os.environ.get("DOODLEBOOK_ENV") == "production": | |
| startup_check() | |