dsh-plugin-image-workbench 1.1.5 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +5 -3
  3. package/README.md +7 -3
  4. package/client.js +241 -241
  5. package/cordis.patch.yml +14 -14
  6. package/host.js +51 -0
  7. package/install.mjs +333 -387
  8. package/package.json +3 -2
  9. package/scripts/fetch-style-images.mjs +104 -0
  10. package/scripts/vendor-snapshot.py +154 -154
  11. package/server/access.py +26 -26
  12. package/server/app.py +1329 -1329
  13. package/server/catalog.py +147 -147
  14. package/server/composer.py +391 -391
  15. package/server/drafts.py +158 -158
  16. package/server/history.py +575 -575
  17. package/server/imaging.py +172 -172
  18. package/server/pool.py +1335 -1335
  19. package/server/pool_admin.py +921 -921
  20. package/server/pool_service.py +1073 -1073
  21. package/server/requirements.txt +6 -6
  22. package/server/smoke_check.py +402 -402
  23. package/server/static/access.html +202 -202
  24. package/vendor/chatgpt2api/source/.env.example +82 -82
  25. package/vendor/chatgpt2api/source/.python-version +1 -1
  26. package/vendor/chatgpt2api/source/LICENSE +21 -21
  27. package/vendor/chatgpt2api/source/README.md +391 -391
  28. package/vendor/chatgpt2api/source/VERSION +1 -1
  29. package/vendor/chatgpt2api/source/api/__init__.py +2 -2
  30. package/vendor/chatgpt2api/source/api/accounts.py +537 -537
  31. package/vendor/chatgpt2api/source/api/ai.py +217 -217
  32. package/vendor/chatgpt2api/source/api/app.py +62 -62
  33. package/vendor/chatgpt2api/source/api/errors.py +46 -46
  34. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
  35. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
  36. package/vendor/chatgpt2api/source/api/support.py +134 -134
  37. package/vendor/chatgpt2api/source/api/system.py +365 -365
  38. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
  39. package/vendor/chatgpt2api/source/config.default.json +108 -108
  40. package/vendor/chatgpt2api/source/main.py +9 -9
  41. package/vendor/chatgpt2api/source/pyproject.toml +27 -27
  42. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
  43. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
  44. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
  45. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
  46. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
  47. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
  48. package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
  49. package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
  50. package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
  51. package/vendor/chatgpt2api/source/services/config.py +667 -667
  52. package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
  53. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
  54. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
  55. package/vendor/chatgpt2api/source/services/image_service.py +376 -376
  56. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
  57. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
  58. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
  59. package/vendor/chatgpt2api/source/services/log_service.py +337 -337
  60. package/vendor/chatgpt2api/source/services/model_service.py +182 -182
  61. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
  62. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
  63. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
  64. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
  65. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
  66. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
  67. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
  68. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
  69. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
  70. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
  71. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
  72. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
  73. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
  74. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
  75. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
  76. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
  77. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
  78. package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
  79. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
  80. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
  81. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
  82. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
  83. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
  84. package/vendor/chatgpt2api/source/utils/helper.py +469 -469
  85. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
  86. package/vendor/chatgpt2api/source/utils/log.py +110 -110
  87. package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
  88. package/vendor/chatgpt2api/source/utils/pow.py +207 -207
  89. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
  90. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
  91. package/vendor/chatgpt2api/source/uv.lock +904 -904
  92. package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
  93. package/vendor/chatgpt2api/source/web/components.json +24 -24
  94. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
  95. package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
  96. package/vendor/chatgpt2api/source/web/package.json +58 -58
  97. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
  98. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
  99. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
  100. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
  101. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
  102. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
  103. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
  104. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
  105. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
  106. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
  107. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
  108. package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
  109. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
  110. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
  111. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
  112. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
  113. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
  114. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
  115. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
  116. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
  117. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
  118. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
  119. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
  120. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
  121. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
  122. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
  123. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
  124. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
  125. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
  126. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
  127. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
  128. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
  129. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
  130. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
  131. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
  132. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
  133. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
  134. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
  135. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
  136. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
  137. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
  138. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
  139. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
  140. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
  141. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
  142. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
  143. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
  144. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
  145. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
  146. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
  147. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
  148. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
  149. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
  150. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
  151. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
  152. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
  153. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
  154. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
  155. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
  156. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
  157. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
  158. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
  159. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
  160. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
  161. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
  162. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
  163. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
  164. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
  165. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
  166. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
  167. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
  168. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
  169. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
  170. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
  171. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
  172. package/vendor/chatgpt2api/source/web/tsconfig.json +42 -42
