dsh-comfyui-rp 0.2.2 → 0.2.4

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
@@ -40,7 +40,7 @@ dsh plugin --profile web add git+https://github.com/bmhty123/dsh-comfyui-rp.git
40
40
 
41
41
  ## 能做什么
42
42
 
43
- - **自动出图**:捕获正文 → 解析提示词 → 出图 → 归档,全自动;投递和出图可分开开关。
43
+ - **自动出图**:捕获正文 → 解析提示词 → 出图 → 归档,全自动;投递和出图可分开开关。纯黑图会自动换种子重生成,进行中可以随时点「停止自动补图」。
44
44
  - **投递**:正文来源和提示词来源分开,可指到另一个会话甚至另一台 DSH;附加消息放正文最上面或最下面,投递成功后自动留空。
45
45
  - **看图**:右侧栏面板、全屏、悬浮球、悬浮图片框。翻页、下载、收藏、刷新、删除、圆角;自己拖过 / 缩过之后,切图不再改回初始大小。
46
46
  - **跟图**:滚动正文自动切图,连续图按段落比例拆位置,可切光标跟图、手动偏移、清空定位。
package/docs/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.2.4
4
+
5
+ - **纯黑图误判修复**:判定前先看体积——真·纯黑 PNG 只有 20 多 KB,超过 **120 KB** 的图直接不参与判定。之前有正常图(约 1 MB)在「原地替换后立刻复查」时被判成黑图,导致同一张图被连续替换 3 次。
6
+ - **判黑要两次都黑**:第一次判黑后会隔 0.9 秒、绕过浏览器缓存再验一次,两次都黑才自动替换,避免一次绘制的偶发全黑。
7
+ - **判定可追溯**:每次判定都会把统计(体积、暗像素比例、平均亮度、亮点比例、不透明覆盖)写进 Host 日志,「日志」页能直接看到;出现误判时能一眼定位是哪条规则触发。
8
+
9
+ ## 0.2.3
10
+
11
+ - **自动补图可以随时停下**:补图进行中,图片框左下菜单的按钮会变成「终止」(之前因为补图是内部任务,菜单里根本看不到它),点一下清空队列并中止正在跑的那一单;全屏和工作台的「终止」也一样。右侧栏顶部还有一条「停止自动补图」。下一次新任务开始时自动复位。
12
+ - **黑图自动替换开关**:工作流页 →「图片输出相关」→「额外设置」里新增开关,关掉后不再检测纯黑图,待补队列也会清空。
13
+ - **纯黑判定更严,修掉误判**:以前只要求「暗像素 ≥ 99%」就会判黑,夜景、黑底小主体这类图会被误伤。现在必须同时满足:暗像素 ≥ 99.5%、平均亮度 ≤ 8、亮点(亮度 > 110)占比 < 0.1%,并且不透明像素要占到一半以上(透明底的小图不算黑)。
14
+ - **固定种子可手填**:工作流页「每次换随机种子」下面多了一个「固定种子」输入框。
15
+ - 勾着自动时它是灰的,显示**上一轮实际用的种子**(方便看到满意的一张后直接固定下来);
16
+ - 取消勾选后解锁,填的整数会立刻存进工作流,出图时覆盖每一处字面量 seed;取消的那一刻会把上一轮的种子自动带进来。
17
+ - 这些开关改完直接生效,不用再点「保存」。
18
+
19
+
3
20
  ## 0.2.2
4
21
 
5
22
  - **LoRA 强度可以填负数**:工作台「连续图」的档位、LoRA 页每条的强度都允许 `-5 ~ 5`(反相 LoRA 用)。
