cortico-world-ellen-voice 0.1.0 → 0.2.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/CHANGELOG.md +9 -0
- package/README.md +11 -0
- package/package.json +40 -38
- package/src/config.ts +3 -0
- package/src/definition.ts +24 -0
- package/src/figure-pack.ts +127 -0
- package/src/png.ts +218 -0
- package/src/world.ts +61 -3
- package/template/figure.json +617 -0
- package/template/model.json +1644 -0
- package/template/textures.json +87 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- 新增 `ellen_install_figure`:把一张透明底 PNG 立绘装成 Coopanion 形象包(自动生成 figure.json、
|
|
6
|
+
model.json、全部骨架贴图与缩略图),装完即可在「装扮」页选中。
|
|
7
|
+
- 附带极简 PNG 编解码(`src/png.ts`,只依赖 node:zlib),不引入任何第三方依赖;
|
|
8
|
+
支持 8/16 位、灰度/RGB/调色板/RGBA、非隔行。
|
|
9
|
+
- 新增设置项 `figuresDir`:留空时按 Coopanion.exe 所在目录推断 `data\home\companion\data\figures`。
|
|
10
|
+
- 扩展仍不附带任何角色美术:模板目录里只有两份 JSON 和贴图清单,图片由使用者自己提供。
|
|
11
|
+
|
|
3
12
|
## 0.1.0
|
|
4
13
|
|
|
5
14
|
- 首个版本:`ellen_speak` 合成语音并作为附件交回模型,`ellen_send_voice` 直接发 QQ 语音条。
|
package/README.md
CHANGED
|
@@ -20,9 +20,19 @@ TTS 代理可以是任何返回音频字节的 HTTP 服务:GPT-SoVITS 微调
|
|
|
20
20
|
- **ellen_speak** `{ text, lang? }`:合成语音,把音频作为附件交回模型。
|
|
21
21
|
模型拿到的是 `log:` 句柄,可以再交给 `qq_draft(voice=<句柄>)` 走草稿确认。
|
|
22
22
|
- **ellen_send_voice** `{ text, to?, group?, lang? }`:合成 + 直接发语音条,一步到位。
|
|
23
|
+
- **ellen_install_figure** `{ path, id, name, overwrite? }`:把一张**透明底 PNG** 立绘装成形象包。
|
|
24
|
+
装出来的包放在 `data\home\companion\data\figures\<id>\`,装完在「装扮」页就能选。
|
|
25
|
+
做法是把图等比缩到 847×859 的画布,骨架贴图全部写成透明占位——所以是静态立绘,不会换姿势。
|
|
23
26
|
|
|
24
27
|
语言缺省 `auto`,支持 `zh` / `ko` / `en` / `ja`。
|
|
25
28
|
|
|
29
|
+
## 关于形象
|
|
30
|
+
|
|
31
|
+
本扩展**不附带任何角色美术**。`template/` 里只有两份 JSON 和一份贴图清单,没有图;
|
|
32
|
+
立绘由使用者自己提供(自绘、委托画师,或按所在地区的同人规则使用官方素材——后者请自行确认授权)。
|
|
33
|
+
|
|
34
|
+
白底图需要先抠成透明底再喂给 `ellen_install_figure`,工具不替你做抠图。
|
|
35
|
+
|
|
26
36
|
## 配置
|
|
27
37
|
|
|
28
38
|
在 Coopanion 的扩展设置里填:
|
|
@@ -33,6 +43,7 @@ worlds.ellen-voice.oneBotUrl OneBot 正向 WS 地址
|
|
|
33
43
|
worlds.ellen-voice.token OneBot access token(可空)
|
|
34
44
|
worlds.ellen-voice.maxChars 单次合成字数上限(默认 200)
|
|
35
45
|
worlds.ellen-voice.defaultPrivate 默认私聊 QQ 号
|
|
46
|
+
worlds.ellen-voice.figuresDir 形象安装目录(留空按程序目录推断)
|
|
36
47
|
```
|
|
37
48
|
|
|
38
49
|
## 许可
|
package/package.json
CHANGED
|
@@ -1,38 +1,40 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "cortico-world-ellen-voice",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"type": "module",
|
|
5
|
-
"description": "Ellen Joe voice world for Coopanion: speak through a local GPT-SoVITS service
|
|
6
|
-
"author": "deepseek study",
|
|
7
|
-
"license": "MIT",
|
|
8
|
-
"main": "./src/index.ts",
|
|
9
|
-
"keywords": [
|
|
10
|
-
"cortico-world",
|
|
11
|
-
"cortico",
|
|
12
|
-
"tts",
|
|
13
|
-
"qq",
|
|
14
|
-
"voice"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "cortico-world-ellen-voice",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Ellen Joe voice world for Coopanion: speak through a local GPT-SoVITS service, deliver voice bars to QQ, and install a figure pack from your own PNG",
|
|
6
|
+
"author": "deepseek study",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"cortico-world",
|
|
11
|
+
"cortico",
|
|
12
|
+
"tts",
|
|
13
|
+
"qq",
|
|
14
|
+
"voice",
|
|
15
|
+
"figure"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": "\u003e=22"
|
|
19
|
+
},
|
|
20
|
+
"cortico": {
|
|
21
|
+
"kind": "world",
|
|
22
|
+
"api": 6,
|
|
23
|
+
"displayName": "艾莲语音",
|
|
24
|
+
"icon": "src/icon.svg",
|
|
25
|
+
"describe": "describe.json",
|
|
26
|
+
"tags": [
|
|
27
|
+
"voice",
|
|
28
|
+
"qq",
|
|
29
|
+
"external-setup"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"src",
|
|
34
|
+
"template",
|
|
35
|
+
"describe.json",
|
|
36
|
+
"README.md",
|
|
37
|
+
"CHANGELOG.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
]
|
|
40
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface EllenVoiceConfig {
|
|
|
12
12
|
maxChars: number;
|
|
13
13
|
/** 默认私聊对象;调用 ellen_send_voice 不给 to 时用它。 */
|
|
14
14
|
defaultPrivate: string;
|
|
15
|
+
/** 形象包安装目录;留空时按 Coopanion 程序所在目录自动推断。 */
|
|
16
|
+
figuresDir: string;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export const ELLEN_DEFAULTS = {
|
|
@@ -21,4 +23,5 @@ export const ELLEN_DEFAULTS = {
|
|
|
21
23
|
token: '',
|
|
22
24
|
maxChars: 200,
|
|
23
25
|
defaultPrivate: '',
|
|
26
|
+
figuresDir: '',
|
|
24
27
|
} as const satisfies EllenVoiceConfig;
|
package/src/definition.ts
CHANGED
|
@@ -48,11 +48,31 @@ export const ELLEN_TOOL_DECLS = {
|
|
|
48
48
|
},
|
|
49
49
|
tags: ['speak', 'act'],
|
|
50
50
|
} satisfies Omit<ToolDef, 'handler'>,
|
|
51
|
+
install: {
|
|
52
|
+
name: 'ellen_install_figure',
|
|
53
|
+
description:
|
|
54
|
+
'把一张透明底 PNG 立绘装成 Coopanion 的形象包(生成 figure.json / model.json / 贴图),' +
|
|
55
|
+
'装好后在「装扮」页的头像列表里就能选。扩展本身不附带任何角色美术,图片由使用者自己提供;' +
|
|
56
|
+
'只接受透明底 PNG,白底图请先抠图。',
|
|
57
|
+
parameters: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
path: { type: 'string', description: '立绘 PNG 的绝对路径' },
|
|
61
|
+
id: { type: 'string', description: '包 id,小写字母和数字,例如 ellen' },
|
|
62
|
+
name: { type: 'string', description: '展示名,例如 艾莲·乔' },
|
|
63
|
+
overwrite: { type: 'boolean', description: '已存在同名包时是否覆盖' },
|
|
64
|
+
},
|
|
65
|
+
required: ['path', 'id', 'name'],
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
},
|
|
68
|
+
tags: ['setup'],
|
|
69
|
+
} satisfies Omit<ToolDef, 'handler'>,
|
|
51
70
|
};
|
|
52
71
|
|
|
53
72
|
export const ELLEN_TOOL_LIST: ReadonlyArray<Omit<ToolDef, 'handler'>> = [
|
|
54
73
|
ELLEN_TOOL_DECLS.speak,
|
|
55
74
|
ELLEN_TOOL_DECLS.send,
|
|
75
|
+
ELLEN_TOOL_DECLS.install,
|
|
56
76
|
];
|
|
57
77
|
|
|
58
78
|
export const EllenVoice: WorldDefinition<EllenVoiceConfig> = {
|
|
@@ -73,6 +93,10 @@ export const EllenVoice: WorldDefinition<EllenVoiceConfig> = {
|
|
|
73
93
|
zh: '合成语音并直接以语音条发到 QQ 私聊或群。',
|
|
74
94
|
en: 'Synthesises speech and sends it as a voice bar to a QQ private chat or group.',
|
|
75
95
|
},
|
|
96
|
+
ellen_install_figure: {
|
|
97
|
+
zh: '把一张透明底 PNG 立绘装成 Coopanion 形象包。',
|
|
98
|
+
en: 'Turns a transparent-background PNG into a Coopanion figure pack.',
|
|
99
|
+
},
|
|
76
100
|
}),
|
|
77
101
|
events: [
|
|
78
102
|
{
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 形象包生成器:把用户给的一张透明底 PNG 变成 Coopanion 能读的 figure 包。
|
|
3
|
+
*
|
|
4
|
+
* 包的结构照抄本目录 template/(figure.json、model.json、textures.json 里列的骨架贴图名称),
|
|
5
|
+
* 其中 tex/figure.png 换成用户那张图,其余贴图写成 1×1 透明像素——骨架还在,但只看得见图本身。
|
|
6
|
+
* 扩展不附带任何角色美术,图由使用者自己提供。
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { blankPng, decodePng, encodePng, fitIntoCanvas } from './png.ts';
|
|
12
|
+
|
|
13
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
/** 骨架画布尺寸,与模板里的 model.view(268×272)× 3.16 像素单位对齐。 */
|
|
16
|
+
const CANVAS = { width: 847, height: 859 };
|
|
17
|
+
const THUMB = 144;
|
|
18
|
+
|
|
19
|
+
export interface InstallOptions {
|
|
20
|
+
/** 源图片(透明底 PNG)绝对路径。 */
|
|
21
|
+
source: string;
|
|
22
|
+
/** 包 id,小写字母数字短横,例如 ellen。 */
|
|
23
|
+
id: string;
|
|
24
|
+
/** 展示名,例如 艾莲·乔。 */
|
|
25
|
+
name: string;
|
|
26
|
+
/** figures 根目录(Coopanion 的 data/home/companion/data/figures)。 */
|
|
27
|
+
figuresDir: string;
|
|
28
|
+
/** 已存在的包是否覆盖。 */
|
|
29
|
+
overwrite: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface InstallResult {
|
|
33
|
+
dir: string;
|
|
34
|
+
files: number;
|
|
35
|
+
canvas: { width: number; height: number };
|
|
36
|
+
sourceSize: { width: number; height: number };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function templateDir(): string {
|
|
40
|
+
const candidates = [
|
|
41
|
+
join(HERE, '..', 'template'),
|
|
42
|
+
join(HERE, '..', '..', 'template'),
|
|
43
|
+
];
|
|
44
|
+
for (const dir of candidates) {
|
|
45
|
+
if (existsSync(join(dir, 'figure.json'))) return dir;
|
|
46
|
+
}
|
|
47
|
+
throw new Error('扩展缺少 template/(figure.json、model.json、textures.json)');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readJson<T>(path: string): T {
|
|
51
|
+
return JSON.parse(readFileSync(path, 'utf8')) as T;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 校验 id:Coopanion 的选项 id 只接受小写字母和数字(含短横会整体拒绝)。 */
|
|
55
|
+
export function normalizeId(raw: string): string {
|
|
56
|
+
const cleaned = String(raw ?? '').trim().toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
57
|
+
if (!cleaned) throw new Error('id 不能为空:只能用小写字母和数字');
|
|
58
|
+
if (cleaned.length > 32) throw new Error('id 太长:最多 32 位');
|
|
59
|
+
return cleaned;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function installFigure(options: InstallOptions): InstallResult {
|
|
63
|
+
const id = normalizeId(options.id);
|
|
64
|
+
const name = String(options.name ?? '').trim() || id;
|
|
65
|
+
const sourcePath = resolve(options.source);
|
|
66
|
+
if (!existsSync(sourcePath)) throw new Error(`找不到图片:${sourcePath}`);
|
|
67
|
+
|
|
68
|
+
const sourceImage = decodePng(readFileSync(sourcePath));
|
|
69
|
+
const figure = fitIntoCanvas(sourceImage, CANVAS.width, CANVAS.height, 0.94);
|
|
70
|
+
const thumb = fitIntoCanvas(sourceImage, THUMB, THUMB, 0.92);
|
|
71
|
+
|
|
72
|
+
const packDir = join(options.figuresDir, id);
|
|
73
|
+
if (existsSync(packDir) && !options.overwrite) {
|
|
74
|
+
throw new Error(`形象包已存在:${packDir}(overwrite=true 可覆盖)`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tpl = templateDir();
|
|
78
|
+
const figureJson = readJson<Record<string, unknown>>(join(tpl, 'figure.json'));
|
|
79
|
+
const modelJson = readJson<Record<string, unknown>>(join(tpl, 'model.json'));
|
|
80
|
+
const textures = readJson<string[]>(join(tpl, 'textures.json'));
|
|
81
|
+
|
|
82
|
+
const thumbRel = `thumbs/${id}.png`;
|
|
83
|
+
figureJson.id = id;
|
|
84
|
+
figureJson.name = { zh: name, en: name };
|
|
85
|
+
figureJson.thumb = thumbRel;
|
|
86
|
+
figureJson.version = String(figureJson.version ?? '0.1.0');
|
|
87
|
+
|
|
88
|
+
const axes = figureJson.axes as Array<Record<string, unknown>> | undefined;
|
|
89
|
+
const schemeAxis = axes?.find((axis) => axis.id === 'scheme');
|
|
90
|
+
if (schemeAxis) {
|
|
91
|
+
schemeAxis.options = [{ id: 'deepseek', name: { zh: name, en: name }, thumb: thumbRel }];
|
|
92
|
+
delete schemeAxis.source;
|
|
93
|
+
}
|
|
94
|
+
figureJson.presets = [];
|
|
95
|
+
|
|
96
|
+
const schemes = modelJson.schemes as Array<Record<string, unknown>> | undefined;
|
|
97
|
+
if (Array.isArray(schemes) && schemes.length > 0) {
|
|
98
|
+
for (const scheme of schemes) scheme.label = name;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
mkdirSync(join(packDir, 'tex'), { recursive: true });
|
|
102
|
+
mkdirSync(join(packDir, 'thumbs'), { recursive: true });
|
|
103
|
+
|
|
104
|
+
const blank = blankPng();
|
|
105
|
+
let files = 0;
|
|
106
|
+
for (const rel of textures) {
|
|
107
|
+
const target = join(packDir, rel);
|
|
108
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
109
|
+
writeFileSync(target, blank);
|
|
110
|
+
files += 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
writeFileSync(join(packDir, 'tex', 'figure.png'), encodePng(figure));
|
|
114
|
+
files += 1;
|
|
115
|
+
writeFileSync(join(packDir, thumbRel), encodePng(thumb));
|
|
116
|
+
files += 1;
|
|
117
|
+
writeFileSync(join(packDir, 'figure.json'), JSON.stringify(figureJson, null, 2), 'utf8');
|
|
118
|
+
writeFileSync(join(packDir, 'model.json'), JSON.stringify(modelJson), 'utf8');
|
|
119
|
+
files += 2;
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
dir: packDir,
|
|
123
|
+
files,
|
|
124
|
+
canvas: CANVAS,
|
|
125
|
+
sourceSize: { width: sourceImage.width, height: sourceImage.height },
|
|
126
|
+
};
|
|
127
|
+
}
|
package/src/png.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 极简 PNG 编解码(不依赖任何第三方库)。
|
|
3
|
+
*
|
|
4
|
+
* 只做形象安装用到的那点事:把用户给的 PNG 解成 RGBA、按目标尺寸缩放、再编码回 PNG。
|
|
5
|
+
* 支持 8/16 位、颜色类型 0(灰度)、2(RGB)、3(调色板)、6(RGBA)、非隔行。
|
|
6
|
+
* 不支持 APNG、16 位小端以外的异常组合、隔行(Adam7)——遇到就报错,让用户重新导一张。
|
|
7
|
+
*/
|
|
8
|
+
import { deflateSync, inflateSync } from 'node:zlib';
|
|
9
|
+
|
|
10
|
+
export interface RgbaImage {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
/** 逐像素 RGBA,长度 = width * height * 4。 */
|
|
14
|
+
data: Uint8Array;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
18
|
+
|
|
19
|
+
const CRC_TABLE = (() => {
|
|
20
|
+
const table = new Int32Array(256);
|
|
21
|
+
for (let n = 0; n < 256; n += 1) {
|
|
22
|
+
let c = n;
|
|
23
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
24
|
+
table[n] = c;
|
|
25
|
+
}
|
|
26
|
+
return table;
|
|
27
|
+
})();
|
|
28
|
+
|
|
29
|
+
function crc32(buf: Buffer): number {
|
|
30
|
+
let c = -1;
|
|
31
|
+
for (let i = 0; i < buf.length; i += 1) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
32
|
+
return (c ^ -1) >>> 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function paeth(a: number, b: number, c: number): number {
|
|
36
|
+
const p = a + b - c;
|
|
37
|
+
const pa = Math.abs(p - a);
|
|
38
|
+
const pb = Math.abs(p - b);
|
|
39
|
+
const pc = Math.abs(p - c);
|
|
40
|
+
if (pa <= pb && pa <= pc) return a;
|
|
41
|
+
return pb <= pc ? b : c;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 解 PNG 为 RGBA。 */
|
|
45
|
+
export function decodePng(input: Uint8Array): RgbaImage {
|
|
46
|
+
const buf = Buffer.from(input);
|
|
47
|
+
if (buf.length < 8 || !buf.subarray(0, 8).equals(SIGNATURE)) throw new Error('不是 PNG 文件');
|
|
48
|
+
|
|
49
|
+
let offset = 8;
|
|
50
|
+
let width = 0;
|
|
51
|
+
let height = 0;
|
|
52
|
+
let bitDepth = 8;
|
|
53
|
+
let colorType = 6;
|
|
54
|
+
let interlace = 0;
|
|
55
|
+
let palette: number[] | null = null;
|
|
56
|
+
let trns: number[] | null = null;
|
|
57
|
+
const idat: Buffer[] = [];
|
|
58
|
+
|
|
59
|
+
while (offset + 8 <= buf.length) {
|
|
60
|
+
const length = buf.readUInt32BE(offset);
|
|
61
|
+
const type = buf.toString('ascii', offset + 4, offset + 8);
|
|
62
|
+
const body = buf.subarray(offset + 8, offset + 8 + length);
|
|
63
|
+
offset += 12 + length;
|
|
64
|
+
|
|
65
|
+
if (type === 'IHDR') {
|
|
66
|
+
width = body.readUInt32BE(0);
|
|
67
|
+
height = body.readUInt32BE(4);
|
|
68
|
+
bitDepth = body[8];
|
|
69
|
+
colorType = body[9];
|
|
70
|
+
interlace = body[12];
|
|
71
|
+
} else if (type === 'PLTE') {
|
|
72
|
+
palette = Array.from(body);
|
|
73
|
+
} else if (type === 'tRNS') {
|
|
74
|
+
trns = Array.from(body);
|
|
75
|
+
} else if (type === 'IDAT') {
|
|
76
|
+
idat.push(Buffer.from(body));
|
|
77
|
+
} else if (type === 'IEND') {
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!width || !height) throw new Error('PNG 缺少 IHDR');
|
|
83
|
+
if (interlace !== 0) throw new Error('不支持隔行 PNG(Adam7),请用普通模式重新导出');
|
|
84
|
+
if (bitDepth !== 8 && bitDepth !== 16) throw new Error(`不支持的位深 ${bitDepth}`);
|
|
85
|
+
|
|
86
|
+
const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 0 ? 1 : colorType === 3 ? 1 : 0;
|
|
87
|
+
if (!channels) throw new Error(`不支持的颜色类型 ${colorType}`);
|
|
88
|
+
|
|
89
|
+
const bytesPerSample = bitDepth / 8;
|
|
90
|
+
const bpp = channels * bytesPerSample;
|
|
91
|
+
const stride = width * bpp;
|
|
92
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
93
|
+
if (raw.length < (stride + 1) * height) throw new Error('PNG 数据不完整');
|
|
94
|
+
|
|
95
|
+
const out = new Uint8Array(width * height * 4);
|
|
96
|
+
const line = new Uint8Array(stride);
|
|
97
|
+
const prev = new Uint8Array(stride);
|
|
98
|
+
|
|
99
|
+
for (let y = 0; y < height; y += 1) {
|
|
100
|
+
const filter = raw[y * (stride + 1)];
|
|
101
|
+
const rowStart = y * (stride + 1) + 1;
|
|
102
|
+
raw.copy(line, 0, rowStart, rowStart + stride);
|
|
103
|
+
for (let x = 0; x < stride; x += 1) {
|
|
104
|
+
const a = x >= bpp ? line[x - bpp] : 0;
|
|
105
|
+
const b = prev[x];
|
|
106
|
+
const c = x >= bpp ? prev[x - bpp] : 0;
|
|
107
|
+
let value = line[x];
|
|
108
|
+
if (filter === 1) value = (value + a) & 0xff;
|
|
109
|
+
else if (filter === 2) value = (value + b) & 0xff;
|
|
110
|
+
else if (filter === 3) value = (value + ((a + b) >> 1)) & 0xff;
|
|
111
|
+
else if (filter === 4) value = (value + paeth(a, b, c)) & 0xff;
|
|
112
|
+
line[x] = value;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (let x = 0; x < width; x += 1) {
|
|
116
|
+
const src = x * bpp;
|
|
117
|
+
const dst = (y * width + x) * 4;
|
|
118
|
+
let r = 0;
|
|
119
|
+
let g = 0;
|
|
120
|
+
let b = 0;
|
|
121
|
+
let alpha = 255;
|
|
122
|
+
if (colorType === 6) {
|
|
123
|
+
r = line[src]; g = line[src + bytesPerSample]; b = line[src + bytesPerSample * 2]; alpha = line[src + bytesPerSample * 3];
|
|
124
|
+
} else if (colorType === 2) {
|
|
125
|
+
r = line[src]; g = line[src + bytesPerSample]; b = line[src + bytesPerSample * 2];
|
|
126
|
+
} else if (colorType === 0) {
|
|
127
|
+
r = line[src]; g = r; b = r;
|
|
128
|
+
} else if (colorType === 3 && palette) {
|
|
129
|
+
const index = line[src];
|
|
130
|
+
r = palette[index * 3] ?? 0;
|
|
131
|
+
g = palette[index * 3 + 1] ?? 0;
|
|
132
|
+
b = palette[index * 3 + 2] ?? 0;
|
|
133
|
+
if (trns && index < trns.length) alpha = trns[index];
|
|
134
|
+
}
|
|
135
|
+
out[dst] = r; out[dst + 1] = g; out[dst + 2] = b; out[dst + 3] = alpha;
|
|
136
|
+
}
|
|
137
|
+
prev.set(line);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { width, height, data: out };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 编码 RGBA 为 PNG(8 位、非隔行、filter 0)。 */
|
|
144
|
+
export function encodePng(image: RgbaImage): Uint8Array {
|
|
145
|
+
const { width, height, data } = image;
|
|
146
|
+
const stride = width * 4;
|
|
147
|
+
const raw = Buffer.alloc((stride + 1) * height);
|
|
148
|
+
for (let y = 0; y < height; y += 1) {
|
|
149
|
+
raw[y * (stride + 1)] = 0;
|
|
150
|
+
Buffer.from(data.buffer, data.byteOffset + y * stride, stride).copy(raw, y * (stride + 1) + 1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const chunk = (type: string, body: Buffer): Buffer => {
|
|
154
|
+
const head = Buffer.alloc(8);
|
|
155
|
+
head.writeUInt32BE(body.length, 0);
|
|
156
|
+
head.write(type, 4, 'ascii');
|
|
157
|
+
const crc = Buffer.alloc(4);
|
|
158
|
+
crc.writeUInt32BE(crc32(Buffer.concat([Buffer.from(type, 'ascii'), body])), 0);
|
|
159
|
+
return Buffer.concat([head, body, crc]);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const ihdr = Buffer.alloc(13);
|
|
163
|
+
ihdr.writeUInt32BE(width, 0);
|
|
164
|
+
ihdr.writeUInt32BE(height, 4);
|
|
165
|
+
ihdr[8] = 8;
|
|
166
|
+
ihdr[9] = 6;
|
|
167
|
+
ihdr[10] = 0;
|
|
168
|
+
ihdr[11] = 0;
|
|
169
|
+
ihdr[12] = 0;
|
|
170
|
+
|
|
171
|
+
return Buffer.concat([
|
|
172
|
+
SIGNATURE,
|
|
173
|
+
chunk('IHDR', ihdr),
|
|
174
|
+
chunk('IDAT', deflateSync(raw, { level: 9 })),
|
|
175
|
+
chunk('IEND', Buffer.alloc(0)),
|
|
176
|
+
]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 透明单像素 PNG,用来占位骨架上的空部件。 */
|
|
180
|
+
export function blankPng(): Uint8Array {
|
|
181
|
+
return encodePng({ width: 1, height: 1, data: new Uint8Array(4) });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 等比缩放进 canvasWidth × canvasHeight,居中、底部对齐、留 padding 比例的空白。
|
|
186
|
+
* 透明像素按直通 alpha 采样(不做预乘),避免边缘发黑。
|
|
187
|
+
*/
|
|
188
|
+
export function fitIntoCanvas(
|
|
189
|
+
image: RgbaImage,
|
|
190
|
+
canvasWidth: number,
|
|
191
|
+
canvasHeight: number,
|
|
192
|
+
fill = 0.94,
|
|
193
|
+
): RgbaImage {
|
|
194
|
+
const scale = Math.min((canvasWidth * fill) / image.width, (canvasHeight * fill) / image.height);
|
|
195
|
+
const drawW = Math.max(1, Math.round(image.width * scale));
|
|
196
|
+
const drawH = Math.max(1, Math.round(image.height * scale));
|
|
197
|
+
|
|
198
|
+
const out = new Uint8Array(canvasWidth * canvasHeight * 4);
|
|
199
|
+
const offsetX = Math.round((canvasWidth - drawW) / 2);
|
|
200
|
+
const offsetY = Math.round((canvasHeight - drawH) / 2);
|
|
201
|
+
|
|
202
|
+
for (let y = 0; y < drawH; y += 1) {
|
|
203
|
+
const sy = Math.min(image.height - 1, Math.floor((y + 0.5) * image.height / drawH));
|
|
204
|
+
for (let x = 0; x < drawW; x += 1) {
|
|
205
|
+
const sx = Math.min(image.width - 1, Math.floor((x + 0.5) * image.width / drawW));
|
|
206
|
+
const src = (sy * image.width + sx) * 4;
|
|
207
|
+
const dx = offsetX + x;
|
|
208
|
+
const dy = offsetY + y;
|
|
209
|
+
if (dx < 0 || dy < 0 || dx >= canvasWidth || dy >= canvasHeight) continue;
|
|
210
|
+
const dst = (dy * canvasWidth + dx) * 4;
|
|
211
|
+
out[dst] = image.data[src];
|
|
212
|
+
out[dst + 1] = image.data[src + 1];
|
|
213
|
+
out[dst + 2] = image.data[src + 2];
|
|
214
|
+
out[dst + 3] = image.data[src + 3];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { width: canvasWidth, height: canvasHeight, data: out };
|
|
218
|
+
}
|
package/src/world.ts
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* 直接发到 QQ。语音字节可以随工具回执交给模型(blobs),由模型再用 qq_draft(voice=…)
|
|
6
6
|
* 发出去;也可以一步到位,由本 World 自己连 OneBot 发。
|
|
7
7
|
*/
|
|
8
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
9
|
-
import { join } from 'node:path';
|
|
8
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
10
|
import { createHash } from 'node:crypto';
|
|
11
11
|
import type { ConfigGroup, Logger, ToolDef, ToolOutcome, World, WorldHost, WorldWebUiDecl } from 'cortico/core/types.ts';
|
|
12
12
|
import { nullLogger } from 'cortico/core/util.ts';
|
|
13
13
|
import { ELLEN_TOOL_DECLS } from './definition.ts';
|
|
14
|
+
import { installFigure } from './figure-pack.ts';
|
|
14
15
|
import type { EllenVoiceConfig } from './config.ts';
|
|
15
16
|
|
|
16
17
|
export interface EllenWorldDeps {
|
|
@@ -100,6 +101,13 @@ export class EllenVoiceWorld implements World {
|
|
|
100
101
|
'x-hot': false,
|
|
101
102
|
description: '工具调用不带 QQ 号时用这个。',
|
|
102
103
|
},
|
|
104
|
+
'worlds.ellen-voice.figuresDir': {
|
|
105
|
+
type: 'string',
|
|
106
|
+
title: '形象目录',
|
|
107
|
+
'x-hot': false,
|
|
108
|
+
description:
|
|
109
|
+
'装形象时装到这里;留空按 Coopanion 程序目录推断 data\\home\\companion\\data\\figures。',
|
|
110
|
+
},
|
|
103
111
|
},
|
|
104
112
|
},
|
|
105
113
|
},
|
|
@@ -113,7 +121,7 @@ export class EllenVoiceWorld implements World {
|
|
|
113
121
|
}
|
|
114
122
|
|
|
115
123
|
tools(): ToolDef[] {
|
|
116
|
-
return [this.speakTool(), this.sendTool()];
|
|
124
|
+
return [this.speakTool(), this.sendTool(), this.installTool()];
|
|
117
125
|
}
|
|
118
126
|
|
|
119
127
|
// ---------------------------------------------------------------- 工具
|
|
@@ -188,6 +196,56 @@ export class EllenVoiceWorld implements World {
|
|
|
188
196
|
|
|
189
197
|
// ---------------------------------------------------------------- 内部
|
|
190
198
|
|
|
199
|
+
/** 把用户给的一张透明底 PNG 装成形象包。 */
|
|
200
|
+
private installTool(): ToolDef {
|
|
201
|
+
return {
|
|
202
|
+
...ELLEN_TOOL_DECLS.install,
|
|
203
|
+
tags: ['setup'],
|
|
204
|
+
handler: async (args): Promise<string> => {
|
|
205
|
+
const source = typeof args.path === 'string' ? args.path.trim() : '';
|
|
206
|
+
if (!source) return '[bad input] path 不能为空';
|
|
207
|
+
const id = typeof args.id === 'string' ? args.id.trim() : '';
|
|
208
|
+
if (!id) return '[bad input] id 不能为空(小写字母和数字)';
|
|
209
|
+
const name = typeof args.name === 'string' ? args.name.trim() : id;
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const figuresDir = this.resolveFiguresDir();
|
|
213
|
+
const result = installFigure({
|
|
214
|
+
source,
|
|
215
|
+
id,
|
|
216
|
+
name,
|
|
217
|
+
figuresDir,
|
|
218
|
+
overwrite: args.overwrite === true,
|
|
219
|
+
});
|
|
220
|
+
this.log.info(`ellen-voice: 已安装形象 ${result.dir}`);
|
|
221
|
+
return [
|
|
222
|
+
`[形象已安装] ${name}(id=${id})`,
|
|
223
|
+
`目录:${result.dir}`,
|
|
224
|
+
`源图:${result.sourceSize.width}×${result.sourceSize.height} → 画布 ${result.canvas.width}×${result.canvas.height}`,
|
|
225
|
+
`写入 ${result.files} 个文件(骨架贴图全部为透明占位)。`,
|
|
226
|
+
'',
|
|
227
|
+
'下一步:打开「装扮」页刷新,形象列表里就会出现它;如果已经打开着,切走再切回以重新读盘。',
|
|
228
|
+
'说明:这是静态贴图,不会换姿势;人物大小沿用形象包里的画布比例。',
|
|
229
|
+
].join('\n');
|
|
230
|
+
} catch (error) {
|
|
231
|
+
return `[install failed] ${this.errText(error)}`;
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** figures 根目录:设置里填了就用,否则按 Coopanion.exe 的位置推断。 */
|
|
238
|
+
private resolveFiguresDir(): string {
|
|
239
|
+
const configured = this.cfg.figuresDir?.trim();
|
|
240
|
+
if (configured) return resolve(configured);
|
|
241
|
+
const exeDir = dirname(process.execPath);
|
|
242
|
+
const guess = join(exeDir, 'data', 'home', 'companion', 'data', 'figures');
|
|
243
|
+
if (existsSync(guess)) return guess;
|
|
244
|
+
throw new Error(
|
|
245
|
+
'找不到形象目录:请在扩展设置里把 figuresDir 填成 Coopanion 的 data\\home\\companion\\data\\figures',
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
191
249
|
private cleanText(raw: unknown): string {
|
|
192
250
|
const text = typeof raw === 'string' ? raw.trim() : '';
|
|
193
251
|
if (!text) return '';
|