yooi commited on
Commit
1a6e48a
·
verified ·
1 Parent(s): 29577b7

Upload folder using huggingface_hub

Browse files
.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: Shi Cheng Hua
3
- emoji: 🚀
4
- colorFrom: blue
5
- colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.17.3
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)