Spaces:
Running
Running
| """诗成画 — write a poem, watch it become a painting, a song, and a living card. | |
| Build Small hackathon Space. All models open-weight and under 32B parameters; | |
| see poetic/providers/ for the swappable provider seam (local ZeroGPU vs HF API). | |
| """ | |
| import logging | |
| import tempfile | |
| import gradio as gr | |
| from poetic import providers | |
| from poetic.card import compose_card | |
| from poetic.recite import ( | |
| DEFAULT_MUSIC, | |
| DEFAULT_VOICE, | |
| MUSIC_STYLE_CHOICES, | |
| VOICE_CHOICES, | |
| music_tags, | |
| voice_or_default, | |
| ) | |
| from poetic.styles import DEFAULT_IMAGE_STYLE, apply_image_style | |
| from poetic.textfree import ensure_text_free | |
| from poetic.video import ( | |
| audio_duration, | |
| mix_recitation_over_music, | |
| mux_song_into_video, | |
| ) | |
| logging.basicConfig(level=logging.INFO) | |
| log = logging.getLogger("poetic.app") | |
| if providers.resolve_mode() == "local": | |
| # ZeroGPU requires eager module-scope model loading; this import does it. | |
| from poetic.providers import local_zerogpu # noqa: F401 | |
| IMAGE_STYLE_CHOICES = [ | |
| ("水墨留白 · Ink wash", "inkWash"), | |
| ("工笔淡彩 · Fine-line", "gongbi"), | |
| ("温柔水彩 · Watercolor", "watercolor"), | |
| ("海报色彩 · Poster", "posterColor"), | |
| ("写实 · Realistic", "realistic"), | |
| ] | |
| def do_interpret(poem: str, correction: str): | |
| if not poem or not poem.strip(): | |
| raise gr.Error("请先写下您的诗,再请我来读。") | |
| try: | |
| interp = providers.get_interpreter().interpret( | |
| poem.strip(), correction.strip() or None if correction else None | |
| ) | |
| except ValueError as exc: | |
| raise gr.Error(f"这一次没有读懂,请再试一下。({exc})") from exc | |
| return f"### 画中之意\n\n{interp.understanding_zh}", interp.brief_en | |
| def do_paint(brief_en: str, style_id: str, poem: str, author: str = "", | |
| progress: gr.Progress = gr.Progress()): | |
| if not brief_en: | |
| raise gr.Error("请先点「读诗」,让我先体会诗意。") | |
| brief = ensure_text_free(apply_image_style(brief_en, style_id or DEFAULT_IMAGE_STYLE)) | |
| try: | |
| progress(0.3, desc="作画中…约15秒") | |
| painting = providers.get_painter().paint(brief) | |
| except Exception as exc: # noqa: BLE001 — cold-start/network → warm copy | |
| raise gr.Error("画师正在热身(首次作画要多等一会儿),请稍候再点一次「作画」。") from exc | |
| # author name → 姓名章 seal; blank → no seal (compose_card handles both). | |
| author = (author or "").strip() | |
| progress(0.85, desc="题字、盖印…" if author else "题字…") | |
| card = compose_card(painting, poem, "", author) | |
| return card, painting | |
| def do_recite(poem: str, voice: str, music_vibe: str, | |
| progress: gr.Progress = gr.Progress()): | |
| """诵诗配乐: spoken recitation (Kokoro) over a soft instrumental bed | |
| (ACE-Step). No singing — clear poem reading + background music.""" | |
| if not poem or not poem.strip(): | |
| raise gr.Error("请先写下您的诗。") | |
| poem = poem.strip() | |
| voice = voice_or_default(voice) | |
| music_vibe = music_vibe or DEFAULT_MUSIC | |
| log.info("do_recite: voice=%s music=%s", voice, music_vibe) | |
| try: | |
| progress(0.15, desc="朗诵诗句…") | |
| reciter = providers.get_reciter() | |
| recite_path = reciter.recite(poem, voice=voice) | |
| dur = audio_duration(recite_path) | |
| progress(0.5, desc="生成背景音乐…约30秒") | |
| # backing a few seconds longer than the reading, for a lead-in + tail | |
| music_path = reciter.instrumental( | |
| music_tags(music_vibe), duration=max(18.0, dur + 5.0) | |
| ) | |
| progress(0.85, desc="混音(朗诵在前,音乐在后)…") | |
| return mix_recitation_over_music(recite_path, music_path) | |
| except Exception as exc: # noqa: BLE001 — cold-start/network → warm copy | |
| raise gr.Error("正在准备朗诵与配乐(首次最久,约一两分钟),请稍候再点一次「诵诗」。") from exc | |
| def _paint_scene(scene_brief: str, style_id: str): | |
| brief = ensure_text_free(apply_image_style(scene_brief, style_id or DEFAULT_IMAGE_STYLE)) | |
| return providers.get_painter().paint(brief) | |
| def do_animate(painting, brief_en: str, audio_path: str | None, style_id: str = "", | |
| progress: gr.Progress = gr.Progress()): | |
| """Single coherent shot: animate the painting itself (slow camera move + | |
| ambient motion) — one continuous living painting, no scene morphing/cuts. | |
| Then lay the recitation + music underneath.""" | |
| if painting is None: | |
| if not (brief_en and brief_en.strip()): | |
| raise gr.Error("请先点「作画」,有了画才能让它动起来。") | |
| progress(0.1, desc="先画一幅…") | |
| painting = _paint_scene(brief_en, style_id) | |
| try: | |
| video = providers.get_animator().animate( | |
| painting, brief_en or "", | |
| progress_cb=lambda f, d: progress(f, desc=d), | |
| ) | |
| progress(0.93, desc="收尾…") | |
| except gr.Error: | |
| raise | |
| except Exception as exc: # noqa: BLE001 — cold-start/network → warm copy | |
| raise gr.Error("正在让画动起来(首次最久,约一两分钟),请稍候再点一次「让画活起来」。") from exc | |
| if audio_path: | |
| progress(0.96, desc="把朗诵与音乐配进来…") | |
| video = mux_song_into_video(video, audio_path) | |
| return video | |
| def do_everything(poem: str, style_id: str, voice: str, music_vibe: str, | |
| author: str = ""): | |
| understanding, brief_en = do_interpret(poem, "") | |
| yield understanding, brief_en, gr.update(), gr.update(), gr.update(), gr.update() | |
| card, painting = do_paint(brief_en, style_id, poem, author) | |
| yield understanding, brief_en, card, painting, gr.update(), gr.update() | |
| audio = do_recite(poem, voice, music_vibe) | |
| yield understanding, brief_en, card, painting, audio, gr.update() | |
| video = do_animate(painting, brief_en, audio, style_id) | |
| yield understanding, brief_en, card, painting, audio, video | |
| # Palette and proportions lifted directly from the production app | |
| # (app/src/config.ts + app/app/index.tsx): warm paper #FDFBF7, ink #3e3a39, | |
| # seal #8c3a38, near-black primary CTA #34302E (seal is an ACCENT, not the | |
| # button color), 8px radii, heavy bold title, uppercase seal kicker. | |
| THEME = gr.themes.Base( | |
| primary_hue=gr.themes.colors.stone, | |
| neutral_hue=gr.themes.colors.stone, | |
| font=[gr.themes.GoogleFont("Noto Serif SC"), "Songti SC", "serif"], | |
| ).set( | |
| body_background_fill="#FDFBF7", | |
| body_text_color="#3e3a39", | |
| block_background_fill="#FFFDF9", | |
| block_border_color="#E7DCCC", | |
| block_radius="8px", | |
| block_label_text_color="#6f655d", | |
| input_background_fill="#FFFCF7", | |
| input_border_color="#E0D4C6", | |
| button_large_radius="8px", | |
| button_primary_background_fill="#34302E", | |
| button_primary_background_fill_hover="#46403d", | |
| button_primary_text_color="#FDF6EF", | |
| ) | |
| # Match the real app's clean, flat character: heavy bold title, "POETRY TO ART" | |
| # kicker in seal red, paper cards with a thin warm border, dark CTA + seal accent. | |
| CSS = """ | |
| :root{ | |
| --paper:#FDFBF7; --paper-card:#FFFDF9; --paper-input:#FFFCF7; --paper-warm:#F2ECE3; | |
| --ink:#3e3a39; --ink-strong:#34302E; --ink-soft:#6E6258; --ink-faint:#81756B; | |
| --seal:#8c3a38; --line:#E7DCCC; | |
| --serif:"Noto Serif SC","Songti SC","STSong",serif; | |
| } | |
| .gradio-container, .gradio-container *{ font-family: var(--serif); } | |
| .gradio-container{ background: var(--paper) !important; color: var(--ink); } | |
| #app-root{ max-width: 820px; margin: 0 auto; } | |
| footer{ display:none !important; } | |
| /* LIGHT-MODE LOCK — the production app is light-only. Pin every surface to | |
| paper tones so a visitor's dark-mode preference can't turn fields black. | |
| These cover both :root.dark and the default scheme. */ | |
| .dark, .gradio-container.dark{ background: var(--paper) !important; color: var(--ink) !important; } | |
| .dark .block, .dark .form, .dark .gr-box, | |
| .gradio-container textarea, .gradio-container input[type="text"], | |
| .gradio-container input[type="number"], | |
| .dark textarea, .dark input{ | |
| background: var(--paper-input) !important; color: var(--ink) !important; | |
| } | |
| .dark .gr-input, .dark .wrap, .dark label, .dark .gr-text-input{ | |
| background: var(--paper-input) !important; color: var(--ink) !important; | |
| } | |
| /* accordion + its body, dataframe (examples), markdown panels */ | |
| .dark .label-wrap, .gradio-container .label-wrap{ | |
| background: var(--paper-card) !important; color: var(--ink) !important; | |
| } | |
| .dark table, .dark thead, .dark tbody, .dark td, .dark th, | |
| .gradio-container td, .gradio-container th{ | |
| background: var(--paper-card) !important; color: var(--ink) !important; | |
| } | |
| /* Examples / dataset table ("试试这些诗"). In Gradio 6 this renders as a real | |
| <table> inside .table-wrap, and its component label is a <div class="label"> | |
| that is NOT covered by td/th rules. The theme's block-label color flips to a | |
| near-white (#e7e5e4) the instant a `dark` class is present — even momentarily | |
| before FORCE_LIGHT_JS runs — leaving the heading invisible on paper. Pin the | |
| label, header cells, body cells, and the cell <span> to dark ink on paper so | |
| the section is always readable regardless of theme. */ | |
| .gradio-container .label, .dark .label, | |
| .gradio-container div.label, .dark div.label{ | |
| color: var(--ink-soft) !important; | |
| } | |
| .gradio-container .table-wrap, .dark .table-wrap, | |
| .gradio-container table, .dark table{ | |
| background: var(--paper-card) !important; border-color: var(--line) !important; | |
| } | |
| .gradio-container th, .dark th, | |
| .gradio-container td, .dark td, | |
| .gradio-container td span, .dark td span, | |
| .gradio-container th span, .dark th span, | |
| .gradio-container .tr-head, .gradio-container .tr-body, | |
| .dark .tr-head, .dark .tr-body{ | |
| background: var(--paper-card) !important; color: var(--ink) !important; | |
| } | |
| .dark .prose, .dark .prose *{ color: var(--ink) !important; } | |
| /* image/audio/video output placeholders + their empty-state canvas */ | |
| .dark .image-container, .dark .empty, .dark .wrap.default, | |
| .gradio-container .image-container, .gradio-container .empty, | |
| .dark [data-testid="block-info"], .dark .icon-button-wrapper{ | |
| background: var(--paper-input) !important; color: var(--ink-faint) !important; | |
| } | |
| .dark .empty svg, .gradio-container .empty svg{ opacity:.4; } | |
| /* accordion: thin warm border, not the default heavy dark outline */ | |
| .gradio-container .label-wrap, .dark .label-wrap, | |
| .gradio-container .accordion, .dark .accordion{ | |
| border-color: var(--line) !important; | |
| } | |
| .gradio-container .accordion{ border:1px solid var(--line) !important; } | |
| /* hero — mirrors the app landing: uppercase seal kicker, big heavy title */ | |
| #hero{ padding: 2rem 0 .5rem; } | |
| #hero .kicker{ | |
| color:var(--seal); font-size:.82rem; font-weight:900; letter-spacing:.18rem; | |
| text-transform:uppercase; margin:0 0 .5rem; | |
| } | |
| #hero .title{ | |
| font-size: clamp(2.6rem,7vw,3.4rem); font-weight:900; color:var(--ink); | |
| line-height:1.1; margin:0; letter-spacing:.05rem; | |
| } | |
| #hero .sub{ margin:.85rem 0 0; max-width:38rem; color:var(--ink-soft); | |
| font-size:1.12rem; font-weight:700; line-height:1.6; } | |
| #hero .note{ margin:.5rem 0 0; color:var(--ink-faint); font-size:1rem; line-height:1.55; } | |
| /* English secondary text — lighter + smaller, sits under the Chinese */ | |
| .en{ color:var(--ink-faint); font-weight:500; font-size:.86em; font-style:italic; } | |
| .step-sub .en{ font-style:normal; } | |
| /* step cards — flat warm paper, thin border, 8px radius like the app */ | |
| .step{ | |
| background:var(--paper-card) !important; border:1px solid var(--line) !important; | |
| border-radius:8px !important; padding:1.4rem 1.3rem 1.2rem !important; | |
| margin:0 0 1.1rem !important; | |
| box-shadow:0 12px 28px -24px rgba(47,41,36,.5) !important; | |
| } | |
| .step-head{ display:flex; align-items:baseline; gap:.6rem; margin-bottom:.9rem; } | |
| .step .styler:has(.step-head){ background:transparent !important; border:none !important; | |
| box-shadow:none !important; padding:0 !important; } | |
| .step-num{ color:var(--seal); font-size:1rem; font-weight:900; letter-spacing:.1rem; } | |
| .step-title{ font-size:1.3rem; font-weight:900; color:var(--ink); line-height:1.2; } | |
| .step-sub{ color:var(--ink-faint); font-size:.96rem; font-weight:700; margin-left:auto; } | |
| /* inputs */ | |
| textarea, input[type="text"]{ | |
| font-size:1.18rem !important; line-height:1.9 !important; color:var(--ink) !important; | |
| border-radius:8px !important; | |
| } | |
| textarea:focus, input[type="text"]:focus{ | |
| border-color:var(--seal) !important; box-shadow:0 0 0 3px rgba(140,58,56,.1) !important; | |
| } | |
| /* buttons — dark ink primary (the app's #34302E), seal only as accent. | |
| Hard-pin the fill so neither light nor dark theme washes it out. */ | |
| button.primary, button.secondary{ | |
| min-height:64px !important; font-size:1.2rem !important; font-weight:900 !important; | |
| letter-spacing:.06rem !important; border-radius:8px !important; | |
| } | |
| button.primary, .dark button.primary{ | |
| background:var(--ink-strong) !important; color:#FDF6EF !important; border:none !important; | |
| } | |
| button.primary:hover{ filter:brightness(1.12); } | |
| button.secondary{ | |
| background:var(--paper-card) !important; color:var(--ink) !important; | |
| border:1px solid var(--line) !important; | |
| } | |
| /* the all-in-one CTA gets the seal color to stand apart from per-step dark buttons */ | |
| button.run-all{ | |
| background:var(--seal) !important; color:#fdf6ef !important; border:none !important; | |
| } | |
| button.run-all:hover{ filter:brightness(1.07); } | |
| /* radios as paper chips */ | |
| .step fieldset label, .step .wrap label{ | |
| background:var(--paper-warm) !important; border:1px solid var(--line) !important; | |
| border-radius:8px !important; padding:.55rem 1.05rem !important; cursor:pointer; | |
| font-size:1.08rem !important; font-weight:700 !important; color:var(--ink) !important; | |
| transition:all .15s ease; | |
| } | |
| .step fieldset label:has(input:checked), .step .wrap label:has(input:checked){ | |
| background:var(--seal) !important; border-color:var(--seal) !important; color:#fdf6ef !important; | |
| } | |
| /* slider endpoints + reset glyph — theme renders these in a pale stone | |
| (#a8a29e / #f5f5f4) that washes out on paper. Pin to soft/faint ink so the | |
| "20 / 60" range labels and the ↺ reset stay legible. */ | |
| .gradio-container .min_value, .gradio-container .max_value, | |
| .dark .min_value, .dark .max_value{ color: var(--ink-faint) !important; } | |
| .gradio-container .reset-button, .dark .reset-button{ color: var(--ink-soft) !important; } | |
| /* bottom Gradio chrome (Use via API · Settings · Built with Gradio) — pale | |
| stone on paper; nudge to faint ink so it is readable but stays secondary. */ | |
| .gradio-container .show-api, .gradio-container .settings, | |
| .gradio-container .built-with, .gradio-container .divider, | |
| .dark .show-api, .dark .settings, .dark .built-with, .dark .divider{ | |
| color: var(--ink-faint) !important; | |
| } | |
| /* understanding panel */ | |
| #understanding{ | |
| background:var(--paper-input); border:1px solid var(--line); | |
| border-left:3px solid var(--seal); border-radius:8px; | |
| padding:1rem 1.15rem; font-size:1.14rem; line-height:1.9; color:var(--ink); | |
| } | |
| #understanding h3{ color:var(--seal); margin-top:0; letter-spacing:.04rem; } | |
| """ | |
| def step_head(num: str, title: str, sub: str, sub_en: str = "") -> str: | |
| en = f"<br><span class='en'>{sub_en}</span>" if sub_en else "" | |
| return ( | |
| f"<div class='step-head'><span class='step-num'>{num}</span>" | |
| f"<span class='step-title'>{title}</span>" | |
| f"<span class='step-sub'>{sub}{en}</span></div>" | |
| ) | |
| with gr.Blocks(title="诗情画意 · Poetry to Art") as demo: | |
| with gr.Column(elem_id="app-root"): | |
| gr.HTML( | |
| "<div id='hero'>" | |
| "<p class='kicker'>Poetry to Art · 早安画卡</p>" | |
| "<h1 class='title'>诗情画意</h1>" | |
| "<p class='sub'>写下自己的诗,生成一张可保存、可分享的早安画卡。<br>" | |
| "<span class='en'>Write a short Chinese poem — get a painting, a heartfelt " | |
| "recitation with music, and a short film to share.</span></p>" | |
| "<p class='note'>我替您读懂它、为它作画、把它读出来,再让画轻轻动起来——每一步都用开源小模型生成。<br>" | |
| "<span class='en'>Read · Paint · Recite · Animate — each step by a small, open model.</span></p>" | |
| "</div>" | |
| ) | |
| brief_state = gr.State("") | |
| painting_state = gr.State(None) | |
| with gr.Group(elem_classes="step"): | |
| gr.HTML(step_head("一", "写诗 · Write", "把心里的话写成几句,长短都可以", | |
| "Write your poem — a few lines, any length")) | |
| poem_box = gr.Textbox( | |
| label="您的诗 · Your poem", lines=4, | |
| placeholder="把您想说的写成几句小诗,长短都可以。", | |
| ) | |
| author_box = gr.Textbox( | |
| label="署名 · Signature (optional — becomes a red seal)", | |
| placeholder="填了名字会在画上盖一枚朱红印;不填则不盖印", | |
| max_lines=1, | |
| ) | |
| with gr.Row(): | |
| interpret_btn = gr.Button("读诗 Read", variant="primary", scale=1) | |
| everything_btn = gr.Button( | |
| "一气呵成 · 读诗 作画 诵诗 入画 (one-tap)", scale=2, elem_classes="run-all" | |
| ) | |
| with gr.Group(elem_classes="step"): | |
| gr.HTML(step_head("二", "画中之意 · The reading", "我把读到的画面说给您听,读错了可以纠正我", | |
| "How I read your poem — correct me if I'm off")) | |
| understanding_md = gr.Markdown( | |
| "点「读诗」后,我会把读到的画面说给您听。", elem_id="understanding" | |
| ) | |
| correction_box = gr.Textbox( | |
| label="如果我没读对,请告诉我 · Correct me", placeholder="例如:我想要更温暖的颜色" | |
| ) | |
| reinterpret_btn = gr.Button("按更正再读一次 · Re-read") | |
| with gr.Accordion("给画师的英文说明(看看也无妨)", open=False): | |
| brief_md = gr.Markdown("") | |
| with gr.Group(elem_classes="step"): | |
| gr.HTML(step_head("三", "作画 · Paint", "选一种画风,为这首诗画一幅画卡", | |
| "Pick a style; paint the poem as a card")) | |
| style_radio = gr.Radio( | |
| IMAGE_STYLE_CHOICES, value=DEFAULT_IMAGE_STYLE, label="选一种画风 · Painting style" | |
| ) | |
| paint_btn = gr.Button("作画 Paint", variant="primary") | |
| card_image = gr.Image(label="您的画卡 · Your card", type="pil", interactive=False) | |
| with gr.Group(elem_classes="step"): | |
| gr.HTML(step_head("四", "诵诗 · Recite", "为这首诗配上朗诵与背景音乐", | |
| "A spoken recitation over soft background music")) | |
| with gr.Row(): | |
| voice_radio = gr.Radio( | |
| VOICE_CHOICES, value=DEFAULT_VOICE, label="选一种朗诵嗓音 · Voice" | |
| ) | |
| music_radio = gr.Radio( | |
| MUSIC_STYLE_CHOICES, value=DEFAULT_MUSIC, label="选一种背景音乐 · Background music", | |
| info="纯音乐做背景,朗诵在前、音乐在后。 · Instrumental bed; voice in front.", | |
| ) | |
| recite_btn = gr.Button("诵诗 Recite", variant="primary") | |
| song_audio = gr.Audio(label="您的诗,被轻轻读出来了 · Recitation + music", | |
| type="filepath", interactive=False) | |
| with gr.Group(elem_classes="step"): | |
| gr.HTML(step_head("五", "入画 · Animate", "把诗变成一段会动的小影片,配上朗诵与音乐", | |
| "Bring the painting to life as a short film with the recitation")) | |
| animate_btn = gr.Button("让画活起来 Animate", variant="primary") | |
| video_out = gr.Video(label="诗的小影片 · Your short film", interactive=False) | |
| gr.Examples( | |
| examples=[ | |
| ["床前明月光,疑是地上霜。举头望明月,低头思故乡。"], | |
| ["白日依山尽,黄河入海流。欲穷千里目,更上一层楼。"], | |
| ["春眠不觉晓,处处闻啼鸟。夜来风雨声,花落知多少。"], | |
| ], | |
| inputs=[poem_box], | |
| label="试试这些诗(也欢迎写您自己的) · Try one, or write your own", | |
| cache_examples=False, | |
| ) | |
| interpret_btn.click( | |
| do_interpret, inputs=[poem_box, correction_box], outputs=[understanding_md, brief_state] | |
| ).then(lambda b: b, inputs=brief_state, outputs=brief_md) | |
| reinterpret_btn.click( | |
| do_interpret, inputs=[poem_box, correction_box], outputs=[understanding_md, brief_state] | |
| ).then(lambda b: b, inputs=brief_state, outputs=brief_md) | |
| paint_btn.click( | |
| do_paint, | |
| inputs=[brief_state, style_radio, poem_box, author_box], | |
| outputs=[card_image, painting_state], | |
| ) | |
| recite_btn.click( | |
| do_recite, | |
| inputs=[poem_box, voice_radio, music_radio], | |
| outputs=song_audio, | |
| ) | |
| animate_btn.click( | |
| do_animate, | |
| inputs=[painting_state, brief_state, song_audio, style_radio], | |
| outputs=video_out, | |
| ) | |
| everything_btn.click( | |
| do_everything, | |
| inputs=[poem_box, style_radio, voice_radio, music_radio, author_box], | |
| outputs=[understanding_md, brief_state, card_image, painting_state, song_audio, video_out], | |
| ) | |
| # Force light mode always — the production app is light-only, and Gradio | |
| # otherwise follows the visitor's OS/browser dark preference, which turns the | |
| # inputs and panels black. This JS runs on load and strips the dark class. | |
| FORCE_LIGHT_JS = """ | |
| () => { | |
| document.documentElement.classList.remove('dark'); | |
| try { localStorage.setItem('theme', 'light'); } catch (e) {} | |
| const url = new URL(window.location); | |
| if (url.searchParams.get('__theme') !== 'light') { | |
| url.searchParams.set('__theme', 'light'); | |
| window.history.replaceState({}, '', url); | |
| } | |
| } | |
| """ | |
| if __name__ == "__main__": | |
| # allowed_paths lets Gradio serve the audio/video the providers write to | |
| # the system temp dir; without it the /gradio_api/file= route returns 403 | |
| # and the card image / download links break. | |
| demo.launch( | |
| theme=THEME, css=CSS, js=FORCE_LIGHT_JS, | |
| allowed_paths=[tempfile.gettempdir()], | |
| ) | |