arona-agent 1.0.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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/assets/blue-archive/arona/spine/arona_spr.atlas.txt +1168 -0
- package/assets/blue-archive/arona/spine/arona_spr.png +0 -0
- package/assets/blue-archive/arona/spine/arona_spr.skel +0 -0
- package/assets/blue-archive/arona/voice.mp3 +0 -0
- package/assets/blue-archive/hoshino/hoshino_spr.atlas.txt +174 -0
- package/assets/blue-archive/hoshino/hoshino_spr.json +15385 -0
- package/assets/blue-archive/hoshino/hoshino_spr.png +0 -0
- package/assets/blue-archive/hoshino/hoshino_spr.skel +0 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.atlas.txt +111 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.png +0 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.skel +0 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.atlas.txt +251 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.png +0 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.skel +0 -0
- package/assets/blue-archive/hoshino/voice.mp3 +0 -0
- package/assets/blue-archive/plana/spine/plana_spr.atlas.txt +1266 -0
- package/assets/blue-archive/plana/spine/plana_spr.png +0 -0
- package/assets/blue-archive/plana/spine/plana_spr.skel +0 -0
- package/assets/blue-archive/plana/voice.mp3 +0 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.atlas.txt +97 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.png +0 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.skel +0 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.atlas.txt +174 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.png +0 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.skel +0 -0
- package/assets/blue-archive/shiroko/shiroko_spr.atlas.txt +167 -0
- package/assets/blue-archive/shiroko/shiroko_spr.json +10702 -0
- package/assets/blue-archive/shiroko/shiroko_spr.png +0 -0
- package/assets/blue-archive/shiroko/shiroko_spr.skel +0 -0
- package/assets/blue-archive/shiroko/voice.mp3 +0 -0
- package/bin/arona.mjs +43 -0
- package/package.json +37 -0
- package/pet/agents.cjs +197 -0
- package/pet/main.cjs +462 -0
- package/pet/preload.cjs +21 -0
- package/pet/renderer/fx.html +30 -0
- package/pet/renderer/fx.js +41 -0
- package/pet/renderer/gallery.html +18 -0
- package/pet/renderer/gallery.js +31 -0
- package/pet/renderer/index.html +18 -0
- package/pet/renderer/renderer.js +382 -0
- package/pet/renderer/spine_layer.js +609 -0
- package/pet/renderer/spinetest.html +24 -0
- package/pet/renderer/spinetest.js +295 -0
- package/pet/renderer/style.css +63 -0
- package/pet/tools/gallery_capture.cjs +267 -0
- package/pet/tools/gen_sway.cjs +170 -0
- package/pet/tools/meshify_region.cjs +105 -0
- package/pet/tools/skel_inspect.cjs +112 -0
- package/pet/tools/skel_to_json.cjs +410 -0
- package/pet/tools/spine_node.cjs +69 -0
- package/pet/tools/visual_test.cjs +262 -0
- package/pet/vendor/spine/spine-canvas.js +8472 -0
- package/pet/vendor/spine/spine-webgl.js +10839 -0
- package/python/__pycache__/_i18n.cpython-313.pyc +0 -0
- package/python/__pycache__/_i18n.cpython-314.pyc +0 -0
- package/python/__pycache__/computer_use.cpython-314.pyc +0 -0
- package/python/__pycache__/hotkey.cpython-313.pyc +0 -0
- package/python/__pycache__/hotkey.cpython-314.pyc +0 -0
- package/python/__pycache__/stt.cpython-313.pyc +0 -0
- package/python/__pycache__/stt.cpython-314.pyc +0 -0
- package/python/__pycache__/tts.cpython-313.pyc +0 -0
- package/python/__pycache__/tts.cpython-314.pyc +0 -0
- package/python/__pycache__/tts_stream.cpython-313.pyc +0 -0
- package/python/__pycache__/tts_stream.cpython-314.pyc +0 -0
- package/python/__pycache__/voice_clone.cpython-314.pyc +0 -0
- package/python/_i18n.py +18 -0
- package/python/computer_use.py +190 -0
- package/python/hotkey.py +196 -0
- package/python/stt.py +240 -0
- package/python/tts_say.py +177 -0
- package/python/voice_clone.py +98 -0
- package/requirements.txt +6 -0
- package/src/agent.ts +791 -0
- package/src/agent_registry.ts +109 -0
- package/src/commands.ts +641 -0
- package/src/config.ts +245 -0
- package/src/in_memory_credentials.ts +61 -0
- package/src/index.ts +78 -0
- package/src/locale.ts +131 -0
- package/src/logo.ts +120 -0
- package/src/mcp.ts +192 -0
- package/src/memory.ts +303 -0
- package/src/pet.ts +346 -0
- package/src/renderer.ts +350 -0
- package/src/repl.ts +902 -0
- package/src/setup.ts +371 -0
- package/src/skills.ts +96 -0
- package/src/slash_menu.ts +371 -0
- package/src/slash_registry.ts +53 -0
- package/src/speaker_context.ts +41 -0
- package/src/text_split.ts +79 -0
- package/src/tools/computer_use.ts +142 -0
- package/src/tools/emotion_tool.ts +55 -0
- package/src/tools/keep_silent_tool.ts +26 -0
- package/src/tools/memory_tool.ts +24 -0
- package/src/tools/skill_tools.ts +100 -0
- package/src/tools/tavily_tools.ts +431 -0
- package/src/tools/voice_tools.ts +28 -0
- package/src/tts_stream.ts +260 -0
- package/src/tui_select.ts +146 -0
- package/src/undo.ts +451 -0
- package/src/utils/python.ts +211 -0
- package/src/utils/spawn.ts +56 -0
- package/src/voice.ts +83 -0
- package/src/voice_cli.ts +198 -0
- package/src/voices.ts +135 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// ARONA Spine 调试页(spinetest):加载 skel/atlas/png → 交互预览(事实转储到 console)。
|
|
2
|
+
// 用途(spike 遗留):
|
|
3
|
+
// 1) Head_Rot 手动设 rotation 是否只转头
|
|
4
|
+
// 2) A/M/Dev 各变体动画语义
|
|
5
|
+
// 3) Look/LookEnd 起止姿势
|
|
6
|
+
// 4) spine-webgl 在透明窗的实际合成效果(?pm=0 可切换非 premultiplied 路径对比)
|
|
7
|
+
// 键盘:←/→ 手动转 Head_Rot;↑/↓ 循环动画;1~9/0/a~z 直接跳转到第 N 个动画。
|
|
8
|
+
const SPINE_BASE = "../../assets/blue-archive/arona/spine/";
|
|
9
|
+
const canvas = document.getElementById("spine");
|
|
10
|
+
const hud = document.getElementById("hud");
|
|
11
|
+
const params = new URLSearchParams(location.search);
|
|
12
|
+
|
|
13
|
+
const USE_PM = params.get("pm") !== "0"; // 默认 premultiplied 路径
|
|
14
|
+
console.log("[spinetest] URL", location.href, "| search", location.search);
|
|
15
|
+
|
|
16
|
+
let gl = null;
|
|
17
|
+
let assetManager = null;
|
|
18
|
+
let skeleton = null;
|
|
19
|
+
let state = null;
|
|
20
|
+
let renderer = null;
|
|
21
|
+
let headBone = null;
|
|
22
|
+
let headRestRot = 0;
|
|
23
|
+
let manualHeadRot = 0;
|
|
24
|
+
let isoSet = null;
|
|
25
|
+
let animIndex = 0;
|
|
26
|
+
let fps = 0;
|
|
27
|
+
let fit = null; // { offset:{x,y}, size:{x,y}, scale }
|
|
28
|
+
|
|
29
|
+
function log(...args) {
|
|
30
|
+
console.log("[spinetest]", ...args);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---- 加载 ----
|
|
34
|
+
async function load() {
|
|
35
|
+
gl = canvas.getContext("webgl", {
|
|
36
|
+
alpha: true,
|
|
37
|
+
premultipliedAlpha: USE_PM,
|
|
38
|
+
antialias: true,
|
|
39
|
+
preserveDrawingBuffer: false,
|
|
40
|
+
});
|
|
41
|
+
if (!gl) {
|
|
42
|
+
hud.textContent = "WebGL 不可用";
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
assetManager = new spine.webgl.AssetManager(gl, SPINE_BASE);
|
|
46
|
+
if (USE_PM) {
|
|
47
|
+
// 纹理上传时预乘(UNPACK_PREMULTIPLY_ALPHA_WEBGL),与 drawSkeleton(skeleton, true) 配套
|
|
48
|
+
// 本 vendored 3.8 构建 GLTexture 无 premultiply 参数,须上传前手动设 pixelStorei
|
|
49
|
+
assetManager.textureLoader = (image) => {
|
|
50
|
+
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
|
|
51
|
+
const tex = new spine.webgl.GLTexture(gl, image, false);
|
|
52
|
+
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
|
53
|
+
return tex;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
assetManager.loadBinary("arona_spr.skel");
|
|
57
|
+
assetManager.loadTextureAtlas("arona_spr.atlas.txt");
|
|
58
|
+
|
|
59
|
+
const ok = await waitLoad();
|
|
60
|
+
if (!ok) {
|
|
61
|
+
hud.textContent = "加载失败:\n" + Object.keys(assetManager.errors).map((k) => k + ": " + assetManager.errors[k]).join("\n");
|
|
62
|
+
console.error("加载失败", assetManager.errors);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const atlas = assetManager.get("arona_spr.atlas.txt");
|
|
67
|
+
const atlasLoader = new spine.AtlasAttachmentLoader(atlas);
|
|
68
|
+
const binary = new spine.SkeletonBinary(atlasLoader);
|
|
69
|
+
const skelBytes = assetManager.get("arona_spr.skel");
|
|
70
|
+
let skelData = null;
|
|
71
|
+
try {
|
|
72
|
+
skelData = binary.readSkeletonData(skelBytes);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
console.error("readSkeletonData 失败:", e.message, "\n", e.stack);
|
|
75
|
+
hud.textContent = "解析失败: " + e.message;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
skeleton = new spine.Skeleton(skelData);
|
|
80
|
+
skeleton.setToSetupPose();
|
|
81
|
+
skeleton.updateWorldTransform();
|
|
82
|
+
headBone = skeleton.findBone("Head_Rot");
|
|
83
|
+
headRestRot = headBone ? headBone.data.rotation : 0;
|
|
84
|
+
|
|
85
|
+
state = new spine.AnimationState(new spine.AnimationStateData(skelData));
|
|
86
|
+
// 基础轨道固定 Idle_01(setup pose 是瘫开的折叠姿势,不能直接显示)
|
|
87
|
+
state.setAnimation(0, "Idle_01", true);
|
|
88
|
+
animIndex = skelData.animations.findIndex((a) => a.name === "Idle_01");
|
|
89
|
+
|
|
90
|
+
renderer = new spine.webgl.SceneRenderer(canvas, gl, true);
|
|
91
|
+
resize();
|
|
92
|
+
fitCamera();
|
|
93
|
+
|
|
94
|
+
dumpFacts();
|
|
95
|
+
requestAnimationFrame(loop);
|
|
96
|
+
window.addEventListener("keydown", onKey);
|
|
97
|
+
window.addEventListener("resize", resize);
|
|
98
|
+
window.addEventListener("mousemove", onMouseMove);
|
|
99
|
+
updateHud();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function waitLoad() {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
const t0 = performance.now();
|
|
105
|
+
const poll = () => {
|
|
106
|
+
if (assetManager.isLoadingComplete()) return resolve(Object.keys(assetManager.errors).length === 0);
|
|
107
|
+
if (performance.now() - t0 > 8000) return resolve(false);
|
|
108
|
+
requestAnimationFrame(poll);
|
|
109
|
+
};
|
|
110
|
+
requestAnimationFrame(poll);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- 尺寸与相机拟合 ----
|
|
115
|
+
function resize() {
|
|
116
|
+
const dpr = window.devicePixelRatio || 1;
|
|
117
|
+
const w = canvas.clientWidth || 320;
|
|
118
|
+
const h = canvas.clientHeight || 674;
|
|
119
|
+
canvas.width = Math.round(w * dpr);
|
|
120
|
+
canvas.height = Math.round(h * dpr);
|
|
121
|
+
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 以 setup pose 包围盒为中心,等比缩放填满画布(CSS px 世界单位;DPR 由 backing store 处理)
|
|
125
|
+
function fitCamera() {
|
|
126
|
+
const offset = new spine.Vector2();
|
|
127
|
+
const size = new spine.Vector2();
|
|
128
|
+
skeleton.getBounds(offset, size, []);
|
|
129
|
+
const pad = 8;
|
|
130
|
+
const cw = canvas.clientWidth || 320;
|
|
131
|
+
const ch = canvas.clientHeight || 674;
|
|
132
|
+
const scale = Math.min(cw / (size.x + pad * 2), ch / (size.y + pad * 2));
|
|
133
|
+
renderer.camera.setViewport(cw / scale, ch / scale);
|
|
134
|
+
renderer.camera.position.set(offset.x + size.x / 2, offset.y + size.y / 2, 0);
|
|
135
|
+
fit = { offset: { x: offset.x, y: offset.y }, size: { x: size.x, y: size.y }, scale };
|
|
136
|
+
log("包围盒", JSON.stringify(fit));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 窗口 CSS px → 骨架世界坐标(逆相机变换)
|
|
140
|
+
function windowToSkeleton(x, y) {
|
|
141
|
+
const cw = canvas.clientWidth || 320;
|
|
142
|
+
const ch = canvas.clientHeight || 674;
|
|
143
|
+
const p = new spine.webgl.Vector3(x, y, 0);
|
|
144
|
+
renderer.camera.screenToWorld(p, cw, ch);
|
|
145
|
+
return { x: p.x, y: p.y };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---- 事实清单转储 ----
|
|
149
|
+
function dumpFacts() {
|
|
150
|
+
const sd = skeleton.data;
|
|
151
|
+
const TL = spine.TimelineType;
|
|
152
|
+
const typeName = (t) => {
|
|
153
|
+
const id = (t.getPropertyId() >> 24) & 0xff;
|
|
154
|
+
for (const k in TL) if (TL[k] === id) return k;
|
|
155
|
+
return "?";
|
|
156
|
+
};
|
|
157
|
+
const anims = sd.animations.map((a) => {
|
|
158
|
+
const types = {};
|
|
159
|
+
const keyedBones = new Set();
|
|
160
|
+
const keyedSlots = new Set();
|
|
161
|
+
const attachments = [];
|
|
162
|
+
for (const tl of a.timelines) {
|
|
163
|
+
types[typeName(tl)] = (types[typeName(tl)] || 0) + 1;
|
|
164
|
+
if (tl.boneIndex != null) keyedBones.add(sd.bones[tl.boneIndex].name);
|
|
165
|
+
if (tl.slotIndex != null) keyedSlots.add(sd.slots[tl.slotIndex].name);
|
|
166
|
+
if (typeName(tl) === "attachment") {
|
|
167
|
+
// 列出该 timeline 里的 attachment 帧名(含眼部 slot 切换;帧名存于 tl.attachmentNames)
|
|
168
|
+
const names = [];
|
|
169
|
+
for (let i = 0; i < tl.frames.length; i++) {
|
|
170
|
+
names.push(sd.slots[tl.slotIndex].name + ":" + (tl.attachmentNames[i] == null ? "∅" : tl.attachmentNames[i]));
|
|
171
|
+
}
|
|
172
|
+
attachments.push(names.join(","));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
name: a.name,
|
|
177
|
+
duration: +a.duration.toFixed(3),
|
|
178
|
+
timelines: a.timelines.length,
|
|
179
|
+
types,
|
|
180
|
+
keyedBones: [...keyedBones],
|
|
181
|
+
keyedSlots: [...keyedSlots],
|
|
182
|
+
attachmentFrames: attachments,
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
// Point attachment(动画师定义的触摸命中点)
|
|
186
|
+
const points = [];
|
|
187
|
+
for (const skin of sd.skins) {
|
|
188
|
+
for (const slotName in skin.attachments) {
|
|
189
|
+
for (const name in skin.attachments[slotName]) {
|
|
190
|
+
const at = skin.attachments[slotName][name];
|
|
191
|
+
if (at instanceof spine.PointAttachment) {
|
|
192
|
+
points.push({ skin: skin.name, slot: slotName, name, x: +at.x.toFixed(1), y: +at.y.toFixed(1), rotation: at.rotation });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const facts = {
|
|
198
|
+
version: sd.version,
|
|
199
|
+
hash: sd.hash,
|
|
200
|
+
width: sd.width,
|
|
201
|
+
height: sd.height,
|
|
202
|
+
bones: sd.bones.map((b) => ({
|
|
203
|
+
name: b.name,
|
|
204
|
+
parent: b.parent ? b.parent.name : null,
|
|
205
|
+
rotation: +b.rotation.toFixed(2),
|
|
206
|
+
x: +b.x.toFixed(1),
|
|
207
|
+
y: +b.y.toFixed(1),
|
|
208
|
+
length: +b.length.toFixed(1),
|
|
209
|
+
transformMode: b.transformMode,
|
|
210
|
+
})),
|
|
211
|
+
slots: sd.slots.map((s) => ({ name: s.name, bone: s.boneData.name, attachment: s.attachmentName })),
|
|
212
|
+
skins: sd.skins.map((s) => s.name),
|
|
213
|
+
animations: anims,
|
|
214
|
+
pointAttachments: points,
|
|
215
|
+
events: sd.events.map((e) => e.name),
|
|
216
|
+
ikConstraints: sd.ikConstraints.map((c) => c.name),
|
|
217
|
+
fit,
|
|
218
|
+
headBone: headBone
|
|
219
|
+
? { name: headBone.data.name, parent: headBone.parent ? headBone.parent.data.name : null, restRot: headRestRot, world: { x: +headBone.x.toFixed(1), y: +headBone.y.toFixed(1) } }
|
|
220
|
+
: null,
|
|
221
|
+
};
|
|
222
|
+
log("FACTS", JSON.stringify(facts));
|
|
223
|
+
log("动画数", sd.animations.length, "骨骼数", sd.bones.length, "Point 附件数", points.length);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---- 渲染循环 ----
|
|
227
|
+
let lastT = performance.now();
|
|
228
|
+
function loop(now) {
|
|
229
|
+
const dt = Math.min(0.1, Math.max(0, (now - lastT) / 1000));
|
|
230
|
+
lastT = now;
|
|
231
|
+
fps = fps * 0.9 + (1000 / Math.max(1, now - (loop._lastNow || now))) * 0.1;
|
|
232
|
+
loop._lastNow = now;
|
|
233
|
+
|
|
234
|
+
state.update(dt);
|
|
235
|
+
state.apply(skeleton);
|
|
236
|
+
// 手动 Head_Rot 覆盖:apply 之后、updateWorldTransform 之前(spike 验证签名)
|
|
237
|
+
if (headBone) headBone.rotation = headRestRot + manualHeadRot;
|
|
238
|
+
// 隔离渲染覆盖(每帧重施加,因 state.apply 会重置 slot 颜色)
|
|
239
|
+
if (isoSet) for (const s of skeleton.slots) s.color.a = isoSet.has(s.data.name) ? 1 : 0;
|
|
240
|
+
skeleton.updateWorldTransform();
|
|
241
|
+
|
|
242
|
+
gl.clearColor(0, 0, 0, 0);
|
|
243
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
244
|
+
renderer.begin();
|
|
245
|
+
renderer.drawSkeleton(skeleton, USE_PM);
|
|
246
|
+
renderer.end();
|
|
247
|
+
requestAnimationFrame(loop);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---- 交互 ----
|
|
251
|
+
function onKey(e) {
|
|
252
|
+
const anims = skeleton.data.animations;
|
|
253
|
+
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
|
254
|
+
const step = e.key === "ArrowLeft" ? -5 : 5;
|
|
255
|
+
manualHeadRot = Math.max(-35, Math.min(35, manualHeadRot + step));
|
|
256
|
+
log("Head_Rot 手动偏移", manualHeadRot + "°");
|
|
257
|
+
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
|
258
|
+
animIndex = (animIndex + (e.key === "ArrowUp" ? 1 : -1) + anims.length) % anims.length;
|
|
259
|
+
state.setAnimation(0, anims[animIndex].name, true);
|
|
260
|
+
log("动画", anims[animIndex].name);
|
|
261
|
+
} else if (/^[0-9a-z]$/i.test(e.key)) {
|
|
262
|
+
const idx = e.key >= "0" && e.key <= "9" ? (e.key === "0" ? 9 : +e.key - 1) : 10 + (e.key.toLowerCase().charCodeAt(0) - 97);
|
|
263
|
+
if (idx < anims.length) {
|
|
264
|
+
animIndex = idx;
|
|
265
|
+
state.setAnimation(0, anims[animIndex].name, true);
|
|
266
|
+
log("动画", anims[animIndex].name);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
updateHud();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 鼠标位置 → 骨架坐标,实时打印(验证 windowToSkeleton / 头部命中换算)
|
|
273
|
+
function onMouseMove(e) {
|
|
274
|
+
const p = windowToSkeleton(e.clientX, e.clientY);
|
|
275
|
+
if (headBone) {
|
|
276
|
+
const dx = p.x - headBone.x;
|
|
277
|
+
const dy = p.y - headBone.y;
|
|
278
|
+
const deg = Math.atan2(dy, dx) * spine.MathUtils.radDeg;
|
|
279
|
+
hud.dataset.head = `光标→骨架 (${p.x.toFixed(0)},${p.y.toFixed(0)}) 头骨(${headBone.x.toFixed(0)},${headBone.y.toFixed(0)}) 夹角 ${deg.toFixed(0)}°`;
|
|
280
|
+
}
|
|
281
|
+
updateHud();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function updateHud() {
|
|
285
|
+
const anim = state.tracks[0] ? state.tracks[0].animation.name : "-";
|
|
286
|
+
hud.textContent =
|
|
287
|
+
`动画[${animIndex}] ${anim} 循环=${state.tracks[0] ? state.tracks[0].loop : "-"}\n` +
|
|
288
|
+
`Head_Rot 手动 ${manualHeadRot}° 渲染=${USE_PM ? "premultiplied" : "straight"} fps≈${fps.toFixed(0)}\n` +
|
|
289
|
+
`骨架 ${skeleton.data.animations.length} 动画 / ${skeleton.data.bones.length} 骨骼\n` +
|
|
290
|
+
`包围盒 ${fit ? `offset(${fit.offset.x.toFixed(0)},${fit.offset.y.toFixed(0)}) size(${fit.size.x.toFixed(0)},${fit.size.y.toFixed(0)}) scale=${fit.scale.toFixed(3)}` : "-"}\n` +
|
|
291
|
+
(hud.dataset.head || "") +
|
|
292
|
+
`\n按键:←→ 转 Head_Rot · ↑↓/数字/字母 切动画`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
load();
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
html, body {
|
|
2
|
+
margin: 0;
|
|
3
|
+
padding: 0;
|
|
4
|
+
width: 100%;
|
|
5
|
+
height: 100%;
|
|
6
|
+
background: transparent;
|
|
7
|
+
overflow: hidden;
|
|
8
|
+
user-select: none;
|
|
9
|
+
-webkit-user-select: none;
|
|
10
|
+
cursor: grab;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
body:active {
|
|
14
|
+
cursor: grabbing;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#stage {
|
|
18
|
+
position: relative;
|
|
19
|
+
width: 100%;
|
|
20
|
+
height: 100%;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* Spine 骨骼动画基底:z1 常驻、永不隐藏(情绪由 track4 数字预设切换,无 DOM 层)。
|
|
24
|
+
固定在窗口左侧 320px 渲染区(fitCamera 读 canvas.clientWidth 仍按 320 拟合,角色位置/大小不变) */
|
|
25
|
+
#spine {
|
|
26
|
+
position: absolute;
|
|
27
|
+
left: 0;
|
|
28
|
+
top: 0;
|
|
29
|
+
width: 320px;
|
|
30
|
+
height: 100%;
|
|
31
|
+
pointer-events: none;
|
|
32
|
+
z-index: 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
/* 桌宠文字气泡:定位到角色头部右侧,贴近角色(不遮脸、不高高飘在右上);
|
|
37
|
+
top 取头部高度(头 CSS y 40~195),left 紧邻渲染区右缘(320 + 10px 间距);
|
|
38
|
+
高于 Spine、鼠标穿透;只显示 mid/final 短消息 */
|
|
39
|
+
#pet-bubble {
|
|
40
|
+
position: absolute;
|
|
41
|
+
left: 310px;
|
|
42
|
+
top: 90px;
|
|
43
|
+
transform: none;
|
|
44
|
+
max-width: 220px;
|
|
45
|
+
padding: 8px 12px;
|
|
46
|
+
box-sizing: border-box;
|
|
47
|
+
background: rgba(20, 22, 34, 0.82);
|
|
48
|
+
border-radius: 14px;
|
|
49
|
+
color: #fff;
|
|
50
|
+
font-size: 13px;
|
|
51
|
+
line-height: 1.45;
|
|
52
|
+
text-align: left;
|
|
53
|
+
white-space: pre-line;
|
|
54
|
+
word-break: break-all;
|
|
55
|
+
pointer-events: none;
|
|
56
|
+
z-index: 10;
|
|
57
|
+
opacity: 1;
|
|
58
|
+
transition: opacity 0.25s ease;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#pet-bubble.hidden {
|
|
62
|
+
opacity: 0;
|
|
63
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// 表情目视图库截图 harness:逐预设/逐情绪渲染(落定末帧)+ capturePage + 生成目视页
|
|
2
|
+
// 用法(预设模式,22 个数字表情):
|
|
3
|
+
// env -u ELECTRON_RUN_AS_NODE ARONA_AGENT=plana ./node_modules/.bin/electron --no-sandbox pet/tools/gallery_capture.cjs
|
|
4
|
+
// 用法(情绪确认模式,按 agents.cjs 映射渲染 17 个情绪,水印 = "情绪名 → 预设"):
|
|
5
|
+
// GALLERY_MODE=emotions ARONA_AGENT=plana ...同上...
|
|
6
|
+
// 产物:/tmp/<agent>_emotions/(预设)或 /tmp/<agent>_emotion_map/(情绪)下 PNG + gallery.html
|
|
7
|
+
// 断言:每帧截图前校验 track4 已就位(防止"截到 Idle 基底脸"——B7 规则 4)
|
|
8
|
+
const { app, BrowserWindow, ipcMain } = require("electron");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const PET = path.join(__dirname, "..");
|
|
12
|
+
const { AGENTS } = require(path.join(PET, "agents.cjs"));
|
|
13
|
+
|
|
14
|
+
const agentId = process.env.ARONA_AGENT && AGENTS[process.env.ARONA_AGENT] ? process.env.ARONA_AGENT : "arona";
|
|
15
|
+
const AGENT = AGENTS[agentId];
|
|
16
|
+
const MODE = process.env.GALLERY_MODE === "emotions" ? "emotions" : "presets";
|
|
17
|
+
// 数字预设普查(CLAUDE.md B5):arona = 00~32 + 99(34 个);plana = 00~20 + 99(22 个);
|
|
18
|
+
// shiroko/hoshino = 00~17 + 99(19 个,精灵图切脸式角色)
|
|
19
|
+
const PRESETS = AGENT.id === "arona"
|
|
20
|
+
? [...Array.from({ length: 33 }, (_, i) => String(i).padStart(2, "0")), "99"]
|
|
21
|
+
: AGENT.id === "plana"
|
|
22
|
+
? [...Array.from({ length: 21 }, (_, i) => String(i).padStart(2, "0")), "99"]
|
|
23
|
+
: [...Array.from({ length: 18 }, (_, i) => String(i).padStart(2, "0")), "99"];
|
|
24
|
+
// 附件签名互异断言仅对骨脸角色(arona/plana)强制;精灵图角色(shiroko/hoshino)可能出现
|
|
25
|
+
// 预设共用同一 sprite(如 99 与 00 同默认脸),碰撞是预期,只警告不算失败。
|
|
26
|
+
const SIGNATURE_UNIQUE_REQUIRED = AGENT.id === "arona" || AGENT.id === "plana";
|
|
27
|
+
|
|
28
|
+
// 情绪确认模式:17 个情绪名 → 其映射预设(顺序 = agents.cjs 定义顺序)
|
|
29
|
+
const EMOTIONS = Object.entries(AGENT.emotions);
|
|
30
|
+
const OUT = MODE === "emotions" ? `/tmp/${agentId}_emotion_map` : `/tmp/${agentId}_emotions`;
|
|
31
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
32
|
+
let win;
|
|
33
|
+
let failures = 0;
|
|
34
|
+
const SIGNATURES = new Map(); // 附件签名 → 预设编号(互不相同断言用)
|
|
35
|
+
|
|
36
|
+
// watchdog:全流程 ~22 × 3.6s + 新鲜度重试余量,220s 强制退出
|
|
37
|
+
setTimeout(() => { console.log("FATAL watchdog timeout"); app.exit(3); }, 220000);
|
|
38
|
+
|
|
39
|
+
// 已捕获 PNG 字节列表:新鲜度守卫(透明窗口上 capturePage 会间歇性返回合成器过期纹理,
|
|
40
|
+
// 表现为截图与某张历史帧完全相同——曾导致 06 重复、08 缺失的编号↔脸错位)
|
|
41
|
+
const seenPngs = [];
|
|
42
|
+
|
|
43
|
+
async function cap(tag) {
|
|
44
|
+
if (!win || win.isDestroyed()) return;
|
|
45
|
+
let png = null;
|
|
46
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
47
|
+
win.webContents.invalidate(); // 强制合成器全量重绘后再截
|
|
48
|
+
await sleep(attempt === 1 ? 200 : 600);
|
|
49
|
+
const img = await win.webContents.capturePage();
|
|
50
|
+
png = img.toPNG();
|
|
51
|
+
if (!seenPngs.some((b) => b.equals(png))) break; // 与所有历史帧都不同 → 新鲜
|
|
52
|
+
console.log("STALE", tag, "attempt", attempt, "-> retry");
|
|
53
|
+
}
|
|
54
|
+
const dup = seenPngs.some((b) => b.equals(png));
|
|
55
|
+
if (dup) console.log("WARN", tag, "仍与某历史帧完全相同(若附件签名独立,则是两预设视觉相同,非过期帧)");
|
|
56
|
+
seenPngs.push(png);
|
|
57
|
+
fs.writeFileSync(path.join(OUT, tag + ".png"), png);
|
|
58
|
+
console.log("CAP", tag, dup ? "(dup)" : "(fresh)");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** PNG → data URI(自包含 HTML:图片全部内嵌,单文件可随意移动/另存,换文件名即防缓存) */
|
|
62
|
+
function dataUri(file) {
|
|
63
|
+
return "data:image/png;base64," + fs.readFileSync(path.join(OUT, file)).toString("base64");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 单元格:图片内嵌 + 点击放大(缩略图太小分不清嘴/眉差异,lightbox 看原尺寸 2× DPR 图) */
|
|
67
|
+
function cellHtml(cap, file) {
|
|
68
|
+
const q = JSON.stringify(cap);
|
|
69
|
+
return `<div class="cell" onclick='zoom(${q}, this.querySelector("img").src)'><img src="${dataUri(file)}"><div class="cap">${cap}</div></div>`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 目视 contact sheet(自包含单文件 HTML,任何浏览器可直接打开):
|
|
74
|
+
* - 图片 base64 内嵌(无相对路径依赖、无 file:// 旧缓存复用问题)
|
|
75
|
+
* - 点击图片全屏放大(标题随动)
|
|
76
|
+
* - binder 非空时(预设模式)附"映射重绑面板":17 情绪 × 下拉选预设(默认 = 当前映射),
|
|
77
|
+
* 一键生成 emotions 映射文本并复制,用户直接粘贴回对话,避免手抄错号
|
|
78
|
+
*/
|
|
79
|
+
function writeSheet({ title, cells, binder }) {
|
|
80
|
+
const binderBlock = binder
|
|
81
|
+
? `<div id="binder">
|
|
82
|
+
<h2>重新绑定映射:为每个情绪选一个预设编号(下拉默认 = 当前映射,只改你要改的)</h2>
|
|
83
|
+
<div id="rows"></div>
|
|
84
|
+
<button class="btn" onclick="genMap()">生成映射文本(并复制)</button>
|
|
85
|
+
<span id="copyhint" style="margin-left:10px;font-size:12px;color:#8f8"></span>
|
|
86
|
+
<textarea id="out" readonly spellcheck="false"></textarea>
|
|
87
|
+
</div>
|
|
88
|
+
<script>
|
|
89
|
+
const PRESETS = ${JSON.stringify(PRESETS)};
|
|
90
|
+
const CURRENT = ${JSON.stringify(Object.fromEntries(binder))};
|
|
91
|
+
const rows = document.getElementById('rows');
|
|
92
|
+
for (const name of Object.keys(CURRENT)) {
|
|
93
|
+
const wrap = document.createElement('div');
|
|
94
|
+
const lab = document.createElement('label');
|
|
95
|
+
lab.textContent = name + ' ';
|
|
96
|
+
const sel = document.createElement('select');
|
|
97
|
+
sel.dataset.e = name;
|
|
98
|
+
for (const p of PRESETS) {
|
|
99
|
+
const opt = document.createElement('option');
|
|
100
|
+
opt.value = p;
|
|
101
|
+
opt.textContent = p;
|
|
102
|
+
if (p === CURRENT[name]) opt.selected = true;
|
|
103
|
+
sel.appendChild(opt);
|
|
104
|
+
}
|
|
105
|
+
wrap.appendChild(lab);
|
|
106
|
+
wrap.appendChild(sel);
|
|
107
|
+
rows.appendChild(wrap);
|
|
108
|
+
}
|
|
109
|
+
function genMap() {
|
|
110
|
+
const lines = Object.keys(CURRENT).map(
|
|
111
|
+
(name) => ' ' + name + ': "' + document.querySelector('select[data-e="' + name + '"]').value + '",',
|
|
112
|
+
);
|
|
113
|
+
const out = document.getElementById('out');
|
|
114
|
+
out.value = 'emotions = {\\n' + lines.join('\\n') + '\\n}';
|
|
115
|
+
out.style.display = 'block';
|
|
116
|
+
out.focus();
|
|
117
|
+
out.select();
|
|
118
|
+
try {
|
|
119
|
+
document.execCommand('copy');
|
|
120
|
+
document.getElementById('copyhint').textContent = '已复制,直接粘贴给 Agent 即可';
|
|
121
|
+
} catch (e) {}
|
|
122
|
+
}
|
|
123
|
+
</script>`
|
|
124
|
+
: "";
|
|
125
|
+
const html = `<!DOCTYPE html>
|
|
126
|
+
<html><head><meta charset="utf-8"><title>${agentId} 表情目视</title>
|
|
127
|
+
<style>
|
|
128
|
+
body { background:#1a1a1a; color:#eee; font-family: Menlo, Consolas, monospace; padding: 20px; }
|
|
129
|
+
h1 { font-size: 18px; }
|
|
130
|
+
.grid { display:flex; flex-wrap:wrap; gap:16px; margin-top:16px; }
|
|
131
|
+
.cell { background:#2a2a2a; border:1px solid #444; border-radius:6px; padding:8px; text-align:center; cursor:zoom-in; }
|
|
132
|
+
.cell img { width:280px; height:590px; display:block; }
|
|
133
|
+
.cap { margin-top:6px; font-size:15px; font-weight:bold; color:#9fd3ff; }
|
|
134
|
+
#lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,.93); z-index:10; cursor:zoom-out; text-align:center; }
|
|
135
|
+
#lightbox .cap { font-size:24px; margin-top:12px; }
|
|
136
|
+
#lightbox img { max-width:96vw; max-height:88vh; }
|
|
137
|
+
#binder { position:sticky; top:0; z-index:5; background:#111; border:1px solid #444; border-radius:8px; padding:12px 18px; }
|
|
138
|
+
#binder h2 { font-size:14px; margin:0 0 10px; }
|
|
139
|
+
#rows { display:flex; flex-wrap:wrap; gap:6px 18px; }
|
|
140
|
+
#rows label { font-size:13px; }
|
|
141
|
+
#rows select { font:inherit; font-size:13px; background:#2a2a2a; color:#eee; border:1px solid #555; border-radius:4px; padding:2px 6px; }
|
|
142
|
+
.btn { margin-top:10px; font:inherit; background:#2f6fb0; color:#fff; border:none; border-radius:4px; padding:6px 14px; cursor:pointer; }
|
|
143
|
+
#out { display:none; width:100%; box-sizing:border-box; margin-top:10px; height:120px; background:#222; color:#9f9; font:13px Menlo, Consolas, monospace; border:1px solid #555; }
|
|
144
|
+
</style></head>
|
|
145
|
+
<body>
|
|
146
|
+
${binderBlock}
|
|
147
|
+
<h1>${title}</h1>
|
|
148
|
+
<div class="grid">\n${cells}\n</div>
|
|
149
|
+
<div id="lightbox" onclick="this.style.display='none'"><div class="cap" id="lb-cap"></div><img id="lb-img" alt=""></div>
|
|
150
|
+
<script>
|
|
151
|
+
function zoom(cap, src) {
|
|
152
|
+
document.getElementById('lb-cap').textContent = cap;
|
|
153
|
+
document.getElementById('lb-img').src = src;
|
|
154
|
+
document.getElementById('lightbox').style.display = 'block';
|
|
155
|
+
}
|
|
156
|
+
</script>
|
|
157
|
+
</body></html>`;
|
|
158
|
+
fs.writeFileSync(path.join(OUT, "gallery.html"), html);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
app.whenReady().then(() => {
|
|
162
|
+
fs.mkdirSync(OUT, { recursive: true });
|
|
163
|
+
win = new BrowserWindow({
|
|
164
|
+
width: 320, height: 674, transparent: true, frame: false, show: true,
|
|
165
|
+
// 窗口被遮挡/后台时 rAF 与 DOM 合成会被节流 → capturePage 拿到过期纹理;关闭节流
|
|
166
|
+
webPreferences: { preload: path.join(PET, "preload.cjs"), contextIsolation: true, nodeIntegration: false, backgroundThrottling: false },
|
|
167
|
+
});
|
|
168
|
+
win.webContents.on("console-message", (_e, _a, b) => console.log("[R]", b));
|
|
169
|
+
ipcMain.handle("pet:get-agent-config", () => ({ id: agentId, ...AGENT }));
|
|
170
|
+
ipcMain.on("pet:drag", () => {});
|
|
171
|
+
ipcMain.on("pet:dragend", () => {});
|
|
172
|
+
ipcMain.on("pet:shake", () => {});
|
|
173
|
+
win.loadFile(path.join(PET, "renderer", "gallery.html"), { search: "agent=" + agentId });
|
|
174
|
+
|
|
175
|
+
win.webContents.on("did-finish-load", async () => {
|
|
176
|
+
const t0 = Date.now();
|
|
177
|
+
while (true) {
|
|
178
|
+
try {
|
|
179
|
+
// ⚠️ 不能用 !== null:__gallery 未赋值时是 undefined(undefined !== null 为 true)
|
|
180
|
+
if (await win.webContents.executeJavaScript("typeof window.__gallery === 'object' && typeof window.__gallery.show === 'function'")) break;
|
|
181
|
+
} catch {}
|
|
182
|
+
if (Date.now() - t0 > 15000) { console.log("FATAL init timeout"); app.exit(3); return; }
|
|
183
|
+
await sleep(200);
|
|
184
|
+
}
|
|
185
|
+
console.log("READY", agentId, "mode:", MODE, "items:", MODE === "emotions" ? EMOTIONS.length : PRESETS.length);
|
|
186
|
+
|
|
187
|
+
if (MODE === "emotions") {
|
|
188
|
+
// 情绪确认模式:水印 = "情绪名 → 预设编号",文件名 = <情绪名>_settled.png
|
|
189
|
+
for (const [name, preset] of EMOTIONS) {
|
|
190
|
+
try {
|
|
191
|
+
await win.webContents.executeJavaScript(`window.__gallery.showEmotion(${JSON.stringify(name)}, ${JSON.stringify(preset)})`);
|
|
192
|
+
} catch (e) {
|
|
193
|
+
console.log("CHECK emotion", name, "FAIL exec:", e.message);
|
|
194
|
+
failures++;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
await sleep(120);
|
|
198
|
+
const st = JSON.parse(await win.webContents.executeJavaScript("JSON.stringify(window.SpineLayer.getState())"));
|
|
199
|
+
if (st.track4 !== preset) {
|
|
200
|
+
console.log("CHECK emotion", name, "FAIL track4=" + st.track4 + " != " + preset);
|
|
201
|
+
failures++;
|
|
202
|
+
}
|
|
203
|
+
await sleep(3600); // 等 3.333s 动画落定到末帧
|
|
204
|
+
await cap(name + "_settled");
|
|
205
|
+
}
|
|
206
|
+
const cells = EMOTIONS.map(([name, preset]) =>
|
|
207
|
+
cellHtml(`${name} → preset ${preset}`, name + "_settled.png"),
|
|
208
|
+
).join("\n");
|
|
209
|
+
writeSheet({ title: `${agentId} 情绪映射确认(settled 末帧,共 ${EMOTIONS.length} 个)`, cells, binder: null });
|
|
210
|
+
console.log(failures === 0 ? "ALL OK" : failures + " FAILURES");
|
|
211
|
+
console.log("DONE →", OUT, "(open gallery.html)");
|
|
212
|
+
app.exit(failures === 0 ? 0 : 2);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 参考帧:Idle_01 基底(track0 常驻循环,不动 track4)
|
|
217
|
+
await sleep(800);
|
|
218
|
+
await cap("idle_base");
|
|
219
|
+
|
|
220
|
+
for (const p of PRESETS) {
|
|
221
|
+
try {
|
|
222
|
+
await win.webContents.executeJavaScript(`window.__gallery.show(${JSON.stringify(p)})`);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
console.log("CHECK preset", p, "FAIL exec:", e.message);
|
|
225
|
+
failures++;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
await sleep(120);
|
|
229
|
+
// B7 规则 4:截图前验证 track4 已就位(像素会骗人,状态不会)
|
|
230
|
+
const st = JSON.parse(await win.webContents.executeJavaScript("JSON.stringify(window.SpineLayer.getState())"));
|
|
231
|
+
if (st.track4 !== p) {
|
|
232
|
+
console.log("CHECK preset", p, "FAIL track4=" + st.track4);
|
|
233
|
+
failures++;
|
|
234
|
+
}
|
|
235
|
+
// 附件签名断言:骨脸角色预设签名必须互不相同(B5 普查);碰撞 = 预设未生效。
|
|
236
|
+
// 精灵图角色允许共用签名(同一 sprite 复用),仅打印 WARN。
|
|
237
|
+
const sig = await win.webContents.executeJavaScript("window.SpineLayer.getAttachmentSignature()");
|
|
238
|
+
if (SIGNATURES.has(sig)) {
|
|
239
|
+
const prev = SIGNATURES.get(sig);
|
|
240
|
+
if (SIGNATURE_UNIQUE_REQUIRED) {
|
|
241
|
+
console.log("CHECK preset", p, "FAIL 附件签名与 preset " + prev + " 碰撞");
|
|
242
|
+
failures++;
|
|
243
|
+
} else {
|
|
244
|
+
console.log("WARN preset", p, "附件签名与 preset " + prev + " 相同(精灵图共用 sprite,预期内)");
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
SIGNATURES.set(sig, p);
|
|
248
|
+
}
|
|
249
|
+
await sleep(3600); // 等 3.333s 动画落定到末帧
|
|
250
|
+
await cap("p" + p + "_settled");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 目视 contact sheet(自包含单文件,附映射重绑面板)
|
|
254
|
+
const cells = [
|
|
255
|
+
cellHtml("Idle_01 基底", "idle_base.png"),
|
|
256
|
+
...PRESETS.map((p) => cellHtml("preset " + p, "p" + p + "_settled.png")),
|
|
257
|
+
].join("\n");
|
|
258
|
+
writeSheet({
|
|
259
|
+
title: `${agentId} 数字情绪预设(settled 末帧,共 ${PRESETS.length} 个)— 点击图片放大;顶部面板重绑映射`,
|
|
260
|
+
cells,
|
|
261
|
+
binder: EMOTIONS,
|
|
262
|
+
});
|
|
263
|
+
console.log(failures === 0 ? "ALL OK" : failures + " FAILURES");
|
|
264
|
+
console.log("DONE →", OUT, "(open gallery.html)");
|
|
265
|
+
app.exit(failures === 0 ? 0 : 2);
|
|
266
|
+
});
|
|
267
|
+
});
|