import base64 import json import logging import gradio as gr from src.config import settings from src.gemini_image import GeminiImageClient from src.gemini_llm import GeminiLLMClient from src.image_generator import ImageGenerator from src.llm import LLMClient from src.stable_diffusion import DiffusionClient from src.story_generator import StoryGenerator logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) _NUM = settings.STORYBOARD_NUM_SCENES _EXAMPLES = [ ( "Một con thỏ đã thách thức một con rùa chạy đua. Trong cuộc đua, thỏ chủ quan " "và ngủ quên giữa đường còn rùa vẫn kiên trì đến cuối nên thỏ đã thua cuộc." ), ( "Cậu bé chăn cừu vì buồn chán nên đã nhiều lần nói dối dân làng rằng có sói đến. " "Đến khi sói xuất hiện thật, không ai tin lời cậu nữa và bầy cừu bị ăn thịt hết." ), ( "Hai con dê cùng đi qua một chiếc cầu hẹp từ hai phía ngược nhau. Không con nào " "chịu nhường đường cho con nào, cuối cùng cả hai đều rơi xuống suối." ), ] _INFO_NOTE_HTML = """
⚠️ Lưu ý sử dụng
""" _USAGE_MARKDOWN = """ 1. **Nhập tóm tắt truyện** bằng tiếng Việt, nêu rõ nhân vật và diễn biến chính. 2. **Tuỳ chọn:** thêm **HF Token** cá nhân nếu gặp giới hạn tốc độ. 3. Bấm **Tạo truyện tranh** và chờ hệ thống tạo 4 ô truyện. 4. Mở phần **Storyboard & Prompts** nếu muốn xem storyboard JSON và prompt chi tiết. **Mẹo:** Tóm tắt càng rõ ràng và có diễn biến cụ thể thì kết quả càng sát ý hơn. """ _llm_client: LLMClient | GeminiLLMClient | None = None _story_gen: StoryGenerator | None = None _img_gen: ImageGenerator | None = None _active_token: str = "" _active_backend: str = "" # "gemini" | "hf" _RATE_LIMIT_HINT = ( "💡 Hãy thử lại sau hoặc dán HF Token / Gemini API Key cá nhân vào rồi bấm lại." ) def _resolve_token(runtime_token: str = "") -> str: token = (runtime_token or "").strip() return token if token else settings.HF_TOKEN def _resolve_gemini_key(runtime_key: str = "") -> str: key = (runtime_key or "").strip() return key if key else settings.GEMINI_API_KEY def _is_limit_error(exc: Exception | str) -> bool: msg = str(exc).lower() keys = ( "429", "402", "rate limit", "rate-limit", "quota", "payment required", "too many requests", "credits", "pre-paid", "limit exceeded", ) return any(key in msg for key in keys) def _friendly_api_status(exc: Exception | str, stage: str) -> str: detail = str(exc)[:300] if _is_limit_error(exc): return f"⚠️ API đang bị giới hạn ở bước {stage}.\n{_RATE_LIMIT_HINT}\n\n🔍 Chi tiết: {detail}" return f"⚠️ Lỗi ở bước **{stage}**.\n\n🔍 Chi tiết lỗi: `{detail}`\n\n{_RATE_LIMIT_HINT}" def _init_pipeline(runtime_token: str = "", runtime_gemini_key: str = "") -> None: global _llm_client, _story_gen, _img_gen, _active_token, _active_backend gemini_key = _resolve_gemini_key(runtime_gemini_key) hf_token = _resolve_token(runtime_token) # Determine which backend to use backend = "gemini" if gemini_key else "hf" cache_key = gemini_key if backend == "gemini" else hf_token if _llm_client is not None and cache_key == _active_token and backend == _active_backend: return if _llm_client is not None: logger.info("Backend or token changed — reinitializing pipeline (backend=%s).", backend) if backend == "gemini": logger.info("Initializing Gemini pipeline (text=%s, image=%s)…", settings.GEMINI_TEXT_MODEL, settings.GEMINI_IMAGE_MODEL) _llm_client = GeminiLLMClient( api_key=gemini_key, model_id=settings.GEMINI_TEXT_MODEL, ) _llm_client.load_model() img_client = GeminiImageClient( api_key=gemini_key, model_id=settings.GEMINI_IMAGE_MODEL, ) img_client.load_model() else: logger.info("Initializing HuggingFace pipeline…") _llm_client = LLMClient(hf_token=hf_token, cache_dir=settings.CACHE_DIR) _llm_client.load_model() img_client = DiffusionClient( model_id=settings.IMAGE_MODEL_ID, hf_token=hf_token, provider=settings.IMAGE_PROVIDER, ) img_client.load_model() _story_gen = StoryGenerator(_llm_client) _img_gen = ImageGenerator(img_client) _active_token = cache_key _active_backend = backend logger.info("Pipeline ready (backend=%s).", backend) _STORY_PLACEHOLDER = "*Chưa chạy pipeline — kết quả storyboard sẽ hiển thị ở đây.*" def _out(panels: list, story_md: str, status: str): padded = list(panels) + [None] * (_NUM - len(panels)) story = story_md or _STORY_PLACEHOLDER status_html = f'
{status}
' return (*padded[:_NUM], story, status_html) def _img_b64(path: str) -> str: with open(path, "rb") as f: return base64.b64encode(f.read()).decode("utf-8") def _build_story_md( scenes: list[dict[str, str]], image_prompts: list[str] | None = None, ) -> str: parts: list[str] = [ "## 🧾 Storyboard JSON", "```json", json.dumps({"scenes": scenes}, ensure_ascii=False, indent=2), "```", "", "## 🎬 Parsed Scenes", ] for idx, scene in enumerate(scenes, start=1): parts.extend( [ f"### Cảnh {idx}", f"- **Caption (VI):** {scene['caption_vi']}", f"- **Prompt (EN):** {scene['prompt_en']}", "", ] ) return "\n".join(parts).strip() def inference(user_story: str, runtime_token: str = "", runtime_gemini_key: str = ""): """Generate a 4-panel comic from a Vietnamese story summary.""" if not user_story or not user_story.strip(): yield _out([], "", "⚠️ Vui lòng nhập tóm tắt truyện.") return try: _init_pipeline(runtime_token, runtime_gemini_key) except Exception as exc: logger.exception("Pipeline initialization failed.") yield _out([], "", _friendly_api_status(exc, "khởi tạo pipeline")) return yield _out([], "", "⏳ Đang tạo storyboard…") try: scenes = _story_gen.generate_storyboard( user_story=user_story, num_scenes=_NUM, max_new_tokens=settings.LLM_MAX_TOKENS, temperature=settings.LLM_TEMPERATURE, ) except ValueError as exc: logger.exception("Storyboard generation failed.") yield _out( [], f"## ⚠️ Lỗi storyboard\n```text\n{exc}\n```", "⚠️ LLM trả về dữ liệu không hợp lệ. Vui lòng thử lại với tóm tắt rõ ràng hơn.", ) return except Exception as exc: logger.exception("Unexpected error during storyboard generation.") yield _out([], "", _friendly_api_status(exc, "tạo storyboard")) return image_prompts: list[str] = [] story_md = _build_story_md(scenes) yield _out([], story_md, f"⏳ Đang tạo hình cho {_NUM} cảnh…") panels: list = [] for idx, scene in enumerate(scenes, start=1): full_prompt = f"{scene['prompt_en']} Style: {settings.COMIC_STYLE}." image_prompts.append(full_prompt) story_md = _build_story_md(scenes, image_prompts=image_prompts) yield _out(panels, story_md, f"🎨 Đang vẽ cảnh {idx}/{_NUM}…") try: panel = _img_gen.generate_image( prompt=full_prompt, paragraph=scene["caption_vi"], ) except Exception as exc: logger.exception("Image generation failed at panel %d.", idx) panels.append(None) yield _out(panels, story_md, _friendly_api_status(exc, f"vẽ cảnh {idx}/{_NUM}")) continue if panel is not None: panels.append(panel) yield _out(panels, story_md, f"✅ Xong cảnh {idx}/{_NUM}") else: panels.append(None) yield _out( panels, story_md, f"⚠️ Cảnh {idx}/{_NUM} thất bại. Có thể do rate-limit/quota API hoặc lỗi tạo ảnh. {_RATE_LIMIT_HINT}", ) ok = sum(1 for p in panels if p is not None) yield _out(panels, story_md, f"🎉 Hoàn tất! {ok}/{_NUM} panel.") _CSS = """ :root,#gradio-app{ --app-max-width:1440px; --panel-gap:4px; --r-lg:16px; --r-md:12px; --r-sm:10px; --bg-page:#f7faff; --bg-page-2:#eef4fb; --bg-card:#ffffff; --bg-card-soft:#fdfefe; --bg-note:#fff7e6; --bg-note-border:#f2d38b; --bg-status:#edf4ff; --bg-preview:#ffffff; --bg-debug:#fcfdff; --text-main:#0d0d0d; --text-sub:#0d0d0d; --text-muted:#0d0d0d; --text-note:#5a4200; --border-soft:#dde7f2; --border-mid:#cad8e8; --accent:#4c76ff; --accent-2:#6c84ff; --accent-soft:#eaf1ff; --accent-soft-border:#cfe0ff; } #gradio-app{ --background-fill-primary:var(--bg-page); --background-fill-secondary:var(--bg-page-2); --block-background-fill:var(--bg-card); --block-border-color:var(--border-soft); --block-border-width:1px; } html,body{ margin:0; padding:0; background:linear-gradient(180deg,var(--bg-page) 0%,var(--bg-page-2) 100%)!important; color:var(--text-main); } #gradio-app,.gradio-container,.main,.contain,.wrap,.column,.row,.block,.html-container,.gr-group{ background:transparent!important; } .gradio-container{ max-width:var(--app-max-width)!important; margin:0 auto!important; padding:10px 14px 8px!important; } /* Header */ .header-row{ gap:12px!important; padding:2px 0 6px!important; align-items:center!important; } .header-row img{ height:112px; width:auto; object-fit:contain; } .header-meta{ display:flex; flex-direction:column; justify-content:center; min-width:0; } .header-title{ margin:0; font-size:1.7rem; line-height:1.15; font-weight:800; color:#0d0d0d; letter-spacing:-.01em; } .header-sub{ margin:4px 0 0; font-size:.96rem; line-height:1.35; color:#0d0d0d; font-weight:700; } /* Info note */ .info-card{ background:var(--bg-note)!important; border:1px solid var(--bg-note-border)!important; border-radius:var(--r-md); padding:10px 12px; color:var(--text-note)!important; margin:0 0 10px; } .info-card-title,.info-card-list,.info-card-list li{ color:var(--text-note)!important; } .info-card-title{ margin-bottom:6px; font-size:1rem; font-weight:700; } .info-card-list{ margin:0; padding-left:18px; font-size:.92rem; line-height:1.55; } .info-card-list li+li{ margin-top:4px; } /* Layout */ .main-layout{ gap:10px!important; align-items:stretch!important; } .control-stack,.preview-col{ gap:8px!important; } /* Cards */ .control-card,.preview-card{ background:#e8f0fe!important; border:1px solid var(--border-soft)!important; border-radius:var(--r-lg)!important; box-shadow:0 4px 18px rgba(25,45,80,.04)!important; padding:10px!important; } .control-card,.control-card *,.preview-card,.preview-card *{ color:#0d0d0d!important; } .control-card a,.preview-card a,.help-markdown a,.story-markdown a{ color:var(--accent)!important; } .section-help,.token-help p,.help-markdown p,.help-markdown li{ font-size:.92rem; line-height:1.58; color:#0d0d0d!important; } .section-help{ margin:0; padding:10px 12px 6px!important; } /* Inputs */ textarea,input{ background:#ffffff!important; color:#0d0d0d!important; font-weight:600!important; border:1px solid var(--border-mid)!important; box-shadow:none!important; } .control-card textarea{ min-height:140px!important; } textarea:focus,input:focus{ border-color:var(--accent)!important; box-shadow:0 0 0 3px rgba(76,118,255,.10)!important; } ::placeholder{ color:#5a6b7d!important; opacity:1!important; } label,.gr-label,.block-title{ color:#0d0d0d!important; font-size:.95rem!important; } .gr-info{ color:#0d0d0d!important; font-size:.9rem!important; } [data-testid="block-info"]{ display:none!important; } /* Button */ .gen-btn{ width:100%!important; height:46px!important; font-size:16px!important; font-weight:700!important; border-radius:12px!important; border:1px solid var(--accent)!important; background:linear-gradient(180deg,#5f88ff 0%,var(--accent) 100%)!important; color:#fff!important; cursor:pointer!important; box-shadow:0 8px 18px rgba(76,118,255,.16); } .gen-btn:hover{ filter:brightness(.985); } /* Status */ .status-bar{ background:var(--bg-status)!important; border:1px solid var(--accent-soft-border)!important; border-radius:var(--r-sm); padding:9px 11px!important; font-size:.92rem!important; line-height:1.5!important; color:#0d0d0d!important; } .status-bar p,.status-bar .prose p{ color:#0d0d0d!important; } .control-stack .html-container:has(.status-bar){ background:transparent!important; border:none!important; box-shadow:none!important; padding:0!important; } /* Accordions */ .gradio-accordion, .gradio-accordion > div, .gradio-accordion details{ background:#e8f0fe!important; border:1px solid var(--border-soft)!important; border-radius:var(--r-md)!important; box-shadow:none!important; overflow:hidden!important; } .gradio-accordion summary, .gradio-accordion button{ background:#e8f0fe!important; color:#0d0d0d!important; font-weight:700!important; font-size:.98rem!important; box-shadow:none!important; outline:none!important; border:none!important; } .gradio-accordion summary:hover, .gradio-accordion button:hover{ background:#f8fbff!important; } .gradio-accordion svg, .gradio-accordion path{ stroke:#0d0d0d!important; fill:#0d0d0d!important; } /* Examples */ .examples-wrap{ gap:5px!important; } .example-btn{ font-size:.88rem!important; padding:8px 10px!important; border-radius:10px!important; white-space:normal!important; text-align:left!important; line-height:1.38!important; background:#e8f0fe!important; border:1px solid var(--border-soft)!important; color:#0d0d0d!important; } .example-btn:hover{ background:#f8fbff!important; border-color:#d6e4ff!important; } /* Preview */ .preview-header{ display:flex; align-items:center; justify-content:space-between; gap:8px; margin:0 0 6px; } .preview-title{ margin:0; font-size:1.06rem; font-weight:700; color:#0d0d0d!important; } .preview-sub{ margin:2px 0 0; font-size:.88rem; color:#0d0d0d!important; } .comic-grid,.panel-row{ gap:var(--panel-gap)!important; } .panel-row{ margin:0!important; padding:0!important; } .panel-img,.panel-img>div,.panel-img .wrap{ margin:0!important; padding:0!important; overflow:hidden; border-radius:12px; background:var(--bg-preview)!important; border:1px solid var(--border-soft)!important; } .panel-img img{ display:block; border-radius:12px; background:var(--bg-preview)!important; } /* Storyboard bottom block */ .story-panel, .story-panel > div, .story-panel details{ background:#e8f0fe!important; border:1px solid var(--border-soft)!important; border-radius:var(--r-lg)!important; box-shadow:0 4px 18px rgba(25,45,80,.04)!important; overflow:hidden!important; } .story-panel summary, .story-panel button{ background:#e8f0fe!important; color:#0d0d0d!important; font-weight:700!important; font-size:.98rem!important; border:none!important; box-shadow:none!important; outline:none!important; } .story-panel summary:hover, .story-panel button:hover{ background:#f8fbff!important; } .story-panel .label-wrap, .story-panel .icon-wrap{ background:transparent!important; } .story-hint{ margin:0 0 4px; padding:8px 12px 0; font-size:.9rem; color:#0d0d0d!important; } .story-markdown{ background:#e8f0fe!important; border-top:1px solid var(--border-soft)!important; border-radius:0 0 14px 14px!important; padding:12px 14px!important; min-height:2em; } .story-markdown h2,.story-markdown h3{ margin:.95rem 0 .45rem!important; line-height:1.28!important; font-size:1.08rem!important; color:#0d0d0d!important; } .story-markdown h2:first-child{ margin-top:0!important; } .story-markdown p,.story-markdown li{ font-size:.94rem!important; line-height:1.64!important; color:#0d0d0d!important; } .story-markdown ul,.story-markdown ol{ padding-left:1.1rem!important; } .story-markdown pre{ margin:.55rem 0 .85rem!important; padding:12px 14px!important; overflow-x:auto!important; border-radius:12px!important; background:#1f2937!important; color:#f3f4f6!important; border:1px solid rgba(255,255,255,.08)!important; } .story-markdown pre,.story-markdown pre *,.story-markdown pre code{ color:#f3f4f6!important; } .story-markdown code{ font-size:.88rem!important; } .story-markdown :not(pre) > code{ color:#0d0d0d!important; background:#eef2f7!important; padding:.15em .4em!important; border-radius:4px!important; } .story-markdown strong{ color:#0d0d0d!important; } /* Footer */ .footer-text{ text-align:center; font-size:.94rem; color:#0d0d0d!important; padding:10px 0 6px; margin-top:10px; border-top:1px solid var(--border-mid); } .footer-text a{ color:var(--accent)!important; text-decoration:none; font-weight:600; } .footer-text a:hover{ text-decoration:underline; } @media (max-width:980px){ .gradio-container{ padding:6px!important; } .header-row{ gap:8px!important; padding:2px 0!important; } .header-row img{ height:60px; } .header-title{ font-size:1.4rem; } .header-sub{ font-size:.9rem; } } """ _theme = gr.themes.Base().set( background_fill_primary="#f7faff", background_fill_secondary="#eef4fb", block_background_fill="#e8f0fe", block_border_color="#dde7f2", block_border_width="1px", input_background_fill="#ffffff", ) with gr.Blocks(title="AI Comic Generation", fill_width=True, fill_height=True, theme=_theme) as demo: with gr.Row(elem_classes="header-row"): logo_b64 = _img_b64("static/aivn_logo.png") gr.HTML(f'AIVN') gr.HTML( '
' '

