dsh-comfyui-rp 0.2.1 → 0.2.3

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,23 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.2.3
4
+
5
+ - **自动补图可以随时停下**:补图进行中,图片框左下菜单的按钮会变成「终止」(之前因为补图是内部任务,菜单里根本看不到它),点一下清空队列并中止正在跑的那一单;全屏和工作台的「终止」也一样。右侧栏顶部还有一条「停止自动补图」。下一次新任务开始时自动复位。
6
+ - **黑图自动替换开关**:工作流页 →「图片输出相关」→「额外设置」里新增开关,关掉后不再检测纯黑图,待补队列也会清空。
7
+ - **纯黑判定更严,修掉误判**:以前只要求「暗像素 ≥ 99%」就会判黑,夜景、黑底小主体这类图会被误伤。现在必须同时满足:暗像素 ≥ 99.5%、平均亮度 ≤ 8、亮点(亮度 > 110)占比 < 0.1%,并且不透明像素要占到一半以上(透明底的小图不算黑)。
8
+ - **固定种子可手填**:工作流页「每次换随机种子」下面多了一个「固定种子」输入框。
9
+ - 勾着自动时它是灰的,显示**上一轮实际用的种子**(方便看到满意的一张后直接固定下来);
10
+ - 取消勾选后解锁,填的整数会立刻存进工作流,出图时覆盖每一处字面量 seed;取消的那一刻会把上一轮的种子自动带进来。
11
+ - 这些开关改完直接生效,不用再点「保存」。
12
+
13
+
14
+ ## 0.2.2
15
+
16
+ - **LoRA 强度可以填负数**:工作台「连续图」的档位、LoRA 页每条的强度都允许 `-5 ~ 5`(反相 LoRA 用)。
17
+ - 这两个输入框在输入过程中不再被强制夹取:打「-」「-.5」这种中间态会先留在文本框里,**失焦或按回车**才夹到范围内并写回。
18
+ - 工作台的分辨率宽高也是同样规则(这条从 0.2.1 起就是)。
19
+ - 连续图说明里补了一句「强度可以填负数(反相),范围 -5 ~ 5」。
20
+
3
21
  ## 0.2.1 — 首个公开发布
4
22
 
5
23
  第一个放到 npm / 插件市场里的版本,功能面就是 README 里描述的这一整套:
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,
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
  }
