minimal-agent 0.8.2 → 0.10.0

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 (484) hide show
  1. package/README.md +14 -12
  2. package/package.json +34 -1
  3. package/plugins/cron-runner/.claude-plugin/plugin.json +5 -0
  4. package/plugins/cron-runner/commands/cron.md +26 -0
  5. package/plugins/cron-runner/commands/crons.md +7 -0
  6. package/plugins/cron-runner/plugin.js +34 -0
  7. package/plugins/cron-runner/src/commands.js +348 -0
  8. package/plugins/cron-runner/src/cronTime.js +148 -0
  9. package/plugins/cron-runner/src/lock.js +68 -0
  10. package/plugins/cron-runner/src/runs.js +89 -0
  11. package/plugins/cron-runner/src/scheduleEngine.js +72 -0
  12. package/plugins/cron-runner/src/scheduler.js +355 -0
  13. package/plugins/cron-runner/src/schema.js +140 -0
  14. package/plugins/cron-runner/src/service.js +167 -0
  15. package/plugins/cron-runner/src/store.js +298 -0
  16. package/plugins/cron-runner/src/types.js +1 -0
  17. package/plugins/workflow-runner/commands/workflow.md +4 -4
  18. package/plugins/workflow-runner/commands/workflows.md +1 -1
  19. package/plugins/workflow-runner/plugin.js +3 -1
  20. package/plugins/workflow-runner/src/agentCommon.js +249 -51
  21. package/plugins/workflow-runner/src/dslManifest.js +72 -0
  22. package/plugins/workflow-runner/src/expressions.js +198 -15
  23. package/plugins/workflow-runner/src/index.js +40 -8
  24. package/plugins/workflow-runner/src/jsonPayload.js +1 -1
  25. package/plugins/workflow-runner/src/loader.js +286 -72
  26. package/plugins/workflow-runner/src/multimodal.js +37 -0
  27. package/plugins/workflow-runner/src/recovery.js +49 -39
  28. package/plugins/workflow-runner/src/runner.js +802 -287
  29. package/plugins/workflow-runner/src/stepExecutors/agent.js +157 -39
  30. package/plugins/workflow-runner/src/stepExecutors/assert.js +16 -3
  31. package/plugins/workflow-runner/src/stepExecutors/llm.js +60 -111
  32. package/plugins/workflow-runner/src/stepExecutors/skill.js +47 -47
  33. package/plugins/workflow-runner/src/stepExecutors/tool.js +76 -10
  34. package/plugins/workflow-runner/src/stepExecutors/verify.js +27 -10
  35. package/plugins/workflow-runner/src/taskIdentity.js +296 -0
  36. package/plugins/workflow-runner/src/types.js +19 -9
  37. package/plugins/workflow-runner/src/workflowState.js +516 -33
  38. package/skills/adversarial-review/SKILL.md +0 -1
  39. package/skills/ai-image/SKILL.md +89 -0
  40. package/skills/ai-image/scripts/generate.mjs +497 -0
  41. package/skills/ai-image/scripts/generate_from_plan.mjs +469 -0
  42. package/skills/ai-image/scripts/generate_from_plan_test.mjs +223 -0
  43. package/skills/ai-image/scripts/generate_test.mjs +74 -0
  44. package/skills/browser-automation/SKILL.md +47 -0
  45. package/skills/docx/SKILL.md +25 -3
  46. package/skills/docx/scripts/office/helpers/__pycache__/__init__.cpython-314.pyc +0 -0
  47. package/skills/docx/scripts/office/helpers/__pycache__/merge_runs.cpython-314.pyc +0 -0
  48. package/skills/docx/scripts/office/helpers/__pycache__/simplify_redlines.cpython-314.pyc +0 -0
  49. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-314.pyc +0 -0
  50. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-314.pyc +0 -0
  51. package/skills/docx/scripts/office/validators/__pycache__/docx.cpython-314.pyc +0 -0
  52. package/skills/docx/scripts/office/validators/__pycache__/pptx.cpython-314.pyc +0 -0
  53. package/skills/docx/scripts/office/validators/__pycache__/redlining.cpython-314.pyc +0 -0
  54. package/skills/docx/scripts/opc_check.py +253 -0
  55. package/skills/image-gen-openrouter/SKILL.md +10 -2
  56. package/skills/image-stats/SKILL.md +65 -0
  57. package/skills/image-stats/scripts/__pycache__/image_stats.cpython-314.pyc +0 -0
  58. package/skills/image-stats/scripts/__pycache__/image_stats_test.cpython-314.pyc +0 -0
  59. package/skills/image-stats/scripts/image_stats.py +368 -0
  60. package/skills/image-stats/scripts/image_stats_test.py +181 -0
  61. package/skills/pptx/SKILL.md +123 -95
  62. package/skills/pptx/design.md +82 -0
  63. package/skills/pptx/examples/README.md +29 -0
  64. package/skills/pptx/examples/hybrid_example.mjs +107 -0
  65. package/skills/pptx/examples/hybrid_example.pptx +0 -0
  66. package/skills/pptx/pptxgenjs.md +41 -0
  67. package/skills/pptx/scripts/deck_helpers.mjs +224 -0
  68. package/skills/pptx/scripts/layout_lint.py +293 -0
  69. package/skills/pptx/scripts/layout_lint_test.py +160 -0
  70. package/skills/pptx/scripts/opc_check.py +339 -0
  71. package/skills/pptx/scripts/svg_fallback.mjs +145 -0
  72. package/skills/pptx/svg.md +67 -0
  73. package/skills/tts-zh/SKILL.md +19 -2
  74. package/skills/ui-design/SKILL.md +158 -0
  75. package/skills/ui-design/claude_design.md +309 -0
  76. package/skills/ui-design/evals/evals.json +254 -0
  77. package/skills/ui-design/references/aesthetic-direction.md +83 -0
  78. package/skills/ui-design/references/claude-design-protocol.md +36 -0
  79. package/skills/ui-design/references/design-context.md +53 -0
  80. package/skills/ui-design/references/design-execution-playbook.md +82 -0
  81. package/skills/ui-design/references/export-pptx.md +124 -0
  82. package/skills/ui-design/references/layout-composition.md +654 -0
  83. package/skills/ui-design/references/preview-feedback.md +37 -0
  84. package/skills/ui-design/references/source-image-layout.md +189 -0
  85. package/skills/ui-design/references/starter-components.md +78 -0
  86. package/skills/ui-design/references/vi-aegis-vector.md +144 -0
  87. package/skills/ui-design/references/vi-amber-prism.md +133 -0
  88. package/skills/ui-design/references/vi-aqua-glass.md +200 -0
  89. package/skills/ui-design/references/vi-comfort-vision.md +147 -0
  90. package/skills/ui-design/references/vi-ember-prism.md +140 -0
  91. package/skills/ui-design/references/vi-nebula-console.md +151 -0
  92. package/skills/ui-design/references/vi-obsidian-optics.md +139 -0
  93. package/skills/ui-design/references/vi-obsidian-xui.md +143 -0
  94. package/skills/ui-design/references/vi-precision-horizon-dark.md +117 -0
  95. package/skills/ui-design/references/vi-precision-horizon.md +215 -0
  96. package/skills/ui-design/references/vi-solar-relay.md +130 -0
  97. package/skills/ui-design/references/vi-template-workflow.md +48 -0
  98. package/skills/ui-design/references/vi-verdant-grid.md +141 -0
  99. package/skills/ui-design/references/vi-violet-meridian.md +172 -0
  100. package/skills/ui-design/scripts/__pycache__/assemble_material.cpython-314.pyc +0 -0
  101. package/skills/ui-design/scripts/__pycache__/assemble_material_test.cpython-314.pyc +0 -0
  102. package/skills/ui-design/scripts/__pycache__/chart_sanity_check.cpython-314.pyc +0 -0
  103. package/skills/ui-design/scripts/__pycache__/check_language_policy.cpython-314.pyc +0 -0
  104. package/skills/ui-design/scripts/__pycache__/check_ui_artifact.cpython-314.pyc +0 -0
  105. package/skills/ui-design/scripts/__pycache__/check_ui_artifact_pptx_test.cpython-314.pyc +0 -0
  106. package/skills/ui-design/scripts/__pycache__/deck_pages_check.cpython-314.pyc +0 -0
  107. package/skills/ui-design/scripts/__pycache__/deck_pages_check_test.cpython-314.pyc +0 -0
  108. package/skills/ui-design/scripts/__pycache__/deck_visual_style_check.cpython-314.pyc +0 -0
  109. package/skills/ui-design/scripts/__pycache__/deck_visual_style_check_test.cpython-314.pyc +0 -0
  110. package/skills/ui-design/scripts/__pycache__/design_budget_check.cpython-314.pyc +0 -0
  111. package/skills/ui-design/scripts/__pycache__/design_budget_check_test.cpython-314.pyc +0 -0
  112. package/skills/ui-design/scripts/__pycache__/design_repair_hints.cpython-314.pyc +0 -0
  113. package/skills/ui-design/scripts/__pycache__/design_repair_hints_test.cpython-314.pyc +0 -0
  114. package/skills/ui-design/scripts/__pycache__/design_style_check.cpython-314.pyc +0 -0
  115. package/skills/ui-design/scripts/__pycache__/design_style_check_test.cpython-314.pyc +0 -0
  116. package/skills/ui-design/scripts/__pycache__/draft_coverage_check.cpython-314.pyc +0 -0
  117. package/skills/ui-design/scripts/__pycache__/extract_source_assets.cpython-314.pyc +0 -0
  118. package/skills/ui-design/scripts/__pycache__/extract_source_assets_test.cpython-314.pyc +0 -0
  119. package/skills/ui-design/scripts/__pycache__/generated_image_review_collect.cpython-314.pyc +0 -0
  120. package/skills/ui-design/scripts/__pycache__/generated_image_review_collect_test.cpython-314.pyc +0 -0
  121. package/skills/ui-design/scripts/__pycache__/image_plan_lint.cpython-314.pyc +0 -0
  122. package/skills/ui-design/scripts/__pycache__/image_plan_lint_test.cpython-314.pyc +0 -0
  123. package/skills/ui-design/scripts/__pycache__/image_prompt_rewrite.cpython-314.pyc +0 -0
  124. package/skills/ui-design/scripts/__pycache__/image_prompt_rewrite_test.cpython-314.pyc +0 -0
  125. package/skills/ui-design/scripts/__pycache__/layout_balance.cpython-314.pyc +0 -0
  126. package/skills/ui-design/scripts/__pycache__/layout_balance_test.cpython-314.pyc +0 -0
  127. package/skills/ui-design/scripts/__pycache__/normalize_design_json.cpython-314.pyc +0 -0
  128. package/skills/ui-design/scripts/__pycache__/normalize_design_json_test.cpython-314.pyc +0 -0
  129. package/skills/ui-design/scripts/__pycache__/plan_source_layout.cpython-314.pyc +0 -0
  130. package/skills/ui-design/scripts/__pycache__/plan_source_layout_test.cpython-314.pyc +0 -0
  131. package/skills/ui-design/scripts/__pycache__/resolve_source_hint.cpython-314.pyc +0 -0
  132. package/skills/ui-design/scripts/__pycache__/resolve_source_hint_test.cpython-314.pyc +0 -0
  133. package/skills/ui-design/scripts/__pycache__/resolve_source_input.cpython-314.pyc +0 -0
  134. package/skills/ui-design/scripts/__pycache__/resolve_source_input_test.cpython-314.pyc +0 -0
  135. package/skills/ui-design/scripts/__pycache__/scaffold_ui_artifact.cpython-314.pyc +0 -0
  136. package/skills/ui-design/scripts/__pycache__/source_usage_check.cpython-314.pyc +0 -0
  137. package/skills/ui-design/scripts/__pycache__/source_usage_check_test.cpython-314.pyc +0 -0
  138. package/skills/ui-design/scripts/__pycache__/text_readability_pixel.cpython-314.pyc +0 -0
  139. package/skills/ui-design/scripts/__pycache__/visual_quality_gate.cpython-314.pyc +0 -0
  140. package/skills/ui-design/scripts/__pycache__/visual_quality_gate_test.cpython-314.pyc +0 -0
  141. package/skills/ui-design/scripts/assemble_material.py +217 -0
  142. package/skills/ui-design/scripts/assemble_material_test.py +149 -0
  143. package/skills/ui-design/scripts/browser_check_ui_artifact.mjs +221 -0
  144. package/skills/ui-design/scripts/chart_sanity_check.py +261 -0
  145. package/skills/ui-design/scripts/chart_sanity_check_test.py +165 -0
  146. package/skills/ui-design/scripts/check_language_policy.py +351 -0
  147. package/skills/ui-design/scripts/check_ui_artifact.py +435 -0
  148. package/skills/ui-design/scripts/check_ui_artifact_pptx_test.py +251 -0
  149. package/skills/ui-design/scripts/deck_pages_check.py +158 -0
  150. package/skills/ui-design/scripts/deck_pages_check_test.py +134 -0
  151. package/skills/ui-design/scripts/deck_visual_style_check.py +767 -0
  152. package/skills/ui-design/scripts/deck_visual_style_check_test.py +713 -0
  153. package/skills/ui-design/scripts/design_budget_check.py +271 -0
  154. package/skills/ui-design/scripts/design_budget_check_test.py +183 -0
  155. package/skills/ui-design/scripts/design_repair_hints.py +162 -0
  156. package/skills/ui-design/scripts/design_repair_hints_test.py +54 -0
  157. package/skills/ui-design/scripts/design_style_check.py +397 -0
  158. package/skills/ui-design/scripts/design_style_check_test.py +233 -0
  159. package/skills/ui-design/scripts/draft_coverage_check.py +184 -0
  160. package/skills/ui-design/scripts/draft_coverage_check_test.py +131 -0
  161. package/skills/ui-design/scripts/export_pptx_from_html.mjs +2367 -0
  162. package/skills/ui-design/scripts/extract_source_assets.py +607 -0
  163. package/skills/ui-design/scripts/extract_source_assets_test.py +256 -0
  164. package/skills/ui-design/scripts/generated_image_review_collect.py +206 -0
  165. package/skills/ui-design/scripts/generated_image_review_collect_test.py +91 -0
  166. package/skills/ui-design/scripts/image_plan_lint.py +571 -0
  167. package/skills/ui-design/scripts/image_plan_lint_test.py +515 -0
  168. package/skills/ui-design/scripts/image_prompt_rewrite.py +713 -0
  169. package/skills/ui-design/scripts/image_prompt_rewrite_test.py +966 -0
  170. package/skills/ui-design/scripts/layout_balance.py +881 -0
  171. package/skills/ui-design/scripts/layout_balance_test.py +927 -0
  172. package/skills/ui-design/scripts/normalize_design_json.py +272 -0
  173. package/skills/ui-design/scripts/normalize_design_json_test.py +119 -0
  174. package/skills/ui-design/scripts/plan_source_layout.py +1450 -0
  175. package/skills/ui-design/scripts/plan_source_layout_test.py +1136 -0
  176. package/skills/ui-design/scripts/resolve_source_input.py +270 -0
  177. package/skills/ui-design/scripts/resolve_source_input_test.py +98 -0
  178. package/skills/ui-design/scripts/scaffold_ui_artifact.py +377 -0
  179. package/skills/ui-design/scripts/source_usage_check.py +581 -0
  180. package/skills/ui-design/scripts/source_usage_check_test.py +427 -0
  181. package/skills/ui-design/scripts/text_readability_pixel.py +340 -0
  182. package/skills/ui-design/scripts/text_readability_pixel_test.py +219 -0
  183. package/skills/ui-design/scripts/visual_quality_gate.py +301 -0
  184. package/skills/ui-design/scripts/visual_quality_gate_test.py +233 -0
  185. package/skills/ui-design/tests/e2e/prompts/deck.md +19 -0
  186. package/skills/ui-design/tests/e2e/prompts/webpage.md +17 -0
  187. package/skills/ui-design/tests/e2e/render_check.mjs +101 -0
  188. package/skills/ui-design/tests/e2e/rubric.json +54 -0
  189. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/checks/browser-check.json +49 -0
  190. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/checks/language-check.txt +12 -0
  191. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/checks/static-check.txt +15 -0
  192. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/outputs/index.html +837 -0
  193. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/prompt.md +12 -0
  194. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/review.md +21 -0
  195. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/screenshots/desktop.png +0 -0
  196. package/skills/ui-design/tests/e2e/runs/aegis-vector-preview-1/screenshots/mobile.png +0 -0
  197. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/outputs/homepage.html +373 -0
  198. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/outputs/platform.html +378 -0
  199. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/prompt.md +13 -0
  200. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/review.md +26 -0
  201. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/screenshots/homepage/browser-check.json +49 -0
  202. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/screenshots/homepage/desktop.png +0 -0
  203. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/screenshots/homepage/mobile.png +0 -0
  204. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/screenshots/platform/browser-check.json +49 -0
  205. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/screenshots/platform/desktop.png +0 -0
  206. package/skills/ui-design/tests/e2e/runs/amber-prism-screenshot-check-1/screenshots/platform/mobile.png +0 -0
  207. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-1/checks/static-check.txt +2 -0
  208. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-1/outputs/index.html +656 -0
  209. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-1/prompt.md +12 -0
  210. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-1/render_check.mjs +67 -0
  211. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-2/checks/static-check.txt +2 -0
  212. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-2/outputs/index.html +587 -0
  213. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-2/prompt.md +12 -0
  214. package/skills/ui-design/tests/e2e/runs/aqua-glass-iteration-2/review.md +36 -0
  215. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/checks/browser-check.json +49 -0
  216. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/checks/language-check.txt +8 -0
  217. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/checks/static-check.txt +16 -0
  218. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/outputs/index.html +739 -0
  219. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/prompt.md +11 -0
  220. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/review.md +22 -0
  221. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/screenshots/desktop.png +0 -0
  222. package/skills/ui-design/tests/e2e/runs/aqua-glass-volcano-refresh-1/screenshots/mobile.png +0 -0
  223. package/skills/ui-design/tests/e2e/runs/comfort-vision-preview-1/checks/browser-check.json +52 -0
  224. package/skills/ui-design/tests/e2e/runs/comfort-vision-preview-1/outputs/index.html +641 -0
  225. package/skills/ui-design/tests/e2e/runs/comfort-vision-preview-1/screenshots/desktop.png +0 -0
  226. package/skills/ui-design/tests/e2e/runs/comfort-vision-preview-1/screenshots/mobile.png +0 -0
  227. package/skills/ui-design/tests/e2e/runs/ember-prism-red-preview-1/outputs/index.html +467 -0
  228. package/skills/ui-design/tests/e2e/runs/ember-prism-red-preview-1/screenshots/browser-check.json +49 -0
  229. package/skills/ui-design/tests/e2e/runs/ember-prism-red-preview-1/screenshots/desktop.png +0 -0
  230. package/skills/ui-design/tests/e2e/runs/ember-prism-red-preview-1/screenshots/mobile.png +0 -0
  231. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/outputs/homepage.html +373 -0
  232. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/outputs/platform.html +378 -0
  233. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/prompt.md +13 -0
  234. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/review.md +26 -0
  235. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/screenshots/homepage/browser-check.json +49 -0
  236. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/screenshots/homepage/desktop.png +0 -0
  237. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/screenshots/homepage/mobile.png +0 -0
  238. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/screenshots/platform/browser-check.json +49 -0
  239. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/screenshots/platform/desktop.png +0 -0
  240. package/skills/ui-design/tests/e2e/runs/ember-prism-screenshot-check-1/screenshots/platform/mobile.png +0 -0
  241. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/checks/browser-check.json +49 -0
  242. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/checks/language-check.txt +4 -0
  243. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/checks/static-check.txt +2 -0
  244. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/execution_notes.md +10 -0
  245. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/outputs/index.html +587 -0
  246. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/prompt.md +14 -0
  247. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/review.md +13 -0
  248. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/screenshots/desktop.png +0 -0
  249. package/skills/ui-design/tests/e2e/runs/empty-agent-protocol-check/screenshots/mobile.png +0 -0
  250. package/skills/ui-design/tests/e2e/runs/iteration-1/deck/checks/browser-check.json +24 -0
  251. package/skills/ui-design/tests/e2e/runs/iteration-1/deck/checks/static-check.txt +2 -0
  252. package/skills/ui-design/tests/e2e/runs/iteration-1/deck/execution_notes.md +21 -0
  253. package/skills/ui-design/tests/e2e/runs/iteration-1/deck/outputs/deck.html +451 -0
  254. package/skills/ui-design/tests/e2e/runs/iteration-1/deck/screenshots/slide-01.png +0 -0
  255. package/skills/ui-design/tests/e2e/runs/iteration-1/deck/screenshots/slide-02.png +0 -0
  256. package/skills/ui-design/tests/e2e/runs/iteration-1/review.md +79 -0
  257. package/skills/ui-design/tests/e2e/runs/iteration-1/webpage/checks/browser-check.json +26 -0
  258. package/skills/ui-design/tests/e2e/runs/iteration-1/webpage/checks/static-check.txt +2 -0
  259. package/skills/ui-design/tests/e2e/runs/iteration-1/webpage/execution_notes.md +20 -0
  260. package/skills/ui-design/tests/e2e/runs/iteration-1/webpage/outputs/index.html +534 -0
  261. package/skills/ui-design/tests/e2e/runs/iteration-1/webpage/screenshots/desktop.png +0 -0
  262. package/skills/ui-design/tests/e2e/runs/iteration-1/webpage/screenshots/mobile.png +0 -0
  263. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/checks/browser-check.json +64 -0
  264. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/checks/language-check.txt +7 -0
  265. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/checks/quick-validate.txt +1 -0
  266. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/checks/static-console.txt +2 -0
  267. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/checks/static-homepage.txt +2 -0
  268. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/outputs/console.html +600 -0
  269. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/outputs/homepage.html +556 -0
  270. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/prompt.md +29 -0
  271. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/review.md +57 -0
  272. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/screenshots/console-desktop.png +0 -0
  273. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/screenshots/console-mobile.png +0 -0
  274. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/screenshots/homepage-desktop.png +0 -0
  275. package/skills/ui-design/tests/e2e/runs/nebula-console-iteration-1/screenshots/homepage-mobile.png +0 -0
  276. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/checks/browser-check.json +33 -0
  277. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/checks/language-check.txt +6 -0
  278. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/checks/quick-validate.txt +1 -0
  279. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/checks/static-check.txt +2 -0
  280. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/outputs/index.html +682 -0
  281. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/prompt.md +13 -0
  282. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/review.md +59 -0
  283. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/screenshots/desktop.png +0 -0
  284. package/skills/ui-design/tests/e2e/runs/obsidian-optics-iteration-1/screenshots/mobile.png +0 -0
  285. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/outputs/page-01.html +466 -0
  286. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/outputs/page-02.html +433 -0
  287. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/prompt.md +13 -0
  288. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/screenshots/page-01/browser-check.json +49 -0
  289. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/screenshots/page-01/desktop.png +0 -0
  290. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/screenshots/page-01/mobile.png +0 -0
  291. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/screenshots/page-02/browser-check.json +49 -0
  292. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/screenshots/page-02/desktop.png +0 -0
  293. package/skills/ui-design/tests/e2e/runs/obsidian-xui-screenshot-check-1/screenshots/page-02/mobile.png +0 -0
  294. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/checks/browser-check.json +49 -0
  295. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/checks/language-check.txt +3 -0
  296. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/checks/static-check.txt +2 -0
  297. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/execution_notes.md +24 -0
  298. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/outputs/index.html +723 -0
  299. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/prompt.md +15 -0
  300. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/review.md +26 -0
  301. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/screenshots/desktop.png +0 -0
  302. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-1/screenshots/mobile.png +0 -0
  303. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/checks/browser-dark.json +49 -0
  304. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/checks/browser-light.json +49 -0
  305. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/checks/language-check.txt +11 -0
  306. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/checks/static-dark.txt +2 -0
  307. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/checks/static-light.txt +3 -0
  308. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/execution_notes.md +23 -0
  309. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/outputs/dark.html +392 -0
  310. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/outputs/light.html +476 -0
  311. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/prompt.md +23 -0
  312. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/review.md +41 -0
  313. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/screenshots/dark/desktop.png +0 -0
  314. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/screenshots/dark/mobile.png +0 -0
  315. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/screenshots/light/desktop.png +0 -0
  316. package/skills/ui-design/tests/e2e/runs/precision-horizon-iteration-2/screenshots/light/mobile.png +0 -0
  317. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/checks/browser-check.json +54 -0
  318. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/checks/language-check.txt +5 -0
  319. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/checks/quick-validate.txt +1 -0
  320. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/checks/static-check.txt +2 -0
  321. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/outputs/index.html +765 -0
  322. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/prompt.md +15 -0
  323. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/review.md +59 -0
  324. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/screenshots/desktop.png +0 -0
  325. package/skills/ui-design/tests/e2e/runs/solar-relay-iteration-1/screenshots/mobile.png +0 -0
  326. package/skills/ui-design/tests/e2e/runs/verdant-grid-preview-1/outputs/index.html +508 -0
  327. package/skills/ui-design/tests/e2e/runs/verdant-grid-preview-1/screenshots/browser-check.json +49 -0
  328. package/skills/ui-design/tests/e2e/runs/verdant-grid-preview-1/screenshots/desktop.png +0 -0
  329. package/skills/ui-design/tests/e2e/runs/verdant-grid-preview-1/screenshots/mobile.png +0 -0
  330. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/checks/browser-check.json +54 -0
  331. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/checks/language-check.txt +6 -0
  332. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/checks/quick-validate.txt +1 -0
  333. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/checks/static-check.txt +2 -0
  334. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/outputs/index.html +788 -0
  335. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/prompt.md +14 -0
  336. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/review.md +60 -0
  337. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/screenshots/desktop.png +0 -0
  338. package/skills/ui-design/tests/e2e/runs/violet-meridian-iteration-1/screenshots/mobile.png +0 -0
  339. package/skills/video-compose/SKILL.md +8 -1
  340. package/skills/web-crawler/SKILL.md +70 -0
  341. package/skills/web-crawler/scripts/crawl.mjs +538 -0
  342. package/skills/web-crawler/scripts/crawl.test.mjs +204 -0
  343. package/skills/xlsx/SKILL.md +12 -0
  344. package/skills/xlsx/scripts/opc_check.py +253 -0
  345. package/src/advisorState.js +22 -0
  346. package/src/askBridge.js +97 -3
  347. package/src/bootstrap/workingDir.js +12 -1
  348. package/src/cli/args.js +17 -1
  349. package/src/cli/configMenu.js +236 -0
  350. package/src/cli/configWizard.js +7 -5
  351. package/src/cli/print.js +37 -4
  352. package/src/cli/streamJson.js +77 -24
  353. package/src/cli/wizards/advisorWizard.js +147 -0
  354. package/src/cli/wizards/fieldForm.js +149 -0
  355. package/src/cli/wizards/imageWizard.js +60 -0
  356. package/src/cli/wizards/openRouterWizard.js +23 -0
  357. package/src/cli/wizards/providerPresets.js +95 -0
  358. package/src/cli/wizards/providerWizard.js +219 -0
  359. package/src/cli/wizards/searchWizard.js +23 -0
  360. package/src/cli/wizards/ttsWizard.js +34 -0
  361. package/src/config/configFile.js +441 -83
  362. package/src/config/configService.js +446 -0
  363. package/src/config.js +24 -64
  364. package/src/context/agentHome.js +9 -0
  365. package/src/context/archive.js +7 -2
  366. package/src/context/compact.js +8 -5
  367. package/src/context/contextLock.js +81 -0
  368. package/src/context/persistContext.js +30 -11
  369. package/src/context/reactiveCompact.js +4 -2
  370. package/src/context/recentDirs.js +21 -61
  371. package/src/context/sessionPath.js +97 -23
  372. package/src/context/sessionRegistry.js +303 -24
  373. package/src/context/tokenCounter.js +18 -0
  374. package/src/context/workspaceIndex.js +731 -0
  375. package/src/infra/verification.js +28 -15
  376. package/src/llm/client.js +394 -159
  377. package/src/llm/providers/anthropicFormat.js +241 -0
  378. package/src/llm/providers/openaiFormat.js +190 -0
  379. package/src/loop.js +219 -32
  380. package/src/main.js +61 -11
  381. package/src/messageQueue.js +97 -0
  382. package/src/multimodal.js +203 -0
  383. package/src/plugin-sdk.js +15 -0
  384. package/src/plugins/commandRouter.js +3 -0
  385. package/src/plugins/hookEngine.js +5 -1
  386. package/src/plugins/pluginLifecycle.js +28 -0
  387. package/src/plugins/pluginLoader.js +2 -1
  388. package/src/plugins/pluginRunner.js +49 -21
  389. package/src/plugins/transcript.js +4 -3
  390. package/src/prompts/pluginList.js +5 -5
  391. package/src/prompts/projectInstructions.js +3 -3
  392. package/src/prompts/promptAssembly.js +133 -144
  393. package/src/prompts/skillList.js +12 -22
  394. package/src/prompts/system.js +0 -1
  395. package/src/session/runAgentTurn.js +70 -0
  396. package/src/session/runTurn.js +41 -6
  397. package/src/toolGate.js +17 -0
  398. package/src/tools/advisor/advisor.js +379 -0
  399. package/src/tools/agent/agent.js +555 -0
  400. package/src/tools/agent/agentTypes.js +59 -0
  401. package/src/tools/agent/coordinator.js +93 -0
  402. package/src/tools/agent/worktree.js +270 -0
  403. package/src/tools/ask/ask.js +7 -2
  404. package/src/tools/bash/bash.js +112 -18
  405. package/src/tools/download/download.js +144 -0
  406. package/src/tools/edit/edit.js +1 -0
  407. package/src/tools/edit/multi-edit.js +1 -0
  408. package/src/tools/genimage/genimage.js +242 -0
  409. package/src/tools/genspeech/genspeech.js +67 -0
  410. package/src/tools/glob/glob.js +1 -0
  411. package/src/tools/grep/grep.js +3 -0
  412. package/src/tools/grep/rgPath.js +2 -0
  413. package/src/tools/index.js +25 -2
  414. package/src/tools/read/read.js +1 -0
  415. package/src/tools/shared/killTree.js +52 -0
  416. package/src/tools/toolProgressBridge.js +48 -0
  417. package/src/tools/webbrowser/agentBrowserPath.js +258 -0
  418. package/src/tools/webbrowser/browser.js +59 -42
  419. package/src/tools/webbrowser/browserControl.js +118 -0
  420. package/src/tools/webbrowser/browserDaemon.js +97 -0
  421. package/src/tools/webbrowser/browserService.js +27 -0
  422. package/src/tools/webbrowser/browserStream.js +119 -0
  423. package/src/tools/webbrowser/webbrowser.js +699 -130
  424. package/src/tools/webfetch/webfetch.js +1 -0
  425. package/src/tools/websearch/websearch.js +134 -51
  426. package/src/tools/write/write.js +1 -0
  427. package/src/ui/App.js +249 -22
  428. package/src/ui/AskQuestion.js +29 -10
  429. package/src/ui/InputBox.js +104 -12
  430. package/src/ui/LiveArea.js +112 -19
  431. package/src/ui/MessageList.js +100 -14
  432. package/src/ui/ModelPicker.js +56 -0
  433. package/src/ui/QueuedMessagesBanner.js +14 -0
  434. package/src/ui/Root.js +18 -10
  435. package/src/ui/SessionPicker.js +102 -0
  436. package/src/ui/StatusLine.js +13 -4
  437. package/src/ui/WorkspacePicker.js +55 -0
  438. package/src/ui/figures.js +44 -0
  439. package/src/ui/hooks/askSlot.js +82 -0
  440. package/src/ui/hooks/useChat.js +358 -64
  441. package/src/ui/hooks/usePasteHandler.js +11 -1
  442. package/src/ui/hooks/useTextBuffer.js +1 -3
  443. package/src/ui/liveViewport.js +5 -1
  444. package/src/ui/markdown.js +206 -0
  445. package/src/ui/textBuffer.js +0 -7
  446. package/src/ui/theme.js +32 -0
  447. package/src/utils/debugLog.js +0 -4
  448. package/src/utils/resourcePaths.js +2 -2
  449. package/src/utils/stripThink.js +50 -0
  450. package/src/web/browserBridge.js +447 -0
  451. package/src/web/protocol.js +19 -0
  452. package/workflows/adaptive-research.yaml +13 -5
  453. package/workflows/book-review-short.yaml +36 -10
  454. package/workflows/e2e-write-greet.yaml +6 -2
  455. package/workflows/illustrate.yaml +227 -0
  456. package/workflows/plan.yaml +460 -179
  457. package/workflows/ppt.yaml +1659 -0
  458. package/workflows/ralph-loop.yaml +199 -0
  459. package/workflows/schema.json +81 -23
  460. package/workflows/webppt.yaml +496 -0
  461. package/workflows/word.yaml +710 -0
  462. package/workflows/youtube-shorts.yaml +46 -15
  463. package/MINIMAL-TRACE.md +0 -77
  464. package/plugins/HOW-TO-WRITE-A-PLUGIN.md +0 -186
  465. package/plugins/ralph-wiggum/.claude-plugin/plugin.json +0 -9
  466. package/plugins/ralph-wiggum/README.md +0 -197
  467. package/plugins/ralph-wiggum/commands/cancel-ralph.md +0 -18
  468. package/plugins/ralph-wiggum/commands/help.md +0 -137
  469. package/plugins/ralph-wiggum/commands/ralph-loop.md +0 -85
  470. package/plugins/ralph-wiggum/hooks/hooks.json +0 -15
  471. package/plugins/ralph-wiggum/hooks/stop-hook.sh +0 -191
  472. package/plugins/ralph-wiggum/plugin.js +0 -280
  473. package/plugins/ralph-wiggum/src/goalState.js +0 -258
  474. package/plugins/ralph-wiggum/src/sentinels.js +0 -76
  475. package/plugins/ralph-wiggum/src/stopHookRunner.js +0 -104
  476. package/plugins/ralph-wiggum/src/verificationGate.js +0 -202
  477. package/plugins/workflow-runner/src/miniReAct.js +0 -223
  478. package/plugins/workflow-runner/src/voteHelpers.js +0 -110
  479. package/skills/config/SKILL.md +0 -123
  480. package/skills/frontend-design/LICENSE.txt +0 -177
  481. package/skills/frontend-design/SKILL.md +0 -42
  482. package/src/ui/ProgressPanel.js +0 -54
  483. package/src/ui/StreamingBlock.js +0 -11
  484. package/src/ui/ToolStatus.js +0 -11
