dsh-agnes-create 0.3.0 → 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 +68 -6
- package/lib/agnes.js +422 -10
- package/lib/client.js +1021 -25
- package/lib/index.js +154 -10
- package/package.json +10 -3
package/lib/index.js
CHANGED
|
@@ -6,10 +6,20 @@
|
|
|
6
6
|
* POST /agnes/generate { prompt, size, ratio } → 生成并落盘
|
|
7
7
|
*
|
|
8
8
|
* 视频(异步任务制)
|
|
9
|
-
* POST /agnes/video { prompt, seconds, ratio
|
|
9
|
+
* POST /agnes/video { prompt, seconds, ratio, seed?, firstFrame?, lastFrame? }
|
|
10
|
+
* 提交,立刻返回 { videoId }
|
|
11
|
+
* 给了 firstFrame/lastFrame 就自动走 keyframe 模式
|
|
10
12
|
* GET /agnes/video?id=… 查询一次状态;completed 时顺带下载落盘并返回 filename
|
|
11
13
|
* (下载结果按 videoId 缓存,反复轮询不会重复下载)
|
|
12
14
|
*
|
|
15
|
+
* 历史(宿主侧持久化,跨会话/重启都还在)
|
|
16
|
+
* GET /agnes/history 读回最近若干条
|
|
17
|
+
* POST /agnes/history 追加一条(生成完成后由前端调用)
|
|
18
|
+
* DELETE /agnes/history 清空
|
|
19
|
+
*
|
|
20
|
+
* 结果收尾
|
|
21
|
+
* POST /agnes/reveal { name } → 在访达里显示该文件(只能是输出目录内的文件)
|
|
22
|
+
*
|
|
13
23
|
* 媒体
|
|
14
24
|
* GET /agnes/media?name=… 把已生成的图片/视频流回浏览器,视频支持 Range
|
|
15
25
|
* GET /agnes/image?name=… 兼容旧链接,等价于 /agnes/media
|
|
@@ -18,15 +28,27 @@
|
|
|
18
28
|
* 也监听 internal/service 事件。
|
|
19
29
|
*/
|
|
20
30
|
|
|
31
|
+
import { execFile } from 'node:child_process';
|
|
32
|
+
import { basename, join, resolve as resolvePath } from 'node:path';
|
|
33
|
+
|
|
21
34
|
import {
|
|
22
35
|
MODEL, VIDEO_MODEL, VIDEO_SECONDS, VIDEO_RATIOS, VIDEO_SIZE,
|
|
23
|
-
DEFAULT_BASE, SIZES, RATIOS,
|
|
24
|
-
generate, keyStatus, readMedia, resolvePaths, saveConfig,
|
|
36
|
+
DEFAULT_BASE, SIZES, RATIOS, MAX_REF_IMAGES, HISTORY_MAX,
|
|
37
|
+
generate, keyStatus, readMedia, resolvePaths, saveConfig, safeName,
|
|
25
38
|
submitVideo, queryVideo, saveVideo,
|
|
39
|
+
readHistory, appendHistory, clearHistory,
|
|
40
|
+
listLibrary, fileExists,
|
|
26
41
|
} from './agnes.js';
|
|
27
42
|
|
|
28
43
|
export const name = 'dsh-agnes-create';
|
|
29
44
|
|
|
45
|
+
/** 普通 JSON 请求(配置、视频)1 MB 够用。 */
|
|
46
|
+
const BODY_LIMIT = 1_000_000;
|
|
47
|
+
|
|
48
|
+
/** 图生图的参考图走 base64 data URI,必须单独放宽到 32 MB:
|
|
49
|
+
* 8 张 × 前端下采样后的约 300–600 KB,base64 膨胀 4/3,再留足余量。 */
|
|
50
|
+
const REF_BODY_LIMIT = 32 * 1024 * 1024;
|
|
51
|
+
|
|
30
52
|
function sendJson(res, data, status = 200) {
|
|
31
53
|
res.writeHead(status, {
|
|
32
54
|
'content-type': 'application/json; charset=utf-8',
|
|
@@ -35,20 +57,24 @@ function sendJson(res, data, status = 200) {
|
|
|
35
57
|
res.end(JSON.stringify(data));
|
|
36
58
|
}
|
|
37
59
|
|
|
38
|
-
function readJson(req) {
|
|
60
|
+
function readJson(req, limit = BODY_LIMIT) {
|
|
39
61
|
return new Promise((resolve, reject) => {
|
|
40
62
|
const chunks = [];
|
|
41
63
|
let size = 0;
|
|
64
|
+
let aborted = false;
|
|
42
65
|
req.on('data', (c) => {
|
|
66
|
+
if (aborted) return;
|
|
43
67
|
size += c.length;
|
|
44
|
-
if (size >
|
|
45
|
-
|
|
68
|
+
if (size > limit) {
|
|
69
|
+
aborted = true;
|
|
70
|
+
reject(new Error(`请求体过大(上限 ${(limit / 1024 / 1024).toFixed(0)} MB)`));
|
|
46
71
|
req.destroy();
|
|
47
72
|
return;
|
|
48
73
|
}
|
|
49
74
|
chunks.push(c);
|
|
50
75
|
});
|
|
51
76
|
req.on('end', () => {
|
|
77
|
+
if (aborted) return;
|
|
52
78
|
const text = Buffer.concat(chunks).toString('utf8');
|
|
53
79
|
if (text.trim() === '') return resolve({});
|
|
54
80
|
try {
|
|
@@ -148,6 +174,7 @@ export function apply(ctx) {
|
|
|
148
174
|
defaults: cfg.defaults,
|
|
149
175
|
sizes: SIZES,
|
|
150
176
|
ratios: RATIOS,
|
|
177
|
+
maxRefImages: MAX_REF_IMAGES, // 图生图 / 多图合成的参考图上限
|
|
151
178
|
videoSizes: [VIDEO_SIZE],
|
|
152
179
|
videoSeconds: VIDEO_SECONDS,
|
|
153
180
|
videoRatios: VIDEO_RATIOS,
|
|
@@ -210,7 +237,7 @@ export function apply(ctx) {
|
|
|
210
237
|
handler: async (req, res) => {
|
|
211
238
|
if (req.method !== 'POST') return sendJson(res, { ok: false, error: '需要 POST' }, 405);
|
|
212
239
|
try {
|
|
213
|
-
const body = await readJson(req);
|
|
240
|
+
const body = await readJson(req, REF_BODY_LIMIT);
|
|
214
241
|
if (typeof body.prompt !== 'string' || body.prompt.trim() === '') {
|
|
215
242
|
return sendJson(res, { ok: false, error: '提示词不能为空' }, 400);
|
|
216
243
|
}
|
|
@@ -219,10 +246,16 @@ export function apply(ctx) {
|
|
|
219
246
|
size: body.size,
|
|
220
247
|
ratio: body.ratio,
|
|
221
248
|
model: body.model,
|
|
249
|
+
// 图生图 / 多图合成:参考图数组(data URI 或 http(s) 链接)。
|
|
250
|
+
// 校验在内核 normalizeRefs 里做,这里只负责透传。
|
|
251
|
+
images: body.images,
|
|
222
252
|
});
|
|
223
253
|
sendJson(res, { ...result, kind: 'image' });
|
|
224
254
|
} catch (e) {
|
|
225
|
-
|
|
255
|
+
// 参考图参数不合法属于用户输入问题,用 400 区分于上游 500
|
|
256
|
+
const msg = errMessage(e);
|
|
257
|
+
const status = /参考图|images|prompt|size 必须|ratio 必须/.test(msg) ? 400 : 500;
|
|
258
|
+
sendJson(res, { ok: false, error: msg }, status);
|
|
226
259
|
}
|
|
227
260
|
},
|
|
228
261
|
});
|
|
@@ -237,7 +270,8 @@ export function apply(ctx) {
|
|
|
237
270
|
|
|
238
271
|
// POST = 提交任务
|
|
239
272
|
if (req.method === 'POST') {
|
|
240
|
-
|
|
273
|
+
// 关键帧是 base64 data URI,1 MB 装不下,所以和参考图共用 32 MB 上限
|
|
274
|
+
const body = await readJson(req, REF_BODY_LIMIT);
|
|
241
275
|
if (typeof body.prompt !== 'string' || body.prompt.trim() === '') {
|
|
242
276
|
return sendJson(res, { ok: false, error: '提示词不能为空' }, 400);
|
|
243
277
|
}
|
|
@@ -245,8 +279,18 @@ export function apply(ctx) {
|
|
|
245
279
|
prompt: body.prompt,
|
|
246
280
|
seconds: body.seconds,
|
|
247
281
|
ratio: body.ratio,
|
|
282
|
+
// 关键帧 / 种子:有就传,没有内核会退回纯文生视频
|
|
283
|
+
seed: body.seed,
|
|
284
|
+
firstFrame: body.firstFrame,
|
|
285
|
+
lastFrame: body.lastFrame,
|
|
286
|
+
});
|
|
287
|
+
return sendJson(res, {
|
|
288
|
+
ok: true,
|
|
289
|
+
videoId: sub.videoId,
|
|
290
|
+
taskId: sub.taskId,
|
|
291
|
+
model: sub.model,
|
|
292
|
+
mode: sub.mode,
|
|
248
293
|
});
|
|
249
|
-
return sendJson(res, { ok: true, videoId: sub.videoId, taskId: sub.taskId, model: sub.model });
|
|
250
294
|
}
|
|
251
295
|
|
|
252
296
|
// GET = 查询一次状态
|
|
@@ -291,6 +335,106 @@ export function apply(ctx) {
|
|
|
291
335
|
},
|
|
292
336
|
});
|
|
293
337
|
|
|
338
|
+
/* ---------- 历史记录(落盘,跨会话保留) ---------- */
|
|
339
|
+
/* 历史里可能留着指向旧输出目录的记录(比如曾经把 outDir 改到别处)。
|
|
340
|
+
这里只把「文件此刻确实还在当前输出目录里」的条目交给界面,
|
|
341
|
+
顺手把过滤后的列表写回磁盘,免得陈旧记录一直躺在文件里。 */
|
|
342
|
+
const liveHistory = async () => {
|
|
343
|
+
const { entries } = await readHistory();
|
|
344
|
+
const { outDir } = await resolvePaths();
|
|
345
|
+
const keep = [];
|
|
346
|
+
for (const entry of entries) {
|
|
347
|
+
if (await fileExists(join(outDir, entry.filename))) keep.push(entry);
|
|
348
|
+
}
|
|
349
|
+
if (keep.length !== entries.length) {
|
|
350
|
+
try {
|
|
351
|
+
await clearHistory();
|
|
352
|
+
// appendHistory 是置顶语义,倒序写回才能保持原有顺序
|
|
353
|
+
for (let i = keep.length - 1; i >= 0; i -= 1) await appendHistory(keep[i]);
|
|
354
|
+
} catch {
|
|
355
|
+
// 写回失败不影响本次返回
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return keep;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
addRoute(webServer, {
|
|
362
|
+
kind: 'exact',
|
|
363
|
+
path: '/agnes/history',
|
|
364
|
+
handler: async (req, res) => {
|
|
365
|
+
try {
|
|
366
|
+
if (req.method === 'GET') {
|
|
367
|
+
const entries = await liveHistory();
|
|
368
|
+
const { file } = await readHistory();
|
|
369
|
+
return sendJson(res, { ok: true, entries, file, max: HISTORY_MAX });
|
|
370
|
+
}
|
|
371
|
+
if (req.method === 'POST') {
|
|
372
|
+
// 单条追加:前端每生成一张图/一段视频就来记一笔
|
|
373
|
+
const body = await readJson(req);
|
|
374
|
+
await appendHistory(body);
|
|
375
|
+
const entries = await liveHistory();
|
|
376
|
+
return sendJson(res, { ok: true, entries, max: HISTORY_MAX });
|
|
377
|
+
}
|
|
378
|
+
if (req.method === 'DELETE') {
|
|
379
|
+
await clearHistory();
|
|
380
|
+
return sendJson(res, { ok: true, entries: [], max: HISTORY_MAX });
|
|
381
|
+
}
|
|
382
|
+
return sendJson(res, { ok: false, error: '需要 GET / POST / DELETE' }, 405);
|
|
383
|
+
} catch (e) {
|
|
384
|
+
sendJson(res, { ok: false, error: errMessage(e) }, 400);
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
/* ---------- 输出目录清单:把已经存在的图/视频捞回界面 ---------- */
|
|
390
|
+
addRoute(webServer, {
|
|
391
|
+
kind: 'exact',
|
|
392
|
+
path: '/agnes/library',
|
|
393
|
+
handler: async (req, res) => {
|
|
394
|
+
try {
|
|
395
|
+
if (req.method !== 'GET') return sendJson(res, { ok: false, error: '需要 GET' }, 405);
|
|
396
|
+
const { outDir } = await resolvePaths();
|
|
397
|
+
const files = await listLibrary(outDir);
|
|
398
|
+
sendJson(res, {
|
|
399
|
+
ok: true,
|
|
400
|
+
outDir,
|
|
401
|
+
files,
|
|
402
|
+
images: files.filter((f) => f.kind === 'image').length,
|
|
403
|
+
videos: files.filter((f) => f.kind === 'video').length,
|
|
404
|
+
});
|
|
405
|
+
} catch (e) {
|
|
406
|
+
sendJson(res, { ok: false, error: errMessage(e) }, 500);
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
/* ---------- 在访达里显示结果文件 ---------- */
|
|
412
|
+
addRoute(webServer, {
|
|
413
|
+
kind: 'exact',
|
|
414
|
+
path: '/agnes/reveal',
|
|
415
|
+
handler: async (req, res) => {
|
|
416
|
+
try {
|
|
417
|
+
if (req.method !== 'POST') return sendJson(res, { ok: false, error: '需要 POST' }, 405);
|
|
418
|
+
const body = await readJson(req);
|
|
419
|
+
// safeName 只放行 [A-Za-z0-9._-],顺带挡住路径穿越
|
|
420
|
+
const safe = safeName(typeof body.name === 'string' ? body.name : '');
|
|
421
|
+
if (safe === null) return sendJson(res, { ok: false, error: '非法文件名' }, 400);
|
|
422
|
+
const { outDir } = await resolvePaths();
|
|
423
|
+
const abs = resolvePath(outDir, safe);
|
|
424
|
+
// 双保险:解析后必须仍在输出目录里
|
|
425
|
+
if (!abs.startsWith(resolvePath(outDir) + '/')) {
|
|
426
|
+
return sendJson(res, { ok: false, error: '文件不在输出目录内' }, 400);
|
|
427
|
+
}
|
|
428
|
+
await new Promise((resolve, reject) => {
|
|
429
|
+
execFile('open', ['-R', abs], (err) => (err ? reject(err) : resolve()));
|
|
430
|
+
});
|
|
431
|
+
sendJson(res, { ok: true, path: abs });
|
|
432
|
+
} catch (e) {
|
|
433
|
+
sendJson(res, { ok: false, error: errMessage(e) }, 500);
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
|
|
294
438
|
/* ---------- 媒体(图片 + 视频) ---------- */
|
|
295
439
|
const mediaHandler = async (req, res) => {
|
|
296
440
|
try {
|
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.7.0",
|
|
4
|
+
"description": "Agnes 创作:在 DeepSeek Harness 里用 agnes-image-2.5-flash 出图(含图生图 / 多图合成)、agnes-video-2.5-flash 出视频(含首帧 / 尾帧图生视频与 seed)。侧边栏面板 + 提示词 + 参考图,结果内联展示并可复制路径 / 访达显示 / 下载;提示词会写进图片元数据,鼠标悬停即可查看与复制;可浏览输出目录里已有的图片与视频,历史落盘 24 条。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -31,7 +31,14 @@
|
|
|
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",
|
|
38
|
+
"text-to-video",
|
|
39
|
+
"image-to-video",
|
|
40
|
+
"keyframe",
|
|
41
|
+
"seed"
|
|
35
42
|
],
|
|
36
43
|
"engines": {
|
|
37
44
|
"dsh": ">=0.2.2-rc.1"
|