"""诗成画 — 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()],
)