Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .pytest_cache/.gitignore +2 -0
- .pytest_cache/CACHEDIR.TAG +4 -0
- .pytest_cache/README.md +8 -0
- .pytest_cache/v/cache/lastfailed +1 -0
- .pytest_cache/v/cache/nodeids +35 -0
- README.md +80 -7
- __pycache__/app.cpython-312.pyc +0 -0
- __pycache__/conftest.cpython-312-pytest-9.0.3.pyc +0 -0
- app.py +223 -0
- conftest.py +1 -0
- poetic/__init__.py +0 -0
- poetic/card.py +82 -0
- poetic/interpretation.py +46 -0
- poetic/motion.py +13 -0
- poetic/prompts.py +19 -0
- poetic/providers/__init__.py +50 -0
- poetic/providers/hf_api.py +120 -0
- poetic/providers/local_zerogpu.py +158 -0
- poetic/song.py +67 -0
- poetic/styles.py +117 -0
- poetic/textfree.py +20 -0
- poetic/video.py +35 -0
- requirements.txt +12 -0
- tests/test_interpretation.py +47 -0
- tests/test_motion_and_registry.py +27 -0
- tests/test_song.py +56 -0
- tests/test_styles.py +45 -0
- tests/test_textfree.py +39 -0
- tests/test_video_and_card.py +38 -0
.pytest_cache/.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Created by pytest automatically.
|
| 2 |
+
*
|
.pytest_cache/CACHEDIR.TAG
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Signature: 8a477f597d28d172789f06886806bc55
|
| 2 |
+
# This file is a cache directory tag created by pytest.
|
| 3 |
+
# For information about cache directory tags, see:
|
| 4 |
+
# https://bford.info/cachedir/spec.html
|
.pytest_cache/README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pytest cache directory #
|
| 2 |
+
|
| 3 |
+
This directory contains data from the pytest's cache plugin,
|
| 4 |
+
which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
|
| 5 |
+
|
| 6 |
+
**Do not** commit this to version control.
|
| 7 |
+
|
| 8 |
+
See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
|
.pytest_cache/v/cache/lastfailed
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{}
|
.pytest_cache/v/cache/nodeids
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
"tests/test_interpretation.py::test_extracts_json_object_from_surrounding_prose",
|
| 3 |
+
"tests/test_interpretation.py::test_includes_poem_and_correction",
|
| 4 |
+
"tests/test_interpretation.py::test_omits_correction_line_when_none_given",
|
| 5 |
+
"tests/test_interpretation.py::test_parses_guaranteed_json_into_both_fields",
|
| 6 |
+
"tests/test_interpretation.py::test_raises_clear_error_on_malformed_json",
|
| 7 |
+
"tests/test_interpretation.py::test_raises_when_fields_missing",
|
| 8 |
+
"tests/test_interpretation.py::test_strips_markdown_code_fences",
|
| 9 |
+
"tests/test_motion_and_registry.py::test_explicit_mode_wins",
|
| 10 |
+
"tests/test_motion_and_registry.py::test_motion_prompt_keeps_brief_and_adds_motion_clause",
|
| 11 |
+
"tests/test_motion_and_registry.py::test_resolve_mode_defaults_to_api_off_space",
|
| 12 |
+
"tests/test_motion_and_registry.py::test_resolve_mode_defaults_to_local_on_zerogpu",
|
| 13 |
+
"tests/test_song.py::test_every_style_has_tags_and_default_is_valid",
|
| 14 |
+
"tests/test_song.py::test_lyrics_handle_blank_and_whitespace_input_lines",
|
| 15 |
+
"tests/test_song.py::test_lyrics_keep_every_poem_line",
|
| 16 |
+
"tests/test_song.py::test_lyrics_split_single_line_poem_on_chinese_punctuation",
|
| 17 |
+
"tests/test_song.py::test_lyrics_use_structure_tags",
|
| 18 |
+
"tests/test_song.py::test_recognizes_only_supported_song_styles",
|
| 19 |
+
"tests/test_song.py::test_song_tags_falls_back_to_default_for_unknown_style",
|
| 20 |
+
"tests/test_styles.py::test_appends_curated_style_clause",
|
| 21 |
+
"tests/test_styles.py::test_defaults_to_watercolor",
|
| 22 |
+
"tests/test_styles.py::test_realistic_style_overrides_chinese_painting_wording",
|
| 23 |
+
"tests/test_styles.py::test_recognizes_only_supported_style_ids",
|
| 24 |
+
"tests/test_styles.py::test_removes_conflicting_art_direction_words_from_realistic_prompts",
|
| 25 |
+
"tests/test_textfree.py::test_appends_canonical_no_text_rule_when_brief_omits_it",
|
| 26 |
+
"tests/test_textfree.py::test_appends_full_bleed_rule_when_brief_omits_it",
|
| 27 |
+
"tests/test_textfree.py::test_does_not_duplicate_full_bleed_rule",
|
| 28 |
+
"tests/test_textfree.py::test_does_not_duplicate_text_free_rule",
|
| 29 |
+
"tests/test_textfree.py::test_exports_exact_safety_rule_strings",
|
| 30 |
+
"tests/test_textfree.py::test_keeps_original_brief_intact",
|
| 31 |
+
"tests/test_video_and_card.py::test_compose_card_returns_same_size_image_and_does_not_mutate_input",
|
| 32 |
+
"tests/test_video_and_card.py::test_mux_cmd_loops_video_under_full_song",
|
| 33 |
+
"tests/test_video_and_card.py::test_vertical_columns_descend_within_a_column",
|
| 34 |
+
"tests/test_video_and_card.py::test_vertical_columns_run_right_to_left"
|
| 35 |
+
]
|
README.md
CHANGED
|
@@ -1,13 +1,86 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.17.3
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: 诗成画 · Poem to Painting & Song
|
| 3 |
+
emoji: 🖌️
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.17.3
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
+
python_version: "3.12"
|
| 10 |
+
license: apache-2.0
|
| 11 |
+
tags:
|
| 12 |
+
- build-small-hackathon
|
| 13 |
+
- track:thousand-token-wood
|
| 14 |
+
- an-adventure-in-thousand-token-wood
|
| 15 |
+
- achievement:offbrand
|
| 16 |
+
- off-brand
|
| 17 |
+
- achievement:bestdemo
|
| 18 |
+
- best-demo
|
| 19 |
+
- sponsor:openbmb
|
| 20 |
+
- minicpm
|
| 21 |
+
- flux2-klein
|
| 22 |
+
- ace-step
|
| 23 |
+
- wan2-2
|
| 24 |
+
- zerogpu
|
| 25 |
+
- text-to-image
|
| 26 |
+
- text-to-audio
|
| 27 |
+
- image-to-video
|
| 28 |
+
short_description: 写一首诗,化作画、歌与会动的画卡 — all small open models
|
| 29 |
---
|
| 30 |
|
| 31 |
+
# 诗成画 · A poem becomes a painting, a song, and a living card
|
| 32 |
+
|
| 33 |
+
Write (or speak) a short Chinese poem. The app **reads it back to you warmly**,
|
| 34 |
+
**paints it** in your chosen classical style, **sings it** as a song, and finally
|
| 35 |
+
**brings the painting to life** as a short video with your poem sung over it —
|
| 36 |
+
a morning card you can send to family.
|
| 37 |
+
|
| 38 |
+
Built for elders first: warm jargon-free Simplified Chinese, one big button per
|
| 39 |
+
step, and a "一气呵成" button that does the whole journey in one go.
|
| 40 |
+
|
| 41 |
+
## How it works — four small open models, all under 32B
|
| 42 |
+
|
| 43 |
+
| Step | 中文 | Model | Size |
|
| 44 |
+
|------|------|-------|------|
|
| 45 |
+
| Read the poem | 读诗 | [openbmb/MiniCPM4.1-8B](https://huggingface.co/openbmb/MiniCPM4.1-8B) | 8B |
|
| 46 |
+
| Paint it | 作画 | [black-forest-labs/FLUX.2-klein-4B](https://huggingface.co/black-forest-labs/FLUX.2-klein-4B) | 4B |
|
| 47 |
+
| Sing it | 唱诗 | [ACE-Step/Ace-Step1.5](https://huggingface.co/ACE-Step/Ace-Step1.5) | ~3.5B |
|
| 48 |
+
| Bring it alive | 入画 | [Wan-AI/Wan2.2-TI2V-5B-Diffusers](https://huggingface.co/Wan-AI/Wan2.2-TI2V-5B-Diffusers) | 5B |
|
| 49 |
+
|
| 50 |
+
The generated painting is always **text-free** (a hard rule injected into every
|
| 51 |
+
brief); the poem calligraphy, greeting, and red seal are composed on top by the
|
| 52 |
+
app, in classical vertical right-to-left layout. The song uses the poem itself
|
| 53 |
+
as lyrics — verse from the full poem, the opening couplet repeated as chorus.
|
| 54 |
+
The final video loops the animated painting under the full song.
|
| 55 |
+
|
| 56 |
+
## Swappable providers
|
| 57 |
+
|
| 58 |
+
Every capability sits behind a provider seam (`poetic/providers/`):
|
| 59 |
+
|
| 60 |
+
- `local` — the models above, on ZeroGPU (this Space's default).
|
| 61 |
+
- `api` — HF Inference Providers / Spaces (Qwen3-8B, FLUX.2 Klein via fal,
|
| 62 |
+
the official ACE-Step Space, Wan2.2 text-to-video), for dev boxes and CPU
|
| 63 |
+
fallback.
|
| 64 |
+
|
| 65 |
+
Mode is `POETIC_MODE` (`local`/`api`); each capability can be overridden
|
| 66 |
+
individually via `INTERPRET_PROVIDER` / `PAINT_PROVIDER` / `SING_PROVIDER` /
|
| 67 |
+
`ANIMATE_PROVIDER`, and models via env (`INTERPRET_MODEL`, `PAINT_MODEL`, …).
|
| 68 |
+
Swapping a model is a config change, not a code change.
|
| 69 |
+
|
| 70 |
+
## Run it elsewhere
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
pip install -r requirements.txt gradio
|
| 74 |
+
POETIC_MODE=api HF_TOKEN=hf_... python app.py
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
Pure logic is tested: `pytest tests/` (interpretation parsing/repair, style
|
| 78 |
+
clauses, text-free guards, lyric formatting, card layout, video mux).
|
| 79 |
+
|
| 80 |
+
## Links
|
| 81 |
+
|
| 82 |
+
- Demo video: _(coming with submission)_
|
| 83 |
+
- Social post: _(coming with submission)_
|
| 84 |
+
|
| 85 |
+
— Built from the [poetic](https://github.com/) project's poem-to-art flow,
|
| 86 |
+
re-imagined on small open models for Build Small.
|
__pycache__/app.cpython-312.pyc
ADDED
|
Binary file (12.8 kB). View file
|
|
|
__pycache__/conftest.cpython-312-pytest-9.0.3.pyc
ADDED
|
Binary file (173 Bytes). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""诗成画 — write a poem, watch it become a painting, a song, and a living card.
|
| 2 |
+
|
| 3 |
+
Build Small hackathon Space. All models open-weight and under 32B parameters;
|
| 4 |
+
see poetic/providers/ for the swappable provider seam (local ZeroGPU vs HF API).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import gradio as gr
|
| 8 |
+
|
| 9 |
+
from poetic import providers
|
| 10 |
+
from poetic.card import compose_card
|
| 11 |
+
from poetic.song import DEFAULT_SONG_STYLE, build_song_lyrics, song_tags
|
| 12 |
+
from poetic.styles import DEFAULT_IMAGE_STYLE, apply_image_style
|
| 13 |
+
from poetic.textfree import ensure_text_free
|
| 14 |
+
from poetic.video import mux_song_into_video
|
| 15 |
+
|
| 16 |
+
if providers.resolve_mode() == "local":
|
| 17 |
+
# ZeroGPU requires eager module-scope model loading; this import does it.
|
| 18 |
+
from poetic.providers import local_zerogpu # noqa: F401
|
| 19 |
+
|
| 20 |
+
IMAGE_STYLE_CHOICES = [
|
| 21 |
+
("水墨留白", "inkWash"),
|
| 22 |
+
("工笔淡彩", "gongbi"),
|
| 23 |
+
("温柔水彩", "watercolor"),
|
| 24 |
+
("海报色彩", "posterColor"),
|
| 25 |
+
("写实", "realistic"),
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
SONG_STYLE_CHOICES = [
|
| 29 |
+
("古风", "guofeng"),
|
| 30 |
+
("民谣", "folk"),
|
| 31 |
+
("摇篮曲", "lullaby"),
|
| 32 |
+
("戏腔", "operatic"),
|
| 33 |
+
("晨光", "uplifting"),
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def do_interpret(poem: str, correction: str):
|
| 38 |
+
if not poem or not poem.strip():
|
| 39 |
+
raise gr.Error("请先写下您的诗,再请我来读。")
|
| 40 |
+
try:
|
| 41 |
+
interp = providers.get_interpreter().interpret(
|
| 42 |
+
poem.strip(), correction.strip() or None if correction else None
|
| 43 |
+
)
|
| 44 |
+
except ValueError as exc:
|
| 45 |
+
raise gr.Error(f"这一次没有读懂,请再试一下。({exc})") from exc
|
| 46 |
+
return f"### 画中之意\n\n{interp.understanding_zh}", interp.brief_en
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def do_paint(brief_en: str, style_id: str, poem: str, greeting: str):
|
| 50 |
+
if not brief_en:
|
| 51 |
+
raise gr.Error("请先点「读诗」,让我先体会诗意。")
|
| 52 |
+
brief = ensure_text_free(apply_image_style(brief_en, style_id or DEFAULT_IMAGE_STYLE))
|
| 53 |
+
painting = providers.get_painter().paint(brief)
|
| 54 |
+
card = compose_card(painting, poem, greeting or "")
|
| 55 |
+
return card, painting
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def do_sing(poem: str, song_style: str, duration: float):
|
| 59 |
+
if not poem or not poem.strip():
|
| 60 |
+
raise gr.Error("请先写下您的诗。")
|
| 61 |
+
lyrics = build_song_lyrics(poem.strip())
|
| 62 |
+
if not lyrics:
|
| 63 |
+
raise gr.Error("这首诗太短了,再写一两句吧。")
|
| 64 |
+
return providers.get_singer().sing(
|
| 65 |
+
song_tags(song_style or DEFAULT_SONG_STYLE), lyrics, float(duration)
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def do_animate(painting, brief_en: str, audio_path: str | None):
|
| 70 |
+
if painting is None:
|
| 71 |
+
raise gr.Error("请先点「作画」,有了画才能让它动起来。")
|
| 72 |
+
video = providers.get_animator().animate(painting, brief_en or "")
|
| 73 |
+
if audio_path:
|
| 74 |
+
video = mux_song_into_video(video, audio_path)
|
| 75 |
+
return video
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def do_everything(poem: str, greeting: str, style_id: str, song_style: str, duration: float):
|
| 79 |
+
understanding, brief_en = do_interpret(poem, "")
|
| 80 |
+
yield understanding, brief_en, gr.update(), gr.update(), gr.update(), gr.update()
|
| 81 |
+
card, painting = do_paint(brief_en, style_id, poem, greeting)
|
| 82 |
+
yield understanding, brief_en, card, painting, gr.update(), gr.update()
|
| 83 |
+
audio = do_sing(poem, song_style, duration)
|
| 84 |
+
yield understanding, brief_en, card, painting, audio, gr.update()
|
| 85 |
+
video = do_animate(painting, brief_en, audio)
|
| 86 |
+
yield understanding, brief_en, card, painting, audio, video
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
CSS = """
|
| 90 |
+
:root { --ink: #2b2620; --vermilion: #a63a2b; --paper: #f6f1e5; --paper-deep: #efe7d4; }
|
| 91 |
+
.gradio-container {
|
| 92 |
+
background:
|
| 93 |
+
radial-gradient(1200px 500px at 80% -10%, #fbf7ec 0%, transparent 60%),
|
| 94 |
+
radial-gradient(900px 400px at -10% 110%, #f1e8d2 0%, transparent 55%),
|
| 95 |
+
var(--paper) !important;
|
| 96 |
+
font-family: "Noto Serif SC", "Source Han Serif SC", "Songti SC", serif !important;
|
| 97 |
+
color: var(--ink);
|
| 98 |
+
}
|
| 99 |
+
#title-block h1 {
|
| 100 |
+
font-size: 2.6rem; letter-spacing: 0.35rem; color: var(--ink);
|
| 101 |
+
border-bottom: 1px solid #d8cdb4; padding-bottom: 0.6rem;
|
| 102 |
+
}
|
| 103 |
+
#title-block .seal {
|
| 104 |
+
display: inline-block; background: var(--vermilion); color: #faf5eb;
|
| 105 |
+
padding: 0.1rem 0.45rem; border-radius: 4px; margin-left: 0.6rem;
|
| 106 |
+
font-size: 1.4rem; vertical-align: middle;
|
| 107 |
+
}
|
| 108 |
+
.step-card {
|
| 109 |
+
background: rgba(255, 252, 244, 0.82) !important;
|
| 110 |
+
border: 1px solid #ddd2b8 !important; border-radius: 14px !important;
|
| 111 |
+
box-shadow: 0 2px 14px rgba(90, 70, 40, 0.08) !important;
|
| 112 |
+
padding: 1rem !important; margin-top: 1rem !important;
|
| 113 |
+
}
|
| 114 |
+
.step-card .label-wrap span, .step-card label span { font-size: 1.05rem !important; }
|
| 115 |
+
button.primary, .primary {
|
| 116 |
+
background: var(--vermilion) !important; border: none !important;
|
| 117 |
+
font-size: 1.15rem !important; letter-spacing: 0.2rem;
|
| 118 |
+
}
|
| 119 |
+
button.primary:hover { filter: brightness(1.08); }
|
| 120 |
+
textarea, input[type="text"] { font-size: 1.15rem !important; line-height: 1.8 !important; }
|
| 121 |
+
.step-number {
|
| 122 |
+
color: var(--vermilion); font-size: 1.2rem; letter-spacing: 0.2rem; margin-bottom: 0;
|
| 123 |
+
}
|
| 124 |
+
footer { display: none !important; }
|
| 125 |
+
"""
|
| 126 |
+
|
| 127 |
+
with gr.Blocks(title="诗成画 · Poem to Painting & Song") as demo:
|
| 128 |
+
with gr.Column(elem_id="title-block"):
|
| 129 |
+
gr.HTML(
|
| 130 |
+
"<h1>诗成画 <span class='seal'>印</span></h1>"
|
| 131 |
+
"<p style='font-size:1.1rem'>写一首小诗,我来替您读懂它、画出来、唱给您听,再让画活起来。"
|
| 132 |
+
"每一步用的都是开源小模型。</p>"
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
brief_state = gr.State("")
|
| 136 |
+
painting_state = gr.State(None)
|
| 137 |
+
|
| 138 |
+
with gr.Group(elem_classes="step-card"):
|
| 139 |
+
gr.Markdown("**一 · 写诗**", elem_classes="step-number")
|
| 140 |
+
poem_box = gr.Textbox(
|
| 141 |
+
label="您的诗", lines=4, placeholder="把您想说的写成几句小诗,长短都可以。"
|
| 142 |
+
)
|
| 143 |
+
greeting_box = gr.Textbox(
|
| 144 |
+
label="想送的问候(可以不填)", placeholder="例如:早安,或一句祝福的话"
|
| 145 |
+
)
|
| 146 |
+
with gr.Row():
|
| 147 |
+
interpret_btn = gr.Button("读诗", variant="primary")
|
| 148 |
+
everything_btn = gr.Button("一气呵成(读诗·作画·唱诗·入画)")
|
| 149 |
+
|
| 150 |
+
with gr.Group(elem_classes="step-card"):
|
| 151 |
+
gr.Markdown("**二 · 画中之意**", elem_classes="step-number")
|
| 152 |
+
understanding_md = gr.Markdown("点「读诗」后,我会把读到的画面说给您听。")
|
| 153 |
+
correction_box = gr.Textbox(
|
| 154 |
+
label="如果我没读对,请告诉我", placeholder="例如:我想要更温暖的颜色"
|
| 155 |
+
)
|
| 156 |
+
reinterpret_btn = gr.Button("按更正再读一次")
|
| 157 |
+
with gr.Accordion("给画师的英文说明(看看也无妨)", open=False):
|
| 158 |
+
brief_md = gr.Markdown("")
|
| 159 |
+
|
| 160 |
+
with gr.Group(elem_classes="step-card"):
|
| 161 |
+
gr.Markdown("**三 · 作画**", elem_classes="step-number")
|
| 162 |
+
style_radio = gr.Radio(
|
| 163 |
+
IMAGE_STYLE_CHOICES, value=DEFAULT_IMAGE_STYLE, label="选一种画风"
|
| 164 |
+
)
|
| 165 |
+
paint_btn = gr.Button("作画", variant="primary")
|
| 166 |
+
card_image = gr.Image(label="您的画卡", type="pil", interactive=False)
|
| 167 |
+
|
| 168 |
+
with gr.Group(elem_classes="step-card"):
|
| 169 |
+
gr.Markdown("**四 · 唱诗**", elem_classes="step-number")
|
| 170 |
+
with gr.Row():
|
| 171 |
+
song_radio = gr.Radio(
|
| 172 |
+
SONG_STYLE_CHOICES, value=DEFAULT_SONG_STYLE, label="选一种曲风"
|
| 173 |
+
)
|
| 174 |
+
duration_slider = gr.Slider(20, 60, value=40, step=5, label="歌曲长度(秒)")
|
| 175 |
+
sing_btn = gr.Button("唱诗", variant="primary")
|
| 176 |
+
song_audio = gr.Audio(label="您的诗,唱出来了", type="filepath", interactive=False)
|
| 177 |
+
|
| 178 |
+
with gr.Group(elem_classes="step-card"):
|
| 179 |
+
gr.Markdown("**五 · 入画**", elem_classes="step-number")
|
| 180 |
+
gr.Markdown("让画轻轻动起来,配上您的歌,做成一段可以发给家人的小影片。")
|
| 181 |
+
animate_btn = gr.Button("让画活起来", variant="primary")
|
| 182 |
+
video_out = gr.Video(label="会唱歌的画", interactive=False)
|
| 183 |
+
|
| 184 |
+
gr.Examples(
|
| 185 |
+
examples=[
|
| 186 |
+
["床前明月光,疑是地上霜。举头望明月,低头思故乡。", "晚安"],
|
| 187 |
+
["白日依山尽,黄河入海流。欲穷千里目,更上一层楼。", "早安"],
|
| 188 |
+
["春眠不觉晓,处处闻啼鸟。夜来风雨声,花落知多少。", ""],
|
| 189 |
+
],
|
| 190 |
+
inputs=[poem_box, greeting_box],
|
| 191 |
+
label="试试这些诗(也欢迎写您自己的)",
|
| 192 |
+
cache_examples=False,
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
interpret_btn.click(
|
| 196 |
+
do_interpret, inputs=[poem_box, correction_box], outputs=[understanding_md, brief_state]
|
| 197 |
+
).then(lambda b: b, inputs=brief_state, outputs=brief_md)
|
| 198 |
+
reinterpret_btn.click(
|
| 199 |
+
do_interpret, inputs=[poem_box, correction_box], outputs=[understanding_md, brief_state]
|
| 200 |
+
).then(lambda b: b, inputs=brief_state, outputs=brief_md)
|
| 201 |
+
|
| 202 |
+
paint_btn.click(
|
| 203 |
+
do_paint,
|
| 204 |
+
inputs=[brief_state, style_radio, poem_box, greeting_box],
|
| 205 |
+
outputs=[card_image, painting_state],
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
sing_btn.click(
|
| 209 |
+
do_sing, inputs=[poem_box, song_radio, duration_slider], outputs=song_audio
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
animate_btn.click(
|
| 213 |
+
do_animate, inputs=[painting_state, brief_state, song_audio], outputs=video_out
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
everything_btn.click(
|
| 217 |
+
do_everything,
|
| 218 |
+
inputs=[poem_box, greeting_box, style_radio, song_radio, duration_slider],
|
| 219 |
+
outputs=[understanding_md, brief_state, card_image, painting_state, song_audio, video_out],
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
if __name__ == "__main__":
|
| 223 |
+
demo.launch(css=CSS)
|
conftest.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Makes `import poetic` work when running pytest from the space/ directory.
|
poetic/__init__.py
ADDED
|
File without changes
|
poetic/card.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compose the final card: poem calligraphy + greeting + seal over the painting.
|
| 2 |
+
|
| 3 |
+
Mirrors the Expo app's react-native-svg overlay — the painting itself stays
|
| 4 |
+
text-free (TEXT_FREE_RULE); all characters are drawn here, on top.
|
| 5 |
+
Classical layout: vertical columns read right-to-left, seal at bottom-left.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import urllib.request
|
| 10 |
+
|
| 11 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 12 |
+
|
| 13 |
+
from poetic.song import split_poem_lines
|
| 14 |
+
|
| 15 |
+
_FONT_URL = (
|
| 16 |
+
"https://github.com/notofonts/noto-cjk/raw/main/Serif/SubsetOTF/SC/NotoSerifSC-Regular.otf"
|
| 17 |
+
)
|
| 18 |
+
_FONT_CACHE = os.path.join(os.path.expanduser("~"), ".cache", "poetic", "NotoSerifSC-Regular.otf")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _load_font(size: int):
|
| 22 |
+
try:
|
| 23 |
+
if not os.path.exists(_FONT_CACHE):
|
| 24 |
+
os.makedirs(os.path.dirname(_FONT_CACHE), exist_ok=True)
|
| 25 |
+
urllib.request.urlretrieve(_FONT_URL, _FONT_CACHE)
|
| 26 |
+
return ImageFont.truetype(_FONT_CACHE, size)
|
| 27 |
+
except Exception:
|
| 28 |
+
# Offline or font host unreachable: degrade to the default font rather
|
| 29 |
+
# than failing the whole card.
|
| 30 |
+
return ImageFont.load_default(size)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def layout_vertical_columns(
|
| 34 |
+
lines: list[str], char_size: int, gap: int, top: int, right: int
|
| 35 |
+
) -> list[tuple[str, int, int]]:
|
| 36 |
+
"""Place poem characters in vertical columns, right-to-left.
|
| 37 |
+
|
| 38 |
+
Returns (char, x, y) tuples. Pure geometry — unit-testable without PIL.
|
| 39 |
+
"""
|
| 40 |
+
placements: list[tuple[str, int, int]] = []
|
| 41 |
+
column_pitch = char_size + gap
|
| 42 |
+
for col, line in enumerate(lines):
|
| 43 |
+
x = right - (col + 1) * column_pitch
|
| 44 |
+
for row, char in enumerate(line):
|
| 45 |
+
placements.append((char, x, top + row * (char_size + gap // 2)))
|
| 46 |
+
return placements
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def compose_card(
|
| 50 |
+
painting: Image.Image,
|
| 51 |
+
poem: str,
|
| 52 |
+
greeting: str = "",
|
| 53 |
+
seal_text: str = "诗",
|
| 54 |
+
) -> Image.Image:
|
| 55 |
+
card = painting.convert("RGB").copy()
|
| 56 |
+
draw = ImageDraw.Draw(card)
|
| 57 |
+
w, h = card.size
|
| 58 |
+
|
| 59 |
+
char_size = max(18, w // 18)
|
| 60 |
+
font = _load_font(char_size)
|
| 61 |
+
lines = split_poem_lines(poem)[:4]
|
| 62 |
+
|
| 63 |
+
for char, x, y in layout_vertical_columns(
|
| 64 |
+
lines, char_size, gap=char_size // 3, top=h // 12, right=w - w // 16
|
| 65 |
+
):
|
| 66 |
+
draw.text((x + 1, y + 2), char, font=font, fill=(40, 30, 20, 160))
|
| 67 |
+
draw.text((x, y), char, font=font, fill=(35, 30, 28))
|
| 68 |
+
|
| 69 |
+
if greeting:
|
| 70 |
+
greeting_size = max(24, w // 10)
|
| 71 |
+
greeting_font = _load_font(greeting_size)
|
| 72 |
+
gx, gy = w // 14, h - h // 5
|
| 73 |
+
draw.text((gx + 2, gy + 2), greeting, font=greeting_font, fill=(40, 30, 20, 160))
|
| 74 |
+
draw.text((gx, gy), greeting, font=greeting_font, fill=(120, 30, 25))
|
| 75 |
+
|
| 76 |
+
seal = max(20, w // 16)
|
| 77 |
+
sx, sy = w // 14, h - h // 12 - seal
|
| 78 |
+
draw.rectangle([sx, sy, sx + seal, sy + seal], fill=(178, 34, 34))
|
| 79 |
+
seal_font = _load_font(int(seal * 0.72))
|
| 80 |
+
draw.text((sx + seal * 0.14, sy + seal * 0.08), seal_text[:1], font=seal_font, fill=(250, 245, 235))
|
| 81 |
+
|
| 82 |
+
return card
|
poetic/interpretation.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@dataclass(frozen=True)
|
| 7 |
+
class Interpretation:
|
| 8 |
+
understanding_zh: str
|
| 9 |
+
brief_en: str
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def build_interpret_contents(poem: str, correction: str | None = None) -> str:
|
| 13 |
+
base = f"诗:\n{poem}"
|
| 14 |
+
return f"{base}\n\n长者的更正:{correction}" if correction else base
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
_FENCE_RE = re.compile(r"^```[a-zA-Z]*\s*|\s*```$", re.MULTILINE)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def parse_interpretation(text: str) -> Interpretation:
|
| 21 |
+
"""Parse the model's reply into both audience fields.
|
| 22 |
+
|
| 23 |
+
MiniCPM/Qwen have no enforced response schema (unlike Vertex AI), so this
|
| 24 |
+
repairs the two common wrappings before giving up: Markdown code fences and
|
| 25 |
+
prose surrounding the JSON object.
|
| 26 |
+
"""
|
| 27 |
+
candidates = [text, _FENCE_RE.sub("", text).strip()]
|
| 28 |
+
start, end = text.find("{"), text.rfind("}")
|
| 29 |
+
if start != -1 and end > start:
|
| 30 |
+
candidates.append(text[start : end + 1])
|
| 31 |
+
|
| 32 |
+
obj = None
|
| 33 |
+
for candidate in candidates:
|
| 34 |
+
try:
|
| 35 |
+
obj = json.loads(candidate)
|
| 36 |
+
break
|
| 37 |
+
except (json.JSONDecodeError, ValueError):
|
| 38 |
+
continue
|
| 39 |
+
if obj is None:
|
| 40 |
+
raise ValueError(f"Failed to parse interpretation JSON: {text[:120]}")
|
| 41 |
+
|
| 42 |
+
understanding = obj.get("understandingZh") if isinstance(obj, dict) else None
|
| 43 |
+
brief = obj.get("briefEn") if isinstance(obj, dict) else None
|
| 44 |
+
if not isinstance(understanding, str) or not isinstance(brief, str):
|
| 45 |
+
raise ValueError("Interpretation missing required fields")
|
| 46 |
+
return Interpretation(understanding_zh=understanding, brief_en=brief)
|
poetic/motion.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Stage 4 (入画 / animate): the painting must come alive without being repainted.
|
| 2 |
+
# The motion clause asks for ambient, loopable movement and pins the composition
|
| 3 |
+
# so the video reads as "the card breathing", not a new scene.
|
| 4 |
+
MOTION_CLAUSE = (
|
| 5 |
+
"The painting gently comes alive: soft ambient motion only — mist drifting, water rippling,"
|
| 6 |
+
" petals or leaves swaying, light shimmering. Slow, dreamy, loop-friendly movement."
|
| 7 |
+
" Keep the exact composition, palette and subjects of the painting; no new objects,"
|
| 8 |
+
" no camera cuts, no text."
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def build_motion_prompt(brief: str) -> str:
|
| 13 |
+
return f"{brief.strip()}\n\n{MOTION_CLAUSE}"
|
poetic/prompts.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# System instruction for Stage 1 (读诗 / interpret), ported verbatim from
|
| 2 |
+
# src/lib/interpretPrompt.ts so the hackathon Space and the production app
|
| 3 |
+
# read poems the same way. The model must return JSON: {understandingZh, briefEn}.
|
| 4 |
+
INTERPRET_SYSTEM_PROMPT = """
|
| 5 |
+
你是一位懂中国古典诗意和中国画构图的温和读诗人,正在帮助一位长者把他亲手写的中文诗变成一张早安画卡。
|
| 6 |
+
|
| 7 |
+
请先体会诗的意境、情绪、季节、时间、光线和空间感,不要只机械地罗列诗中的物件。若诗意含蓄,请用山水、花鸟、留白等中国审美词汇表达其气韵,但不要把最终画风锁死;应用稍后会按用户选择改成水彩、工笔、写实等不同画风。除非诗中明确需要人物,否则避免画清晰人脸。
|
| 8 |
+
|
| 9 |
+
若输入中包含"长者的更正",请把它当作最重要的指示,据此重新调整你的理解与画面。
|
| 10 |
+
|
| 11 |
+
你必须只返回 JSON,不要 Markdown 或解释文字。JSON 包含两个字段:
|
| 12 |
+
understandingZh:用温暖、平实、尊重的简体中文,简短地(两三句话)向长者复述你读到的画面和意境,让他觉得"这首诗被懂了"。不要使用技术词。
|
| 13 |
+
briefEn:写给图像模型的、流畅自然且具体的英文画面说明,描述场景、季节、时间、天气、构图、色调、主体关系和情绪。重点写清楚“画什么”和“怎样构图”,不要强行指定最终画风;可以把 shan-shui (山水)、liúbái (留白) 作为意境和构图词,但避免把所有 brief 都写成 ink-wash 或 classical Chinese painting。
|
| 14 |
+
|
| 15 |
+
briefEn 必须明确要求背景图不要包含任何文字,因为诗和问候语会由应用稍后叠加。briefEn 必须原样写入这条英文硬性规则:
|
| 16 |
+
a painting only — no text, no Chinese characters, no calligraphy, no seals, no watermark
|
| 17 |
+
|
| 18 |
+
不要替用户写诗,不要添加新的问候语,不要把诗句放进画面里。
|
| 19 |
+
""".strip()
|
poetic/providers/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provider registry — one seam per capability so models swap by config, not code.
|
| 2 |
+
|
| 3 |
+
Selection order per capability:
|
| 4 |
+
1. Explicit env var (INTERPRET_PROVIDER / PAINT_PROVIDER / SING_PROVIDER / ANIMATE_PROVIDER)
|
| 5 |
+
2. POETIC_MODE bundle ("local" = on-Space ZeroGPU pipelines, "api" = remote inference)
|
| 6 |
+
3. Default mode: "local" when running on ZeroGPU, else "api".
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
from functools import cache
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def resolve_mode() -> str:
|
| 14 |
+
mode = os.environ.get("POETIC_MODE")
|
| 15 |
+
if mode in ("local", "api"):
|
| 16 |
+
return mode
|
| 17 |
+
return "local" if os.environ.get("SPACES_ZERO_GPU") else "api"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _resolve(capability: str, env_var: str):
|
| 21 |
+
name = os.environ.get(env_var) or resolve_mode()
|
| 22 |
+
if name == "local":
|
| 23 |
+
from poetic.providers import local_zerogpu as module
|
| 24 |
+
elif name == "api":
|
| 25 |
+
from poetic.providers import hf_api as module
|
| 26 |
+
else:
|
| 27 |
+
raise ValueError(
|
| 28 |
+
f"Unknown {env_var}={name!r}; expected 'local' or 'api'"
|
| 29 |
+
)
|
| 30 |
+
return getattr(module, capability)()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@cache
|
| 34 |
+
def get_interpreter():
|
| 35 |
+
return _resolve("Interpreter", "INTERPRET_PROVIDER")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@cache
|
| 39 |
+
def get_painter():
|
| 40 |
+
return _resolve("Painter", "PAINT_PROVIDER")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@cache
|
| 44 |
+
def get_singer():
|
| 45 |
+
return _resolve("Singer", "SING_PROVIDER")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@cache
|
| 49 |
+
def get_animator():
|
| 50 |
+
return _resolve("Animator", "ANIMATE_PROVIDER")
|
poetic/providers/hf_api.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Remote providers via HF Inference Providers / Spaces — every model open-weight, <32B.
|
| 2 |
+
|
| 3 |
+
This bundle works anywhere with an HF_TOKEN (dev boxes, CPU Spaces). The local
|
| 4 |
+
ZeroGPU bundle (local_zerogpu.py) is the hackathon-submission path; this one is
|
| 5 |
+
the swap target the rest of the app can fall back to without code changes.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import tempfile
|
| 10 |
+
|
| 11 |
+
from huggingface_hub import InferenceClient
|
| 12 |
+
|
| 13 |
+
from poetic.interpretation import Interpretation, build_interpret_contents, parse_interpretation
|
| 14 |
+
from poetic.prompts import INTERPRET_SYSTEM_PROMPT
|
| 15 |
+
|
| 16 |
+
# MiniCPM is not on HF Inference Providers, so api-mode interpretation uses an
|
| 17 |
+
# open zh-strong chat model; the Space's local mode uses MiniCPM itself.
|
| 18 |
+
DEFAULT_INTERPRET_MODEL = "Qwen/Qwen3-8B"
|
| 19 |
+
# The router exposes FLUX.2-klein only as image-to-image, so api mode paints
|
| 20 |
+
# with Qwen-Image (20B, Apache-2.0); local mode uses FLUX.2-klein-4B directly.
|
| 21 |
+
DEFAULT_PAINT_MODEL = "Qwen/Qwen-Image"
|
| 22 |
+
DEFAULT_SING_SPACE = "ACE-Step/Ace-Step-v1.5"
|
| 23 |
+
DEFAULT_ANIMATE_MODEL = "Wan-AI/Wan2.2-TI2V-5B"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _client(provider_env: str | None = None) -> InferenceClient:
|
| 27 |
+
# Routing provider (fal-ai, together, replicate, …) is chosen per capability
|
| 28 |
+
# (e.g. PAINT_HF_PROVIDER), falling back to HF_PROVIDER, then router "auto".
|
| 29 |
+
# Per-capability matters: each model is live on a different provider set.
|
| 30 |
+
provider = (provider_env and os.environ.get(provider_env)) or os.environ.get("HF_PROVIDER")
|
| 31 |
+
return InferenceClient(token=os.environ.get("HF_TOKEN"), provider=provider)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class Interpreter:
|
| 35 |
+
def interpret(self, poem: str, correction: str | None = None) -> Interpretation:
|
| 36 |
+
model = os.environ.get("INTERPRET_MODEL", DEFAULT_INTERPRET_MODEL)
|
| 37 |
+
response = _client("INTERPRET_HF_PROVIDER").chat.completions.create(
|
| 38 |
+
model=model,
|
| 39 |
+
messages=[
|
| 40 |
+
{"role": "system", "content": INTERPRET_SYSTEM_PROMPT},
|
| 41 |
+
{"role": "user", "content": build_interpret_contents(poem, correction)},
|
| 42 |
+
],
|
| 43 |
+
max_tokens=1024,
|
| 44 |
+
)
|
| 45 |
+
return parse_interpretation(response.choices[0].message.content)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class Painter:
|
| 49 |
+
def paint(self, brief: str):
|
| 50 |
+
model = os.environ.get("PAINT_MODEL", DEFAULT_PAINT_MODEL)
|
| 51 |
+
return _client("PAINT_HF_PROVIDER").text_to_image(brief, model=model, width=768, height=1024)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _first_audio_path(result) -> str | None:
|
| 55 |
+
"""Depth-first scan of a gradio_client result for the first audio file."""
|
| 56 |
+
if isinstance(result, str) and result.lower().endswith((".mp3", ".flac", ".wav")):
|
| 57 |
+
return result
|
| 58 |
+
if isinstance(result, dict):
|
| 59 |
+
for value in result.values():
|
| 60 |
+
if (found := _first_audio_path(value)) is not None:
|
| 61 |
+
return found
|
| 62 |
+
if isinstance(result, (list, tuple)):
|
| 63 |
+
for value in result:
|
| 64 |
+
if (found := _first_audio_path(value)) is not None:
|
| 65 |
+
return found
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class Singer:
|
| 70 |
+
def sing(self, tags: str, lyrics: str, duration: float = 40.0) -> str:
|
| 71 |
+
"""Generate a sung rendition through the official ACE-Step Space.
|
| 72 |
+
|
| 73 |
+
Dev/fallback path only — the hackathon Space runs ACEStepPipeline
|
| 74 |
+
locally. The wrapper endpoint exposes ~50 positional params; we set the
|
| 75 |
+
custom-mode essentials (caption=param_4, lyrics=param_5,
|
| 76 |
+
duration=param_15) and leave the rest at their defaults.
|
| 77 |
+
"""
|
| 78 |
+
from gradio_client import Client
|
| 79 |
+
|
| 80 |
+
space = os.environ.get("SING_SPACE", DEFAULT_SING_SPACE)
|
| 81 |
+
client = Client(space, token=os.environ.get("HF_TOKEN"))
|
| 82 |
+
# The wrapper marks several params required while still publishing
|
| 83 |
+
# defaults; build the full kwargs from the live API info so this stays
|
| 84 |
+
# robust as the Space evolves, then override our four essentials.
|
| 85 |
+
endpoint = client.view_api(return_format="dict", print_info=False)[
|
| 86 |
+
"named_endpoints"
|
| 87 |
+
]["/generation_wrapper"]
|
| 88 |
+
kwargs = {
|
| 89 |
+
p["parameter_name"]: p.get("parameter_default")
|
| 90 |
+
for p in endpoint["parameters"]
|
| 91 |
+
if p.get("parameter_name")
|
| 92 |
+
}
|
| 93 |
+
kwargs.update(
|
| 94 |
+
selected_model=os.environ.get("SING_SPACE_MODEL", "acestep-v15-turbo"),
|
| 95 |
+
generation_mode="custom",
|
| 96 |
+
param_4=tags, # caption / style tags
|
| 97 |
+
param_5=lyrics, # lyrics
|
| 98 |
+
param_15=float(duration),
|
| 99 |
+
)
|
| 100 |
+
result = client.predict(api_name="/generation_wrapper", **kwargs)
|
| 101 |
+
audio = _first_audio_path(result)
|
| 102 |
+
if audio is None:
|
| 103 |
+
raise RuntimeError(f"No audio file in ACE-Step Space response: {str(result)[:200]}")
|
| 104 |
+
return audio
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class Animator:
|
| 108 |
+
def animate(self, image, brief: str) -> str:
|
| 109 |
+
"""API mode runs text-to-video (the HF router exposes Wan2.2 as t2v only),
|
| 110 |
+
so the painting image is used as mood reference via the brief, not as the
|
| 111 |
+
first frame. The local provider does true image-to-video."""
|
| 112 |
+
model = os.environ.get("ANIMATE_MODEL", DEFAULT_ANIMATE_MODEL)
|
| 113 |
+
video_bytes = _client("ANIMATE_HF_PROVIDER").text_to_video(
|
| 114 |
+
f"{brief}\n\nGentle, slow, dreamy camera drift; subtle natural motion only.",
|
| 115 |
+
model=model,
|
| 116 |
+
)
|
| 117 |
+
out = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
|
| 118 |
+
out.write(video_bytes)
|
| 119 |
+
out.close()
|
| 120 |
+
return out.name
|
poetic/providers/local_zerogpu.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""On-Space providers: every model open-weight and under 32B, running on ZeroGPU.
|
| 2 |
+
|
| 3 |
+
interpret openbmb/MiniCPM4.1-8B (sponsor: OpenBMB)
|
| 4 |
+
paint black-forest-labs/FLUX.2-klein-4B (sponsor: Black Forest Labs)
|
| 5 |
+
sing ACE-Step/Ace-Step1.5 (~3.5B)
|
| 6 |
+
animate Wan-AI/Wan2.2-TI2V-5B-Diffusers
|
| 7 |
+
|
| 8 |
+
ZeroGPU rules applied here: models load eagerly at module scope with
|
| 9 |
+
device="cuda" (the platform registers weights and streams them in per call);
|
| 10 |
+
real CUDA work only happens inside @spaces.GPU functions; every declared
|
| 11 |
+
duration is the realistic worst case, not the default 60s; outputs go to
|
| 12 |
+
tempfiles; returns are picklable (PIL images, file paths).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
import tempfile
|
| 17 |
+
|
| 18 |
+
import spaces
|
| 19 |
+
import torch
|
| 20 |
+
|
| 21 |
+
from poetic.interpretation import Interpretation, build_interpret_contents, parse_interpretation
|
| 22 |
+
from poetic.motion import build_motion_prompt
|
| 23 |
+
from poetic.prompts import INTERPRET_SYSTEM_PROMPT
|
| 24 |
+
|
| 25 |
+
INTERPRET_MODEL_ID = os.environ.get("INTERPRET_LOCAL_MODEL", "openbmb/MiniCPM4.1-8B")
|
| 26 |
+
PAINT_MODEL_ID = os.environ.get("PAINT_LOCAL_MODEL", "black-forest-labs/FLUX.2-klein-4B")
|
| 27 |
+
ANIMATE_MODEL_ID = os.environ.get("ANIMATE_LOCAL_MODEL", "Wan-AI/Wan2.2-TI2V-5B-Diffusers")
|
| 28 |
+
|
| 29 |
+
# Painting is 3:4 portrait, matching the production app's card format.
|
| 30 |
+
PAINT_WIDTH, PAINT_HEIGHT = 768, 1024
|
| 31 |
+
|
| 32 |
+
ANIMATE_STEPS = int(os.environ.get("ANIMATE_STEPS", "20"))
|
| 33 |
+
ANIMATE_FRAMES = int(os.environ.get("ANIMATE_FRAMES", "49"))
|
| 34 |
+
ANIMATE_FPS = 16
|
| 35 |
+
|
| 36 |
+
_WAN_NEGATIVE_PROMPT = (
|
| 37 |
+
"色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,"
|
| 38 |
+
"低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,"
|
| 39 |
+
"毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走"
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 43 |
+
|
| 44 |
+
# --- interpret: MiniCPM ----------------------------------------------------
|
| 45 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer # noqa: E402
|
| 46 |
+
|
| 47 |
+
_tokenizer = AutoTokenizer.from_pretrained(INTERPRET_MODEL_ID, trust_remote_code=True)
|
| 48 |
+
_llm = AutoModelForCausalLM.from_pretrained(
|
| 49 |
+
INTERPRET_MODEL_ID, torch_dtype=torch.bfloat16, trust_remote_code=True
|
| 50 |
+
).to(device)
|
| 51 |
+
|
| 52 |
+
# --- paint: FLUX.2 Klein ----------------------------------------------------
|
| 53 |
+
from diffusers import Flux2KleinPipeline # noqa: E402
|
| 54 |
+
|
| 55 |
+
_flux = Flux2KleinPipeline.from_pretrained(PAINT_MODEL_ID, torch_dtype=torch.bfloat16).to(device)
|
| 56 |
+
|
| 57 |
+
# --- animate: Wan 2.2 TI2V-5B ----------------------------------------------
|
| 58 |
+
from diffusers import WanImageToVideoPipeline # noqa: E402
|
| 59 |
+
from diffusers.utils import export_to_video # noqa: E402
|
| 60 |
+
|
| 61 |
+
_wan = WanImageToVideoPipeline.from_pretrained(
|
| 62 |
+
ANIMATE_MODEL_ID, torch_dtype=torch.bfloat16
|
| 63 |
+
).to(device)
|
| 64 |
+
|
| 65 |
+
# --- sing: ACE-Step ---------------------------------------------------------
|
| 66 |
+
# ACEStepPipeline manages its own checkpoint download and device placement
|
| 67 |
+
# lazily on first call (inside the GPU context); constructing it here is cheap.
|
| 68 |
+
from acestep.pipeline_ace_step import ACEStepPipeline # noqa: E402
|
| 69 |
+
|
| 70 |
+
_acestep = ACEStepPipeline(dtype="bfloat16")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@spaces.GPU(duration=45)
|
| 74 |
+
def _interpret_gpu(poem: str, correction: str | None) -> str:
|
| 75 |
+
messages = [
|
| 76 |
+
{"role": "system", "content": INTERPRET_SYSTEM_PROMPT},
|
| 77 |
+
{"role": "user", "content": build_interpret_contents(poem, correction)},
|
| 78 |
+
]
|
| 79 |
+
try:
|
| 80 |
+
prompt_text = _tokenizer.apply_chat_template(
|
| 81 |
+
messages, tokenize=False, add_generation_prompt=True, enable_thinking=False
|
| 82 |
+
)
|
| 83 |
+
except TypeError: # chat template without an enable_thinking switch
|
| 84 |
+
prompt_text = _tokenizer.apply_chat_template(
|
| 85 |
+
messages, tokenize=False, add_generation_prompt=True
|
| 86 |
+
)
|
| 87 |
+
inputs = _tokenizer(prompt_text, return_tensors="pt").to(device)
|
| 88 |
+
output = _llm.generate(
|
| 89 |
+
**inputs, max_new_tokens=1024, do_sample=True, temperature=0.7, top_p=0.95
|
| 90 |
+
)
|
| 91 |
+
return _tokenizer.decode(output[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@spaces.GPU(duration=30)
|
| 95 |
+
def _paint_gpu(brief: str):
|
| 96 |
+
return _flux(
|
| 97 |
+
brief,
|
| 98 |
+
num_inference_steps=4,
|
| 99 |
+
guidance_scale=1.0,
|
| 100 |
+
width=PAINT_WIDTH,
|
| 101 |
+
height=PAINT_HEIGHT,
|
| 102 |
+
).images[0]
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@spaces.GPU(duration=90)
|
| 106 |
+
def _sing_gpu(tags: str, lyrics: str, duration: float) -> str:
|
| 107 |
+
out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
| 108 |
+
out.close()
|
| 109 |
+
_acestep(
|
| 110 |
+
prompt=tags,
|
| 111 |
+
lyrics=lyrics,
|
| 112 |
+
audio_duration=duration,
|
| 113 |
+
save_path=out.name,
|
| 114 |
+
)
|
| 115 |
+
return out.name
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _estimate_animate_duration(image, brief: str) -> int:
|
| 119 |
+
# ~6s per denoising step at 768x1024x49f on a half-slice, plus model stream-in.
|
| 120 |
+
return min(540, 60 + ANIMATE_STEPS * 6 * max(1, ANIMATE_FRAMES // 49))
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@spaces.GPU(duration=_estimate_animate_duration)
|
| 124 |
+
def _animate_gpu(image, brief: str) -> str:
|
| 125 |
+
frames = _wan(
|
| 126 |
+
image=image.convert("RGB").resize((PAINT_WIDTH, PAINT_HEIGHT)),
|
| 127 |
+
prompt=build_motion_prompt(brief),
|
| 128 |
+
negative_prompt=_WAN_NEGATIVE_PROMPT,
|
| 129 |
+
width=PAINT_WIDTH,
|
| 130 |
+
height=PAINT_HEIGHT,
|
| 131 |
+
num_frames=ANIMATE_FRAMES,
|
| 132 |
+
num_inference_steps=ANIMATE_STEPS,
|
| 133 |
+
guidance_scale=5.0,
|
| 134 |
+
).frames[0]
|
| 135 |
+
out = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
|
| 136 |
+
out.close()
|
| 137 |
+
export_to_video(frames, out.name, fps=ANIMATE_FPS)
|
| 138 |
+
return out.name
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class Interpreter:
|
| 142 |
+
def interpret(self, poem: str, correction: str | None = None) -> Interpretation:
|
| 143 |
+
return parse_interpretation(_interpret_gpu(poem, correction))
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
class Painter:
|
| 147 |
+
def paint(self, brief: str):
|
| 148 |
+
return _paint_gpu(brief)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class Singer:
|
| 152 |
+
def sing(self, tags: str, lyrics: str, duration: float = 40.0) -> str:
|
| 153 |
+
return _sing_gpu(tags, lyrics, duration)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
class Animator:
|
| 157 |
+
def animate(self, image, brief: str) -> str:
|
| 158 |
+
return _animate_gpu(image, brief)
|
poetic/song.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
# Stage 3 (唱诗 / sing): turn the poem into ACE-Step inputs. ACE-Step takes a
|
| 4 |
+
# comma-separated style/instrument/vocal tag prompt plus lyrics with [verse] /
|
| 5 |
+
# [chorus] structure tags. Every preset must request vocals — the whole point
|
| 6 |
+
# is the poem being sung, never an instrumental.
|
| 7 |
+
|
| 8 |
+
SONG_STYLE_IDS = ("guofeng", "folk", "lullaby", "operatic", "uplifting")
|
| 9 |
+
|
| 10 |
+
DEFAULT_SONG_STYLE = "guofeng"
|
| 11 |
+
|
| 12 |
+
SONG_STYLE_TAGS = {
|
| 13 |
+
"guofeng": (
|
| 14 |
+
"chinese classical guofeng, guzheng, dizi flute, erhu strings, gentle female vocals,"
|
| 15 |
+
" elegant, serene, poetic ballad, mandarin singing"
|
| 16 |
+
),
|
| 17 |
+
"folk": (
|
| 18 |
+
"acoustic folk ballad, fingerstyle guitar, warm male vocals, intimate, storytelling,"
|
| 19 |
+
" soft percussion, mandarin singing"
|
| 20 |
+
),
|
| 21 |
+
"lullaby": (
|
| 22 |
+
"gentle lullaby, music box, soft humming female vocals, slow tempo, warm, soothing,"
|
| 23 |
+
" night song, mandarin singing"
|
| 24 |
+
),
|
| 25 |
+
"operatic": (
|
| 26 |
+
"chinese opera influenced, jinghu, dramatic operatic female vocals, traditional percussion,"
|
| 27 |
+
" expressive xiqiang, mandarin singing"
|
| 28 |
+
),
|
| 29 |
+
"uplifting": (
|
| 30 |
+
"uplifting pop ballad, piano, strings, bright female vocals, hopeful sunrise mood,"
|
| 31 |
+
" morning energy, mandarin singing"
|
| 32 |
+
),
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def is_song_style_id(value: object) -> bool:
|
| 37 |
+
return isinstance(value, str) and value in SONG_STYLE_IDS
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def song_tags(style_id: str) -> str:
|
| 41 |
+
return SONG_STYLE_TAGS.get(style_id, SONG_STYLE_TAGS[DEFAULT_SONG_STYLE])
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
_LINE_SPLIT_RE = re.compile(r"[,。!?;、…,.;!? ]+")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def split_poem_lines(poem: str) -> list[str]:
|
| 48 |
+
"""Split a poem into lyric lines on newlines and Chinese/ASCII punctuation."""
|
| 49 |
+
lines: list[str] = []
|
| 50 |
+
for raw_line in poem.splitlines():
|
| 51 |
+
for piece in _LINE_SPLIT_RE.split(raw_line):
|
| 52 |
+
piece = piece.strip()
|
| 53 |
+
if piece:
|
| 54 |
+
lines.append(piece)
|
| 55 |
+
return lines
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def build_song_lyrics(poem: str) -> str:
|
| 59 |
+
"""Format the poem as ACE-Step lyrics: the full poem as a verse, with the
|
| 60 |
+
opening couplet repeated as a chorus so the song has a memorable hook."""
|
| 61 |
+
lines = split_poem_lines(poem)
|
| 62 |
+
if not lines:
|
| 63 |
+
return ""
|
| 64 |
+
|
| 65 |
+
verse = "\n".join(lines)
|
| 66 |
+
chorus = "\n".join(lines[:2])
|
| 67 |
+
return f"[verse]\n{verse}\n\n[chorus]\n{chorus}"
|
poetic/styles.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
# Ported from src/lib/imageStyle.ts — the five curated painting styles and the
|
| 4 |
+
# realistic-style conflict scrubbing that keeps user style choice authoritative.
|
| 5 |
+
|
| 6 |
+
IMAGE_STYLE_IDS = ("inkWash", "gongbi", "watercolor", "posterColor", "realistic")
|
| 7 |
+
|
| 8 |
+
DEFAULT_IMAGE_STYLE = "watercolor"
|
| 9 |
+
|
| 10 |
+
STYLE_CLAUSES = {
|
| 11 |
+
"inkWash": (
|
| 12 |
+
"Selected style / 已选画风: Ink wash / 水墨留白. Render the scene as classical Chinese"
|
| 13 |
+
" shui-mo ink-wash painting with shan-shui feeling, soft ink diffusion, restrained palette,"
|
| 14 |
+
" generous liúbái (留白), poetic mist, elegant brushwork."
|
| 15 |
+
),
|
| 16 |
+
"gongbi": (
|
| 17 |
+
"Selected style / 已选画风: Gongbi / 工笔淡彩. Render the scene as refined gongbi-style"
|
| 18 |
+
" classical Chinese painting, delicate fine-line brushwork, light mineral colors, graceful"
|
| 19 |
+
" botanical and landscape details, calm scholarly elegance."
|
| 20 |
+
),
|
| 21 |
+
"watercolor": (
|
| 22 |
+
"Selected style / 已选画风: Watercolor / 温柔水彩. Reinterpret the same poem mood as soft"
|
| 23 |
+
" transparent watercolor painting, gentle washes, wet-on-wet edges, airy light, warm handmade"
|
| 24 |
+
" paper texture, quiet morning-card mood. Conflicting ink-wash or gongbi wording in the scene"
|
| 25 |
+
" brief is only a mood reference."
|
| 26 |
+
),
|
| 27 |
+
"posterColor": (
|
| 28 |
+
"Selected style / 已选画风: Poster color / 海报色彩. Reinterpret the same poem mood as vintage"
|
| 29 |
+
" poster-color and gouache painting, richer flat color fields, simplified elegant shapes, warm"
|
| 30 |
+
" auspicious palette, refined print-like composition. Conflicting ink-wash or gongbi wording in"
|
| 31 |
+
" the scene brief is only a mood reference."
|
| 32 |
+
),
|
| 33 |
+
"realistic": (
|
| 34 |
+
"Selected style / 已选画风: Realistic / 写实. This selection is mandatory and overrides any"
|
| 35 |
+
" earlier Chinese-painting, shui-mo, gongbi, watercolor, shan-shui, scroll, antique paper,"
|
| 36 |
+
" ink-brush, or illustration style wording in the scene brief. Preserve only the poem's subject,"
|
| 37 |
+
" season, mood, and composition cues. Render a realistic, life-like scene from the real world"
|
| 38 |
+
" with natural sunlight, believable depth, true botanical forms, real textures and materials,"
|
| 39 |
+
" atmospheric perspective, and a subtle painterly finish. Avoid stylized Chinese painting, ink"
|
| 40 |
+
" wash, watercolor wash, flat poster shapes, calligraphy aesthetics, scroll-paper framing,"
|
| 41 |
+
" ornamental borders, and decorative seal motifs."
|
| 42 |
+
),
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
_REALISTIC_CONFLICT_PATTERNS = [
|
| 46 |
+
re.compile(p, re.IGNORECASE)
|
| 47 |
+
for p in (
|
| 48 |
+
r"\bclassical Chinese (?:painting|art)\b",
|
| 49 |
+
r"\btraditional Chinese (?:painting|art)\b",
|
| 50 |
+
r"\bChinese (?:ink[- ]?wash|painting|brush|scroll)\b",
|
| 51 |
+
r"\bshan[- ]?shui\b",
|
| 52 |
+
r"\bshui[- ]?mo\b",
|
| 53 |
+
r"\bgongbi(?:-style)?\b",
|
| 54 |
+
r"\bink[- ]?wash(?: painting)?\b",
|
| 55 |
+
r"\bwatercolor(?: wash| painting)?\b",
|
| 56 |
+
r"\bposter[- ]?color\b",
|
| 57 |
+
r"\bgouache\b",
|
| 58 |
+
r"\bscroll[- ]?paper\b",
|
| 59 |
+
r"\bantique paper\b",
|
| 60 |
+
r"\bhandmade paper texture\b",
|
| 61 |
+
r"\bbrushwork\b",
|
| 62 |
+
r"\bcalligraphy aesthetics?\b",
|
| 63 |
+
r"\bornamental borders?\b",
|
| 64 |
+
r"\bdecorative seal motifs?\b",
|
| 65 |
+
r"\bclassical painting\b",
|
| 66 |
+
)
|
| 67 |
+
] + [re.compile(r"水墨|山水|工笔|淡彩|留白|书法|印章|卷轴|宣纸|国画|古画")]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def is_image_style_id(value: object) -> bool:
|
| 71 |
+
return isinstance(value, str) and value in IMAGE_STYLE_IDS
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def apply_image_style(brief: str, style_id: str = DEFAULT_IMAGE_STYLE) -> str:
|
| 75 |
+
clause = STYLE_CLAUSES.get(style_id, STYLE_CLAUSES[DEFAULT_IMAGE_STYLE])
|
| 76 |
+
clean_brief = _sanitize_brief_for_style(brief, style_id)
|
| 77 |
+
|
| 78 |
+
if style_id == "realistic":
|
| 79 |
+
return "\n\n".join(
|
| 80 |
+
[
|
| 81 |
+
clause,
|
| 82 |
+
"Real-world scene description extracted from the poem, with conflicting art-style words removed:",
|
| 83 |
+
clean_brief,
|
| 84 |
+
"Make it look like a believable real-world scene first, not an illustration or Chinese"
|
| 85 |
+
" painting. Keep the generated image text-free; the app adds poem text later.",
|
| 86 |
+
]
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
return "\n\n".join(
|
| 90 |
+
[
|
| 91 |
+
"The user's selected visual style is authoritative and must control the final image. If the"
|
| 92 |
+
" scene brief below contains style words that conflict with the selected style, keep only the"
|
| 93 |
+
" poem meaning, subject, mood, and composition cues, then render in the selected style.",
|
| 94 |
+
f"Scene brief to reinterpret:\n{clean_brief}",
|
| 95 |
+
clause,
|
| 96 |
+
]
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _sanitize_brief_for_style(brief: str, style_id: str) -> str:
|
| 101 |
+
trimmed = brief.strip()
|
| 102 |
+
if style_id != "realistic":
|
| 103 |
+
return trimmed
|
| 104 |
+
|
| 105 |
+
without_conflicts = trimmed
|
| 106 |
+
for pattern in _REALISTIC_CONFLICT_PATTERNS:
|
| 107 |
+
without_conflicts = pattern.sub("", without_conflicts)
|
| 108 |
+
|
| 109 |
+
cleaned = without_conflicts
|
| 110 |
+
cleaned = re.sub(r"\s{2,}", " ", cleaned)
|
| 111 |
+
cleaned = re.sub(r"\s+([,.;:])", r"\1", cleaned)
|
| 112 |
+
cleaned = re.sub(r"(?:,\s*){2,}", ", ", cleaned)
|
| 113 |
+
cleaned = re.sub(r"\b(on|in|with|as|of)\s+(?:with|on|in|as|of)\b", r"\1", cleaned, flags=re.IGNORECASE)
|
| 114 |
+
cleaned = re.sub(r"\b(?:on|in|with|as|of)\s*([,.;:])", r"\1", cleaned, flags=re.IGNORECASE)
|
| 115 |
+
cleaned = re.sub(r"\s+\b(?:on|in|with|as|of)$", "", cleaned, flags=re.IGNORECASE)
|
| 116 |
+
cleaned = re.sub(r"^[^\w一-鿿]+|[^\w一-鿿]+$", "", cleaned)
|
| 117 |
+
return cleaned.strip()
|
poetic/textfree.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# The one hard rule every generated background must obey: NO text in the image,
|
| 2 |
+
# because the app overlays the poem/greeting itself. Defense-in-depth backstop
|
| 3 |
+
# applied right before the paint call, ported from src/lib/textFree.ts.
|
| 4 |
+
TEXT_FREE_RULE = (
|
| 5 |
+
"a painting only — no text, no Chinese characters, no calligraphy, no seals, no watermark"
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
FULL_BLEED_PAINTING_RULE = (
|
| 9 |
+
"full-bleed 3:4 portrait painting -- fill the entire canvas edge to edge; no borders,"
|
| 10 |
+
" no frame, no mat, no margins, no black bands, no poster layout, no split panels, no embedded text"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def ensure_text_free(brief: str, rule: str = TEXT_FREE_RULE) -> str:
|
| 15 |
+
"""Return the brief guaranteed to carry the paint safety rules (appended once if missing)."""
|
| 16 |
+
guarded = brief
|
| 17 |
+
for next_rule in (FULL_BLEED_PAINTING_RULE, rule):
|
| 18 |
+
if next_rule not in guarded:
|
| 19 |
+
guarded = f"{guarded}\n\n{next_rule}"
|
| 20 |
+
return guarded
|
poetic/video.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import subprocess
|
| 2 |
+
import tempfile
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def build_mux_cmd(ffmpeg: str, video_path: str, audio_path: str, out_path: str) -> list[str]:
|
| 6 |
+
"""ffmpeg command that lays the song under the animation. -shortest trims the
|
| 7 |
+
longer track so a 40s song over a 4s loop doesn't produce a 40s freeze-frame —
|
| 8 |
+
instead we loop the video to the song with -stream_loop beforehand."""
|
| 9 |
+
return [
|
| 10 |
+
ffmpeg,
|
| 11 |
+
"-y",
|
| 12 |
+
"-stream_loop", "-1",
|
| 13 |
+
"-i", video_path,
|
| 14 |
+
"-i", audio_path,
|
| 15 |
+
"-map", "0:v:0",
|
| 16 |
+
"-map", "1:a:0",
|
| 17 |
+
"-c:v", "libx264",
|
| 18 |
+
"-pix_fmt", "yuv420p",
|
| 19 |
+
"-c:a", "aac",
|
| 20 |
+
"-shortest",
|
| 21 |
+
out_path,
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def mux_song_into_video(video_path: str, audio_path: str) -> str:
|
| 26 |
+
"""Loop the silent animation under the full song; returns the muxed mp4 path."""
|
| 27 |
+
import imageio_ffmpeg
|
| 28 |
+
|
| 29 |
+
out = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
|
| 30 |
+
out.close()
|
| 31 |
+
cmd = build_mux_cmd(imageio_ffmpeg.get_ffmpeg_exe(), video_path, audio_path, out.name)
|
| 32 |
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
| 33 |
+
if result.returncode != 0:
|
| 34 |
+
raise RuntimeError(f"ffmpeg mux failed: {result.stderr[-400:]}")
|
| 35 |
+
return out.name
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# spaces is intentionally NOT pinned here — the Space platform provides it.
|
| 2 |
+
torch
|
| 3 |
+
transformers
|
| 4 |
+
diffusers
|
| 5 |
+
accelerate
|
| 6 |
+
safetensors
|
| 7 |
+
sentencepiece
|
| 8 |
+
huggingface_hub
|
| 9 |
+
pillow
|
| 10 |
+
imageio-ffmpeg
|
| 11 |
+
gradio_client
|
| 12 |
+
git+https://github.com/ace-step/ACE-Step.git
|
tests/test_interpretation.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from poetic.interpretation import build_interpret_contents, parse_interpretation
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_includes_poem_and_correction():
|
| 7 |
+
c = build_interpret_contents("床前明月光", "要更温暖的色调")
|
| 8 |
+
assert "床前明月光" in c
|
| 9 |
+
assert "要更温暖的色调" in c
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_omits_correction_line_when_none_given():
|
| 13 |
+
c = build_interpret_contents("床前明月光")
|
| 14 |
+
assert "更正" not in c
|
| 15 |
+
assert "correction" not in c.lower()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_parses_guaranteed_json_into_both_fields():
|
| 19 |
+
out = parse_interpretation('{"understandingZh":"一幅月夜图","briefEn":"A moonlit night"}')
|
| 20 |
+
assert out.understanding_zh == "一幅月夜图"
|
| 21 |
+
assert out.brief_en == "A moonlit night"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_raises_clear_error_on_malformed_json():
|
| 25 |
+
with pytest.raises(ValueError, match="[Ii]nterpretation"):
|
| 26 |
+
parse_interpretation("not json")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_raises_when_fields_missing():
|
| 30 |
+
with pytest.raises(ValueError, match="[Ii]nterpretation"):
|
| 31 |
+
parse_interpretation('{"understandingZh":"只有一半"}')
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# The local/remote LLMs (MiniCPM, Qwen) have no enforced JSON mode like Vertex AI's
|
| 35 |
+
# response schema, so the parser must repair the two common wrappings.
|
| 36 |
+
|
| 37 |
+
def test_strips_markdown_code_fences():
|
| 38 |
+
text = '```json\n{"understandingZh":"月夜","briefEn":"moonlit"}\n```'
|
| 39 |
+
out = parse_interpretation(text)
|
| 40 |
+
assert out.understanding_zh == "月夜"
|
| 41 |
+
assert out.brief_en == "moonlit"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_extracts_json_object_from_surrounding_prose():
|
| 45 |
+
text = '好的,以下是结果:{"understandingZh":"月夜","briefEn":"moonlit"} 希望您喜欢。'
|
| 46 |
+
out = parse_interpretation(text)
|
| 47 |
+
assert out.brief_en == "moonlit"
|
tests/test_motion_and_registry.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from poetic.motion import MOTION_CLAUSE, build_motion_prompt
|
| 2 |
+
from poetic.providers import resolve_mode
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def test_motion_prompt_keeps_brief_and_adds_motion_clause():
|
| 6 |
+
out = build_motion_prompt(" A misty river at dawn. ")
|
| 7 |
+
assert out.startswith("A misty river at dawn.")
|
| 8 |
+
assert MOTION_CLAUSE in out
|
| 9 |
+
assert "no new objects" in out
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_resolve_mode_defaults_to_api_off_space(monkeypatch):
|
| 13 |
+
monkeypatch.delenv("POETIC_MODE", raising=False)
|
| 14 |
+
monkeypatch.delenv("SPACES_ZERO_GPU", raising=False)
|
| 15 |
+
assert resolve_mode() == "api"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_resolve_mode_defaults_to_local_on_zerogpu(monkeypatch):
|
| 19 |
+
monkeypatch.delenv("POETIC_MODE", raising=False)
|
| 20 |
+
monkeypatch.setenv("SPACES_ZERO_GPU", "true")
|
| 21 |
+
assert resolve_mode() == "local"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_explicit_mode_wins(monkeypatch):
|
| 25 |
+
monkeypatch.setenv("POETIC_MODE", "api")
|
| 26 |
+
monkeypatch.setenv("SPACES_ZERO_GPU", "true")
|
| 27 |
+
assert resolve_mode() == "api"
|
tests/test_song.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from poetic.song import (
|
| 2 |
+
DEFAULT_SONG_STYLE,
|
| 3 |
+
SONG_STYLE_IDS,
|
| 4 |
+
SONG_STYLE_TAGS,
|
| 5 |
+
build_song_lyrics,
|
| 6 |
+
is_song_style_id,
|
| 7 |
+
song_tags,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def test_recognizes_only_supported_song_styles():
|
| 12 |
+
assert is_song_style_id("guofeng") is True
|
| 13 |
+
assert is_song_style_id("heavyMetal") is False
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_every_style_has_tags_and_default_is_valid():
|
| 17 |
+
assert set(SONG_STYLE_TAGS) == set(SONG_STYLE_IDS)
|
| 18 |
+
assert DEFAULT_SONG_STYLE in SONG_STYLE_IDS
|
| 19 |
+
for tags in SONG_STYLE_TAGS.values():
|
| 20 |
+
assert "vocal" in tags # every preset must ask for singing, not instrumental
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_song_tags_falls_back_to_default_for_unknown_style():
|
| 24 |
+
assert song_tags("notAStyle") == SONG_STYLE_TAGS[DEFAULT_SONG_STYLE]
|
| 25 |
+
assert song_tags("guofeng") == SONG_STYLE_TAGS["guofeng"]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_lyrics_keep_every_poem_line():
|
| 29 |
+
poem = "床前明月光,疑是地上霜。\n举头望明月,低头思故乡。"
|
| 30 |
+
lyrics = build_song_lyrics(poem)
|
| 31 |
+
for line in ("床前明月光", "疑是地上霜", "举头望明月", "低头思故乡"):
|
| 32 |
+
assert line in lyrics
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_lyrics_use_structure_tags():
|
| 36 |
+
lyrics = build_song_lyrics("床前明月光,疑是地上霜。举头望明月,低头思故乡。")
|
| 37 |
+
assert "[verse]" in lyrics
|
| 38 |
+
assert "[chorus]" in lyrics
|
| 39 |
+
# chorus repeats the opening couplet so the song has a memorable hook
|
| 40 |
+
assert lyrics.count("床前明月光") >= 2
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_lyrics_split_single_line_poem_on_chinese_punctuation():
|
| 44 |
+
lyrics = build_song_lyrics("白日依山尽,黄河入海流。欲穷千里目,更上一层楼。")
|
| 45 |
+
lines = [l for l in lyrics.splitlines() if l and not l.startswith("[")]
|
| 46 |
+
assert "白日依山尽" in lines
|
| 47 |
+
assert "更上一层楼" in lines
|
| 48 |
+
# no punctuation residue inside lyric lines
|
| 49 |
+
assert all(not any(p in l for p in ",。!?;") for l in lines)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_lyrics_handle_blank_and_whitespace_input_lines():
|
| 53 |
+
lyrics = build_song_lyrics(" 静夜思 \n\n床前明月光。\n")
|
| 54 |
+
lines = [l for l in lyrics.splitlines() if l and not l.startswith("[")]
|
| 55 |
+
assert "静夜思" in lines
|
| 56 |
+
assert "" not in [l.strip() for l in lines if l.strip() == ""]
|
tests/test_styles.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from poetic.styles import DEFAULT_IMAGE_STYLE, apply_image_style, is_image_style_id
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_recognizes_only_supported_style_ids():
|
| 5 |
+
assert is_image_style_id("inkWash") is True
|
| 6 |
+
assert is_image_style_id("posterColor") is True
|
| 7 |
+
assert is_image_style_id("oilPainting") is False
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_appends_curated_style_clause():
|
| 11 |
+
brief = apply_image_style("A lotus pond at dawn.", "watercolor")
|
| 12 |
+
assert "A lotus pond at dawn." in brief
|
| 13 |
+
assert "selected visual style is authoritative" in brief
|
| 14 |
+
assert "soft transparent watercolor painting" in brief
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_realistic_style_overrides_chinese_painting_wording():
|
| 18 |
+
brief = apply_image_style("A shui-mo Chinese painting of red flowers.", "realistic")
|
| 19 |
+
assert "Selected style / 已选画风: Realistic / 写实" in brief
|
| 20 |
+
assert "selection is mandatory" in brief
|
| 21 |
+
assert "Avoid stylized Chinese painting" in brief
|
| 22 |
+
assert "red flowers" in brief
|
| 23 |
+
assert "A shui-mo Chinese painting of red flowers." not in brief
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_removes_conflicting_art_direction_words_from_realistic_prompts():
|
| 27 |
+
brief = apply_image_style(
|
| 28 |
+
"A watercolor Chinese ink-wash shan-shui painting of red blossoms"
|
| 29 |
+
" on antique paper with decorative seal motifs.",
|
| 30 |
+
"realistic",
|
| 31 |
+
)
|
| 32 |
+
marker = "Real-world scene description extracted from the poem, with conflicting art-style words removed:"
|
| 33 |
+
cleaned_scene = brief.split(marker)[1].split("Make it look like a believable real-world scene first")[0]
|
| 34 |
+
|
| 35 |
+
assert "red blossoms" in cleaned_scene
|
| 36 |
+
assert "believable real-world scene" in brief
|
| 37 |
+
assert "on with" not in cleaned_scene
|
| 38 |
+
assert "watercolor Chinese ink-wash shan-shui painting" not in cleaned_scene
|
| 39 |
+
assert "antique paper" not in cleaned_scene
|
| 40 |
+
assert "decorative seal motifs" not in cleaned_scene
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_defaults_to_watercolor():
|
| 44 |
+
assert DEFAULT_IMAGE_STYLE == "watercolor"
|
| 45 |
+
assert "soft transparent watercolor painting" in apply_image_style("A mountain.")
|
tests/test_textfree.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from poetic.textfree import FULL_BLEED_PAINTING_RULE, TEXT_FREE_RULE, ensure_text_free
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_appends_canonical_no_text_rule_when_brief_omits_it():
|
| 5 |
+
out = ensure_text_free("A misty river at dawn.")
|
| 6 |
+
assert out.startswith("A misty river at dawn.")
|
| 7 |
+
assert TEXT_FREE_RULE in out
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_appends_full_bleed_rule_when_brief_omits_it():
|
| 11 |
+
out = ensure_text_free("A lotus pond in a classical Chinese painting style.")
|
| 12 |
+
assert FULL_BLEED_PAINTING_RULE in out
|
| 13 |
+
assert "full-bleed 3:4 portrait painting" in out
|
| 14 |
+
assert "no black bands" in out
|
| 15 |
+
assert "no split panels" in out
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_does_not_duplicate_text_free_rule():
|
| 19 |
+
out = ensure_text_free(f"A quiet lake. {TEXT_FREE_RULE}")
|
| 20 |
+
assert out.count(TEXT_FREE_RULE) == 1
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_does_not_duplicate_full_bleed_rule():
|
| 24 |
+
out = ensure_text_free(f"A quiet lake. {FULL_BLEED_PAINTING_RULE}")
|
| 25 |
+
assert out.count(FULL_BLEED_PAINTING_RULE) == 1
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_keeps_original_brief_intact():
|
| 29 |
+
assert "Bamboo in snow" in ensure_text_free("Bamboo in snow")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_exports_exact_safety_rule_strings():
|
| 33 |
+
assert TEXT_FREE_RULE == (
|
| 34 |
+
"a painting only — no text, no Chinese characters, no calligraphy, no seals, no watermark"
|
| 35 |
+
)
|
| 36 |
+
assert FULL_BLEED_PAINTING_RULE == (
|
| 37 |
+
"full-bleed 3:4 portrait painting -- fill the entire canvas edge to edge; no borders,"
|
| 38 |
+
" no frame, no mat, no margins, no black bands, no poster layout, no split panels, no embedded text"
|
| 39 |
+
)
|
tests/test_video_and_card.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PIL import Image
|
| 2 |
+
|
| 3 |
+
from poetic.card import compose_card, layout_vertical_columns
|
| 4 |
+
from poetic.video import build_mux_cmd
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_mux_cmd_loops_video_under_full_song():
|
| 8 |
+
cmd = build_mux_cmd("ffmpeg", "v.mp4", "a.wav", "out.mp4")
|
| 9 |
+
assert cmd[0] == "ffmpeg"
|
| 10 |
+
assert "-stream_loop" in cmd
|
| 11 |
+
assert "-shortest" in cmd
|
| 12 |
+
assert cmd[cmd.index("-i")] == "-i"
|
| 13 |
+
assert "v.mp4" in cmd and "a.wav" in cmd and cmd[-1] == "out.mp4"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_vertical_columns_run_right_to_left():
|
| 17 |
+
placements = layout_vertical_columns(["床前明月光", "疑是地上霜"], 20, 6, top=10, right=300)
|
| 18 |
+
first_col_x = [x for ch, x, y in placements if ch in "床前明月光"]
|
| 19 |
+
second_col_x = [x for ch, x, y in placements if ch in "疑是地上霜"]
|
| 20 |
+
assert len(set(first_col_x)) == 1
|
| 21 |
+
assert len(set(second_col_x)) == 1
|
| 22 |
+
assert second_col_x[0] < first_col_x[0] # second line sits to the LEFT
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_vertical_columns_descend_within_a_column():
|
| 26 |
+
placements = layout_vertical_columns(["床前明月光"], 20, 6, top=10, right=300)
|
| 27 |
+
ys = [y for _, _, y in placements]
|
| 28 |
+
assert ys == sorted(ys)
|
| 29 |
+
assert ys[0] == 10
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_compose_card_returns_same_size_image_and_does_not_mutate_input():
|
| 33 |
+
painting = Image.new("RGB", (300, 400), (240, 230, 210))
|
| 34 |
+
card = compose_card(painting, "床前明月光,疑是地上霜。", greeting="早安")
|
| 35 |
+
assert card.size == (300, 400)
|
| 36 |
+
assert card is not painting
|
| 37 |
+
# input untouched: corner pixel of the original is still the background color
|
| 38 |
+
assert painting.getpixel((5, 395)) == (240, 230, 210)
|