🎨 AI Comic Generation

' '

AIO2025 — LLM + FLUX.1-schnell API

' '
' ) gr.HTML(_INFO_NOTE_HTML) with gr.Row(equal_height=False, elem_classes="main-layout"): with gr.Column(scale=4, min_width=320, elem_classes="control-stack"): with gr.Group(elem_classes="control-card"): gr.Markdown("### 💡 Tóm tắt truyện") prompt_input = gr.Textbox( label="", info="Nhập tóm tắt bằng tiếng Việt, nêu rõ nhân vật và các sự kiện chính.", placeholder="Ví dụ: Một con thỏ lười biếng học được bài học về sự chăm chỉ từ một con rùa…", lines=5, max_lines=8, ) gr.Markdown( '

Tóm tắt càng rõ ràng và có diễn biến cụ thể thì kết quả càng sát ý hơn.

' ) with gr.Accordion("📌 Ví dụ nhanh", open=False): with gr.Column(elem_classes="examples-wrap"): for example in _EXAMPLES: gr.Button( example[:92] + "…" if len(example) > 92 else example, size="sm", elem_classes="example-btn", ).click(fn=lambda text=example: text, inputs=None, outputs=prompt_input) with gr.Accordion("🔑 API Keys (tuỳ chọn)", open=False): gr.Markdown( "**Gemini API Key** *(ưu tiên)*: Nếu bạn cung cấp key này, pipeline sẽ dùng " "Google Gemini để sinh text và Imagen để vẽ hình. " "Vào **Google AI Studio → API Keys** để lấy key.\n\n" "**HF Token** *(dự phòng)*: Dùng khi không có Gemini key và bị rate-limit trên " "HuggingFace. Vào **HuggingFace → Settings → Access Tokens** để lấy token. " "Cả hai key **chỉ dùng trong phiên hiện tại và không được lưu trữ**.", elem_classes="token-help", ) gemini_key_input = gr.Textbox( label="Gemini API Key", info="Ưu tiên — dùng Gemini + Imagen thay vì HuggingFace.", type="password", placeholder="AIza...", lines=1, max_lines=1, ) token_input = gr.Textbox( label="Hugging Face Token", info="Dự phòng — chỉ dùng khi không có Gemini key và gặp lỗi rate-limit.", type="password", placeholder="hf_...", lines=1, max_lines=1, ) generate_btn = gr.Button("Tạo truyện tranh 🎨", elem_classes="gen-btn") status = gr.HTML('
Sẵn sàng.
') with gr.Accordion("❓ Hướng dẫn sử dụng", open=False): gr.Markdown(_USAGE_MARKDOWN, elem_classes="help-markdown") with gr.Column(scale=7, min_width=520, elem_classes="preview-col"): with gr.Group(elem_classes="preview-card"): gr.HTML( '
' '

🖼️ Comic Preview

' '

4 ô truyện sẽ được tạo và cập nhật lần lượt theo thời gian thực.

' '
' ) with gr.Column(elem_classes="comic-grid"): panel_imgs = [] for row_start in (0, 2): with gr.Row(equal_height=True, elem_classes="panel-row"): for _ in range(row_start, row_start + 2): panel_imgs.append( gr.Image( label="", show_label=False, type="pil", elem_classes="panel-img", height=300, ) ) with gr.Accordion("📝 Storyboard & Prompts", open=False, elem_classes="story-panel"): gr.Markdown( "Mở phần này để xem storyboard JSON, caption đã parse và image prompt cho từng cảnh.", elem_classes="story-hint", ) story_output = gr.Markdown( value="*Chưa chạy pipeline — kết quả storyboard sẽ hiển thị ở đây.*", elem_classes="story-markdown", ) gr.HTML( '' ) generate_btn.click( fn=inference, inputs=[prompt_input, token_input, gemini_key_input], outputs=[*panel_imgs, story_output, status], ) if __name__ == "__main__": demo.launch( allowed_paths=["static/aivn_logo.png"], css=_CSS )