@@ -504,6 +528,8 @@ window.__ModuleLoader__.load({
504
528
  :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
529
  .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
530
  .ac-root *{box-sizing:border-box;}
531
+ .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;}
532
+ .ac-blackbar .ac-blackbar-txt{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
507
533
  .ac-ballroot,.ac-ballroot *,.ac-overlay,.ac-overlay *{box-sizing:border-box;}
508
534
  .ac-root ::selection{background:rgba(130,150,180,.35);}
509
535
  .ac-root ::-webkit-scrollbar{width:8px;height:8px;}
@@ -1758,7 +1784,10 @@ window.__ModuleLoader__.load({
1758
1784
  var shown = view || img || null;
1759
1785
  if (shown && shown.id) lastFrameView = shown;
1760
1786
  }, [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"));
1787
+ // 黑图补图是 internal 任务,不在 visibleJobs 里,所以「有没有在跑」要单看这个计数:
1788
+ // 补图进行中也要能把菜单里的「请求生成」换成「终止」。
1789
+ var blackActive = useBlackPending() > 0;
1790
+ var live = blackActive || !!(job && (job.status === "delivering" || job.status === "parsing" || job.status === "running" || job.status === "queued"));
1762
1791
  // anchor / turn 也进 key:旧图回填锚点后 id 不变,但跟随逻辑必须重建,
1763
1792
  // 否则监听器里还是“没有锚点”的旧 imgs。
1764
1793
  var imgKey = imgs.map(function (x) {
@@ -3693,14 +3722,45 @@ window.__ModuleLoader__.load({
3693
3722
  });
3694
3723
  }, "重新生成");
3695
3724
  };
3725
+ /**
3726
+ * 纯黑判定(纯函数,便于自检):从缩放后的 RGBA 采样里算,三条同时满足才算黑:
3727
+ * ① 暗像素(亮度 < 14)占比 ≥ 99.5%
3728
+ * ② 平均亮度 ≤ 8
3729
+ * ③ 亮点(亮度 > 110)占比 < 0.1%
3730
+ * 只满足 ① 会误伤夜景 / 黑底小主体——之前就是这么误判的。
3731
+ * 另外要求不透明像素覆盖 ≥ 50%:透明底的小图不是“黑图”,是透明图。
3732
+ */
3733
+ function judgeNearlyBlack(d) {
3734
+ var pixels = d.length / 4;
3735
+ var opaque = 0;
3736
+ var dark = 0;
3737
+ var bright = 0;
3738
+ var lumSum = 0;
3739
+ for (var i = 0; i < d.length; i += 4) {
3740
+ if (d[i + 3] < 16) continue; // 透明像素不算
3741
+ opaque += 1;
3742
+ var lum = 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2];
3743
+ lumSum += lum;
3744
+ if (lum < 14) dark += 1;
3745
+ else if (lum > 110) bright += 1;
3746
+ }
3747
+ var darkRatio = opaque ? dark / opaque : 0;
3748
+ var brightRatio = opaque ? bright / opaque : 0;
3749
+ var meanLum = opaque ? lumSum / opaque : 0;
3750
+ var coverage = pixels ? opaque / pixels : 0;
3751
+ var black = opaque >= 8 && coverage >= 0.5
3752
+ && darkRatio >= 0.995 && meanLum <= 8 && brightRatio < 0.001;
3753
+ return { black: black, darkRatio: darkRatio, brightRatio: brightRatio, meanLum: meanLum, coverage: coverage, opaque: opaque };
3754
+ }
3696
3755
  var isNearlyBlack = function (url) {
3697
3756
  return new Promise(function (resolve) {
3698
3757
  if (!url || typeof document === "undefined" || typeof Image === "undefined") return resolve(false);
3699
3758
  var probe = new Image();
3700
3759
  probe.onload = function () {
3701
3760
  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));
3761
+ if (!probe.naturalWidth || !probe.naturalHeight) return resolve(false);
3762
+ var w = Math.max(1, Math.min(256, probe.naturalWidth));
3763
+ var h = Math.max(1, Math.min(256, probe.naturalHeight));
3704
3764
  var canvas = document.createElement("canvas");
3705
3765
  canvas.width = w;
3706
3766
  canvas.height = h;
@@ -3708,14 +3768,7 @@ window.__ModuleLoader__.load({
3708
3768
  if (!g) return resolve(false);
3709
3769
  g.drawImage(probe, 0, 0, w, h);
3710
3770
  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);
3771
+ resolve(judgeNearlyBlack(d).black);
3719
3772
  } catch (e) { resolve(false); }
3720
3773
  };
3721
3774
  probe.onerror = function () { resolve(false); };
@@ -3723,6 +3776,27 @@ window.__ModuleLoader__.load({
3723
3776
  });
3724
3777
  };
3725
3778
  var blackQueueRef = useRef([]);
3779
+ // 自动补图可以随时叫停:队列清空 + 正在跑的那单 abort,并且这一轮不再自动补。
3780
+ // blackStopRef 在下一轮新任务完成时复位,避免「点过一次停止后就再也不补」。
3781
+ var blackStopRef = useRef(false);
3782
+ var blackJobRef = useRef("");
3783
+ var syncBlackPending = function () {
3784
+ setBlackPendingGlobal(blackQueueRef.current.length + (blackBusyRef.current ? 1 : 0));
3785
+ };
3786
+ var stopBlack = function () {
3787
+ blackStopRef.current = true;
3788
+ blackQueueRef.current = [];
3789
+ var jid = blackJobRef.current;
3790
+ blackJobRef.current = "";
3791
+ syncBlackPending();
3792
+ showToast("已停止自动补图", "ok");
3793
+ if (jid) api.abort(jid).catch(function () { /* 任务可能已经结束了 */ });
3794
+ };
3795
+ // 面板上的「停止自动补图」通过这个入口回调过来。
3796
+ useEffect(function () {
3797
+ BLACK_UI.stop = stopBlack;
3798
+ return function () { if (BLACK_UI.stop === stopBlack) BLACK_UI.stop = null; };
3799
+ }, []);
3726
3800
  /** 同一张图(同一套提示词)跨多次自动换种子仍用同一个 key,用来限制最多补几次。 */
3727
3801
  var blackSlotKey = function (sourceJob, im) {
3728
3802
  var sets = Array.isArray(sourceJob && sourceJob.sets) ? sourceJob.sets : [];
@@ -3738,16 +3812,20 @@ window.__ModuleLoader__.load({
3738
3812
  };
3739
3813
  var enqueueBlack = function (im, sourceJob, attempt, key, label) {
3740
3814
  if (!im || !im.id || !im.url) return;
3815
+ if (blackStopRef.current) return;
3741
3816
  blackQueueRef.current.push({
3742
3817
  id: String(im.id), jobId: im.jobId || (sourceJob && sourceJob.id) || "",
3743
3818
  setIndex: im.setIndex, url: im.url, attempt: attempt || 1, key: key || "", label: label || "",
3744
3819
  });
3820
+ syncBlackPending();
3745
3821
  };
3746
3822
  var drainBlackQueue = function () {
3747
3823
  if (blackBusyRef.current) return;
3748
3824
  var next = blackQueueRef.current.shift();
3749
- if (!next) return;
3825
+ if (!next) { syncBlackPending(); return; }
3826
+ if (blackStopRef.current) { blackQueueRef.current = []; syncBlackPending(); return; }
3750
3827
  blackBusyRef.current = true;
3828
+ syncBlackPending();
3751
3829
  isNearlyBlack(next.url).then(function (black) {
3752
3830
  if (!black) return null;
3753
3831
  if (next.attempt === 1) {
@@ -3767,17 +3845,32 @@ window.__ModuleLoader__.load({
3767
3845
  mode: "auto",
3768
3846
  sessionId: data && data.activeSessionId || "",
3769
3847
  sessionTitle: data && data.activeSessionTitle || "",
3848
+ }).then(function (r) {
3849
+ blackJobRef.current = r && r.job && r.job.id ? String(r.job.id) : "";
3850
+ return r;
3770
3851
  });
3771
3852
  }).catch(function () { /* 黑图重生成失败不阻塞其它图 */ }).then(function () {
3772
3853
  blackBusyRef.current = false;
3854
+ blackJobRef.current = "";
3855
+ syncBlackPending();
3773
3856
  drainBlackQueue();
3774
3857
  });
3775
3858
  };
3776
3859
  var checkBlackImages = function (sourceJob) {
3777
3860
  if (!sourceJob || sourceJob.status !== "done") return;
3861
+ // 工作流页「图片输出相关 → 黑图自动替换」关掉就整个不检测(连已在队列里的也清掉)。
3862
+ if (data && data.activeWorkflow && data.activeWorkflow.blackRetry === false) {
3863
+ if (blackQueueRef.current.length || blackBusyRef.current) {
3864
+ blackQueueRef.current = [];
3865
+ syncBlackPending();
3866
+ }
3867
+ return;
3868
+ }
3778
3869
  // 只处理刚完成的这一轮;翻旧记录 / 重启页面不追溯历史图。
3779
3870
  var finished = Date.parse(sourceJob.finishedAt || "");
3780
3871
  if (!Number.isFinite(finished) || Date.now() - finished > 120000) return;
3872
+ // 新一轮(非自动补图)开始时解除上一次的「停止」,否则停过一次就再也不会补了。
3873
+ if (sourceJob.regenMode !== "auto") blackStopRef.current = false;
3781
3874
 
3782
3875
  if (sourceJob.regenMode === "auto") {
3783
3876
  // 自动补图后继续检查:仍然黑就再换种子,最多 BLACK_MAX_RETRIES 次。
@@ -3821,6 +3914,12 @@ window.__ModuleLoader__.load({
3821
3914
  drainBlackQueue();
3822
3915
  };
3823
3916
  var stopJob = function () {
3917
+ // 图片框左下菜单的「终止」就是真正的终止:黑图补图是 internal 任务,
3918
+ // 一起停掉(清队列 + 中止正在跑的那单),否则点了终止它还在后台接着补。
3919
+ if (blackQueueRef.current.length || blackBusyRef.current) {
3920
+ stopBlack();
3921
+ return;
3922
+ }
3824
3923
  if (!job || !job.id) return;
3825
3924
  act(function () {
3826
3925
  return api.abort(job.id).then(function () { showToast("已终止", "ok"); });
@@ -4547,6 +4646,8 @@ window.__ModuleLoader__.load({
4547
4646
  });
4548
4647
  var [toasts, setToasts] = useState([]);
4549
4648
  var [conn, setConn] = useState(true);
4649
+ // 自动补图是 FloatBall 在跑,这里只订阅「还有几个在补」用来显示停止条。
4650
+ var blackPending = useBlackPending();
4550
4651
  var [viewer, setViewer] = useState(null);
4551
4652
  // 切到右侧栏其它标签时 BridgePanel 会卸载;memory 让回来时还在同一张图。
4552
4653
  var bootSession = props && typeof props.sessionId === "string" ? props.sessionId : "";
@@ -5441,6 +5542,8 @@ window.__ModuleLoader__.load({
5441
5542
  }).catch(function (e) { showToast("重新生成失败:" + (e.message || e), "err"); });
5442
5543
  };
5443
5544
  var fullStop = function () {
5545
+ // 全屏里的「终止」也一样:先把自动补图停掉,再中止当前任务。
5546
+ if (BLACK_UI.stop) BLACK_UI.stop();
5444
5547
  if (!liveJob || !liveJob.id) return;
5445
5548
  Promise.resolve().then(function () { return api.abort(liveJob.id); }).then(function () { showToast("已终止", "ok"); }).catch(function (e) { showToast("终止失败:" + (e.message || e), "err"); });
5446
5549
  };
@@ -5809,6 +5912,11 @@ window.__ModuleLoader__.load({
5809
5912
  };
5810
5913
 
5811
5914
  return h("div", { className: cls("ac-root", fullImage && "fullimg", theme === "light" && "light") },
5915
+ blackPending > 0 ? h("div", { className: "ac-blackbar" },
5916
+ h("span", { className: "ac-blackbar-txt" },
5917
+ "检测到纯黑图,正在自动重新生成" + (blackPending > 1 ? "(队列 " + blackPending + " 个)" : "")),
5918
+ h(Btn, { small: true, variant: "danger", onClick: function () { if (BLACK_UI.stop) BLACK_UI.stop(); } }, "停止自动补图"),
5919
+ ) : null,
5812
5920
  fullImage
5813
5921
  ? h("div", {
5814
5922
  className: cls("ac-fullview", square && "square"),
@@ -6561,6 +6669,8 @@ window.__ModuleLoader__.load({
6561
6669
  })();
6562
6670
  var [contCount, setContCount] = useState(initialStrengths.count);
6563
6671
  var [contStrengths, setContStrengths] = useState(initialStrengths.values);
6672
+ // 连续图档位的输入原文:允许先打「-」「-.5」,离开输入框才兜底。
6673
+ var [contText, setContText] = useState({});
6564
6674
  var cfgTimer = useRef(null);
6565
6675
  var wfSkip = useRef(!!draft);
6566
6676
 
@@ -6786,12 +6896,37 @@ window.__ModuleLoader__.load({
6786
6896
  setContCount(n);
6787
6897
  saveContinuous({ count: n, strengths: contStrengths }, true);
6788
6898
  };
6899
+ // 档位允许负数(反相 LoRA):输入期间保留原文(「-」「-.5」这种中间态),
6900
+ // 失焦 / 回车才夹到 -5..5 并落库。夹取范围和 lib/config.js 的服务端归一化一致。
6789
6901
  var setStrength = function (i, value) {
6790
6902
  var next = contStrengths.slice();
6791
- next[i] = clamp(value, 0, 2);
6903
+ next[i] = Math.round(clamp(value, -5, 5) * 100) / 100;
6792
6904
  setContStrengths(next);
6793
6905
  saveContinuous({ strengths: next }, false);
6794
6906
  };
6907
+ var editContStrength = function (i, raw) {
6908
+ setContText(function (old) {
6909
+ var n = Object.assign({}, old);
6910
+ n[i] = String(raw);
6911
+ return n;
6912
+ });
6913
+ var v = Number(raw);
6914
+ if (String(raw).trim() !== "" && isFinite(v)) {
6915
+ var next = contStrengths.slice();
6916
+ next[i] = v;
6917
+ setContStrengths(next);
6918
+ saveContinuous({ strengths: next }, false);
6919
+ }
6920
+ };
6921
+ var commitContStrength = function (i, raw) {
6922
+ setContText(function (old) {
6923
+ var n = Object.assign({}, old);
6924
+ delete n[i];
6925
+ return n;
6926
+ });
6927
+ if (String(raw === undefined || raw === null ? "" : raw).trim() === "") return;
6928
+ setStrength(i, raw);
6929
+ };
6795
6930
 
6796
6931
  var loraOptions = [];
6797
6932
  (loraStacks || []).forEach(function (st) {
@@ -6816,7 +6951,7 @@ window.__ModuleLoader__.load({
6816
6951
  h("div", { className: "ac-wfbar" },
6817
6952
  h("span", { className: "ac-wfname", title: wf ? wf.name : "未选择" }, "工作流:" + (wf ? wf.name : "未选择")),
6818
6953
  h("span", { style: { flex: 1 } }),
6819
- 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,
6954
+ 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,
6820
6955
  h(Btn, {
6821
6956
  variant: "primary", className: "ac-genbtn",
6822
6957
  disabled: busy || !sets.length || !sets.some(function (s) { return s.positive; }) || !!(job && job.cancellable),
@@ -6939,11 +7074,15 @@ window.__ModuleLoader__.load({
6939
7074
  h("div", { className: "ac-strengthrow" },
6940
7075
  contStrengths.slice(0, contCount).map(function (v, i) {
6941
7076
  return h(Field, { key: i, label: "档位" + (i + 1) }, h(NumInput, {
6942
- step: "0.05", value: v, onChange: function (e) { setStrength(i, e.target.value); },
7077
+ step: "0.05", min: "-5", max: "5",
7078
+ value: contText[i] !== undefined ? contText[i] : String(v),
7079
+ onChange: function (e) { editContStrength(i, e.target.value); },
7080
+ onBlur: function (e) { commitContStrength(i, e.target.value); },
7081
+ onKeyDown: function (e) { if (e.key === "Enter") e.currentTarget.blur(); },
6943
7082
  }));
6944
7083
  }),
6945
7084
  ),
6946
- h("div", { className: "ac-hint" }, "支持 LoRA 堆、单 LoRA、Power / Stacker / Tag 节点;仅影响最后一组提示词,按设置张数用不同强度连续生图"),
7085
+ h("div", { className: "ac-hint" }, "支持 LoRA 堆、单 LoRA、Power / Stacker / Tag 节点;仅影响最后一组提示词,按设置张数用不同强度连续生图。强度可以填负数(反相),范围 -5 ~ 5。"),
6947
7086
  ),
6948
7087
  ),
6949
7088
 
@@ -7332,6 +7471,21 @@ window.__ModuleLoader__.load({
7332
7471
  var [dry, setDry] = useState(props.initialDry || null);
7333
7472
  var [jsonOpen, setJsonOpen] = useState(props.initialJsonOpen === true);
7334
7473
  var fileRef = useRef(null);
7474
+ // 「固定种子」文本框:null = 不在编辑(显示自动 / 已保存的值),字符串 = 正在输入的原文。
7475
+ var [seedDraft, setSeedDraft] = useState(null);
7476
+ var seedTimer = useRef(null);
7477
+ useEffect(function () {
7478
+ return function () { if (seedTimer.current) clearTimeout(seedTimer.current); };
7479
+ }, []);
7480
+ // 种子这类开关不走「保存」按钮也会立刻落库,否则改完忘了保存,出图还是老种子。
7481
+ var persistWf = function (p) {
7482
+ patch(p);
7483
+ if (!wf || !wf.id) return;
7484
+ if (seedTimer.current) clearTimeout(seedTimer.current);
7485
+ seedTimer.current = setTimeout(function () {
7486
+ api.updateWorkflow(wf.id, p).catch(function () { /* 落库失败不阻塞界面 */ });
7487
+ }, 400);
7488
+ };
7335
7489
 
7336
7490
  var loadWf = function (wid) {
7337
7491
  if (!wid) { setWf(null); setScan(null); setJsonText(""); return Promise.resolve(null); }
@@ -7394,6 +7548,7 @@ window.__ModuleLoader__.load({
7394
7548
  size: wf ? wf.size : null,
7395
7549
  sizeMode: wf ? wf.sizeMode : "manual",
7396
7550
  randomSeed: wf ? wf.randomSeed : true,
7551
+ seed: wf && wf.seed !== undefined && wf.seed !== null && Number.isFinite(Number(wf.seed)) ? Math.round(Number(wf.seed)) : null,
7397
7552
  pruneOutputs: wf ? wf.pruneOutputs : true,
7398
7553
  onlyFirst: true,
7399
7554
  };
@@ -7424,7 +7579,7 @@ window.__ModuleLoader__.load({
7424
7579
  doSave(undefined, name);
7425
7580
  };
7426
7581
  var create = function () {
7427
- setWf({ id: "", name: "新工作流", json: {}, mapping: {}, mappingSeeded: false, mappingVersion: 0, outputNodes: [], outputSelectionVersion: 2, size: null, sizeMode: "manual", randomSeed: true, pruneOutputs: true, onlyFirst: false, loraState: null });
7582
+ 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 });
7428
7583
  setId("");
7429
7584
  setJsonText("{}");
7430
7585
  setScan(null);
@@ -7448,6 +7603,32 @@ window.__ModuleLoader__.load({
7448
7603
  var patch = function (p) {
7449
7604
  setWf(function (old) { return old ? Object.assign({}, old, p) : old; });
7450
7605
  };
7606
+ // 固定种子:自动模式只读,显示上一轮实际用的种子;取消自动后可以手填,
7607
+ // 填的数字会随保存写进工作流,出图时覆盖每一处字面量 seed。
7608
+ var seedAuto = !wf || wf.randomSeed !== false;
7609
+ var seedDisplay = seedAuto
7610
+ ? (wf && wf.lastSeed !== null && wf.lastSeed !== undefined ? String(wf.lastSeed) : "")
7611
+ : (wf && wf.seed !== null && wf.seed !== undefined ? String(wf.seed) : "");
7612
+ var editSeed = function (value) {
7613
+ setSeedDraft(value);
7614
+ var n = Number(value);
7615
+ if (String(value).trim() !== "" && isFinite(n) && n >= 0) patch({ seed: Math.round(n) });
7616
+ };
7617
+ var commitSeed = function (value) {
7618
+ setSeedDraft(null);
7619
+ var raw = String(value === undefined || value === null ? "" : value).trim();
7620
+ if (raw === "") { persistWf({ seed: null }); return; }
7621
+ var n = Number(raw);
7622
+ if (!isFinite(n)) { persistWf({ seed: null }); return; }
7623
+ persistWf({ seed: Math.max(0, Math.min(9007199254740991, Math.round(n))) });
7624
+ };
7625
+ var toggleRandomSeed = function (on) {
7626
+ if (on) { persistWf({ randomSeed: true }); return; }
7627
+ // 取消自动时顺手把「上一轮用的种子」带过来,省得再手敲一遍。
7628
+ var carry = wf.seed !== null && wf.seed !== undefined ? wf.seed
7629
+ : (wf.lastSeed !== null && wf.lastSeed !== undefined ? wf.lastSeed : null);
7630
+ persistWf({ randomSeed: false, seed: carry });
7631
+ };
7451
7632
  var mapping = (wf && wf.mapping) || {};
7452
7633
  var setMapping = function (nodeId, role) {
7453
7634
  var next = Object.assign({}, mapping);
@@ -7756,11 +7937,31 @@ window.__ModuleLoader__.load({
7756
7937
  h("div", { className: "ac-subtitle", style: { marginTop: 12 } }, "额外设置"),
7757
7938
  h("div", { className: "ac-form" },
7758
7939
  h("div", { className: "ac-row" },
7759
- h(Switch, { checked: wf.randomSeed !== false, onChange: function (e) { patch({ randomSeed: e.target.checked }); } }, "每次换随机种子"),
7940
+ h(Switch, { checked: wf.randomSeed !== false, onChange: function (e) { toggleRandomSeed(e.target.checked); } }, "每次换随机种子"),
7941
+ ),
7942
+ h("div", { className: "ac-row" },
7943
+ h(Field, { label: "固定种子" }, h(TextInput, {
7944
+ value: seedDraft !== null ? seedDraft : seedDisplay,
7945
+ disabled: seedAuto,
7946
+ placeholder: seedAuto ? "自动(显示上一轮用的种子)" : "填一个整数,每轮都用它",
7947
+ title: seedAuto
7948
+ ? "自动换种子时只读:显示上一轮实际用的种子;取消上面勾选即可编辑"
7949
+ : "取消自动后,这里填的数字会写进工作流,每轮都用它",
7950
+ onChange: function (e) { editSeed(e.target.value); },
7951
+ onBlur: function (e) { commitSeed(e.target.value); },
7952
+ onKeyDown: function (e) { if (e.key === "Enter") e.currentTarget.blur(); },
7953
+ })),
7954
+ h("span", { className: "ac-hint" }, seedAuto ? "自动:每轮随机" : "固定:每轮都用这个种子"),
7760
7955
  ),
7761
7956
  h("div", { className: "ac-row" },
7762
7957
  h(Switch, { checked: wf.pruneOutputs !== false, onChange: function (e) { patch({ pruneOutputs: e.target.checked }); } }, "提交前裁剪输出节点(只提交上面勾选的)"),
7763
7958
  ),
7959
+ h("div", { className: "ac-row" },
7960
+ h(Switch, { checked: wf.blackRetry !== false, onChange: function (e) { persistWf({ blackRetry: e.target.checked }); } }, "黑图自动替换"),
7961
+ h("span", { className: "ac-hint" }, wf.blackRetry !== false
7962
+ ? "出图后检测到纯黑图就自动换种子重生成;进行中可在图片框菜单点「终止」"
7963
+ : "已关闭:纯黑图不再自动替换"),
7964
+ ),
7764
7965
  ),
7765
7966
  ),
7766
7967
  ),
@@ -7783,6 +7984,9 @@ window.__ModuleLoader__.load({
7783
7984
  var dropRef = useRef(null);
7784
7985
  // 点 LoRA 名字就地改名:输入期间只改本地草稿,回车 / 失焦才写进 loraState。
7785
7986
  var [nameEdit, setNameEdit] = useState(null);
7987
+ // 强度输入:输入期间只改文本框(允许「-」「-.5」这种中间态),失焦 / 回车才夹到 -5..5 写回。
7988
+ // 以前每按一次键就 Number(),"-" 会变成 NaN 被弹回去,负数根本打不进去。
7989
+ var [numText, setNumText] = useState({});
7786
7990
  var nameEditRef = useRef(null);
7787
7991
  var applyTimer = useRef(null);
7788
7992
  // 所有临时覆盖串行发送:保存到工作流时先等已发出的覆盖落地,
@@ -7850,6 +8054,29 @@ window.__ModuleLoader__.load({
7850
8054
  };
7851
8055
  if (immediate) send(); else applyTimer.current = setTimeout(send, 260);
7852
8056
  };
8057
+ var numKey = function (stackId, idx) { return stackId + ":" + idx; };
8058
+ // 强度:输入时先别夹、也别丢原文;失焦 / 回车才落到 -5..5。
8059
+ var editStrength = function (stackId, idx, raw) {
8060
+ setNumText(function (old) {
8061
+ var n = Object.assign({}, old);
8062
+ n[numKey(stackId, idx)] = String(raw);
8063
+ return n;
8064
+ });
8065
+ var v = Number(raw);
8066
+ if (String(raw).trim() !== "" && isFinite(v)) {
8067
+ updateEntry(stackId, idx, { strength: v, clipStrength: v });
8068
+ }
8069
+ };
8070
+ var commitStrength = function (stackId, idx, raw) {
8071
+ setNumText(function (old) {
8072
+ var n = Object.assign({}, old);
8073
+ delete n[numKey(stackId, idx)];
8074
+ return n;
8075
+ });
8076
+ if (String(raw === undefined || raw === null ? "" : raw).trim() === "") return;
8077
+ var v = Math.round(clamp(raw, -5, 5) * 100) / 100;
8078
+ updateEntry(stackId, idx, { strength: v, clipStrength: v });
8079
+ };
7853
8080
  var updateEntry = function (stackId, idx, patch, immediate) {
7854
8081
  if (!data) return;
7855
8082
  var next = JSON.parse(JSON.stringify(data));
@@ -8106,9 +8333,13 @@ window.__ModuleLoader__.load({
8106
8333
  onClick: function () { if (!taken) beginNameEdit(st.nodeId, i, e.label || e.name); },
8107
8334
  }, e.label || e.name),
8108
8335
  h("input", {
8109
- type: "number", step: "0.05", value: e.strength, disabled: taken,
8110
- title: taken ? "连续图接管中:强度按连续图档位,这里不生效" : "强度",
8111
- onChange: function (ev) { updateEntry(st.nodeId, i, { strength: Number(ev.target.value), clipStrength: Number(ev.target.value) }); },
8336
+ type: "number", step: "0.05", min: "-5", max: "5",
8337
+ value: numText[numKey(st.nodeId, i)] !== undefined ? numText[numKey(st.nodeId, i)] : e.strength,
8338
+ disabled: taken,
8339
+ title: taken ? "连续图接管中:强度按连续图档位,这里不生效" : "强度,可用负数;范围 -5 ~ 5",
8340
+ onChange: function (ev) { editStrength(st.nodeId, i, ev.target.value); },
8341
+ onBlur: function (ev) { commitStrength(st.nodeId, i, ev.target.value); },
8342
+ onKeyDown: function (ev) { if (ev.key === "Enter") ev.currentTarget.blur(); },
8112
8343
  }),
8113
8344
  canSort ? h("button", {
8114
8345
  className: "ac-btn sm", disabled: taken,
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.1';
9
+ export const VERSION = '0.2.3';
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.1",
3
+ "version": "0.2.3",
4
4
  "description": "ComfyRP Bridge:装在 DSH 里的本地文生图自动化桥。捕获会话正文 → 提示词会话 → 本地 ComfyUI 出图 → 图片回到右侧栏 / 悬浮球 / 图库。不依赖外部桥进程,也不调用 LLM。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",