dsh-agnes-create 0.3.0 → 0.4.1
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 +25 -2
- package/lib/agnes.js +36 -1
- package/lib/client.js +359 -6
- package/lib/index.js +24 -6
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -66,9 +66,31 @@ Key 从 [Agnes AI 平台](https://platform.agnes-ai.com) 获取。端点默认
|
|
|
66
66
|
|
|
67
67
|
| 类型 | 尺寸 | 宽高比 | 其它 |
|
|
68
68
|
| --- | --- | --- | --- |
|
|
69
|
-
| 图片 | `1K` `2K` `3K` `4K` | `1:1` `3:4` `4:3` `16:9` `9:16` `2:3` `3:2` `21:9` |
|
|
69
|
+
| 图片 | `1K` `2K` `3K` `4K` | `1:1` `3:4` `4:3` `16:9` `9:16` `2:3` `3:2` `21:9` | 可选 1–8 张参考图(图生图 / 多图合成) |
|
|
70
70
|
| 视频 | 固定 `720P` | `21:9` `16:9` `4:3` `1:1` `3:4` `9:16` | 时长 4–12 秒 |
|
|
71
71
|
|
|
72
|
+
### 图生图 / 多图合成
|
|
73
|
+
|
|
74
|
+
「参考图」区有两种来源,可以混着加,最多 8 张:
|
|
75
|
+
|
|
76
|
+
- **+ 本地图片** —— 从磁盘选一个或多个文件(PNG / JPEG / WebP / GIF)
|
|
77
|
+
- **+ 生成结果** —— 从下方「最近生成」里挑一张(视频不能当参考图)
|
|
78
|
+
|
|
79
|
+
加好之后提示词写「想怎么改」,按钮会变成 **图生图**:
|
|
80
|
+
|
|
81
|
+
| 参考图数量 | 实际行为 |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| 0 张 | 纯文生图 |
|
|
84
|
+
| 1 张 | 图生图(按参考图重绘 / 改风格 / 换背景) |
|
|
85
|
+
| 2–8 张 | 多图合成(把多张图的元素 / 配色融合) |
|
|
86
|
+
|
|
87
|
+
实现细节:
|
|
88
|
+
|
|
89
|
+
- 图片在**浏览器侧先下采样到长边 1280** 再编码成 data URI(PNG 优先保留透明通道,超过 800 KB 自动转 JPEG),所以一张 4K 原图发出的请求也只有几百 KB
|
|
90
|
+
- 参考图走接口的 `extra_body.image`;多图就是往这个数组里放多条
|
|
91
|
+
- 校验在前端和内核各做一次:只接受 `http(s)` 或 `data:image/…;base64`,超过 8 张、空串、非图片类型都会直接报错,不会把脏数据发给上游
|
|
92
|
+
- 本地文件路径**不会**被读取或上传到别处:文件内容只在内存里转成 base64 随请求发给 Agnes
|
|
93
|
+
|
|
72
94
|
## 关于费用
|
|
73
95
|
|
|
74
96
|
插件的费用由 Agnes AI 账号结算,本插件不涉及任何计费。
|
|
@@ -82,7 +104,8 @@ Key 从 [Agnes AI 平台](https://platform.agnes-ai.com) 获取。端点默认
|
|
|
82
104
|
|
|
83
105
|
- 视频为**异步任务**:提交后轮询,通常需要 1–4 分钟;队列繁忙时提交会返回 `503`,插件会自动重试
|
|
84
106
|
- 查询接口有频率限制,触发 `429` 时面板会显示「限流重试中…」并退避,任务不会因此失败
|
|
85
|
-
-
|
|
107
|
+
- 视频只支持**文生视频**:暂无首尾帧、参考音频与视频输入(Flash 版接口本身不支持这些)
|
|
108
|
+
- 参考图最多 8 张;单张在浏览器侧压到长边 1280,细节极多的小字参考图重绘后可能看不清
|
|
86
109
|
- 生成结果只保留最近 8 条(本次会话内),刷新页面后历史清空;文件本身在输出目录里不会丢
|
|
87
110
|
|
|
88
111
|
## 开发
|
package/lib/agnes.js
CHANGED
|
@@ -40,6 +40,35 @@ export const TIMEOUT_MS = 300_000; // 文档建议 60s–360s
|
|
|
40
40
|
export const SIZES = ['1K', '2K', '3K', '4K'];
|
|
41
41
|
export const RATIOS = ['1:1', '3:4', '4:3', '16:9', '9:16', '2:3', '3:2', '21:9'];
|
|
42
42
|
|
|
43
|
+
/** 图生图 / 多图合成:一次最多几张参考图。文档没写上限,8 张是面板与请求体的安全值。 */
|
|
44
|
+
export const MAX_REF_IMAGES = 8;
|
|
45
|
+
|
|
46
|
+
/** data URI 只接受图片 MIME,其余一律拒绝(防止把任意 base64 塞进上游请求)。 */
|
|
47
|
+
const DATA_URI_RE = /^data:image\/(png|jpe?g|webp|gif);base64,[A-Za-z0-9+/=]+$/i;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 校验并规整参考图数组。
|
|
51
|
+
* 文档要求 extra_body.image 里是「公共图像 URL 或 Data URI Base64」,
|
|
52
|
+
* 所以这里只放行 http(s) 与 data:image 两种,别的直接报错而不是透传给上游。
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeRefs(images) {
|
|
55
|
+
if (images === undefined || images === null) return [];
|
|
56
|
+
if (!Array.isArray(images)) throw new Error('参考图必须是数组');
|
|
57
|
+
if (images.length > MAX_REF_IMAGES) {
|
|
58
|
+
throw new Error(`参考图最多 ${MAX_REF_IMAGES} 张,收到 ${images.length} 张`);
|
|
59
|
+
}
|
|
60
|
+
return images.map((value, i) => {
|
|
61
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
62
|
+
throw new Error(`第 ${i + 1} 张参考图不是有效字符串`);
|
|
63
|
+
}
|
|
64
|
+
const s = value.trim();
|
|
65
|
+
if (/^https?:\/\//i.test(s)) return s;
|
|
66
|
+
if (DATA_URI_RE.test(s)) return s;
|
|
67
|
+
const kind = s.slice(0, 30).replace(/\s+/g, ' ');
|
|
68
|
+
throw new Error(`第 ${i + 1} 张参考图只接受 http(s) 链接或 data:image/…;base64,收到:${kind}`);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
43
72
|
/** 把开头的 ~ 展开成家目录;其余原样返回。 */
|
|
44
73
|
export function expandHome(p) {
|
|
45
74
|
const s = String(p ?? '').trim();
|
|
@@ -210,10 +239,13 @@ export async function generate({
|
|
|
210
239
|
if (!SIZES.includes(size)) throw new Error(`size 必须是 ${SIZES.join(' / ')}`);
|
|
211
240
|
if (!RATIOS.includes(ratio)) throw new Error(`ratio 必须是 ${RATIOS.join(' / ')}`);
|
|
212
241
|
|
|
242
|
+
// 有参考图 → 图生图 / 多图合成;没有 → 纯文生图。校验在发请求之前做完。
|
|
243
|
+
const refs = normalizeRefs(images);
|
|
244
|
+
|
|
213
245
|
const key = await readKey();
|
|
214
246
|
const started = Date.now();
|
|
215
247
|
|
|
216
|
-
let { payload, item } = await post({ baseUrl, key, body: buildBody({ prompt, size, ratio, model, fmt, images }) });
|
|
248
|
+
let { payload, item } = await post({ baseUrl, key, body: buildBody({ prompt, size, ratio, model, fmt, images: refs }) });
|
|
217
249
|
|
|
218
250
|
const dir = outDir ?? (await resolvePaths()).outDir;
|
|
219
251
|
const name = filename || `agnes-${stamp()}.png`;
|
|
@@ -266,6 +298,9 @@ export async function generate({
|
|
|
266
298
|
size,
|
|
267
299
|
ratio,
|
|
268
300
|
model,
|
|
301
|
+
// 有参考图 = 图生图 / 多图合成;没有 = 文生图。面板据此显示模式。
|
|
302
|
+
mode: refs.length > 0 ? 'img2img' : 'text2img',
|
|
303
|
+
refCount: refs.length,
|
|
269
304
|
revisedPrompt: item.revised_prompt ?? null,
|
|
270
305
|
remoteUrl,
|
|
271
306
|
};
|
package/lib/client.js
CHANGED
|
@@ -16,6 +16,12 @@ window.__ModuleLoader__.load({
|
|
|
16
16
|
const HISTORY_MAX = 8;
|
|
17
17
|
const POLL_MS = 6000;
|
|
18
18
|
const VIDEO_DEADLINE_MS = 15 * 60 * 1000;
|
|
19
|
+
// 图生图:一次最多 8 张参考图(与宿主 MAX_REF_IMAGES 保持一致)
|
|
20
|
+
const MAX_REFS = 8;
|
|
21
|
+
// 发给上游前先下采样:长边 1280 足够模型识别,base64 也压得住
|
|
22
|
+
const REF_MAX_EDGE = 1280;
|
|
23
|
+
const REF_QUALITY = 0.88;
|
|
24
|
+
const REF_PNG_KEEP_BYTES = 800 * 1024;
|
|
19
25
|
|
|
20
26
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
21
27
|
|
|
@@ -109,6 +115,35 @@ window.__ModuleLoader__.load({
|
|
|
109
115
|
.agn-hint { font-size: 12px; color: var(--dsw-alias-label-secondary, #888); line-height: 1.6; }
|
|
110
116
|
.agn-code { font-family: var(--ds-font-family-code, ui-monospace, monospace); font-size: 11px;
|
|
111
117
|
word-break: break-all; }
|
|
118
|
+
|
|
119
|
+
/* 参考图(图生图 / 多图合成) */
|
|
120
|
+
.agn-refs { display: flex; flex-direction: column; gap: 8px; }
|
|
121
|
+
.agn-ref-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
122
|
+
.agn-ref-strip { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
123
|
+
.agn-ref { position: relative; width: 72px; height: 72px; border-radius: 8px; overflow: hidden;
|
|
124
|
+
flex: none; border: 1px solid var(--dsw-alias-border-l2, #e3e3e3);
|
|
125
|
+
background: var(--dsw-alias-bg-layer-2, #fafafa); }
|
|
126
|
+
.agn-ref img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
127
|
+
.agn-ref-del { position: absolute; top: 2px; right: 2px; width: 18px; height: 18px;
|
|
128
|
+
border: none; border-radius: 50%; cursor: pointer; padding: 0;
|
|
129
|
+
font-size: 11px; line-height: 18px; text-align: center;
|
|
130
|
+
color: #fff; background: rgba(0, 0, 0, .6); }
|
|
131
|
+
.agn-ref-del:hover { background: rgba(0, 0, 0, .8); }
|
|
132
|
+
.agn-ref-add { width: 72px; height: 72px; border-radius: 8px; cursor: pointer; flex: none;
|
|
133
|
+
font: inherit; font-size: 11px; line-height: 1.35; padding: 4px;
|
|
134
|
+
color: var(--dsw-alias-label-secondary, #666); background: transparent;
|
|
135
|
+
border: 1px dashed var(--dsw-alias-border-l2, #ccc); }
|
|
136
|
+
.agn-ref-add:disabled { opacity: .45; cursor: not-allowed; }
|
|
137
|
+
.agn-ref-pick { display: flex; gap: 6px; flex-wrap: wrap; padding: 8px; border-radius: 8px;
|
|
138
|
+
border: 1px solid var(--dsw-alias-border-l2, #e3e3e3);
|
|
139
|
+
background: var(--dsw-alias-bg-layer-2, #fafafa); }
|
|
140
|
+
.agn-ref-pick button { width: 56px; height: 56px; padding: 0; border-radius: 6px;
|
|
141
|
+
overflow: hidden; cursor: pointer; flex: none;
|
|
142
|
+
border: 1px solid var(--dsw-alias-border-l2, #ddd); background: transparent; }
|
|
143
|
+
.agn-ref-pick img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
144
|
+
.agn-tag { font-size: 11px; padding: 2px 8px; border-radius: 999px;
|
|
145
|
+
background: var(--dsw-alias-bg-layer-2, #f1f1f1);
|
|
146
|
+
color: var(--dsw-alias-label-secondary, #555); }
|
|
112
147
|
`;
|
|
113
148
|
|
|
114
149
|
function ensureCss() {
|
|
@@ -125,6 +160,84 @@ window.__ModuleLoader__.load({
|
|
|
125
160
|
return "/agnes/media?name=" + encodeURIComponent(name);
|
|
126
161
|
}
|
|
127
162
|
|
|
163
|
+
/* ==========================================================================
|
|
164
|
+
参考图预处理(图生图)
|
|
165
|
+
--------------------------------------------------------------------------
|
|
166
|
+
两个来源最终都归一成 data URI:
|
|
167
|
+
· 本地文件 —— <input type="file"> 给的 File
|
|
168
|
+
· 生成结果 —— /agnes/media 取回 Blob
|
|
169
|
+
先按长边 1280 下采样再编码,否则一张 4K PNG 的 base64 就有十几 MB,
|
|
170
|
+
宿主请求体和上游都要跟着炸。
|
|
171
|
+
========================================================================== */
|
|
172
|
+
|
|
173
|
+
let refSeq = 0;
|
|
174
|
+
const nextRefId = () => "ref-" + (refSeq += 1) + "-" + Date.now();
|
|
175
|
+
|
|
176
|
+
function blobToDataUrl(blob) {
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
const fr = new FileReader();
|
|
179
|
+
fr.onload = () => resolve(String(fr.result));
|
|
180
|
+
fr.onerror = () => reject(new Error("读取图片失败"));
|
|
181
|
+
fr.readAsDataURL(blob);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 编码一次 canvas;toBlob 回调可能是 null(极端尺寸/内存不足) */
|
|
186
|
+
function canvasToBlob(canvas, type, quality) {
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
try {
|
|
189
|
+
canvas.toBlob((b) => resolve(b), type, quality);
|
|
190
|
+
} catch {
|
|
191
|
+
resolve(null);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 把任意图片 Blob 压成适合上传的 data URI。
|
|
198
|
+
* PNG 优先保留(多图合成常带透明通道),但超过阈值就退回 JPEG。
|
|
199
|
+
*/
|
|
200
|
+
async function shrinkImage(blob) {
|
|
201
|
+
if (!(blob instanceof Blob) || blob.size === 0) throw new Error("图片是空的");
|
|
202
|
+
|
|
203
|
+
let bitmap;
|
|
204
|
+
try {
|
|
205
|
+
bitmap = await createImageBitmap(blob);
|
|
206
|
+
} catch {
|
|
207
|
+
// 极少数格式解码不了:小图原样上传,大图明确报错而不是偷偷传十几 MB
|
|
208
|
+
if (blob.size <= REF_PNG_KEEP_BYTES) {
|
|
209
|
+
return { src: await blobToDataUrl(blob), bytes: blob.size, shrunk: false };
|
|
210
|
+
}
|
|
211
|
+
throw new Error("无法解码这张图片(试试 PNG/JPEG/WebP)");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const longest = Math.max(bitmap.width, bitmap.height) || 1;
|
|
215
|
+
const scale = Math.min(1, REF_MAX_EDGE / longest);
|
|
216
|
+
const w = Math.max(1, Math.round(bitmap.width * scale));
|
|
217
|
+
const h = Math.max(1, Math.round(bitmap.height * scale));
|
|
218
|
+
|
|
219
|
+
const canvas = document.createElement("canvas");
|
|
220
|
+
canvas.width = w;
|
|
221
|
+
canvas.height = h;
|
|
222
|
+
const ctx = canvas.getContext("2d");
|
|
223
|
+
if (ctx === null) {
|
|
224
|
+
if (typeof bitmap.close === "function") bitmap.close();
|
|
225
|
+
return { src: await blobToDataUrl(blob), bytes: blob.size, shrunk: false };
|
|
226
|
+
}
|
|
227
|
+
ctx.drawImage(bitmap, 0, 0, w, h);
|
|
228
|
+
if (typeof bitmap.close === "function") bitmap.close();
|
|
229
|
+
|
|
230
|
+
const wantPng = blob.type === "image/png";
|
|
231
|
+
let out = await canvasToBlob(canvas, wantPng ? "image/png" : "image/jpeg", REF_QUALITY);
|
|
232
|
+
if (out === null) throw new Error("图片编码失败");
|
|
233
|
+
if (wantPng && out.size > REF_PNG_KEEP_BYTES) {
|
|
234
|
+
const jpeg = await canvasToBlob(canvas, "image/jpeg", REF_QUALITY);
|
|
235
|
+
if (jpeg !== null && jpeg.size < out.size) out = jpeg;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { src: await blobToDataUrl(out), bytes: out.size, shrunk: scale < 1 };
|
|
239
|
+
}
|
|
240
|
+
|
|
128
241
|
/* ==========================================================================
|
|
129
242
|
状态仓库(放组件外面:main slot 切换会卸载组件,状态必须独立存活)
|
|
130
243
|
========================================================================== */
|
|
@@ -160,6 +273,12 @@ window.__ModuleLoader__.load({
|
|
|
160
273
|
cfgSaving: false,
|
|
161
274
|
cfgError: "",
|
|
162
275
|
cfgNotice: "",
|
|
276
|
+
|
|
277
|
+
/* ---- 图生图参考图 ---- */
|
|
278
|
+
refs: [], // { id, name, src(dataURI), bytes, from: 'local' | 'result' }
|
|
279
|
+
refsBusy: false,
|
|
280
|
+
refsError: "",
|
|
281
|
+
refPickOpen: false,
|
|
163
282
|
};
|
|
164
283
|
|
|
165
284
|
const snapshot = () => ({ ...state, history: state.history.slice() });
|
|
@@ -172,6 +291,12 @@ window.__ModuleLoader__.load({
|
|
|
172
291
|
Object.assign(state, patch);
|
|
173
292
|
notify();
|
|
174
293
|
};
|
|
294
|
+
/** 基于当前状态计算补丁:避免「读旧值再写回」导致的覆盖。 */
|
|
295
|
+
const update = (fn) => {
|
|
296
|
+
const patch = fn(state);
|
|
297
|
+
if (patch !== null && patch !== undefined) Object.assign(state, patch);
|
|
298
|
+
notify();
|
|
299
|
+
};
|
|
175
300
|
|
|
176
301
|
let timer = null;
|
|
177
302
|
let runToken = 0;
|
|
@@ -259,10 +384,18 @@ window.__ModuleLoader__.load({
|
|
|
259
384
|
|
|
260
385
|
/* ---------- 图片:一次请求就出结果 ---------- */
|
|
261
386
|
async function runImage(token, prompt) {
|
|
387
|
+
const refs = state.refs;
|
|
388
|
+
const payload = {
|
|
389
|
+
prompt,
|
|
390
|
+
size: state.size,
|
|
391
|
+
ratio: state.ratio,
|
|
392
|
+
// 有参考图就是图生图 / 多图合成;宿主原样透传给上游的 extra_body.image
|
|
393
|
+
...(refs.length > 0 ? { images: refs.map((r) => r.src) } : {}),
|
|
394
|
+
};
|
|
262
395
|
const res = await fetch("/agnes/generate", {
|
|
263
396
|
method: "POST",
|
|
264
397
|
headers: { "content-type": "application/json" },
|
|
265
|
-
body: JSON.stringify(
|
|
398
|
+
body: JSON.stringify(payload),
|
|
266
399
|
});
|
|
267
400
|
const data = await res.json();
|
|
268
401
|
if (!data.ok) throw new Error(data.error || "生成失败");
|
|
@@ -270,6 +403,122 @@ window.__ModuleLoader__.load({
|
|
|
270
403
|
pushResult({ ...data, kind: "image", wallSec: ((Date.now() - state.startedAt) / 1000).toFixed(1) });
|
|
271
404
|
}
|
|
272
405
|
|
|
406
|
+
/* ---------- 参考图(图生图) ---------- */
|
|
407
|
+
|
|
408
|
+
const refsFull = () => state.refs.length >= MAX_REFS;
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* 参考图操作串行化。
|
|
412
|
+
*
|
|
413
|
+
* 之前用 `if (state.refsBusy) return` 做互斥:一旦某个操作正在跑
|
|
414
|
+
* (读生成结果要 fetch + 解码,可能几百毫秒),这期间用户的另一次添加
|
|
415
|
+
* 会被**静默丢弃**——点了没反应,看起来就像"刚加的图不见了"。
|
|
416
|
+
* 现在改成排队执行:不丢单,也不会出现两个操作互相覆盖 refs。
|
|
417
|
+
*/
|
|
418
|
+
let refQueue = Promise.resolve();
|
|
419
|
+
function queueRefOp(task) {
|
|
420
|
+
const run = refQueue.then(task, task);
|
|
421
|
+
// 单个操作失败不能卡死后面的队列
|
|
422
|
+
refQueue = run.catch(() => {});
|
|
423
|
+
return run;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** 来源一:本地磁盘文件(可多选)。返回 Promise,调用方可等它读完再清空 input。 */
|
|
427
|
+
function addRefsFromFiles(files) {
|
|
428
|
+
const list = Array.from(files || []).filter(
|
|
429
|
+
(f) => f && String(f.type || "").startsWith("image/")
|
|
430
|
+
);
|
|
431
|
+
if (list.length === 0) {
|
|
432
|
+
set({ refsError: "没有可用的图片文件(需要 PNG / JPEG / WebP / GIF)" });
|
|
433
|
+
return Promise.resolve();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return queueRefOp(async () => {
|
|
437
|
+
set({ refsBusy: true, refsError: "" });
|
|
438
|
+
const added = [];
|
|
439
|
+
const failed = [];
|
|
440
|
+
try {
|
|
441
|
+
for (const file of list) {
|
|
442
|
+
if (state.refs.length + added.length >= MAX_REFS) {
|
|
443
|
+
failed.push("超出 " + MAX_REFS + " 张上限");
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
// 先把文件读进内存再继续:即使 input.value 被清空,读也不会断
|
|
448
|
+
const r = await shrinkImage(file);
|
|
449
|
+
added.push({
|
|
450
|
+
id: nextRefId(),
|
|
451
|
+
name: file.name || "本地图片",
|
|
452
|
+
src: r.src,
|
|
453
|
+
bytes: r.bytes,
|
|
454
|
+
from: "local",
|
|
455
|
+
});
|
|
456
|
+
} catch (e) {
|
|
457
|
+
failed.push((file.name || "图片") + ":" + (e && e.message ? e.message : String(e)));
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (added.length > 0) {
|
|
461
|
+
// 用 update 基于最新状态追加,杜绝覆盖别的来源刚加进来的图
|
|
462
|
+
update((prev) => ({ refs: prev.refs.concat(added) }));
|
|
463
|
+
}
|
|
464
|
+
set({
|
|
465
|
+
refsError: failed.length > 0 ? failed.join(";") : "",
|
|
466
|
+
refPickOpen: false,
|
|
467
|
+
});
|
|
468
|
+
} finally {
|
|
469
|
+
set({ refsBusy: false });
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** 来源二:生成结果里的图片(通过 /agnes/media 取回再下采样) */
|
|
475
|
+
function addRefFromResult(entry) {
|
|
476
|
+
if (!entry || entry.kind !== "image" || !entry.filename) return Promise.resolve();
|
|
477
|
+
if (refsFull()) {
|
|
478
|
+
set({ refsError: "参考图最多 " + MAX_REFS + " 张" });
|
|
479
|
+
return Promise.resolve();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return queueRefOp(async () => {
|
|
483
|
+
set({ refsBusy: true, refsError: "" });
|
|
484
|
+
try {
|
|
485
|
+
const res = await fetch(srcOf(entry.filename));
|
|
486
|
+
if (!res.ok) throw new Error("读取生成结果失败:HTTP " + res.status);
|
|
487
|
+
const blob = await res.blob();
|
|
488
|
+
const r = await shrinkImage(blob);
|
|
489
|
+
update((prev) => ({
|
|
490
|
+
refs: prev.refs.concat([{
|
|
491
|
+
id: nextRefId(),
|
|
492
|
+
name: entry.filename,
|
|
493
|
+
src: r.src,
|
|
494
|
+
bytes: r.bytes,
|
|
495
|
+
from: "result",
|
|
496
|
+
}]),
|
|
497
|
+
refPickOpen: false,
|
|
498
|
+
}));
|
|
499
|
+
} catch (e) {
|
|
500
|
+
set({ refsError: e && e.message ? e.message : String(e) });
|
|
501
|
+
} finally {
|
|
502
|
+
set({ refsBusy: false });
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function removeRef(id) {
|
|
508
|
+
if (state.busy) return;
|
|
509
|
+
update((prev) => ({ refs: prev.refs.filter((r) => r.id !== id), refsError: "" }));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function clearRefs() {
|
|
513
|
+
if (state.busy) return;
|
|
514
|
+
set({ refs: [], refsError: "", refPickOpen: false });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function toggleRefPick() {
|
|
518
|
+
if (state.busy) return;
|
|
519
|
+
set({ refPickOpen: !state.refPickOpen, refsError: "" });
|
|
520
|
+
}
|
|
521
|
+
|
|
273
522
|
/* ---------- 视频:提交 → 轮询 → 下载完成 ---------- */
|
|
274
523
|
async function runVideo(token, prompt) {
|
|
275
524
|
set({ vStatus: "提交中…", vProgress: 0, videoId: null });
|
|
@@ -344,7 +593,7 @@ window.__ModuleLoader__.load({
|
|
|
344
593
|
}
|
|
345
594
|
|
|
346
595
|
async function run() {
|
|
347
|
-
if (state.busy) return;
|
|
596
|
+
if (state.busy || state.refsBusy) return;
|
|
348
597
|
const prompt = state.prompt.trim();
|
|
349
598
|
if (prompt === "") return;
|
|
350
599
|
|
|
@@ -385,6 +634,13 @@ window.__ModuleLoader__.load({
|
|
|
385
634
|
},
|
|
386
635
|
toggleCfg() { set({ cfgOpen: !state.cfgOpen }); },
|
|
387
636
|
run,
|
|
637
|
+
/* ---- 参考图动作 ---- */
|
|
638
|
+
addRefsFromFiles,
|
|
639
|
+
addRefFromResult,
|
|
640
|
+
removeRef,
|
|
641
|
+
clearRefs,
|
|
642
|
+
toggleRefPick,
|
|
643
|
+
maxRefs: MAX_REFS,
|
|
388
644
|
/** 切换主预览到历史里的某一张 */
|
|
389
645
|
show(entry) { set({ result: entry }); },
|
|
390
646
|
/**
|
|
@@ -506,14 +762,18 @@ window.__ModuleLoader__.load({
|
|
|
506
762
|
const key = s.health && s.health.key ? s.health.key : null;
|
|
507
763
|
const keyBad = key !== null && key.present !== true;
|
|
508
764
|
const shown = s.result;
|
|
765
|
+
const hasRefs = s.refs.length > 0;
|
|
766
|
+
const refsFull = s.refs.length >= MAX_REFS;
|
|
767
|
+
const imageHistory = s.history.filter((h) => h.kind === "image");
|
|
768
|
+
const fileInput = React.useRef(null);
|
|
509
769
|
|
|
510
770
|
const onKeyDown = (e) => {
|
|
511
771
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); store.run(); }
|
|
512
772
|
};
|
|
513
773
|
|
|
514
774
|
const buttonLabel = busy
|
|
515
|
-
? (isVideo ? "生成视频… " : "生成中… ") + (s.elapsed > 0 ? s.elapsed.toFixed(0) + "s" : "")
|
|
516
|
-
: (isVideo ? "生成视频" : "生成");
|
|
775
|
+
? (isVideo ? "生成视频… " : (hasRefs ? "图生图中… " : "生成中… ")) + (s.elapsed > 0 ? s.elapsed.toFixed(0) + "s" : "")
|
|
776
|
+
: (isVideo ? "生成视频" : (hasRefs ? "图生图" : "生成"));
|
|
517
777
|
|
|
518
778
|
const modeBtn = (mode, text) => React.createElement("button", {
|
|
519
779
|
key: mode,
|
|
@@ -556,6 +816,25 @@ window.__ModuleLoader__.load({
|
|
|
556
816
|
: (shown.kind === "video" ? "生成结果 · 视频" : "生成结果 · 图片");
|
|
557
817
|
|
|
558
818
|
return React.createElement("div", { className: "agn-root" },
|
|
819
|
+
/* 隐藏的文件选择器:由「+ 本地图片」按钮触发,支持多选 */
|
|
820
|
+
React.createElement("input", {
|
|
821
|
+
ref: fileInput,
|
|
822
|
+
type: "file",
|
|
823
|
+
accept: "image/png,image/jpeg,image/webp,image/gif",
|
|
824
|
+
multiple: true,
|
|
825
|
+
style: { display: "none" },
|
|
826
|
+
onChange: (e) => {
|
|
827
|
+
const el = e.target;
|
|
828
|
+
const files = el.files;
|
|
829
|
+
// 关键:等文件真正读完再清空 input。
|
|
830
|
+
// 之前是「发起读取后立刻 el.value = ''」,Chromium 下会把刚选的文件
|
|
831
|
+
// 的底层数据一起释放,读取中途失败 → 这一次添加被整单丢掉。
|
|
832
|
+
Promise.resolve(store.addRefsFromFiles(files))
|
|
833
|
+
.catch(() => {})
|
|
834
|
+
.finally(() => { try { el.value = ""; } catch { /* input 可能已卸载 */ } });
|
|
835
|
+
},
|
|
836
|
+
}),
|
|
837
|
+
|
|
559
838
|
React.createElement("div", { className: "agn-head" },
|
|
560
839
|
React.createElement("span", { className: "agn-title" }, "Agnes 创作"),
|
|
561
840
|
React.createElement("span", { className: "agn-meta" },
|
|
@@ -569,6 +848,79 @@ window.__ModuleLoader__.load({
|
|
|
569
848
|
modeBtn("image", "图片"),
|
|
570
849
|
modeBtn("video", "视频")
|
|
571
850
|
),
|
|
851
|
+
|
|
852
|
+
/* ---- 参考图(图生图 / 多图合成):本地文件 + 生成结果两种来源 ---- */
|
|
853
|
+
!isVideo
|
|
854
|
+
? React.createElement("div", { className: "agn-refs" },
|
|
855
|
+
React.createElement("div", { className: "agn-ref-head" },
|
|
856
|
+
React.createElement("span", { className: "agn-label" }, "参考图"),
|
|
857
|
+
React.createElement("span", { className: "agn-tag" },
|
|
858
|
+
s.refs.length + " / " + MAX_REFS),
|
|
859
|
+
s.refsBusy ? React.createElement("span", { className: "agn-hint" }, "处理中…") : null,
|
|
860
|
+
s.refs.length > 0
|
|
861
|
+
? React.createElement("button", {
|
|
862
|
+
type: "button", className: "agn-btn-ghost", disabled: busy,
|
|
863
|
+
onClick: () => store.clearRefs(),
|
|
864
|
+
}, "清空参考图")
|
|
865
|
+
: null
|
|
866
|
+
),
|
|
867
|
+
React.createElement("div", { className: "agn-ref-strip" },
|
|
868
|
+
s.refs.map((r) => React.createElement("div", {
|
|
869
|
+
key: r.id,
|
|
870
|
+
className: "agn-ref",
|
|
871
|
+
title: r.name + " · " + (r.bytes / 1024).toFixed(0) + " KB · " +
|
|
872
|
+
(r.from === "result" ? "来自生成结果" : "来自本地"),
|
|
873
|
+
},
|
|
874
|
+
React.createElement("img", { src: r.src, alt: r.name }),
|
|
875
|
+
React.createElement("button", {
|
|
876
|
+
type: "button", className: "agn-ref-del",
|
|
877
|
+
title: "移除这张参考图",
|
|
878
|
+
disabled: busy,
|
|
879
|
+
onClick: () => store.removeRef(r.id),
|
|
880
|
+
}, "×")
|
|
881
|
+
)),
|
|
882
|
+
React.createElement("button", {
|
|
883
|
+
type: "button", className: "agn-ref-add",
|
|
884
|
+
disabled: busy || refsFull,
|
|
885
|
+
title: "从本地磁盘选择一个或多个图片(PNG / JPEG / WebP / GIF)",
|
|
886
|
+
onClick: () => {
|
|
887
|
+
const el = fileInput.current;
|
|
888
|
+
if (el !== null) el.click();
|
|
889
|
+
},
|
|
890
|
+
}, "+ 本地图片"),
|
|
891
|
+
React.createElement("button", {
|
|
892
|
+
type: "button", className: "agn-ref-add",
|
|
893
|
+
disabled: busy || refsFull || imageHistory.length === 0,
|
|
894
|
+
title: imageHistory.length === 0 ? "还没有生成过图片" : "从「生成结果」里挑一张",
|
|
895
|
+
onClick: () => store.toggleRefPick(),
|
|
896
|
+
}, "+ 生成结果")
|
|
897
|
+
),
|
|
898
|
+
|
|
899
|
+
/* 从生成结果挑图:只列图片(视频不能当参考图) */
|
|
900
|
+
s.refPickOpen
|
|
901
|
+
? React.createElement("div", { className: "agn-ref-pick" },
|
|
902
|
+
imageHistory.length === 0
|
|
903
|
+
? React.createElement("span", { className: "agn-hint" }, "还没有生成过图片")
|
|
904
|
+
: imageHistory.map((h) => React.createElement("button", {
|
|
905
|
+
key: "pick-" + h.filename,
|
|
906
|
+
type: "button",
|
|
907
|
+
title: h.filename,
|
|
908
|
+
onClick: () => store.addRefFromResult(h),
|
|
909
|
+
}, React.createElement("img", {
|
|
910
|
+
src: srcOf(h.filename), alt: h.filename, loading: "lazy",
|
|
911
|
+
})))
|
|
912
|
+
)
|
|
913
|
+
: null,
|
|
914
|
+
|
|
915
|
+
React.createElement("div", { className: "agn-note" },
|
|
916
|
+
hasRefs
|
|
917
|
+
? "将按参考图重绘(" + s.refs.length + " 张" +
|
|
918
|
+
(s.refs.length > 1 ? ",多图合成" : "") + ")——提示词写「想怎么改」"
|
|
919
|
+
: "不加参考图 = 纯文生图;加 1 张 = 图生图;加多张 = 多图合成"),
|
|
920
|
+
s.refsError !== "" ? React.createElement("div", { className: "agn-err" }, s.refsError) : null
|
|
921
|
+
)
|
|
922
|
+
: null,
|
|
923
|
+
|
|
572
924
|
React.createElement("div", { className: "agn-label" }, "提示词"),
|
|
573
925
|
React.createElement("textarea", {
|
|
574
926
|
className: "agn-textarea",
|
|
@@ -584,7 +936,7 @@ window.__ModuleLoader__.load({
|
|
|
584
936
|
params,
|
|
585
937
|
React.createElement("button", {
|
|
586
938
|
className: "agn-btn", type: "button",
|
|
587
|
-
disabled: busy || s.prompt.trim() === "" || keyBad,
|
|
939
|
+
disabled: busy || s.refsBusy || s.prompt.trim() === "" || keyBad,
|
|
588
940
|
onClick: () => store.run(),
|
|
589
941
|
}, buttonLabel)
|
|
590
942
|
),
|
|
@@ -642,6 +994,7 @@ window.__ModuleLoader__.load({
|
|
|
642
994
|
: (shown.bytes / 1024).toFixed(0) + " KB")
|
|
643
995
|
+ " · " + shown.size + "/" + shown.ratio
|
|
644
996
|
+ (shown.seconds ? " · " + shown.seconds + " 秒" : "")
|
|
997
|
+
+ (shown.mode === "img2img" ? " · 图生图 " + (shown.refCount || 0) + " 张参考" : "")
|
|
645
998
|
+ " · 用时 " + shown.elapsedSec + "s"),
|
|
646
999
|
React.createElement("br"),
|
|
647
1000
|
React.createElement("span", null, "已保存到 "),
|
|
@@ -777,7 +1130,7 @@ window.__ModuleLoader__.load({
|
|
|
777
1130
|
|
|
778
1131
|
exports.apply = apply;
|
|
779
1132
|
exports.inject = ["slots"];
|
|
780
|
-
exports.__internals = { getStore, createStore, srcOf };
|
|
1133
|
+
exports.__internals = { getStore, createStore, srcOf, shrinkImage, MAX_REFS };
|
|
781
1134
|
return module.exports;
|
|
782
1135
|
},
|
|
783
1136
|
});
|
package/lib/index.js
CHANGED
|
@@ -20,13 +20,20 @@
|
|
|
20
20
|
|
|
21
21
|
import {
|
|
22
22
|
MODEL, VIDEO_MODEL, VIDEO_SECONDS, VIDEO_RATIOS, VIDEO_SIZE,
|
|
23
|
-
DEFAULT_BASE, SIZES, RATIOS,
|
|
23
|
+
DEFAULT_BASE, SIZES, RATIOS, MAX_REF_IMAGES,
|
|
24
24
|
generate, keyStatus, readMedia, resolvePaths, saveConfig,
|
|
25
25
|
submitVideo, queryVideo, saveVideo,
|
|
26
26
|
} from './agnes.js';
|
|
27
27
|
|
|
28
28
|
export const name = 'dsh-agnes-create';
|
|
29
29
|
|
|
30
|
+
/** 普通 JSON 请求(配置、视频)1 MB 够用。 */
|
|
31
|
+
const BODY_LIMIT = 1_000_000;
|
|
32
|
+
|
|
33
|
+
/** 图生图的参考图走 base64 data URI,必须单独放宽到 32 MB:
|
|
34
|
+
* 8 张 × 前端下采样后的约 300–600 KB,base64 膨胀 4/3,再留足余量。 */
|
|
35
|
+
const REF_BODY_LIMIT = 32 * 1024 * 1024;
|
|
36
|
+
|
|
30
37
|
function sendJson(res, data, status = 200) {
|
|
31
38
|
res.writeHead(status, {
|
|
32
39
|
'content-type': 'application/json; charset=utf-8',
|
|
@@ -35,20 +42,24 @@ function sendJson(res, data, status = 200) {
|
|
|
35
42
|
res.end(JSON.stringify(data));
|
|
36
43
|
}
|
|
37
44
|
|
|
38
|
-
function readJson(req) {
|
|
45
|
+
function readJson(req, limit = BODY_LIMIT) {
|
|
39
46
|
return new Promise((resolve, reject) => {
|
|
40
47
|
const chunks = [];
|
|
41
48
|
let size = 0;
|
|
49
|
+
let aborted = false;
|
|
42
50
|
req.on('data', (c) => {
|
|
51
|
+
if (aborted) return;
|
|
43
52
|
size += c.length;
|
|
44
|
-
if (size >
|
|
45
|
-
|
|
53
|
+
if (size > limit) {
|
|
54
|
+
aborted = true;
|
|
55
|
+
reject(new Error(`请求体过大(上限 ${(limit / 1024 / 1024).toFixed(0)} MB)`));
|
|
46
56
|
req.destroy();
|
|
47
57
|
return;
|
|
48
58
|
}
|
|
49
59
|
chunks.push(c);
|
|
50
60
|
});
|
|
51
61
|
req.on('end', () => {
|
|
62
|
+
if (aborted) return;
|
|
52
63
|
const text = Buffer.concat(chunks).toString('utf8');
|
|
53
64
|
if (text.trim() === '') return resolve({});
|
|
54
65
|
try {
|
|
@@ -148,6 +159,7 @@ export function apply(ctx) {
|
|
|
148
159
|
defaults: cfg.defaults,
|
|
149
160
|
sizes: SIZES,
|
|
150
161
|
ratios: RATIOS,
|
|
162
|
+
maxRefImages: MAX_REF_IMAGES, // 图生图 / 多图合成的参考图上限
|
|
151
163
|
videoSizes: [VIDEO_SIZE],
|
|
152
164
|
videoSeconds: VIDEO_SECONDS,
|
|
153
165
|
videoRatios: VIDEO_RATIOS,
|
|
@@ -210,7 +222,7 @@ export function apply(ctx) {
|
|
|
210
222
|
handler: async (req, res) => {
|
|
211
223
|
if (req.method !== 'POST') return sendJson(res, { ok: false, error: '需要 POST' }, 405);
|
|
212
224
|
try {
|
|
213
|
-
const body = await readJson(req);
|
|
225
|
+
const body = await readJson(req, REF_BODY_LIMIT);
|
|
214
226
|
if (typeof body.prompt !== 'string' || body.prompt.trim() === '') {
|
|
215
227
|
return sendJson(res, { ok: false, error: '提示词不能为空' }, 400);
|
|
216
228
|
}
|
|
@@ -219,10 +231,16 @@ export function apply(ctx) {
|
|
|
219
231
|
size: body.size,
|
|
220
232
|
ratio: body.ratio,
|
|
221
233
|
model: body.model,
|
|
234
|
+
// 图生图 / 多图合成:参考图数组(data URI 或 http(s) 链接)。
|
|
235
|
+
// 校验在内核 normalizeRefs 里做,这里只负责透传。
|
|
236
|
+
images: body.images,
|
|
222
237
|
});
|
|
223
238
|
sendJson(res, { ...result, kind: 'image' });
|
|
224
239
|
} catch (e) {
|
|
225
|
-
|
|
240
|
+
// 参考图参数不合法属于用户输入问题,用 400 区分于上游 500
|
|
241
|
+
const msg = errMessage(e);
|
|
242
|
+
const status = /参考图|images|prompt|size 必须|ratio 必须/.test(msg) ? 400 : 500;
|
|
243
|
+
sendJson(res, { ok: false, error: msg }, status);
|
|
226
244
|
}
|
|
227
245
|
},
|
|
228
246
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agnes-create",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Agnes 创作:在 DeepSeek Harness 里用 agnes-image-2.5-flash
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "Agnes 创作:在 DeepSeek Harness 里用 agnes-image-2.5-flash 出图(含图生图 / 多图合成)、agnes-video-2.5-flash 出视频。侧边栏面板 + 提示词 + 参考图,结果内联展示,Key 与输出目录可在面板配置。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -31,7 +31,10 @@
|
|
|
31
31
|
"deepseek-harness",
|
|
32
32
|
"agnes",
|
|
33
33
|
"image-generation",
|
|
34
|
-
"video-generation"
|
|
34
|
+
"video-generation",
|
|
35
|
+
"image-to-image",
|
|
36
|
+
"img2img",
|
|
37
|
+
"multi-image"
|
|
35
38
|
],
|
|
36
39
|
"engines": {
|
|
37
40
|
"dsh": ">=0.2.2-rc.1"
|