weflow-cli 1.8.2 → 1.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 (185) hide show
  1. package/ARCHITECTURE.md +62 -26
  2. package/CHANGELOG.md +1290 -0
  3. package/OPERATIONS.md +144 -11
  4. package/README.en.md +20 -4
  5. package/README.md +30 -5
  6. package/bin/weflow-cli-electron.cjs +2 -0
  7. package/bin/weflow-cli.ts +996 -118
  8. package/dist/bin/weflow-cli.js +1038 -146
  9. package/dist/bin/weflow-cli.js.map +1 -1
  10. package/dist/mcp-server/http.d.ts +14 -0
  11. package/dist/mcp-server/http.d.ts.map +1 -0
  12. package/dist/mcp-server/http.js +84 -0
  13. package/dist/mcp-server/http.js.map +1 -0
  14. package/dist/mcp-server/httpConfig.d.ts +50 -0
  15. package/dist/mcp-server/httpConfig.d.ts.map +1 -0
  16. package/dist/mcp-server/httpConfig.js +104 -0
  17. package/dist/mcp-server/httpConfig.js.map +1 -0
  18. package/dist/mcp-server/index.d.ts +13 -1
  19. package/dist/mcp-server/index.d.ts.map +1 -1
  20. package/dist/mcp-server/index.js +58 -374
  21. package/dist/mcp-server/index.js.map +1 -1
  22. package/dist/src/core/dbPathService.d.ts +12 -0
  23. package/dist/src/core/dbPathService.d.ts.map +1 -1
  24. package/dist/src/core/dbPathService.js +20 -3
  25. package/dist/src/core/dbPathService.js.map +1 -1
  26. package/dist/src/panel/server.d.ts.map +1 -1
  27. package/dist/src/panel/server.js +61 -1
  28. package/dist/src/panel/server.js.map +1 -1
  29. package/dist/src/services/assistantEval.d.ts +69 -0
  30. package/dist/src/services/assistantEval.d.ts.map +1 -1
  31. package/dist/src/services/assistantEval.js +270 -8
  32. package/dist/src/services/assistantEval.js.map +1 -1
  33. package/dist/src/services/assistantScenes.d.ts +81 -0
  34. package/dist/src/services/assistantScenes.d.ts.map +1 -0
  35. package/dist/src/services/assistantScenes.js +322 -0
  36. package/dist/src/services/assistantScenes.js.map +1 -0
  37. package/dist/src/services/assistantService.d.ts +33 -4
  38. package/dist/src/services/assistantService.d.ts.map +1 -1
  39. package/dist/src/services/assistantService.js +102 -6
  40. package/dist/src/services/assistantService.js.map +1 -1
  41. package/dist/src/services/assistantSkills.d.ts +94 -0
  42. package/dist/src/services/assistantSkills.d.ts.map +1 -0
  43. package/dist/src/services/assistantSkills.js +330 -0
  44. package/dist/src/services/assistantSkills.js.map +1 -0
  45. package/dist/src/services/assistantTools.d.ts +34 -1
  46. package/dist/src/services/assistantTools.d.ts.map +1 -1
  47. package/dist/src/services/assistantTools.js +931 -88
  48. package/dist/src/services/assistantTools.js.map +1 -1
  49. package/dist/src/services/assistantTrace.d.ts +4 -0
  50. package/dist/src/services/assistantTrace.d.ts.map +1 -1
  51. package/dist/src/services/assistantTrace.js +9 -0
  52. package/dist/src/services/assistantTrace.js.map +1 -1
  53. package/dist/src/services/configService.d.ts +9 -0
  54. package/dist/src/services/configService.d.ts.map +1 -1
  55. package/dist/src/services/configService.js +5 -2
  56. package/dist/src/services/configService.js.map +1 -1
  57. package/dist/src/services/messageQuery.d.ts +18 -0
  58. package/dist/src/services/messageQuery.d.ts.map +1 -1
  59. package/dist/src/services/messageQuery.js +29 -0
  60. package/dist/src/services/messageQuery.js.map +1 -1
  61. package/dist/src/services/outboundSend.d.ts +53 -0
  62. package/dist/src/services/outboundSend.d.ts.map +1 -0
  63. package/dist/src/services/outboundSend.js +90 -0
  64. package/dist/src/services/outboundSend.js.map +1 -0
  65. package/dist/src/services/pythonBridge.d.ts.map +1 -1
  66. package/dist/src/services/pythonBridge.js +1 -0
  67. package/dist/src/services/pythonBridge.js.map +1 -1
  68. package/dist/src/services/wechatClient.d.ts +125 -0
  69. package/dist/src/services/wechatClient.d.ts.map +1 -0
  70. package/dist/src/services/wechatClient.js +344 -0
  71. package/dist/src/services/wechatClient.js.map +1 -0
  72. package/dist/src/services/wechatMedia.d.ts +44 -0
  73. package/dist/src/services/wechatMedia.d.ts.map +1 -0
  74. package/dist/src/services/wechatMedia.js +155 -0
  75. package/dist/src/services/wechatMedia.js.map +1 -0
  76. package/dist/src/services/wechatMessageService.d.ts +11 -0
  77. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  78. package/dist/src/services/wechatMessageService.js +55 -11
  79. package/dist/src/services/wechatMessageService.js.map +1 -1
  80. package/dist/src/types.d.ts +4 -0
  81. package/dist/src/types.d.ts.map +1 -1
  82. package/dist/src/utils/python.js +2 -2
  83. package/dist/src/utils/python.js.map +1 -1
  84. package/docs/BRANCHES.md +37 -0
  85. package/docs/CONTACT_DB_SCHEMA.md +1085 -0
  86. package/docs/DECISIONS.md +1624 -0
  87. package/docs/EXTENDING.md +98 -10
  88. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +6 -0
  89. package/docs/HEALTH-CHECK.md +12 -7
  90. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
  91. package/docs/MCP.md +58 -4
  92. package/docs/PROJECT_STATE.md +180 -11
  93. package/docs/RELEASING.md +115 -1
  94. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  95. package/logo.png +0 -0
  96. package/mcp-server/http.ts +108 -0
  97. package/mcp-server/httpConfig.ts +126 -0
  98. package/mcp-server/index.ts +359 -681
  99. package/package.json +5 -3
  100. package/resources/js/graph3d/NOTICE.txt +35 -0
  101. package/resources/js/graph3d/d3-binarytree.min.js +2 -0
  102. package/resources/js/graph3d/d3-dispatch.min.js +2 -0
  103. package/resources/js/graph3d/d3-force-3d.min.js +2 -0
  104. package/resources/js/graph3d/d3-octree.min.js +2 -0
  105. package/resources/js/graph3d/d3-quadtree.min.js +2 -0
  106. package/resources/js/graph3d/d3-timer.min.js +2 -0
  107. package/resources/js/graph3d/layout.mjs +63 -0
  108. package/resources/js/graph3d/three.min.js +6 -0
  109. package/resources/js/graph3d/viewer2d.js +448 -0
  110. package/resources/panel/ball-position.cjs +48 -2
  111. package/resources/panel/index.html +18 -4
  112. package/resources/panel/main.cjs +137 -5
  113. package/resources/panel/mascot-base.png +0 -0
  114. package/resources/panel/mascot-blink-1.png +0 -0
  115. package/resources/panel/mascot-blink-2.png +0 -0
  116. package/resources/panel/mascot-iris.png +0 -0
  117. package/resources/panel/mascot-lift-down-1.png +0 -0
  118. package/resources/panel/mascot-lift-down-2.png +0 -0
  119. package/resources/panel/mascot-lift-down-3.png +0 -0
  120. package/resources/panel/mascot-lift-down-4.png +0 -0
  121. package/resources/panel/mascot-lift-down-5.png +0 -0
  122. package/resources/panel/mascot-lift-down-6.png +0 -0
  123. package/resources/panel/mascot-lift-up-1.png +0 -0
  124. package/resources/panel/mascot-lift-up-2.png +0 -0
  125. package/resources/panel/mascot-lift-up-3.png +0 -0
  126. package/resources/panel/mascot-lift-up-4.png +0 -0
  127. package/resources/panel/mascot-lift-up-5.png +0 -0
  128. package/resources/panel/mascot-lift-up-6.png +0 -0
  129. package/resources/panel/mascot-peek.png +0 -0
  130. package/resources/panel/mascot-tickle-1.png +0 -0
  131. package/resources/panel/mascot-tickle-2.png +0 -0
  132. package/resources/panel/mascot-tickle-3.png +0 -0
  133. package/resources/panel/mascot-tickle-4.png +0 -0
  134. package/resources/panel/panel.css +205 -59
  135. package/resources/panel/preload.cjs +26 -0
  136. package/resources/panel/renderer.js +646 -17
  137. package/scripts/_utils.py +1203 -931
  138. package/scripts/backfill_articles.py +202 -21
  139. package/scripts/biz_daily.py +26 -34
  140. package/scripts/bonds.py +331 -0
  141. package/scripts/chat_notes.py +561 -470
  142. package/scripts/chat_stats.py +8 -7
  143. package/scripts/check_readme_commands.py +426 -0
  144. package/scripts/compile_wiki.py +187 -59
  145. package/scripts/contact_schema.py +339 -0
  146. package/scripts/data/wechat_emoji.json +225 -0
  147. package/scripts/export_chat_html.py +3 -1
  148. package/scripts/fav_notes.py +4 -1
  149. package/scripts/graph_2d.py +151 -0
  150. package/scripts/graph_3d.py +646 -0
  151. package/scripts/health_check.py +163 -26
  152. package/scripts/mcp_bridge.py +6 -7
  153. package/scripts/nt_decrypt.py +3 -3
  154. package/scripts/panel_frames.py +683 -0
  155. package/scripts/pipeline.py +21 -10
  156. package/scripts/quality_eval.py +5 -0
  157. package/scripts/rag_chat.py +33 -16
  158. package/scripts/reply_debt.py +26 -6
  159. package/scripts/semantic_search.py +130 -22
  160. package/scripts/vault_rag.py +42 -16
  161. package/scripts/vault_search.py +44 -8
  162. package/scripts/wechat_emoticon.py +6 -0
  163. package/scripts/wechat_voice.py +9 -0
  164. package/scripts/wiki_lint.py +124 -24
  165. package/src/core/dbPathService.ts +22 -4
  166. package/src/panel/server.ts +58 -1
  167. package/src/services/assistantEval.ts +323 -12
  168. package/src/services/assistantScenes.ts +342 -0
  169. package/src/services/assistantService.ts +107 -6
  170. package/src/services/assistantSkills.ts +364 -0
  171. package/src/services/assistantTools.ts +2452 -1640
  172. package/src/services/assistantTrace.ts +9 -0
  173. package/src/services/configService.ts +415 -403
  174. package/src/services/messageQuery.ts +29 -0
  175. package/src/services/outboundSend.ts +126 -0
  176. package/src/services/pythonBridge.ts +1 -0
  177. package/src/services/wechatClient.ts +380 -0
  178. package/src/services/wechatMedia.ts +166 -0
  179. package/src/services/wechatMessageService.ts +62 -11
  180. package/src/types.ts +4 -0
  181. package/src/utils/python.ts +2 -2
  182. package/dist/src/utils/wechatEmoji.d.ts +0 -5
  183. package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
  184. package/dist/src/utils/wechatEmoji.js +0 -35
  185. package/dist/src/utils/wechatEmoji.js.map +0 -1