@@ -0,0 +1,1450 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ plan_source_layout.py —— 客户图「版式 + 图生图增强」**确定性**规划器(ppt 工作流专用)。
5
+
6
+ 为什么需要(根因治理)
7
+ ======================
8
+ 规则、能力、数据、闸门都齐了,客户图却仍被用烂(低清原图硬铺当背景、满屏文字配角落小图、
9
+ 该图生图增强的没增强):根因是「每张图 → 用什么版式 / 要不要图生图 / 文字排哪 / 配什么色 / 用什么
10
+ 提示词」这一**逐图决策**全压在一个弱模型身上——它要边读上百行 prose、边对十几页逐张心算分辨率
11
+ 是否过线、边兼顾其它十几条要求,必然走「省事路径」(直接铺、堆文字),而非「正确但费事路径」
12
+ (route=source-enhanced + 图生图)。
13
+
14
+ 本脚本把**可计算的那部分**从弱模型手里收成确定性算法:读 material.json(每图 base + vision 识图 +
15
+ stats 统计),为每张客户图算出 quality / usage_intent / 是否必须图生图 / 目标比例 / 推荐图文混排模板 /
16
+ 文字落区 / 文字深浅,并**直接生成图生图提示词骨架**(英文、只表达「增强变换」,主体由 --input-image
17
+ 参考图携带,无需翻译中文识图)。设计步从此「按算好的约束选模板」,不再自由心算。
18
+
19
+ 阈值与「低清不得做背景」判定**复用 source_usage_check.py 的单一真相源**(LOW_RES_BG_MIN_WIDTH /
20
+ LOW_RES_BG_MIN_MEGAPIXELS / _is_low_res_for_background),避免两处阈值漂移。
21
+
22
+ 模板名对齐 ui-design 既有 references(layout-composition.md §2.1/§2.2 + source-image-layout.md),
23
+ 不另造名词——脚本只做「确定性映射」,模板定义仍是 references 单一来源。
24
+
25
+ 两个模式
26
+ ========
27
+ 1. 规划(默认):`python plan_source_layout.py [--build ./build]`
28
+ 读 build/material.json → 写 build/source_layout_plan.json(机读)+ build/source_layout_plan.md(人/LLM 读)。
29
+ 恒成功(无 material/无客户图也写空规划);只用标准库 + 同目录 source_usage_check。
30
+
31
+ 2. 兜底注入(reconcile):`python plan_source_layout.py --ensure-img-plan [--build ./build]`
32
+ 读 source_layout_plan.json 的 enhance_entries,**union 进 build/img_plan.json**(按 file 去重),
33
+ 保证「必须图生图」的客户图一定有 image-to-image 生图条目——**不靠设计 LLM 记得加**。置于生图前调用。
34
+
35
+ 退出码:0(恒成功路径)。
36
+ """
37
+
38
+ import argparse
39
+ import json
40
+ import os
41
+ import re
42
+ import shutil
43
+ import sys
44
+
45
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
46
+ import source_usage_check as su # noqa: E402 复用低清阈值与判定(单一真相源)
47
+
48
+
49
+ # 极端比例:窄到 <0.45 或宽到 >3.6 的原图,塞进标准槽位必大留黑边或重裁 → 需图生图改比例。
50
+ BAD_ASPECT_MIN = 0.45
51
+ BAD_ASPECT_MAX = 3.6
52
+ # 文字深浅分界:图(或叠字区)平均亮度 ≥ 此值算「亮底」→ 深墨字;否则「暗底」→ 白字。
53
+ BRIGHTNESS_DARK_TEXT_MIN = 140
54
+ # 同章节图片数 ≥ 此值视为「可组画廊」。
55
+ GALLERY_MIN_PER_SECTION = 3
56
+
57
+ # 客户图「显示档位 → 最小占画布比例」(治「图被缩成角落小图/底部小条看不清」):
58
+ # 任何客户图显示都不得低于其档位下限;够大/高清的图给大槽(背景/hero),别塞进小卡缩成看不清。
59
+ MIN_CANVAS_FRACTION = {
60
+ "discard": 0.0, # 弃用图(不相关/负面):不进 deck、无槽位
61
+ "background": 1.0, # 整页底图
62
+ "foreground_hero": 0.30, # 前景主视觉 / 半幅大图(≈半背景),至少占画布 ~30%
63
+ "evidence_card": 0.12, # 证据图也必须可读,绝非角落缩略图
64
+ "gallery": 0.10, # 画廊每格下限
65
+ }
66
+ # 栅格客户图缩放上限(用户原话「最多 1 倍」):原图**绝不放大超过原生像素**(放大=糊)。
67
+ # 要显示得比原生大 → 必须图生图重绘到目标分辨率(enhance),不是硬拉伸 object-fit。
68
+ MAX_UPSCALE = 1.0
69
+
70
+ # 「信息密集图」关键词(截图/图表/界面/结构图等)。
71
+ # ⚠️ 用户明确要求:这类**照样 img2img 重绘**(enhance_style="dense"),但用「忠实保留版面与数据结构、
72
+ # 清晰化、去杂、统一 PPT 风格」的提示词。**数据图 img2img 天然有重绘/篡改风险,best-effort**——不背景化、
73
+ # 保持原始框幅;usage_intent 仍是 direct_contain(图文混排、不堆卡墙),与「全图生图」政策一致。
74
+ DENSE_INFO_KEYWORDS = (
75
+ "截图", "屏幕", "界面", "仪表盘", "控制台", "图表", "表格", "柱状", "折线", "饼图",
76
+ "曲线图", "流程", "架构", "示意图", "结构图", "矩阵", "参数表", "列表", "网页", "网站",
77
+ "文档", "表单", "代码", "对比图", "时间线", "看板",
78
+ "screenshot", "dashboard", "chart", "graph", "table", "diagram", "flowchart",
79
+ "ui", "interface", "matrix", "spreadsheet", "plot", "console",
80
+ )
81
+
82
+ _NO_TEXT_TAIL = "no text, no words, no letters, no captions, no logos"
83
+
84
+ # ③ 统一质量/分辨率词(用户要求:样式/质量/高分辨率写进每态提示词)。bg 另加 cinematic depth、cutout 另加 crisp cut-out edges。
85
+ _QUALITY = "photorealistic, sharp focus, high resolution (2K), clean professional lighting, high detail. "
86
+
87
+ # ② 背景类型关键词(识图驱动图生图分流,见 _background_kind):
88
+ # real_scene = 真实场景/环境/全景 → 保留并增强场景(hero,不去背景);
89
+ # flat_base = 纯色/渐变/白底/棚拍底/纹理 → 离散主体抠透明 或 换 VI 同色系干净底;
90
+ # 皆不命中 → unknown(回落既有 usage_intent + 离散主体启发式)。
91
+ # real_scene 优先于 flat_base(真实场景误判代价高——宁可保场景也别误抠/误换底)。
92
+ REAL_SCENE_KEYWORDS = (
93
+ "现场", "车间", "厂房", "环境", "全景", "驾驶舱", "户外", "天空", "场景",
94
+ "工厂", "产线", "生产线", "机房", "鸟瞰", "航拍", "实景", "街景", "园区",
95
+ "scene", "on-site", "on site", "factory", "workshop", "outdoor", "panorama",
96
+ "environment", "cockpit", "real scene", "on location",
97
+ )
98
+ FLAT_BASE_KEYWORDS = (
99
+ "白底", "纯色背景", "纯色底", "纯色", "单色背景", "灰底", "黑底", "渐变", "棚拍",
100
+ "抠图", "纹理底", "白色背景", "无背景",
101
+ "studio", "gradient", "plain background", "solid color", "solid background",
102
+ "white background", "seamless backdrop", "product shot on white",
103
+ )
104
+
105
+
106
+ def _background_kind(vision):
107
+ """识图驱动的背景类型分流:'real_scene' / 'flat_base' / 'unknown'。
108
+ 取 vision 的 description + palette_impression + key_elements 关键词判定;real_scene 优先命中(保守保场景)。
109
+ 驱动 _enhance_prompt 的 bg/hero/cutout 三态选择(与 _is_discrete_subject 联动,见 plan_one::enhance_style)。"""
110
+ if not isinstance(vision, dict):
111
+ return "unknown"
112
+ hay = " ".join(
113
+ [str(vision.get("description") or ""), str(vision.get("palette_impression") or "")]
114
+ + [str(x) for x in (vision.get("key_elements") or []) if isinstance(x, (str, int, float))]
115
+ ).lower()
116
+ if any(kw.lower() in hay for kw in REAL_SCENE_KEYWORDS):
117
+ return "real_scene"
118
+ if any(kw.lower() in hay for kw in FLAT_BASE_KEYWORDS):
119
+ return "flat_base"
120
+ return "unknown"
121
+
122
+
123
+ def _as_float(v, default=0.0):
124
+ try:
125
+ return float(v)
126
+ except (TypeError, ValueError):
127
+ return default
128
+
129
+
130
+ def _aspect_of(img):
131
+ ar = _as_float(img.get("aspect_ratio"), 0.0)
132
+ if ar > 0:
133
+ return ar
134
+ w = _as_float(img.get("w"), 0.0)
135
+ h = _as_float(img.get("h"), 0.0)
136
+ return (w / h) if (w > 0 and h > 0) else 0.0
137
+
138
+
139
+ def _orient_of(img, ar):
140
+ o = (img.get("orient") or "").strip().lower()
141
+ if o in ("landscape", "portrait", "square"):
142
+ return o
143
+ if ar <= 0:
144
+ return "square"
145
+ if ar >= 1.25:
146
+ return "landscape"
147
+ if ar <= 0.8:
148
+ return "portrait"
149
+ return "square"
150
+
151
+
152
+ def _is_dense_info(vision):
153
+ """识图描述/关键元素命中信息密集关键词 → True(照样 img2img,但走忠实保真提示词,见 DENSE_INFO_KEYWORDS 注释)。"""
154
+ if not isinstance(vision, dict):
155
+ return False
156
+ hay = " ".join(
157
+ [str(vision.get("description") or "")]
158
+ + [str(x) for x in (vision.get("key_elements") or []) if isinstance(x, (str, int, float))]
159
+ ).lower()
160
+ product_terminal = any(
161
+ kw.lower() in hay
162
+ for kw in (
163
+ "多终端", "播放终端", "终端设备", "电脑", "显示器", "平板", "手机", "vr", "眼镜",
164
+ "头显", "multi-device", "terminal device", "computer", "monitor", "tablet",
165
+ "smartphone", "phone", "vr headset", "headset", "goggles",
166
+ )
167
+ )
168
+ dense_context = any(
169
+ kw.lower() in hay
170
+ for kw in (
171
+ "截图", "界面", "表格", "图表", "数据", "列表", "控制台", "dashboard", "screenshot",
172
+ "interface", "table", "chart", "graph", "data", "list", "console",
173
+ )
174
+ )
175
+ if product_terminal and not dense_context:
176
+ return False
177
+ return any(kw in hay for kw in DENSE_INFO_KEYWORDS)
178
+
179
+
180
+ def _hero_target_aspect(orient, ar):
181
+ if orient == "portrait":
182
+ return "4:5"
183
+ if orient == "landscape":
184
+ return "21:9" if ar >= 2.0 else "16:9"
185
+ return "1:1"
186
+
187
+
188
+ def _complement_text_zone(orient):
189
+ # 横图配上下、竖图配左右(图文混排互补侧)。
190
+ if orient == "landscape":
191
+ return "bottom"
192
+ if orient == "portrait":
193
+ return "right"
194
+ return "right"
195
+
196
+
197
+ def _text_zone(vision, orient):
198
+ if isinstance(vision, dict):
199
+ z = (vision.get("recommended_text_zone") or "").strip().lower()
200
+ if z in ("left", "right", "top", "bottom", "center"):
201
+ return z
202
+ return _complement_text_zone(orient)
203
+
204
+
205
+ def _text_color(stats, is_background):
206
+ mean = None
207
+ if isinstance(stats, dict):
208
+ bri = stats.get("brightness")
209
+ if isinstance(bri, dict):
210
+ mean = bri.get("mean")
211
+ if isinstance(mean, (int, float)):
212
+ return "dark" if mean >= BRIGHTNESS_DARK_TEXT_MIN else "light"
213
+ # 无统计时:背景用法默认白字 + scrim(底图亮暗不可控),contain 小图默认深墨字(落在浅色正文页)。
214
+ return "light" if is_background else "dark"
215
+
216
+
217
+ def _templates_for(usage_intent, orient, is_dense):
218
+ if usage_intent == "discard":
219
+ return []
220
+ if usage_intent == "source_enhanced_bg":
221
+ if orient == "landscape":
222
+ return ["背景主导", "上图下文", "全幅浮层"]
223
+ return ["背景主导", "全幅浮层"]
224
+ if usage_intent == "source_enhanced_hero":
225
+ if orient == "portrait":
226
+ return ["左右产品剧场", "半包围文字"]
227
+ if orient == "landscape":
228
+ return ["上图下文", "左右产品剧场"]
229
+ return ["中心产品 + 周围论点", "左右产品剧场"]
230
+ if usage_intent == "gallery":
231
+ return ["四宫格画廊"]
232
+ if usage_intent == "small_evidence":
233
+ return ["图像证据卡", "四宫格画廊"]
234
+ # direct_contain
235
+ if is_dense:
236
+ return ["图像证据卡", "上图下文", "参数条 + 大图"]
237
+ if orient == "portrait":
238
+ return ["左右产品剧场", "半包围文字"]
239
+ if orient == "landscape":
240
+ return ["上图下文", "左右产品剧场"]
241
+ return ["中心产品 + 周围论点", "左右产品剧场"]
242
+
243
+
244
+ def _display_role(usage_intent):
245
+ """usage_intent → 显示档位(驱动 build 给多大的槽位,治「图被缩太小看不清」)。
246
+ background=整页底图;foreground_hero=前景主视觉/半幅大图(≈半背景);
247
+ evidence_card=证据图(仍须可读、非缩略图);gallery=画廊每格。"""
248
+ if usage_intent == "discard":
249
+ return "discard"
250
+ if usage_intent == "source_enhanced_bg":
251
+ return "background"
252
+ if usage_intent == "gallery":
253
+ return "gallery"
254
+ if usage_intent == "small_evidence":
255
+ return "evidence_card"
256
+ # source_enhanced_hero / direct_contain
257
+ return "foreground_hero"
258
+
259
+
260
+ def _image_role(usage_intent, enhance_style):
261
+ """给设计/构建步的页面语义:这张图到底是背景、透明前景、保真文档还是证据画廊。"""
262
+ if usage_intent == "discard":
263
+ return "discard"
264
+ if usage_intent == "source_enhanced_bg":
265
+ return "background"
266
+ if enhance_style == "cutout":
267
+ return "foreground_cutout"
268
+ if enhance_style == "dense":
269
+ return "faithful_document"
270
+ if usage_intent == "gallery":
271
+ return "evidence_gallery"
272
+ if usage_intent == "source_enhanced_hero":
273
+ return "foreground_hero"
274
+ return "evidence_gallery"
275
+
276
+
277
+ def _text_policy(enhance_style, has_text, strip_dense_caption=False):
278
+ """图生图文字策略:背景/主体图去字,信息密集图才保留原数据文字。
279
+ `strip_dense_caption=True`(2026-07-03,dense 且 content_type=screenshot_slide/poster 且有 embedded_text):
280
+ 虽是 dense,但去掉的是装饰性 chrome 标题/标签而非数据本身——复用 `remove_all_text`(与 bg/hero/cutout
281
+ 去字语义相同:"这段文字已被从图里抹掉",不新造枚举值)。"""
282
+ if enhance_style == "dense":
283
+ return "remove_all_text" if strip_dense_caption else "preserve_data_text"
284
+ if has_text:
285
+ return "remove_all_text"
286
+ return "avoid_new_text"
287
+
288
+
289
+ def _target_layer(usage_intent, enhance_style):
290
+ if usage_intent == "discard":
291
+ return "none"
292
+ if usage_intent == "source_enhanced_bg":
293
+ return "background"
294
+ if usage_intent == "source_enhanced_hero" or enhance_style in ("cutout", "hero"):
295
+ return "foreground"
296
+ return "evidence"
297
+
298
+
299
+ def _layout_constraint(usage_intent):
300
+ """usage_intent → 该图所在页的**版式硬约束**(设计步照读、不自行决定 render,治「有底图还套表格/卡墙划区」):
301
+ - fusion_no_cards:必走编辑式融合(图打底 / 半幅 hero + 文字浮前景 + scrim),**禁表格、禁悬浮卡片墙/网格划区**;
302
+ - image_text_mixed:图文混排(contain 图 + 邻区文字),可少量 HTML,但勿在图上堆卡墙;
303
+ - evidence_ok:低清证据图 / 画廊,证据卡 / 四宫格画廊布局 OK。"""
304
+ if usage_intent == "discard":
305
+ return "do_not_use"
306
+ if usage_intent in ("source_enhanced_bg", "source_enhanced_hero"):
307
+ return "fusion_no_cards"
308
+ if usage_intent in ("small_evidence", "gallery"):
309
+ return "evidence_ok"
310
+ # direct_contain
311
+ return "image_text_mixed"
312
+
313
+
314
+ # 「离散主体 / 产品特写」关键词 → 适合抠成**透明背景前景插图**(浮在编辑式背景上,原背景色不再与 PPT 冲突)。
315
+ DISCRETE_SUBJECT_KEYWORDS = (
316
+ "产品", "设备", "器材", "物体", "零件", "部件", "工件", "螺栓", "螺丝", "元件", "电路板",
317
+ "芯片", "镜头", "相机", "无人机", "机器人", "终端", "传感器", "模组", "样机", "样品",
318
+ "硬件", "工具", "按键", "特写",
319
+ "电脑", "显示器", "平板", "手机", "vr", "眼镜", "头显", "播放终端", "多终端", "终端设备",
320
+ "设备组合", "多设备", "笔记本", "屏幕设备",
321
+ # 轻量补(保守、皆为明确实体器件,不与 DENSE/SCENE 词重叠——避免误把截图/场景抠成透明):
322
+ "仪器", "仪表", "模块", "配件", "主板", "整机", "阀门", "电机", "马达", "电池", "摄像头",
323
+ "product", "device", "component", "part", "lens", "camera", "drone", "robot",
324
+ "sensor", "module", "hardware", "closeup", "close-up",
325
+ "instrument", "gauge", "motherboard", "battery", "motor", "valve", "gadget",
326
+ "computer", "monitor", "display", "tablet", "smartphone", "phone", "vr headset",
327
+ "headset", "goggles", "viewer", "terminal device", "multi-device",
328
+ )
329
+ # 「场景 / 全景」关键词 → 留作背景/场景,不抠透明(抠了会丢环境信息)。
330
+ SCENE_KEYWORDS = (
331
+ "全景", "现场", "车间", "厂房", "风光", "风景", "天际线", "城市", "星空", "场景",
332
+ "驾驶舱", "机房", "工厂", "户外", "环境", "鸟瞰", "航拍",
333
+ "panorama", "scene", "landscape", "cockpit", "factory", "skyline",
334
+ )
335
+
336
+
337
+ def _is_discrete_subject(vision):
338
+ """识图描述像「离散主体/产品特写」(适合抠透明前景插图浮背景)而非「场景/全景」(留作背景)。
339
+ 两者都命中时**场景优先**(保守:宁可不抠也别误抠掉场景环境)。"""
340
+ if not isinstance(vision, dict):
341
+ return False
342
+ hay = " ".join(
343
+ [str(vision.get("description") or "")]
344
+ + [str(x) for x in (vision.get("key_elements") or []) if isinstance(x, (str, int, float))]
345
+ ).lower()
346
+ if any(kw.lower() in hay for kw in SCENE_KEYWORDS):
347
+ return False
348
+ return any(kw.lower() in hay for kw in DISCRETE_SUBJECT_KEYWORDS)
349
+
350
+
351
+ # 「图上自带文字/水印/logo」关键词 → 该图若做背景【必须重绘成无文字版】(治用户诉求:图本身有文字就不能直接当背景)。
352
+ # 注:dense 信息图(截图/图表)本就含合法数据文字、走 dense 态保真,不在此列(has_text 只作用于 bg/hero 背景类图)。
353
+ EMBEDDED_TEXT_KEYWORDS = (
354
+ "文字", "文本", "字样", "标题", "字幕", "水印", "标语", "标牌", "标注", "文案",
355
+ "说明文字", "广告语", "商标", "字体", "印字", "题字", "横幅",
356
+ "slogan", "logo", "banner", "text", "words", "caption", "watermark", "title",
357
+ "label", "lettering", "typography", "subtitle", "wordmark",
358
+ )
359
+
360
+
361
+ def _embedded_text_list(vision):
362
+ """识图结构化 `embedded_text` 字段(2026-07-03 加):`[{"text","region"}, ...]`,图上没有烧进画面
363
+ 的文字则 `[]`。非法/非列表元素静默丢弃(容错,不因个别脏项让整张图的规划失败)。"""
364
+ if not isinstance(vision, dict):
365
+ return []
366
+ items = vision.get("embedded_text")
367
+ if not isinstance(items, list):
368
+ return []
369
+ out = []
370
+ for it in items:
371
+ if isinstance(it, dict) and str(it.get("text") or "").strip():
372
+ out.append({"text": str(it["text"]).strip(), "region": str(it.get("region") or "").strip()})
373
+ elif isinstance(it, str) and it.strip():
374
+ out.append({"text": it.strip(), "region": ""})
375
+ return out
376
+
377
+
378
+ def _has_embedded_text(vision):
379
+ """图上是否烧着文字(该图做背景/去字重绘必须抹掉)。**结构化 `embedded_text` 字段优先**(2026-07-03,
380
+ 逐字记录了文字内容,判定即数据、不是猜)——`vision` 里存在该键就以它为准(含空列表 = 识图明确判过没有)。
381
+ 该键**不存在**(旧格式识图 JSON,无此字段)才退回对 description/key_elements 自由文本的关键词启发式。
382
+ dense 信息图另走 dense 态保真、has_text 只调制 bg/hero 背景类图(dense 内的新分支见 _enhance_prompt)。"""
383
+ if not isinstance(vision, dict):
384
+ return False
385
+ if "embedded_text" in vision:
386
+ return bool(_embedded_text_list(vision))
387
+ hay = " ".join(
388
+ [str(vision.get("description") or "")]
389
+ + [str(x) for x in (vision.get("key_elements") or []) if isinstance(x, (str, int, float))]
390
+ ).lower()
391
+ return any(kw.lower() in hay for kw in EMBEDDED_TEXT_KEYWORDS)
392
+
393
+
394
+ def _quote_embedded_text(embedded_text, limit=6):
395
+ """把结构化 embedded_text 列表格式化成 prompt 里可引用的 "'文字'(位置), ..." 短语;空列表返回 ''。"""
396
+ parts = []
397
+ for it in (embedded_text or [])[:limit]:
398
+ text = it.get("text") if isinstance(it, dict) else None
399
+ if not text:
400
+ continue
401
+ region = it.get("region") if isinstance(it, dict) else ""
402
+ parts.append(f"'{text}'" + (f" ({region})" if region else ""))
403
+ return ", ".join(parts)
404
+
405
+
406
+ def _enhance_prompt(enhance_style, target_aspect, text_zone, bg_kind="unknown", has_text=False,
407
+ embedded_text=None, content_type=""):
408
+ """图生图提示词骨架(英文,只表达「增强变换」;主体/真色由 --input-image 参考图携带,故无需翻译中文识图)。
409
+ 四态(由 plan_one 算定 enhance_style,bg/hero/cutout 三态再受 bg_kind 调制):
410
+ · dense :信息密集图(截图/图表/结构)→ **忠实保留版面与每个数据点/标签/坐标轴/框幅不变**,只清晰化、
411
+ 去杂、统一 PPT 风格。⚠️ 数据图 img2img 有重绘/篡改风险,best-effort(用户明确要求照样重绘);
412
+ **不加 no-text 尾**(这类图本就含合法文字/数据标签,须保留)。
413
+ 例外(2026-07-03):`content_type ∈ screenshot_slide|poster` 且有 `embedded_text` 时,
414
+ 那些文字是"别人做好的整版截图/海报自带的标题/说明标签"这类**装饰性 chrome**——不是数据本身
415
+ (数据本身在 chart 类型里,坐标轴/图例数值仍走上面的纯保真分支),本页会有自己的标题、继续
416
+ 保留原图标题会一图两标题/不可编辑,故走下面的「保结构去字」变体:结构/子图/非文字视觉细节
417
+ 原样保留,但把识图逐字抄下来的文字明确列出要求去掉。
418
+ · cutout :离散主体特写 / **flat 底前景图**(主体+自有背景纹理)→ **抠成透明背景前景插图**
419
+ (连背景纹理一起去掉,主体浮在编辑式背景上,原背景不再与 PPT 两层底打架)。
420
+ · bg :背景图扩成干净全幅底图——real_scene/unknown 续景 outpaint 扩展;flat_base 换 VI 同色系干净底。
421
+ · hero :场景主视觉 → 保场景、清晰化、换干净底、扩到目标比例(不去背景)。
422
+ `has_text=True`(识图发现图上自带文字/水印/logo,仅调制 bg/hero 背景类图):放宽「严格保内容」、
423
+ **强制抹掉所有文字重绘干净**——治用户诉求「图本身有文字就不能直接当背景,当背景必须重绘成无文字的」。
424
+ `embedded_text` 非空时(2026-07-03):抹字指令**引用识图逐字抄下来的实际文字**(而非泛泛的"has text,
425
+ captions..."),指令越具体模型越容易照办;这段文字被抹掉后,规划条目仍原样保留 `embedded_text`,供
426
+ 设计阶段判断是否需要用真实 HTML 文字重新配出(write_plan_md 负责渲染,本函数只管生图提示词)。
427
+ 共同要点:保留原图主体/内容/真实配色不变;高清重绘到目标分辨率;统一加入 _QUALITY 质量/分辨率词。"""
428
+ quoted = _quote_embedded_text(embedded_text)
429
+ _PRESERVE = (
430
+ "Use the attached image as the strict visual reference: preserve its main subject, content and real colors "
431
+ "exactly — do not replace, restyle or invent a different subject. "
432
+ )
433
+ # 背景/hero 图自带文字时:换一段「保气质但允许重绘 + 强制抹字」前导,覆盖严格保内容(否则模型照搬原文字露字)。
434
+ bg_preserve = _PRESERVE
435
+ if has_text:
436
+ if quoted:
437
+ bg_preserve = (
438
+ "Use the attached image as a style and composition reference: keep its overall scene, subject and "
439
+ "real color palette, but redraw it. The original image has this exact baked-in text that must be "
440
+ f"completely REMOVED, producing a clean, fully text-free background: {quoted}. Never reproduce or "
441
+ "invent any text. "
442
+ )
443
+ else:
444
+ bg_preserve = (
445
+ "Use the attached image as a style and composition reference: keep its overall scene, subject and real "
446
+ "color palette, but redraw it. The original image has text, captions, logos or watermarks on it — "
447
+ "completely REMOVE all of that and produce a clean, fully text-free background; never reproduce or "
448
+ "invent any text. "
449
+ )
450
+ if enhance_style == "dense":
451
+ if has_text and quoted and content_type in ("screenshot_slide", "poster"):
452
+ # 装饰性 chrome 文字(整版截图/海报自带的标题/说明标签,非图表数据):保结构去字。
453
+ return (
454
+ _PRESERVE
455
+ + "This is an information-dense composite image (screenshot / comparison slide / poster): "
456
+ "faithfully keep the exact same layout, sub-images, comparison structure and every non-text visual "
457
+ "detail unchanged, and keep the original framing and aspect ratio (do not crop, stretch or "
458
+ "outpaint). However, the original image also has this exact baked-in caption/title text that must "
459
+ f"be completely REMOVED, leaving clean empty space in its place: {quoted}. Never reproduce or "
460
+ "invent any text. Only clean up noise and compression artifacts, sharpen blurry edges and "
461
+ "re-render it crisply in a unified clean professional PPT style. " + _QUALITY
462
+ )
463
+ # 信息密集图(默认,含 content_type=chart):忠实保真,只清晰化/去杂/统一风格(best-effort,有重绘
464
+ # 风险);不加 no-text 尾(须保留数据标签——图表坐标轴/图例数值是数据本身,不是装饰性 chrome)。
465
+ return (
466
+ _PRESERVE
467
+ + "This is an information-dense image (screenshot / chart / diagram / table): faithfully keep the exact "
468
+ "same layout, every data point, label, axis, value and structure unchanged, and keep the original "
469
+ "framing and aspect ratio (do not crop, stretch or outpaint). Only clean it up — remove noise and "
470
+ "compression artifacts, sharpen blurry edges and re-render it crisply in a unified clean professional "
471
+ "PPT style. " + _QUALITY
472
+ )
473
+ if enhance_style == "cutout":
474
+ # 离散主体特写 / flat 底前景图 → 透明背景前景插图(浮在编辑式背景之上,原背景纹理不再与 PPT 冲突/两层底打架)。
475
+ return (
476
+ _PRESERVE
477
+ + "Redraw it in high resolution, keep the subject complete, centered and sharp, "
478
+ "**remove the original background and any background texture entirely and output the isolated subject on "
479
+ "a fully transparent background (transparent alpha channel, PNG RGBA) — no backdrop, no scene, no "
480
+ "texture, only the subject**, "
481
+ "upscale and sharpen details, clean even product lighting, subject fills the frame. "
482
+ + _QUALITY + "crisp cut-out edges. " + _NO_TEXT_TAIL
483
+ )
484
+ if enhance_style == "bg":
485
+ if bg_kind == "flat_base":
486
+ # 纯色/白底/棚拍底 → 换成 VI 同色系干净背景,不保留原杂底。
487
+ return (
488
+ bg_preserve
489
+ + f"Redraw it in high resolution and place the subject on a clean, uncluttered full-bleed "
490
+ f"{target_aspect} background that exactly fills the slot: replace the plain or messy original base "
491
+ "with a professional VI-toned backdrop in the same color family as the subject (subtle gradient, "
492
+ "no clutter), upscale and sharpen details, "
493
+ f"leave a calm low-detail area on the {text_zone} side for overlay text. "
494
+ + _QUALITY + "cinematic depth. " + _NO_TEXT_TAIL
495
+ )
496
+ # real_scene / unknown → 续景 outpaint 扩展成干净全幅底图。
497
+ return (
498
+ bg_preserve
499
+ + f"Redraw it in high resolution and extend it into a clean, uncluttered full-bleed {target_aspect} background "
500
+ "that exactly fills the slot: outpaint and intelligently complete the missing edges by naturally continuing "
501
+ "the existing scene (do not stretch or distort), upscale and sharpen details, remove distracting clutter, "
502
+ f"soft even studio lighting, leave a calm low-detail area on the {text_zone} side for overlay text. "
503
+ + _QUALITY + "cinematic depth. " + _NO_TEXT_TAIL
504
+ )
505
+ # hero(场景型主视觉,换干净底)
506
+ return (
507
+ bg_preserve
508
+ + f"Redraw it in high resolution into a clean {target_aspect} hero shot that exactly fills the slot: "
509
+ "keep the subject centered and complete, outpaint and intelligently complete any missing area by naturally "
510
+ "extending the scene (do not stretch or distort), upscale and sharpen details, "
511
+ "replace the messy or low-quality background with a clean minimal backdrop, "
512
+ "professional product-photography lighting, subject fills the frame. " + _QUALITY + _NO_TEXT_TAIL
513
+ )
514
+
515
+
516
+ def plan_one(img):
517
+ """对单张客户图做确定性版式/增强规划,返回规划 dict。
518
+
519
+ ① 全图生图政策:**所有客户图一律 enhance_required=true(不再直接用原图)**——
520
+ 合格+背景图 → source_enhanced_bg;合格+非背景 → source_enhanced_hero;低清/坏比例维持现有 bg/hero;
521
+ dense(截图/图表)照样 img2img 但走忠实保真提示词。唯一保守例外 = **无 vision 盲态**(不知是否截图、
522
+ 无法选对增强提示词,维持 enhance=false)。display_role / layout_constraint 等 DISPLAY 语义不变。
523
+ ② 图生图按背景类型分流(_background_kind):real_scene→hero / flat_base+离散主体→cutout / flat_base 铺底→bg。"""
524
+ file = img.get("file")
525
+ w_val = _as_float(img.get("w"), 0.0)
526
+ h_val = _as_float(img.get("h"), 0.0)
527
+ ar = _aspect_of(img)
528
+ orient = _orient_of(img, ar)
529
+ vision = img.get("vision") if isinstance(img.get("vision"), dict) else None
530
+ stats = img.get("stats") if isinstance(img.get("stats"), dict) else None
531
+
532
+ low_res = bool(su._is_low_res_for_background(img))
533
+ bad_aspect = bool(ar and (ar < BAD_ASPECT_MIN or ar > BAD_ASPECT_MAX))
534
+ quality = "low_res" if low_res else ("bad_aspect" if bad_aspect else "ok")
535
+ is_dense = _is_dense_info(vision)
536
+ bg_suitable = bool(vision and vision.get("suitable_as_background") is True)
537
+ has_vision = vision is not None
538
+ bg_kind = _background_kind(vision) # ② 背景类型分流(real_scene / flat_base / unknown)
539
+ has_text = _has_embedded_text(vision) # 图上自带文字/水印 → bg/hero 背景类图必须重绘成无文字版(治「带字图当背景露字」)
540
+ embedded_text = _embedded_text_list(vision) # 识图逐字抄下来的图上文字(2026-07-03,供去字提示词引用 + 设计阶段重配)
541
+ # 识图结构化字段(2026-07-02 加,识图 prompt 同步产出;旧 vision JSON 缺字段 → None/空串,走既有分支):
542
+ # relevant_to_topic=false → 弃用(坠机资讯图与「制造+影像档案」主题负相关,识图早写了「强烈建议不采用」
543
+ # 但只活在散文 notes 里,规划器消费不到 → 被 source_usage_check「全部用上」硬塞进 deck 的事故根因)。
544
+ # content_type=screenshot_slide → 整版 PPT 截图:强制按信息密集图走 direct_contain(contain 框图展示、
545
+ # 忠实保真、绝不铺背景),不管 _is_dense_info 的关键词启发有没有命中。
546
+ relevant = vision.get("relevant_to_topic") if has_vision else None
547
+ content_type = str(vision.get("content_type") or "").strip().lower() if has_vision else ""
548
+ if content_type == "screenshot_slide":
549
+ is_dense = True
550
+
551
+ # ① 默认全图生图重绘;唯一保守例外是无 vision 盲态(下面分支置 false)。
552
+ enhance_required = True
553
+ target_aspect = None
554
+
555
+ if has_vision and relevant is False:
556
+ # 弃用:与主题不相关/负面暗示的图不进 deck(豁免「全部用上」,见 source_usage_check)。
557
+ usage_intent = "discard"
558
+ enhance_required = False
559
+ elif is_dense:
560
+ # 信息密集图(截图/图表/结构):照样 img2img,但忠实保真(保版面/数据/框幅);不背景化、保持原始比例。
561
+ usage_intent = "direct_contain"
562
+ target_aspect = f"{int(w_val)}:{int(h_val)}" if (w_val > 0 and h_val > 0) else None
563
+ elif not has_vision:
564
+ # 无识图(无多模态模型):唯一保守例外——不冒险图生图(可能是截图、无法选对提示词),低清做证据小图,否则 contain。
565
+ usage_intent = "small_evidence" if (low_res or bad_aspect) else "direct_contain"
566
+ enhance_required = False
567
+ elif bg_suitable and not _is_discrete_subject(vision):
568
+ # 合格/低清/坏比例的背景图都图生图重绘成高清干净全幅背景。
569
+ # 【离散主体(产品/设备)即便被识图标 suitable_as_background 也排除在外 → 落下面 else 分支】。
570
+ usage_intent = "source_enhanced_bg"
571
+ target_aspect = "16:9"
572
+ else:
573
+ # 合格/低清/坏比例的非背景图都图生图重绘成高清主视觉。
574
+ # 含【被识图误标 bg 的离散主体/产品】:用户红线——主体+自有底纹的图绝不当整页背景(放我们背景/渐变上=
575
+ # 两层底打架、走位背景也不行),强制走前景主视觉、下面 enhance_style 再把它抠成透明前景插图。
576
+ usage_intent = "source_enhanced_hero"
577
+ target_aspect = _hero_target_aspect(orient, ar)
578
+
579
+ is_background = usage_intent == "source_enhanced_bg"
580
+ text_zone = _text_zone(vision, orient)
581
+ text_color = _text_color(stats, is_background)
582
+ display_role = _display_role(usage_intent)
583
+ # ② 增强样式(识图驱动):dense=忠实保真 / bg=干净全幅底图 / hero=保场景换干净底 / cutout=离散主体抠透明前景插图。
584
+ if not enhance_required:
585
+ enhance_style = None
586
+ elif is_dense:
587
+ enhance_style = "dense"
588
+ elif usage_intent == "source_enhanced_bg":
589
+ # 背景图恒 bg(real_scene 续景 / flat_base 换 VI 底,由 _enhance_prompt 按 bg_kind 区分文案)。
590
+ enhance_style = "bg"
591
+ else: # source_enhanced_hero(前景主视觉):按背景类型决定保场景 vs 抠透明。
592
+ if bg_kind == "real_scene":
593
+ enhance_style = "hero" # 真实场景:保场景、清晰化、换干净底(不抠,否则丢环境信息)。
594
+ elif bg_kind == "flat_base" or _is_discrete_subject(vision):
595
+ # flat 底(纯色/渐变/纹理/棚拍)的前景图,或离散主体 → 抠透明前景插图:主体浮我们页面背景上、
596
+ # 不带自己那块底(治用户:带「前景主体+自有背景纹理」的图直接放我们背景/渐变上=两层底打架、当背景也不行)。
597
+ enhance_style = "cutout"
598
+ else: # unknown 且非离散主体 → 当场景型处理、换干净底。
599
+ enhance_style = "hero"
600
+
601
+ # dense 且是"整版截图/海报"自带装饰性 chrome 文字(非图表数据)→ 去字保结构(2026-07-03)。
602
+ strip_dense_caption = (
603
+ enhance_style == "dense" and has_text and bool(embedded_text)
604
+ and content_type in ("screenshot_slide", "poster")
605
+ )
606
+
607
+ entry = {
608
+ "file": file,
609
+ "w": img.get("w"),
610
+ "h": img.get("h"),
611
+ "aspect_ratio": round(ar, 3) if ar else None,
612
+ "orient": orient,
613
+ "quality": quality,
614
+ "is_dense_info": is_dense,
615
+ "suitable_as_background": bg_suitable,
616
+ "has_vision": has_vision,
617
+ # ② 背景类型(识图驱动图生图分流):real_scene / flat_base / unknown。
618
+ "background_kind": bg_kind,
619
+ # 图上是否自带文字/水印(True → bg/hero 重绘强制抹字,治「带字图当背景露字」)。
620
+ "has_embedded_text": has_text,
621
+ "usage_intent": usage_intent,
622
+ # 版式硬约束(设计步照读、不自行决定 render):有底图的页禁表格/卡墙、走编辑式融合。
623
+ "layout_constraint": _layout_constraint(usage_intent),
624
+ # 显示档位 + 尺寸下限 + 缩放上限(治「客户图缩成角落小图看不清 / 低清原图被放大糊」):
625
+ "display_role": display_role,
626
+ "min_canvas_fraction": MIN_CANVAS_FRACTION[display_role],
627
+ "max_upscale": MAX_UPSCALE,
628
+ "enhance_required": enhance_required,
629
+ "enhance_style": enhance_style,
630
+ "image_role": _image_role(usage_intent, enhance_style),
631
+ "text_policy": _text_policy(enhance_style, has_text, strip_dense_caption),
632
+ "target_layer": _target_layer(usage_intent, enhance_style),
633
+ "enhance_target_aspect": target_aspect,
634
+ "recommended_templates": _templates_for(usage_intent, orient, is_dense),
635
+ "text_zone": text_zone,
636
+ "text_color": text_color,
637
+ }
638
+ # ③ 章节锚定(治张冠李戴):来源章节/邻近文案/出处从 material 原样透传——此前 entry 把
639
+ # section 丢弃(仅 gallery 图以 gallery_group 幸存),设计步只能按识图语义配页,客户图被
640
+ # 配到无关主题页。透传后设计步以 section 为配页第一锚点,source_usage_check 据此校验错位。
641
+ sec = (img.get("section") or "").strip()
642
+ if sec:
643
+ entry["section"] = sec
644
+ near = (img.get("near_text") or "").strip()
645
+ if near:
646
+ entry["near_text"] = near[:120]
647
+ if img.get("origin"):
648
+ entry["origin"] = img.get("origin")
649
+ if isinstance(vision, dict) and vision.get("description"):
650
+ entry["vision_description"] = vision["description"]
651
+ if isinstance(vision, dict) and vision.get("key_message"):
652
+ entry["key_message"] = vision["key_message"]
653
+ if embedded_text:
654
+ # 逐字记录的图上原文(2026-07-03):图生图会把这些字抹掉,非空才挂——供 write_plan_md 渲染
655
+ # 「去字后待重配」提示,设计阶段据此判断是否需要用真实 HTML 文字重新配出。
656
+ entry["embedded_text"] = embedded_text
657
+ if enhance_required and file:
658
+ prompt = _enhance_prompt(
659
+ enhance_style, target_aspect, text_zone, bg_kind, has_text,
660
+ embedded_text=embedded_text, content_type=content_type,
661
+ )
662
+ entry["enhanced_file"] = f"enhanced_{file}"
663
+ entry["enhance_prompt"] = prompt
664
+ return entry
665
+
666
+
667
+ # 可组画廊的(非背景)用途:前景主视觉 / contain 图 / 证据小图。背景图(source_enhanced_bg)绝不进四宫格。
668
+ # 注:全图生图政策下这些图多已 enhance_required=true;分组只改 DISPLAY 字段,**保留各图既有增强决策**
669
+ # (enhance_required / enhance_style / enhanced_file / enhance_prompt 原样带过——画廊每格照样图生图增强到高清)。
670
+ _GALLERY_OK_INTENTS = ("source_enhanced_hero", "direct_contain", "small_evidence")
671
+
672
+
673
+ def _apply_gallery(entries):
674
+ """同章节图片数 ≥ 阈值且都是非背景用途 → 标 gallery_group,建议四宫格画廊。"""
675
+ by_section = {}
676
+ for e, raw in entries:
677
+ sec = (raw.get("section") or "").strip()
678
+ if sec:
679
+ by_section.setdefault(sec, []).append(e)
680
+ for sec, group in by_section.items():
681
+ if len(group) < GALLERY_MIN_PER_SECTION:
682
+ continue
683
+ if all(e["usage_intent"] in _GALLERY_OK_INTENTS for e in group):
684
+ for e in group:
685
+ e["gallery_group"] = sec
686
+ e["usage_intent"] = "gallery"
687
+ e["layout_constraint"] = "evidence_ok"
688
+ e["display_role"] = "gallery"
689
+ e["image_role"] = _image_role("gallery", e.get("enhance_style"))
690
+ e["target_layer"] = _target_layer("gallery", e.get("enhance_style"))
691
+ e["min_canvas_fraction"] = MIN_CANVAS_FRACTION["gallery"]
692
+ e["recommended_templates"] = ["四宫格画廊"]
693
+
694
+
695
+ def build_plan(build_dir):
696
+ build_dir = os.path.abspath(build_dir)
697
+ material = _load_json(os.path.join(build_dir, "material.json"), {}) or {}
698
+ base_images = [im for im in (material.get("images") or []) if isinstance(im, dict) and im.get("file")]
699
+
700
+ paired = [(plan_one(im), im) for im in base_images]
701
+ _apply_gallery(paired)
702
+ images = [e for e, _ in paired]
703
+ enhance_entries = []
704
+ for e in images:
705
+ if e.get("enhance_required") and e.get("file"):
706
+ enhance_entries.append(
707
+ {
708
+ "file": e["enhanced_file"],
709
+ "mode": "image-to-image",
710
+ "source_file": e["file"],
711
+ "aspect": e["enhance_target_aspect"],
712
+ "prompt": e["enhance_prompt"],
713
+ "image_role": e.get("image_role"),
714
+ "text_policy": e.get("text_policy"),
715
+ "target_layer": e.get("target_layer"),
716
+ "enhance_style": e.get("enhance_style"),
717
+ }
718
+ )
719
+
720
+ return {
721
+ "has_source": bool(material.get("has_source")),
722
+ "image_count": len(images),
723
+ "enhance_count": len(enhance_entries),
724
+ "images": images,
725
+ "enhance_entries": enhance_entries,
726
+ }
727
+
728
+
729
+ def _load_json(path, default=None):
730
+ try:
731
+ with open(path, encoding="utf-8-sig") as f:
732
+ return json.load(f)
733
+ except Exception:
734
+ return default
735
+
736
+
737
+ _USAGE_CN = {
738
+ "source_enhanced_bg": "整页背景【需图生图扩到 16:9 干净底图】",
739
+ "source_enhanced_hero": "主视觉 hero【需图生图修比例/提清晰/换干净背景】",
740
+ "direct_contain": "原图 contain 展示(不裁不变形,勿放大勿背景化)",
741
+ "small_evidence": "证据小图 / 侧图(低清,原图小用即可)",
742
+ "gallery": "多图画廊(四宫格 / 横向带)",
743
+ }
744
+
745
+ _ROLE_CN = {
746
+ "background": "整页背景大图",
747
+ "foreground_hero": "前景主视觉 / 半幅大图(≈半背景,至少占画布 30%)",
748
+ "evidence_card": "证据图(必须可读、绝非角落缩略图,≥12% 画布)",
749
+ "gallery": "画廊每格(≥10% 画布)",
750
+ }
751
+
752
+ _CONSTRAINT_CN = {
753
+ "fusion_no_cards": "编辑式融合(图打底/半幅 hero + 文字浮前景 + scrim)—— 🚫该页禁表格、禁悬浮卡片墙/网格划区",
754
+ "image_text_mixed": "图文混排(contain 图 + 邻区文字),可少量 HTML,勿在图上堆卡墙",
755
+ "evidence_ok": "低清证据图 / 画廊,证据卡 / 四宫格画廊布局 OK",
756
+ }
757
+
758
+
759
+ def write_plan_md(build_dir, plan):
760
+ L = ["# 客户图版式 + 图生图增强规划(确定性产出,设计步据此选模板、勿再逐图心算)\n"]
761
+ L.append(
762
+ "> 来源 material.json。每张图的 usage_intent / **显示档位 + 槽位最小占比 + 缩放上限** / 是否图生图 / 目标比例 / 模板 / "
763
+ "文字落区与深浅均已算定;图生图提示词见 source_layout_plan.json 的 enhance_prompt。\n"
764
+ "> **尺寸铁律(治『客户图缩太小看不清 / 低清图放大糊』)**:每张图按 `display_role` 给够大的槽(至少占 `min_canvas_fraction` 画布、"
765
+ "绝不缩成角落小图/底部小条);原图**缩放 ≤1×**(绝不放大超原生像素糊掉),要显示得比原生大就走图生图重绘到目标分辨率。\n"
766
+ "> **enhance_required 的图**:设计步必须写成 `route=source-enhanced`、引用 `enhanced_<file>`;生图前引擎确定性把这些条目并进 img_plan.json。\n"
767
+ )
768
+ if not plan["images"]:
769
+ L.append("\n(无客户图 —— 纯 topic / 纯文档无图,跳过本规划。)\n")
770
+ else:
771
+ L.append(
772
+ f"\n共 {plan['image_count']} 张客户图,其中 {plan['enhance_count']} 张**必须图生图增强**。\n"
773
+ )
774
+ for i, e in enumerate(plan["images"], 1):
775
+ L.append(f"\n## 图 {i}:{e['file']}")
776
+ wh = f"{e.get('w', '?')}×{e.get('h', '?')}"
777
+ L.append(
778
+ f"- 尺寸/比例/朝向:{wh},比例 {e.get('aspect_ratio', '?')},{e['orient']};"
779
+ f"质量 **{e['quality']}**" + ("(信息密集图,保真勿增强)" if e["is_dense_info"] else "")
780
+ )
781
+ L.append(f"- 用途:**{e['usage_intent']}** — {_USAGE_CN.get(e['usage_intent'], '')}")
782
+ L.append(
783
+ f"- 显示档位:**{e.get('display_role', '?')}** — {_ROLE_CN.get(e.get('display_role'), '')};"
784
+ f"**缩放上限 ≤{e.get('max_upscale', MAX_UPSCALE)}×**(原图绝不放大糊;要更大走图生图);"
785
+ f"槽位**至少占画布 {int(e.get('min_canvas_fraction', 0) * 100)}%**(绝不缩成角落小图/底部小条)"
786
+ )
787
+ L.append(
788
+ f"- 版式约束:**{e.get('layout_constraint', '?')}** — {_CONSTRAINT_CN.get(e.get('layout_constraint'), '')}"
789
+ )
790
+ L.append(
791
+ f"- 图层语义:image_role=`{e.get('image_role')}`,target_layer=`{e.get('target_layer')}`,"
792
+ f"text_policy=`{e.get('text_policy')}`(背景/主体图与带装饰性说明字的截图去字;真图表数据标签才保留)"
793
+ )
794
+ if e["enhance_required"]:
795
+ _style_cn = {
796
+ "bg": "背景图·扩成干净全幅底图",
797
+ "hero": "场景主视觉·换干净底",
798
+ "cutout": "离散主体特写·**抠透明背景前景插图**(作前景特写浮在编辑式背景上,原背景色不再与 PPT 冲突)",
799
+ "dense": "信息密集图·**忠实保留版面与数据结构**清晰化去杂(best-effort,有重绘/篡改风险)",
800
+ }.get(e.get("enhance_style"), "")
801
+ L.append(
802
+ f"- 🚨 图生图:是({_style_cn})→ 生成 `{e['enhanced_file']}`(目标比例 {e['enhance_target_aspect']}),"
803
+ "deck 引用增强图、保留 `data-source-file` 映射;提示词见 json。"
804
+ )
805
+ else:
806
+ L.append("- 图生图:否(原图直接用)")
807
+ L.append(f"- 推荐模板(对齐 layout-composition §2.2):{ ' / '.join(e['recommended_templates']) }")
808
+ L.append(
809
+ f"- 文字落区:{e['text_zone']}(避让重点区);文字深浅:"
810
+ + ("深墨字(亮底)" if e["text_color"] == "dark" else "白字(暗底/背景,配 scrim)")
811
+ )
812
+ if e.get("section"):
813
+ L.append(
814
+ f"- 来源章节:**{e['section']}**(🚨 配页锚点:必须配到覆盖本章节内容的页,"
815
+ "识图语义只用于章节内挑页;仅该章节无对应页时才按语义另配)"
816
+ )
817
+ if e.get("near_text"):
818
+ L.append(f"- 原文邻近文案:{e['near_text']}")
819
+ if e.get("key_message"):
820
+ L.append(f"- 🚨 图表核心结论(该页 key_point/evidence 须呼应):{e['key_message']}")
821
+ if e.get("vision_description"):
822
+ L.append(f"- 识图内容(文案须呼应):{e['vision_description']}")
823
+ et = e.get("embedded_text")
824
+ if et:
825
+ quoted = "、".join(f"「{it['text']}」" for it in et if it.get("text"))
826
+ L.append(
827
+ "- 🚨 原图文字已在图生图阶段去除,如信息有价值需在本页用真实 HTML 文字重新配出"
828
+ f"(不必逐字照抄,按论点整合;纯水印/logo 可直接丢弃):{quoted}"
829
+ )
830
+ with open(os.path.join(os.path.abspath(build_dir), "source_layout_plan.md"), "w", encoding="utf-8") as f:
831
+ f.write("\n".join(L).strip() + "\n")
832
+
833
+
834
+ def ensure_img_plan(build_dir):
835
+ """把规划里的图生图条目 union 进 img_plan.json(按 file 去重)——保证『必须图生图』的图一定会被生成。"""
836
+ build_dir = os.path.abspath(build_dir)
837
+ plan = _load_json(os.path.join(build_dir, "source_layout_plan.json"))
838
+ if not isinstance(plan, dict):
839
+ print("ENSURE_IMG_PLAN_SKIP: 无 source_layout_plan.json,跳过。")
840
+ return 0
841
+ entries = plan.get("enhance_entries") or []
842
+ if not entries:
843
+ print("ENSURE_IMG_PLAN_OK: 无需图生图增强的客户图(注入 0 条)。")
844
+ return 0
845
+
846
+ img_plan_path = os.path.join(build_dir, "img_plan.json")
847
+ existing = _load_json(img_plan_path)
848
+ if not isinstance(existing, list):
849
+ existing = []
850
+ have = {e.get("file") for e in existing if isinstance(e, dict)}
851
+ injected = 0
852
+ for ent in entries:
853
+ if ent.get("file") and ent["file"] not in have:
854
+ existing.append(ent)
855
+ have.add(ent["file"])
856
+ injected += 1
857
+ with open(img_plan_path, "w", encoding="utf-8") as f:
858
+ json.dump(existing, f, ensure_ascii=False, indent=2)
859
+ print(
860
+ f"ENSURE_IMG_PLAN_OK: 注入 {injected} 条图生图条目(规划共 {len(entries)} 条,"
861
+ f"img_plan.json 现 {len(existing)} 条)→ {img_plan_path}"
862
+ )
863
+ return 0
864
+
865
+
866
+ def reconcile_source_extensions(build_dir, design_path=None):
867
+ """把 design.json / img_plan.json 里客户图引用的扩展名,对齐 source_assets.json 的真实文件名。
868
+
869
+ 根因(2026-07-05 真机 src_9 事故):design 阶段的 LLM 在 design.json 的 `visual.source_file`/
870
+ `enhanced_file` 与 best-effort `img_plan.json` 条目里,凭常见约定猜扩展名(如 `.jpg`),但客户图
871
+ 实际落盘扩展名由 stage_extract 决定、可能是任意格式(真机是 `.png`)——猜错时 image-to-image
872
+ 生图找不到 source_file,直接 degraded=gradient-png 兜底占位;由于占位文件仍然「存在」(不是
873
+ 404),deck.html 的 `<img onerror>` 永远不会触发,页面静默用一张低质量渐变图顶替真实产品图,
874
+ 且同一张源图在 `source_layout_plan.json` 里已有的正确条目(正确扩展名、且已成功生成)被晾在
875
+ 一边——生成了却没被任何页引用,白白浪费一次生图。
876
+
877
+ 修法:按 stem(去扩展名)比对 `source_assets.json` 的真实文件列表,把两处扩展名不匹配的
878
+ `source_file`/`file`/`enhanced_file` 就地纠正为真实扩展名;纠正后 `img_plan.json` 若出现同
879
+ `file` 的重复条目(原猜测条目纠正后与已有正确条目撞车),去重保留先出现的一条。"""
880
+ build_dir = os.path.abspath(build_dir)
881
+ assets = _load_json(os.path.join(build_dir, "source_assets.json"))
882
+ images = assets.get("images") if isinstance(assets, dict) else None
883
+ if not isinstance(images, list) or not images:
884
+ print("RECONCILE_EXT_SKIP: 无 source_assets.json / 无客户图,跳过。")
885
+ return 0
886
+
887
+ stem_to_real = {
888
+ su._stem(im["file"]): im["file"]
889
+ for im in images
890
+ if isinstance(im, dict) and im.get("file")
891
+ }
892
+
893
+ def _fix_name(name):
894
+ if not isinstance(name, str) or not name:
895
+ return name, False
896
+ prefix = "enhanced_" if name.startswith("enhanced_") else ""
897
+ base = name[len(prefix):]
898
+ real = stem_to_real.get(su._stem(base))
899
+ if not real or real == base:
900
+ return name, False
901
+ return prefix + real, True
902
+
903
+ fixed_count = 0
904
+
905
+ design_path = design_path or os.path.join(build_dir, "design.json")
906
+ design = _load_json(design_path)
907
+ design_changed = False
908
+ if isinstance(design, dict) and isinstance(design.get("pages"), list):
909
+ for p in design["pages"]:
910
+ visual = p.get("visual") if isinstance(p, dict) else None
911
+ if not isinstance(visual, dict):
912
+ continue
913
+ for key in ("source_file", "enhanced_file"):
914
+ new_val, changed = _fix_name(visual.get(key))
915
+ if changed:
916
+ visual[key] = new_val
917
+ design_changed = True
918
+ fixed_count += 1
919
+ if design_changed:
920
+ with open(design_path, "w", encoding="utf-8") as f:
921
+ json.dump(design, f, ensure_ascii=False, indent=2)
922
+
923
+ img_plan_path = os.path.join(build_dir, "img_plan.json")
924
+ plan = _load_json(img_plan_path)
925
+ plan_changed = False
926
+ deduped = 0
927
+ if isinstance(plan, list) and plan:
928
+ seen_files = set()
929
+ result = []
930
+ for e in plan:
931
+ if not isinstance(e, dict):
932
+ result.append(e)
933
+ continue
934
+ for key in ("file", "source_file", "enhanced_file"):
935
+ new_val, changed = _fix_name(e.get(key))
936
+ if changed:
937
+ e[key] = new_val
938
+ plan_changed = True
939
+ fixed_count += 1
940
+ fkey = e.get("file")
941
+ if fkey and fkey in seen_files:
942
+ deduped += 1
943
+ plan_changed = True
944
+ continue # 纠正扩展名后与既有条目撞车 → 丢弃这条重复的,保留先出现者
945
+ if fkey:
946
+ seen_files.add(fkey)
947
+ result.append(e)
948
+ if plan_changed:
949
+ with open(img_plan_path, "w", encoding="utf-8") as f:
950
+ json.dump(result, f, ensure_ascii=False, indent=2)
951
+
952
+ if fixed_count == 0 and deduped == 0:
953
+ print("RECONCILE_EXT_OK: 未发现扩展名不匹配,无需修正。")
954
+ else:
955
+ print(
956
+ f"RECONCILE_EXT_OK: 修正 {fixed_count} 处扩展名不匹配"
957
+ + (f",去重 {deduped} 条重复条目" if deduped else "")
958
+ + "(design.json/img_plan.json 已对齐 source_assets.json 真实文件名)。"
959
+ )
960
+ return 0
961
+
962
+
963
+ def ensure_ai_img_plan(build_dir, design_path=None):
964
+ """把 design.json 里 route∈{ai-A,ai-B} 页的文生图条目确定性 union 进 img_plan.json。
965
+
966
+ 约定:
967
+ - `side=full` 的 ai-* 是整页背景,文件名 `ai_p<page>.png`;
968
+ - 非 full 的 ai-* 是前景插图/特写,文件名 `ai_p<page>_fg.png`;
969
+ 背景层仍由 ensure_page_backgrounds 用 `ai_p<page>.png` 补齐。
970
+
971
+ 这样防止同一张 `ai_pN.png` 既当背景又当前景,造成第 7 页那类重复贴图。"""
972
+ build_dir = os.path.abspath(build_dir)
973
+ if not design_path:
974
+ design_path = os.path.join(build_dir, "design.json")
975
+ design = _load_json(design_path)
976
+ pages = design.get("pages") if isinstance(design, dict) else None
977
+ if not isinstance(pages, list):
978
+ print("ENSURE_AI_IMG_PLAN_SKIP: 无 design.json / pages,跳过。")
979
+ return 0
980
+
981
+ img_plan_path = os.path.join(build_dir, "img_plan.json")
982
+ existing = _load_json(img_plan_path)
983
+ if not isinstance(existing, list):
984
+ existing = []
985
+ have = {e.get("file") for e in existing if isinstance(e, dict)}
986
+
987
+ injected = 0
988
+ for p in pages:
989
+ if not isinstance(p, dict):
990
+ continue
991
+ visual = p.get("visual")
992
+ if not isinstance(visual, dict):
993
+ continue
994
+ if str(visual.get("route", "")).lower() not in ("ai-a", "ai-b"):
995
+ continue
996
+ prompt = visual.get("prompt")
997
+ if not isinstance(prompt, str) or not prompt.strip():
998
+ continue # 无提示词没法生图,留给 .ai-fallback 渐变兜底
999
+ side = str(visual.get("side") or "").lower()
1000
+ is_foreground_ai = side and side not in ("full", "none")
1001
+ ai_file = f"ai_p{p.get('page')}_fg.png" if is_foreground_ai else f"ai_p{p.get('page')}.png"
1002
+ if ai_file in have:
1003
+ continue
1004
+ existing.append(
1005
+ {
1006
+ "file": ai_file,
1007
+ "mode": "text-to-image",
1008
+ "aspect": visual.get("aspect") or "16:9",
1009
+ "prompt": prompt,
1010
+ "target_layer": "foreground" if is_foreground_ai else "background",
1011
+ }
1012
+ )
1013
+ have.add(ai_file)
1014
+ injected += 1
1015
+
1016
+ with open(img_plan_path, "w", encoding="utf-8") as f:
1017
+ json.dump(existing, f, ensure_ascii=False, indent=2)
1018
+ print(
1019
+ f"ENSURE_AI_IMG_PLAN_OK: 注入 {injected} 条文生图 AI 背景条目"
1020
+ f"(img_plan.json 现 {len(existing)} 条)→ {img_plan_path}"
1021
+ )
1022
+ return 0
1023
+
1024
+
1025
+ # 确定性氛围底图的逐页轻变体(按页号取模,给相邻补背景的页一点光位差异、不靠 LLM、不随机)。
1026
+ _ATMOS_VARIANTS = (
1027
+ "soft radial glow in the upper left",
1028
+ "gentle diagonal light sweeping from the lower right",
1029
+ "calm horizontal gradient band across the frame",
1030
+ "subtle centered vignette with quiet depth",
1031
+ )
1032
+
1033
+
1034
+ def _vi_tone_hint(build_dir, design):
1035
+ """尽力取一个【纯 ASCII 的 VI 色调词】喂确定性氛围背景提示词(不调 LLM、不翻译中文避免乱码);
1036
+ 取不到返回 ''(走通用氛围——背景照样生成,只是不带具体色调)。
1037
+ 探序:design.json 顶层 vi 类字段 → build/{chosen_vi,vi}.txt 首行。含 CJK/杂字符则放弃。"""
1038
+ palette_map = {
1039
+ "aegis-vector": "deep industrial blue and clean white palette, cool chrome lines, aviation-grade precision, no shield symbol",
1040
+ "aegis vector": "deep industrial blue and clean white palette, cool chrome lines, aviation-grade precision, no shield symbol",
1041
+ "aqua-glass": "light blue and white glass palette, crisp black-blue typography, clean luminous surfaces",
1042
+ "aqua glass": "light blue and white glass palette, crisp black-blue typography, clean luminous surfaces",
1043
+ "comfort-vision": "warm off-white paper, deep ink green, restrained rust-orange signal accents",
1044
+ "comfort vision": "warm off-white paper, deep ink green, restrained rust-orange signal accents",
1045
+ "obsidian-optics": "near-black graphite, cold white highlights, restrained signal red, precision camera hardware mood",
1046
+ "obsidian optics": "near-black graphite, cold white highlights, restrained signal red, precision camera hardware mood",
1047
+ "precision-horizon": "minimal gray-white product launch palette, black typography, refined hardware photography mood",
1048
+ "precision horizon": "minimal gray-white product launch palette, black typography, refined hardware photography mood",
1049
+ "precision-horizon-dark": "graphite black product theater, warm orange material highlights, premium hardware mood",
1050
+ "precision horizon dark": "graphite black product theater, warm orange material highlights, premium hardware mood",
1051
+ "nebula-console": "deep space blue-black palette, electric blue and cyan edge light, commercial technology mood",
1052
+ "nebula console": "deep space blue-black palette, electric blue and cyan edge light, commercial technology mood",
1053
+ "violet-meridian": "white base with violet and cobalt-blue light paths, clear AI workspace mood",
1054
+ "violet meridian": "white base with violet and cobalt-blue light paths, clear AI workspace mood",
1055
+ "ember-prism": "white base with strong red and warm orange accents, energetic AI product launch mood",
1056
+ "ember prism": "white base with strong red and warm orange accents, energetic AI product launch mood",
1057
+ }
1058
+ cand = None
1059
+ if isinstance(design, dict):
1060
+ for k in ("vi", "vi_name", "visual_identity", "theme", "vi_template"):
1061
+ v = design.get(k)
1062
+ if isinstance(v, str) and v.strip():
1063
+ cand = v
1064
+ break
1065
+ if cand is None:
1066
+ for name in ("chosen_vi.txt", "vi.txt"):
1067
+ path = os.path.join(build_dir, name)
1068
+ try:
1069
+ if os.path.isfile(path):
1070
+ with open(path, encoding="utf-8") as f:
1071
+ txt = f.read().strip()
1072
+ if txt:
1073
+ cand = txt.splitlines()[0]
1074
+ break
1075
+ except Exception: # noqa: BLE001 色调词只是锦上添花,读不到不影响背景生成
1076
+ pass
1077
+ if not isinstance(cand, str):
1078
+ return ""
1079
+ s = cand.strip().lower()
1080
+ mapped = palette_map.get(s.replace("_", "-")) or palette_map.get(" ".join(s.replace("-", " ").replace("_", " ").split()))
1081
+ if mapped:
1082
+ return mapped
1083
+ for pre in ("vi-", "vi_", "vi "):
1084
+ if s.startswith(pre):
1085
+ s = s[len(pre):]
1086
+ s = " ".join(s.replace("-", " ").replace("_", " ").split())
1087
+ # 仅「纯 ASCII 字母+空格」的核心名才用(如 "aqua glass");含 CJK/数字/杂符则放弃走通用氛围。
1088
+ if s and all((c.isascii() and (c.isalpha() or c.isspace())) for c in s):
1089
+ return f"in a {s} color palette"
1090
+ return ""
1091
+
1092
+
1093
+ def _sanitize_image_prompt(prompt):
1094
+ if not prompt:
1095
+ return prompt
1096
+ replacements = {
1097
+ "abstract blueprint": "low-interference cinematic technical background",
1098
+ "blueprint background": "clean low-interference technical background",
1099
+ "crosshair": "subtle optical alignment lines",
1100
+ "target reticle": "subtle optical alignment lines",
1101
+ "shield": "precision hardware silhouette",
1102
+ "aegis": "industrial precision",
1103
+ "18 thumbnails": "a few clean visual evidence moments",
1104
+ "thumbnail grid": "editorial image strip",
1105
+ "many thumbnails": "selected large visual moments",
1106
+ "UI card": "minimal parameter overlay",
1107
+ "ui card": "minimal parameter overlay",
1108
+ "floating cards": "small restrained parameter overlays",
1109
+ "dashboard cards": "small restrained parameter overlays",
1110
+ "matrix background": "cinematic depth background",
1111
+ }
1112
+ out = prompt
1113
+ for src, dst in replacements.items():
1114
+ out = re.sub(re.escape(src), dst, out, flags=re.I)
1115
+ return out
1116
+
1117
+
1118
+ def _page_text(value):
1119
+ if value is None:
1120
+ return ""
1121
+ if isinstance(value, str):
1122
+ return value.strip()
1123
+ if isinstance(value, (int, float, bool)):
1124
+ return str(value)
1125
+ if isinstance(value, list):
1126
+ return " ".join(_page_text(v) for v in value if v is not None).strip()
1127
+ if isinstance(value, dict):
1128
+ return " ".join(_page_text(v) for v in value.values() if v is not None).strip()
1129
+ return str(value).strip()
1130
+
1131
+
1132
+ I2I_PROMPT_MARKERS = (
1133
+ "use the attached image",
1134
+ "attached image",
1135
+ "strict visual reference",
1136
+ "preserve its main subject",
1137
+ "preserve its main subject, content and real colors",
1138
+ "input image",
1139
+ "source_file",
1140
+ "source image",
1141
+ )
1142
+
1143
+
1144
+ def _looks_like_image_to_image_prompt(prompt):
1145
+ if not isinstance(prompt, str) or not prompt.strip():
1146
+ return False
1147
+ hay = prompt.lower()
1148
+ return any(marker in hay for marker in I2I_PROMPT_MARKERS)
1149
+
1150
+
1151
+ def _complementary_bg_prompt_from_page(page, tone_hint):
1152
+ """为「semantic_image_prompt 本身写成了 i2i 指令」的页生成互补背景,而不是把该指令原样喂给 t2i。
1153
+
1154
+ 这类页的前景主体已经由某张 `enhanced_src_K` 承担;背景只负责气氛、空间和文字净区。
1155
+ 如果把 "Use the attached image..." 直接喂给 text-to-image,模型没有参考图时会随机生成瓶子、
1156
+ 盾牌、设备等无关主体,所以这里强制生成不抢戏的互补背景。(`route==source-enhanced` 的页现由
1157
+ `ensure_page_backgrounds` 直接跳过整个背景注入,不会走到这里——2026-07-03 真机 3/5/6/8/9 页事故。)
1158
+ """
1159
+ takeaway = _page_text(page.get("one_sentence_takeaway") or page.get("key_point") or page.get("title"))
1160
+ visual_story = _page_text(page.get("visual_story"))
1161
+ text_zone = _page_text(page.get("text_overlay_zone") or page.get("safe_text_zone") or page.get("text_zone"))
1162
+ tone = f"{tone_hint}, " if tone_hint else ""
1163
+ subject = f"supporting the idea: {takeaway}. {visual_story}." if (takeaway or visual_story) else ""
1164
+ return (
1165
+ "Complementary low-interference background for an Apple / DJI style product-detail PPT page, "
1166
+ "designed to sit behind a separate customer-image foreground or hero cutout. "
1167
+ f"{subject} Keep a spacious clean text-safe zone at {text_zone or 'one side'}; "
1168
+ f"{tone}premium aviation and industrial atmosphere, soft depth, subtle light, elegant negative space, "
1169
+ "no standalone product, no bottle, no medicine, no cosmetic container, no shield symbol, no UI cards, "
1170
+ "no thumbnail collage, no diagram, no chart, no text, no words, no letters, no logos, no watermark"
1171
+ + _QUALITY
1172
+ + _NO_TEXT_TAIL
1173
+ )
1174
+
1175
+
1176
+ def _semantic_bg_prompt_from_page(page, tone_hint):
1177
+ """优先把 design.json 的页面级语义转成背景/场景生图提示词。
1178
+
1179
+ 设计阶段若已经写了 semantic_image_prompt,就尊重它;否则用页面主句、视觉故事和主视觉层
1180
+ 生成一个“产品详情页式语义背景”。精确信息页仍不让 AI 画结构图,而是生成低干扰语义底图。
1181
+ """
1182
+ if not isinstance(page, dict):
1183
+ return ""
1184
+ semantic = _page_text(page.get("semantic_image_prompt"))
1185
+ if semantic and not semantic.lower().startswith("html-svg"):
1186
+ if _looks_like_image_to_image_prompt(semantic):
1187
+ return _complementary_bg_prompt_from_page(page, tone_hint)
1188
+ semantic = _sanitize_image_prompt(semantic)
1189
+ if "no text" not in semantic.lower():
1190
+ semantic = semantic.rstrip(" .") + ", no text, no words, no letters, no logos, no watermark"
1191
+ return semantic + _NO_TEXT_TAIL
1192
+
1193
+ style = _page_text(page.get("composition_style"))
1194
+ grammar = _page_text(page.get("page_grammar"))
1195
+ takeaway = _page_text(page.get("one_sentence_takeaway") or page.get("key_point") or page.get("title"))
1196
+ visual_story = _page_text(page.get("visual_story"))
1197
+ main_visual = _page_text(page.get("main_visual_layer") or page.get("visual_focus"))
1198
+ text_zone = _page_text(page.get("text_overlay_zone") or page.get("safe_text_zone") or page.get("text_zone"))
1199
+ tone = f"{tone_hint}, " if tone_hint else ""
1200
+
1201
+ if not any((takeaway, visual_story, main_visual)):
1202
+ return ""
1203
+
1204
+ if style == "data-visual" or grammar == "data-visual":
1205
+ return (
1206
+ "Low-interference premium product-detail-page background that supports an editable HTML/SVG "
1207
+ f"data visualization about {takeaway}. "
1208
+ f"{tone}soft cinematic depth, calm negative space for overlay text and charts, no charts drawn, "
1209
+ "no diagrams, no numbers, no text, no words, no letters, no logos, no watermark"
1210
+ + _QUALITY
1211
+ + _NO_TEXT_TAIL
1212
+ )
1213
+
1214
+ return (
1215
+ "Premium Apple / DJI style product-detail-page visual background, cinematic and photographic, "
1216
+ f"visually representing: {takeaway}. {visual_story}. Primary visual idea: {main_visual}. "
1217
+ f"Keep a clean text-safe zone at {text_zone or 'one side'}; "
1218
+ f"{tone}high-end editorial lighting, realistic depth, elegant composition, no clutter, "
1219
+ "no text, no words, no letters, no captions, no logos, no watermark"
1220
+ + _QUALITY
1221
+ + _NO_TEXT_TAIL
1222
+ )
1223
+
1224
+
1225
+ def _atmos_bg_prompt(tone_hint, page_no, page=None):
1226
+ """确定性合成「产品详情页式底图」文生图提示词。
1227
+
1228
+ 优先使用页面级 semantic_image_prompt / visual_story / takeaway,避免无图页只生成廉价抽象渐变;
1229
+ 实在没有页面语义时才退为 VI 氛围背景。
1230
+ """
1231
+ semantic_prompt = _semantic_bg_prompt_from_page(page, tone_hint)
1232
+ if semantic_prompt:
1233
+ return semantic_prompt
1234
+ try:
1235
+ idx = int(page_no)
1236
+ except (TypeError, ValueError):
1237
+ idx = 0
1238
+ variant = _ATMOS_VARIANTS[idx % len(_ATMOS_VARIANTS)]
1239
+ tone = f"{tone_hint}, " if tone_hint else ""
1240
+ return (
1241
+ "Professional atmospheric editorial background for a premium product detail page "
1242
+ "(Apple / DJI style): " + variant + ", soft cinematic gradient depth, subtle texture and gentle "
1243
+ "light, generous calm low-detail negative space for overlay text, no diagrams, no text, no words, "
1244
+ "no letters, " + tone + _QUALITY + _NO_TEXT_TAIL
1245
+ )
1246
+
1247
+
1248
+ def ensure_page_backgrounds(build_dir, design_path=None):
1249
+ """把【每个无背景图的内容页】确定性补一条 VI 氛围文生图底图条目 union 进 img_plan.json
1250
+ (按约定名 `ai_p<page>.png`、按 file 去重幂等)。
1251
+
1252
+ 真凶(真机 deck.html 实证):设计 LLM 给约一半内容页留 `route:none/html`(无背景图)→ build 无底图可挂 →
1253
+ 那些页退化成「白底 + 卡片墙/表格」(§0.7 编辑式三层「前景禁卡墙」规则只对**有背景**的编辑式页生效)。
1254
+ 本函数把「每页必须有背景层」从弱模型手里收成确定性:凡某页既无客户图(`route=source-enhanced`,任意
1255
+ usage_intent——hero/contain/gallery/bg 皆算「该页已有真实视觉」)、又无设计选定且带提示词的 ai-* 底图
1256
+ (ensure_ai_img_plan 已注入)、img_plan 里也没有 `ai_p<page>.png` → 优先按该页 semantic_image_prompt /
1257
+ visual_story / takeaway 补语义化产品详情页背景;信息不足时才退为 VI 同色系氛围底。卡片墙因此「每页都成
1258
+ 编辑式页」被既有规则自动挡掉(Leak B 是 Leak A 的下游症状)。
1259
+
1260
+ 2026-07-03 真机 3/5/6/8/9 页事故修正:此前只有 `usage_intent==source_enhanced_bg` 才跳过独立 AI 背景
1261
+ 注入,`source_enhanced_hero`/`direct_contain`/`gallery`(页面已经在用客户真图做主视觉/画廊)仍会被强插
1262
+ 一张完全无关的 `ai_p<page>.png`——真机验证出:与客户真图同主题重复(页3,两张厂房图叠一页)、或
1263
+ visual_focus 被背景 prompt 去内容化后落入"通用室内"陈词滥调完全跑题(页5/6/9,办公桌+窗帘)。
1264
+ 对照组:页7(无客户图,纯 AI 背景 + VI 渐变兜底)效果干净——证明"客户图页不需要再叠一张竞争性 AI 图"
1265
+ 这个判断成立。现在只要该页 `route=="source-enhanced"`(有任意客户图视觉)就跳过独立背景注入,
1266
+ `.edit-bg` 交给 build_batch 已有的 `--vi-fallback` VI 渐变兜底(页7已验证效果,不需要新机制)。
1267
+
1268
+ 跑序:在 `--ensure-img-plan`(客户图)+ `--ensure-ai-img-plan`(LLM 选的 ai-*)**之后**。build_batch 按约定名
1269
+ `ai_p<page>.png` 引用 + onerror 渐变兜底,故**无需回写 design.json**(build 早于 gate 的时序安全)。
1270
+ `generate_from_plan` 文生图失败退渐变 PNG,三方对齐、优雅退化。ppt 独占(webppt 不调用此脚本)。"""
1271
+ build_dir = os.path.abspath(build_dir)
1272
+ if not design_path:
1273
+ design_path = os.path.join(build_dir, "design.json")
1274
+ design = _load_json(design_path)
1275
+ pages = design.get("pages") if isinstance(design, dict) else None
1276
+ if not isinstance(pages, list):
1277
+ print("ENSURE_PAGE_BG_SKIP: 无 design.json / pages,跳过。")
1278
+ return 0
1279
+
1280
+ img_plan_path = os.path.join(build_dir, "img_plan.json")
1281
+ existing = _load_json(img_plan_path)
1282
+ if not isinstance(existing, list):
1283
+ existing = []
1284
+ have = {e.get("file") for e in existing if isinstance(e, dict)}
1285
+
1286
+ tone_hint = _vi_tone_hint(build_dir, design)
1287
+
1288
+ injected = 0
1289
+ for p in pages:
1290
+ if not isinstance(p, dict):
1291
+ continue
1292
+ page_no = p.get("page")
1293
+ if page_no is None:
1294
+ continue
1295
+ ai_file = f"ai_p{page_no}.png"
1296
+ if ai_file in have:
1297
+ continue # ai-* 已注入 / 本函数已补过 → 去重
1298
+ visual = p.get("visual") if isinstance(p.get("visual"), dict) else {}
1299
+ route = str(visual.get("route", "")).lower()
1300
+ # 已有【真实视觉】的页跳过:① 客户图整页底 / 前景 hero / contain 框图 / 画廊——任意 usage_intent,
1301
+ # 只要该页在用客户真图,就不需要再叠一张无关竞争性 AI 背景(2026-07-03 真机 3/5/6/8/9 页事故:
1302
+ # source_enhanced_hero/direct_contain/gallery 曾被排除在外,仍强插 ai_p<page>.png,导致同主题
1303
+ # 重复或彻底跑题);② 设计选定且带提示词的 ai-* 底图(ensure_ai_img_plan 管)。
1304
+ # 注:route 为 source(前景 contain 原图直用)、html、none、缺 visual —— 才是真【没有背景图】,
1305
+ # 照样补氛围底图;`route=="source-enhanced"` 一律视为已有真实视觉,`.edit-bg` 交给 build_batch
1306
+ # 既有的 VI 渐变兜底(--vi-fallback,页7 无客户图对照组已验证效果,不需要新机制)。
1307
+ if route == "source-enhanced":
1308
+ continue
1309
+ prompt_existing = visual.get("prompt")
1310
+ if route in ("ai-a", "ai-b") and isinstance(prompt_existing, str) and prompt_existing.strip():
1311
+ continue
1312
+ existing.append(
1313
+ {
1314
+ "file": ai_file,
1315
+ "mode": "text-to-image",
1316
+ "aspect": "16:9",
1317
+ "prompt": _atmos_bg_prompt(tone_hint, page_no, p),
1318
+ }
1319
+ )
1320
+ have.add(ai_file)
1321
+ injected += 1
1322
+
1323
+ with open(img_plan_path, "w", encoding="utf-8") as f:
1324
+ json.dump(existing, f, ensure_ascii=False, indent=2)
1325
+ print(
1326
+ f"ENSURE_PAGE_BG_OK: 为 {injected} 个无背景内容页补 VI 氛围底图条目"
1327
+ f"(img_plan.json 现 {len(existing)} 条)→ {img_plan_path}"
1328
+ )
1329
+ return 0
1330
+
1331
+
1332
+ def reconcile_missing(build_dir):
1333
+ """生图后**确定性兜底**(治「图生图缺图 → deck 死链空框」):遍历 source_layout_plan.json 的
1334
+ enhance_entries,凡 `assets/<enhanced_file>` **缺失**(gen 中断/早退/某条没轮到,连降级占位都没写)
1335
+ 且其 `assets/<source_file>` 原图存在 → **把原图拷一份顶上**(宁可用没增强的原图、绝不留空框/断图)。
1336
+ 已生成的增强图**不覆盖**;连原图也缺的记 warning 不崩。恒 return 0。
1337
+
1338
+ 与 generate_from_plan.mjs 的逐条降级互补:那个只在 gen 真迭代到该条时才写占位,本函数是
1339
+ **gen 之后、独立于 gen 是否覆盖**的最终兜底。ppt 独占(webppt 不调用此脚本)。"""
1340
+ build_dir = os.path.abspath(build_dir)
1341
+ assets = os.path.join(build_dir, "assets")
1342
+ plan = _load_json(os.path.join(build_dir, "source_layout_plan.json"))
1343
+ entries = (plan or {}).get("enhance_entries") or [] if isinstance(plan, dict) else []
1344
+ if not entries:
1345
+ print("RECONCILE_MISSING_OK: 无图生图增强条目(注入 0 张兜底)。")
1346
+ return 0
1347
+
1348
+ copied = []
1349
+ missing_source = []
1350
+ for ent in entries:
1351
+ if not isinstance(ent, dict):
1352
+ continue
1353
+ f = ent.get("file")
1354
+ src = ent.get("source_file")
1355
+ if not f:
1356
+ continue
1357
+ dst_path = os.path.join(assets, f)
1358
+ if os.path.isfile(dst_path):
1359
+ continue # 真增强图已生成 → 绝不覆盖
1360
+ src_path = os.path.join(assets, src) if src else None
1361
+ if src_path and os.path.isfile(src_path):
1362
+ try:
1363
+ os.makedirs(assets, exist_ok=True)
1364
+ shutil.copyfile(src_path, dst_path)
1365
+ copied.append((src, f))
1366
+ except Exception as e: # noqa: BLE001 兜底绝不崩
1367
+ missing_source.append(f"{f}(拷贝失败:{e})")
1368
+ else:
1369
+ missing_source.append(f"{f}(原图 {src} 也缺)")
1370
+
1371
+ msg = f"RECONCILE_MISSING_OK: 回退拷贝 {len(copied)} 张缺失增强图(原图顶上,防 deck 空框)"
1372
+ if copied:
1373
+ msg += ":" + ", ".join(f"{s}→{d}" for s, d in copied)
1374
+ if missing_source:
1375
+ msg += f";⚠️ {len(missing_source)} 张无法兜底: {missing_source}"
1376
+ print(msg)
1377
+ return 0
1378
+
1379
+
1380
+ def main() -> int:
1381
+ parser = argparse.ArgumentParser(
1382
+ description="客户图版式/图生图增强确定性规划器(读 material.json → source_layout_plan.json/md)"
1383
+ )
1384
+ parser.add_argument("--build", default="./build", help="摄取产物目录(默认 ./build)")
1385
+ parser.add_argument(
1386
+ "--ensure-img-plan",
1387
+ action="store_true",
1388
+ help="reconcile 模式:把规划的图生图条目 union 进 img_plan.json(生图前调用)",
1389
+ )
1390
+ parser.add_argument(
1391
+ "--ensure-ai-img-plan",
1392
+ action="store_true",
1393
+ help="reconcile 模式:把 design.json 的 route=ai-A/ai-B 页【文生图 AI 背景】条目 union 进 img_plan.json"
1394
+ "(约定名 ai_p<page>.png,不靠设计 LLM 记得写;生图前调用)",
1395
+ )
1396
+ parser.add_argument(
1397
+ "--design",
1398
+ default=None,
1399
+ help="design.json 路径(--ensure-ai-img-plan 用;缺省取 <build>/design.json)",
1400
+ )
1401
+ parser.add_argument(
1402
+ "--reconcile-extensions",
1403
+ action="store_true",
1404
+ help="reconcile 模式:把 design.json/img_plan.json 里客户图扩展名对齐 source_assets.json 真实"
1405
+ "文件名(治 design LLM 猜错扩展名致生图找不到源图、静默退化占位图;在 --ensure-img-plan 之后、"
1406
+ "生图之前调用)",
1407
+ )
1408
+ parser.add_argument(
1409
+ "--ensure-page-backgrounds",
1410
+ action="store_true",
1411
+ help="reconcile 模式:为【无背景图的内容页】补 VI 氛围文生图底图条目 union 进 img_plan.json"
1412
+ "(约定名 ai_p<page>.png,治「route:none/html 页无底图→退化白底卡片墙」;在 --ensure-ai-img-plan 之后调用)",
1413
+ )
1414
+ parser.add_argument(
1415
+ "--reconcile-missing",
1416
+ action="store_true",
1417
+ help="兜底模式:生图后把缺失的 enhanced 派生图从原图拷贝顶上(防 deck 死链空框;生图后、导出前调用)",
1418
+ )
1419
+ args = parser.parse_args()
1420
+
1421
+ if args.ensure_img_plan:
1422
+ return ensure_img_plan(args.build)
1423
+
1424
+ if args.reconcile_extensions:
1425
+ return reconcile_source_extensions(args.build, args.design)
1426
+
1427
+ if args.ensure_ai_img_plan:
1428
+ return ensure_ai_img_plan(args.build, args.design)
1429
+
1430
+ if args.ensure_page_backgrounds:
1431
+ return ensure_page_backgrounds(args.build, args.design)
1432
+
1433
+ if args.reconcile_missing:
1434
+ return reconcile_missing(args.build)
1435
+
1436
+ plan = build_plan(args.build)
1437
+ build_dir = os.path.abspath(args.build)
1438
+ os.makedirs(build_dir, exist_ok=True)
1439
+ with open(os.path.join(build_dir, "source_layout_plan.json"), "w", encoding="utf-8") as f:
1440
+ json.dump(plan, f, ensure_ascii=False, indent=2)
1441
+ write_plan_md(build_dir, plan)
1442
+ print(
1443
+ f"SOURCE_LAYOUT_PLANNED: {plan['image_count']} 张客户图,{plan['enhance_count']} 张需图生图增强 "
1444
+ f"→ {os.path.join(build_dir, 'source_layout_plan.json')}"
1445
+ )
1446
+ return 0
1447
+
1448
+
1449
+ if __name__ == "__main__":
1450
+ sys.exit(main())