dsh-agnes-create 0.4.1 → 0.7.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/README.md +44 -5
- package/lib/agnes.js +386 -9
- package/lib/client.js +662 -19
- package/lib/index.js +131 -5
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -53,8 +53,11 @@ Key 从 [Agnes AI 平台](https://platform.agnes-ai.com) 获取。端点默认
|
|
|
53
53
|
|
|
54
54
|
1. 侧边栏点 **Agnes创作**
|
|
55
55
|
2. 顶部选择 **图片** 或 **视频**
|
|
56
|
-
3. 输入提示词,`Cmd/Ctrl + Enter`
|
|
57
|
-
4.
|
|
56
|
+
3. 输入提示词,`Cmd/Ctrl + Enter` 或点按钮生成;提示词上方有 6 个风格预设(写实摄影 / 水彩 / 3D 渲染 / 海报 / 夜景霓虹 / 极简),点一下追加到末尾,不会冲掉已写的主体
|
|
57
|
+
4. 想放弃等待就点生成按钮旁的 **取消**(只是停止本地等待,服务端任务可能仍在跑)
|
|
58
|
+
5. 结果直接显示在面板里;图片用 `<img>`、视频用 `<video>` 播放(支持拖动进度条)
|
|
59
|
+
6. 结果下方三个按钮:**复制路径**(写到剪贴板)、**在访达中显示**(定位到文件)、**下载**(另存一份到「下载」目录);如果结果本身是图片,还会多出 **设为首帧** / **设为尾帧**,一键把它变成下一段视频的关键帧
|
|
60
|
+
7. 生成过的内容记在面板底部「最近生成」里,最多 24 条,落盘到 `~/.dsh/agnes-create/history.json`,重启应用也不丢
|
|
58
61
|
|
|
59
62
|
提示词建议按「主体 + 场景 + 动作/镜头 + 光线 + 风格 + 构图」组织,例如:
|
|
60
63
|
|
|
@@ -67,7 +70,7 @@ Key 从 [Agnes AI 平台](https://platform.agnes-ai.com) 获取。端点默认
|
|
|
67
70
|
| 类型 | 尺寸 | 宽高比 | 其它 |
|
|
68
71
|
| --- | --- | --- | --- |
|
|
69
72
|
| 图片 | `1K` `2K` `3K` `4K` | `1:1` `3:4` `4:3` `16:9` `9:16` `2:3` `3:2` `21:9` | 可选 1–8 张参考图(图生图 / 多图合成) |
|
|
70
|
-
| 视频 | 固定 `720P` | `21:9` `16:9` `4:3` `1:1` `3:4` `9:16` | 时长 4–12
|
|
73
|
+
| 视频 | 固定 `720P` | `21:9` `16:9` `4:3` `1:1` `3:4` `9:16` | 时长 4–12 秒,可选首帧 / 尾帧,可选 seed |
|
|
71
74
|
|
|
72
75
|
### 图生图 / 多图合成
|
|
73
76
|
|
|
@@ -91,6 +94,42 @@ Key 从 [Agnes AI 平台](https://platform.agnes-ai.com) 获取。端点默认
|
|
|
91
94
|
- 校验在前端和内核各做一次:只接受 `http(s)` 或 `data:image/…;base64`,超过 8 张、空串、非图片类型都会直接报错,不会把脏数据发给上游
|
|
92
95
|
- 本地文件路径**不会**被读取或上传到别处:文件内容只在内存里转成 base64 随请求发给 Agnes
|
|
93
96
|
|
|
97
|
+
### 图生视频(首帧 / 尾帧)
|
|
98
|
+
|
|
99
|
+
视频模式多出「关键帧」一栏,可以给**首帧**、**尾帧**各选一张图片,也可以只给一张。图片来源有三种:
|
|
100
|
+
|
|
101
|
+
- **+ 本地** —— 从磁盘选(PNG / JPEG / WebP / GIF)
|
|
102
|
+
- **+ 结果** —— 从「最近生成」里已经出过的图片中挑一张(没有生成过图片时按钮是灰的)
|
|
103
|
+
- **结果下方「设为首帧 / 设为尾帧」** —— 当前这张结果图直接变成关键帧,切回视频模式就能看到
|
|
104
|
+
|
|
105
|
+
| 给了什么 | 实际行为 |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| 都不给 | 纯文生视频(和以前一样) |
|
|
108
|
+
| 只给首帧 | 从这张图开始动 |
|
|
109
|
+
| 只给尾帧 | 向这张图收尾 |
|
|
110
|
+
| 首帧 + 尾帧 | 从首帧过渡到尾帧(时长决定过渡的快慢) |
|
|
111
|
+
|
|
112
|
+
- 关键帧同样在浏览器侧压到长边 1280 再编码成 data URI,**直接以 base64 发给上游**,不需要先传到公网图床,本地图片可以直接用
|
|
113
|
+
- 「种子」是可选的非负整数:同一个提示词配同一个 seed 更容易得到相近的结果,留空则由服务端随机;结果下方会显示实际用的 seed
|
|
114
|
+
|
|
115
|
+
> 关键帧只在视频模式出现;图片模式用的是上面的「参考图」。
|
|
116
|
+
|
|
117
|
+
### 提示词随图保存
|
|
118
|
+
|
|
119
|
+
出图时会把这次的提示词写进图片文件本身(PNG 用 iTXt 块、JPEG 用 COM 段),所以:
|
|
120
|
+
|
|
121
|
+
- 在面板里把鼠标移到结果图上,底部会浮出当初的提示词,点「复制」就能拿到;
|
|
122
|
+
- 用系统看图软件、Photoshop、exiftool 打开也能看到这段文字——不依赖本插件;
|
|
123
|
+
- 只对新生成的图片生效;视频不写(mp4 元数据没有通用写法)。
|
|
124
|
+
|
|
125
|
+
### 浏览输出目录
|
|
126
|
+
|
|
127
|
+
结果卡片右上角的 **输出目录** 按钮会把输出目录里已经存在的文件列成缩略图:
|
|
128
|
+
|
|
129
|
+
- 分「图片 / 视频」两页,各带数量;
|
|
130
|
+
- 点任意一张就把它显示到上方结果区,图片会连带读回当初写在文件里的提示词;
|
|
131
|
+
- 只读不写,不会改动或删除你的文件;「刷新」用于同步新生成的文件。
|
|
132
|
+
|
|
94
133
|
## 关于费用
|
|
95
134
|
|
|
96
135
|
插件的费用由 Agnes AI 账号结算,本插件不涉及任何计费。
|
|
@@ -104,9 +143,9 @@ Key 从 [Agnes AI 平台](https://platform.agnes-ai.com) 获取。端点默认
|
|
|
104
143
|
|
|
105
144
|
- 视频为**异步任务**:提交后轮询,通常需要 1–4 分钟;队列繁忙时提交会返回 `503`,插件会自动重试
|
|
106
145
|
- 查询接口有频率限制,触发 `429` 时面板会显示「限流重试中…」并退避,任务不会因此失败
|
|
107
|
-
-
|
|
146
|
+
- 视频暂不支持参考音频与参考视频输入(Flash 版接口本身不支持);图生视频目前只走首帧 / 尾帧关键帧
|
|
108
147
|
- 参考图最多 8 张;单张在浏览器侧压到长边 1280,细节极多的小字参考图重绘后可能看不清
|
|
109
|
-
-
|
|
148
|
+
- 生成历史保留最近 24 条,落盘到 `~/.dsh/agnes-create/history.json`;点「清空」会同时清掉磁盘记录,但已生成的文件仍在输出目录里
|
|
110
149
|
|
|
111
150
|
## 开发
|
|
112
151
|
|
package/lib/agnes.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
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
|
|
|
@@ -26,8 +26,12 @@ export const DEFAULT_BASE = 'https://api.agnes-ai.cn/v1'; // 中国站
|
|
|
26
26
|
配置存于 ~/.dsh/agnes-image/config.json,由面板里的「设置」卡片写入。
|
|
27
27
|
========================================================================== */
|
|
28
28
|
|
|
29
|
-
export const CONFIG_DIR = join(homedir(), '.dsh', 'agnes-
|
|
29
|
+
export const CONFIG_DIR = join(homedir(), '.dsh', 'agnes-create');
|
|
30
30
|
export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
31
|
+
/** 旧目录:包名改名前配置存在 ~/.dsh/agnes-image/,读的时候回退,写只写新路径。 */
|
|
32
|
+
export const LEGACY_CONFIG_FILE = join(homedir(), '.dsh', 'agnes-image', 'config.json');
|
|
33
|
+
/** 生成历史落盘位置(面板重启后仍能看到最近的结果)。 */
|
|
34
|
+
export const HISTORY_FILE = join(CONFIG_DIR, 'history.json');
|
|
31
35
|
export const DEFAULT_KEY_FILE = join(homedir(), '.agnes-key.txt');
|
|
32
36
|
export const DEFAULT_OUT_DIR = join(homedir(), 'Desktop', 'Agnes出图');
|
|
33
37
|
|
|
@@ -83,10 +87,24 @@ export function expandHome(p) {
|
|
|
83
87
|
*/
|
|
84
88
|
export async function resolvePaths() {
|
|
85
89
|
let stored = {};
|
|
90
|
+
let configFile = CONFIG_FILE;
|
|
86
91
|
try {
|
|
87
92
|
const raw = JSON.parse(await readFile(CONFIG_FILE, 'utf8'));
|
|
88
93
|
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) stored = raw;
|
|
89
|
-
} catch {
|
|
94
|
+
} catch {
|
|
95
|
+
// 新路径没有 → 读旧路径(改名前的配置),迁移到新目录
|
|
96
|
+
try {
|
|
97
|
+
const raw = JSON.parse(await readFile(LEGACY_CONFIG_FILE, 'utf8'));
|
|
98
|
+
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
99
|
+
stored = raw;
|
|
100
|
+
configFile = CONFIG_FILE;
|
|
101
|
+
await saveConfig({
|
|
102
|
+
keyFile: typeof raw.keyFile === 'string' ? raw.keyFile : null,
|
|
103
|
+
outDir: typeof raw.outDir === 'string' ? raw.outDir : null,
|
|
104
|
+
}).catch(() => { /* 迁移失败不影响本次读取 */ });
|
|
105
|
+
}
|
|
106
|
+
} catch { /* 旧配置也没有 → 用默认 */ }
|
|
107
|
+
}
|
|
90
108
|
|
|
91
109
|
const storedKey = typeof stored.keyFile === 'string' && stored.keyFile.trim() !== ''
|
|
92
110
|
? expandHome(stored.keyFile) : null;
|
|
@@ -96,7 +114,9 @@ export async function resolvePaths() {
|
|
|
96
114
|
return {
|
|
97
115
|
keyFile: storedKey ?? (process.env.AGNES_KEY_FILE || DEFAULT_KEY_FILE),
|
|
98
116
|
outDir: storedOut ?? (process.env.AGNES_OUT_DIR || DEFAULT_OUT_DIR),
|
|
99
|
-
configFile
|
|
117
|
+
configFile,
|
|
118
|
+
legacyConfigFile: LEGACY_CONFIG_FILE,
|
|
119
|
+
historyFile: HISTORY_FILE,
|
|
100
120
|
defaults: { keyFile: DEFAULT_KEY_FILE, outDir: DEFAULT_OUT_DIR },
|
|
101
121
|
stored: { keyFile: storedKey, outDir: storedOut },
|
|
102
122
|
};
|
|
@@ -136,6 +156,68 @@ export async function saveConfig(patch = {}) {
|
|
|
136
156
|
return resolvePaths();
|
|
137
157
|
}
|
|
138
158
|
|
|
159
|
+
/* ==========================================================================
|
|
160
|
+
生成历史(落盘)
|
|
161
|
+
--------------------------------------------------------------------------
|
|
162
|
+
面板重启 / 切面板 / 关掉再打开都能看到最近生成的东西。
|
|
163
|
+
只存可序列化的标量字段:文件本体在磁盘上,这里只有描述信息。
|
|
164
|
+
========================================================================== */
|
|
165
|
+
|
|
166
|
+
export const HISTORY_MAX = 24;
|
|
167
|
+
|
|
168
|
+
/** 只保留白名单里的标量字段,防止把乱七八糟的东西写进磁盘。 */
|
|
169
|
+
function sanitizeHistoryEntry(raw) {
|
|
170
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
171
|
+
const name = typeof raw.filename === 'string' ? raw.filename.trim() : '';
|
|
172
|
+
if (name === '') return null;
|
|
173
|
+
const kind = raw.kind === 'video' ? 'video' : 'image';
|
|
174
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
175
|
+
const str = (v) => (typeof v === 'string' && v !== '' ? v : null);
|
|
176
|
+
return {
|
|
177
|
+
filename: basename(name),
|
|
178
|
+
kind,
|
|
179
|
+
bytes: num(raw.bytes),
|
|
180
|
+
size: str(raw.size),
|
|
181
|
+
ratio: str(raw.ratio),
|
|
182
|
+
seconds: str(raw.seconds),
|
|
183
|
+
path: str(raw.path),
|
|
184
|
+
mode: str(raw.mode),
|
|
185
|
+
refCount: num(raw.refCount),
|
|
186
|
+
elapsedSec: num(raw.elapsedSec),
|
|
187
|
+
wallSec: str(raw.wallSec),
|
|
188
|
+
prompt: str(raw.prompt)?.slice(0, 500) ?? null,
|
|
189
|
+
createdAt: str(raw.createdAt) ?? new Date().toISOString(),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function readHistory() {
|
|
194
|
+
try {
|
|
195
|
+
const raw = JSON.parse(await readFile(HISTORY_FILE, 'utf8'));
|
|
196
|
+
const list = Array.isArray(raw) ? raw : (Array.isArray(raw?.entries) ? raw.entries : []);
|
|
197
|
+
const entries = list.map(sanitizeHistoryEntry).filter((x) => x !== null);
|
|
198
|
+
return { entries: entries.slice(0, HISTORY_MAX), file: HISTORY_FILE };
|
|
199
|
+
} catch {
|
|
200
|
+
return { entries: [], file: HISTORY_FILE };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 追加一条到历史最前面(按 filename 去重)。返回最新的完整列表。 */
|
|
205
|
+
export async function appendHistory(entry) {
|
|
206
|
+
const clean = sanitizeHistoryEntry(entry);
|
|
207
|
+
if (clean === null) throw new Error('历史记录缺少 filename');
|
|
208
|
+
const { entries } = await readHistory();
|
|
209
|
+
const next = [clean, ...entries.filter((x) => x.filename !== clean.filename)].slice(0, HISTORY_MAX);
|
|
210
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
211
|
+
await writeFile(HISTORY_FILE, JSON.stringify(next, null, 2) + '\n', 'utf8');
|
|
212
|
+
return { entries: next, file: HISTORY_FILE };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function clearHistory() {
|
|
216
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
217
|
+
await writeFile(HISTORY_FILE, '[]\n', 'utf8');
|
|
218
|
+
return { entries: [], file: HISTORY_FILE };
|
|
219
|
+
}
|
|
220
|
+
|
|
139
221
|
/** 读 Key。不传 file 则按当前配置解析。 */
|
|
140
222
|
export async function readKey(file) {
|
|
141
223
|
const target = file ?? (await resolvePaths()).keyFile;
|
|
@@ -288,6 +370,10 @@ export async function generate({
|
|
|
288
370
|
if (!buf || buf.length === 0) throw new Error('图片数据为空');
|
|
289
371
|
await writeFile(path, buf);
|
|
290
372
|
|
|
373
|
+
// 把提示词写进图片自身,之后从磁盘上任意时候读回来都知道这张图是怎么来的。
|
|
374
|
+
// 失败不影响出图结果:元数据只是附加值。
|
|
375
|
+
const meta = await embedPrompt(path, prompt);
|
|
376
|
+
|
|
291
377
|
return {
|
|
292
378
|
ok: true,
|
|
293
379
|
filename: name,
|
|
@@ -301,6 +387,7 @@ export async function generate({
|
|
|
301
387
|
// 有参考图 = 图生图 / 多图合成;没有 = 文生图。面板据此显示模式。
|
|
302
388
|
mode: refs.length > 0 ? 'img2img' : 'text2img',
|
|
303
389
|
refCount: refs.length,
|
|
390
|
+
promptMeta: meta,
|
|
304
391
|
revisedPrompt: item.revised_prompt ?? null,
|
|
305
392
|
remoteUrl,
|
|
306
393
|
};
|
|
@@ -337,6 +424,258 @@ export async function readImage(name, outDir) {
|
|
|
337
424
|
return { path, buf };
|
|
338
425
|
}
|
|
339
426
|
|
|
427
|
+
/* ==========================================================================
|
|
428
|
+
把提示词写进图片文件本身
|
|
429
|
+
--------------------------------------------------------------------------
|
|
430
|
+
直接改文件结构,不依赖任何三方库:
|
|
431
|
+
|
|
432
|
+
PNG → 插入 iTXt 块(keyword="prompt",UTF-8,中文安全)
|
|
433
|
+
JPEG → SOI 之后插入 COM 段(原始 UTF-8 字节)
|
|
434
|
+
其它格式(webp/gif/mp4…)→ 不支持,跳过
|
|
435
|
+
|
|
436
|
+
这样图片被拷到别处、被历史记录忘掉之后,提示词依然跟着文件走。
|
|
437
|
+
========================================================================== */
|
|
438
|
+
|
|
439
|
+
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
440
|
+
// 用自己的命名空间,别用通用名 "prompt":ComfyUI / A1111 也往 tEXt 里写 prompt,
|
|
441
|
+
// 那是工作流 JSON,混进来会被当成提示词显示成一堆花括号。
|
|
442
|
+
const META_KEYWORD = 'dsh-agnes-prompt';
|
|
443
|
+
const META_PREFIX = 'dsh-agnes-prompt: ';
|
|
444
|
+
const LIB_MAX = 200;
|
|
445
|
+
|
|
446
|
+
const CRC_TABLE = (() => {
|
|
447
|
+
const t = new Uint32Array(256);
|
|
448
|
+
for (let n = 0; n < 256; n += 1) {
|
|
449
|
+
let c = n;
|
|
450
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
451
|
+
t[n] = c >>> 0;
|
|
452
|
+
}
|
|
453
|
+
return t;
|
|
454
|
+
})();
|
|
455
|
+
|
|
456
|
+
function crc32(buf) {
|
|
457
|
+
let c = 0xffffffff;
|
|
458
|
+
for (let i = 0; i < buf.length; i += 1) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
459
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** 按 PNG 规范拼一个块:长度 + 类型 + 数据 + CRC(类型+数据)。 */
|
|
463
|
+
function pngChunk(type, data) {
|
|
464
|
+
const head = Buffer.alloc(4);
|
|
465
|
+
head.writeUInt32BE(data.length, 0);
|
|
466
|
+
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
|
467
|
+
const tail = Buffer.alloc(4);
|
|
468
|
+
tail.writeUInt32BE(crc32(body), 0);
|
|
469
|
+
return Buffer.concat([head, body, tail]);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** 把整张 PNG 切成块序列,方便插入 / 覆盖 / 查找。 */
|
|
473
|
+
function pngChunks(buf) {
|
|
474
|
+
const out = [];
|
|
475
|
+
let off = PNG_SIG.length;
|
|
476
|
+
while (off + 8 <= buf.length) {
|
|
477
|
+
const len = buf.readUInt32BE(off);
|
|
478
|
+
const type = buf.toString('ascii', off + 4, off + 8);
|
|
479
|
+
const end = off + 12 + len;
|
|
480
|
+
if (end > buf.length || len < 0) break;
|
|
481
|
+
out.push({ type, start: off, end });
|
|
482
|
+
off = end;
|
|
483
|
+
if (type === 'IEND') break;
|
|
484
|
+
}
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** 组一个 iTXt 块(不做压缩,关键字固定 prompt)。 */
|
|
489
|
+
function itxtChunk(text) {
|
|
490
|
+
const data = Buffer.concat([
|
|
491
|
+
Buffer.from(META_KEYWORD, 'latin1'),
|
|
492
|
+
Buffer.from([0]), // 关键字结束
|
|
493
|
+
Buffer.from([0, 0]), // 压缩标志 0 / 压缩方法 0
|
|
494
|
+
Buffer.from([0]), // 语言标签(空)
|
|
495
|
+
Buffer.from([0]), // 翻译关键字(空)
|
|
496
|
+
Buffer.from(text, 'utf8'),
|
|
497
|
+
]);
|
|
498
|
+
return pngChunk('iTXt', data);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* 从 PNG 里读 prompt:优先取我们自己写的 iTXt,其次兼容 tEXt。
|
|
503
|
+
* 旧的 0.7.0 早期版本用的是通用关键字 "prompt",只在 iTXt 里认它——
|
|
504
|
+
* ComfyUI / A1111 写的是 tEXt["prompt"](工作流 JSON),不能当提示词读出来。
|
|
505
|
+
*/
|
|
506
|
+
function readPngPrompt(buf) {
|
|
507
|
+
let legacy = null;
|
|
508
|
+
for (const c of pngChunks(buf)) {
|
|
509
|
+
if (c.type === 'iTXt') {
|
|
510
|
+
const data = buf.subarray(c.start + 8, c.end - 4);
|
|
511
|
+
const keyEnd = data.indexOf(0);
|
|
512
|
+
if (keyEnd < 0) continue;
|
|
513
|
+
const key = data.toString('latin1', 0, keyEnd);
|
|
514
|
+
const text = data.subarray(keyEnd + 5); // 跳关键字\0 + 压缩标志\0 + 方法\0 + 语言\0 + 翻译\0
|
|
515
|
+
if (key === META_KEYWORD) return text.toString('utf8').replace(/\0+$/, '');
|
|
516
|
+
if (key === 'prompt' && legacy === null) legacy = text.toString('utf8').replace(/\0+$/, '');
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (c.type === 'tEXt') {
|
|
520
|
+
const data = buf.subarray(c.start + 8, c.end - 4);
|
|
521
|
+
const keyEnd = data.indexOf(0);
|
|
522
|
+
if (keyEnd < 0) continue;
|
|
523
|
+
if (data.toString('latin1', 0, keyEnd) !== META_KEYWORD) continue;
|
|
524
|
+
return data.toString('latin1', keyEnd + 1).replace(/\0+$/, '');
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return legacy;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** 重写 PNG:删掉旧的 prompt 块,把新的插在 IEND 之前。 */
|
|
531
|
+
function withPngPrompt(buf, text) {
|
|
532
|
+
const chunks = pngChunks(buf);
|
|
533
|
+
const keep = [];
|
|
534
|
+
for (const c of chunks) {
|
|
535
|
+
if (c.type !== 'iTXt' && c.type !== 'tEXt') { keep.push(c); continue; }
|
|
536
|
+
const data = buf.subarray(c.start + 8, c.end - 4);
|
|
537
|
+
const keyEnd = data.indexOf(0);
|
|
538
|
+
const key = keyEnd < 0 ? '' : data.toString('latin1', 0, keyEnd);
|
|
539
|
+
if (key !== META_KEYWORD) keep.push(c); // 别人的元数据原样保留
|
|
540
|
+
}
|
|
541
|
+
const iend = keep.find((c) => c.type === 'IEND');
|
|
542
|
+
const parts = [PNG_SIG];
|
|
543
|
+
for (const c of keep) {
|
|
544
|
+
if (c === iend) parts.push(itxtChunk(text));
|
|
545
|
+
parts.push(buf.subarray(c.start, c.end));
|
|
546
|
+
}
|
|
547
|
+
if (iend === undefined) parts.push(itxtChunk(text));
|
|
548
|
+
return Buffer.concat(parts);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function isPng(buf) {
|
|
552
|
+
return buf.length > 12 && buf.subarray(0, 8).equals(PNG_SIG);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function isJpeg(buf) {
|
|
556
|
+
return buf.length > 4 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** JPEG:在 SOI 之后插一个 COM 段(已存在就替换)。 */
|
|
560
|
+
function withJpegPrompt(buf, text) {
|
|
561
|
+
const seg = (() => {
|
|
562
|
+
// COM 段没有关键字位置,用前缀标记所有权,避免覆盖/误读相机或别家写的注释
|
|
563
|
+
const payload = Buffer.from(META_PREFIX + text, 'utf8');
|
|
564
|
+
const head = Buffer.alloc(4);
|
|
565
|
+
head[0] = 0xff; head[1] = 0xfe; // COM
|
|
566
|
+
head.writeUInt16BE(payload.length + 2, 2); // 长度包含自身 2 字节
|
|
567
|
+
return Buffer.concat([head, payload]);
|
|
568
|
+
})();
|
|
569
|
+
// 先看 SOI 后面是不是已经有一个 COM,是就换掉
|
|
570
|
+
if (buf.length > 6 && buf[2] === 0xff && buf[3] === 0xfe) {
|
|
571
|
+
const len = buf.readUInt16BE(4);
|
|
572
|
+
return Buffer.concat([buf.subarray(0, 2), seg, buf.subarray(4 + len)]);
|
|
573
|
+
}
|
|
574
|
+
return Buffer.concat([buf.subarray(0, 2), seg, buf.subarray(2)]);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function readJpegPrompt(buf) {
|
|
578
|
+
let off = 2;
|
|
579
|
+
while (off + 4 <= buf.length) {
|
|
580
|
+
if (buf[off] !== 0xff) break;
|
|
581
|
+
const marker = buf[off + 1];
|
|
582
|
+
if (marker === 0xda || marker === 0xd9) break; // SOS / EOI 之后不再有段
|
|
583
|
+
const len = buf.readUInt16BE(off + 2);
|
|
584
|
+
if (len < 2) break;
|
|
585
|
+
if (marker === 0xfe) {
|
|
586
|
+
const text = buf.toString('utf8', off + 4, off + 2 + len).replace(/\0+$/, '');
|
|
587
|
+
// 只认自己写的(带前缀);别人的 COM 注释原样留着,不当提示词
|
|
588
|
+
if (!text.startsWith(META_PREFIX)) return null;
|
|
589
|
+
return text.slice(META_PREFIX.length);
|
|
590
|
+
}
|
|
591
|
+
off += 2 + len;
|
|
592
|
+
}
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* 把提示词写进图片文件。失败不抛(元数据只是附加价值,不该影响出图)。
|
|
598
|
+
* @returns {Promise<{embedded:boolean, format?:string, reason?:string}>}
|
|
599
|
+
*/
|
|
600
|
+
export async function embedPrompt(filePath, prompt) {
|
|
601
|
+
const text = typeof prompt === 'string' ? prompt.trim() : '';
|
|
602
|
+
if (text === '') return { embedded: false, reason: '没有提示词' };
|
|
603
|
+
try {
|
|
604
|
+
const buf = await readFile(filePath);
|
|
605
|
+
let next = null;
|
|
606
|
+
let format = null;
|
|
607
|
+
if (isPng(buf)) { next = withPngPrompt(buf, text); format = 'png'; }
|
|
608
|
+
else if (isJpeg(buf)) { next = withJpegPrompt(buf, text); format = 'jpeg'; }
|
|
609
|
+
else return { embedded: false, reason: '该格式不支持写入元数据' };
|
|
610
|
+
if (next !== null && next.length > 0) await writeFile(filePath, next);
|
|
611
|
+
return { embedded: true, format };
|
|
612
|
+
} catch (e) {
|
|
613
|
+
return { embedded: false, reason: e.message };
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** 从图片文件里读回提示词;读不到返回 null。 */
|
|
618
|
+
export async function readEmbeddedPrompt(filePath) {
|
|
619
|
+
try {
|
|
620
|
+
const buf = await readFile(filePath);
|
|
621
|
+
if (isPng(buf)) return readPngPrompt(buf);
|
|
622
|
+
if (isJpeg(buf)) return readJpegPrompt(buf);
|
|
623
|
+
return null;
|
|
624
|
+
} catch {
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
|
|
630
|
+
const VIDEO_EXT = new Set(['.mp4', '.webm', '.mov']);
|
|
631
|
+
|
|
632
|
+
export function kindOf(name) {
|
|
633
|
+
const dot = name.lastIndexOf('.');
|
|
634
|
+
const ext = dot < 0 ? '' : name.slice(dot).toLowerCase();
|
|
635
|
+
if (IMAGE_EXT.has(ext)) return 'image';
|
|
636
|
+
if (VIDEO_EXT.has(ext)) return 'video';
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** 文件还在不在(历史里可能有被删掉或被挪走的文件)。 */
|
|
641
|
+
export async function fileExists(path) {
|
|
642
|
+
try { await access(path); return true; } catch { return false; }
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* 列出输出目录里已有的图片 / 视频,供面板「加载输出目录」用。
|
|
647
|
+
* prompt 优先从文件元数据读,读不到就留 null(前端可再看历史)。
|
|
648
|
+
*/
|
|
649
|
+
export async function listLibrary(outDir) {
|
|
650
|
+
const entries = await readdir(outDir, { withFileTypes: true }).catch(() => []);
|
|
651
|
+
const files = [];
|
|
652
|
+
for (const ent of entries) {
|
|
653
|
+
if (!ent.isFile()) continue;
|
|
654
|
+
const kind = kindOf(ent.name);
|
|
655
|
+
if (kind === null) continue;
|
|
656
|
+
const full = join(outDir, ent.name);
|
|
657
|
+
let info = null;
|
|
658
|
+
try { info = await stat(full); } catch { continue; }
|
|
659
|
+
files.push({
|
|
660
|
+
filename: ent.name,
|
|
661
|
+
name: ent.name, // 面板侧沿用自己的 name,两种叫法都给出
|
|
662
|
+
kind,
|
|
663
|
+
bytes: info.size,
|
|
664
|
+
mtime: info.mtimeMs,
|
|
665
|
+
path: full,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
files.sort((a, b) => b.mtime - a.mtime);
|
|
669
|
+
const top = files.slice(0, LIB_MAX);
|
|
670
|
+
// 只给最新的若干张读元数据,避免目录很大时逐个开文件拖慢面板
|
|
671
|
+
for (const f of top.slice(0, 60)) {
|
|
672
|
+
if (f.kind !== 'image') continue;
|
|
673
|
+
const prompt = await readEmbeddedPrompt(f.path);
|
|
674
|
+
if (prompt !== null) f.prompt = prompt;
|
|
675
|
+
}
|
|
676
|
+
return top;
|
|
677
|
+
}
|
|
678
|
+
|
|
340
679
|
/* ==========================================================================
|
|
341
680
|
视频(agnes-video-2.5-flash)
|
|
342
681
|
--------------------------------------------------------------------------
|
|
@@ -364,20 +703,32 @@ export function apiOrigin(baseUrl = DEFAULT_BASE) {
|
|
|
364
703
|
return new URL(baseUrl).origin;
|
|
365
704
|
}
|
|
366
705
|
|
|
367
|
-
/**
|
|
706
|
+
/**
|
|
707
|
+
* 提交视频任务,返回 video_id。队列满(503)/限流(429) 会自动重试。
|
|
708
|
+
*
|
|
709
|
+
* 三种模式(由是否给关键帧决定,不用调用方自己传 mode):
|
|
710
|
+
* - 纯文本 prompt → mode: 'text'
|
|
711
|
+
* - 给 firstFrame / lastFrame → mode: 'keyframe'(两者至少一个)
|
|
712
|
+
*
|
|
713
|
+
* 关键帧接受公网 http(s) 图片 URL,也接受 data:image/... 的 base64 —— 后者
|
|
714
|
+
* 已实测被上游接受,所以本地磁盘里的图片可以直接当首帧,不用先传到公网。
|
|
715
|
+
*/
|
|
368
716
|
export async function submitVideo({
|
|
369
717
|
prompt,
|
|
370
718
|
seconds = '5',
|
|
371
719
|
ratio = '16:9',
|
|
372
720
|
model = VIDEO_MODEL,
|
|
373
721
|
baseUrl = DEFAULT_BASE,
|
|
722
|
+
seed = null,
|
|
723
|
+
firstFrame = null,
|
|
724
|
+
lastFrame = null,
|
|
374
725
|
retries = 3,
|
|
375
726
|
retryDelayMs = 15_000,
|
|
376
727
|
} = {}) {
|
|
377
728
|
let lastError = null;
|
|
378
729
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
379
730
|
try {
|
|
380
|
-
return await submitVideoOnce({ prompt, seconds, ratio, model, baseUrl });
|
|
731
|
+
return await submitVideoOnce({ prompt, seconds, ratio, model, baseUrl, seed, firstFrame, lastFrame });
|
|
381
732
|
} catch (e) {
|
|
382
733
|
lastError = e;
|
|
383
734
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -390,7 +741,23 @@ export async function submitVideo({
|
|
|
390
741
|
throw lastError ?? new Error('提交任务失败');
|
|
391
742
|
}
|
|
392
743
|
|
|
393
|
-
|
|
744
|
+
/** 单个关键帧校验:只放行 http(s) 与 data:image,空值归为 null。 */
|
|
745
|
+
function normalizeFrame(frame, label) {
|
|
746
|
+
if (frame === null || frame === undefined || frame === '') return null;
|
|
747
|
+
const refs = normalizeRefs([frame]); // 复用参考图校验:单张也要过同一套 URL/data URI 白名单
|
|
748
|
+
if (refs.length !== 1) throw new Error(`${label}无效`);
|
|
749
|
+
return refs[0];
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** seed 校验:允许空;给了就必须是非负整数。 */
|
|
753
|
+
function normalizeSeed(seed) {
|
|
754
|
+
if (seed === null || seed === undefined || seed === '') return null;
|
|
755
|
+
const n = typeof seed === 'number' ? seed : Number(String(seed).trim());
|
|
756
|
+
if (!Number.isInteger(n) || n < 0) throw new Error('seed 必须是非负整数');
|
|
757
|
+
return n;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async function submitVideoOnce({ prompt, seconds, ratio, model, baseUrl, seed, firstFrame, lastFrame }) {
|
|
394
761
|
if (typeof prompt !== 'string' || prompt.trim() === '') throw new Error('prompt 不能为空');
|
|
395
762
|
const sec = String(seconds);
|
|
396
763
|
if (!VIDEO_SECONDS.includes(sec)) {
|
|
@@ -398,16 +765,26 @@ async function submitVideoOnce({ prompt, seconds, ratio, model, baseUrl }) {
|
|
|
398
765
|
}
|
|
399
766
|
if (!VIDEO_RATIOS.includes(ratio)) throw new Error(`aspect_ratio 必须是 ${VIDEO_RATIOS.join(' / ')}`);
|
|
400
767
|
|
|
768
|
+
const first = normalizeFrame(firstFrame, '首帧图');
|
|
769
|
+
const last = normalizeFrame(lastFrame, '尾帧图');
|
|
770
|
+
const mode = first !== null || last !== null ? 'keyframe' : 'text';
|
|
771
|
+
const seedValue = normalizeSeed(seed);
|
|
772
|
+
|
|
401
773
|
const key = await readKey();
|
|
402
774
|
const url = `${baseUrl.replace(/\/+$/, '')}/videos`;
|
|
403
775
|
const body = {
|
|
404
776
|
model,
|
|
405
777
|
prompt,
|
|
406
778
|
seconds: sec,
|
|
407
|
-
mode
|
|
779
|
+
mode,
|
|
408
780
|
size: VIDEO_SIZE, // Flash 只认 720P
|
|
409
781
|
aspect_ratio: ratio,
|
|
410
782
|
};
|
|
783
|
+
if (mode === 'keyframe') {
|
|
784
|
+
if (first !== null) body.first_frame = first;
|
|
785
|
+
if (last !== null) body.last_frame = last;
|
|
786
|
+
}
|
|
787
|
+
if (seedValue !== null) body.seed = seedValue;
|
|
411
788
|
|
|
412
789
|
let res;
|
|
413
790
|
try {
|
|
@@ -439,7 +816,7 @@ async function submitVideoOnce({ prompt, seconds, ratio, model, baseUrl }) {
|
|
|
439
816
|
if (typeof videoId !== 'string' || videoId === '') {
|
|
440
817
|
throw new Error(`响应里没有 video_id:${text.slice(0, 300)}`);
|
|
441
818
|
}
|
|
442
|
-
return { videoId, taskId: payload?.task_id ?? null, model, baseUrl };
|
|
819
|
+
return { videoId, taskId: payload?.task_id ?? null, model, baseUrl, mode };
|
|
443
820
|
}
|
|
444
821
|
|
|
445
822
|
/** 查询一次任务状态。429/503 属于瞬时限流,自动退避重试。 */
|