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/lib/client.js CHANGED
@@ -16,6 +16,25 @@ window.__ModuleLoader__.load({
16
16
  const HISTORY_MAX = 8;
17
17
  const POLL_MS = 6000;
18
18
  const VIDEO_DEADLINE_MS = 15 * 60 * 1000;
19
+ // 图生图:一次最多 8 张参考图(与宿主 MAX_REF_IMAGES 保持一致)
20
+ const MAX_REFS = 8;
21
+ // 发给上游前先下采样:长边 1280 足够模型识别,base64 也压得住
22
+ const REF_MAX_EDGE = 1280;
23
+ const REF_QUALITY = 0.88;
24
+ const REF_PNG_KEEP_BYTES = 800 * 1024;
25
+
26
+ /**
27
+ * 提示词预设:点一下把风格描述追加到当前提示词末尾。
28
+ * 刻意写成「可叠加的风格后缀」,而不是整段替换——用户已经写好的主体不会被冲掉。
29
+ */
30
+ const PROMPT_PRESETS = [
31
+ { label: "写实摄影", text: "写实摄影风格,自然光,浅景深,35mm 镜头,细节丰富" },
32
+ { label: "水彩", text: "水彩画风格,纸纹质感,柔和晕染,留白构图" },
33
+ { label: "3D 渲染", text: "3D 渲染风格,柔光棚拍,材质清晰,背景简洁" },
34
+ { label: "海报", text: "平面海报构图,大面积留白,主体突出,配色克制" },
35
+ { label: "夜景霓虹", text: "夜景霓虹灯光,湿润地面反光,氛围浓烈" },
36
+ { label: "极简", text: "极简构图,单一主体,纯净背景,柔和渐变光" },
37
+ ];
19
38
 
20
39
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
40
 
@@ -67,7 +86,8 @@ window.__ModuleLoader__.load({
67
86
  transition: width .3s ease; }
68
87
 
69
88
  /* 媒体展示区 */
70
- .agn-shot { border-radius: 10px; border: 1px solid var(--dsw-alias-border-l2, #e3e3e3);
89
+ .agn-shot { position: relative; border-radius: 10px;
90
+ border: 1px solid var(--dsw-alias-border-l2, #e3e3e3);
71
91
  background: var(--dsw-alias-bg-layer-2, #fafafa); overflow: hidden;
72
92
  display: flex; align-items: center; justify-content: center; min-height: 120px; }
73
93
  .agn-shot img, .agn-shot video { display: block; max-width: 100%; height: auto; }
@@ -76,6 +96,39 @@ window.__ModuleLoader__.load({
76
96
  .agn-shot-msg { padding: 18px; font-size: 12px; text-align: center; line-height: 1.7;
77
97
  color: var(--dsw-alias-label-secondary, #888); }
78
98
 
99
+ /* 悬停浮现的提示词:贴在媒体底部,默认透明,鼠标进来才出现 */
100
+ .agn-prompt { position: absolute; left: 0; right: 0; bottom: 0;
101
+ display: flex; align-items: flex-end; gap: 8px; padding: 8px 10px;
102
+ background: linear-gradient(to top, rgba(0,0,0,.62), rgba(0,0,0,0));
103
+ opacity: 0; transition: opacity .16s ease; pointer-events: none; }
104
+ .agn-shot:hover .agn-prompt { opacity: 1; pointer-events: auto; }
105
+ .agn-prompt-text { flex: 1 1 auto; min-width: 0; font-size: 11px; line-height: 1.5;
106
+ color: #fff; display: -webkit-box; -webkit-line-clamp: 3;
107
+ -webkit-box-orient: vertical; overflow: hidden; word-break: break-word; }
108
+ .agn-prompt-copy { flex: none; font: inherit; font-size: 11px; line-height: 1.3;
109
+ padding: 2px 8px; border-radius: 5px; cursor: pointer;
110
+ color: #fff; background: rgba(255,255,255,.18);
111
+ border: 1px solid rgba(255,255,255,.45); }
112
+ .agn-prompt-copy:hover { background: rgba(255,255,255,.3); }
113
+
114
+ /* 输出目录清单 */
115
+ .agn-lib { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
116
+ .agn-lib-tabs { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
117
+ .agn-lib-grid { display: flex; gap: 8px; flex-wrap: wrap;
118
+ max-height: 300px; overflow-y: auto; padding: 2px; }
119
+ .agn-lib-item { position: relative; width: 84px; height: 84px; border-radius: 8px;
120
+ padding: 0; overflow: hidden; cursor: pointer; flex: none;
121
+ border: 2px solid var(--dsw-alias-border-l2, #e3e3e3);
122
+ background: var(--dsw-alias-bg-layer-2, #fafafa); }
123
+ .agn-lib-item:hover { border-color: var(--dsw-alias-state-business-primary, #4b7bec); }
124
+ .agn-lib-item img, .agn-lib-item video { width: 100%; height: 100%;
125
+ object-fit: cover; display: block; pointer-events: none; }
126
+ .agn-lib-badge { position: absolute; left: 4px; bottom: 4px; font-size: 10px;
127
+ line-height: 1.4; padding: 0 5px; border-radius: 4px;
128
+ color: #fff; background: rgba(0,0,0,.55); }
129
+ .agn-lib-empty { font-size: 12px; line-height: 1.6;
130
+ color: var(--dsw-alias-label-secondary, #888); }
131
+
79
132
  /* 历史缩略图 */
80
133
  .agn-hist { display: flex; flex-direction: column; gap: 6px; }
81
134
  .agn-hist-strip { display: flex; gap: 8px; flex-wrap: wrap; }
@@ -84,10 +137,15 @@ window.__ModuleLoader__.load({
84
137
  border: 2px solid var(--dsw-alias-border-l2, #e3e3e3);
85
138
  background: var(--dsw-alias-bg-layer-2, #fafafa); }
86
139
  .agn-thumb-on { border-color: var(--dsw-alias-state-business-primary, #4b7bec); }
87
- .agn-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
140
+ /* 缩略图里放 <video> 取首帧;pointer-events: none 让点击继续落到外层按钮上
141
+ (video 属于可交互内容,套在 button 里会吃掉点击) */
142
+ .agn-thumb img, .agn-thumb video { width: 100%; height: 100%;
143
+ object-fit: cover; display: block; pointer-events: none; }
144
+ /* ▶ 只是角标,压在首帧上;别挡点击,也别把首帧盖住 */
88
145
  .agn-thumb-play { position: absolute; inset: 0; display: flex; align-items: center;
89
- justify-content: center; font-size: 22px; line-height: 1;
90
- color: #fff; background: rgba(0, 0, 0, .3); }
146
+ justify-content: center; font-size: 20px; line-height: 1;
147
+ color: #fff; text-shadow: 0 1px 4px rgba(0, 0, 0, .75);
148
+ pointer-events: none; background: rgba(0, 0, 0, .12); }
91
149
  .agn-thumb-cap { position: absolute; left: 0; right: 0; bottom: 0; font-size: 10px;
92
150
  text-align: center; padding: 1px 0;
93
151
  color: var(--dsw-alias-label-primary-foreground, #fff);
@@ -109,6 +167,84 @@ window.__ModuleLoader__.load({
109
167
  .agn-hint { font-size: 12px; color: var(--dsw-alias-label-secondary, #888); line-height: 1.6; }
110
168
  .agn-code { font-family: var(--ds-font-family-code, ui-monospace, monospace); font-size: 11px;
111
169
  word-break: break-all; }
170
+
171
+ /* 参考图(图生图 / 多图合成) */
172
+ .agn-refs { display: flex; flex-direction: column; gap: 8px; }
173
+ .agn-ref-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
174
+ .agn-ref-strip { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
175
+ .agn-ref { position: relative; width: 72px; height: 72px; border-radius: 8px; overflow: hidden;
176
+ flex: none; border: 1px solid var(--dsw-alias-border-l2, #e3e3e3);
177
+ background: var(--dsw-alias-bg-layer-2, #fafafa); }
178
+ .agn-ref img { width: 100%; height: 100%; object-fit: cover; display: block; }
179
+ .agn-ref-del { position: absolute; top: 2px; right: 2px; width: 18px; height: 18px;
180
+ border: none; border-radius: 50%; cursor: pointer; padding: 0;
181
+ font-size: 11px; line-height: 18px; text-align: center;
182
+ color: #fff; background: rgba(0, 0, 0, .6); }
183
+ .agn-ref-del:hover { background: rgba(0, 0, 0, .8); }
184
+ .agn-ref-add { width: 72px; height: 72px; border-radius: 8px; cursor: pointer; flex: none;
185
+ font: inherit; font-size: 11px; line-height: 1.35; padding: 4px;
186
+ color: var(--dsw-alias-label-secondary, #666); background: transparent;
187
+ border: 1px dashed var(--dsw-alias-border-l2, #ccc); }
188
+ .agn-ref-add:disabled { opacity: .45; cursor: not-allowed; }
189
+ .agn-ref-pick { display: flex; gap: 6px; flex-wrap: wrap; padding: 8px; border-radius: 8px;
190
+ border: 1px solid var(--dsw-alias-border-l2, #e3e3e3);
191
+ background: var(--dsw-alias-bg-layer-2, #fafafa); }
192
+ .agn-ref-pick button { width: 56px; height: 56px; padding: 0; border-radius: 6px;
193
+ overflow: hidden; cursor: pointer; flex: none;
194
+ border: 1px solid var(--dsw-alias-border-l2, #ddd); background: transparent; }
195
+ .agn-ref-pick img { width: 100%; height: 100%; object-fit: cover; display: block; }
196
+ .agn-tag { font-size: 11px; padding: 2px 8px; border-radius: 999px;
197
+ background: var(--dsw-alias-bg-layer-2, #f1f1f1);
198
+ color: var(--dsw-alias-label-secondary, #555); }
199
+
200
+ /* 提示词预设 */
201
+ .agn-presets { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
202
+ .agn-preset { font: inherit; font-size: 11px; line-height: 1.4; padding: 2px 9px;
203
+ border-radius: 999px; cursor: pointer;
204
+ color: var(--dsw-alias-label-secondary, #555); background: transparent;
205
+ border: 1px solid var(--dsw-alias-border-l2, #ddd); }
206
+ .agn-preset:hover:not(:disabled) { border-color: var(--dsw-alias-state-business-primary, #4b7bec);
207
+ color: var(--dsw-alias-state-business-primary, #4b7bec); }
208
+ .agn-preset:disabled { opacity: .45; cursor: not-allowed; }
209
+
210
+ /* 视频关键帧(图生视频) */
211
+ .agn-frames { display: flex; flex-direction: column; gap: 8px; }
212
+ .agn-frames-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-start; }
213
+ .agn-frame { display: flex; flex-direction: column; gap: 4px; flex: none; }
214
+ .agn-frame-cap { font-size: 11px; color: var(--dsw-alias-label-secondary, #888); }
215
+ .agn-frame-box { position: relative; width: 72px; height: 72px; border-radius: 8px;
216
+ overflow: hidden; border: 1px solid var(--dsw-alias-border-l2, #e3e3e3);
217
+ background: var(--dsw-alias-bg-layer-2, #fafafa); }
218
+ .agn-frame-box img { width: 100%; height: 100%; object-fit: cover; display: block; }
219
+ .agn-frame-add { width: 72px; height: 72px; border-radius: 8px; cursor: pointer;
220
+ font: inherit; font-size: 11px; line-height: 1.35; padding: 4px;
221
+ color: var(--dsw-alias-label-secondary, #666); background: transparent;
222
+ border: 1px dashed var(--dsw-alias-border-l2, #ccc); }
223
+ .agn-frame-add:disabled { opacity: .45; cursor: not-allowed; }
224
+ /* 「+ 结果」:从已生成的图片里挑一张当关键帧 */
225
+ .agn-frame-from { width: 72px; font: inherit; font-size: 11px; line-height: 1.4;
226
+ padding: 2px 0; border-radius: 6px; cursor: pointer;
227
+ color: var(--dsw-alias-label-secondary, #555); background: transparent;
228
+ border: 1px solid var(--dsw-alias-border-l2, #ddd); }
229
+ .agn-frame-from:hover:not(:disabled) {
230
+ border-color: var(--dsw-alias-state-business-primary, #4b7bec);
231
+ color: var(--dsw-alias-state-business-primary, #4b7bec); }
232
+ .agn-frame-from:disabled { opacity: .45; cursor: not-allowed; }
233
+ .agn-frame .agn-ref-pick { margin-top: 2px; }
234
+ .agn-seed { width: 72px; }
235
+
236
+ /* 结果收尾动作 */
237
+ .agn-acts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
238
+ .agn-act { font: inherit; font-size: 11px; line-height: 1.4; padding: 3px 10px;
239
+ border-radius: 6px; cursor: pointer;
240
+ color: var(--dsw-alias-label-secondary, #555); background: transparent;
241
+ border: 1px solid var(--dsw-alias-border-l2, #ddd); }
242
+ .agn-act:hover { border-color: var(--dsw-alias-state-business-primary, #4b7bec);
243
+ color: var(--dsw-alias-state-business-primary, #4b7bec); }
244
+ .agn-act-on { border-color: var(--dsw-alias-state-business-primary, #4b7bec);
245
+ color: var(--dsw-alias-state-business-primary, #4b7bec); }
246
+ .agn-notice { font-size: 12px; color: var(--dsw-alias-state-success-primary, #2a2);
247
+ line-height: 1.6; }
112
248
  `;
113
249
 
114
250
  function ensureCss() {
@@ -125,6 +261,84 @@ window.__ModuleLoader__.load({
125
261
  return "/agnes/media?name=" + encodeURIComponent(name);
126
262
  }
127
263
 
264
+ /* ==========================================================================
265
+ 参考图预处理(图生图)
266
+ --------------------------------------------------------------------------
267
+ 两个来源最终都归一成 data URI:
268
+ · 本地文件 —— <input type="file"> 给的 File
269
+ · 生成结果 —— /agnes/media 取回 Blob
270
+ 先按长边 1280 下采样再编码,否则一张 4K PNG 的 base64 就有十几 MB,
271
+ 宿主请求体和上游都要跟着炸。
272
+ ========================================================================== */
273
+
274
+ let refSeq = 0;
275
+ const nextRefId = () => "ref-" + (refSeq += 1) + "-" + Date.now();
276
+
277
+ function blobToDataUrl(blob) {
278
+ return new Promise((resolve, reject) => {
279
+ const fr = new FileReader();
280
+ fr.onload = () => resolve(String(fr.result));
281
+ fr.onerror = () => reject(new Error("读取图片失败"));
282
+ fr.readAsDataURL(blob);
283
+ });
284
+ }
285
+
286
+ /** 编码一次 canvas;toBlob 回调可能是 null(极端尺寸/内存不足) */
287
+ function canvasToBlob(canvas, type, quality) {
288
+ return new Promise((resolve) => {
289
+ try {
290
+ canvas.toBlob((b) => resolve(b), type, quality);
291
+ } catch {
292
+ resolve(null);
293
+ }
294
+ });
295
+ }
296
+
297
+ /**
298
+ * 把任意图片 Blob 压成适合上传的 data URI。
299
+ * PNG 优先保留(多图合成常带透明通道),但超过阈值就退回 JPEG。
300
+ */
301
+ async function shrinkImage(blob) {
302
+ if (!(blob instanceof Blob) || blob.size === 0) throw new Error("图片是空的");
303
+
304
+ let bitmap;
305
+ try {
306
+ bitmap = await createImageBitmap(blob);
307
+ } catch {
308
+ // 极少数格式解码不了:小图原样上传,大图明确报错而不是偷偷传十几 MB
309
+ if (blob.size <= REF_PNG_KEEP_BYTES) {
310
+ return { src: await blobToDataUrl(blob), bytes: blob.size, shrunk: false };
311
+ }
312
+ throw new Error("无法解码这张图片(试试 PNG/JPEG/WebP)");
313
+ }
314
+
315
+ const longest = Math.max(bitmap.width, bitmap.height) || 1;
316
+ const scale = Math.min(1, REF_MAX_EDGE / longest);
317
+ const w = Math.max(1, Math.round(bitmap.width * scale));
318
+ const h = Math.max(1, Math.round(bitmap.height * scale));
319
+
320
+ const canvas = document.createElement("canvas");
321
+ canvas.width = w;
322
+ canvas.height = h;
323
+ const ctx = canvas.getContext("2d");
324
+ if (ctx === null) {
325
+ if (typeof bitmap.close === "function") bitmap.close();
326
+ return { src: await blobToDataUrl(blob), bytes: blob.size, shrunk: false };
327
+ }
328
+ ctx.drawImage(bitmap, 0, 0, w, h);
329
+ if (typeof bitmap.close === "function") bitmap.close();
330
+
331
+ const wantPng = blob.type === "image/png";
332
+ let out = await canvasToBlob(canvas, wantPng ? "image/png" : "image/jpeg", REF_QUALITY);
333
+ if (out === null) throw new Error("图片编码失败");
334
+ if (wantPng && out.size > REF_PNG_KEEP_BYTES) {
335
+ const jpeg = await canvasToBlob(canvas, "image/jpeg", REF_QUALITY);
336
+ if (jpeg !== null && jpeg.size < out.size) out = jpeg;
337
+ }
338
+
339
+ return { src: await blobToDataUrl(out), bytes: out.size, shrunk: scale < 1 };
340
+ }
341
+
128
342
  /* ==========================================================================
129
343
  状态仓库(放组件外面:main slot 切换会卸载组件,状态必须独立存活)
130
344
  ========================================================================== */
@@ -160,6 +374,33 @@ window.__ModuleLoader__.load({
160
374
  cfgSaving: false,
161
375
  cfgError: "",
162
376
  cfgNotice: "",
377
+
378
+ /* ---- 图生图参考图 ---- */
379
+ refs: [], // { id, name, src(dataURI), bytes, from: 'local' | 'result' }
380
+ refsBusy: false,
381
+ refsError: "",
382
+ refPickOpen: false,
383
+
384
+ /* ---- 视频关键帧(可选):本地图直接当首帧 / 尾帧 ---- */
385
+ vFirst: null, // { src(dataURI), name, bytes }
386
+ vLast: null,
387
+ vSeed: "", // 空串 = 不传 seed
388
+ frameBusy: false, // 正在压缩关键帧图片
389
+ framePick: "", // '' | 'first' | 'last':本地文件选择器的目标
390
+ framePickFrom: "", // '' | 'first' | 'last':生成结果选择面板的目标
391
+
392
+ /* ---- 历史:宿主落盘,刷新/重启后还在 ---- */
393
+ historyMax: HISTORY_MAX,
394
+ historyLoaded: false,
395
+ notice: "", // 一次性操作反馈(复制路径、打开访达…)
396
+
397
+ /* ---- 输出目录清单:把磁盘上已有的图/视频捞回来看 ---- */
398
+ library: null, // null = 还没加载过;[] = 加载过但是空
399
+ libraryOpen: false,
400
+ libraryTab: "image", // 'image' | 'video'
401
+ libraryBusy: false,
402
+ libraryError: "",
403
+ copiedKey: "", // 刚刚复制了哪一条的提示词(用于显示「已复制」)
163
404
  };
164
405
 
165
406
  const snapshot = () => ({ ...state, history: state.history.slice() });
@@ -172,6 +413,12 @@ window.__ModuleLoader__.load({
172
413
  Object.assign(state, patch);
173
414
  notify();
174
415
  };
416
+ /** 基于当前状态计算补丁:避免「读旧值再写回」导致的覆盖。 */
417
+ const update = (fn) => {
418
+ const patch = fn(state);
419
+ if (patch !== null && patch !== undefined) Object.assign(state, patch);
420
+ notify();
421
+ };
175
422
 
176
423
  let timer = null;
177
424
  let runToken = 0;
@@ -187,9 +434,58 @@ window.__ModuleLoader__.load({
187
434
  };
188
435
 
189
436
  const pushResult = (entry) => {
190
- set({ result: entry, history: [entry, ...state.history].slice(0, HISTORY_MAX) });
437
+ set({ result: entry, history: [entry, ...state.history].slice(0, state.historyMax) });
438
+ // 顺手落盘:/agnes/history 是唯一真相,写失败也不影响本次展示
439
+ void appendHistory(entry);
191
440
  };
192
441
 
442
+ /* ---------- 历史(宿主持久化) ---------- */
443
+ async function loadHistory() {
444
+ try {
445
+ const res = await fetch("/agnes/history");
446
+ if (!res.ok) {
447
+ // 旧版宿主没有这条路由:保持空历史即可,不要报错吓人
448
+ set({ historyLoaded: true });
449
+ return;
450
+ }
451
+ const data = await res.json();
452
+ if (!data || data.ok !== true) {
453
+ set({ historyLoaded: true });
454
+ return;
455
+ }
456
+ const entries = Array.isArray(data.entries) ? data.entries : [];
457
+ const max = typeof data.max === "number" && data.max > 0 ? data.max : HISTORY_MAX;
458
+ update((prev) => ({
459
+ history: entries,
460
+ historyMax: max,
461
+ historyLoaded: true,
462
+ // 主预览默认显示最新一条;已经有结果时不动它
463
+ result: prev.result === null ? (entries[0] || null) : prev.result,
464
+ }));
465
+ } catch {
466
+ set({ historyLoaded: true });
467
+ }
468
+ }
469
+
470
+ async function appendHistory(entry) {
471
+ try {
472
+ const res = await fetch("/agnes/history", {
473
+ method: "POST",
474
+ headers: { "content-type": "application/json" },
475
+ body: JSON.stringify(entry),
476
+ });
477
+ if (!res.ok) return;
478
+ const data = await res.json();
479
+ if (!data || data.ok !== true) return;
480
+ // 只有宿主确实回了列表才覆盖本地:宁可用旧列表,也不要把界面清空
481
+ if (!Array.isArray(data.entries)) return;
482
+ update((prev) => ({
483
+ history: data.entries,
484
+ historyMax: typeof data.max === "number" && data.max > 0 ? data.max : prev.historyMax,
485
+ }));
486
+ } catch { /* 落盘失败不影响当前结果展示 */ }
487
+ }
488
+
193
489
  async function loadHealth() {
194
490
  if (state.healthRequested) return;
195
491
  state.healthRequested = true;
@@ -259,30 +555,361 @@ window.__ModuleLoader__.load({
259
555
 
260
556
  /* ---------- 图片:一次请求就出结果 ---------- */
261
557
  async function runImage(token, prompt) {
558
+ const refs = state.refs;
559
+ const payload = {
560
+ prompt,
561
+ size: state.size,
562
+ ratio: state.ratio,
563
+ // 有参考图就是图生图 / 多图合成;宿主原样透传给上游的 extra_body.image
564
+ ...(refs.length > 0 ? { images: refs.map((r) => r.src) } : {}),
565
+ };
262
566
  const res = await fetch("/agnes/generate", {
263
567
  method: "POST",
264
568
  headers: { "content-type": "application/json" },
265
- body: JSON.stringify({ prompt, size: state.size, ratio: state.ratio }),
569
+ body: JSON.stringify(payload),
266
570
  });
267
571
  const data = await res.json();
268
572
  if (!data.ok) throw new Error(data.error || "生成失败");
269
573
  if (token !== runToken) return;
270
- pushResult({ ...data, kind: "image", wallSec: ((Date.now() - state.startedAt) / 1000).toFixed(1) });
574
+ pushResult({ ...data, kind: "image", prompt, wallSec: ((Date.now() - state.startedAt) / 1000).toFixed(1) });
575
+ }
576
+
577
+ /* ---------- 参考图(图生图) ---------- */
578
+
579
+ const refsFull = () => state.refs.length >= MAX_REFS;
580
+
581
+ /**
582
+ * 参考图操作串行化。
583
+ *
584
+ * 之前用 `if (state.refsBusy) return` 做互斥:一旦某个操作正在跑
585
+ * (读生成结果要 fetch + 解码,可能几百毫秒),这期间用户的另一次添加
586
+ * 会被**静默丢弃**——点了没反应,看起来就像"刚加的图不见了"。
587
+ * 现在改成排队执行:不丢单,也不会出现两个操作互相覆盖 refs。
588
+ */
589
+ let refQueue = Promise.resolve();
590
+ function queueRefOp(task) {
591
+ const run = refQueue.then(task, task);
592
+ // 单个操作失败不能卡死后面的队列
593
+ refQueue = run.catch(() => {});
594
+ return run;
595
+ }
596
+
597
+ /** 来源一:本地磁盘文件(可多选)。返回 Promise,调用方可等它读完再清空 input。 */
598
+ function addRefsFromFiles(files) {
599
+ const list = Array.from(files || []).filter(
600
+ (f) => f && String(f.type || "").startsWith("image/")
601
+ );
602
+ if (list.length === 0) {
603
+ set({ refsError: "没有可用的图片文件(需要 PNG / JPEG / WebP / GIF)" });
604
+ return Promise.resolve();
605
+ }
606
+
607
+ return queueRefOp(async () => {
608
+ set({ refsBusy: true, refsError: "" });
609
+ const added = [];
610
+ const failed = [];
611
+ try {
612
+ for (const file of list) {
613
+ if (state.refs.length + added.length >= MAX_REFS) {
614
+ failed.push("超出 " + MAX_REFS + " 张上限");
615
+ break;
616
+ }
617
+ try {
618
+ // 先把文件读进内存再继续:即使 input.value 被清空,读也不会断
619
+ const r = await shrinkImage(file);
620
+ added.push({
621
+ id: nextRefId(),
622
+ name: file.name || "本地图片",
623
+ src: r.src,
624
+ bytes: r.bytes,
625
+ from: "local",
626
+ });
627
+ } catch (e) {
628
+ failed.push((file.name || "图片") + ":" + (e && e.message ? e.message : String(e)));
629
+ }
630
+ }
631
+ if (added.length > 0) {
632
+ // 用 update 基于最新状态追加,杜绝覆盖别的来源刚加进来的图
633
+ update((prev) => ({ refs: prev.refs.concat(added) }));
634
+ }
635
+ set({
636
+ refsError: failed.length > 0 ? failed.join(";") : "",
637
+ refPickOpen: false,
638
+ });
639
+ } finally {
640
+ set({ refsBusy: false });
641
+ }
642
+ });
643
+ }
644
+
645
+ /** 来源二:生成结果里的图片(通过 /agnes/media 取回再下采样) */
646
+ function addRefFromResult(entry) {
647
+ if (!entry || entry.kind !== "image" || !entry.filename) return Promise.resolve();
648
+ if (refsFull()) {
649
+ set({ refsError: "参考图最多 " + MAX_REFS + " 张" });
650
+ return Promise.resolve();
651
+ }
652
+
653
+ return queueRefOp(async () => {
654
+ set({ refsBusy: true, refsError: "" });
655
+ try {
656
+ const res = await fetch(srcOf(entry.filename));
657
+ if (!res.ok) throw new Error("读取生成结果失败:HTTP " + res.status);
658
+ const blob = await res.blob();
659
+ const r = await shrinkImage(blob);
660
+ update((prev) => ({
661
+ refs: prev.refs.concat([{
662
+ id: nextRefId(),
663
+ name: entry.filename,
664
+ src: r.src,
665
+ bytes: r.bytes,
666
+ from: "result",
667
+ }]),
668
+ refPickOpen: false,
669
+ }));
670
+ } catch (e) {
671
+ set({ refsError: e && e.message ? e.message : String(e) });
672
+ } finally {
673
+ set({ refsBusy: false });
674
+ }
675
+ });
676
+ }
677
+
678
+ function removeRef(id) {
679
+ if (state.busy) return;
680
+ update((prev) => ({ refs: prev.refs.filter((r) => r.id !== id), refsError: "" }));
681
+ }
682
+
683
+ function clearRefs() {
684
+ if (state.busy) return;
685
+ set({ refs: [], refsError: "", refPickOpen: false });
686
+ }
687
+
688
+ function toggleRefPick() {
689
+ if (state.busy) return;
690
+ set({ refPickOpen: !state.refPickOpen, refsError: "" });
691
+ }
692
+
693
+ /* ---------- 视频关键帧(首帧 / 尾帧,本地图直接当帧) ---------- */
694
+ const frameField = (which) => (which === "last" ? "vLast" : "vFirst");
695
+
696
+ function pickFrame(which) {
697
+ if (state.busy || state.frameBusy) return;
698
+ set({ framePick: which, error: "" });
699
+ }
700
+
701
+ async function setFrameFromFile(which, file) {
702
+ set({ frameBusy: true, error: "" });
703
+ try {
704
+ // 和参考图走同一套下采样:长边 1280、PNG 优先,既省上行又不会糊
705
+ const r = await shrinkImage(file);
706
+ set({
707
+ [frameField(which)]: { src: r.src, name: file.name || "关键帧", bytes: r.bytes },
708
+ framePick: "",
709
+ });
710
+ } catch (e) {
711
+ set({ framePick: "", error: e && e.message ? e.message : String(e) });
712
+ } finally {
713
+ set({ frameBusy: false });
714
+ }
715
+ }
716
+
717
+ function clearFrame(which) {
718
+ if (state.busy) return;
719
+ set({ [frameField(which)]: null, framePickFrom: "" });
720
+ }
721
+
722
+ /* 把「生成结果」里的图直接当关键帧:和本地图完全同一条链路(blob → 下采样 → data URI) */
723
+ function toggleFramePick(which) {
724
+ if (state.busy || state.frameBusy) return;
725
+ set({ framePickFrom: state.framePickFrom === which ? "" : which, error: "" });
726
+ }
727
+
728
+ async function setFrameFromResult(which, entry) {
729
+ if (!entry || entry.kind !== "image" || !entry.filename) return;
730
+ if (state.busy || state.frameBusy) return;
731
+ set({ frameBusy: true, error: "" });
732
+ try {
733
+ const res = await fetch(srcOf(entry.filename));
734
+ if (!res.ok) throw new Error("读取生成结果失败:HTTP " + res.status);
735
+ const blob = await res.blob();
736
+ const r = await shrinkImage(blob);
737
+ set({
738
+ [frameField(which)]: { src: r.src, name: entry.filename, bytes: r.bytes },
739
+ framePickFrom: "",
740
+ });
741
+ } catch (e) {
742
+ set({ framePickFrom: "", error: e && e.message ? e.message : String(e) });
743
+ } finally {
744
+ set({ frameBusy: false });
745
+ }
746
+ }
747
+
748
+ /* 结果区的一键设置:只在「结果本身是图片」时有意义 */
749
+ function useResultAsFrame(which) {
750
+ const cur = state.result;
751
+ if (cur === null || cur.kind !== "image") return;
752
+ return setFrameFromResult(which, cur);
753
+ }
754
+
755
+ /* ---------- 结果收尾动作:复制路径 / 访达显示 / 下载 ---------- */
756
+ async function copyPath() {
757
+ const cur = state.result;
758
+ if (cur === null) return;
759
+ const text = cur.path || "";
760
+ try {
761
+ await navigator.clipboard.writeText(text);
762
+ set({ notice: "路径已复制" });
763
+ } catch {
764
+ set({ notice: "复制失败,可手动复制:" + text });
765
+ }
766
+ }
767
+
768
+ async function reveal() {
769
+ const cur = state.result;
770
+ if (cur === null) return;
771
+ try {
772
+ const res = await fetch("/agnes/reveal", {
773
+ method: "POST",
774
+ headers: { "content-type": "application/json" },
775
+ body: JSON.stringify({ name: cur.filename }),
776
+ });
777
+ const data = await res.json().catch(() => ({}));
778
+ set({
779
+ notice: data && data.ok === true
780
+ ? "已在访达中显示"
781
+ : "打开失败:" + ((data && data.error) || "HTTP " + res.status),
782
+ });
783
+ } catch (e) {
784
+ set({ notice: "打开失败:" + (e && e.message ? e.message : String(e)) });
785
+ }
786
+ }
787
+
788
+ function downloadResult() {
789
+ const cur = state.result;
790
+ if (cur === null) return;
791
+ const a = document.createElement("a");
792
+ a.href = srcOf(cur.filename);
793
+ a.download = cur.filename;
794
+ document.body.appendChild(a);
795
+ a.click();
796
+ a.remove();
797
+ set({ notice: "已开始下载" });
798
+ }
799
+
800
+ /** 停掉等待:让在途的轮询/请求结果失效(服务端任务本身无法撤回) */
801
+ function cancel() {
802
+ if (!state.busy) return;
803
+ runToken += 1;
804
+ stopTimer();
805
+ set({
806
+ busy: false, vStatus: "", vProgress: 0,
807
+ notice: "已停止等待;服务端任务可能仍在跑",
808
+ });
809
+ }
810
+
811
+ /* ---------- 输出目录:把磁盘上已经有的图/视频捞回来 ---------- */
812
+ async function copyPrompt(text) {
813
+ const t = typeof text === "string" ? text.trim() : "";
814
+ if (t === "") return;
815
+ try {
816
+ await navigator.clipboard.writeText(t);
817
+ set({ notice: "提示词已复制", copiedKey: t });
818
+ } catch {
819
+ set({ notice: "复制失败,可手动选中复制" });
820
+ }
821
+ }
822
+
823
+ async function loadLibrary() {
824
+ if (state.libraryBusy) return;
825
+ set({ libraryBusy: true, libraryError: "" });
826
+ try {
827
+ const res = await fetch("/agnes/library");
828
+ const data = await res.json().catch(() => ({}));
829
+ if (!res.ok || !data || data.ok !== true) {
830
+ throw new Error((data && data.error) || "HTTP " + res.status);
831
+ }
832
+ const files = Array.isArray(data.files) ? data.files : [];
833
+ set({
834
+ library: files,
835
+ libraryOpen: true,
836
+ libraryBusy: false,
837
+ libraryError: "",
838
+ });
839
+ const images = files.filter((f) => f.kind === "image").length;
840
+ if (images === 0) {
841
+ const videos = files.filter((f) => f.kind === "video").length;
842
+ if (videos > 0) set({ libraryTab: "video" });
843
+ }
844
+ } catch (e) {
845
+ set({
846
+ libraryBusy: false,
847
+ libraryOpen: true,
848
+ libraryError: e && e.message ? e.message : String(e),
849
+ });
850
+ }
851
+ }
852
+
853
+ function toggleLibrary() {
854
+ if (state.libraryOpen) {
855
+ set({ libraryOpen: false });
856
+ return;
857
+ }
858
+ // 没加载过就先拉一次;已经有清单则直接展开
859
+ if (state.library === null) return loadLibrary();
860
+ set({ libraryOpen: true });
861
+ }
862
+
863
+ function setLibraryTab(tab) {
864
+ set({ libraryTab: tab === "video" ? "video" : "image" });
865
+ }
866
+
867
+ /** 点磁盘上的文件:直接放进结果区看,不经过生成流程 */
868
+ function showLibraryFile(file) {
869
+ if (!file || !file.name) return;
870
+ if (state.busy) return;
871
+ set({
872
+ result: {
873
+ kind: file.kind === "video" ? "video" : "image",
874
+ filename: file.name,
875
+ path: file.path || "",
876
+ bytes: file.bytes || 0,
877
+ prompt: typeof file.prompt === "string" ? file.prompt : "",
878
+ fromDisk: true,
879
+ },
880
+ error: "",
881
+ notice: "",
882
+ });
271
883
  }
272
884
 
273
885
  /* ---------- 视频:提交 → 轮询 → 下载完成 ---------- */
274
886
  async function runVideo(token, prompt) {
275
887
  set({ vStatus: "提交中…", vProgress: 0, videoId: null });
276
888
 
889
+ const seedText = String(state.vSeed).trim();
890
+ const seedValue = seedText === "" ? null : Number(seedText);
891
+ if (seedValue !== null && !(Number.isInteger(seedValue) && seedValue >= 0)) {
892
+ throw new Error("seed 需要非负整数(也可以留空)");
893
+ }
894
+
895
+ // 关键帧直接发 data URI:上游已实测接受 base64,本地图片不用先传图床
277
896
  const subRes = await fetch("/agnes/video", {
278
897
  method: "POST",
279
898
  headers: { "content-type": "application/json" },
280
- body: JSON.stringify({ prompt, seconds: state.vSeconds, ratio: state.vRatio }),
899
+ body: JSON.stringify({
900
+ prompt,
901
+ seconds: state.vSeconds,
902
+ ratio: state.vRatio,
903
+ seed: seedValue,
904
+ firstFrame: state.vFirst !== null ? state.vFirst.src : null,
905
+ lastFrame: state.vLast !== null ? state.vLast.src : null,
906
+ }),
281
907
  });
282
908
  const sub = await subRes.json();
283
909
  if (!sub.ok) throw new Error(sub.error || "提交失败");
284
910
  if (token !== runToken) return;
285
911
 
912
+ const usedMode = sub.mode === "keyframe" ? "keyframe" : "text";
286
913
  set({ videoId: sub.videoId, vStatus: "排队中…" });
287
914
  const deadline = Date.now() + VIDEO_DEADLINE_MS;
288
915
 
@@ -331,6 +958,9 @@ window.__ModuleLoader__.load({
331
958
  ratio: state.vRatio,
332
959
  seconds: st.seconds || state.vSeconds,
333
960
  transport: "异步任务",
961
+ mode: usedMode,
962
+ seed: seedValue === null ? undefined : seedValue,
963
+ prompt,
334
964
  wallSec: ((Date.now() - state.startedAt) / 1000).toFixed(1),
335
965
  });
336
966
  return;
@@ -344,7 +974,7 @@ window.__ModuleLoader__.load({
344
974
  }
345
975
 
346
976
  async function run() {
347
- if (state.busy) return;
977
+ if (state.busy || state.refsBusy) return;
348
978
  const prompt = state.prompt.trim();
349
979
  if (prompt === "") return;
350
980
 
@@ -379,21 +1009,49 @@ window.__ModuleLoader__.load({
379
1009
  set,
380
1010
  loadHealth,
381
1011
  loadConfig,
1012
+ loadHistory,
382
1013
  saveConfig,
383
1014
  setCfgDraft(field, value) {
384
1015
  set({ cfgDraft: { ...state.cfgDraft, [field]: value }, cfgNotice: "" });
385
1016
  },
386
1017
  toggleCfg() { set({ cfgOpen: !state.cfgOpen }); },
387
1018
  run,
1019
+ /* ---- 参考图动作 ---- */
1020
+ addRefsFromFiles,
1021
+ addRefFromResult,
1022
+ removeRef,
1023
+ clearRefs,
1024
+ toggleRefPick,
1025
+ /* ---- 视频关键帧 ---- */
1026
+ pickFrame,
1027
+ setFrameFromFile,
1028
+ clearFrame,
1029
+ toggleFramePick,
1030
+ setFrameFromResult,
1031
+ useResultAsFrame,
1032
+ /* ---- 结果收尾 ---- */
1033
+ copyPath,
1034
+ reveal,
1035
+ downloadResult,
1036
+ cancel,
1037
+ copyPrompt,
1038
+ /* ---- 输出目录清单 ---- */
1039
+ loadLibrary,
1040
+ toggleLibrary,
1041
+ setLibraryTab,
1042
+ showLibraryFile,
1043
+ maxRefs: MAX_REFS,
388
1044
  /** 切换主预览到历史里的某一张 */
389
1045
  show(entry) { set({ result: entry }); },
390
1046
  /**
391
- * 清空界面上的生成结果。只清显示状态:不删磁盘文件、不动提示词和参数。
392
- * 进行中的请求不清(否则结果回来又会写一遍,反而更乱)。
1047
+ * 清空界面上的生成结果。删的是界面状态和宿主持久化的历史记录,
1048
+ * 不删磁盘文件、不动提示词和参数。进行中的请求不清。
393
1049
  */
394
1050
  clear() {
395
1051
  if (state.busy) return;
396
- set({ result: null, history: [], error: "" });
1052
+ set({ result: null, history: [], error: "", notice: "" });
1053
+ // 宿主是历史的真源;这里的 DELETE 失败也不该打断界面
1054
+ void fetch("/agnes/history", { method: "DELETE" }).catch(() => {});
397
1055
  },
398
1056
  dispose() {
399
1057
  stopTimer();
@@ -410,7 +1068,7 @@ window.__ModuleLoader__.load({
410
1068
  }
411
1069
 
412
1070
  /* ---------- 媒体显示:视频直接用 <video>,图片带加载兜底 ---------- */
413
- function MediaBox({ entry, big }) {
1071
+ function MediaBox({ entry, big, onCopyPrompt }) {
414
1072
  const isVideo = entry !== null && entry.kind === "video";
415
1073
  const name = entry === null ? null : entry.filename;
416
1074
 
@@ -459,6 +1117,8 @@ window.__ModuleLoader__.load({
459
1117
  React.createElement("span", { className: "agn-code" }, entry.path || name)));
460
1118
  }
461
1119
 
1120
+ const promptText = typeof entry.prompt === "string" ? entry.prompt.trim() : "";
1121
+
462
1122
  return React.createElement("div", { className: "agn-shot" + (big ? " agn-shot-big" : "") },
463
1123
  isVideo
464
1124
  ? React.createElement("video", {
@@ -468,7 +1128,20 @@ window.__ModuleLoader__.load({
468
1128
  preload: "metadata",
469
1129
  onError: () => setFailed(true),
470
1130
  })
471
- : React.createElement("img", { src, alt: "生成结果", onError })
1131
+ : React.createElement("img", { src, alt: "生成结果", onError }),
1132
+ /* 鼠标移上去才浮出来的一层:显示当初的提示词,带一键复制 */
1133
+ promptText === ""
1134
+ ? null
1135
+ : React.createElement("div", { className: "agn-prompt" },
1136
+ React.createElement("div", { className: "agn-prompt-text", title: promptText }, promptText),
1137
+ React.createElement("button", {
1138
+ type: "button", className: "agn-prompt-copy",
1139
+ title: "复制这段提示词",
1140
+ onClick: (e) => {
1141
+ e.stopPropagation();
1142
+ if (typeof onCopyPrompt === "function") onCopyPrompt(promptText);
1143
+ },
1144
+ }, "复制"))
472
1145
  );
473
1146
  }
474
1147
 
@@ -499,21 +1172,30 @@ window.__ModuleLoader__.load({
499
1172
  return () => { off(); };
500
1173
  }, []);
501
1174
 
502
- React.useEffect(() => { store.loadHealth(); store.loadConfig(); }, []);
1175
+ React.useEffect(() => { store.loadHealth(); store.loadConfig(); store.loadHistory(); }, []);
503
1176
 
504
1177
  const busy = s.busy;
505
1178
  const isVideo = s.mode === "video";
506
1179
  const key = s.health && s.health.key ? s.health.key : null;
507
1180
  const keyBad = key !== null && key.present !== true;
508
1181
  const shown = s.result;
1182
+ const hasRefs = s.refs.length > 0;
1183
+ const refsFull = s.refs.length >= MAX_REFS;
1184
+ const imageHistory = s.history.filter((h) => h.kind === "image");
1185
+ const libraryAll = s.library || [];
1186
+ const libraryImages = libraryAll.filter((f) => f.kind !== "video");
1187
+ const libraryVideos = libraryAll.filter((f) => f.kind === "video");
1188
+ const libraryShown = s.libraryTab === "video" ? libraryVideos : libraryImages;
1189
+ const fileInput = React.useRef(null);
1190
+ const frameInput = React.useRef(null);
509
1191
 
510
1192
  const onKeyDown = (e) => {
511
1193
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); store.run(); }
512
1194
  };
513
1195
 
514
1196
  const buttonLabel = busy
515
- ? (isVideo ? "生成视频… " : "生成中… ") + (s.elapsed > 0 ? s.elapsed.toFixed(0) + "s" : "")
516
- : (isVideo ? "生成视频" : "生成");
1197
+ ? (isVideo ? "生成视频… " : (hasRefs ? "图生图中… " : "生成中… ")) + (s.elapsed > 0 ? s.elapsed.toFixed(0) + "s" : "")
1198
+ : (isVideo ? "生成视频" : (hasRefs ? "图生图" : "生成"));
517
1199
 
518
1200
  const modeBtn = (mode, text) => React.createElement("button", {
519
1201
  key: mode,
@@ -537,6 +1219,13 @@ window.__ModuleLoader__.load({
537
1219
  onChange: (e) => store.set({ vRatio: e.target.value }),
538
1220
  }, VIDEO_RATIOS.map((x) => React.createElement("option", { key: x, value: x }, x))),
539
1221
  React.createElement("span", { key: "m", className: "agn-meta" }, "720P"),
1222
+ React.createElement("span", { key: "l3", className: "agn-label" }, "种子"),
1223
+ React.createElement("input", {
1224
+ key: "s3", type: "text", inputMode: "numeric", className: "agn-seed",
1225
+ value: s.vSeed, disabled: busy, placeholder: "随机",
1226
+ title: "固定 seed 可让同一提示词出片更稳定;留空由服务端随机",
1227
+ onChange: (e) => store.set({ vSeed: e.target.value }),
1228
+ }),
540
1229
  ]
541
1230
  : [
542
1231
  React.createElement("span", { key: "l1", className: "agn-label" }, "尺寸"),
@@ -555,7 +1244,93 @@ window.__ModuleLoader__.load({
555
1244
  ? "生成结果(还没有)"
556
1245
  : (shown.kind === "video" ? "生成结果 · 视频" : "生成结果 · 图片");
557
1246
 
1247
+ /* 视频缩略图:放一个真的 <video>,浏览器会解码出首帧当封面。
1248
+ #t=0.1 是媒体片段定位,避免第一帧偶尔是黑场;muted + playsInline
1249
+ 保证不会被自动播放策略拦掉,也不会突然出声。 */
1250
+ const videoThumb = (name) => React.createElement("video", {
1251
+ src: srcOf(name) + "#t=0.1",
1252
+ preload: "metadata",
1253
+ muted: true,
1254
+ playsInline: true,
1255
+ tabIndex: -1,
1256
+ });
1257
+
1258
+ /* 关键帧小格:有图显示缩略图 + ×,无图显示「+ 本地」;
1259
+ 下方固定一行「+ 结果」,从已经生成过的图片里挑一张 */
1260
+ const frameSlot = (which, label, cur) => React.createElement("div", { className: "agn-frame" },
1261
+ React.createElement("div", { className: "agn-frame-cap" }, label),
1262
+ cur !== null
1263
+ ? React.createElement("div", { className: "agn-frame-box" },
1264
+ React.createElement("img", { src: cur.src, alt: label }),
1265
+ React.createElement("button", {
1266
+ type: "button", className: "agn-ref-del", title: "移除" + label,
1267
+ disabled: busy,
1268
+ onClick: () => store.clearFrame(which),
1269
+ }, "×"))
1270
+ : React.createElement("button", {
1271
+ type: "button", className: "agn-frame-add",
1272
+ disabled: busy || s.frameBusy,
1273
+ title: "从本地磁盘选择" + label + "图片(PNG / JPEG / WebP / GIF)",
1274
+ onClick: () => {
1275
+ store.pickFrame(which);
1276
+ const el = frameInput.current;
1277
+ if (el !== null) el.click();
1278
+ },
1279
+ }, "+ 本地"),
1280
+ React.createElement("button", {
1281
+ type: "button", className: "agn-frame-from",
1282
+ disabled: busy || s.frameBusy || imageHistory.length === 0,
1283
+ title: imageHistory.length === 0
1284
+ ? "还没有生成过图片,先出一张图再来选"
1285
+ : "从生成结果里挑一张当" + label,
1286
+ onClick: () => store.toggleFramePick(which),
1287
+ }, s.framePickFrom === which ? "收起" : "+ 结果"),
1288
+ s.framePickFrom === which
1289
+ ? React.createElement("div", { className: "agn-ref-pick agn-frame-pick" },
1290
+ imageHistory.map((h) => React.createElement("button", {
1291
+ key: h.filename, type: "button", title: h.filename,
1292
+ disabled: busy || s.frameBusy,
1293
+ onClick: () => store.setFrameFromResult(which, h),
1294
+ }, React.createElement("img", { src: srcOf(h.filename), alt: h.filename }))))
1295
+ : null
1296
+ );
1297
+
558
1298
  return React.createElement("div", { className: "agn-root" },
1299
+ /* 隐藏的文件选择器:由「+ 本地图片」按钮触发,支持多选 */
1300
+ React.createElement("input", {
1301
+ ref: fileInput,
1302
+ type: "file",
1303
+ accept: "image/png,image/jpeg,image/webp,image/gif",
1304
+ multiple: true,
1305
+ style: { display: "none" },
1306
+ onChange: (e) => {
1307
+ const el = e.target;
1308
+ const files = el.files;
1309
+ // 关键:等文件真正读完再清空 input。
1310
+ // 之前是「发起读取后立刻 el.value = ''」,Chromium 下会把刚选的文件
1311
+ // 的底层数据一起释放,读取中途失败 → 这一次添加被整单丢掉。
1312
+ Promise.resolve(store.addRefsFromFiles(files))
1313
+ .catch(() => {})
1314
+ .finally(() => { try { el.value = ""; } catch { /* input 可能已卸载 */ } });
1315
+ },
1316
+ }),
1317
+
1318
+ /* 隐藏的关键帧选择器:首帧 / 尾帧共用,靠 store.framePick 区分目标 */
1319
+ React.createElement("input", {
1320
+ ref: frameInput,
1321
+ type: "file",
1322
+ accept: "image/png,image/jpeg,image/webp,image/gif",
1323
+ style: { display: "none" },
1324
+ onChange: (e) => {
1325
+ const el = e.target;
1326
+ const file = el.files && el.files[0];
1327
+ const which = s.framePick === "last" ? "last" : "first";
1328
+ Promise.resolve(store.setFrameFromFile(which, file))
1329
+ .catch(() => {})
1330
+ .finally(() => { try { el.value = ""; } catch { /* input 可能已卸载 */ } });
1331
+ },
1332
+ }),
1333
+
559
1334
  React.createElement("div", { className: "agn-head" },
560
1335
  React.createElement("span", { className: "agn-title" }, "Agnes 创作"),
561
1336
  React.createElement("span", { className: "agn-meta" },
@@ -569,7 +1344,110 @@ window.__ModuleLoader__.load({
569
1344
  modeBtn("image", "图片"),
570
1345
  modeBtn("video", "视频")
571
1346
  ),
1347
+
1348
+ /* ---- 视频关键帧(图生视频):本地图片直接当首帧/尾帧,留空即纯文生视频 ---- */
1349
+ isVideo
1350
+ ? React.createElement("div", { className: "agn-frames" },
1351
+ React.createElement("div", { className: "agn-ref-head" },
1352
+ React.createElement("span", { className: "agn-label" }, "关键帧"),
1353
+ React.createElement("span", { className: "agn-hint" },
1354
+ s.vFirst !== null || s.vLast !== null ? "图生视频" : "留空 = 纯文生视频"),
1355
+ s.frameBusy ? React.createElement("span", { className: "agn-hint" }, "处理中…") : null
1356
+ ),
1357
+ React.createElement("div", { className: "agn-frames-row" },
1358
+ frameSlot("first", "首帧", s.vFirst),
1359
+ frameSlot("last", "尾帧", s.vLast)
1360
+ )
1361
+ )
1362
+ : null,
1363
+
1364
+ /* ---- 参考图(图生图 / 多图合成):本地文件 + 生成结果两种来源 ---- */
1365
+ !isVideo
1366
+ ? React.createElement("div", { className: "agn-refs" },
1367
+ React.createElement("div", { className: "agn-ref-head" },
1368
+ React.createElement("span", { className: "agn-label" }, "参考图"),
1369
+ React.createElement("span", { className: "agn-tag" },
1370
+ s.refs.length + " / " + MAX_REFS),
1371
+ s.refsBusy ? React.createElement("span", { className: "agn-hint" }, "处理中…") : null,
1372
+ s.refs.length > 0
1373
+ ? React.createElement("button", {
1374
+ type: "button", className: "agn-btn-ghost", disabled: busy,
1375
+ onClick: () => store.clearRefs(),
1376
+ }, "清空参考图")
1377
+ : null
1378
+ ),
1379
+ React.createElement("div", { className: "agn-ref-strip" },
1380
+ s.refs.map((r) => React.createElement("div", {
1381
+ key: r.id,
1382
+ className: "agn-ref",
1383
+ title: r.name + " · " + (r.bytes / 1024).toFixed(0) + " KB · " +
1384
+ (r.from === "result" ? "来自生成结果" : "来自本地"),
1385
+ },
1386
+ React.createElement("img", { src: r.src, alt: r.name }),
1387
+ React.createElement("button", {
1388
+ type: "button", className: "agn-ref-del",
1389
+ title: "移除这张参考图",
1390
+ disabled: busy,
1391
+ onClick: () => store.removeRef(r.id),
1392
+ }, "×")
1393
+ )),
1394
+ React.createElement("button", {
1395
+ type: "button", className: "agn-ref-add",
1396
+ disabled: busy || refsFull,
1397
+ title: "从本地磁盘选择一个或多个图片(PNG / JPEG / WebP / GIF)",
1398
+ onClick: () => {
1399
+ const el = fileInput.current;
1400
+ if (el !== null) el.click();
1401
+ },
1402
+ }, "+ 本地图片"),
1403
+ React.createElement("button", {
1404
+ type: "button", className: "agn-ref-add",
1405
+ disabled: busy || refsFull || imageHistory.length === 0,
1406
+ title: imageHistory.length === 0 ? "还没有生成过图片" : "从「生成结果」里挑一张",
1407
+ onClick: () => store.toggleRefPick(),
1408
+ }, "+ 生成结果")
1409
+ ),
1410
+
1411
+ /* 从生成结果挑图:只列图片(视频不能当参考图) */
1412
+ s.refPickOpen
1413
+ ? React.createElement("div", { className: "agn-ref-pick" },
1414
+ imageHistory.length === 0
1415
+ ? React.createElement("span", { className: "agn-hint" }, "还没有生成过图片")
1416
+ : imageHistory.map((h) => React.createElement("button", {
1417
+ key: "pick-" + h.filename,
1418
+ type: "button",
1419
+ title: h.filename,
1420
+ onClick: () => store.addRefFromResult(h),
1421
+ }, React.createElement("img", {
1422
+ src: srcOf(h.filename), alt: h.filename, loading: "lazy",
1423
+ })))
1424
+ )
1425
+ : null,
1426
+
1427
+ React.createElement("div", { className: "agn-note" },
1428
+ hasRefs
1429
+ ? "将按参考图重绘(" + s.refs.length + " 张" +
1430
+ (s.refs.length > 1 ? ",多图合成" : "") + ")——提示词写「想怎么改」"
1431
+ : "不加参考图 = 纯文生图;加 1 张 = 图生图;加多张 = 多图合成"),
1432
+ s.refsError !== "" ? React.createElement("div", { className: "agn-err" }, s.refsError) : null
1433
+ )
1434
+ : null,
1435
+
572
1436
  React.createElement("div", { className: "agn-label" }, "提示词"),
1437
+ React.createElement("div", { className: "agn-presets" },
1438
+ PROMPT_PRESETS.map((preset) => React.createElement("button", {
1439
+ key: preset.label,
1440
+ type: "button",
1441
+ className: "agn-preset",
1442
+ disabled: busy,
1443
+ title: "追加到提示词末尾:" + preset.text,
1444
+ onClick: () => store.set({
1445
+ prompt: s.prompt.trim() === ""
1446
+ ? preset.text
1447
+ : s.prompt.replace(/[\s,,;;。.]+$/, "") + "," + preset.text,
1448
+ }),
1449
+ }, preset.label))
1450
+ ),
573
1451
  React.createElement("textarea", {
574
1452
  className: "agn-textarea",
575
1453
  value: s.prompt,
@@ -584,9 +1462,19 @@ window.__ModuleLoader__.load({
584
1462
  params,
585
1463
  React.createElement("button", {
586
1464
  className: "agn-btn", type: "button",
587
- disabled: busy || s.prompt.trim() === "" || keyBad,
1465
+ disabled: busy || s.refsBusy || s.prompt.trim() === "" || keyBad,
588
1466
  onClick: () => store.run(),
589
- }, buttonLabel)
1467
+ }, buttonLabel),
1468
+ /* 视频是异步任务,等待可能很长:给出真正的「停止等待」而不是让用户干等 */
1469
+ busy
1470
+ ? React.createElement("button", {
1471
+ className: "agn-btn-ghost", type: "button",
1472
+ title: isVideo
1473
+ ? "停止等待(服务端任务可能仍在跑,已生成的文件不会丢)"
1474
+ : "放弃这次等待(请求已发出,结果可能仍会落盘)",
1475
+ onClick: () => store.cancel(),
1476
+ }, "取消")
1477
+ : null
590
1478
  ),
591
1479
 
592
1480
  /* 视频是异步任务:给出阶段与百分比,避免只看到一个跳动的秒数 */
@@ -608,6 +1496,7 @@ window.__ModuleLoader__.load({
608
1496
  "未找到 Key:" + (key.file || "") + " —— 把 Key 写进该文件后可立即使用")
609
1497
  : null,
610
1498
  s.error !== "" ? React.createElement("div", { className: "agn-err" }, s.error) : null,
1499
+ s.notice !== "" ? React.createElement("div", { className: "agn-notice" }, s.notice) : null,
611
1500
  busy && !isVideo
612
1501
  ? React.createElement("div", { className: "agn-hint" },
613
1502
  "正在生成:可切到别的面板,回来仍会接着显示结果(请求不会中断)")
@@ -618,6 +1507,12 @@ window.__ModuleLoader__.load({
618
1507
  React.createElement("div", { className: "agn-card" },
619
1508
  React.createElement("div", { className: "agn-card-head" },
620
1509
  React.createElement("div", { className: "agn-label" }, resultLabel),
1510
+ React.createElement("button", {
1511
+ type: "button",
1512
+ className: "agn-btn-ghost",
1513
+ title: "把输出目录里已有的图片 / 视频列出来,点一下就能显示在上方",
1514
+ onClick: () => store.toggleLibrary(),
1515
+ }, s.libraryOpen ? "收起目录" : "输出目录"),
621
1516
  React.createElement("button", {
622
1517
  type: "button",
623
1518
  className: "agn-btn-ghost",
@@ -628,7 +1523,7 @@ window.__ModuleLoader__.load({
628
1523
  ),
629
1524
 
630
1525
  shown !== null
631
- ? React.createElement(MediaBox, { entry: shown, big: true })
1526
+ ? React.createElement(MediaBox, { entry: shown, big: true, onCopyPrompt: (t) => store.copyPrompt(t) })
632
1527
  : React.createElement("div", { className: "agn-shot" },
633
1528
  React.createElement("div", { className: "agn-shot-msg" },
634
1529
  busy ? (isVideo ? "正在生成视频…" : "正在生成图片…")
@@ -637,18 +1532,116 @@ window.__ModuleLoader__.load({
637
1532
  shown !== null
638
1533
  ? React.createElement("div", { className: "agn-hint" },
639
1534
  React.createElement("span", null,
640
- shown.filename + " · " + (shown.bytes / 1024 / 1024 >= 1
1535
+ (shown.fromDisk ? "来自输出目录 · " : "")
1536
+ + shown.filename + " · " + (shown.bytes / 1024 / 1024 >= 1
641
1537
  ? (shown.bytes / 1024 / 1024).toFixed(2) + " MB"
642
1538
  : (shown.bytes / 1024).toFixed(0) + " KB")
643
1539
  + " · " + shown.size + "/" + shown.ratio
644
1540
  + (shown.seconds ? " · " + shown.seconds + " 秒" : "")
645
- + " · 用时 " + shown.elapsedSec + "s"),
1541
+ + (shown.mode === "keyframe" ? " · 图生视频" : "")
1542
+ + (shown.mode === "img2img" ? " · 图生图 " + (shown.refCount || 0) + " 张参考" : "")
1543
+ + (shown.seed !== undefined && shown.seed !== null ? " · seed " + shown.seed : "")
1544
+ + " · 用时 " + (shown.elapsedSec || shown.wallSec) + "s"),
646
1545
  React.createElement("br"),
647
1546
  React.createElement("span", null, "已保存到 "),
648
1547
  React.createElement("span", { className: "agn-code" }, shown.path || ""))
1548
+ : null,
1549
+
1550
+ shown !== null
1551
+ ? React.createElement("div", { className: "agn-acts" },
1552
+ React.createElement("button", {
1553
+ type: "button", className: "agn-act",
1554
+ title: "把文件路径复制到剪贴板",
1555
+ onClick: () => store.copyPath(),
1556
+ }, "复制路径"),
1557
+ React.createElement("button", {
1558
+ type: "button", className: "agn-act",
1559
+ title: "在访达中显示这个文件",
1560
+ onClick: () => store.reveal(),
1561
+ }, "在访达中显示"),
1562
+ React.createElement("button", {
1563
+ type: "button", className: "agn-act",
1564
+ title: "另存一份到「下载」目录",
1565
+ onClick: () => store.downloadResult(),
1566
+ }, "下载"),
1567
+ shown.kind === "image"
1568
+ ? React.createElement("button", {
1569
+ type: "button", className: "agn-act",
1570
+ disabled: busy || s.frameBusy,
1571
+ title: "把这张图设为视频首帧(切到视频模式即可看到)",
1572
+ onClick: () => store.useResultAsFrame("first"),
1573
+ }, "设为首帧")
1574
+ : null,
1575
+ shown.kind === "image"
1576
+ ? React.createElement("button", {
1577
+ type: "button", className: "agn-act",
1578
+ disabled: busy || s.frameBusy,
1579
+ title: "把这张图设为视频尾帧(切到视频模式即可看到)",
1580
+ onClick: () => store.useResultAsFrame("last"),
1581
+ }, "设为尾帧")
1582
+ : null)
649
1583
  : null
650
1584
  ),
651
1585
 
1586
+ /* ---- 输出目录:把磁盘上已有的图 / 视频捞回来看 ---- */
1587
+ s.libraryOpen
1588
+ ? React.createElement("div", { className: "agn-card" },
1589
+ React.createElement("div", { className: "agn-card-head" },
1590
+ React.createElement("div", { className: "agn-label" }, "输出目录"),
1591
+ React.createElement("button", {
1592
+ type: "button", className: "agn-btn-ghost",
1593
+ disabled: s.libraryBusy,
1594
+ title: "重新读一遍输出目录(新生成的文件会自动出现在这里)",
1595
+ onClick: () => store.loadLibrary(),
1596
+ }, s.libraryBusy ? "读取中…" : "刷新")
1597
+ ),
1598
+ React.createElement("div", { className: "agn-hint" },
1599
+ "点缩略图会把它显示到上方结果区,并带上当初写进图片里的提示词"),
1600
+ React.createElement("div", { className: "agn-lib" },
1601
+ React.createElement("div", { className: "agn-lib-tabs" },
1602
+ React.createElement("button", {
1603
+ type: "button",
1604
+ className: "agn-act" + (s.libraryTab === "image" ? " agn-act-on" : ""),
1605
+ onClick: () => store.setLibraryTab("image"),
1606
+ }, "图片 " + libraryImages.length),
1607
+ React.createElement("button", {
1608
+ type: "button",
1609
+ className: "agn-act" + (s.libraryTab === "video" ? " agn-act-on" : ""),
1610
+ onClick: () => store.setLibraryTab("video"),
1611
+ }, "视频 " + libraryVideos.length)
1612
+ ),
1613
+ s.libraryError !== ""
1614
+ ? React.createElement("div", { className: "agn-err" }, s.libraryError)
1615
+ : null,
1616
+ libraryShown.length === 0
1617
+ ? React.createElement("div", { className: "agn-lib-empty" },
1618
+ s.libraryBusy
1619
+ ? "正在读取输出目录…"
1620
+ : "这个目录里还没有" + (s.libraryTab === "video" ? "视频" : "图片"))
1621
+ : React.createElement("div", { className: "agn-lib-grid" },
1622
+ libraryShown.map((f) => React.createElement("button", {
1623
+ key: f.name,
1624
+ type: "button",
1625
+ className: "agn-lib-item" + (shown && shown.filename === f.name ? " agn-thumb-on" : ""),
1626
+ title: (f.prompt ? f.prompt + "\n" : "") + f.name,
1627
+ disabled: busy,
1628
+ onClick: () => store.showLibraryFile(f),
1629
+ },
1630
+ f.kind === "video"
1631
+ ? videoThumb(f.name)
1632
+ : React.createElement("img", { src: srcOf(f.name), alt: f.name, loading: "lazy" }),
1633
+ f.kind === "video"
1634
+ ? React.createElement("div", { className: "agn-thumb-play" }, "▶")
1635
+ : null,
1636
+ f.kind === "video"
1637
+ ? React.createElement("span", { className: "agn-lib-badge" }, "视频")
1638
+ : null
1639
+ ))
1640
+ )
1641
+ )
1642
+ )
1643
+ : null,
1644
+
652
1645
  /* ---- 历史 ---- */
653
1646
  s.history.length > 1
654
1647
  ? React.createElement("div", { className: "agn-card agn-hist" },
@@ -662,8 +1655,11 @@ window.__ModuleLoader__.load({
662
1655
  onClick: () => store.show(h),
663
1656
  },
664
1657
  h.kind === "video"
665
- ? React.createElement("div", { className: "agn-thumb-play" }, "▶")
1658
+ ? videoThumb(h.filename)
666
1659
  : React.createElement("img", { src: srcOf(h.filename), alt: h.filename, loading: "lazy" }),
1660
+ h.kind === "video"
1661
+ ? React.createElement("div", { className: "agn-thumb-play" }, "▶")
1662
+ : null,
667
1663
  React.createElement("span", { className: "agn-thumb-cap" },
668
1664
  h.kind === "video"
669
1665
  ? (h.seconds ? h.seconds + "s" : "视频")
@@ -738,7 +1734,7 @@ window.__ModuleLoader__.load({
738
1734
  s.cfgOpen
739
1735
  ? React.createElement("div", { className: "agn-note" },
740
1736
  "保存到 ",
741
- (s.health && s.health.configFile) || "~/.dsh/agnes-image/config.json")
1737
+ (s.health && s.health.configFile) || "~/.dsh/agnes-create/config.json")
742
1738
  : null
743
1739
  ),
744
1740
 
@@ -777,7 +1773,7 @@ window.__ModuleLoader__.load({
777
1773
 
778
1774
  exports.apply = apply;
779
1775
  exports.inject = ["slots"];
780
- exports.__internals = { getStore, createStore, srcOf };
1776
+ exports.__internals = { getStore, createStore, srcOf, shrinkImage, MAX_REFS };
781
1777
  return module.exports;
782
1778
  },
783
1779
  });