| """ |
| 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__) |
|
|
|
|
| |
| |
| |
|
|
| 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" |
|
|
|
|
| |
| |
| |
|
|
| @dataclass(frozen=True) |
| 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 |
| |
| @property |
| 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) |
| |
| @property |
| 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 |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| 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", |
| 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: 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_VOICE: str = "kid" |
|
|
| |
| 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"] |
|
|
|
|
| |
| |
| |
|
|
| BASE_SEED: int = 42 |
|
|
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class GenerationParams: |
| """ |
| Generation parameters for image and story creation. |
| |
| These are the "knobs" that control quality vs speed tradeoffs. |
| """ |
| |
| |
| image_width: int = 768 |
| image_height: int = 512 |
| num_inference_steps: int = 20 |
| num_inference_steps_tiny: int = 4 |
| guidance_scale: float = 3.5 |
| |
| |
| lora_scale: float = 0.85 |
| lora_repo: str = "build-small-hackathon/doodlebook-flux-lora" |
| |
| |
| max_story_tokens: int = 800 |
| story_temperature: float = 0.7 |
| story_top_p: float = 0.9 |
| |
| |
| tts_sample_rate: int = 48000 |
| tts_speed: float = 1.0 |
| |
| |
| num_pages: int = 6 |
| target_age: int = 5 |
|
|
|
|
| GENERATION_PARAMS = GenerationParams() |
|
|
|
|
| |
| |
| |
|
|
| COLORS = { |
| "paper": "#FEF9E7", |
| "page": "#FFFDE7", |
| "ink": "#3E2723", |
| "title_brown": "#5D4037", |
| "crayon_orange": "#FF7043", |
| "sky_accent": "#4FC3F7", |
| "muted": "#BCAAA4", |
| } |
|
|
|
|
| |
| |
| |
|
|
| MODAL_CONFIG = { |
| "image_gen": { |
| "app_name": "doodlebook-image-gen", |
| "gpu": FLUX_MODEL.modal_gpu, |
| "memory": FLUX_MODEL.modal_memory, |
| "timeout": 300, |
| "keep_warm": 1, |
| }, |
| "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_PATH = "assets/sample_book" |
| SAMPLE_DOODLE_PATH = "assets/sample_doodle.jpg" |
|
|
|
|
| |
| |
| |
|
|
| 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) |
| """ |
| |
| |
| return model |
|
|
|
|
| |
| |
| |
|
|
| 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") |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| if os.environ.get("DOODLEBOOK_ENV") == "production": |
| startup_check() |
|
|