package/lib/api.js CHANGED
@@ -358,7 +358,7 @@ export function createApi({ store, jobs, hub, getConfig, ctx, sc, routePath, ver
358
358
  workflows: compact ? [] : store.listWorkflows(),
359
359
  activeWorkflowId: cfg.workflowId,
360
360
  activeWorkflow: activeWf
361
- ? { id: activeWf.id, name: activeWf.name, size: activeWf.size || null, sizeMode: activeWf.sizeMode || 'manual', randomSeed: activeWf.randomSeed !== false, nodeCount: Object.keys(activeWf.json || {}).length }
361
+ ? { id: activeWf.id, name: activeWf.name, size: activeWf.size || null, sizeMode: activeWf.sizeMode || 'manual', randomSeed: activeWf.randomSeed !== false, blackRetry: activeWf.blackRetry !== false, nodeCount: Object.keys(activeWf.json || {}).length }
362
362
  : null,
363
363
  jobs: jobsList,
364
364
  images: imageItems,
@@ -604,6 +604,9 @@ export function createApi({ store, jobs, hub, getConfig, ctx, sc, routePath, ver
604
604
  size: wf.size || null,
605
605
  sizeMode: wf.sizeMode || 'manual',
606
606
  randomSeed: wf.randomSeed !== false,
607
+ seed: wf.seed === null || wf.seed === undefined ? null : Number(wf.seed),
608
+ lastSeed: wf.lastSeed === null || wf.lastSeed === undefined ? null : Number(wf.lastSeed),
609
+ blackRetry: wf.blackRetry !== false,
607
610
  pruneOutputs: wf.pruneOutputs !== false,
608
611
  onlyFirst: wf.onlyFirst === true,
609
612
  loraState: wf.loraState || null,
@@ -662,12 +665,15 @@ export function createApi({ store, jobs, hub, getConfig, ctx, sc, routePath, ver
662
665
  const sizeMode = body.sizeMode || wf?.sizeMode || 'manual';
663
666
  const loraState = body.loraState !== undefined ? body.loraState : resolveLoraState(wf || {});
664
667
  const loraScan = scanLoraStacks(json);
668
+ // 固定种子:干跑也要按工作流页填的那个值算,否则「干跑」和真出一张的结果对不上。
669
+ const seed = body.seed !== undefined ? body.seed : (wf && wf.seed !== undefined ? wf.seed : null);
665
670
  const { workflow, report } = applyOverrides(json, {
666
671
  positive: String(body.positive ?? ''),
667
672
  negative: String(body.negative ?? ''),
668
673
  mapping,
669
674
  tokenEmpty: 'blank',
670
675
  randomSeed: body.randomSeed === true,
676
+ seed,
671
677
  loraState,
672
678
  size,
673
679
  sizeSource: sizeMode,
@@ -1287,6 +1293,21 @@ export function createApi({ store, jobs, hub, getConfig, ctx, sc, routePath, ver
1287
1293
  }));
1288
1294
  }
1289
1295
 
1296
+ if (action === 'black-check' && method === 'POST') {
1297
+ const b = body || {};
1298
+ const stats = [
1299
+ b.darkRatio !== undefined ? `暗 ${(Number(b.darkRatio) * 100).toFixed(2)}%` : '',
1300
+ b.meanLum !== undefined ? `平均亮度 ${Number(b.meanLum).toFixed(1)}` : '',
1301
+ b.brightRatio !== undefined ? `亮点 ${(Number(b.brightRatio) * 100).toFixed(2)}%` : '',
1302
+ b.coverage !== undefined ? `不透明 ${Math.round(Number(b.coverage) * 100)}%` : '',
1303
+ ].filter(Boolean).join(' · ');
1304
+ const skippedText = b.skipped === 'bytes' ? '跳过(体积太大,不可能是纯黑)' : (b.skipped ? `跳过(${b.skipped})` : '');
1305
+ const head = b.black === true ? '判为纯黑图' : (skippedText || '不是黑图');
1306
+ hub.pushLog(b.black === true ? 'warn' : 'info',
1307
+ `黑图检测:${head} · ${b.fileName || b.imageId || '?'}${b.bytes ? ` · ${b.bytes} B` : ''}${stats ? ` · ${stats}` : ''}`);
1308
+ return sendJson(res, 200, ok({ logged: true }));
1309
+ }
1310
+
1290
1311
  if (action === 'logs' && method === 'GET') {
1291
1312
  return sendJson(res, 200, ok({ logs: hub.logs.slice(-300) }));
1292
1313
  }
package/lib/client.js CHANGED
@@ -221,6 +221,30 @@ window.__ModuleLoader__.load({
221
221
 
222
222
  function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, Number(v) || 0)); }
223
223
 
224
+ /**
225
+ * 自动补图的全局状态:逻辑在 FloatBall 里跑(它一直在,负责检测纯黑图),
226
+ * 但「停止」按钮要能出现在右侧栏面板里,所以两边共享这一份。
227
+ * pending = 队列里的 + 正在跑的;stop = FloatBall 注册过来的停止函数。
228
+ */
229
+ var BLACK_UI = { pending: 0, stop: null, subs: new Set() };
230
+ function setBlackPendingGlobal(n) {
231
+ var value = Math.max(0, Number(n) || 0);
232
+ if (BLACK_UI.pending === value) return;
233
+ BLACK_UI.pending = value;
234
+ BLACK_UI.subs.forEach(function (fn) { try { fn(value); } catch (e) { /* ignore */ } });
235
+ }
236
+ function useBlackPending() {
237
+ var [pending, setPending] = useState(BLACK_UI.pending);
238
+ useEffect(function () {
239
+ var fn = function (n) { setPending(n); };
240
+ BLACK_UI.subs.add(fn);
241
+ setPending(BLACK_UI.pending);
242
+ return function () { BLACK_UI.subs.delete(fn); };
243
+ }, []);
244
+ return pending;
245
+ }
246
+
247
+
224
248
  function loadTheme() {
225
249
  try { return localStorage.getItem(LS_THEME) === "light" ? "light" : "dark"; } catch (e) { return "dark"; }
226
250
  }
@@ -423,6 +447,7 @@ window.__ModuleLoader__.load({
423
447
  jobs: function (limit) { return req("GET", "jobs?limit=" + (limit || 30)); },
424
448
  job: function (id) { return req("GET", "jobs/" + encodeURIComponent(id)); },
425
449
  abort: function (id) { return req("POST", "jobs/" + encodeURIComponent(id) + "/abort", {}); },
450
+ blackCheck: function (body) { return req("POST", "black-check", body || {}); },
426
451
  delJob: function (id) { return req("DELETE", "jobs/" + encodeURIComponent(id)); },
427
452
  clearJobs: function (keepActive) { return req("POST", "jobs/clear", { keepActive: keepActive !== false }); },
428
453
  images: function (q) { return req("GET", "images" + (q ? "?" + q : "")); },
@@ -504,6 +529,8 @@ window.__ModuleLoader__.load({
504
529
  :root{--ac-bg:#151517;--ac-bg2:#1b1b1d;--ac-card:#1b1b1d;--ac-raised:#2a2a2c;--ac-line:#303033;--ac-line2:#3a3a3d;--ac-text:#ececee;--ac-text2:#c6c6ca;--ac-muted:#a8a8ad;--ac-dim:#8a8a90;--ac-accent:#b3bfd0;--ac-accent2:#dbe1ea;--ac-ok:#6fbf8f;--ac-warn:#d1ac72;--ac-err:#d18a8a;--ac-fs:12.5px;--ac-fs-s:11.5px;--ac-fs-l:11px;}
505
530
  .ac-root{--ac-bg:#151517;--ac-bg2:#1b1b1d;--ac-card:#1b1b1d;--ac-raised:#2a2a2c;--ac-line:#303033;--ac-line2:#3a3a3d;--ac-text:#ececee;--ac-text2:#c6c6ca;--ac-muted:#a8a8ad;--ac-dim:#8a8a90;--ac-accent:#b3bfd0;--ac-accent2:#dbe1ea;--ac-ok:#6fbf8f;--ac-warn:#d1ac72;--ac-err:#d18a8a;--ac-fs:12.5px;--ac-fs-s:11.5px;--ac-fs-l:11px;box-sizing:border-box;height:100%;min-height:0;display:flex;flex-direction:column;background:var(--ac-bg);color:var(--ac-text);font-size:var(--ac-fs);line-height:1.5;font-family:var(--ac-font,inherit);color-scheme:dark;}
506
531
  .ac-root *{box-sizing:border-box;}
532
+ .ac-blackbar{display:flex;align-items:center;gap:8px;padding:7px 10px;background:rgba(96,72,30,.5);border-bottom:1px solid var(--ac-line);color:var(--ac-text);font-size:var(--ac-fs-s);flex:0 0 auto;}
533
+ .ac-blackbar .ac-blackbar-txt{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
507
534
  .ac-ballroot,.ac-ballroot *,.ac-overlay,.ac-overlay *{box-sizing:border-box;}
508
535
  .ac-root ::selection{background:rgba(130,150,180,.35);}
509
536
  .ac-root ::-webkit-scrollbar{width:8px;height:8px;}
@@ -1758,7 +1785,10 @@ window.__ModuleLoader__.load({
1758
1785
  var shown = view || img || null;
1759
1786
  if (shown && shown.id) lastFrameView = shown;
1760
1787
  }, [view && view.id, view && view.url, img && img.id, img && img.url]);
1761
- var live = !!(job && (job.status === "delivering" || job.status === "parsing" || job.status === "running" || job.status === "queued"));
1788
+ // 黑图补图是 internal 任务,不在 visibleJobs 里,所以「有没有在跑」要单看这个计数:
1789
+ // 补图进行中也要能把菜单里的「请求生成」换成「终止」。
1790
+ var blackActive = useBlackPending() > 0;
1791
+ var live = blackActive || !!(job && (job.status === "delivering" || job.status === "parsing" || job.status === "running" || job.status === "queued"));
1762
1792
  // anchor / turn 也进 key:旧图回填锚点后 id 不变,但跟随逻辑必须重建,
1763
1793
  // 否则监听器里还是“没有锚点”的旧 imgs。
1764
1794
  var imgKey = imgs.map(function (x) {
@@ -3693,36 +3723,114 @@ window.__ModuleLoader__.load({
3693
3723
  });
3694
3724
  }, "重新生成");
3695
3725
  };
3696
- var isNearlyBlack = function (url) {
3726
+ /**
3727
+ * 纯黑判定(纯函数,便于自检):从缩放后的 RGBA 采样里算,三条同时满足才算黑:
3728
+ * ① 暗像素(亮度 < 14)占比 ≥ 99.5%
3729
+ * ② 平均亮度 ≤ 8
3730
+ * ③ 亮点(亮度 > 110)占比 < 0.1%
3731
+ * 只满足 ① 会误伤夜景 / 黑底小主体——之前就是这么误判的。
3732
+ * 另外要求不透明像素覆盖 ≥ 50%:透明底的小图不是“黑图”,是透明图。
3733
+ */
3734
+ function judgeNearlyBlack(d) {
3735
+ var pixels = d.length / 4;
3736
+ var opaque = 0;
3737
+ var dark = 0;
3738
+ var bright = 0;
3739
+ var lumSum = 0;
3740
+ for (var i = 0; i < d.length; i += 4) {
3741
+ if (d[i + 3] < 16) continue; // 透明像素不算
3742
+ opaque += 1;
3743
+ var lum = 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2];
3744
+ lumSum += lum;
3745
+ if (lum < 14) dark += 1;
3746
+ else if (lum > 110) bright += 1;
3747
+ }
3748
+ var darkRatio = opaque ? dark / opaque : 0;
3749
+ var brightRatio = opaque ? bright / opaque : 0;
3750
+ var meanLum = opaque ? lumSum / opaque : 0;
3751
+ var coverage = pixels ? opaque / pixels : 0;
3752
+ var black = opaque >= 8 && coverage >= 0.5
3753
+ && darkRatio >= 0.995 && meanLum <= 8 && brightRatio < 0.001;
3754
+ return { black: black, darkRatio: darkRatio, brightRatio: brightRatio, meanLum: meanLum, coverage: coverage, opaque: opaque };
3755
+ }
3756
+ // 纯黑图 PNG 压缩后极小(832×1216 的真黑图 ≈ 25 KB),超过这个体积不可能是纯黑。
3757
+ // 这条是硬保险:就算画布绘制 / 解码出了岔子,也不会把一张有内容的图当成黑图。
3758
+ var BLACK_MAX_BYTES = 120000;
3759
+ // 返回 judgeNearlyBlack 的统计对象;bust=true 时加个 nc 参数绕过浏览器缓存(确认二次判定用)。
3760
+ var isNearlyBlack = function (url, bust) {
3697
3761
  return new Promise(function (resolve) {
3698
- if (!url || typeof document === "undefined" || typeof Image === "undefined") return resolve(false);
3762
+ if (!url || typeof document === "undefined" || typeof Image === "undefined") return resolve({ black: false });
3699
3763
  var probe = new Image();
3700
3764
  probe.onload = function () {
3701
3765
  try {
3702
- var w = Math.max(1, Math.min(256, probe.naturalWidth || 1));
3703
- var h = Math.max(1, Math.min(256, probe.naturalHeight || 1));
3766
+ if (!probe.naturalWidth || !probe.naturalHeight) return resolve({ black: false });
3767
+ var w = Math.max(1, Math.min(256, probe.naturalWidth));
3768
+ var h = Math.max(1, Math.min(256, probe.naturalHeight));
3704
3769
  var canvas = document.createElement("canvas");
3705
3770
  canvas.width = w;
3706
3771
  canvas.height = h;
3707
3772
  var g = canvas.getContext("2d");
3708
- if (!g) return resolve(false);
3773
+ if (!g) return resolve({ black: false });
3709
3774
  g.drawImage(probe, 0, 0, w, h);
3710
3775
  var d = g.getImageData(0, 0, w, h).data;
3711
- var total = 0;
3712
- var black = 0;
3713
- for (var i = 0; i < d.length; i += 4) {
3714
- if (d[i + 3] < 16) continue; // 透明像素不算黑
3715
- total += 1;
3716
- if (d[i] < 16 && d[i + 1] < 16 && d[i + 2] < 16) black += 1;
3717
- }
3718
- resolve(total > 0 && black / total >= 0.99);
3719
- } catch (e) { resolve(false); }
3776
+ resolve(judgeNearlyBlack(d));
3777
+ } catch (e) { resolve({ black: false }); }
3720
3778
  };
3721
- probe.onerror = function () { resolve(false); };
3722
- probe.src = url;
3779
+ probe.onerror = function () { resolve({ black: false }); };
3780
+ probe.src = url + (bust ? (url.indexOf("?") >= 0 ? "&" : "?") + "nc=" + Date.now() : "");
3781
+ });
3782
+ };
3783
+ // 判定入口:先看体积,再验像素;判黑要**两次都黑**才算数(第二次绕过缓存)。
3784
+ var probeBlackImage = function (im) {
3785
+ var url = im && im.url ? String(im.url) : "";
3786
+ var bytes = im && im.bytes ? Number(im.bytes) : 0;
3787
+ if (!url) return Promise.resolve({ black: false, skipped: "no-url" });
3788
+ if (bytes > BLACK_MAX_BYTES) return Promise.resolve({ black: false, skipped: "bytes", bytes: bytes, url: url });
3789
+ return isNearlyBlack(url).then(function (first) {
3790
+ if (!first.black) return { black: false, stats: first, url: url, bytes: bytes };
3791
+ return new Promise(function (resolve) { setTimeout(resolve, 900); })
3792
+ .then(function () { return isNearlyBlack(url, true); })
3793
+ .then(function (second) {
3794
+ return { black: !!second.black, stats: second, first: first, url: url, bytes: bytes, confirmed: true };
3795
+ });
3723
3796
  });
3724
3797
  };
3798
+ // 把判定结果写进 Host 日志(日志页可见),误判时能直接看到当时的统计。
3799
+ var reportBlackCheck = function (im, verdict) {
3800
+ try {
3801
+ var s = verdict && verdict.stats ? verdict.stats : {};
3802
+ api.blackCheck({
3803
+ imageId: im && im.id ? String(im.id) : "",
3804
+ fileName: im && im.fileName ? String(im.fileName) : "",
3805
+ bytes: verdict && verdict.bytes ? verdict.bytes : (im && im.bytes) || 0,
3806
+ black: !!(verdict && verdict.black),
3807
+ skipped: (verdict && verdict.skipped) || "",
3808
+ darkRatio: s.darkRatio, meanLum: s.meanLum, brightRatio: s.brightRatio, coverage: s.coverage,
3809
+ }).catch(function () { /* 诊断日志失败不影响主流程 */ });
3810
+ } catch (e) { /* ignore */ }
3811
+ };
3725
3812
  var blackQueueRef = useRef([]);
3813
+ // 自动补图可以随时叫停:队列清空 + 正在跑的那单 abort,并且这一轮不再自动补。
3814
+ // blackStopRef 在下一轮新任务完成时复位,避免「点过一次停止后就再也不补」。
3815
+ var blackStopRef = useRef(false);
3816
+ var blackJobRef = useRef("");
3817
+ var syncBlackPending = function () {
3818
+ setBlackPendingGlobal(blackQueueRef.current.length + (blackBusyRef.current ? 1 : 0));
3819
+ };
3820
+ var stopBlack = function () {
3821
+ blackStopRef.current = true;
3822
+ blackQueueRef.current = [];
3823
+ var jid = blackJobRef.current;
3824
+ blackJobRef.current = "";
3825
+ syncBlackPending();
3826
+ showToast("已停止自动补图", "ok");
3827
+ if (jid) api.abort(jid).catch(function () { /* 任务可能已经结束了 */ });
3828
+ };
3829
+ // 面板上的「停止自动补图」通过这个入口回调过来。
3830
+ useEffect(function () {
3831
+ BLACK_UI.stop = stopBlack;
3832
+ return function () { if (BLACK_UI.stop === stopBlack) BLACK_UI.stop = null; };
3833
+ }, []);
3726
3834
  /** 同一张图(同一套提示词)跨多次自动换种子仍用同一个 key,用来限制最多补几次。 */
3727
3835
  var blackSlotKey = function (sourceJob, im) {
3728
3836
  var sets = Array.isArray(sourceJob && sourceJob.sets) ? sourceJob.sets : [];
@@ -3738,18 +3846,24 @@ window.__ModuleLoader__.load({
3738
3846
  };
3739
3847
  var enqueueBlack = function (im, sourceJob, attempt, key, label) {
3740
3848
  if (!im || !im.id || !im.url) return;
3849
+ if (blackStopRef.current) return;
3741
3850
  blackQueueRef.current.push({
3742
3851
  id: String(im.id), jobId: im.jobId || (sourceJob && sourceJob.id) || "",
3743
- setIndex: im.setIndex, url: im.url, attempt: attempt || 1, key: key || "", label: label || "",
3852
+ setIndex: im.setIndex, url: im.url, bytes: im.bytes || 0,
3853
+ attempt: attempt || 1, key: key || "", label: label || "",
3744
3854
  });
3855
+ syncBlackPending();
3745
3856
  };
3746
3857
  var drainBlackQueue = function () {
3747
3858
  if (blackBusyRef.current) return;
3748
3859
  var next = blackQueueRef.current.shift();
3749
- if (!next) return;
3860
+ if (!next) { syncBlackPending(); return; }
3861
+ if (blackStopRef.current) { blackQueueRef.current = []; syncBlackPending(); return; }
3750
3862
  blackBusyRef.current = true;
3751
- isNearlyBlack(next.url).then(function (black) {
3752
- if (!black) return null;
3863
+ syncBlackPending();
3864
+ probeBlackImage(next).then(function (verdict) {
3865
+ reportBlackCheck(next, verdict);
3866
+ if (!verdict.black) return null;
3753
3867
  if (next.attempt === 1) {
3754
3868
  var nk = String(next.jobId || next.id || "");
3755
3869
  if (!blackNotifiedRef.current.has(nk)) {
@@ -3767,17 +3881,32 @@ window.__ModuleLoader__.load({
3767
3881
  mode: "auto",
3768
3882
  sessionId: data && data.activeSessionId || "",
3769
3883
  sessionTitle: data && data.activeSessionTitle || "",
3884
+ }).then(function (r) {
3885
+ blackJobRef.current = r && r.job && r.job.id ? String(r.job.id) : "";
3886
+ return r;
3770
3887
  });
3771
3888
  }).catch(function () { /* 黑图重生成失败不阻塞其它图 */ }).then(function () {
3772
3889
  blackBusyRef.current = false;
3890
+ blackJobRef.current = "";
3891
+ syncBlackPending();
3773
3892
  drainBlackQueue();
3774
3893
  });
3775
3894
  };
3776
3895
  var checkBlackImages = function (sourceJob) {
3777
3896
  if (!sourceJob || sourceJob.status !== "done") return;
3897
+ // 工作流页「图片输出相关 → 黑图自动替换」关掉就整个不检测(连已在队列里的也清掉)。
3898
+ if (data && data.activeWorkflow && data.activeWorkflow.blackRetry === false) {
3899
+ if (blackQueueRef.current.length || blackBusyRef.current) {
3900
+ blackQueueRef.current = [];
3901
+ syncBlackPending();
3902
+ }
3903
+ return;
3904
+ }
3778
3905
  // 只处理刚完成的这一轮;翻旧记录 / 重启页面不追溯历史图。
3779
3906
  var finished = Date.parse(sourceJob.finishedAt || "");
3780
3907
  if (!Number.isFinite(finished) || Date.now() - finished > 120000) return;
3908
+ // 新一轮(非自动补图)开始时解除上一次的「停止」,否则停过一次就再也不会补了。
3909
+ if (sourceJob.regenMode !== "auto") blackStopRef.current = false;
3781
3910
 
3782
3911
  if (sourceJob.regenMode === "auto") {
3783
3912
  // 自动补图后继续检查:仍然黑就再换种子,最多 BLACK_MAX_RETRIES 次。
@@ -3788,8 +3917,9 @@ window.__ModuleLoader__.load({
3788
3917
  if (!retried || !retried.id || !retried.url) return;
3789
3918
  var slotKey = blackSlotKey(sourceJob, retried);
3790
3919
  var attempt = Math.max(1, Number(blackRetryRef.current[slotKey]) || 1);
3791
- isNearlyBlack(retried.url).then(function (black) {
3792
- if (!black) {
3920
+ probeBlackImage(retried).then(function (verdict) {
3921
+ reportBlackCheck(retried, verdict);
3922
+ if (!verdict.black) {
3793
3923
  delete blackRetryRef.current[slotKey];
3794
3924
  notify("黑图已替换为正常图", "ok");
3795
3925
  // 替换已经落到图片上、也确认不是黑图了,最后再报出图完成。
@@ -3821,6 +3951,12 @@ window.__ModuleLoader__.load({
3821
3951
  drainBlackQueue();
3822
3952
  };
3823
3953
  var stopJob = function () {
3954
+ // 图片框左下菜单的「终止」就是真正的终止:黑图补图是 internal 任务,
3955
+ // 一起停掉(清队列 + 中止正在跑的那单),否则点了终止它还在后台接着补。
3956
+ if (blackQueueRef.current.length || blackBusyRef.current) {
3957
+ stopBlack();
3958
+ return;
3959
+ }
3824
3960
  if (!job || !job.id) return;
3825
3961
  act(function () {
3826
3962
  return api.abort(job.id).then(function () { showToast("已终止", "ok"); });
@@ -4547,6 +4683,8 @@ window.__ModuleLoader__.load({
4547
4683
  });
4548
4684
  var [toasts, setToasts] = useState([]);
4549
4685
  var [conn, setConn] = useState(true);
4686
+ // 自动补图是 FloatBall 在跑,这里只订阅「还有几个在补」用来显示停止条。
4687
+ var blackPending = useBlackPending();
4550
4688
  var [viewer, setViewer] = useState(null);
4551
4689
  // 切到右侧栏其它标签时 BridgePanel 会卸载;memory 让回来时还在同一张图。
4552
4690
  var bootSession = props && typeof props.sessionId === "string" ? props.sessionId : "";
@@ -5441,6 +5579,8 @@ window.__ModuleLoader__.load({
5441
5579
  }).catch(function (e) { showToast("重新生成失败:" + (e.message || e), "err"); });
5442
5580
  };
5443
5581
  var fullStop = function () {
5582
+ // 全屏里的「终止」也一样:先把自动补图停掉,再中止当前任务。
5583
+ if (BLACK_UI.stop) BLACK_UI.stop();
5444
5584
  if (!liveJob || !liveJob.id) return;
5445
5585
  Promise.resolve().then(function () { return api.abort(liveJob.id); }).then(function () { showToast("已终止", "ok"); }).catch(function (e) { showToast("终止失败:" + (e.message || e), "err"); });
5446
5586
  };
@@ -5809,6 +5949,11 @@ window.__ModuleLoader__.load({
5809
5949
  };
5810
5950
 
5811
5951
  return h("div", { className: cls("ac-root", fullImage && "fullimg", theme === "light" && "light") },
5952
+ blackPending > 0 ? h("div", { className: "ac-blackbar" },
5953
+ h("span", { className: "ac-blackbar-txt" },
5954
+ "检测到纯黑图,正在自动重新生成" + (blackPending > 1 ? "(队列 " + blackPending + " 个)" : "")),
5955
+ h(Btn, { small: true, variant: "danger", onClick: function () { if (BLACK_UI.stop) BLACK_UI.stop(); } }, "停止自动补图"),
5956
+ ) : null,
5812
5957
  fullImage
5813
5958
  ? h("div", {
5814
5959
  className: cls("ac-fullview", square && "square"),
@@ -6843,7 +6988,7 @@ window.__ModuleLoader__.load({
6843
6988
  h("div", { className: "ac-wfbar" },
6844
6989
  h("span", { className: "ac-wfname", title: wf ? wf.name : "未选择" }, "工作流:" + (wf ? wf.name : "未选择")),
6845
6990
  h("span", { style: { flex: 1 } }),
6846
- job && job.cancellable ? h(Btn, { small: true, variant: "danger", onClick: function () { api.abort(job.id).then(function () { props.toast("已终止", "ok"); }).catch(function (e) { props.toast("终止失败:" + (e.message || e), "err"); }); } }, "终止") : null,
6991
+ job && job.cancellable ? h(Btn, { small: true, variant: "danger", onClick: function () { if (BLACK_UI.stop) BLACK_UI.stop(); api.abort(job.id).then(function () { props.toast("已终止", "ok"); }).catch(function (e) { props.toast("终止失败:" + (e.message || e), "err"); }); } }, "终止") : null,
6847
6992
  h(Btn, {
6848
6993
  variant: "primary", className: "ac-genbtn",
6849
6994
  disabled: busy || !sets.length || !sets.some(function (s) { return s.positive; }) || !!(job && job.cancellable),
@@ -7363,6 +7508,21 @@ window.__ModuleLoader__.load({
7363
7508
  var [dry, setDry] = useState(props.initialDry || null);
7364
7509
  var [jsonOpen, setJsonOpen] = useState(props.initialJsonOpen === true);
7365
7510
  var fileRef = useRef(null);
7511
+ // 「固定种子」文本框:null = 不在编辑(显示自动 / 已保存的值),字符串 = 正在输入的原文。
7512
+ var [seedDraft, setSeedDraft] = useState(null);
7513
+ var seedTimer = useRef(null);
7514
+ useEffect(function () {
7515
+ return function () { if (seedTimer.current) clearTimeout(seedTimer.current); };
7516
+ }, []);
7517
+ // 种子这类开关不走「保存」按钮也会立刻落库,否则改完忘了保存,出图还是老种子。
7518
+ var persistWf = function (p) {
7519
+ patch(p);
7520
+ if (!wf || !wf.id) return;
7521
+ if (seedTimer.current) clearTimeout(seedTimer.current);
7522
+ seedTimer.current = setTimeout(function () {
7523
+ api.updateWorkflow(wf.id, p).catch(function () { /* 落库失败不阻塞界面 */ });
7524
+ }, 400);
7525
+ };
7366
7526
 
7367
7527
  var loadWf = function (wid) {
7368
7528
  if (!wid) { setWf(null); setScan(null); setJsonText(""); return Promise.resolve(null); }
@@ -7425,6 +7585,7 @@ window.__ModuleLoader__.load({
7425
7585
  size: wf ? wf.size : null,
7426
7586
  sizeMode: wf ? wf.sizeMode : "manual",
7427
7587
  randomSeed: wf ? wf.randomSeed : true,
7588
+ seed: wf && wf.seed !== undefined && wf.seed !== null && Number.isFinite(Number(wf.seed)) ? Math.round(Number(wf.seed)) : null,
7428
7589
  pruneOutputs: wf ? wf.pruneOutputs : true,
7429
7590
  onlyFirst: true,
7430
7591
  };
@@ -7455,7 +7616,7 @@ window.__ModuleLoader__.load({
7455
7616
  doSave(undefined, name);
7456
7617
  };
7457
7618
  var create = function () {
7458
- setWf({ id: "", name: "新工作流", json: {}, mapping: {}, mappingSeeded: false, mappingVersion: 0, outputNodes: [], outputSelectionVersion: 2, size: null, sizeMode: "manual", randomSeed: true, pruneOutputs: true, onlyFirst: false, loraState: null });
7619
+ setWf({ id: "", name: "新工作流", json: {}, mapping: {}, mappingSeeded: false, mappingVersion: 0, outputNodes: [], outputSelectionVersion: 2, size: null, sizeMode: "manual", randomSeed: true, seed: null, lastSeed: null, pruneOutputs: true, onlyFirst: false, loraState: null });
7459
7620
  setId("");
7460
7621
  setJsonText("{}");
7461
7622
  setScan(null);
@@ -7479,6 +7640,32 @@ window.__ModuleLoader__.load({
7479
7640
  var patch = function (p) {
7480
7641
  setWf(function (old) { return old ? Object.assign({}, old, p) : old; });
7481
7642
  };
7643
+ // 固定种子:自动模式只读,显示上一轮实际用的种子;取消自动后可以手填,
7644
+ // 填的数字会随保存写进工作流,出图时覆盖每一处字面量 seed。
7645
+ var seedAuto = !wf || wf.randomSeed !== false;
7646
+ var seedDisplay = seedAuto
7647
+ ? (wf && wf.lastSeed !== null && wf.lastSeed !== undefined ? String(wf.lastSeed) : "")
7648
+ : (wf && wf.seed !== null && wf.seed !== undefined ? String(wf.seed) : "");
7649
+ var editSeed = function (value) {
7650
+ setSeedDraft(value);
7651
+ var n = Number(value);
7652
+ if (String(value).trim() !== "" && isFinite(n) && n >= 0) patch({ seed: Math.round(n) });
7653
+ };
7654
+ var commitSeed = function (value) {
7655
+ setSeedDraft(null);
7656
+ var raw = String(value === undefined || value === null ? "" : value).trim();
7657
+ if (raw === "") { persistWf({ seed: null }); return; }
7658
+ var n = Number(raw);
7659
+ if (!isFinite(n)) { persistWf({ seed: null }); return; }
7660
+ persistWf({ seed: Math.max(0, Math.min(9007199254740991, Math.round(n))) });
7661
+ };
7662
+ var toggleRandomSeed = function (on) {
7663
+ if (on) { persistWf({ randomSeed: true }); return; }
7664
+ // 取消自动时顺手把「上一轮用的种子」带过来,省得再手敲一遍。
7665
+ var carry = wf.seed !== null && wf.seed !== undefined ? wf.seed
7666
+ : (wf.lastSeed !== null && wf.lastSeed !== undefined ? wf.lastSeed : null);
7667
+ persistWf({ randomSeed: false, seed: carry });
7668
+ };
7482
7669
  var mapping = (wf && wf.mapping) || {};
7483
7670
  var setMapping = function (nodeId, role) {
7484
7671
  var next = Object.assign({}, mapping);
@@ -7787,11 +7974,31 @@ window.__ModuleLoader__.load({
7787
7974
  h("div", { className: "ac-subtitle", style: { marginTop: 12 } }, "额外设置"),
7788
7975
  h("div", { className: "ac-form" },
7789
7976
  h("div", { className: "ac-row" },
7790
- h(Switch, { checked: wf.randomSeed !== false, onChange: function (e) { patch({ randomSeed: e.target.checked }); } }, "每次换随机种子"),
7977
+ h(Switch, { checked: wf.randomSeed !== false, onChange: function (e) { toggleRandomSeed(e.target.checked); } }, "每次换随机种子"),
7978
+ ),
7979
+ h("div", { className: "ac-row" },
7980
+ h(Field, { label: "固定种子" }, h(TextInput, {
7981
+ value: seedDraft !== null ? seedDraft : seedDisplay,
7982
+ disabled: seedAuto,
7983
+ placeholder: seedAuto ? "自动(显示上一轮用的种子)" : "填一个整数,每轮都用它",
7984
+ title: seedAuto
7985
+ ? "自动换种子时只读:显示上一轮实际用的种子;取消上面勾选即可编辑"
7986
+ : "取消自动后,这里填的数字会写进工作流,每轮都用它",
7987
+ onChange: function (e) { editSeed(e.target.value); },
7988
+ onBlur: function (e) { commitSeed(e.target.value); },
7989
+ onKeyDown: function (e) { if (e.key === "Enter") e.currentTarget.blur(); },
7990
+ })),
7991
+ h("span", { className: "ac-hint" }, seedAuto ? "自动:每轮随机" : "固定:每轮都用这个种子"),
7791
7992
  ),
7792
7993
  h("div", { className: "ac-row" },
7793
7994
  h(Switch, { checked: wf.pruneOutputs !== false, onChange: function (e) { patch({ pruneOutputs: e.target.checked }); } }, "提交前裁剪输出节点(只提交上面勾选的)"),
7794
7995
  ),
7996
+ h("div", { className: "ac-row" },
7997
+ h(Switch, { checked: wf.blackRetry !== false, onChange: function (e) { persistWf({ blackRetry: e.target.checked }); } }, "黑图自动替换"),
7998
+ h("span", { className: "ac-hint" }, wf.blackRetry !== false
7999
+ ? "出图后检测到纯黑图就自动换种子重生成;进行中可在图片框菜单点「终止」"
8000
+ : "已关闭:纯黑图不再自动替换"),
8001
+ ),
7795
8002
  ),
7796
8003
  ),
7797
8004
  ),
package/lib/comfy.js CHANGED
@@ -631,7 +631,7 @@ function countOccurrences(hay, needle) {
631
631
  */
632
632
  export function applyOverrides(
633
633
  workflow,
634
- { positive = '', negative = '', mapping = {}, tokenEmpty = 'blank', randomSeed = true, loraState = null, size = null, sizeSource = '' } = {},
634
+ { positive = '', negative = '', mapping = {}, tokenEmpty = 'blank', randomSeed = true, seed = null, loraState = null, size = null, sizeSource = '' } = {},
635
635
  ) {
636
636
  let wf = structuredClone(workflow);
637
637
  const report = {
@@ -978,6 +978,29 @@ export function applyOverrides(
978
978
  });
979
979
  }
980
980
  }
981
+ } else if (seed !== null && seed !== undefined && Number.isFinite(Number(seed))) {
982
+ // 固定种子:把每一处字面量 seed 都写成用户填的这个值(工作台「固定种子」文本框)。
983
+ // 只动和上面同一类节点,连线出来的种子不碰。
984
+ const fixed = Math.round(Number(seed));
985
+ for (const [nodeId, node] of Object.entries(wf)) {
986
+ if (!node || typeof node !== 'object' || !node.inputs) continue;
987
+ if (!SEED_NODE_RE.test(String(node.class_type || ''))) continue;
988
+ for (const key of Object.keys(node.inputs)) {
989
+ if (key !== 'seed' && key !== 'noise_seed' && key !== 'rand_seed') continue;
990
+ const before = node.inputs[key];
991
+ if (typeof before !== 'number' || !Number.isFinite(before)) continue;
992
+ node.inputs[key] = fixed;
993
+ report.seeds.push({
994
+ nodeId,
995
+ classType: node.class_type,
996
+ title: node?._meta?.title || node?.title || '',
997
+ inputKey: key,
998
+ before,
999
+ after: fixed,
1000
+ fixed: true,
1001
+ });
1002
+ }
1003
+ }
981
1004
  }
982
1005
 
983
1006
  /* ── 第 4 步:LoRA 开关 ──
package/lib/jobs.js CHANGED
@@ -578,6 +578,7 @@ export function createJobsEngine({ ctx, sc, store, hub, getConfig, getActiveSess
578
578
  mapping: wf.mapping || {},
579
579
  tokenEmpty: 'blank',
580
580
  randomSeed: job?.forceRandomSeed === true || wf.randomSeed !== false,
581
+ seed: wf.seed === null || wf.seed === undefined ? null : Number(wf.seed),
581
582
  loraState: set.continuousShot
582
583
  ? withLoraStrength(wf, resolveLoraState(wf), set.continuousShot)
583
584
  : resolveLoraState(wf),
@@ -743,6 +744,11 @@ export function createJobsEngine({ ctx, sc, store, hub, getConfig, getActiveSess
743
744
  const wfN = prepared.workflow;
744
745
  if (index === 0) job.overrideReport = prepared.report;
745
746
  const usedSeeds = seedsOfWorkflow(wfN);
747
+ // 把这一轮实际用的种子记到工作流上:工作流页「固定种子」那一栏在自动模式下
748
+ // 显示的就是它,用户看到满意的一张可以直接取消自动、把这颗种子固定下来。
749
+ if (index === 0 && wf.id && usedSeeds.length) {
750
+ try { store.patchWorkflow(wf.id, { lastSeed: usedSeeds[0].value }); } catch { /* 记录失败不影响出图 */ }
751
+ }
746
752
  if (runSets.length > 1) log('info', `第 ${index + 1}/${runSets.length} 套:排队提交`, { jobId: job.id });
747
753
  const { promptId } = await queuePrompt(cfg.comfy.baseUrl, wfN, CLIENT_ID);
748
754
  job.comfyPromptId = promptId;
package/lib/meta.js CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
  export const PACKAGE_NAME = 'dsh-comfyui-rp';
8
8
  export const PLUGIN_NAME = 'dsh-comfyui-rp';
9
- export const VERSION = '0.2.2';
9
+ export const VERSION = '0.2.4';
10
10
 
11
11
  /** 对外路由前缀。不能带尾斜杠(dsh-host-webserver 的 prefix 匹配规则)。 */
12
12
  export const ROUTE_PATH = '/__airp_comfy';
package/lib/store.js CHANGED
@@ -483,6 +483,9 @@ export function createStore(dataDir) {
483
483
  size: null,
484
484
  sizeMode: 'manual',
485
485
  randomSeed: true,
486
+ seed: null,
487
+ lastSeed: null,
488
+ blackRetry: true,
486
489
  pruneOutputs: true,
487
490
  onlyFirst: false,
488
491
  loraState: null,
@@ -553,6 +556,9 @@ export function createStore(dataDir) {
553
556
  size: null,
554
557
  sizeMode: 'manual',
555
558
  randomSeed: true,
559
+ seed: null,
560
+ lastSeed: null,
561
+ blackRetry: true,
556
562
  pruneOutputs: true,
557
563
  onlyFirst: false,
558
564
  loraState: null,
@@ -577,7 +583,7 @@ export function createStore(dataDir) {
577
583
  const w = api.getWorkflow(id);
578
584
  if (!w) return null;
579
585
  const allow = ['name', 'json', 'mapping', 'mappingSeeded', 'mappingVersion', 'outputNodes', 'outputSelectionVersion', 'tokenEmpty', 'size', 'sizeMode',
580
- 'randomSeed', 'pruneOutputs', 'onlyFirst', 'loraState'];
586
+ 'randomSeed', 'seed', 'lastSeed', 'pruneOutputs', 'onlyFirst', 'loraState', 'blackRetry'];
581
587
  for (const k of allow) {
582
588
  if (patch && Object.prototype.hasOwnProperty.call(patch, k)) w[k] = k === 'tokenEmpty' ? 'blank' : patch[k];
583
589
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-comfyui-rp",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "ComfyRP Bridge:装在 DSH 里的本地文生图自动化桥。捕获会话正文 → 提示词会话 → 本地 ComfyUI 出图 → 图片回到右侧栏 / 悬浮球 / 图库。不依赖外部桥进程,也不调用 LLM。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",