@@ -1,391 +1,391 @@
1
- """把 handraw-style 的「风格 + 主题色 + 图型 + 融合 + 文字槽位 + 情绪 + 比例」
2
- 拼成两层产物:
3
-
4
- 1. 出图指令(instruction):一行短指令,人能看懂、也能直接丢给 AI 再加工
5
- 2. 完整提示词(prompt):真正提交给号池生图的那段文本
6
-
7
- 规则来源:handdraw-painter/SKILL.md
8
- - 提示词内不出现任何编号(FA-001 / SC-002 / C-16 一律换成自然语言名),
9
- 否则生图模型会把编号当文字画进画面
10
- - 风格特征只保留正向描述,含「避免/不要/不准/禁止」的分句一律剔除
11
- - 图型 prompt 原文照用,它是排版权威描述
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import re
17
-
18
- NEG_MARKERS = ("避免", "不要", "不准", "禁止")
19
-
20
- # 缩微摄影跨媒介融合:角色小、场景真、同光源同空间
21
- MICRO_FUSION = (
22
- "缩微摄影跨媒介融合:角色尺寸异常小、场景正常尺寸。"
23
- "场景视觉语言=暖调写实生活摄影(真实材质、可信光影)。"
24
- "角色与场景同空间同光源,有可信接触、投影、遮挡,角色真正活在场景里而不是贴纸。"
25
- )
26
-
27
- WHITESPACE = {
28
- "moderate": "【大量留白】",
29
- "high": "【大量留白,场景只显示必要部分,不要显示全】",
30
- }
31
-
32
- # 2.35:1 的公众号首图是「3:2 出图 + 上下各裁一截」得来的,不提前约束构图,
33
- # 裁完就会砍掉标题或者削掉人物头顶。这里把安全区写死进提示词里。
34
- WIDE_CROP_RATIO = "2.35:1"
35
- WIDE_CROP_HINT = (
36
- "横幅构图:主体、标题和所有关键元素都压在同一块居中的横向安全区里,"
37
- "画面上下各留一段可以整块裁掉的次要背景(纯色、天空、地面或虚化),"
38
- "文字和人物头顶都不要贴到画面上沿或下沿"
39
- )
40
-
41
- # 摄影模式:文案全部照搬 handraw-style 上游,不另写一套
42
- # - 「摄影写真模式」见 handraw-style/gallery/tutorials.html 的 photo 模式前缀
43
- # - 「高级摄影师风格,创意构图,强烈的戏剧张力」见
44
- # handraw-style/skills/couple-photo-orchestrator/SKILL.md「通用摄影关键词」
45
- # 上游那句「请先出3个方案供我挑选」是让 AI 先给方案的指令,进生图会被当成画面要求,
46
- # 所以这里只取模式标识本身。
47
- PHOTO_LEAD = "摄影写真模式。"
48
- PHOTO_KEYWORDS = "通用摄影关键词:高级摄影师风格,创意构图,强烈的戏剧张力。"
49
- PHOTO_MODES = ("graphic-text", "photo")
50
-
51
- TAIL_INSTRUCTION = ""
52
-
53
- # 带了参考图时插在最前面:先定画风基准,再谈版式和文字,避免模型把参考图
54
- # 原样复刻(用户要的是「融合」,不是「重画同一张」)
55
- REF_FUSION = (
56
- "参考我上传的这张图:沿用它的画风、笔触、配色和整体质感,把它当作视觉基准。"
57
- "画面内容按下文重新绘制,下面的版式与文字要求优先,不要照抄参考图里的文字。"
58
- )
59
-
60
- # 中层小辅助图没填时的兜底描述,保证三层版式不会空一层
61
- SCENE_FALLBACK = "一个涂鸦小人和一件具体实物道具,动作直接对应上层标题的意思,造型简单、线条少"
62
-
63
-
64
- def clean_traits(features: str, limit: int = 110) -> str:
65
- """只留正向特征,砍掉含否定词的分句,过长截断。"""
66
- segs = re.split(r"[;;。]", features or "")
67
- keep = []
68
- for seg in segs:
69
- seg = seg.strip()
70
- if not seg or any(m in seg for m in NEG_MARKERS):
71
- continue
72
- keep.append(seg)
73
- # 按分句累加,超长就整句舍弃,不留半句、不加省略号
74
- out: list[str] = []
75
- total = 0
76
- for seg in keep:
77
- add = len(seg) + (1 if out else 0)
78
- if out and total + add > limit:
79
- break
80
- if not out and len(seg) > limit:
81
- seg = seg[:limit].rstrip("、,;")
82
- out.append(seg)
83
- total += add
84
- return ";".join(out)
85
-
86
-
87
- def style_line(style: dict | None) -> str:
88
- """Loose Ink-and-Wash Storybook(参考 Quentin Blake:抖动墨线、松散水彩…)。"""
89
- if not style:
90
- return ""
91
- name = (style.get("name_en") or "").strip()
92
- ref = (style.get("ref") or "").strip()
93
- traits = clean_traits(style.get("features", ""))
94
- inner = ""
95
- if ref and traits:
96
- inner = f"参考 {ref}:{traits}"
97
- elif ref:
98
- inner = f"参考 {ref}"
99
- elif traits:
100
- inner = traits
101
- if not name and not inner:
102
- return ""
103
- if name and inner:
104
- return f"{name}({inner})。"
105
- return f"{name or inner}。"
106
-
107
-
108
- def color_line(color: dict | None) -> str:
109
- if not color:
110
- return ""
111
- return (color.get("prompt") or "").strip()
112
-
113
-
114
- def layout_line(layout: dict | None) -> str:
115
- if not layout:
116
- return ""
117
- text = (layout.get("prompt") or "").strip()
118
- if text and not text.endswith(("。", "!", "》", "」")):
119
- text += "。"
120
- return text
121
-
122
-
123
- def clean_title(raw: str) -> str:
124
- """01|先存后花 / **01|先存后花** / 【01|先存后花】 → 01 先存后花"""
125
- t = (raw or "").strip()
126
- t = t.strip("**【】[] ")
127
- t = re.sub(r"[||]", " ", t)
128
- return re.sub(r"\s{2,}", " ", t).strip()
129
-
130
-
131
- def short_body(body: str, limit: int = 62) -> str:
132
- """副标题槽位只用正文第一句;太长就在句内标点处收掉,别让图里塞一整段。"""
133
- t = re.sub(r"\s+", "", body or "")
134
- if not t:
135
- return ""
136
- parts = re.split(r"(?<=[。!?!?])", t)
137
- first = (parts[0] if parts and parts[0] else t).strip()
138
- if len(first) <= limit:
139
- return first
140
- cut = first[:limit]
141
- for mark in (",", "、", ";", ":"):
142
- i = cut.rfind(mark)
143
- if i > limit * 0.45:
144
- return cut[:i] + "。"
145
- return cut + "。"
146
-
147
-
148
- def build_slots(title: str, body: str, scene: str, layout: dict | None) -> str:
149
- """文字槽位。三层版式按「上层主标题 / 中层小辅助图 / 下层副标题」写,
150
- 其他图型退回「主标题 / 正文」,免得槽位和图型打架。"""
151
- three = bool(layout) and "三层" in (layout.get("prompt") or "")
152
- sub = short_body(body)
153
- bits = []
154
- if three:
155
- if title:
156
- bits.append(f"上层主标题文字:「{title}」")
157
- bits.append("中层小辅助图:" + (scene.strip() or SCENE_FALLBACK))
158
- if sub:
159
- bits.append(f"下层副标题文字:「{sub}」")
160
- else:
161
- if title:
162
- bits.append(f"主标题文字:「{title}」")
163
- if sub:
164
- bits.append(f"正文文字:「{sub}」")
165
- if not bits:
166
- return ""
167
- out = "。".join(bits)
168
- if not out.endswith(("」", "。")):
169
- out += "。"
170
- return out
171
-
172
-
173
- def parse_moods(raw) -> list[str]:
174
- if not raw:
175
- return []
176
- if isinstance(raw, (list, tuple)):
177
- tokens = []
178
- for part in raw:
179
- tokens += re.split(r"[、,,/\s]+", str(part))
180
- else:
181
- tokens = re.split(r"[、,,/\s]+", str(raw))
182
- seen, out = set(), []
183
- for t in tokens:
184
- t = t.strip()
185
- if t and t not in seen:
186
- seen.add(t)
187
- out.append(t)
188
- return out
189
-
190
-
191
- def build_instruction(sel: dict, ratio: str, moods: list[str], fusion: bool, whitespace: str,
192
- ref: bool = False, image_mode: str = "graphic-text", shoot: str = "") -> str:
193
- """一行短指令,形如:图文模式,图型:SC-002,风格:FB-001,主题色:C-26,画幅比例:3:4,情绪:治愈、幽默"""
194
- bits = ["摄影写真模式" if image_mode == "photo" else "图文模式"]
195
- if ref:
196
- bits.append("参考图:已上传")
197
- layout = sel.get("layout")
198
- style = sel.get("style")
199
- color = sel.get("color")
200
- # 摄影模式不套图型版式:图型模板写的是「主标题/小辅助图/副标题」的位置,和写真不排字冲突
201
- if layout and image_mode != "photo":
202
- bits.append(f"图型:{layout['id']}")
203
- if style:
204
- bits.append(f"风格:{style['id']}")
205
- if color:
206
- bits.append(f"主题色:{color['id']}")
207
- # 三样都可以不选:说明这一轮靠什么出图,避免指令读起来像漏了参数
208
- if not layout and not style and not color:
209
- bits.append("图型 / 风格 / 主题色:跟参考图" if ref else "图型 / 风格 / 主题色:不限")
210
- if ratio:
211
- bits.append(f"画幅比例:{ratio}")
212
- if moods:
213
- bits.append("情绪:" + "、".join(moods))
214
- if image_mode == "photo" and shoot:
215
- bits.append(f"拍摄类型:{shoot}")
216
- if fusion:
217
- bits.append("缩微摄影融合")
218
- if whitespace == "moderate":
219
- bits.append("留白:大量")
220
- elif whitespace == "high":
221
- bits.append("留白:极简")
222
- return ",".join(bits)
223
-
224
-
225
- def _ids_to_names(instr: str, sel: dict) -> str:
226
- """手改指令里的编号换成自然语言名,遵守「提示词不出现编号」的规矩。"""
227
- layout = sel.get("layout")
228
- style = sel.get("style")
229
- color = sel.get("color")
230
- if layout:
231
- instr = instr.replace(f"图型:{layout['id']}", f"图型:{(layout.get('title') or '').strip()}")
232
- if style:
233
- instr = instr.replace(f"风格:{style['id']}",
234
- f"风格:{(style.get('name_en') or style.get('name') or '').strip()}")
235
- if color:
236
- instr = instr.replace(f"主题色:{color['id']}", f"主题色:{(color.get('name') or '').strip()}")
237
- return instr
238
-
239
-
240
- def build_prompt(item: dict, sel: dict, ratio: str, moods: list[str],
241
- fusion: bool, whitespace: str, note: str = "", ref: bool = False,
242
- image_mode: str = "graphic-text", shoot: str = "", instr: str = "") -> str:
243
- """单条完整提示词。item = {title, body}"""
244
- style = sel.get("style")
245
- layout = sel.get("layout")
246
- color = sel.get("color")
247
- title = clean_title(item.get("title") or "")
248
- body = (item.get("body") or "").strip()
249
- scene = (item.get("scene") or "").strip()
250
-
251
- blocks: list[str] = []
252
-
253
- if ref:
254
- blocks.append(REF_FUSION)
255
-
256
- # 手改过的出图指令原样进提示词(编号已换成自然语言名),用户改了什么就生效什么
257
- if instr:
258
- blocks.append(_ids_to_names(instr, sel))
259
-
260
- head = style_line(style) + color_line(color)
261
- if head:
262
- blocks.append(head)
263
-
264
- layout_text = "" if image_mode == "photo" else layout_line(layout)
265
- if layout_text:
266
- blocks.append(layout_text)
267
-
268
- # 缩微摄影融合在图文 / 摄影两种模式下都生效(用户 2026-11-05 定)
269
- if fusion:
270
- blocks.append(MICRO_FUSION)
271
-
272
- if note.strip():
273
- blocks.append(f"补充要求:{note.strip()}。")
274
-
275
- if image_mode == "photo":
276
- # 摄影模式:上游提示词里没有文字槽位,分镜的标题/正文只当画面内容
277
- blocks.append(PHOTO_LEAD)
278
- if shoot:
279
- blocks.append(f"拍摄类型:{shoot}。")
280
- blocks.append(PHOTO_KEYWORDS)
281
- subject = re.sub(r"^0*\d{1,3}[\s..、,,::||\-—]+", "", title).strip() or title
282
- # 分镜标题/正文/小辅助图都当画面内容;各自收掉句末标点,避免「。。」
283
- scene_bits = [re.sub(r"[。!?\.!?,,;;]+$", "", x).strip() for x in (subject, body, scene)]
284
- scene_bits = [x for x in scene_bits if x]
285
- if scene_bits:
286
- blocks.append("画面:" + "。".join(scene_bits) + "。")
287
- elif ref:
288
- # 只传了参考图、没写任何文案:不给主体的话模型只能照抄原图
289
- blocks.append("画面:沿用参考图里的主体与场景,重新组织构图拍一张。")
290
- else:
291
- slots = build_slots(title, body, scene, layout)
292
- if slots:
293
- blocks.append(slots)
294
- blocks.append("文字清晰可读,文字参与构图,不要出现多余文字。")
295
- elif ref:
296
- # 只上传参考图、不选图型风格主题色(用户 2026-11-08 要求):没有文案可排,
297
- # 就让参考图当唯一依据,重新组织构图出一张完整画面,别把原图原样复刻
298
- blocks.append(
299
- "没有文案:画面主体与画风都取自参考图,重新组织构图、环境与动作,"
300
- "画一张完整的插画,画面里不排文字。"
301
- )
302
-
303
- tail = []
304
- if moods:
305
- tail.append("情绪:" + "、".join(moods))
306
- if color:
307
- tail.append(f"{color.get('name') or ''}作统一色调点缀")
308
- if ratio:
309
- tail.append(f"画幅比例 {ratio}")
310
- tail = [t for t in tail if t and t.strip(":")]
311
- if tail:
312
- blocks.append("。".join(tail) + "。")
313
-
314
- if str(ratio or "").strip() == WIDE_CROP_RATIO:
315
- blocks.append(WIDE_CROP_HINT)
316
-
317
- if whitespace in WHITESPACE:
318
- blocks.append(WHITESPACE[whitespace])
319
-
320
- blocks.append(TAIL_INSTRUCTION)
321
- return "\n".join(b for b in blocks if b)
322
-
323
-
324
- def compose(payload: dict, styles: list[dict], layouts: list[dict], colors: list[dict]) -> dict:
325
- """组装全部条目。payload 里 items 为空时用 topic 兜底生成一条。"""
326
-
327
- def pick(items, item_id):
328
- if not item_id:
329
- return None
330
- return next((x for x in items if x["id"] == item_id), None)
331
-
332
- sel = {
333
- "style": pick(styles, payload.get("style_id")),
334
- "layout": pick(layouts, payload.get("layout_id")),
335
- "color": pick(colors, payload.get("color_id")),
336
- }
337
- ratio = (payload.get("ratio") or "").strip()
338
- moods = parse_moods(payload.get("mood"))
339
- fusion = bool(payload.get("fusion"))
340
- whitespace = (payload.get("whitespace") or "normal").strip()
341
- note = (payload.get("note") or "").strip()
342
- ref = bool(payload.get("ref"))
343
- # 出图模式:默认图文,和以前完全一样;只有显式传 photo 才走摄影分支
344
- image_mode = (payload.get("image_mode") or "graphic-text").strip()
345
- if image_mode not in PHOTO_MODES:
346
- image_mode = "graphic-text"
347
- shoot = (payload.get("shoot_type") or "").strip()[:40] if image_mode == "photo" else ""
348
- # allow_blank:左侧没写标题/正文时也允许出提示词(纯指令出图);instruction:手改指令
349
- allow_blank = bool(payload.get("allow_blank"))
350
- custom_instr = (payload.get("instruction") or "").strip()
351
-
352
- raw_items = payload.get("items") or []
353
- items = []
354
- for raw in raw_items:
355
- if not isinstance(raw, dict):
356
- continue
357
- title = (raw.get("title") or "").strip()
358
- body = (raw.get("body") or "").strip()
359
- scene = (raw.get("scene") or "").strip()
360
- if not title and not body and not allow_blank:
361
- continue
362
- items.append({"title": title, "body": body, "scene": scene})
363
-
364
- if not items:
365
- topic = (payload.get("topic") or "").strip()
366
- if topic:
367
- lines = topic.split("\n", 1)
368
- items.append({"title": lines[0].strip(), "body": lines[1].strip() if len(lines) > 1 else "", "scene": ""})
369
-
370
- instruction = custom_instr or build_instruction(
371
- sel, ratio, moods, fusion, whitespace, ref, image_mode, shoot)
372
- out_items = []
373
- for idx, item in enumerate(items, 1):
374
- out_items.append({
375
- "index": idx,
376
- "title": item["title"],
377
- "body": item["body"],
378
- "scene": item.get("scene", ""),
379
- "instruction": instruction,
380
- "prompt": build_prompt(item, sel, ratio, moods, fusion, whitespace, note, ref,
381
- image_mode, shoot, instr=custom_instr),
382
- })
383
- return {
384
- "instruction": instruction,
385
- "items": out_items,
386
- "selection": {
387
- "style": sel["style"]["id"] if sel["style"] else None,
388
- "layout": sel["layout"]["id"] if sel["layout"] else None,
389
- "color": sel["color"]["id"] if sel["color"] else None,
390
- },
391
- }
1
+ """把 handraw-style 的「风格 + 主题色 + 图型 + 融合 + 文字槽位 + 情绪 + 比例」
2
+ 拼成两层产物:
3
+
4
+ 1. 出图指令(instruction):一行短指令,人能看懂、也能直接丢给 AI 再加工
5
+ 2. 完整提示词(prompt):真正提交给号池生图的那段文本
6
+
7
+ 规则来源:handdraw-painter/SKILL.md
8
+ - 提示词内不出现任何编号(FA-001 / SC-002 / C-16 一律换成自然语言名),
9
+ 否则生图模型会把编号当文字画进画面
10
+ - 风格特征只保留正向描述,含「避免/不要/不准/禁止」的分句一律剔除
11
+ - 图型 prompt 原文照用,它是排版权威描述
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+
18
+ NEG_MARKERS = ("避免", "不要", "不准", "禁止")
19
+
20
+ # 缩微摄影跨媒介融合:角色小、场景真、同光源同空间
21
+ MICRO_FUSION = (
22
+ "缩微摄影跨媒介融合:角色尺寸异常小、场景正常尺寸。"
23
+ "场景视觉语言=暖调写实生活摄影(真实材质、可信光影)。"
24
+ "角色与场景同空间同光源,有可信接触、投影、遮挡,角色真正活在场景里而不是贴纸。"
25
+ )
26
+
27
+ WHITESPACE = {
28
+ "moderate": "【大量留白】",
29
+ "high": "【大量留白,场景只显示必要部分,不要显示全】",
30
+ }
31
+
32
+ # 2.35:1 的公众号首图是「3:2 出图 + 上下各裁一截」得来的,不提前约束构图,
33
+ # 裁完就会砍掉标题或者削掉人物头顶。这里把安全区写死进提示词里。
34
+ WIDE_CROP_RATIO = "2.35:1"
35
+ WIDE_CROP_HINT = (
36
+ "横幅构图:主体、标题和所有关键元素都压在同一块居中的横向安全区里,"
37
+ "画面上下各留一段可以整块裁掉的次要背景(纯色、天空、地面或虚化),"
38
+ "文字和人物头顶都不要贴到画面上沿或下沿"
39
+ )
40
+
41
+ # 摄影模式:文案全部照搬 handraw-style 上游,不另写一套
42
+ # - 「摄影写真模式」见 handraw-style/gallery/tutorials.html 的 photo 模式前缀
43
+ # - 「高级摄影师风格,创意构图,强烈的戏剧张力」见
44
+ # handraw-style/skills/couple-photo-orchestrator/SKILL.md「通用摄影关键词」
45
+ # 上游那句「请先出3个方案供我挑选」是让 AI 先给方案的指令,进生图会被当成画面要求,
46
+ # 所以这里只取模式标识本身。
47
+ PHOTO_LEAD = "摄影写真模式。"
48
+ PHOTO_KEYWORDS = "通用摄影关键词:高级摄影师风格,创意构图,强烈的戏剧张力。"
49
+ PHOTO_MODES = ("graphic-text", "photo")
50
+
51
+ TAIL_INSTRUCTION = ""
52
+
53
+ # 带了参考图时插在最前面:先定画风基准,再谈版式和文字,避免模型把参考图
54
+ # 原样复刻(用户要的是「融合」,不是「重画同一张」)
55
+ REF_FUSION = (
56
+ "参考我上传的这张图:沿用它的画风、笔触、配色和整体质感,把它当作视觉基准。"
57
+ "画面内容按下文重新绘制,下面的版式与文字要求优先,不要照抄参考图里的文字。"
58
+ )
59
+
60
+ # 中层小辅助图没填时的兜底描述,保证三层版式不会空一层
61
+ SCENE_FALLBACK = "一个涂鸦小人和一件具体实物道具,动作直接对应上层标题的意思,造型简单、线条少"
62
+
63
+
64
+ def clean_traits(features: str, limit: int = 110) -> str:
65
+ """只留正向特征,砍掉含否定词的分句,过长截断。"""
66
+ segs = re.split(r"[;;。]", features or "")
67
+ keep = []
68
+ for seg in segs:
69
+ seg = seg.strip()
70
+ if not seg or any(m in seg for m in NEG_MARKERS):
71
+ continue
72
+ keep.append(seg)
73
+ # 按分句累加,超长就整句舍弃,不留半句、不加省略号
74
+ out: list[str] = []
75
+ total = 0
76
+ for seg in keep:
77
+ add = len(seg) + (1 if out else 0)
78
+ if out and total + add > limit:
79
+ break
80
+ if not out and len(seg) > limit:
81
+ seg = seg[:limit].rstrip("、,;")
82
+ out.append(seg)
83
+ total += add
84
+ return ";".join(out)
85
+
86
+
87
+ def style_line(style: dict | None) -> str:
88
+ """Loose Ink-and-Wash Storybook(参考 Quentin Blake:抖动墨线、松散水彩…)。"""
89
+ if not style:
90
+ return ""
91
+ name = (style.get("name_en") or "").strip()
92
+ ref = (style.get("ref") or "").strip()
93
+ traits = clean_traits(style.get("features", ""))
94
+ inner = ""
95
+ if ref and traits:
96
+ inner = f"参考 {ref}:{traits}"
97
+ elif ref:
98
+ inner = f"参考 {ref}"
99
+ elif traits:
100
+ inner = traits
101
+ if not name and not inner:
102
+ return ""
103
+ if name and inner:
104
+ return f"{name}({inner})。"
105
+ return f"{name or inner}。"
106
+
107
+
108
+ def color_line(color: dict | None) -> str:
109
+ if not color:
110
+ return ""
111
+ return (color.get("prompt") or "").strip()
112
+
113
+
114
+ def layout_line(layout: dict | None) -> str:
115
+ if not layout:
116
+ return ""
117
+ text = (layout.get("prompt") or "").strip()
118
+ if text and not text.endswith(("。", "!", "》", "」")):
119
+ text += "。"
120
+ return text
121
+
122
+
123
+ def clean_title(raw: str) -> str:
124
+ """01|先存后花 / **01|先存后花** / 【01|先存后花】 → 01 先存后花"""
125
+ t = (raw or "").strip()
126
+ t = t.strip("**【】[] ")
127
+ t = re.sub(r"[||]", " ", t)
128
+ return re.sub(r"\s{2,}", " ", t).strip()
129
+
130
+
131
+ def short_body(body: str, limit: int = 62) -> str:
132
+ """副标题槽位只用正文第一句;太长就在句内标点处收掉,别让图里塞一整段。"""
133
+ t = re.sub(r"\s+", "", body or "")
134
+ if not t:
135
+ return ""
136
+ parts = re.split(r"(?<=[。!?!?])", t)
137
+ first = (parts[0] if parts and parts[0] else t).strip()
138
+ if len(first) <= limit:
139
+ return first
140
+ cut = first[:limit]
141
+ for mark in (",", "、", ";", ":"):
142
+ i = cut.rfind(mark)
143
+ if i > limit * 0.45:
144
+ return cut[:i] + "。"
145
+ return cut + "。"
146
+
147
+
148
+ def build_slots(title: str, body: str, scene: str, layout: dict | None) -> str:
149
+ """文字槽位。三层版式按「上层主标题 / 中层小辅助图 / 下层副标题」写,
150
+ 其他图型退回「主标题 / 正文」,免得槽位和图型打架。"""
151
+ three = bool(layout) and "三层" in (layout.get("prompt") or "")
152
+ sub = short_body(body)
153
+ bits = []
154
+ if three:
155
+ if title:
156
+ bits.append(f"上层主标题文字:「{title}」")
157
+ bits.append("中层小辅助图:" + (scene.strip() or SCENE_FALLBACK))
158
+ if sub:
159
+ bits.append(f"下层副标题文字:「{sub}」")
160
+ else:
161
+ if title:
162
+ bits.append(f"主标题文字:「{title}」")
163
+ if sub:
164
+ bits.append(f"正文文字:「{sub}」")
165
+ if not bits:
166
+ return ""
167
+ out = "。".join(bits)
168
+ if not out.endswith(("」", "。")):
169
+ out += "。"
170
+ return out
171
+
172
+
173
+ def parse_moods(raw) -> list[str]:
174
+ if not raw:
175
+ return []
176
+ if isinstance(raw, (list, tuple)):
177
+ tokens = []
178
+ for part in raw:
179
+ tokens += re.split(r"[、,,/\s]+", str(part))
180
+ else:
181
+ tokens = re.split(r"[、,,/\s]+", str(raw))
182
+ seen, out = set(), []
183
+ for t in tokens:
184
+ t = t.strip()
185
+ if t and t not in seen:
186
+ seen.add(t)
187
+ out.append(t)
188
+ return out
189
+
190
+
191
+ def build_instruction(sel: dict, ratio: str, moods: list[str], fusion: bool, whitespace: str,
192
+ ref: bool = False, image_mode: str = "graphic-text", shoot: str = "") -> str:
193
+ """一行短指令,形如:图文模式,图型:SC-002,风格:FB-001,主题色:C-26,画幅比例:3:4,情绪:治愈、幽默"""
194
+ bits = ["摄影写真模式" if image_mode == "photo" else "图文模式"]
195
+ if ref:
196
+ bits.append("参考图:已上传")
197
+ layout = sel.get("layout")
198
+ style = sel.get("style")
199
+ color = sel.get("color")
200
+ # 摄影模式不套图型版式:图型模板写的是「主标题/小辅助图/副标题」的位置,和写真不排字冲突
201
+ if layout and image_mode != "photo":
202
+ bits.append(f"图型:{layout['id']}")
203
+ if style:
204
+ bits.append(f"风格:{style['id']}")
205
+ if color:
206
+ bits.append(f"主题色:{color['id']}")
207
+ # 三样都可以不选:说明这一轮靠什么出图,避免指令读起来像漏了参数
208
+ if not layout and not style and not color:
209
+ bits.append("图型 / 风格 / 主题色:跟参考图" if ref else "图型 / 风格 / 主题色:不限")
210
+ if ratio:
211
+ bits.append(f"画幅比例:{ratio}")
212
+ if moods:
213
+ bits.append("情绪:" + "、".join(moods))
214
+ if image_mode == "photo" and shoot:
215
+ bits.append(f"拍摄类型:{shoot}")
216
+ if fusion:
217
+ bits.append("缩微摄影融合")
218
+ if whitespace == "moderate":
219
+ bits.append("留白:大量")
220
+ elif whitespace == "high":
221
+ bits.append("留白:极简")
222
+ return ",".join(bits)
223
+
224
+
225
+ def _ids_to_names(instr: str, sel: dict) -> str:
226
+ """手改指令里的编号换成自然语言名,遵守「提示词不出现编号」的规矩。"""
227
+ layout = sel.get("layout")
228
+ style = sel.get("style")
229
+ color = sel.get("color")
230
+ if layout:
231
+ instr = instr.replace(f"图型:{layout['id']}", f"图型:{(layout.get('title') or '').strip()}")
232
+ if style:
233
+ instr = instr.replace(f"风格:{style['id']}",
234
+ f"风格:{(style.get('name_en') or style.get('name') or '').strip()}")
235
+ if color:
236
+ instr = instr.replace(f"主题色:{color['id']}", f"主题色:{(color.get('name') or '').strip()}")
237
+ return instr
238
+
239
+
240
+ def build_prompt(item: dict, sel: dict, ratio: str, moods: list[str],
241
+ fusion: bool, whitespace: str, note: str = "", ref: bool = False,
242
+ image_mode: str = "graphic-text", shoot: str = "", instr: str = "") -> str:
243
+ """单条完整提示词。item = {title, body}"""
244
+ style = sel.get("style")
245
+ layout = sel.get("layout")
246
+ color = sel.get("color")
247
+ title = clean_title(item.get("title") or "")
248
+ body = (item.get("body") or "").strip()
249
+ scene = (item.get("scene") or "").strip()
250
+
251
+ blocks: list[str] = []
252
+
253
+ if ref:
254
+ blocks.append(REF_FUSION)
255
+
256
+ # 手改过的出图指令原样进提示词(编号已换成自然语言名),用户改了什么就生效什么
257
+ if instr:
258
+ blocks.append(_ids_to_names(instr, sel))
259
+
260
+ head = style_line(style) + color_line(color)
261
+ if head:
262
+ blocks.append(head)
263
+
264
+ layout_text = "" if image_mode == "photo" else layout_line(layout)
265
+ if layout_text:
266
+ blocks.append(layout_text)
267
+
268
+ # 缩微摄影融合在图文 / 摄影两种模式下都生效(用户 2026-11-05 定)
269
+ if fusion:
270
+ blocks.append(MICRO_FUSION)
271
+
272
+ if note.strip():
273
+ blocks.append(f"补充要求:{note.strip()}。")
274
+
275
+ if image_mode == "photo":
276
+ # 摄影模式:上游提示词里没有文字槽位,分镜的标题/正文只当画面内容
277
+ blocks.append(PHOTO_LEAD)
278
+ if shoot:
279
+ blocks.append(f"拍摄类型:{shoot}。")
280
+ blocks.append(PHOTO_KEYWORDS)
281
+ subject = re.sub(r"^0*\d{1,3}[\s..、,,::||\-—]+", "", title).strip() or title
282
+ # 分镜标题/正文/小辅助图都当画面内容;各自收掉句末标点,避免「。。」
283
+ scene_bits = [re.sub(r"[。!?\.!?,,;;]+$", "", x).strip() for x in (subject, body, scene)]
284
+ scene_bits = [x for x in scene_bits if x]
285
+ if scene_bits:
286
+ blocks.append("画面:" + "。".join(scene_bits) + "。")
287
+ elif ref:
288
+ # 只传了参考图、没写任何文案:不给主体的话模型只能照抄原图
289
+ blocks.append("画面:沿用参考图里的主体与场景,重新组织构图拍一张。")
290
+ else:
291
+ slots = build_slots(title, body, scene, layout)
292
+ if slots:
293
+ blocks.append(slots)
294
+ blocks.append("文字清晰可读,文字参与构图,不要出现多余文字。")
295
+ elif ref:
296
+ # 只上传参考图、不选图型风格主题色(用户 2026-11-08 要求):没有文案可排,
297
+ # 就让参考图当唯一依据,重新组织构图出一张完整画面,别把原图原样复刻
298
+ blocks.append(
299
+ "没有文案:画面主体与画风都取自参考图,重新组织构图、环境与动作,"
300
+ "画一张完整的插画,画面里不排文字。"
301
+ )
302
+
303
+ tail = []
304
+ if moods:
305
+ tail.append("情绪:" + "、".join(moods))
306
+ if color:
307
+ tail.append(f"{color.get('name') or ''}作统一色调点缀")
308
+ if ratio:
309
+ tail.append(f"画幅比例 {ratio}")
310
+ tail = [t for t in tail if t and t.strip(":")]
311
+ if tail:
312
+ blocks.append("。".join(tail) + "。")
313
+
314
+ if str(ratio or "").strip() == WIDE_CROP_RATIO:
315
+ blocks.append(WIDE_CROP_HINT)
316
+
317
+ if whitespace in WHITESPACE:
318
+ blocks.append(WHITESPACE[whitespace])
319
+
320
+ blocks.append(TAIL_INSTRUCTION)
321
+ return "\n".join(b for b in blocks if b)
322
+
323
+
324
+ def compose(payload: dict, styles: list[dict], layouts: list[dict], colors: list[dict]) -> dict:
325
+ """组装全部条目。payload 里 items 为空时用 topic 兜底生成一条。"""
326
+
327
+ def pick(items, item_id):
328
+ if not item_id:
329
+ return None
330
+ return next((x for x in items if x["id"] == item_id), None)
331
+
332
+ sel = {
333
+ "style": pick(styles, payload.get("style_id")),
334
+ "layout": pick(layouts, payload.get("layout_id")),
335
+ "color": pick(colors, payload.get("color_id")),
336
+ }
337
+ ratio = (payload.get("ratio") or "").strip()
338
+ moods = parse_moods(payload.get("mood"))
339
+ fusion = bool(payload.get("fusion"))
340
+ whitespace = (payload.get("whitespace") or "normal").strip()
341
+ note = (payload.get("note") or "").strip()
342
+ ref = bool(payload.get("ref"))
343
+ # 出图模式:默认图文,和以前完全一样;只有显式传 photo 才走摄影分支
344
+ image_mode = (payload.get("image_mode") or "graphic-text").strip()
345
+ if image_mode not in PHOTO_MODES:
346
+ image_mode = "graphic-text"
347
+ shoot = (payload.get("shoot_type") or "").strip()[:40] if image_mode == "photo" else ""
348
+ # allow_blank:左侧没写标题/正文时也允许出提示词(纯指令出图);instruction:手改指令
349
+ allow_blank = bool(payload.get("allow_blank"))
350
+ custom_instr = (payload.get("instruction") or "").strip()
351
+
352
+ raw_items = payload.get("items") or []
353
+ items = []
354
+ for raw in raw_items:
355
+ if not isinstance(raw, dict):
356
+ continue
357
+ title = (raw.get("title") or "").strip()
358
+ body = (raw.get("body") or "").strip()
359
+ scene = (raw.get("scene") or "").strip()
360
+ if not title and not body and not allow_blank:
361
+ continue
362
+ items.append({"title": title, "body": body, "scene": scene})
363
+
364
+ if not items:
365
+ topic = (payload.get("topic") or "").strip()
366
+ if topic:
367
+ lines = topic.split("\n", 1)
368
+ items.append({"title": lines[0].strip(), "body": lines[1].strip() if len(lines) > 1 else "", "scene": ""})
369
+
370
+ instruction = custom_instr or build_instruction(
371
+ sel, ratio, moods, fusion, whitespace, ref, image_mode, shoot)
372
+ out_items = []
373
+ for idx, item in enumerate(items, 1):
374
+ out_items.append({
375
+ "index": idx,
376
+ "title": item["title"],
377
+ "body": item["body"],
378
+ "scene": item.get("scene", ""),
379
+ "instruction": instruction,
380
+ "prompt": build_prompt(item, sel, ratio, moods, fusion, whitespace, note, ref,
381
+ image_mode, shoot, instr=custom_instr),
382
+ })
383
+ return {
384
+ "instruction": instruction,
385
+ "items": out_items,
386
+ "selection": {
387
+ "style": sel["style"]["id"] if sel["style"] else None,
388
+ "layout": sel["layout"]["id"] if sel["layout"] else None,
389
+ "color": sel["color"]["id"] if sel["color"] else None,
390
+ },
391
+ }