"""诗成画 — 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 inside .table-wrap, and its component label is a
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 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"
{sub_en}" if sub_en else "" return ( f"
{num}" f"{title}" f"{sub}{en}
" ) with gr.Blocks(title="诗情画意 · Poetry to Art") as demo: with gr.Column(elem_id="app-root"): gr.HTML( "
" "

Poetry to Art · 早安画卡

" "

诗情画意

" "

写下自己的诗,生成一张可保存、可分享的早安画卡。
" "Write a short Chinese poem — get a painting, a heartfelt " "recitation with music, and a short film to share.

" "

我替您读懂它、为它作画、把它读出来,再让画轻轻动起来——每一步都用开源小模型生成。
" "Read · Paint · Recite · Animate — each step by a small, open model.

" "
" ) 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()], )