@@ -0,0 +1,683 @@
1
+ # -*- coding: utf-8 -*-
2
+ """悬浮宠物"被拎起来"动作帧的生成、归一化与几何校验。
3
+
4
+ 现有那套出图参数此前只写在 `resources/panel/panel.css` 的注释里(内容框、0.98R、
5
+ 圆外实心 0),没有可执行的东西 —— 每次换素材都靠人眼比,而人眼看不出的错
6
+ (差 1 像素的错位、超圆的 4 个像素)是这里最容易翻的车。这个脚本把它变成可复跑的。
7
+
8
+ 子命令:
9
+ --gen 调生图模型出原始帧(需要 requests + IMAGE_API_KEY + IMAGE_API_BASE)
10
+ --normalize 把原始帧归一化到契约上(需要 Pillow 做重采样)
11
+ --check 校验成品帧是否落在契约里。**只用标准库**,因为测试会跑它
12
+
13
+ 口径(必须与 `test/panel-frames.test.ts` 一致,两处漂了就会各说各话):
14
+
15
+ 画布 256x256、8 位 RGBA、非隔行。
16
+ "实心" = **alpha >= 128**(不是 alpha > 0)。这条是踩出来的:用 alpha>=1 会把描边的
17
+ 极淡锯齿也算进轮廓,于是同一张图量出 125.88、看着像"超了 4 个像素",而按 alpha>=128
18
+ 量是 125.25 —— 后者才是 panel.css 记的那个 125.2。
19
+ 距离从**画布中心 (128,128)** 量(不是内容 bbox 中心;从 bbox 中心量是另一个数 124.16,
20
+ 两个口径混着用就会得出相反的结论)。
21
+ 上限 0.98 x 128 = 125.44,现有素材实测 125.25 —— 余下那 2% 的半径预算归悬停的
22
+ `scale(1.02)`(125.25 x 1.02 = 127.76 < 128,正好不出圆)。
23
+
24
+ 动作帧另加两条:**逐帧收小**(s 见 SCALES,拎到最高点最小),以及**头顶锚定**
25
+ (最上面那行实心像素的 y 与静止帧对齐)——被拎住的是后颈,身体缩小是往下坠的,
26
+ 锚点跟着跑就会看成"整只猫在飘"。
27
+
28
+ 输出一律纯 ASCII:本机 Python 在 GBK 控制台上打非 ASCII 会崩(仓库已有记录)。
29
+ """
30
+ import argparse
31
+ import base64
32
+ import json
33
+ import math
34
+ import os
35
+ import sys
36
+ import time
37
+ import zlib
38
+
39
+ CANVAS = 256
40
+ ALPHA_SOLID = 128
41
+ CAP = 0.98 * (CANVAS / 2.0) # 125.44
42
+ BASE_EXTENT = 125.25 # 现有静止帧实测的半径占用
43
+ BASE = 'mascot-base.png'
44
+ MODEL = 'gpt-image-2-ca'
45
+ PNG_DIR = os.path.join('resources', 'panel')
46
+ RAW_DIR = os.path.join('output', 'mascot-lift', 'raw')
47
+
48
+ # 文件名 -> (相对静止帧的缩放, 姿势)
49
+ #
50
+ # 12 帧:6 帧拎起来 + 6 帧落回去(再加静止帧,共 13 个状态)。缩放不是线性的,走的是一条
51
+ # 缓入缓出的弧线 —— 起步慢、中段快、接近悬空时收敛,看着才像被"提"起来而不是匀速缩放。
52
+ #
53
+ # 姿势全部**收敛**写:只让四肢与表情动,不许改轮廓。第一版把"被拎起来"写得太用力,模型
54
+ # 于是把猫画成了尖顶斗篷 + 细长身子(实测猫的 bbox 长宽比从 0.794 掉到 0.650,差 18%),
55
+ # 耳朵也一起没了 —— 那已经不是同一只猫,贴上球也不再是"那颗球"。
56
+ SCALES = [
57
+ ('mascot-lift-up-1.png', 0.992, 'eyes wide and startled, the two cat ears pricked up, front paws '
58
+ 'just beginning to lift off the ground'),
59
+ ('mascot-lift-up-2.png', 0.980, 'the two cat ears still upright, front paws off the ground, head '
60
+ 'tilted slightly up, body still round'),
61
+ ('mascot-lift-up-3.png', 0.965, 'front paws hanging loose, hind paws curling under the body, the two '
62
+ 'cat ears tilting back but clearly still two ears'),
63
+ ('mascot-lift-up-4.png', 0.948, 'body clearly lifted with all four paws off the ground, the two cat '
64
+ 'ears pressed further back, eyes wide'),
65
+ ('mascot-lift-up-5.png', 0.933, 'hanging with the front paws straight down, the two cat ears almost '
66
+ 'flat, eyes wide, small mouth'),
67
+ ('mascot-lift-up-6.png', 0.920, 'held up at the highest point by the scruff, front paws dangling '
68
+ 'straight down, hind paws tucked, the two cat ears pressed back but still clearly TWO triangular '
69
+ 'ears, tiny round mouth, and the hood still perfectly round like a ball'),
70
+ ('mascot-lift-down-1.png', 0.928, 'still hanging but the two cat ears starting to lift back up, eyes wide'),
71
+ ('mascot-lift-down-2.png', 0.940, 'front paws reaching slightly downward as the body starts to come '
72
+ 'down, the two cat ears half back, the body keeps exactly the same width and height as the reference image, only the limbs move'),
73
+ ('mascot-lift-down-3.png', 0.955, 'body descending, hind paws reaching for the ground, the two cat '
74
+ 'ears coming back up, the body keeps exactly the same width and height as the reference image, only the limbs move'),
75
+ ('mascot-lift-down-4.png', 0.968, 'nearly landed, paws touching the ground, the two cat ears back to normal'),
76
+ ('mascot-lift-down-5.png', 0.982, 'just landed, body back to its normal round shape with no squash at '
77
+ 'all, eyes wide, the body keeps exactly the same width and height as the reference image, only the limbs move'),
78
+ ('mascot-lift-down-6.png', 0.992, 'almost back to the resting pose, eyes still a little wide'),
79
+ ]
80
+ # 挠痒痒那一族:4 张姿势帧,**缩放固定 1.0** —— 扭动不该让球变大变小(与"被拎起来"那套的
81
+ # 逐帧收小正相反)。它们只负责表情与爪子的小动作;倾斜由渲染进程的程序化旋转负责。
82
+ TICKLE_SCALE = 0.985 # 留一点点余量:静止帧已经用掉 125.25/125.44,
83
+ # 同尺寸的姿势帧只要稍宽一点就会顶出圆(实测 tickle-2 就是这样),
84
+ # 而回退缩放会让它在循环里比别的帧小一圈、看得出跳。
85
+ TICKLE = [
86
+ ('mascot-tickle-1.png', 'eyes squeezed shut in a giggle, one front paw raised as if warding the '
87
+ 'tickle off, the two cat ears flicked slightly outward'),
88
+ ('mascot-tickle-2.png', 'mouth open laughing, front paws pulled in close to the chest, the two cat '
89
+ 'ears tilted back a little'),
90
+ ('mascot-tickle-3.png', 'eyes squeezed shut with a small tongue sticking out, one paw up near the '
91
+ 'cheek, the two cat ears flicked outward'),
92
+ ('mascot-tickle-4.png', 'a big open laugh, both front paws up, the two cat ears flicked back'),
93
+ ]
94
+
95
+
96
+
97
+ # "偶尔眨一下眼"那 2 帧:半闭 -> 闭。**缩放固定 1.0**(眨眼不该改大小)。
98
+ # 收回来之后要**只把眼睛那块贴回静止帧**(见 README/脚本里的 normalize_blink),因为生图模型
99
+ # 会把整张脸重画一遍,那点像素差在动画里就是"整颗球闪一下"。
100
+ BLINK = [
101
+ ('mascot-blink-1.png', 'eyes half closed, mid blink, everything else in the drawing identical to the reference'),
102
+ ('mascot-blink-2.png', 'eyes fully closed, a calm contented expression, everything else in the drawing '
103
+ 'identical to the reference'),
104
+ ]
105
+
106
+
107
+ def all_frames():
108
+ """两族帧合起来:被拎起来那套(有序、逐帧收小)+ 挠痒痒那族(同尺寸)。"""
109
+ return (list(SCALES) + [(n, TICKLE_SCALE, p) for n, p in TICKLE]
110
+ + [(n, 1.0, p) for n, p in BLINK])
111
+
112
+
113
+ FRAME_FILES = [f for f, _, _ in SCALES]
114
+ TICKLE_FILES = [f for f, _ in TICKLE]
115
+
116
+ BLINK_FILES = [f for f, _ in BLINK]
117
+ REFERENCE_FILES = [BASE, 'mascot-happy.png', 'mascot-sorry.png', 'mascot-tired.png']
118
+
119
+ # 不变式:每帧都带同样这段话,只换姿势那一句。生图模型最容易"顺手"改的就是比例与构图。
120
+ INVARIANT = (
121
+ 'Keep the character exactly as it is: same style, same palette, same line weight, same facing '
122
+ 'direction, same proportions of face and body. Same camera, same distance -- do NOT change the '
123
+ 'overall size of the character or where it sits in the frame, except for the pose described. '
124
+ 'THE SILHOUETTE MUST STAY ROUND AND BALL-LIKE, exactly as round as in the reference image: do '
125
+ 'not turn the hood into a pointed hat, a cone or a triangle, and do not stretch the body into '
126
+ 'a long column. Keep BOTH triangular cat ears clearly visible on top of the head, same size and '
127
+ 'same place as in the reference. Keep the body wide and round; only the limbs and the face may '
128
+ 'move. Keep the whole character comfortably inside a centred circle, well away from every edge. '
129
+ 'Transparent background. No shadow, no glow, no floor, no ground, no props, no text, no border. '
130
+ 'Whole body visible. Not sad, not worried, not angry, not sleepy, no tears.'
131
+ )
132
+
133
+
134
+ # ---------------------------------------------------------------- PNG 解码(标准库)
135
+
136
+ def decode_png(path):
137
+ """最小 PNG 解码:只处理 8 位 RGBA、非隔行。返回 (w, h, bytearray)。"""
138
+ with open(path, 'rb') as fh:
139
+ b = fh.read()
140
+ if b[:8] != b'\x89PNG\r\n\x1a\n':
141
+ raise ValueError('not a PNG: %s' % path)
142
+ w = int.from_bytes(b[16:20], 'big')
143
+ h = int.from_bytes(b[20:24], 'big')
144
+ depth, ctype = b[24], b[25]
145
+ if depth != 8 or ctype != 6:
146
+ # 生图通道会返回 RGB(ctype=2)。那不是"透明底没抠干净",是格式不对:
147
+ # 这里替它补一个 alpha 就等于把整张图当成实心方块,必须让它重出。
148
+ raise ValueError('need 8-bit RGBA, got depth=%d ctype=%d: %s' % (depth, ctype, path))
149
+ idat = []
150
+ off = 8
151
+ while off < len(b):
152
+ ln = int.from_bytes(b[off:off + 4], 'big')
153
+ if b[off + 4:off + 8] == b'IDAT':
154
+ idat.append(b[off + 8:off + 8 + ln])
155
+ off += 12 + ln
156
+ raw = zlib.decompress(b''.join(idat))
157
+ stride = w * 4
158
+ out = bytearray(h * stride)
159
+ prev = bytearray(stride)
160
+ pos = 0
161
+ for y in range(h):
162
+ ft = raw[pos]
163
+ pos += 1
164
+ cur = bytearray(raw[pos:pos + stride])
165
+ pos += stride
166
+ if ft == 1:
167
+ for x in range(4, stride):
168
+ cur[x] = (cur[x] + cur[x - 4]) & 255
169
+ elif ft == 2:
170
+ for x in range(stride):
171
+ cur[x] = (cur[x] + prev[x]) & 255
172
+ elif ft == 3:
173
+ for x in range(stride):
174
+ a = cur[x - 4] if x >= 4 else 0
175
+ cur[x] = (cur[x] + ((a + prev[x]) >> 1)) & 255
176
+ elif ft == 4:
177
+ for x in range(stride):
178
+ a = cur[x - 4] if x >= 4 else 0
179
+ up = prev[x]
180
+ c = prev[x - 4] if x >= 4 else 0
181
+ p = a + up - c
182
+ pa, pb, pc = abs(p - a), abs(p - up), abs(p - c)
183
+ cur[x] = (cur[x] + (a if (pa <= pb and pa <= pc) else (up if pb <= pc else c))) & 255
184
+ elif ft != 0:
185
+ raise ValueError('bad PNG filter %d in %s' % (ft, path))
186
+ out[y * stride:(y + 1) * stride] = cur
187
+ prev = cur
188
+ return w, h, out
189
+
190
+
191
+ def metrics(path):
192
+ """量一张帧。距离一律从**画布中心**量,实心一律 alpha>=128。"""
193
+ w, h, buf = decode_png(path)
194
+ solid = []
195
+ for y in range(h):
196
+ base = y * w * 4
197
+ for x in range(w):
198
+ if buf[base + x * 4 + 3] >= ALPHA_SOLID:
199
+ solid.append((x, y))
200
+ if not solid:
201
+ return None
202
+ minx = min(p[0] for p in solid)
203
+ maxx = max(p[0] for p in solid)
204
+ miny = min(p[1] for p in solid)
205
+ maxy = max(p[1] for p in solid)
206
+ rows = {}
207
+ for x, y in solid:
208
+ rows[y] = rows.get(y, 0) + 1
209
+ apex_y = min(y for y, n in rows.items() if n >= 4)
210
+ cx, cy = CANVAS / 2.0, CANVAS / 2.0
211
+ maxd = 0.0
212
+ over_cap = 0
213
+ over_circle = 0
214
+ for x, y in solid:
215
+ d = math.hypot(x - cx, y - cy)
216
+ if d > maxd:
217
+ maxd = d
218
+ if d > CAP:
219
+ over_cap += 1
220
+ if d > CANVAS / 2.0:
221
+ over_circle += 1
222
+ return {
223
+ 'file': os.path.basename(path), 'w': w, 'h': h, 'opaque': len(solid),
224
+ 'bbox_w': maxx - minx + 1, 'bbox_h': maxy - miny + 1,
225
+ 'x': minx, 'y': miny, 'cx': (minx + maxx) / 2.0, 'apex_y': apex_y,
226
+ 'maxd': maxd, 'over_cap': over_cap, 'over_circle': over_circle,
227
+ }
228
+
229
+
230
+ def describe(m):
231
+ return ('%-24s %dx%d solid=%-6d bbox=%dx%d at=%d,%d apex=%d maxDist=%.2f >cap=%d >R=%d' % (
232
+ m['file'], m['w'], m['h'], m['opaque'], m['bbox_w'], m['bbox_h'],
233
+ m['x'], m['y'], m['apex_y'], m['maxd'], m['over_cap'], m['over_circle']))
234
+
235
+
236
+ def expected_scale(name):
237
+ for f, s, _ in SCALES:
238
+ if f == name:
239
+ return s
240
+ return None
241
+
242
+
243
+ ASPECT_DRIFT_TOL = 0.06 # 猫的长宽比相对静止帧允许漂多少(生成期闸门是 4%,这里留余量)
244
+ CAT_W_TOL = 4 # 猫的 bbox 宽相对目标允许差几个像素
245
+
246
+
247
+ def problems(m, cat, ref_cat, target_cat_w):
248
+ """契约检查。cat/ref_cat 为 None 时跳过"猫"那两条。返回挑错清单(空 = 通过)。"""
249
+ if m is None:
250
+ return ['empty image (no solid pixel)']
251
+ bad = []
252
+ if m['w'] != CANVAS or m['h'] != CANVAS:
253
+ bad.append('canvas %dx%d != %dx%d' % (m['w'], m['h'], CANVAS, CANVAS))
254
+ if m['maxd'] > CAP:
255
+ bad.append('maxDist %.2f > cap %.2f' % (m['maxd'], CAP))
256
+ if m['over_circle']:
257
+ bad.append('%d solid pixel(s) outside r=%d -- the ball would lose a chunk'
258
+ % (m['over_circle'], CANVAS // 2))
259
+ if m['opaque'] < 1000:
260
+ bad.append('only %d solid pixels (suspiciously empty)' % m['opaque'])
261
+ if cat is not None and ref_cat is not None and target_cat_w is not None:
262
+ if abs(cat['w'] - target_cat_w) > CAT_W_TOL:
263
+ bad.append('cat width %d not near the target %.1f (+- %d) -- the scale is off'
264
+ % (cat['w'], target_cat_w, CAT_W_TOL))
265
+ drift = cat['aspect'] / ref_cat['aspect'] - 1.0
266
+ if abs(drift) > ASPECT_DRIFT_TOL:
267
+ bad.append('cat aspect drifted %+.1f%% from the resting frame (limit +-%.0f%%)'
268
+ % (drift * 100, ASPECT_DRIFT_TOL * 100))
269
+ return bad
270
+
271
+
272
+ def cmd_check(panel_dir, sample, include_reference):
273
+ base_path = os.path.join(panel_dir, BASE)
274
+ try:
275
+ baseline = metrics(base_path)
276
+ ref_cat = main_component(base_path)
277
+ except (OSError, ValueError) as exc:
278
+ print('cannot read the baseline %s: %s' % (BASE, exc))
279
+ return 1
280
+ print('baseline %s: apex=%d cx=%.1f maxDist=%.2f cat=%dx%d aspect=%.3f' % (
281
+ BASE, baseline['apex_y'], baseline['cx'], baseline['maxd'],
282
+ ref_cat['w'], ref_cat['h'], ref_cat['aspect']))
283
+ targets = []
284
+ if include_reference:
285
+ targets += [os.path.join(panel_dir, f) for f in REFERENCE_FILES]
286
+ targets += [os.path.join(panel_dir, f) for f in FRAME_FILES]
287
+ targets += [os.path.join(panel_dir, f) for f in TICKLE_FILES]
288
+
289
+ targets += [os.path.join(panel_dir, f) for f in BLINK_FILES]
290
+ if sample:
291
+ targets = targets[:sample]
292
+ failed = 0
293
+ for p in targets:
294
+ name = os.path.basename(p)
295
+ if not os.path.exists(p):
296
+ print('MISSING %s' % name)
297
+ failed += 1
298
+ continue
299
+ try:
300
+ m = metrics(p)
301
+ cat = main_component(p)
302
+ except ValueError as exc:
303
+ print('BAD %s (%s)' % (name, exc))
304
+ failed += 1
305
+ continue
306
+ s = expected_scale(name)
307
+ target_w = None if s is None else ref_cat['w'] * s
308
+ bad = problems(m, cat if s is not None else None, ref_cat, target_w)
309
+ if s is not None:
310
+ if abs(m['apex_y'] - baseline['apex_y']) > 2:
311
+ bad.append('apex y %d drifts from the baseline %d by more than 2px'
312
+ % (m['apex_y'], baseline['apex_y']))
313
+ if abs(m['cx'] - baseline['cx']) > 2:
314
+ bad.append('horizontal centre %.1f drifts from the baseline %.1f'
315
+ % (m['cx'], baseline['cx']))
316
+ print(('OK ' if not bad else 'FAIL ') + describe(m) +
317
+ ('' if cat is None else ' cat=%dx%d aspect=%.3f' % (cat['w'], cat['h'], cat['aspect'])))
318
+ for b in bad:
319
+ print(' - %s' % b)
320
+ failed += 1
321
+ print('%d problem(s)' % failed)
322
+ return 1 if failed else 0
323
+
324
+
325
+ def cmd_gen(only):
326
+ try:
327
+ import requests
328
+ except ImportError:
329
+ print('requests is required for --gen (pip install requests)')
330
+ return 2
331
+ key = os.environ.get('IMAGE_API_KEY')
332
+ base_url = os.environ.get('IMAGE_API_BASE')
333
+ if not key or not base_url:
334
+ print('set IMAGE_API_KEY and IMAGE_API_BASE (no default is committed on purpose)')
335
+ return 2
336
+ src = os.path.join(PNG_DIR, 'mascot.png')
337
+ if not os.path.exists(src):
338
+ print('missing the generation base: %s' % src)
339
+ return 2
340
+ if not os.path.isdir(RAW_DIR):
341
+ os.makedirs(RAW_DIR)
342
+ url = base_url.rstrip('/') + '/images/edits'
343
+ rc = 0
344
+ for name, _scale, pose in all_frames():
345
+ if only and name not in only:
346
+ continue
347
+ out = os.path.join(RAW_DIR, name)
348
+ if os.path.exists(out):
349
+ print('SKIP %s (already generated)' % name)
350
+ continue
351
+ prompt = INVARIANT + ' Pose: ' + pose + '.'
352
+ started = time.time()
353
+ print('GEN %s ...' % name, end='')
354
+ sys.stdout.flush()
355
+ try:
356
+ with open(src, 'rb') as fh:
357
+ resp = requests.post(
358
+ url,
359
+ headers={'Authorization': 'Bearer ' + key,
360
+ # 这条通道按 UA 拦截:不带正常 UA 会 403,别把 403 读成"没权限"
361
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
362
+ 'AppleWebKit/537.36 (KHTML, like Gecko) '
363
+ 'Chrome/131.0.0.0 Safari/537.36'},
364
+ files={'image': ('mascot.png', fh, 'image/png')},
365
+ data={'model': MODEL, 'prompt': prompt, 'n': '1'},
366
+ timeout=1800,
367
+ )
368
+ except Exception as exc: # noqa: BLE001 - 网络层什么都可能抛
369
+ print(' request failed: %s' % (type(exc).__name__,))
370
+ rc = 1
371
+ continue
372
+ mins = (time.time() - started) / 60.0
373
+ if resp.status_code != 200:
374
+ print(' HTTP %d after %.1f min: %s' % (resp.status_code, mins, resp.text[:200]))
375
+ rc = 1
376
+ continue
377
+ try:
378
+ payload = resp.json()
379
+ item = (payload.get('data') or [{}])[0]
380
+ except ValueError:
381
+ print(' unparsable body after %.1f min' % mins)
382
+ rc = 1
383
+ continue
384
+ blob = None
385
+ if item.get('b64_json'):
386
+ blob = base64.b64decode(item['b64_json'])
387
+ elif item.get('url'):
388
+ got = requests.get(item['url'], timeout=300,
389
+ headers={'User-Agent': 'Mozilla/5.0'})
390
+ if got.status_code == 200:
391
+ blob = got.content
392
+ if not blob:
393
+ print(' no image in the response after %.1f min: %s' % (mins, json.dumps(item)[:160]))
394
+ rc = 1
395
+ continue
396
+ with open(out, 'wb') as fh:
397
+ fh.write(blob)
398
+ # 只报格式与尺寸,不用 Pillow:raw 是"待归一化"的,这一步只确认拿到了东西
399
+ head = open(out, 'rb').read(26)
400
+ fmt = 'png' if head[:8] == b'\x89PNG\r\n\x1a\n' else 'not-png'
401
+ dims = ''
402
+ if fmt == 'png':
403
+ dims = '%dx%d depth=%d ctype=%d' % (int.from_bytes(head[16:20], 'big'),
404
+ int.from_bytes(head[20:24], 'big'),
405
+ head[24], head[25])
406
+ print(' ok in %.1f min -> %s (%s %s)' % (mins, out, fmt, dims))
407
+ return rc
408
+
409
+
410
+ # ---------------------------------------------------------------- 归一化
411
+
412
+ def blink_band(panel_dir):
413
+ """眨眼带:虹膜层的不透明范围 + 一圈余量。用它把生成的眨眼帧"只取眼睛那块"。"""
414
+ w, h, buf = decode_png(os.path.join(panel_dir, 'mascot-iris.png'))
415
+ xs, ys = [], []
416
+ for y in range(h):
417
+ for x in range(w):
418
+ if buf[(y * w + x) * 4 + 3] >= 8:
419
+ xs.append(x)
420
+ ys.append(y)
421
+ if not xs:
422
+ return None
423
+ # 余量取得不大:带子越大,把生成帧"顺手重画"的部分带进来的越多(那正是要避免的)
424
+ return (max(0, min(xs) - 15), max(0, min(ys) - 12), min(w, max(xs) + 16), min(h, max(ys) + 13))
425
+
426
+
427
+ def composite_blink(blink_path, base_path, band):
428
+ """把眨眼帧的眼睛带贴到静止帧的副本上,写回 blink_path。返回带外不同的像素数(必须是 0)。"""
429
+ from PIL import Image
430
+ band_img = Image.open(blink_path).convert('RGBA')
431
+ base = Image.open(base_path).convert('RGBA')
432
+ patch = band_img.crop(band)
433
+ base.paste(patch, (band[0], band[1]), patch)
434
+ base.save(blink_path)
435
+ a = Image.open(blink_path).convert('RGBA')
436
+ b = Image.open(base_path).convert('RGBA')
437
+ pa, pb = a.load(), b.load()
438
+ outside = 0
439
+ for y in range(a.size[1]):
440
+ if band[1] <= y < band[3]:
441
+ continue
442
+ for x in range(a.size[0]):
443
+ if pa[x, y] != pb[x, y]:
444
+ outside += 1
445
+ for y in range(band[1], band[3]):
446
+ for x in range(a.size[0]):
447
+ if not (band[0] <= x < band[2]) and pa[x, y] != pb[x, y]:
448
+ outside += 1
449
+ return outside
450
+
451
+
452
+ def cmd_normalize(panel_dir, raw_dir):
453
+ """按**猫自己**(最大连通域)的 bbox 宽缩放,头顶对齐静止帧,水平居中。
454
+
455
+ 缩放基准是猫的宽度而不是整张内容:画布上还有旁白气泡与惊叹号,按整张缩放会让装饰件
456
+ 决定角色的比例,逐帧就跳。但**硬契约优先** —— 某帧整体更宽时,按猫宽缩放会把整张内容
457
+ 顶出圆(实测 down-5 的 maxDist 到过 127.88,上限 125.44)。那种情况就**回退这一帧的
458
+ 缩放**直到不出圆;猫宽那 ±4 的余量足够吸收(实测回退约 2%)。宁可让这一帧比设计值小一点点,
459
+ 也不能让球被圆裁掉一块 —— 后者是看得见的坏。
460
+ """
461
+ try:
462
+ from PIL import Image
463
+ except ImportError:
464
+ print('Pillow is required for --normalize (pip install pillow); '
465
+ '--check needs only the stdlib')
466
+ return 2
467
+ base_path = os.path.join(panel_dir, BASE)
468
+ baseline = metrics(base_path) # 静止帧不进归一化,只当锚点与比例基准
469
+ ref_cat = main_component(base_path)
470
+ if ref_cat is None:
471
+ print('cannot measure the resting cat')
472
+ return 1
473
+ rc = 0
474
+ for name, scale, _pose in all_frames():
475
+ raw = os.path.join(raw_dir, name)
476
+ if not os.path.exists(raw):
477
+ print('MISSING %s (run --gen first)' % raw)
478
+ rc = 1
479
+ continue
480
+ cat = main_component(raw)
481
+ if cat is None:
482
+ print('EMPTY %s' % name)
483
+ rc = 1
484
+ continue
485
+ target_w = ref_cat['w'] * scale
486
+ im = Image.open(raw).convert('RGBA')
487
+ alpha = im.getchannel('A').point(lambda v: 255 if v >= ALPHA_SOLID else 0)
488
+ box = alpha.getbbox()
489
+ if box is None:
490
+ print('EMPTY %s' % name)
491
+ rc = 1
492
+ continue
493
+ crop = im.crop(box)
494
+ k = target_w / float(cat['w']) # 猫是内容的一部分,所以同一个 k 也缩放了猫
495
+ out = os.path.join(panel_dir, name)
496
+ m2 = None
497
+ for attempt in range(10):
498
+ new_w = max(1, int(round(crop.width * k)))
499
+ new_h = max(1, int(round(crop.height * k)))
500
+ resized = crop.resize((new_w, new_h), Image.LANCZOS)
501
+ canvas = Image.new('RGBA', (CANVAS, CANVAS), (0, 0, 0, 0))
502
+ apex_row = _apex_row(resized)
503
+ left = int(round(CANVAS / 2.0 - new_w / 2.0))
504
+ top = int(round(baseline['apex_y'] - apex_row))
505
+ canvas.paste(resized, (left, top), resized)
506
+ canvas.save(out)
507
+ m2 = metrics(out)
508
+ if m2 is None or m2['maxd'] <= CAP - 0.2:
509
+ break
510
+ if attempt == 5:
511
+ break
512
+ print(' note: %s would exceed the cap (maxDist %.2f) -- backing the scale off'
513
+ % (name, m2['maxd']))
514
+ # 固定比例回退(用 maxDist 反推时收敛太慢:实测每步只掉 0.6%)
515
+ k *= 0.94
516
+ if name in BLINK_FILES:
517
+ # 眨眼帧只取眼睛那块,其余用静止帧(见 composite_blink 的说明)
518
+ band = blink_band(panel_dir)
519
+ if band is None:
520
+ print(' note: cannot find the eye band; leaving %s as generated' % name)
521
+ else:
522
+ stray = composite_blink(out, base_path, band)
523
+ print(' note: %s -> eye band %s, %d pixel(s) differ outside it' % (name, band, stray))
524
+ if stray:
525
+ print(' !! %s 的眼睛带之外动了 %d 个像素 —— 那会让整颗球闪一下' % (name, stray))
526
+
527
+ cat2 = main_component(out)
528
+ bad = problems(m2, cat2, ref_cat, target_w)
529
+ if m2 is not None:
530
+ if abs(m2['apex_y'] - baseline['apex_y']) > 2:
531
+ bad.append('apex y %d vs baseline %d' % (m2['apex_y'], baseline['apex_y']))
532
+ if abs(m2['cx'] - baseline['cx']) > 2:
533
+ bad.append('centre x %.1f vs baseline %.1f' % (m2['cx'], baseline['cx']))
534
+ print(('OK ' if not bad else 'FAIL ') + describe(m2) +
535
+ ('' if cat2 is None else ' cat=%dx%d aspect=%.3f' % (cat2['w'], cat2['h'], cat2['aspect'])))
536
+ for b in bad:
537
+ print(' - %s' % b)
538
+ rc = 1
539
+ return rc
540
+
541
+
542
+ def _apex_row(img):
543
+ """最上面那行"实心像素 >= 4"的相对 y(抗单像素噪点)。"""
544
+ w, h = img.size
545
+ px = img.load()
546
+ for y in range(h):
547
+ n = 0
548
+ for x in range(w):
549
+ if px[x, y][3] >= ALPHA_SOLID:
550
+ n += 1
551
+ if n >= 4:
552
+ return y
553
+ return 0
554
+
555
+
556
+ # ---------------------------------------------------------------- 轮廓漂移闸门
557
+
558
+ # 生图模型每帧独立出图,最容易翻的车是**悄悄改了轮廓**:第一版里"被拎起"写成尖顶斗篷、
559
+ # 耳朵也没了,肉眼看还觉得"挺可爱",而猫的 bbox 长宽比已经掉到 0.650(静止帧 0.794)。
560
+ # 那种帧贴到球上就不再是同一只猫,而所有几何断言照样全绿(它确实在圆里)。
561
+ # 所以这里按**最大连通域**(= 猫本身,不含旁白气泡与惊叹号)的长宽比设一道闸门。
562
+ ASPECT_TOL = 0.04 # 与静止帧相比,猫的长宽比允许漂移多少
563
+
564
+
565
+ def main_component(path):
566
+ """最大连通域(4 邻域)的 bbox 与长宽比。按行扫描 + 并查集:1254^2 也很快。"""
567
+ w, h, buf = decode_png(path)
568
+ parent = []
569
+
570
+ def find(a):
571
+ while parent[a] != a:
572
+ parent[a] = parent[parent[a]]
573
+ a = parent[a]
574
+ return a
575
+
576
+ def union(a, b):
577
+ ra, rb = find(a), find(b)
578
+ if ra != rb:
579
+ parent[rb] = ra
580
+
581
+ runs = [] # 每行: [ (x0,x1,idx), ... ]
582
+ stats = [] # 每个 run 所属集合的累计 (area, minx, maxx, miny, maxy)
583
+ for y in range(h):
584
+ base = y * w * 4
585
+ row = []
586
+ x = 0
587
+ while x < w:
588
+ if buf[base + x * 4 + 3] >= ALPHA_SOLID:
589
+ x0 = x
590
+ while x < w and buf[base + x * 4 + 3] >= ALPHA_SOLID:
591
+ x += 1
592
+ idx = len(parent)
593
+ parent.append(idx)
594
+ stats.append([x - x0, x0, x - 1, y, y])
595
+ for px0, px1, pidx in (runs[-1] if runs else []):
596
+ if px1 >= x0 and px0 <= x - 1:
597
+ union(idx, pidx)
598
+ row.append((x0, x - 1, idx))
599
+ else:
600
+ x += 1
601
+ runs.append(row)
602
+ best = None
603
+ for idx in range(len(parent)):
604
+ root = find(idx)
605
+ if root == idx:
606
+ continue
607
+ t = stats[root]
608
+ c = stats[idx]
609
+ t[0] += c[0]
610
+ if c[1] < t[1]: t[1] = c[1]
611
+ if c[2] > t[2]: t[2] = c[2]
612
+ if c[3] < t[3]: t[3] = c[3]
613
+ if c[4] > t[4]: t[4] = c[4]
614
+ for idx in range(len(parent)):
615
+ if find(idx) != idx:
616
+ continue
617
+ area, minx, maxx, miny, maxy = stats[idx]
618
+ bw, bh = maxx - minx + 1, maxy - miny + 1
619
+ if best is None or area > best[0]:
620
+ best = (area, bw, bh, bw / float(bh))
621
+ if best is None:
622
+ return None
623
+ return {'area': best[0], 'w': best[1], 'h': best[2], 'aspect': best[3]}
624
+
625
+
626
+ def cmd_check_raw(panel_dir, raw_dir):
627
+ """原始帧的轮廓漂移:与静止帧比最大连通域的长宽比。"""
628
+ ref = main_component(os.path.join(panel_dir, BASE))
629
+ if ref is None:
630
+ print('cannot measure the baseline')
631
+ return 1
632
+ print('baseline %s: cat %dx%d aspect %.3f' % (BASE, ref['w'], ref['h'], ref['aspect']))
633
+ failed = 0
634
+ for name, _s, _p in all_frames():
635
+ raw = os.path.join(raw_dir, name)
636
+ if not os.path.exists(raw):
637
+ print('MISSING %s' % name)
638
+ failed += 1
639
+ continue
640
+ m = main_component(raw)
641
+ if m is None:
642
+ print('EMPTY %s' % name)
643
+ failed += 1
644
+ continue
645
+ drift = m['aspect'] / ref['aspect'] - 1.0
646
+ ok = abs(drift) <= ASPECT_TOL
647
+ print('%s %-24s cat %dx%d aspect %.3f drift %+.1f%%' % (
648
+ 'OK ' if ok else 'OFF-MODEL', name, m['w'], m['h'], m['aspect'], drift * 100))
649
+ if not ok:
650
+ failed += 1
651
+ print('%d frame(s) drifted beyond +-%.0f%%' % (failed, ASPECT_TOL * 100))
652
+ return 1 if failed else 0
653
+
654
+
655
+ def main(argv):
656
+ ap = argparse.ArgumentParser(description='panel mascot lift frames')
657
+ ap.add_argument('--gen', action='store_true', help='generate raw frames from the image model')
658
+ ap.add_argument('--only', nargs='+', metavar='FILE', help='with --gen: only these frame files')
659
+ ap.add_argument('--normalize', action='store_true', help='normalize raw frames into place')
660
+ ap.add_argument('--check', action='store_true', help='check shipped frames against the contract')
661
+ ap.add_argument('--check-raw', action='store_true',
662
+ help='measure silhouette drift of the RAW frames against the resting frame')
663
+ ap.add_argument('--panel-dir', default=PNG_DIR)
664
+ ap.add_argument('--raw-dir', default=RAW_DIR)
665
+ ap.add_argument('--include-reference', action='store_true',
666
+ help='with --check: also check the existing expression frames')
667
+ ap.add_argument('--sample', type=int, default=0, help='with --check: only the first N targets')
668
+ args = ap.parse_args(argv)
669
+
670
+ if args.gen:
671
+ return cmd_gen(set(args.only or []))
672
+ if args.normalize:
673
+ return cmd_normalize(args.panel_dir, args.raw_dir)
674
+ if args.check_raw:
675
+ return cmd_check_raw(args.panel_dir, args.raw_dir)
676
+ if args.check or not argv:
677
+ return cmd_check(args.panel_dir, args.sample, args.include_reference)
678
+ ap.print_help()
679
+ return 0
680
+
681
+
682
+ if __name__ == '__main__':
683
+ sys.exit(main(sys.argv[1:]))