dsh-agnes-create 0.4.1 → 0.10.3
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 +78 -5
- package/lib/agnes.js +545 -14
- package/lib/client.js +1164 -62
- package/lib/index.js +625 -13
- package/package.json +7 -3
package/lib/agnes.js
CHANGED
|
@@ -12,13 +12,18 @@
|
|
|
12
12
|
* API Key 永远从文件读,不进代码、不进日志。
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
15
|
+
import { readFile, writeFile, mkdir, readdir, stat, access } from 'node:fs/promises';
|
|
16
16
|
import { homedir } from 'node:os';
|
|
17
17
|
import { join, basename } from 'node:path';
|
|
18
18
|
|
|
19
19
|
export const MODEL = 'agnes-image-2.5-flash';
|
|
20
20
|
export const DEFAULT_BASE = 'https://api.agnes-ai.cn/v1'; // 中国站
|
|
21
21
|
|
|
22
|
+
/** 画图模型候选:上游 /models 目前只列出这两个图像模型(2.0 已下架)。 */
|
|
23
|
+
export const IMAGE_MODELS = ['agnes-image-2.5-flash', 'agnes-image-2.1-flash'];
|
|
24
|
+
/** 视频模型候选。 */
|
|
25
|
+
export const VIDEO_MODELS = ['agnes-video-2.5-flash', 'agnes-video-2.5'];
|
|
26
|
+
|
|
22
27
|
/* ==========================================================================
|
|
23
28
|
可配置路径:Key 文件位置 / 输出目录
|
|
24
29
|
--------------------------------------------------------------------------
|
|
@@ -26,8 +31,12 @@ export const DEFAULT_BASE = 'https://api.agnes-ai.cn/v1'; // 中国站
|
|
|
26
31
|
配置存于 ~/.dsh/agnes-image/config.json,由面板里的「设置」卡片写入。
|
|
27
32
|
========================================================================== */
|
|
28
33
|
|
|
29
|
-
export const CONFIG_DIR = join(homedir(), '.dsh', 'agnes-
|
|
34
|
+
export const CONFIG_DIR = join(homedir(), '.dsh', 'agnes-create');
|
|
30
35
|
export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
36
|
+
/** 旧目录:包名改名前配置存在 ~/.dsh/agnes-image/,读的时候回退,写只写新路径。 */
|
|
37
|
+
export const LEGACY_CONFIG_FILE = join(homedir(), '.dsh', 'agnes-image', 'config.json');
|
|
38
|
+
/** 生成历史落盘位置(面板重启后仍能看到最近的结果)。 */
|
|
39
|
+
export const HISTORY_FILE = join(CONFIG_DIR, 'history.json');
|
|
31
40
|
export const DEFAULT_KEY_FILE = join(homedir(), '.agnes-key.txt');
|
|
32
41
|
export const DEFAULT_OUT_DIR = join(homedir(), 'Desktop', 'Agnes出图');
|
|
33
42
|
|
|
@@ -51,11 +60,11 @@ const DATA_URI_RE = /^data:image\/(png|jpe?g|webp|gif);base64,[A-Za-z0-9+/=]+$/i
|
|
|
51
60
|
* 文档要求 extra_body.image 里是「公共图像 URL 或 Data URI Base64」,
|
|
52
61
|
* 所以这里只放行 http(s) 与 data:image 两种,别的直接报错而不是透传给上游。
|
|
53
62
|
*/
|
|
54
|
-
export function normalizeRefs(images) {
|
|
63
|
+
export function normalizeRefs(images, limit = MAX_REF_IMAGES) {
|
|
55
64
|
if (images === undefined || images === null) return [];
|
|
56
65
|
if (!Array.isArray(images)) throw new Error('参考图必须是数组');
|
|
57
|
-
if (images.length >
|
|
58
|
-
throw new Error(`参考图最多 ${
|
|
66
|
+
if (images.length > limit) {
|
|
67
|
+
throw new Error(`参考图最多 ${limit} 张,收到 ${images.length} 张`);
|
|
59
68
|
}
|
|
60
69
|
return images.map((value, i) => {
|
|
61
70
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
@@ -77,28 +86,71 @@ export function expandHome(p) {
|
|
|
77
86
|
return s;
|
|
78
87
|
}
|
|
79
88
|
|
|
89
|
+
/** 只接受候选表里的模型名:配置里写了乱七八糟的值就当没写,回退默认。 */
|
|
90
|
+
function pickModel(value, choices) {
|
|
91
|
+
if (typeof value !== 'string') return null;
|
|
92
|
+
const name = value.trim();
|
|
93
|
+
return choices.includes(name) ? name : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
80
96
|
/**
|
|
81
97
|
* 解析生效配置。
|
|
82
98
|
* 配置文件不存在或内容损坏时一律退回默认值 —— 配置坏了不该把插件拖死。
|
|
83
99
|
*/
|
|
84
100
|
export async function resolvePaths() {
|
|
85
101
|
let stored = {};
|
|
102
|
+
let configFile = CONFIG_FILE;
|
|
86
103
|
try {
|
|
87
104
|
const raw = JSON.parse(await readFile(CONFIG_FILE, 'utf8'));
|
|
88
105
|
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) stored = raw;
|
|
89
|
-
} catch {
|
|
106
|
+
} catch {
|
|
107
|
+
// 新路径没有 → 读旧路径(改名前的配置),迁移到新目录
|
|
108
|
+
try {
|
|
109
|
+
const raw = JSON.parse(await readFile(LEGACY_CONFIG_FILE, 'utf8'));
|
|
110
|
+
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
111
|
+
stored = raw;
|
|
112
|
+
configFile = CONFIG_FILE;
|
|
113
|
+
// 直接写文件,别调 saveConfig():saveConfig 内部会先 resolvePaths(),
|
|
114
|
+
// 而此刻新配置还没落盘,于是又走到这里 —— 无限递归。
|
|
115
|
+
const migrated = {};
|
|
116
|
+
if (typeof raw.keyFile === 'string' && raw.keyFile.trim() !== '') migrated.keyFile = raw.keyFile.trim();
|
|
117
|
+
if (typeof raw.outDir === 'string' && raw.outDir.trim() !== '') migrated.outDir = raw.outDir.trim();
|
|
118
|
+
if (typeof raw.imageModel === 'string') migrated.imageModel = raw.imageModel;
|
|
119
|
+
if (typeof raw.videoModel === 'string') migrated.videoModel = raw.videoModel;
|
|
120
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
121
|
+
await writeFile(CONFIG_FILE, JSON.stringify(migrated, null, 2) + '\n', 'utf8');
|
|
122
|
+
}
|
|
123
|
+
} catch { /* 旧配置也没有 → 用默认 */ }
|
|
124
|
+
}
|
|
90
125
|
|
|
91
126
|
const storedKey = typeof stored.keyFile === 'string' && stored.keyFile.trim() !== ''
|
|
92
127
|
? expandHome(stored.keyFile) : null;
|
|
93
128
|
const storedOut = typeof stored.outDir === 'string' && stored.outDir.trim() !== ''
|
|
94
129
|
? expandHome(stored.outDir) : null;
|
|
95
130
|
|
|
131
|
+
const storedImageModel = pickModel(stored.imageModel, IMAGE_MODELS);
|
|
132
|
+
const storedVideoModel = pickModel(stored.videoModel, VIDEO_MODELS);
|
|
133
|
+
|
|
96
134
|
return {
|
|
97
135
|
keyFile: storedKey ?? (process.env.AGNES_KEY_FILE || DEFAULT_KEY_FILE),
|
|
98
136
|
outDir: storedOut ?? (process.env.AGNES_OUT_DIR || DEFAULT_OUT_DIR),
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
137
|
+
imageModel: storedImageModel ?? MODEL,
|
|
138
|
+
videoModel: storedVideoModel ?? VIDEO_MODEL,
|
|
139
|
+
configFile,
|
|
140
|
+
legacyConfigFile: LEGACY_CONFIG_FILE,
|
|
141
|
+
historyFile: HISTORY_FILE,
|
|
142
|
+
defaults: {
|
|
143
|
+
keyFile: DEFAULT_KEY_FILE,
|
|
144
|
+
outDir: DEFAULT_OUT_DIR,
|
|
145
|
+
imageModel: MODEL,
|
|
146
|
+
videoModel: VIDEO_MODEL,
|
|
147
|
+
},
|
|
148
|
+
stored: {
|
|
149
|
+
keyFile: storedKey,
|
|
150
|
+
outDir: storedOut,
|
|
151
|
+
imageModel: storedImageModel,
|
|
152
|
+
videoModel: storedVideoModel,
|
|
153
|
+
},
|
|
102
154
|
};
|
|
103
155
|
}
|
|
104
156
|
|
|
@@ -113,8 +165,22 @@ export async function saveConfig(patch = {}) {
|
|
|
113
165
|
const next = {
|
|
114
166
|
keyFile: patch.keyFile === undefined ? current.stored.keyFile : patch.keyFile,
|
|
115
167
|
outDir: patch.outDir === undefined ? current.stored.outDir : patch.outDir,
|
|
168
|
+
imageModel: patch.imageModel === undefined ? current.stored.imageModel : patch.imageModel,
|
|
169
|
+
videoModel: patch.videoModel === undefined ? current.stored.videoModel : patch.videoModel,
|
|
116
170
|
};
|
|
117
171
|
|
|
172
|
+
for (const [field, label, choices] of [
|
|
173
|
+
['imageModel', '图片模型', IMAGE_MODELS],
|
|
174
|
+
['videoModel', '视频模型', VIDEO_MODELS],
|
|
175
|
+
]) {
|
|
176
|
+
const v = next[field];
|
|
177
|
+
if (v === null) continue; // 清空 = 回退默认
|
|
178
|
+
if (typeof v !== 'string' || !choices.includes(v.trim())) {
|
|
179
|
+
throw new Error(`${label}不在可选范围内:${choices.join(' / ')}`);
|
|
180
|
+
}
|
|
181
|
+
next[field] = v.trim();
|
|
182
|
+
}
|
|
183
|
+
|
|
118
184
|
for (const [field, label] of [['keyFile', 'Key 文件路径'], ['outDir', '输出目录']]) {
|
|
119
185
|
const v = next[field];
|
|
120
186
|
if (v === null) continue; // 允许清空回默认
|
|
@@ -129,6 +195,8 @@ export async function saveConfig(patch = {}) {
|
|
|
129
195
|
const payload = {};
|
|
130
196
|
if (typeof next.keyFile === 'string') payload.keyFile = next.keyFile;
|
|
131
197
|
if (typeof next.outDir === 'string') payload.outDir = next.outDir;
|
|
198
|
+
if (typeof next.imageModel === 'string') payload.imageModel = next.imageModel;
|
|
199
|
+
if (typeof next.videoModel === 'string') payload.videoModel = next.videoModel;
|
|
132
200
|
|
|
133
201
|
await mkdir(CONFIG_DIR, { recursive: true });
|
|
134
202
|
await writeFile(CONFIG_FILE, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
@@ -136,7 +204,158 @@ export async function saveConfig(patch = {}) {
|
|
|
136
204
|
return resolvePaths();
|
|
137
205
|
}
|
|
138
206
|
|
|
207
|
+
/* ==========================================================================
|
|
208
|
+
生成历史(落盘)
|
|
209
|
+
--------------------------------------------------------------------------
|
|
210
|
+
面板重启 / 切面板 / 关掉再打开都能看到最近生成的东西。
|
|
211
|
+
只存可序列化的标量字段:文件本体在磁盘上,这里只有描述信息。
|
|
212
|
+
========================================================================== */
|
|
213
|
+
|
|
214
|
+
export const HISTORY_MAX = 24;
|
|
215
|
+
|
|
216
|
+
/** 只保留白名单里的标量字段,防止把乱七八糟的东西写进磁盘。 */
|
|
217
|
+
function sanitizeHistoryEntry(raw) {
|
|
218
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
219
|
+
const name = typeof raw.filename === 'string' ? raw.filename.trim() : '';
|
|
220
|
+
if (name === '') return null;
|
|
221
|
+
const kind = raw.kind === 'video' ? 'video' : 'image';
|
|
222
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
223
|
+
const str = (v) => (typeof v === 'string' && v !== '' ? v : null);
|
|
224
|
+
return {
|
|
225
|
+
filename: basename(name),
|
|
226
|
+
kind,
|
|
227
|
+
bytes: num(raw.bytes),
|
|
228
|
+
size: str(raw.size),
|
|
229
|
+
ratio: str(raw.ratio),
|
|
230
|
+
seconds: str(raw.seconds),
|
|
231
|
+
path: str(raw.path),
|
|
232
|
+
mode: str(raw.mode),
|
|
233
|
+
refCount: num(raw.refCount),
|
|
234
|
+
elapsedSec: num(raw.elapsedSec),
|
|
235
|
+
wallSec: str(raw.wallSec),
|
|
236
|
+
prompt: str(raw.prompt)?.slice(0, 500) ?? null,
|
|
237
|
+
createdAt: str(raw.createdAt) ?? new Date().toISOString(),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function readHistory() {
|
|
242
|
+
try {
|
|
243
|
+
const raw = JSON.parse(await readFile(HISTORY_FILE, 'utf8'));
|
|
244
|
+
const list = Array.isArray(raw) ? raw : (Array.isArray(raw?.entries) ? raw.entries : []);
|
|
245
|
+
const entries = list.map(sanitizeHistoryEntry).filter((x) => x !== null);
|
|
246
|
+
return { entries: entries.slice(0, HISTORY_MAX), file: HISTORY_FILE };
|
|
247
|
+
} catch {
|
|
248
|
+
return { entries: [], file: HISTORY_FILE };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 追加一条到历史最前面(按 filename 去重)。返回最新的完整列表。 */
|
|
253
|
+
export async function appendHistory(entry) {
|
|
254
|
+
const clean = sanitizeHistoryEntry(entry);
|
|
255
|
+
if (clean === null) throw new Error('历史记录缺少 filename');
|
|
256
|
+
const { entries } = await readHistory();
|
|
257
|
+
const next = [clean, ...entries.filter((x) => x.filename !== clean.filename)].slice(0, HISTORY_MAX);
|
|
258
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
259
|
+
await writeFile(HISTORY_FILE, JSON.stringify(next, null, 2) + '\n', 'utf8');
|
|
260
|
+
return { entries: next, file: HISTORY_FILE };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function clearHistory() {
|
|
264
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
265
|
+
await writeFile(HISTORY_FILE, '[]\n', 'utf8');
|
|
266
|
+
return { entries: [], file: HISTORY_FILE };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* ==========================================================================
|
|
270
|
+
视频任务(落盘 + 宿主侧轮询)
|
|
271
|
+
--------------------------------------------------------------------------
|
|
272
|
+
从前轮询在前端,刷新 / 关面板 / 崩溃就丢;现在任务表在宿主进程里,
|
|
273
|
+
写进 tasks.json,应用重启后把「排队中 / 生成中」的任务重新接管。
|
|
274
|
+
========================================================================== */
|
|
275
|
+
|
|
276
|
+
export const TASKS_FILE = join(CONFIG_DIR, 'tasks.json');
|
|
277
|
+
export const TASK_MAX = 50;
|
|
278
|
+
|
|
279
|
+
/** 还在跑、需要继续轮询的状态。 */
|
|
280
|
+
export const TASK_ACTIVE = ['queued', 'running'];
|
|
281
|
+
|
|
282
|
+
const TASK_STATUS = ['queued', 'running', 'completed', 'failed', 'cancelled'];
|
|
283
|
+
|
|
284
|
+
function sanitizeTask(raw) {
|
|
285
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
286
|
+
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
|
|
287
|
+
if (id === '') return null;
|
|
288
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
289
|
+
const str = (v) => (typeof v === 'string' && v !== '' ? v : null);
|
|
290
|
+
const status = TASK_STATUS.includes(raw.status) ? raw.status : 'queued';
|
|
291
|
+
return {
|
|
292
|
+
id,
|
|
293
|
+
kind: 'video',
|
|
294
|
+
status,
|
|
295
|
+
progress: num(raw.progress) ?? 0,
|
|
296
|
+
videoId: str(raw.videoId),
|
|
297
|
+
mode: raw.mode === 'keyframe' ? 'keyframe' : 'text',
|
|
298
|
+
prompt: str(raw.prompt)?.slice(0, 500) ?? null,
|
|
299
|
+
seconds: str(raw.seconds),
|
|
300
|
+
ratio: str(raw.ratio),
|
|
301
|
+
seed: num(raw.seed),
|
|
302
|
+
filename: str(raw.filename),
|
|
303
|
+
path: str(raw.path),
|
|
304
|
+
bytes: num(raw.bytes),
|
|
305
|
+
error: str(raw.error),
|
|
306
|
+
createdAt: str(raw.createdAt) ?? new Date().toISOString(),
|
|
307
|
+
updatedAt: str(raw.updatedAt) ?? new Date().toISOString(),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function readTasks() {
|
|
312
|
+
try {
|
|
313
|
+
const raw = JSON.parse(await readFile(TASKS_FILE, 'utf8'));
|
|
314
|
+
const list = Array.isArray(raw) ? raw : (Array.isArray(raw?.tasks) ? raw.tasks : []);
|
|
315
|
+
return list.map(sanitizeTask).filter((x) => x !== null).slice(0, TASK_MAX);
|
|
316
|
+
} catch {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function writeTasks(tasks) {
|
|
322
|
+
const list = (Array.isArray(tasks) ? tasks : []).map(sanitizeTask).filter((x) => x !== null).slice(0, TASK_MAX);
|
|
323
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
324
|
+
await writeFile(TASKS_FILE, JSON.stringify(list, null, 2) + '\n', 'utf8');
|
|
325
|
+
return list;
|
|
326
|
+
}
|
|
327
|
+
|
|
139
328
|
/** 读 Key。不传 file 则按当前配置解析。 */
|
|
329
|
+
/**
|
|
330
|
+
* 问上游要模型清单,用来做「测试连接」和填下拉选项。
|
|
331
|
+
* 顺带区分三种失败:Key 不对(401/403)、额度/限流(429)、网络或服务异常。
|
|
332
|
+
*/
|
|
333
|
+
export async function listModels({ baseUrl = DEFAULT_BASE, key } = {}) {
|
|
334
|
+
const k = key ?? (await readKey());
|
|
335
|
+
if (!k) throw new Error('没有找到 API Key,先确认 Key 文件路径');
|
|
336
|
+
let res;
|
|
337
|
+
try {
|
|
338
|
+
res = await fetch(`${baseUrl.replace(/\/+$/, '')}/models`, {
|
|
339
|
+
headers: { Authorization: `Bearer ${k}` },
|
|
340
|
+
signal: AbortSignal.timeout(20_000),
|
|
341
|
+
});
|
|
342
|
+
} catch (e) {
|
|
343
|
+
throw new Error(`连不上上游:${e && e.message ? e.message : String(e)}`);
|
|
344
|
+
}
|
|
345
|
+
if (res.status === 401 || res.status === 403) throw new Error('Key 无效或已过期(HTTP ' + res.status + ')');
|
|
346
|
+
if (res.status === 429) throw new Error('上游限流(HTTP 429),稍后再试');
|
|
347
|
+
if (!res.ok) throw new Error('上游返回 HTTP ' + res.status);
|
|
348
|
+
|
|
349
|
+
const payload = await res.json().catch(() => null);
|
|
350
|
+
const rows = payload && Array.isArray(payload.data) ? payload.data : [];
|
|
351
|
+
const ids = rows.map((r) => (r && typeof r.id === 'string' ? r.id : '')).filter(Boolean);
|
|
352
|
+
return {
|
|
353
|
+
models: ids,
|
|
354
|
+
image: ids.filter((id) => id.startsWith('agnes-image-')),
|
|
355
|
+
video: ids.filter((id) => id.startsWith('agnes-video-')),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
140
359
|
export async function readKey(file) {
|
|
141
360
|
const target = file ?? (await resolvePaths()).keyFile;
|
|
142
361
|
let raw;
|
|
@@ -288,6 +507,10 @@ export async function generate({
|
|
|
288
507
|
if (!buf || buf.length === 0) throw new Error('图片数据为空');
|
|
289
508
|
await writeFile(path, buf);
|
|
290
509
|
|
|
510
|
+
// 把提示词写进图片自身,之后从磁盘上任意时候读回来都知道这张图是怎么来的。
|
|
511
|
+
// 失败不影响出图结果:元数据只是附加值。
|
|
512
|
+
const meta = await embedPrompt(path, prompt);
|
|
513
|
+
|
|
291
514
|
return {
|
|
292
515
|
ok: true,
|
|
293
516
|
filename: name,
|
|
@@ -301,6 +524,7 @@ export async function generate({
|
|
|
301
524
|
// 有参考图 = 图生图 / 多图合成;没有 = 文生图。面板据此显示模式。
|
|
302
525
|
mode: refs.length > 0 ? 'img2img' : 'text2img',
|
|
303
526
|
refCount: refs.length,
|
|
527
|
+
promptMeta: meta,
|
|
304
528
|
revisedPrompt: item.revised_prompt ?? null,
|
|
305
529
|
remoteUrl,
|
|
306
530
|
};
|
|
@@ -337,6 +561,258 @@ export async function readImage(name, outDir) {
|
|
|
337
561
|
return { path, buf };
|
|
338
562
|
}
|
|
339
563
|
|
|
564
|
+
/* ==========================================================================
|
|
565
|
+
把提示词写进图片文件本身
|
|
566
|
+
--------------------------------------------------------------------------
|
|
567
|
+
直接改文件结构,不依赖任何三方库:
|
|
568
|
+
|
|
569
|
+
PNG → 插入 iTXt 块(keyword="prompt",UTF-8,中文安全)
|
|
570
|
+
JPEG → SOI 之后插入 COM 段(原始 UTF-8 字节)
|
|
571
|
+
其它格式(webp/gif/mp4…)→ 不支持,跳过
|
|
572
|
+
|
|
573
|
+
这样图片被拷到别处、被历史记录忘掉之后,提示词依然跟着文件走。
|
|
574
|
+
========================================================================== */
|
|
575
|
+
|
|
576
|
+
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
577
|
+
// 用自己的命名空间,别用通用名 "prompt":ComfyUI / A1111 也往 tEXt 里写 prompt,
|
|
578
|
+
// 那是工作流 JSON,混进来会被当成提示词显示成一堆花括号。
|
|
579
|
+
const META_KEYWORD = 'dsh-agnes-prompt';
|
|
580
|
+
const META_PREFIX = 'dsh-agnes-prompt: ';
|
|
581
|
+
const LIB_MAX = 200;
|
|
582
|
+
|
|
583
|
+
const CRC_TABLE = (() => {
|
|
584
|
+
const t = new Uint32Array(256);
|
|
585
|
+
for (let n = 0; n < 256; n += 1) {
|
|
586
|
+
let c = n;
|
|
587
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
588
|
+
t[n] = c >>> 0;
|
|
589
|
+
}
|
|
590
|
+
return t;
|
|
591
|
+
})();
|
|
592
|
+
|
|
593
|
+
function crc32(buf) {
|
|
594
|
+
let c = 0xffffffff;
|
|
595
|
+
for (let i = 0; i < buf.length; i += 1) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
596
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** 按 PNG 规范拼一个块:长度 + 类型 + 数据 + CRC(类型+数据)。 */
|
|
600
|
+
function pngChunk(type, data) {
|
|
601
|
+
const head = Buffer.alloc(4);
|
|
602
|
+
head.writeUInt32BE(data.length, 0);
|
|
603
|
+
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
|
604
|
+
const tail = Buffer.alloc(4);
|
|
605
|
+
tail.writeUInt32BE(crc32(body), 0);
|
|
606
|
+
return Buffer.concat([head, body, tail]);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** 把整张 PNG 切成块序列,方便插入 / 覆盖 / 查找。 */
|
|
610
|
+
function pngChunks(buf) {
|
|
611
|
+
const out = [];
|
|
612
|
+
let off = PNG_SIG.length;
|
|
613
|
+
while (off + 8 <= buf.length) {
|
|
614
|
+
const len = buf.readUInt32BE(off);
|
|
615
|
+
const type = buf.toString('ascii', off + 4, off + 8);
|
|
616
|
+
const end = off + 12 + len;
|
|
617
|
+
if (end > buf.length || len < 0) break;
|
|
618
|
+
out.push({ type, start: off, end });
|
|
619
|
+
off = end;
|
|
620
|
+
if (type === 'IEND') break;
|
|
621
|
+
}
|
|
622
|
+
return out;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** 组一个 iTXt 块(不做压缩,关键字固定 prompt)。 */
|
|
626
|
+
function itxtChunk(text) {
|
|
627
|
+
const data = Buffer.concat([
|
|
628
|
+
Buffer.from(META_KEYWORD, 'latin1'),
|
|
629
|
+
Buffer.from([0]), // 关键字结束
|
|
630
|
+
Buffer.from([0, 0]), // 压缩标志 0 / 压缩方法 0
|
|
631
|
+
Buffer.from([0]), // 语言标签(空)
|
|
632
|
+
Buffer.from([0]), // 翻译关键字(空)
|
|
633
|
+
Buffer.from(text, 'utf8'),
|
|
634
|
+
]);
|
|
635
|
+
return pngChunk('iTXt', data);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* 从 PNG 里读 prompt:优先取我们自己写的 iTXt,其次兼容 tEXt。
|
|
640
|
+
* 旧的 0.7.0 早期版本用的是通用关键字 "prompt",只在 iTXt 里认它——
|
|
641
|
+
* ComfyUI / A1111 写的是 tEXt["prompt"](工作流 JSON),不能当提示词读出来。
|
|
642
|
+
*/
|
|
643
|
+
function readPngPrompt(buf) {
|
|
644
|
+
let legacy = null;
|
|
645
|
+
for (const c of pngChunks(buf)) {
|
|
646
|
+
if (c.type === 'iTXt') {
|
|
647
|
+
const data = buf.subarray(c.start + 8, c.end - 4);
|
|
648
|
+
const keyEnd = data.indexOf(0);
|
|
649
|
+
if (keyEnd < 0) continue;
|
|
650
|
+
const key = data.toString('latin1', 0, keyEnd);
|
|
651
|
+
const text = data.subarray(keyEnd + 5); // 跳关键字\0 + 压缩标志\0 + 方法\0 + 语言\0 + 翻译\0
|
|
652
|
+
if (key === META_KEYWORD) return text.toString('utf8').replace(/\0+$/, '');
|
|
653
|
+
if (key === 'prompt' && legacy === null) legacy = text.toString('utf8').replace(/\0+$/, '');
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (c.type === 'tEXt') {
|
|
657
|
+
const data = buf.subarray(c.start + 8, c.end - 4);
|
|
658
|
+
const keyEnd = data.indexOf(0);
|
|
659
|
+
if (keyEnd < 0) continue;
|
|
660
|
+
if (data.toString('latin1', 0, keyEnd) !== META_KEYWORD) continue;
|
|
661
|
+
return data.toString('latin1', keyEnd + 1).replace(/\0+$/, '');
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return legacy;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** 重写 PNG:删掉旧的 prompt 块,把新的插在 IEND 之前。 */
|
|
668
|
+
function withPngPrompt(buf, text) {
|
|
669
|
+
const chunks = pngChunks(buf);
|
|
670
|
+
const keep = [];
|
|
671
|
+
for (const c of chunks) {
|
|
672
|
+
if (c.type !== 'iTXt' && c.type !== 'tEXt') { keep.push(c); continue; }
|
|
673
|
+
const data = buf.subarray(c.start + 8, c.end - 4);
|
|
674
|
+
const keyEnd = data.indexOf(0);
|
|
675
|
+
const key = keyEnd < 0 ? '' : data.toString('latin1', 0, keyEnd);
|
|
676
|
+
if (key !== META_KEYWORD) keep.push(c); // 别人的元数据原样保留
|
|
677
|
+
}
|
|
678
|
+
const iend = keep.find((c) => c.type === 'IEND');
|
|
679
|
+
const parts = [PNG_SIG];
|
|
680
|
+
for (const c of keep) {
|
|
681
|
+
if (c === iend) parts.push(itxtChunk(text));
|
|
682
|
+
parts.push(buf.subarray(c.start, c.end));
|
|
683
|
+
}
|
|
684
|
+
if (iend === undefined) parts.push(itxtChunk(text));
|
|
685
|
+
return Buffer.concat(parts);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function isPng(buf) {
|
|
689
|
+
return buf.length > 12 && buf.subarray(0, 8).equals(PNG_SIG);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function isJpeg(buf) {
|
|
693
|
+
return buf.length > 4 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** JPEG:在 SOI 之后插一个 COM 段(已存在就替换)。 */
|
|
697
|
+
function withJpegPrompt(buf, text) {
|
|
698
|
+
const seg = (() => {
|
|
699
|
+
// COM 段没有关键字位置,用前缀标记所有权,避免覆盖/误读相机或别家写的注释
|
|
700
|
+
const payload = Buffer.from(META_PREFIX + text, 'utf8');
|
|
701
|
+
const head = Buffer.alloc(4);
|
|
702
|
+
head[0] = 0xff; head[1] = 0xfe; // COM
|
|
703
|
+
head.writeUInt16BE(payload.length + 2, 2); // 长度包含自身 2 字节
|
|
704
|
+
return Buffer.concat([head, payload]);
|
|
705
|
+
})();
|
|
706
|
+
// 先看 SOI 后面是不是已经有一个 COM,是就换掉
|
|
707
|
+
if (buf.length > 6 && buf[2] === 0xff && buf[3] === 0xfe) {
|
|
708
|
+
const len = buf.readUInt16BE(4);
|
|
709
|
+
return Buffer.concat([buf.subarray(0, 2), seg, buf.subarray(4 + len)]);
|
|
710
|
+
}
|
|
711
|
+
return Buffer.concat([buf.subarray(0, 2), seg, buf.subarray(2)]);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function readJpegPrompt(buf) {
|
|
715
|
+
let off = 2;
|
|
716
|
+
while (off + 4 <= buf.length) {
|
|
717
|
+
if (buf[off] !== 0xff) break;
|
|
718
|
+
const marker = buf[off + 1];
|
|
719
|
+
if (marker === 0xda || marker === 0xd9) break; // SOS / EOI 之后不再有段
|
|
720
|
+
const len = buf.readUInt16BE(off + 2);
|
|
721
|
+
if (len < 2) break;
|
|
722
|
+
if (marker === 0xfe) {
|
|
723
|
+
const text = buf.toString('utf8', off + 4, off + 2 + len).replace(/\0+$/, '');
|
|
724
|
+
// 只认自己写的(带前缀);别人的 COM 注释原样留着,不当提示词
|
|
725
|
+
if (!text.startsWith(META_PREFIX)) return null;
|
|
726
|
+
return text.slice(META_PREFIX.length);
|
|
727
|
+
}
|
|
728
|
+
off += 2 + len;
|
|
729
|
+
}
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* 把提示词写进图片文件。失败不抛(元数据只是附加价值,不该影响出图)。
|
|
735
|
+
* @returns {Promise<{embedded:boolean, format?:string, reason?:string}>}
|
|
736
|
+
*/
|
|
737
|
+
export async function embedPrompt(filePath, prompt) {
|
|
738
|
+
const text = typeof prompt === 'string' ? prompt.trim() : '';
|
|
739
|
+
if (text === '') return { embedded: false, reason: '没有提示词' };
|
|
740
|
+
try {
|
|
741
|
+
const buf = await readFile(filePath);
|
|
742
|
+
let next = null;
|
|
743
|
+
let format = null;
|
|
744
|
+
if (isPng(buf)) { next = withPngPrompt(buf, text); format = 'png'; }
|
|
745
|
+
else if (isJpeg(buf)) { next = withJpegPrompt(buf, text); format = 'jpeg'; }
|
|
746
|
+
else return { embedded: false, reason: '该格式不支持写入元数据' };
|
|
747
|
+
if (next !== null && next.length > 0) await writeFile(filePath, next);
|
|
748
|
+
return { embedded: true, format };
|
|
749
|
+
} catch (e) {
|
|
750
|
+
return { embedded: false, reason: e.message };
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** 从图片文件里读回提示词;读不到返回 null。 */
|
|
755
|
+
export async function readEmbeddedPrompt(filePath) {
|
|
756
|
+
try {
|
|
757
|
+
const buf = await readFile(filePath);
|
|
758
|
+
if (isPng(buf)) return readPngPrompt(buf);
|
|
759
|
+
if (isJpeg(buf)) return readJpegPrompt(buf);
|
|
760
|
+
return null;
|
|
761
|
+
} catch {
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
|
|
767
|
+
const VIDEO_EXT = new Set(['.mp4', '.webm', '.mov']);
|
|
768
|
+
|
|
769
|
+
export function kindOf(name) {
|
|
770
|
+
const dot = name.lastIndexOf('.');
|
|
771
|
+
const ext = dot < 0 ? '' : name.slice(dot).toLowerCase();
|
|
772
|
+
if (IMAGE_EXT.has(ext)) return 'image';
|
|
773
|
+
if (VIDEO_EXT.has(ext)) return 'video';
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** 文件还在不在(历史里可能有被删掉或被挪走的文件)。 */
|
|
778
|
+
export async function fileExists(path) {
|
|
779
|
+
try { await access(path); return true; } catch { return false; }
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* 列出输出目录里已有的图片 / 视频,供面板「加载输出目录」用。
|
|
784
|
+
* prompt 优先从文件元数据读,读不到就留 null(前端可再看历史)。
|
|
785
|
+
*/
|
|
786
|
+
export async function listLibrary(outDir) {
|
|
787
|
+
const entries = await readdir(outDir, { withFileTypes: true }).catch(() => []);
|
|
788
|
+
const files = [];
|
|
789
|
+
for (const ent of entries) {
|
|
790
|
+
if (!ent.isFile()) continue;
|
|
791
|
+
const kind = kindOf(ent.name);
|
|
792
|
+
if (kind === null) continue;
|
|
793
|
+
const full = join(outDir, ent.name);
|
|
794
|
+
let info = null;
|
|
795
|
+
try { info = await stat(full); } catch { continue; }
|
|
796
|
+
files.push({
|
|
797
|
+
filename: ent.name,
|
|
798
|
+
name: ent.name, // 面板侧沿用自己的 name,两种叫法都给出
|
|
799
|
+
kind,
|
|
800
|
+
bytes: info.size,
|
|
801
|
+
mtime: info.mtimeMs,
|
|
802
|
+
path: full,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
files.sort((a, b) => b.mtime - a.mtime);
|
|
806
|
+
const top = files.slice(0, LIB_MAX);
|
|
807
|
+
// 只给最新的若干张读元数据,避免目录很大时逐个开文件拖慢面板
|
|
808
|
+
for (const f of top.slice(0, 60)) {
|
|
809
|
+
if (f.kind !== 'image') continue;
|
|
810
|
+
const prompt = await readEmbeddedPrompt(f.path);
|
|
811
|
+
if (prompt !== null) f.prompt = prompt;
|
|
812
|
+
}
|
|
813
|
+
return top;
|
|
814
|
+
}
|
|
815
|
+
|
|
340
816
|
/* ==========================================================================
|
|
341
817
|
视频(agnes-video-2.5-flash)
|
|
342
818
|
--------------------------------------------------------------------------
|
|
@@ -357,6 +833,9 @@ export const VIDEO_MODEL = 'agnes-video-2.5-flash';
|
|
|
357
833
|
export const VIDEO_SECONDS = ['4', '5', '6', '7', '8', '9', '10', '11', '12'];
|
|
358
834
|
export const VIDEO_RATIOS = ['21:9', '16:9', '4:3', '1:1', '3:4', '9:16'];
|
|
359
835
|
export const VIDEO_SIZE = '720P';
|
|
836
|
+
|
|
837
|
+
/** 视频 reference 模式的参考图上限(上游文档:images 最多 5 张)。 */
|
|
838
|
+
export const MAX_VIDEO_REF_IMAGES = 5;
|
|
360
839
|
export const VIDEO_TIMEOUT_MS = 600_000;
|
|
361
840
|
|
|
362
841
|
/** 从 baseUrl 剥出域名根:/agnesapi 不在 /v1 下。 */
|
|
@@ -364,20 +843,33 @@ export function apiOrigin(baseUrl = DEFAULT_BASE) {
|
|
|
364
843
|
return new URL(baseUrl).origin;
|
|
365
844
|
}
|
|
366
845
|
|
|
367
|
-
/**
|
|
846
|
+
/**
|
|
847
|
+
* 提交视频任务,返回 video_id。队列满(503)/限流(429) 会自动重试。
|
|
848
|
+
*
|
|
849
|
+
* 三种模式(由是否给关键帧决定,不用调用方自己传 mode):
|
|
850
|
+
* - 纯文本 prompt → mode: 'text'
|
|
851
|
+
* - 给 firstFrame / lastFrame → mode: 'keyframe'(两者至少一个)
|
|
852
|
+
*
|
|
853
|
+
* 关键帧接受公网 http(s) 图片 URL,也接受 data:image/... 的 base64 —— 后者
|
|
854
|
+
* 已实测被上游接受,所以本地磁盘里的图片可以直接当首帧,不用先传到公网。
|
|
855
|
+
*/
|
|
368
856
|
export async function submitVideo({
|
|
369
857
|
prompt,
|
|
370
858
|
seconds = '5',
|
|
371
859
|
ratio = '16:9',
|
|
372
860
|
model = VIDEO_MODEL,
|
|
373
861
|
baseUrl = DEFAULT_BASE,
|
|
862
|
+
seed = null,
|
|
863
|
+
firstFrame = null,
|
|
864
|
+
lastFrame = null,
|
|
865
|
+
referenceImages = [],
|
|
374
866
|
retries = 3,
|
|
375
867
|
retryDelayMs = 15_000,
|
|
376
868
|
} = {}) {
|
|
377
869
|
let lastError = null;
|
|
378
870
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
379
871
|
try {
|
|
380
|
-
return await submitVideoOnce({ prompt, seconds, ratio, model, baseUrl });
|
|
872
|
+
return await submitVideoOnce({ prompt, seconds, ratio, model, baseUrl, seed, firstFrame, lastFrame, referenceImages });
|
|
381
873
|
} catch (e) {
|
|
382
874
|
lastError = e;
|
|
383
875
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -390,7 +882,33 @@ export async function submitVideo({
|
|
|
390
882
|
throw lastError ?? new Error('提交任务失败');
|
|
391
883
|
}
|
|
392
884
|
|
|
393
|
-
|
|
885
|
+
/** 单个关键帧校验:只放行 http(s) 与 data:image,空值归为 null。 */
|
|
886
|
+
function normalizeFrame(frame, label) {
|
|
887
|
+
if (frame === null || frame === undefined || frame === '') return null;
|
|
888
|
+
const refs = normalizeRefs([frame]); // 复用参考图校验:单张也要过同一套 URL/data URI 白名单
|
|
889
|
+
if (refs.length !== 1) throw new Error(`${label}无效`);
|
|
890
|
+
return refs[0];
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** seed 校验:允许空;给了就必须是非负整数。 */
|
|
894
|
+
function normalizeSeed(seed) {
|
|
895
|
+
if (seed === null || seed === undefined || seed === '') return null;
|
|
896
|
+
const n = typeof seed === 'number' ? seed : Number(String(seed).trim());
|
|
897
|
+
if (!Number.isInteger(n) || n < 0) throw new Error('seed 必须是非负整数');
|
|
898
|
+
return n;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function submitVideoOnce({
|
|
902
|
+
prompt,
|
|
903
|
+
seconds,
|
|
904
|
+
ratio,
|
|
905
|
+
model,
|
|
906
|
+
baseUrl,
|
|
907
|
+
seed,
|
|
908
|
+
firstFrame,
|
|
909
|
+
lastFrame,
|
|
910
|
+
referenceImages = [],
|
|
911
|
+
}) {
|
|
394
912
|
if (typeof prompt !== 'string' || prompt.trim() === '') throw new Error('prompt 不能为空');
|
|
395
913
|
const sec = String(seconds);
|
|
396
914
|
if (!VIDEO_SECONDS.includes(sec)) {
|
|
@@ -398,16 +916,29 @@ async function submitVideoOnce({ prompt, seconds, ratio, model, baseUrl }) {
|
|
|
398
916
|
}
|
|
399
917
|
if (!VIDEO_RATIOS.includes(ratio)) throw new Error(`aspect_ratio 必须是 ${VIDEO_RATIOS.join(' / ')}`);
|
|
400
918
|
|
|
919
|
+
const first = normalizeFrame(firstFrame, '首帧图');
|
|
920
|
+
const last = normalizeFrame(lastFrame, '尾帧图');
|
|
921
|
+
const refs = normalizeRefs(referenceImages, MAX_VIDEO_REF_IMAGES);
|
|
922
|
+
// 关键帧优先:给了首/尾帧就是 keyframe;否则有参考图走 reference;都没有才是纯文本
|
|
923
|
+
const mode = first !== null || last !== null ? 'keyframe' : refs.length > 0 ? 'reference' : 'text';
|
|
924
|
+
const seedValue = normalizeSeed(seed);
|
|
925
|
+
|
|
401
926
|
const key = await readKey();
|
|
402
927
|
const url = `${baseUrl.replace(/\/+$/, '')}/videos`;
|
|
403
928
|
const body = {
|
|
404
929
|
model,
|
|
405
930
|
prompt,
|
|
406
931
|
seconds: sec,
|
|
407
|
-
mode
|
|
932
|
+
mode,
|
|
408
933
|
size: VIDEO_SIZE, // Flash 只认 720P
|
|
409
934
|
aspect_ratio: ratio,
|
|
410
935
|
};
|
|
936
|
+
if (mode === 'keyframe') {
|
|
937
|
+
if (first !== null) body.first_frame = first;
|
|
938
|
+
if (last !== null) body.last_frame = last;
|
|
939
|
+
}
|
|
940
|
+
if (mode === 'reference') body.images = refs;
|
|
941
|
+
if (seedValue !== null) body.seed = seedValue;
|
|
411
942
|
|
|
412
943
|
let res;
|
|
413
944
|
try {
|
|
@@ -439,7 +970,7 @@ async function submitVideoOnce({ prompt, seconds, ratio, model, baseUrl }) {
|
|
|
439
970
|
if (typeof videoId !== 'string' || videoId === '') {
|
|
440
971
|
throw new Error(`响应里没有 video_id:${text.slice(0, 300)}`);
|
|
441
972
|
}
|
|
442
|
-
return { videoId, taskId: payload?.task_id ?? null, model, baseUrl };
|
|
973
|
+
return { videoId, taskId: payload?.task_id ?? null, model, baseUrl, mode };
|
|
443
974
|
}
|
|
444
975
|
|
|
445
976
|
/** 查询一次任务状态。429/503 属于瞬时限流,自动退避重试。 */
|