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 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. 结果直接显示在面板里;图片用 `<img>`、视频用 `<video>` 播放(支持拖动进度条)
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
- - 视频只支持**文生视频**:暂无首尾帧、参考音频与视频输入(Flash 版接口本身不支持这些)
146
+ - 视频暂不支持参考音频与参考视频输入(Flash 版接口本身不支持);图生视频目前只走首帧 / 尾帧关键帧
108
147
  - 参考图最多 8 张;单张在浏览器侧压到长边 1280,细节极多的小字参考图重绘后可能看不清
109
- - 生成结果只保留最近 8 条(本次会话内),刷新页面后历史清空;文件本身在输出目录里不会丢
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-image');
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: CONFIG_FILE,
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
- /** 提交文生视频任务,返回 video_id。队列满(503)/限流(429) 会自动重试。 */
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
- async function submitVideoOnce({ prompt, seconds, ratio, model, baseUrl }) {
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: 'text',
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 属于瞬时限流,自动退避重试。 */