cortico-world-image 0.5.4 → 0.5.5
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/README.md +6 -1
- package/package.json +1 -1
- package/src/api.js +38 -5
- package/src/world.js +22 -4
- package/tools/check.mjs +64 -0
package/README.md
CHANGED
|
@@ -381,7 +381,8 @@ pnpm add "D:\path\to\cortico-world-image" --ignore-workspace
|
|
|
381
381
|
|
|
382
382
|
控制台「图片工坊 → 作品」有个 **插入自检样图** 按钮:它会用手写的极简 PNG 编码器造一张图,
|
|
383
383
|
走完「落盘 → 索引 → 面板取图 → 缩略图显示」整条路。看不到这张图,说明是扩展或面板的问题,
|
|
384
|
-
|
|
384
|
+
不是图片接口的问题。「状态」页也有同一个按钮,两边都能用(`status.selftest` 与 `gallery.selftest`
|
|
385
|
+
都认;0.5.5 前只有后者,从状态页点会报 `未知面板方法:status.selftest`,issue #3 一起修了)。
|
|
385
386
|
|
|
386
387
|
开发侧一共有五份检查脚本,外加一份发布前自检。
|
|
387
388
|
|
|
@@ -489,6 +490,10 @@ cortico-world-image/
|
|
|
489
490
|
- 尺寸:`size` 能写 `1024x1536`、比例 `16:9` / `4:3`、或 `square` / `portrait` / `landscape`(中文
|
|
490
491
|
`竖图` / `横图` / `方图` 也认),比例会换成一套对齐好的尺寸(16:9 → 1344×768,4:3 → 1152×864);
|
|
491
492
|
写死的数字只按 latent 要求对齐到 8,不动你写的整数。
|
|
493
|
+
- 云端那档(OpenAI 兼容端点)**只认 `宽x高`**:别名和比例会先换成它认的三档之一
|
|
494
|
+
(`1024x1024` / `1536x1024` / `1024x1536`),认不出来的值就不发这个字段、让服务端用自己的默认,
|
|
495
|
+
并在回执里说明一句。以前是原样透传,`square` 这种会让上游直接失败(报成 503「无返回结果」,
|
|
496
|
+
很难联想到 size),issue #3 修的就是这个。
|
|
492
497
|
- 改图的尺寸:ComfyUI 那档**不传 `size`、或写 `auto`,就照参考图的比例**出图(最长边压到 1536、对齐
|
|
493
498
|
16),传了 `size` 就按给的;云端那档走接口自己的默认(一般不传尺寸)。所以改一张 1600×1200 的图
|
|
494
499
|
会出 1536×1152,不会变成方图。图生图的回执/作品页记的是**实际出的尺寸**,不再是 `auto`。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortico-world-image",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Image Studio(图片工坊)—— 给 Coopanion 桌宠的 cortico World 扩展:用任意 OpenAI 兼容的图片接口画图/改图、落盘成作品库、把图贴进控制台「对话」页,并自带一套自动重打的自愈补丁。 / A cortico World extension that draws through any OpenAI-compatible image API, keeps pictures on disk and shows them in the console.",
|
|
6
6
|
"license": "MIT",
|
package/src/api.js
CHANGED
|
@@ -18,6 +18,39 @@ export class ImageApiError extends Error {
|
|
|
18
18
|
|
|
19
19
|
const DEFAULT_TIMEOUT = 180000;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* 云端端点只认 `宽x高` 这种字面尺寸:把 `square` 这类别名原样发过去,
|
|
23
|
+
* 上游会以「无返回」包装成 503(issue #3)。这里先归一化,认不出来就干脆不发。
|
|
24
|
+
* 键都是小写、去掉空格之后的写法。
|
|
25
|
+
*/
|
|
26
|
+
const CLOUD_SIZE_ALIASES = {
|
|
27
|
+
square: '1024x1024', '1:1': '1024x1024', '方图': '1024x1024', '方形': '1024x1024', '正方形': '1024x1024',
|
|
28
|
+
portrait: '1024x1536', '3:4': '1024x1536', '9:16': '1024x1536', '竖图': '1024x1536', '竖屏': '1024x1536', '竖版': '1024x1536',
|
|
29
|
+
landscape: '1536x1024', '4:3': '1536x1024', '3:2': '1536x1024', '16:9': '1536x1024', '横图': '1536x1024', '横屏': '1536x1024', '横版': '1536x1024',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 云端那档的 `size` 归一化:返回合法的 `宽x高`,`auto`/空/认不出来返回 ''(调用方就别发这个字段)。
|
|
34
|
+
* 字面尺寸原样保留(`1792x1024` 这类也认),只把 `×`/`*` 统一成 `x`。
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeCloudSize(size) {
|
|
37
|
+
const raw = String(size ?? '').trim().toLowerCase().replace(/\s+/g, '');
|
|
38
|
+
if (!raw || raw === 'auto') return '';
|
|
39
|
+
const wh = /^(\d{2,5})[x×*](\d{2,5})$/.exec(raw);
|
|
40
|
+
if (wh) return `${Number(wh[1])}x${Number(wh[2])}`;
|
|
41
|
+
const alias = CLOUD_SIZE_ALIASES[raw];
|
|
42
|
+
if (alias) return alias;
|
|
43
|
+
// 任意 a:b:按横竖落到云端那三档上(方/横/竖)。
|
|
44
|
+
const ratio = /^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/.exec(raw);
|
|
45
|
+
if (ratio) {
|
|
46
|
+
const a = Number(ratio[1]);
|
|
47
|
+
const b = Number(ratio[2]);
|
|
48
|
+
if (a > 0 && b > 0) return a === b ? '1024x1024' : (a > b ? '1536x1024' : '1024x1536');
|
|
49
|
+
}
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
21
54
|
function joinUrl(baseUrl, path) {
|
|
22
55
|
const base = String(baseUrl ?? '').trim().replace(/[/\s]+$/, '');
|
|
23
56
|
if (!base) throw new ImageApiError('没有配置接口地址');
|
|
@@ -175,11 +208,11 @@ async function materialize(json, { timeoutMs, format }) {
|
|
|
175
208
|
throw new ImageApiError('接口返回里既没有 base64 也没有图片地址');
|
|
176
209
|
}
|
|
177
210
|
|
|
178
|
-
/** 画图请求体:`n` 固定 1
|
|
211
|
+
/** 画图请求体:`n` 固定 1;size 归一化成 `宽x高`(别名云端不认),认不出来就不发;quality 是 auto 也不发。 */
|
|
179
212
|
function generationBody(opts) {
|
|
180
213
|
const body = { model: opts.model, prompt: opts.prompt, n: 1 };
|
|
181
|
-
const size =
|
|
182
|
-
if (size
|
|
214
|
+
const size = normalizeCloudSize(opts.size);
|
|
215
|
+
if (size) body.size = size;
|
|
183
216
|
const quality = String(opts.quality ?? '').trim();
|
|
184
217
|
if (quality && quality.toLowerCase() !== 'auto') body.quality = quality;
|
|
185
218
|
return body;
|
|
@@ -233,8 +266,8 @@ export async function editImage(opts = {}) {
|
|
|
233
266
|
form.set('model', String(opts.model ?? ''));
|
|
234
267
|
form.set('prompt', String(opts.prompt ?? ''));
|
|
235
268
|
form.set('n', '1');
|
|
236
|
-
const size =
|
|
237
|
-
if (size
|
|
269
|
+
const size = normalizeCloudSize(opts.size);
|
|
270
|
+
if (size) form.set('size', size);
|
|
238
271
|
const quality = String(opts.quality ?? '').trim();
|
|
239
272
|
if (quality && quality.toLowerCase() !== 'auto') form.set('quality', quality);
|
|
240
273
|
form.set('image', new Blob([opts.image], { type: opts.mime ?? 'image/png' }), opts.name ?? 'image.png');
|
package/src/world.js
CHANGED
|
@@ -13,7 +13,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node
|
|
|
13
13
|
import { extname, isAbsolute, join, resolve } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
|
|
16
|
-
import { ImageApiError, editImage, generateImage } from './api.js';
|
|
16
|
+
import { ImageApiError, editImage, generateImage, normalizeCloudSize } from './api.js';
|
|
17
17
|
import { ChainError, chainSnapshot, entryLabel, entryState, parseChainEntries, runChain } from './chain.js';
|
|
18
18
|
import { applyChatImagePatches, consolePort, patchBackupStatus, requestConsoleRestart, resolveAppRoot, restoreChatImagePatches } from './chat-image-patch.js';
|
|
19
19
|
import { comfyDraw, comfyEdit, listModels, probe as probeComfy } from './comfyui.js';
|
|
@@ -360,7 +360,8 @@ export class ImageWorld {
|
|
|
360
360
|
const label = entryLabel(used);
|
|
361
361
|
const shown = res.revisedPrompt ? String(res.revisedPrompt) : prompt;
|
|
362
362
|
const note = asText(args.note);
|
|
363
|
-
const
|
|
363
|
+
const sentSize = used.preset === 'openai' ? normalizeCloudSize(size || used.size) : '';
|
|
364
|
+
const dims = res.size || sentSize || size || used.size || 'auto';
|
|
364
365
|
const { item, warning } = this.#saveSafe({
|
|
365
366
|
bytes: res.bytes,
|
|
366
367
|
mime: res.mime,
|
|
@@ -381,6 +382,7 @@ export class ImageWorld {
|
|
|
381
382
|
item,
|
|
382
383
|
warning,
|
|
383
384
|
attempts: res.attempts,
|
|
385
|
+
hint: this.#sizeHint(used, size),
|
|
384
386
|
});
|
|
385
387
|
} catch (err) {
|
|
386
388
|
return this.#failure('image_draw', err);
|
|
@@ -413,7 +415,8 @@ export class ImageWorld {
|
|
|
413
415
|
const label = entryLabel(used);
|
|
414
416
|
const shown = res.revisedPrompt ? String(res.revisedPrompt) : prompt;
|
|
415
417
|
const note = asText(args.note);
|
|
416
|
-
const
|
|
418
|
+
const sentSize = used.preset === 'openai' ? normalizeCloudSize(size || used.size) : '';
|
|
419
|
+
const dims = res.size || sentSize || size || used.size || 'auto';
|
|
417
420
|
const { item, warning } = this.#saveSafe({
|
|
418
421
|
bytes: res.bytes,
|
|
419
422
|
mime: res.mime,
|
|
@@ -434,6 +437,7 @@ export class ImageWorld {
|
|
|
434
437
|
item,
|
|
435
438
|
warning,
|
|
436
439
|
attempts: res.attempts,
|
|
440
|
+
hint: this.#sizeHint(used, size),
|
|
437
441
|
});
|
|
438
442
|
} catch (err) {
|
|
439
443
|
return this.#failure('image_edit', err);
|
|
@@ -593,6 +597,17 @@ export class ImageWorld {
|
|
|
593
597
|
return item;
|
|
594
598
|
}
|
|
595
599
|
|
|
600
|
+
/**
|
|
601
|
+
* 云端那档只认「宽x高」:别名会被换成三档之一,认不出来的干脆不发。
|
|
602
|
+
* 换掉的不吭声(结果就是你要的方向),**没发**的必须在回执里说一句,否则用户会以为尺寸生效了。
|
|
603
|
+
*/
|
|
604
|
+
#sizeHint(entry, sizeArg) {
|
|
605
|
+
if (entry?.preset !== 'openai') return '';
|
|
606
|
+
const asked = asText(sizeArg) || asText(entry.size);
|
|
607
|
+
if (!asked || normalizeCloudSize(asked)) return '';
|
|
608
|
+
return `提示:云端这档不认 size「${asked}」,这次没带 size、按它的默认尺寸出图;要指定就写「1024x1536」或「16:9」。`;
|
|
609
|
+
}
|
|
610
|
+
|
|
596
611
|
/** 工具回执:文本给模型读,blob 交给 Core 进上下文。 */
|
|
597
612
|
#outcome(spec) {
|
|
598
613
|
const bytes = spec.bytes;
|
|
@@ -601,6 +616,7 @@ export class ImageWorld {
|
|
|
601
616
|
const lines = [spec.verb];
|
|
602
617
|
if (spec.item) lines.push(`文件:${spec.item.path}`);
|
|
603
618
|
lines.push(`参数:${spec.size || '默认尺寸'} · ${spec.model || '默认模型'} · ${kb} KB`);
|
|
619
|
+
if (spec.hint) lines.push(spec.hint);
|
|
604
620
|
if (Array.isArray(spec.attempts) && spec.attempts.length) {
|
|
605
621
|
lines.push(`前几档没成:${spec.attempts.map((it) => `${it.name}(${it.message})`).join(';')}`);
|
|
606
622
|
}
|
|
@@ -674,7 +690,7 @@ export class ImageWorld {
|
|
|
674
690
|
],
|
|
675
691
|
panels: [
|
|
676
692
|
{ id: 'gallery', title: t.panelGalleryTitle, description: t.panelGalleryDesc, getMethods: ['list'] },
|
|
677
|
-
{ id: 'status', title: t.panelStatusTitle, description: t.panelStatusDesc, getMethods: ['state'] },
|
|
693
|
+
{ id: 'status', title: t.panelStatusTitle, description: t.panelStatusDesc, getMethods: ['state', 'selftest'] },
|
|
678
694
|
],
|
|
679
695
|
config: [configGroup(this.language)],
|
|
680
696
|
promptDocs: [{
|
|
@@ -715,6 +731,8 @@ export class ImageWorld {
|
|
|
715
731
|
case 'restart': return this.#askRestart('控制台手动请求');
|
|
716
732
|
case 'setKey': return this.#setKey(list[0]);
|
|
717
733
|
case 'clearKey': return this.#setKey('');
|
|
734
|
+
// 面板「自检」按钮发的是 status.selftest(旧版挂在 gallery 上),两处都认。
|
|
735
|
+
case 'selftest': return this.#selfTest();
|
|
718
736
|
default: throw new Error(`未知面板方法:status.${method}`);
|
|
719
737
|
}
|
|
720
738
|
}
|
package/tools/check.mjs
CHANGED
|
@@ -541,6 +541,65 @@ try {
|
|
|
541
541
|
calls = stubFetch([['/images/generations', () => jsonResponse({ data: [{ b64_json: tinyB64 }] })]]);
|
|
542
542
|
await api.generateImage({ ...editOpts, image: undefined });
|
|
543
543
|
check(!('image' in JSON.parse(String(calls[0].init.body))), 'image_draw 的请求体里没有 image 字段');
|
|
544
|
+
|
|
545
|
+
// 6. size 别名(issue #3):云端只吃「宽x高」,别名必须换掉;认不出来的干脆不发。
|
|
546
|
+
const cloudSizeCases = [
|
|
547
|
+
['square', '1024x1024'],
|
|
548
|
+
['1:1', '1024x1024'],
|
|
549
|
+
['方图', '1024x1024'],
|
|
550
|
+
['portrait', '1024x1536'],
|
|
551
|
+
['9:16', '1024x1536'],
|
|
552
|
+
['landscape', '1536x1024'],
|
|
553
|
+
['16:9', '1536x1024'],
|
|
554
|
+
['4:3', '1536x1024'],
|
|
555
|
+
['21:9', '1536x1024'],
|
|
556
|
+
['1024x1536', '1024x1536'],
|
|
557
|
+
['1792x1024', '1792x1024'],
|
|
558
|
+
['1024×1024', '1024x1024'],
|
|
559
|
+
['auto', ''],
|
|
560
|
+
['', ''],
|
|
561
|
+
['超大图', ''],
|
|
562
|
+
];
|
|
563
|
+
let badSize = null;
|
|
564
|
+
for (const [input, want] of cloudSizeCases) {
|
|
565
|
+
const got = api.normalizeCloudSize(input);
|
|
566
|
+
if (got !== want) badSize = `${input} → ${got || '(空)'},期望 ${want || '(空)'}`;
|
|
567
|
+
}
|
|
568
|
+
check(!badSize, '云端尺寸归一化:别名换成宽x高、字面尺寸原样、认不出来不发', badSize ?? '');
|
|
569
|
+
|
|
570
|
+
calls = stubFetch([['/images/generations', () => jsonResponse({ data: [{ b64_json: tinyB64 }] })]]);
|
|
571
|
+
await api.generateImage({ ...editOpts, image: undefined, size: 'square' });
|
|
572
|
+
check(JSON.parse(String(calls[0].init.body)).size === '1024x1024', 'size=square 发给云端前换成 1024x1024(以前原样发、必 503)', String(JSON.parse(String(calls[0].init.body)).size));
|
|
573
|
+
|
|
574
|
+
calls = stubFetch([['/images/generations', () => jsonResponse({ data: [{ b64_json: tinyB64 }] })]]);
|
|
575
|
+
await api.generateImage({ ...editOpts, image: undefined, size: '16:9' });
|
|
576
|
+
check(JSON.parse(String(calls[0].init.body)).size === '1536x1024', 'size=16:9 换成 1536x1024');
|
|
577
|
+
|
|
578
|
+
calls = stubFetch([['/images/generations', () => jsonResponse({ data: [{ b64_json: tinyB64 }] })]]);
|
|
579
|
+
await api.generateImage({ ...editOpts, image: undefined, size: '怪尺寸' });
|
|
580
|
+
check(!('size' in JSON.parse(String(calls[0].init.body))), '认不出的 size 不发,交给服务端默认(不再 503)');
|
|
581
|
+
|
|
582
|
+
calls = stubFetch([['/images/edits', () => jsonResponse({ data: [{ b64_json: tinyB64 }] })]]);
|
|
583
|
+
await api.editImage({ ...editOpts, size: 'portrait' });
|
|
584
|
+
check(calls[0].init.body.get('size') === '1024x1536', 'multipart 那条路的 size 也换成长宽', String(calls[0].init.body.get('size')));
|
|
585
|
+
|
|
586
|
+
// 7. 世界层:云端那档 + 认不出的 size,回执照实说一句(以前是静默按默认尺寸出图)。
|
|
587
|
+
const cloudWorld = definition.create({
|
|
588
|
+
cfg: {
|
|
589
|
+
...definition.defaults(),
|
|
590
|
+
enabled: true,
|
|
591
|
+
models: JSON.stringify([{ name: 'cloud', preset: 'openai', baseUrl: 'https://api.test/v1', model: 'gpt-image-1', apiKeySecret: 'IMAGE_API_KEY' }]),
|
|
592
|
+
},
|
|
593
|
+
dataDir,
|
|
594
|
+
botDir,
|
|
595
|
+
packageDir: pkgDir,
|
|
596
|
+
secret: (name) => (name === 'IMAGE_API_KEY' ? 'sk-x' : ''),
|
|
597
|
+
storeSecret: () => {},
|
|
598
|
+
});
|
|
599
|
+
calls = stubFetch([['/images/generations', () => jsonResponse({ data: [{ b64_json: tinyB64 }] })]]);
|
|
600
|
+
const drewOdd = await cloudWorld.tools().find((t) => t.name === 'image_draw').handler({ prompt: '猫', size: '怪尺寸' });
|
|
601
|
+
check(drewOdd && !drewOdd.failed, '云端那档能出图', JSON.stringify(drewOdd).slice(0, 100));
|
|
602
|
+
check(String(drewOdd.text).includes('不认 size'), '认不出的 size 在回执里说了一句,不是静默', String(drewOdd.text).split('\n')[2]);
|
|
544
603
|
} finally {
|
|
545
604
|
globalThis.fetch = realFetch;
|
|
546
605
|
}
|
|
@@ -548,6 +607,11 @@ try {
|
|
|
548
607
|
section('离线自检样图(落盘 → 索引 → 取图)');
|
|
549
608
|
const self = await world.invoke('gallery', 'selftest', []);
|
|
550
609
|
check(self?.ok === true && self.item?.id, 'selftest 返回条目');
|
|
610
|
+
// 面板「自检」按钮发的是 status.selftest(issue #3:以前只挂在 gallery 上,点了必报未知方法)。
|
|
611
|
+
const selfStatus = await world.invoke('status', 'selftest', []);
|
|
612
|
+
check(selfStatus?.ok === true && selfStatus.item?.id, 'status 面板也能自检(面板按钮走的就是这条)');
|
|
613
|
+
check(existsSync(selfStatus.item.path), 'status 自检的图也真落盘了', String(selfStatus.item?.path));
|
|
614
|
+
await world.invoke('gallery', 'remove', [selfStatus.item.id]); // 别影响后面「只有一张」的断言
|
|
551
615
|
check(existsSync(self.item.path), '图片真的落盘了', String(self.item?.path));
|
|
552
616
|
const bytes = readFileSync(self.item.path);
|
|
553
617
|
check(bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])), 'PNG 魔术字节正确');
|