Compare commits

...
2 Commits
35 changed files with 426 additions and 122 deletions
+11
View File
@@ -1,5 +1,16 @@
# Changelog
## 2.0.1 · stability fixes (2026-11-10)
- Release late preview object URLs after sibling failures, discard corrupt cached PNGs, bound decoding and cancel stale preview reads. Downloads and both ordinary/crop adoption use bounded transfers too.
- Add upload inactivity detection and cancellation without limiting the overall duration of advancing XHR transfers; keep authentication fallback and real byte progress.
- Dispose workspaces safely during initialization/validation, coalesce background checks, and hand submitted task IDs to a reopened workspace before result recovery. Recovery selects sliding comparison even when the result was already cached.
- Share one exact-task history poller between node and workspace; cancel disposed recovery requests and release discarded pending states.
- Keep the last good source usable after an invalid replacement. Protect source rollback from intervening completed strokes/crop changes and release partially decoded masks and retired blend canvases.
- Bound preview-cache batch metadata to 512 batches as well as the existing byte limit. Empty/invalid batches leave cached images unchanged.
- Find dense mask bounds from row/column occupancy instead of allocating coordinates per painted pixel; sanitize into a private tensor with one less full-image allocation.
- Reject malformed workflow input mappings before core replacement hooks can crash. Existing node IDs, workflows, masks, crop layers, presets and sampling behavior are retained.
## 2.0.0 · 2026-11-09
- Ship the standalone Adachi Workbench with English default labels and Simplified Chinese translations following ComfyUI's language setting.
+1 -1
View File
@@ -235,7 +235,7 @@ All example labels are English. Prompts are newly written neutral examples; the
- **Crop layers:** the fusion mask and crop coordinates are saved in the workflow; the latest layer is a relative image reference under `input/adachi-inpaint-layers/`. Include that file when moving an unmerged edit between machines, or merge layers and embed the source first.
- **Sharing:** masks are embedded in saved workflows. Source images use a relative `input/` path by default; enable **Embed source in workflow** to include the picture too. Embedded images increase JSON size. If an embedded source would exceed browser draft storage when loading a workflow, uploading an image, changing modes or enabling embedding, the plugin first uploads an exact source copy and switches to an input-file reference, preserving masks and layers. To intentionally keep a very large source embedded, disable ComfyUI browser workflow persistence before embedding; exported files are not subject to the browser draft quota. If the page already reports draft failures on every edit, save with **Ctrl+S**, then reload with **Ctrl+F5** to reset ComfyUI's disabled draft-storage state.
- **Adoption:** source and result dimensions must match to preserve mask coordinates. Undo/redo commands survive adoption in the current session; saving/reloading retains the mask, not the command history.
- **Comparison previews:** new originals and results are held in server memory instead of temporary image files. The default cache is 512 MiB; set `ADACHI_COMPARE_MEMORY_MIB` to 64–4096 before starting ComfyUI to change it. Cache eviction or server restart expires old comparisons and their download references; rerun the workflow to refresh them. A single batch larger than the limit reports an error. Previous disk-based preview references remain supported while available, and expired old plugin preview files are still cleaned up. Images adopted into the editor are saved separately under `input/adachi-inpaint/` or `input/adachi-inpaint-layers/`.
- **Comparison previews:** new originals and results are held in server memory instead of temporary image files. The default cache is 512 MiB and at most 512 comparison batches; set `ADACHI_COMPARE_MEMORY_MIB` to 64–4096 before starting ComfyUI to change it. Cache eviction or server restart expires old comparisons and their download references; rerun the workflow to refresh them. A single batch larger than the limit reports an error. Previous disk-based preview references remain supported while available, and expired old plugin preview files are still cleaned up. Images adopted into the editor are saved separately under `input/adachi-inpaint/` or `input/adachi-inpaint-layers/`.
- **Scope:** this is an original WebUI-style implementation. It does not promise identical WebUI pixels, scripts or extension behaviour. Extra soft-edge feathering is not WebUI's full Soft Inpainting algorithm.
<details>
+1 -1
View File
@@ -235,7 +235,7 @@ Refiner 输入是可选项。只有需要兼容的第二阶段模型时才连接
- **裁切图层:**选区和融合蒙版会保存到工作流,最新重绘图层引用 `input/adachi-inpaint-layers/` 中的图片。跨机器移动尚未合并的编辑时需带上这张图,或先合并图层再嵌入原图。
- **分享工作流:**蒙版会写入 JSON。原图默认只存 `input/` 下的相对路径,开启**原图嵌入工作流**才会把图片也写进去,文件会变大。加载工作流、上传图片、切换模式或勾选嵌入时,如果浏览器草稿空间不足,插件先保存一份完全相同的原图到 input 图片目录,再改用文件引用,保留蒙版、图层和笔画记录。若确实要把大图嵌入 JSON,可先关闭 ComfyUI 的浏览器工作流持久化,再勾选嵌入;导出文件不受浏览器草稿限额影响。若当前页面已经每次操作都提示草稿失败,请先按 **Ctrl+S** 保存,再按 **Ctrl+F5** 刷新一次,重置 ComfyUI 本页已停用的草稿状态。
- **采用结果:**结果尺寸必须与原图一致,才能保留蒙版坐标。撤销/重做记录在本次会话中保留;保存重开保留蒙版,不保存逐笔命令历史。
- **对比预览:**新的原图和重绘结果保存在服务器内存中,不再生成对比临时图片。默认缓存上限为 512 MiB,可在启动 ComfyUI 前设置 `ADACHI_COMPARE_MEMORY_MIB` 为 64~4096 调整。内存淘汰或服务器重启后,旧对比和下载引用会失效,重新运行工作流即可恢复;单批超过上限会提示错误。旧版磁盘预览文件存在时仍可读取,其过期清理也继续保留。采用到编辑器的图片另存于 `input/adachi-inpaint/` 或 `input/adachi-inpaint-layers/`。
- **对比预览:**新的原图和重绘结果保存在服务器内存中,不再生成对比临时图片。默认缓存上限为 512 MiB,同时最多保留 512 批对比,可在启动 ComfyUI 前设置 `ADACHI_COMPARE_MEMORY_MIB` 为 64~4096 调整字节上限。内存淘汰或服务器重启后,旧对比和下载引用会失效,重新运行工作流即可恢复;单批超过上限会提示错误。旧版磁盘预览文件存在时仍可读取,其过期清理也继续保留。采用到编辑器的图片另存于 `input/adachi-inpaint/` 或 `input/adachi-inpaint-layers/`。
- **功能范围:**这是独立实现的 WebUI 风格工具,不保证与 WebUI 像素、脚本和扩展行为完全一致;额外软边羽化并非 WebUI 的完整 Soft Inpainting 算法。
<details>
+6 -3
View File
@@ -1,11 +1,14 @@
# Git versions and rollback
The current version is **v2.0.0**. Git history lives in the source repository. The installed custom-node folder may be a ZIP/file copy without its own `.git`; do not run rollback commands in ComfyUI's parent repository.
The stability fixes in 2.0.1 start from `local-before-stability-20261110` (the published `v2.0.0` commit). A complete copied-installation ZIP is stored in `ComfyUI/user/adachi-workbench-backups/` with prefix `Adachi-Inpaint-Workbench-before-stability-20261110-`. Restore that backup or switch a clean source checkout to the baseline tag, then reinstall, restart ComfyUI and hard-refresh. The published 2.0.0 archive remains unchanged.
The current version is **v2.0.1**. Git history lives in the source repository. The installed custom-node folder may be a ZIP/file copy without its own `.git`; do not run rollback commands in ComfyUI's parent repository.
Before installing, the installer saves the previous plugin directory under `ComfyUI/user/adachi-workbench-backups/`. Restore that ZIP to roll back a copied installation. For a Git clone, use the tags below.
| Tag | Contents |
|---|---|
| `v2.0.1` | Stability, cancellation, recovery and dense-mask memory fixes |
| `v2.0.0` | Standalone workbench, workflow discovery, cloud recovery, localization and unified template |
| `v1.0.1` | Previous published version with comparison retry |
| `v1.0.0` | First stable release with current bilingual crop instructions |
@@ -49,13 +52,13 @@ To undo only this update, run in the plugin folder:
```powershell
git status --short
git switch --detach v1.0.1
git switch --detach v2.0.0
```
Return to this version with:
```powershell
git switch --detach v2.0.0
git switch --detach v2.0.1
```
Restart ComfyUI and hard-refresh the browser (`Ctrl+F5`) after switching versions.
+9
View File
@@ -1,5 +1,14 @@
# Verification record
## 2.0.1 · stability audit (2026-11-10)
- 68 Python regression tests and all 20 JavaScript test files cover new cancellation, stalled decoding/upload, corrupt-cache retry, dense/sparse/edge masks, bounded cache metadata, partial rollback failures, concurrent completed edits and malformed input mappings.
- Real ComfyUI browser checks pass on frontends 1.42.11 and 1.52.7 in classic and Nodes 2.0 rendering. Each configuration performs 20 mount/close cycles with zero remaining workspace API listeners, delayed initialization disposal, a delayed submit response across close/reopen, and recovery with execution notifications suppressed.
- Actual CPU image queues cover normal adoption/undo and crop-origin validation, crop/mask retention, layer adoption, automatic return to editing, protected exterior pixels and blend clear/undo. Invalid source replacement retains the previous usable image. Model sampling algorithms are unchanged; no new model-quality claim is based on these CPU queues.
- Browser resource checks recover a corrupt PNG, reload 25 times and overlap 30 cancellation attempts. At settled checkpoints exactly two preview object URLs remain, destruction releases all tracked URLs, and no unhandled promise rejection is observed. Network fault tests exhaust immediate retries, then recover on reconnect with byte progress and no duplicate history entries.
- Isolated CPU benchmark: a full 2048×4096 mask, four threads, four iterations per process averages 203.4 ms before and 14.6 ms after. Additional measured process peak working set is 521.0 MiB before versus 9.2 MiB after. This measures mask-bound computation only, not overall generation time or workflow frame rate.
- Scope: local fault injection is not a long-duration cloud soak test, and cannot establish compatibility with every third-party plugin/model combination. The public 2.0.0 Registry version remains flagged pending external review; this local audit does not establish Registry approval.
## 2.0.0 · 2026-11-09
- All 63 Python regression tests and 19 JavaScript test files pass. Frontend modules pass syntax checks; locale JSON, release metadata and local links in both READMEs validate.
+10 -6
View File
@@ -100,7 +100,7 @@ def _image_tensor(image):
value = torch.as_tensor(image)
if value.ndim != 4 or value.shape[-1] not in (3, 4) or min(value.shape[:3]) < 1:
raise ValueError("IMAGE must have shape [batch, height, width, 3 or 4].")
return torch.nan_to_num(value[..., :3].float(), nan=0.0, posinf=1.0, neginf=0.0).clamp(0.0, 1.0)
return torch.nan_to_num(value[..., :3].float(), nan=0.0, posinf=1.0, neginf=0.0).clamp_(0.0, 1.0)
def _mask_tensor(mask, images):
@@ -117,7 +117,7 @@ def _mask_tensor(mask, images):
value = value.expand(images.shape[0], -1, -1)
if value.shape[0] != images.shape[0]:
raise ValueError("Mask batch must contain one mask or match the image batch.")
value = torch.nan_to_num(value, nan=0.0, posinf=1.0, neginf=0.0).clamp(0, 1)
value = torch.nan_to_num(value, nan=0.0, posinf=1.0, neginf=0.0).clamp_(0, 1)
if value.shape[-2:] != images.shape[1:3]:
value = F.interpolate(value[:, None], size=images.shape[1:3], mode="bilinear", align_corners=False)[:, 0]
return value
@@ -143,11 +143,15 @@ def _feather(mask, grow, blur):
def _bbox(mask, padding, target_ratio):
height, width = mask.shape
nonzero = torch.nonzero(mask > 0, as_tuple=False)
if not len(nonzero):
occupied = mask > 0
rows = torch.where(occupied.any(dim=1))[0]
if not len(rows):
return (0, 0, width, height)
y0, x0 = nonzero.amin(0).tolist()
y1, x1 = (nonzero.amax(0) + 1).tolist()
# Dense masks previously allocated two int64 coordinates per painted pixel
# (512 MB for a 32 MP mask). Only row/column occupancy is needed for bounds.
columns = torch.where(occupied.any(dim=0))[0]
y0, y1 = int(rows[0]), int(rows[-1]) + 1
x0, x1 = int(columns[0]), int(columns[-1]) + 1
x0, y0 = max(0, x0 - padding), max(0, y0 - padding)
x1, y1 = min(width, x1 + padding), min(height, y1 + padding)
crop_width, crop_height = x1 - x0, y1 - y0
+5 -2
View File
@@ -16,8 +16,9 @@ def _limit():
class PreviewStore:
def __init__(self, limit=None):
def __init__(self, limit=None, max_batches=512):
self.limit = _limit() if limit is None else limit
self.max_batches = max(1, int(max_batches))
self.nonce = secrets.token_hex(12)
self.epoch = 0
self.size = 0
@@ -32,6 +33,8 @@ class PreviewStore:
def put_batch(self, images, owner):
"""Atomically retain a whole comparison batch, or explicitly reject it."""
if not images or any(not image for image in images):
raise ValueError("The comparison batch must contain nonempty images.")
total = sum(len(image) for image in images)
if total > self.limit:
raise ValueError("The comparison batch exceeds the in-memory preview limit. Reduce batch size or increase ADACHI_COMPARE_MEMORY_MIB (64–4096 MiB).")
@@ -52,7 +55,7 @@ class PreviewStore:
self.groups.move_to_end(last[0])
self.latest[owner] = (last[0], before)
return list(group["descriptors"])
while self.groups and self.size + total > self.limit:
while self.groups and (self.size + total > self.limit or len(self.groups) >= self.max_batches):
group_id, group = self.groups.popitem(last=False)
self.size -= group["size"]
for key in group["keys"]:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "adachi-inpaint-workbench"
version = "2.0.0"
version = "2.0.1"
description = "Inline mask painting, crop blending and a standalone inpainting workbench for ComfyUI"
requires-python = ">=3.10"
license = { file = "LICENSE" }
+2 -2
View File
@@ -20,6 +20,6 @@ Per the [official publishing guide](https://docs.comfy.org/registry/publishing),
The publishing workflow is manual-only. Check its result and the Registry version status after publishing.
A Registry version cannot be overwritten once published, so later releases have to bump the version in `pyproject.toml`.
## 2.0.0 verification
## 2.0.1 verification
After publishing 2.0.0, check both the Actions result and the new version's status in the [public Registry API](https://api.comfy.org/nodes/adachi-inpaint-workbench/versions?include_status_reason=true). A successful publishing job confirms upload, not Registry approval or Manager availability. Previous versions received automated scan flags; [scan review notes](SECURITY-REVIEW.md) explain the public findings and the checks required for this release. Do not report the version as approved while its status is `Pending` or `Flagged`.
After publishing 2.0.1, check both the Actions result and the new version's status in the [public Registry API](https://api.comfy.org/nodes/adachi-inpaint-workbench/versions?include_status_reason=true). A successful publishing job confirms upload, not Registry approval or Manager availability. Previous versions received automated scan flags; [scan review notes](SECURITY-REVIEW.md) explain the public findings and the checks required for this release. Do not report the version as approved while its status is `Pending` or `Flagged`.
+2
View File
@@ -4,6 +4,8 @@ Publication and Registry approval are separate steps. Check the [public version
## Previous scan findings
On 2026-11-10, **2.0.0** is also `NodeVersionStatusFlagged`, with extraction `pending`. Its six `info` records match the configuration read, `Function.bind()` in `web/compare_node.js`, `web/inline_crop.js`, `web/result_recovery.js` and `web/workspace_ui.js`, and the upload send in `web/upload_transport.js`. The same code-purpose explanations below apply; no official approval is claimed.
On 2026-11-09, versions **1.0.0** and **1.0.1** were `NodeVersionStatusFlagged`, with `comfy_node_extract_status: pending`. Their public scan reasons contained the following three `info` findings. Line numbers refer to those releases.
| File / line | Reported rule | Purpose and assessment |
+34
View File
@@ -0,0 +1,34 @@
"""Image-edge and dense-mask equivalence for the bounded-memory preparation path."""
import unittest
import torch
from test_backend import backend
class MaskBoundaryTests(unittest.TestCase):
def test_dense_sparse_empty_and_skinny_mask_bounds(self):
for height, width in [(1, 1), (1, 79), (81, 1), (192, 256)]:
for kind in ('empty', 'full', 'corners', 'middle'):
mask = torch.zeros(height, width)
if kind == 'full': mask.fill_(1)
elif kind == 'corners': mask[0, 0] = mask[-1, -1] = .01
elif kind == 'middle': mask[height//2, width//2] = .7
for padding in (0, 7, 2048):
box = backend._bbox(mask, padding, width/height)
x0, y0, x1, y1 = box
self.assertTrue(0 <= x0 < x1 <= width)
self.assertTrue(0 <= y0 < y1 <= height)
self.assertEqual(float(mask.sum()), float(mask[y0:y1, x0:x1].sum()))
if kind in ('full', 'corners', 'empty'):
self.assertEqual(box, (0, 0, width, height))
def test_sanitizing_never_mutates_upstream_tensors(self):
image = torch.tensor([float('nan'), float('inf'), -1.0, 2.0]*3).reshape(1, 2, 2, 3)
original = image.clone()
output = backend._image_tensor(image)
self.assertTrue(torch.equal(torch.isnan(original), torch.isnan(image)))
self.assertTrue(torch.equal(original[~torch.isnan(original)], image[~torch.isnan(image)]))
self.assertTrue(torch.isfinite(output).all())
self.assertTrue(((output >= 0) & (output <= 1)).all())
mask = torch.full((1, 2, 2), 2.0)
self.assertEqual(float(backend._mask_tensor(mask, output).max()), 1)
self.assertEqual(float(mask.min()), 2)
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import {decodePreview,loadPreviewPair} from '../web/preview_images.js';
const deferred=()=>{let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b;});return{promise,resolve,reject};};
const blob=new Blob(['PNG']);
let allocated=0;const revoked=[];
const urls={createObjectURL(){return 'blob:'+ ++allocated;},revokeObjectURL:url=>revoked.push(url)};
class ImageStub{width=512;height=512;decode(){return Promise.resolve();}}
const canceled=new AbortController();canceled.abort();
await assert.rejects(decodePreview(blob,canceled.signal,{urls,ImageClass:ImageStub}),{name:'AbortError'});
assert.equal(allocated,0,'already-canceled decoding never allocates a URL');
class Stalled extends ImageStub{decode(){return new Promise(()=>{});}}
await assert.rejects(decodePreview(blob,new AbortController().signal,{urls,ImageClass:Stalled,timeoutMs:5}),/timed out/);
assert.deepEqual(revoked,['blob:1'],'a stalled decode settles and releases the URL');
class Huge extends ImageStub{width=20000;}
await assert.rejects(decodePreview(blob,new AbortController().signal,{urls,ImageClass:Huge}),/dimensions/);
class Borderline extends ImageStub{width=5500;height=6000;}
await assert.rejects(decodePreview(blob,new AbortController().signal,{urls,ImageClass:Borderline}),/dimensions/);
const abort=new AbortController(),decode=decodePreview(blob,abort.signal,{urls,ImageClass:Stalled});abort.abort();
await assert.rejects(decode,{name:'AbortError'});
assert.equal(revoked.length,4);
// A sibling may finish after the first failure, even if an integration ignores
// cancellation. Wait for it and release its late URL before returning.
const later=deferred(),lost=[];let decodes=0;
const pending=loadPreviewPair(['a','b'],{cached:[null,null],signal:new AbortController().signal,
loadPreview:async()=>blob,decode:async()=>{if(++decodes===1)throw Error('corrupt PNG');return later.promise;},revoke:url=>lost.push(url)});
await new Promise(r=>setTimeout(r,0));later.resolve({url:'late-url'});
await assert.rejects(pending,/corrupt PNG/);assert.deepEqual(lost,['late-url']);
const bad=new Blob(['bad']),good=new Blob(['good']),cache=[bad,good];let reads=0;
const options={cached:cache,signal:new AbortController().signal,loadPreview:async()=>{reads++;return good;},
decode:async b=>{if(b===bad)throw Error('invalid pixels');return{url:'good-url'};},revoke:()=>{}};
await assert.rejects(loadPreviewPair(['a','b'],options),/invalid pixels/);
assert.equal(cache[0],null,'invalid downloaded bytes cannot poison every future retry');
await loadPreviewPair(['a','b'],options);assert.equal(reads,1,'retry fetches the corrupt half again and keeps the valid half');
console.log('Preview decoding: pre-cancel, decode timeout, dimension limits, late sibling URL cleanup and corrupt-cache recovery pass');
+17
View File
@@ -7,6 +7,23 @@ def key(descriptor):
class PreviewStoreTests(unittest.TestCase):
def test_tiny_batches_have_a_metadata_budget_and_empty_batches_are_atomic(self):
store = PreviewStore(1024 * 1024, max_batches=2)
first = store.put_batch([b"a"], "first")
second = store.put_batch([b"b"], "second")
store.get(key(first[0]))
generation = store.fingerprint()
with self.assertRaises(ValueError):
store.put_batch([], "empty")
with self.assertRaises(ValueError):
store.put_batch([b""], "empty")
self.assertEqual(store.fingerprint(), generation)
store.put_batch([b"c"], "third")
self.assertEqual(store.get(key(first[0])), b"a")
self.assertIsNone(store.get(key(second[0])))
self.assertEqual(len(store.groups), 2)
self.assertEqual(store.size, 2)
def test_whole_batch_lru_and_cache_generation(self):
store = PreviewStore(10)
first = store.put_batch([b"aaa", b"bbb"], "1")
+6
View File
@@ -15,3 +15,9 @@ const pending=readPreview(stalled,'/cancel',{signal:cancel.signal});cancel.abort
// A long transfer is allowed while actual data continues to arrive.
let ticks=0;await readPreview(async(_,options)=>new Response(new ReadableStream({start(c){const t=setInterval(()=>{if(++ticks===5){clearInterval(t);c.close();}else c.enqueue(bytes);},5);options.signal.addEventListener('abort',()=>{clearInterval(t);c.error(Error('timed out'));});}})),'/slow',{idleMs:20});assert.equal(ticks,5);
console.log('Preview transport: streamed byte progress, unknown length, truncation, expiry, size limit, inactivity timeout, cancellation and slow advancing transfer pass');
let requests=0;
await assert.rejects(readPreview(async()=>{requests++;return new Response(bytes);},'/never-start',{signal:AbortSignal.abort()}),{name:'AbortError'});
assert.equal(requests,0);
let stopped=false;
await assert.rejects(readPreview(async()=>new Response(new ReadableStream({cancel(){stopped=true;}})),'/uncooperative-wrapper',{idleMs:5}),/timed out/);
assert.equal(stopped,true,'cancel the response reader even when a custom fetch wrapper ignores its signal');
+10
View File
@@ -20,3 +20,13 @@ let nestedResult;
const sub=new ResultRecovery({getGraph:()=>root,fetchApi:async()=>({ok:true,json:async()=>({nestedTask:{status:{completed:true,status_str:'success'},outputs:{'5:8':{adachi_originals:[{}],adachi_results:[{}]}}}})}),onOutput:n=>{nestedResult=n;}});
try{sub.track(root,'nestedTask','5:8');await sub.recover();assert.equal(nestedResult,nested);}finally{sub.destroy();}
console.log('Result recovery: native subgraph execution IDs resolve to the queued live comparison');
{
let resolve,signal,discarded=0,delivered=0;
const pending=new ResultRecovery({getGraph:()=>graph,onOutput:()=>delivered++,onDiscard:()=>discarded++,
fetchApi:async(_path,options)=>{signal=options.signal;return new Promise(r=>resolve=r);}});
pending.track(graph,'late','3');const task=pending.recover();pending.destroy();
assert.equal(signal.aborted,true);assert.equal(discarded,1);
resolve({ok:true,json:async()=>({late:{status:{completed:true},outputs:{'3':{adachi_originals:[{}],adachi_results:[{}]}}}})});
await task;assert.equal(delivered,0,'destroyed recovery cannot deliver a late response');
pending.track(graph,'new','3');assert.equal(pending.pending.size,0);assert.equal(pending.controllers.size,0);
}
+24
View File
@@ -42,3 +42,27 @@ assert.equal(phases.at(-1).phase, "processing");
status = 500;
await assert.rejects(uploadImageWithProgress(api, body), /500/);
console.log("Upload transport: real byte progress, proxy URL, multi-user header, auth fallback and server error passed");
let aborted=0;
globalThis.XMLHttpRequest=class {
constructor(){this.upload={};}open(){}setRequestHeader(){}send(){}abort(){aborted++;this.onabort?.();}
};
await assert.rejects(uploadImageWithProgress(api,body,null,{idleMs:5}),/timed out/);
assert.equal(aborted,1);
const cancel=new AbortController(),pending=uploadImageWithProgress(api,body,null,{signal:cancel.signal});cancel.abort();
await assert.rejects(pending,{name:'AbortError'});assert.equal(aborted,2);
// Overall duration may exceed the inactivity limit as long as real bytes move.
globalThis.XMLHttpRequest=class {
constructor(){this.upload={};}open(){}setRequestHeader(){}
send(){let n=0;this.timer=setInterval(()=>{this.upload.onprogress?.({loaded:++n,total:6,lengthComputable:true});if(n===6){clearInterval(this.timer);this.status=200;this.responseText='{"name":"slow.png"}';this.onload();}},3);}
abort(){clearInterval(this.timer);this.onabort?.();}
};
assert.equal((await uploadImageWithProgress(api,body,null,{idleMs:12})).name,'slow.png');
console.log('Upload transport: inactivity timeout, cancellation and slow advancing uploads pass');
globalThis.XMLHttpRequest=class {
constructor(){this.upload={};}open(){}setRequestHeader(){}
send(){this.status=403;this.onload();}abort(){this.onabort?.();}
};
let fallbackSignal;
const stalledAuth={...api,fetchApi:async(_path,options)=>{fallbackSignal=options.signal;return new Promise((_,reject)=>options.signal.addEventListener('abort',()=>reject(new DOMException('Aborted','AbortError')),{once:true}));}};
await assert.rejects(uploadImageWithProgress(stalledAuth,body,null,{idleMs:5}),/timed out/);
assert.equal(fallbackSignal.aborted,true,'the authenticated fallback also has a cancellation deadline');
+17
View File
@@ -58,6 +58,23 @@ class WorkspaceTests(unittest.IsolatedAsyncioTestCase):
with self.assertRaisesRegex(ValueError, "missing node"):
workspace.dependency_prompt(prompt, "3")
def test_malformed_node_inputs_are_a_validation_error(self):
for value in ([], None, "invalid", 42):
prompt = self.prompt()
prompt["2"]["inputs"] = value
with self.assertRaisesRegex(ValueError, "Invalid input mapping"):
workspace.dependency_prompt(prompt, "3")
async def test_bad_inputs_never_reach_core_replacement_hooks(self):
def unexpected(_prompt):
self.fail("Malformed inputs reached a core hook")
server = types.SimpleNamespace(trigger_on_prompt=lambda data: data,
node_replace_manager=types.SimpleNamespace(apply_replacements=unexpected))
prompt = self.prompt()
prompt["2"]["inputs"] = None
with self.assertRaisesRegex(ValueError, "Invalid input mapping"):
await workspace.prepare_workspace(server, types.SimpleNamespace(), {"prompt":prompt, "compare_id":"3"})
async def test_validation_error_is_returned_without_queueing(self):
def validate(prompt):
return False, {"message": "missing checkpoint"}, [], {"1": {"errors": []}}
+12
View File
@@ -56,3 +56,15 @@ function node() {
n.adachiEditor.stroke={};assert.throws(()=>h.checkpoint("stroke"),/Finish the current edit/);
}
console.log("Workspace source history: repaint/blend pixels, crop state, layer, commands, failure atomicity and retention passed");
{
const n=node(),before=captureSourceState(n),created=[];
let count=0;
await assert.rejects(restoreSourceState(n,before,{decode:async data=>{if(++count===2)throw Error('bad base');const c=await decode(data);created.push(c);return c;}}),/bad base/);
assert.equal(created[0].width,0,'a partial decode failure frees already allocated masks');
assert.equal(n.adachiEditor.image,before.image);
for(const change of [()=>n.adachiEditor.maskRevision++,()=>n.adachiCrop.revision++]){
await assert.rejects(restoreSourceState(n,before,{decode:async data=>{change();return decode(data);}}),/image changed/);
assert.equal(n.adachiEditor.image,before.image,'a completed stroke or crop change during decoding is preserved');
}
await assert.rejects(restoreSourceState(n,before,{decode:async data=>{n.graph=null;return decode(data);}}),/image changed/);
}
+16 -26
View File
@@ -1,20 +1,12 @@
/* Original hover-comparison UI, MIT licensed. */
import { T } from "./i18n.js";
import {loadPreviewPair} from './preview_images.js';
const element = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text) node.textContent = text;
return node;
};
const decodePreview = async (blob, signal) => {
const url=URL.createObjectURL(blob), image=new Image();
const abort=()=>{image.src="";};
signal.addEventListener("abort",abort,{once:true});
const timer=setTimeout(abort,20000);
try { image.src=url;await image.decode();if(signal.aborted)throw new DOMException("Cancelled","AbortError");return {image,url}; }
catch(error){URL.revokeObjectURL(url);throw error;}
finally{clearTimeout(timer);signal.removeEventListener("abort",abort);}
};
export class ImageCompareView {
constructor(options) {
@@ -114,8 +106,9 @@ export class ImageCompareView {
if (!this.latestResult || this.downloading) return;
const result = this.latestResult;
this.downloading = true; this.updateButton();
const controller=this.downloadController=new AbortController();
try {
const blob = await this.options.fetchResult(result);
const blob = await this.options.fetchResult(result,{signal:controller.signal});
if (this.destroyed) return;
const url = URL.createObjectURL(blob), link = document.createElement("a");
link.href = url; link.download = "adachi-inpaint-result.png";
@@ -123,7 +116,7 @@ export class ImageCompareView {
setTimeout(() => URL.revokeObjectURL(url), 60000);
this.status("");
} catch (error) { if (!this.destroyed) this.status(error.message, true); }
finally { this.downloading = false; if (!this.destroyed) this.updateButton(); }
finally {if(this.downloadController===controller)this.downloadController=null;this.downloading = false; if (!this.destroyed) this.updateButton();}
}
async setData(data, index = this.index) {
@@ -151,6 +144,7 @@ export class ImageCompareView {
}
async loadSelected({background=false}={}) {
if(this.destroyed)return;
clearTimeout(this.retryTimer);this.loadController?.abort();
const version=++this.version,controller=this.loadController=new AbortController();
this.reloadButton.disabled=false;this.loadingActive=true;
@@ -165,24 +159,18 @@ export class ImageCompareView {
try {
let images;
for(let attempt=0;attempt<3;attempt++) {
const states=blobs.map(blob=>({loaded:blob?.size||0,total:blob?.size||0,complete:!!blob})),decoded=[];
const states=blobs.map(blob=>({loaded:blob?.size||0,total:blob?.size||0,complete:!!blob}));
this.loading(attempt?T("Retrying comparison images")+` (${attempt+1}/3)`:T("Loading comparison images"),states);
const pair=new AbortController();const cancel=()=>pair.abort();controller.signal.addEventListener("abort",cancel,{once:true});
try {
images=await Promise.all([original,this.results[this.index]].map(async (ref,i)=>{
const blob=blobs[i] || await this.options.loadPreview(ref,{signal:pair.signal,onProgress:state=>{states[i]=state;if(current() && !pair.signal.aborted)this.loading(T("Loading comparison images"),states);}});
blobs[i]=blob;
if(current() && states.every(s=>s.complete))this.loading(T("Decoding comparison images…"));
if(!current())throw new DOMException("Cancelled","AbortError");
const result=await decodePreview(blob,pair.signal);decoded.push(result);return result;
}));
images=await loadPreviewPair([original,this.results[this.index]],{loadPreview:(...args)=>this.options.loadPreview(...args),cached:blobs,signal:controller.signal,
onProgress:(i,state)=>{states[i]=state;if(current())this.loading(T("Loading comparison images"),states);},
onDecoding:()=>{if(current() && states.every(s=>s.complete))this.loading(T("Decoding comparison images…"));}});
break;
} catch(error) {
pair.abort();for(const item of decoded)URL.revokeObjectURL(item.url);
if(!current())return;
if(attempt===2)throw error;
await new Promise(resolve=>{const timer=setTimeout(resolve,1000*(attempt+1));controller.signal.addEventListener("abort",()=>{clearTimeout(timer);resolve();},{once:true});});
} finally {controller.signal.removeEventListener("abort",cancel);}
if(attempt===2 || [404,410].includes(error.status))throw error;
await new Promise(resolve=>{const done=()=>{clearTimeout(timer);controller.signal.removeEventListener('abort',done);resolve();};const timer=setTimeout(done,1000*(attempt+1));controller.signal.addEventListener('abort',done,{once:true});});
}
}
if(!current()){for(const item of images||[])URL.revokeObjectURL(item.url);return;}
for(const url of this.previewUrls||[])URL.revokeObjectURL(url);
@@ -208,11 +196,12 @@ export class ImageCompareView {
if (!this.targetSelect.value) { this.status(T("Select an inline mask to adopt into"), true); return; }
const target = this.targetSelect.value, result = this.results[this.index];
this.busy = true; this.updateButton(); this.status(T("Setting the result as the new source image…"));
const controller=this.adoptController=new AbortController();
try {
await this.options.onAdopt(target, result);
await this.options.onAdopt(target, result,{signal:controller.signal});
if (!this.destroyed) this.status(T("Adopted: back in the inline mask, keep painting to inpaint again"));
} catch (error) { if (!this.destroyed) this.status(error.message, true); }
finally { this.busy = false; if (!this.destroyed) this.updateButton(); }
finally {if(this.adoptController===controller)this.adoptController=null;this.busy = false; if (!this.destroyed) this.updateButton();}
}
transform() {
@@ -281,6 +270,7 @@ export class ImageCompareView {
destroy() {
this.destroyed = true; ++this.version; clearTimeout(this.retryTimer);this.loadController?.abort();
this.downloadController?.abort();this.adoptController?.abort();
window.removeEventListener("online",this.retryOnOnline);for(const url of this.previewUrls||[])URL.revokeObjectURL(url);
cancelAnimationFrame(this.frame); this.observer.disconnect();
this.pendingBlobs=null;this.before = this.after = null; this.canvas.width = this.canvas.height = 0; this.root.remove();
+10 -12
View File
@@ -1,6 +1,6 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { ImageCompareView } from "./compare.js?v=workspace-network2";
import { ImageCompareView } from "./compare.js?v=stability1";
import { readPreview } from "./preview_transport.js";
import { installComparisonRecovery } from "./result_recovery.js";
import {executionId,executionNode,graphNodeEntries} from "./workspace_flow.js";
@@ -34,7 +34,7 @@ app.registerExtension({
nodeType.prototype.onNodeCreated = function () {
const value = created?.apply(this, arguments), node = this;
const recovery=installComparisonRecovery(api,()=>app.rootGraph||app.graph);
node.adachiTrackPrompt=id=>{const graph=app.rootGraph||app.graph;return recovery.track(graph,id,executionId(graph,node));};
node.adachiTrackPrompt=(id,graph=app.rootGraph||app.graph)=>recovery.track(graph,id,executionId(graph,node));
const compare = new ImageCompareView({
resolveUrl: viewUrl,
loadPreview: (image,options)=>readPreview(api.fetchApi.bind(api),viewPath(image),options),
@@ -44,19 +44,17 @@ app.registerExtension({
onGraphWheel: event => app.canvas?.processMouseWheel(event),
getTargets: () => targets(node),
onSelect: index => { node.properties.adachi_result_index = index; },
async fetchResult(image) {
const response = await api.fetchApi(viewPath(image));
if (!response.ok) throw new Error(T("Result preview expired; run the workflow again"));
return response.blob();
async fetchResult(image, options) {
return readPreview(api.fetchApi.bind(api),viewPath(image),options);
},
async onAdopt(id, image) {
const target = executionNode(app.rootGraph||app.graph,id);
async onAdopt(id, image, options={}) {
const graph=app.rootGraph||app.graph,target = executionNode(graph,id);
if (!target?.adachiUseResult) throw new Error(T("No inline mask node is available to receive the result"));
const response = await api.fetchApi(viewPath(image));
if (!response.ok) throw new Error(T("Result preview expired; run the workflow again"));
const blob = await response.blob();
const blob = await readPreview(api.fetchApi.bind(api),viewPath(image),{...options,maxBytes:64*1024*1024});
options.signal?.throwIfAborted();
if(graph!==(app.rootGraph||app.graph)||target!==executionNode(graph,id))throw Error(T("The workflow changed; check connections again"));
if (blob.size > 64 * 1024 * 1024) throw new Error(T("Result image exceeds 64 MiB; downscale it first"));
await target.adachiUseResult(new File([blob], `adachi-result-${Date.now()}.png`, { type: "image/png" }), image.adachi_origin);
await target.adachiUseResult(new File([blob], `adachi-result-${Date.now()}.png`, { type: "image/png" }), image.adachi_origin, options);
// Adoption starts a new round. Keep the accepted image visible
// as 1/1, then replace it with the next round's first outputs.
const data = { originals: [image], results: [image] };
+8 -3
View File
@@ -249,6 +249,8 @@ export class InlineMaskEditor {
async upload(file) {
if (file.size > 64 * 1024 * 1024) { this.status(T("File is too large (max 64 MB)"), true); return; }
this.uploadController?.abort();
const controller=this.uploadController=new AbortController();
const version = this.uploadUiVersion = (this.uploadUiVersion || 0) + 1;
const progress = ({phase, loaded = 0, total = 0}) => {
if (version !== this.uploadUiVersion || this.destroyed) return;
@@ -267,10 +269,11 @@ export class InlineMaskEditor {
this.status(T("Loading image…"));
// Keep the painted mask when a new source replaces the current one,
// the same way adopting a result does. A first upload starts empty.
await this.options.onUpload?.(file, { preserveMask: !!this.image, onProgress: progress });
await this.options.onUpload?.(file, { preserveMask: !!this.image, onProgress: progress, signal:controller.signal });
} catch (error) {
if (version === this.uploadUiVersion && !this.destroyed) this.status(error.message, true);
} finally {
if(this.uploadController===controller)this.uploadController=null;
if (version === this.uploadUiVersion && !this.destroyed) this.uploadIndicator.hidden = true;
}
}
@@ -278,11 +281,12 @@ export class InlineMaskEditor {
async setImage(url, maskData = "", name = "") {
this.stroke = null; this.pointerId = null;
const version = ++this.loadVersion;
const wasReady=this.ready;
this.ready = false;
try {
const [image, loadedMask] = await Promise.all([loadImage(url), maskData ? loadImage(maskData) : Promise.resolve(null)]);
if (version !== this.loadVersion || this.destroyed) return;
if (image.width * image.height > 32 * 1024 * 1024 || Math.max(image.width, image.height) > 16384) {
if (image.width * image.height > 32_000_000 || Math.max(image.width, image.height) > 16384) {
throw new Error(T("Image is too large; keep it under 32 MP and 16384 px per side"));
}
this.image = image;
@@ -316,7 +320,7 @@ export class InlineMaskEditor {
this.options.onImage?.(image);
this.status(T("Orange is the painted mask · Alt = temporary erase · Ctrl+wheel = brush size"));
} catch (error) {
if (version === this.loadVersion && !this.destroyed) this.status(error.message, true);
if (version === this.loadVersion && !this.destroyed) {this.ready=!!this.image && wasReady;this.updateButtons();this.schedule();this.status(error.message, true);}
throw error;
}
}
@@ -718,6 +722,7 @@ export class InlineMaskEditor {
}
destroy() {
this.uploadController?.abort();++this.uploadUiVersion;
this.destroyed = true; ++this.loadVersion;
cancelAnimationFrame(this.frame); this.observer.disconnect();
this.stopWheelCapture();
+1
View File
@@ -6,6 +6,7 @@
// buttons are drawn by this plugin, so they translate here by reading Comfy.Locale.
const ZH = {
"Image upload timed out; check the connection and try again": "图片上传超时,请检查连接后重试",
"Load a source image first": "请先载入原图",
"Finish the current edit first": "请先结束当前编辑",
"The source image node was removed": "原图节点已移除",
+3 -1
View File
@@ -1,7 +1,7 @@
import { CROP_PRESETS, cropAt } from "./crop_geometry.js?v=0.6.2";
import { T } from "./i18n.js";
import { syncCropDimensions, syncCurrentDimensions } from "./parameter_bridge.js?v=0.8.2";
import { installResultAdoption } from "./result_adoption.js?v=0.7.2";
import { installResultAdoption } from "./result_adoption.js?v=stability1";
const fields = ["mask", "baseMask", "history", "future", "lastMask", "dirty"];
const canvas = (w, h) =>
@@ -646,6 +646,7 @@ export class InlineCropController {
this.layer = null;
this.widgets.layer_image.value = "";
this.widgets.blend_data.value = "";
for(const key of ['mask','baseMask']){const raster=this.contexts.blend?.[key];if(raster && raster!==e.mask && raster!==e.baseMask)raster.width=raster.height=0;}
this.contexts.blend = null;
this.state({ ...this.region, job: crypto.randomUUID() });
} finally {
@@ -670,6 +671,7 @@ export class InlineCropController {
this.changed();
}
destroy() {
this.adoptController?.abort();
this.stopOriginalPreview();
window.removeEventListener("blur", this.previewBlur);
for (const name of ["pointerup", "pointercancel"]) {
+10 -15
View File
@@ -1,14 +1,14 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { InlineMaskEditor } from "./editor.js?v=0.9.0";
import { InlineMaskEditor } from "./editor.js?v=stability1";
import { installInpaintWorkbench } from "./inpaint_node.js?v=2.0.0";
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
import { routeWheelOutsideImage } from "./wheel_routing.js";
import { InlineCropController } from "./inline_crop.js?v=0.8.2";
import { InlineCropController } from "./inline_crop.js?v=stability1";
import { canStoreDraft, prepareInlineDraft, syncInlineWidgets } from "./draft_guard.js?v=0.6.3";
import { T } from "./i18n.js";
import { removeLegacyFeedback } from "./legacy_feedback.js?v=0.6.4";
import { uploadImageWithProgress } from "./upload_transport.js?v=0.9.0";
import { uploadImageWithProgress } from "./upload_transport.js?v=stability1";
const styleId = "adachi-inpaint-style";
const stylesheet = document.getElementById(styleId) || document.createElement("link");
@@ -132,22 +132,17 @@ app.registerExtension({
onKeepFit(enabled) { node.properties.adachi_keep_fit = enabled; markChanged(); },
onImage() { if (!restoring || !node.properties.adachi_compact_ui) fitNodeToImage(); },
onGraphWheel: event => app.canvas?.processMouseWheel(event),
async onUpload(file, { preserveMask = false, preserveCrop = false, onProgress = null } = {}) {
async onUpload(file, { preserveMask = false, preserveCrop = false, onProgress = null, signal } = {}) {
if (preserveMask && editor.image && !editor.ready) throw new Error(T("Wait for the current image to finish loading"));
const version = ++uploadVersion;
const body = new FormData();
body.append("image", file, file.name);
body.append("type", "input");
body.append("subfolder", "adachi-inpaint");
let uploaded;
if (onProgress) uploaded = await uploadImageWithProgress(api, body, onProgress);
else {
const response = await api.fetchApi("/upload/image", { method: "POST", body });
if (!response.ok) throw new Error(`${T("Upload failed")} (${response.status})`);
uploaded = await response.json();
}
const uploaded = await uploadImageWithProgress(api, body, onProgress, {signal});
if (typeof uploaded?.name !== "string" || !uploaded.name) throw new Error(T("Upload failed"));
if (version !== uploadVersion || editor.destroyed) return;
signal?.throwIfAborted();
const name = uploaded.subfolder ? `${uploaded.subfolder}/${uploaded.name}` : uploaded.name;
restoring = false;
crop?.activate("mask");
@@ -204,10 +199,10 @@ app.registerExtension({
node.adachiCrop = crop;
node.adachiEditor = editor;
const stopWheelRouting = routeWheelOutsideImage(editor.root, editor.canvas, () => editor.ready);
node.adachiUseResult = async (file, origin) => {
node.adachiUseResult = async (file, origin, options={}) => {
if (editor.destroyed) throw new Error(T("The inline mask node was removed"));
if (crop.mode === "crop") return node.adachiAdoptCrop(file, origin);
await editor.options.onUpload(file, { preserveMask: true });
if (crop.mode === "crop") return node.adachiAdoptCrop(file, origin, options);
await editor.options.onUpload(file, { preserveMask: true, signal:options.signal });
editor.view = "overlay"; editor.updateButtons(); editor.schedule();
editor.status(T("Adopted the result as the new source image; the mask and undo history were preserved"));
};
@@ -286,7 +281,7 @@ app.registerExtension({
const previousImageCallback = imageWidget.callback;
imageWidget.callback = function () { previousImageCallback?.apply(this, arguments); restore(); };
const previousRemoved = node.onRemoved;
node.onRemoved = function () { clearTimeout(draftNoticeTimer); cancelAnimationFrame(restoreFrame); cancelAnimationFrame(fitFrame); stopWheelRouting(); crop.destroy(); editor.destroy(); return previousRemoved?.apply(this, arguments); };
node.onRemoved = function () { node.adachiWorkspaceHistory?.clear();node.adachiWorkspaceHistory=null;clearTimeout(draftNoticeTimer); cancelAnimationFrame(restoreFrame); cancelAnimationFrame(fitFrame); stopWheelRouting(); crop.destroy(); editor.destroy(); return previousRemoved?.apply(this, arguments); };
node.setSize([Math.max(380, node.size[0]), Math.max(480, node.size[1])]);
restoreFrame = requestAnimationFrame(restore);
return result;
+44
View File
@@ -0,0 +1,44 @@
// A failed half of a comparison must not leave its sibling decoding in the
// background, retain a corrupt blob forever, or leak a late object URL.
export async function decodePreview(blob, signal, {ImageClass=globalThis.Image, urls=URL, timeoutMs=20000}={}) {
signal?.throwIfAborted();
const url=urls.createObjectURL(blob), image=new ImageClass();
let timer, abort;
const stopped=new Promise((_,reject)=>{
abort=()=>{image.src='';reject(new DOMException('Cancelled','AbortError'));};
signal?.addEventListener('abort',abort,{once:true});
timer=setTimeout(()=>{image.src='';reject(Error('Preview image decoding timed out'));},timeoutMs);
});
try {
image.src=url;
await Promise.race([image.decode(),stopped]);
signal?.throwIfAborted();
const width=image.naturalWidth||image.width,height=image.naturalHeight||image.height;
if(!width || !height || width*height>32_000_000 || Math.max(width,height)>16384)throw Error('Preview image exceeds supported dimensions');
return {image,url};
} catch(error) {urls.revokeObjectURL(url);throw error;}
finally {clearTimeout(timer);signal?.removeEventListener('abort',abort);}
}
export async function loadPreviewPair(refs,{loadPreview,cached,signal,onProgress,onDecoding,decode=decodePreview,revoke=url=>URL.revokeObjectURL(url)}) {
signal.throwIfAborted();
const pair=new AbortController(),abort=()=>pair.abort();
signal.addEventListener('abort',abort,{once:true});
let failure;
try {
const results=await Promise.allSettled(refs.map(async(ref,index)=>{
try {
const blob=cached[index]||await loadPreview(ref,{signal:pair.signal,onProgress:state=>{if(!pair.signal.aborted)onProgress?.(index,state);}});
pair.signal.throwIfAborted();cached[index]=blob;
onDecoding?.();
try {return await decode(blob,pair.signal);}
catch(error){if(error.name!=='AbortError')cached[index]=null;throw error;}
} catch(error) {failure??=error;pair.abort();throw error;}
}));
if(failure || signal.aborted) {
for(const result of results)if(result.status==='fulfilled')revoke(result.value.url);
signal.throwIfAborted();throw failure;
}
return results.map(result=>result.value);
} finally {signal.removeEventListener('abort',abort);}
}
+10 -2
View File
@@ -6,20 +6,27 @@ export async function readPreview(fetchApi, path, {signal, onProgress, idleMs=30
const reset=()=>{clearTimeout(timer);timer=setTimeout(()=>{timedOut=true;controller.abort();},idleMs);};
if(signal?.aborted)controller.abort();else signal?.addEventListener('abort',abort,{once:true});
try {
controller.signal.throwIfAborted();
reset();
const response=await fetchApi(path,{signal:controller.signal,cache:'no-store'});
if(!response.ok)throw Object.assign(new Error(`Preview request failed (${response.status})`),{status:response.status});
const encoded=response.headers?.get('content-encoding');
const total=encoded && encoded!=='identity'?0:Number(response.headers?.get('content-length'))||0;
const length=Number(response.headers?.get('content-length'));
const total=encoded && encoded!=='identity'?0:Number.isFinite(length)&&length>0?length:0;
if(total>maxBytes)throw Error('Preview exceeds the image size limit');
onProgress?.({loaded:0,total});
let blob;
if(response.body?.getReader) {
const reader=response.body.getReader(),chunks=[];let loaded=0;
const cancelRead=()=>{reader.cancel().catch(()=>{});};
controller.signal.addEventListener('abort',cancelRead,{once:true});
try {
while(true) {
controller.signal.throwIfAborted();
const {done,value}=await reader.read();
controller.signal.throwIfAborted();
if(done)break;
if(!value.byteLength)continue;
reset();loaded+=value.byteLength;
if(loaded>maxBytes)throw Error('Preview exceeds the image size limit');
chunks.push(value);onProgress?.({loaded,total});
@@ -28,9 +35,10 @@ export async function readPreview(fetchApi, path, {signal, onProgress, idleMs=30
blob=new Blob(chunks,{type:response.headers?.get('content-type')||'image/png'});
onProgress?.({loaded,total:loaded,complete:true});
} catch(error) {await reader.cancel().catch(()=>{});throw error;}
finally {reader.releaseLock();}
finally {controller.signal.removeEventListener('abort',cancelRead);reader.releaseLock();}
} else {
blob=await response.blob();
controller.signal.throwIfAborted();
if(blob.size>maxBytes)throw Error('Preview exceeds the image size limit');
onProgress?.({loaded:blob.size,total:blob.size,complete:true});
}
+8 -4
View File
@@ -2,6 +2,7 @@ import { api } from "../../scripts/api.js";
import { app } from "../../scripts/app.js";
import { executionId } from "./workspace_flow.js";
import { T } from "./i18n.js";
import {uploadImageWithProgress} from './upload_transport.js?v=stability1';
export function installResultAdoption(node, crop) {
// Saved workflows may still contain the old feedback socket and wire.
@@ -16,7 +17,7 @@ export function installResultAdoption(node, crop) {
removeLegacyInput();
return result;
};
node.adachiAdoptCrop = async (file, origin) => {
node.adachiAdoptCrop = async (file, origin, {signal}={}) => {
const editor = crop.editor, region = crop.region;
if (!editor.ready || !region.locked) throw Error(T("Select a crop region before adopting a result"));
if (crop.busy || editor.stroke) throw Error(T("Finish editing before adopting a result"));
@@ -24,19 +25,21 @@ export function installResultAdoption(node, crop) {
throw Error(T("This result belongs to another crop; generate a result for the current region"));
}
if (file.size > 64 * 1024 * 1024) throw Error(T("File is too large (max 64 MB)"));
signal?.throwIfAborted();
const controller=crop.adoptController=new AbortController(),abort=()=>controller.abort();
signal?.addEventListener('abort',abort,{once:true});
crop.busy = true; crop.sync();
const objectUrl = URL.createObjectURL(file);
try {
const image = new Image(); image.src = objectUrl; await image.decode();
controller.signal.throwIfAborted();
if (image.width !== region.width || image.height !== region.height) {
throw Error(T("Result dimensions must match the selected crop"));
}
const body = new FormData();
body.append("image", file, `adopted-${crypto.randomUUID()}.png`);
body.append("type", "input"); body.append("subfolder", "adachi-inpaint-layers");
const response = await api.fetchApi("/upload/image", {method:"POST", body});
if (!response.ok) throw Error(`${T("Upload failed")} (${response.status})`);
const uploaded = await response.json();
const uploaded = await uploadImageWithProgress(api,body,null,{signal:controller.signal});
if (typeof uploaded.name !== "string" || !uploaded.name) throw Error(T("Upload failed"));
if (editor.destroyed || crop.mode !== "crop" || crop.region.job !== region.job) {
throw Error(T("The crop changed while adopting the result"));
@@ -50,6 +53,7 @@ export function installResultAdoption(node, crop) {
}
editor.status("");
} finally {
signal?.removeEventListener('abort',abort);if(crop.adoptController===controller)crop.adoptController=null;
URL.revokeObjectURL(objectUrl); crop.busy = false; crop.sync();
}
};
+15 -10
View File
@@ -4,33 +4,37 @@ import {T} from './i18n.js';
// latest history entry or from a coincidentally matching node ID.
import {executionNode,graphNodeEntries} from './workspace_flow.js';
export class ResultRecovery {
constructor({fetchApi,getGraph,onOutput,onPending=()=>{},onTerminal=()=>{},interval=2500}) {
Object.assign(this,{fetchApi,getGraph,onOutput,onPending,onTerminal,interval});
this.pending=new Map();this.delivered=new Set();
constructor({fetchApi,getGraph,onOutput,onPending=()=>{},onTerminal=()=>{},onDiscard=()=>{},interval=2500}) {
Object.assign(this,{fetchApi,getGraph,onOutput,onPending,onTerminal,onDiscard,interval});
this.pending=new Map();this.delivered=new Set();this.controllers=new Set();
}
track(graph,promptId,nodeId) {
const node=executionNode(graph,nodeId),key=`${promptId}:${nodeId}`;
if(!promptId || node?.type!=='AdachiImageCompare' || this.delivered.has(key) || this.pending.has(key))return;
if(this.destroyed || !promptId || node?.type!=='AdachiImageCompare' || this.delivered.has(key) || this.pending.has(key))return;
this.pending.set(key,{graph,node,promptId,nodeId:String(nodeId),key});
this.onPending(node);this.schedule(0);
}
schedule(ms=this.interval) {clearTimeout(this.timer);if(this.pending.size)this.timer=setTimeout(()=>this.recover(),ms);}
schedule(ms=this.interval) {clearTimeout(this.timer);if(!this.destroyed && this.pending.size)this.timer=setTimeout(()=>this.recover(),ms);}
discard(record){if(this.pending.delete(record.key))this.onDiscard(record.node);}
async recover() {
if(this.busy)return;this.busy=true;clearTimeout(this.timer);
if(this.destroyed || this.busy)return;this.busy=true;clearTimeout(this.timer);
const groups=new Map();
for(const record of this.pending.values()) {
if(record.graph!==this.getGraph() || executionNode(record.graph,record.nodeId)!==record.node){this.pending.delete(record.key);continue;}
if(record.graph!==this.getGraph() || executionNode(record.graph,record.nodeId)!==record.node){this.discard(record);continue;}
const records=groups.get(record.promptId)||[];records.push(record);groups.set(record.promptId,records);
}
try {
for(const [promptId,records] of groups) {
if(this.destroyed)break;
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),15000);
this.controllers.add(controller);
try {
const response=await this.fetchApi(`/history/${encodeURIComponent(promptId)}`,{cache:'no-store',signal:controller.signal});
if(!response.ok)continue;
const entry=(await response.json())[promptId];if(!entry)continue;
for(const r of records) {
if(r.graph!==this.getGraph() || executionNode(r.graph,r.nodeId)!==r.node){this.pending.delete(r.key);continue;}
if(this.destroyed || !this.pending.has(r.key))continue;
if(r.graph!==this.getGraph() || executionNode(r.graph,r.nodeId)!==r.node){this.discard(r);continue;}
const output=entry.outputs?.[r.nodeId];
if(output?.adachi_originals?.length && output?.adachi_results?.length) {
await this.onOutput(r.node,output,promptId,entry);
@@ -39,20 +43,21 @@ export class ResultRecovery {
this.pending.delete(r.key);this.onTerminal(r.node,entry,promptId);
}
}
} catch {} finally {clearTimeout(timer);}
} catch {} finally {clearTimeout(timer);this.controllers.delete(controller);controller.abort();}
}
} finally {
while(this.delivered.size>256)this.delivered.delete(this.delivered.values().next().value);
this.busy=false;this.schedule();
}
}
destroy(){clearTimeout(this.timer);this.pending.clear();}
destroy(){this.destroyed=true;clearTimeout(this.timer);for(const controller of this.controllers)controller.abort();for(const record of this.pending.values())this.discard(record);this.delivered.clear();}
}
const installed=new WeakMap();
export function installComparisonRecovery(api,getGraph) {
if(installed.has(api))return installed.get(api);
const recovery=new ResultRecovery({fetchApi:api.fetchApi.bind(api),getGraph,
onDiscard:node=>{const view=node.adachiCompare;if(view){view.pendingOutputs=Math.max(0,(view.pendingOutputs||0)-1);view.updateButton();if(!view.pendingOutputs&&!view.loadingActive)view.progressBox.hidden=true;}},
onPending:node=>{const view=node.adachiCompare;if(view){view.pendingOutputs=(view.pendingOutputs||0)+1;view.updateButton();view.loading(view.options.waitingText||T("Waiting for the generated image…"));}},
onOutput(node,output,promptId,entry){
if(node.adachiCompare)node.adachiCompare.pendingOutputs=Math.max(0,(node.adachiCompare.pendingOutputs||0)-1);
+26 -9
View File
@@ -2,17 +2,27 @@ import { T } from "./i18n.js";
// XMLHttpRequest reports bytes sent, unlike fetch. Keep ComfyUI's API URL and
// per-user header; the browser automatically sends same-origin session cookies.
export async function uploadImageWithProgress(api, body, onProgress) {
export async function uploadImageWithProgress(api, body, onProgress, {signal,idleMs=60000}={}) {
const controller=new AbortController();let timer,timedOut=false;
const abort=()=>controller.abort();
const reset=(ms=idleMs)=>{clearTimeout(timer);timer=setTimeout(()=>{timedOut=true;controller.abort();},ms);};
if(signal?.aborted)controller.abort();else signal?.addEventListener('abort',abort,{once:true});
try {
controller.signal.throwIfAborted();reset();
const response = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
let settled=false,lastLoaded=0;
const finish=(fn,value)=>{if(settled)return;settled=true;controller.signal.removeEventListener('abort',cancel);xhr.onload=xhr.onerror=xhr.onabort=null;xhr.upload.onprogress=xhr.upload.onload=null;fn(value);};
const cancel=()=>{xhr.abort();finish(reject,new DOMException(T('Upload was cancelled'),'AbortError'));};
controller.signal.addEventListener('abort',cancel,{once:true});
xhr.open("POST", api.apiURL("/upload/image"));
if (api.user) xhr.setRequestHeader("Comfy-User", api.user);
xhr.upload.onprogress = event => onProgress?.({phase: "upload", loaded: event.loaded, total: event.lengthComputable ? event.total : 0});
xhr.upload.onload = () => onProgress?.({phase: "processing"});
xhr.onload = () => resolve(xhr);
xhr.onerror = () => reject(new Error(T("Network error while uploading image")));
xhr.onabort = () => reject(new Error(T("Upload was cancelled")));
xhr.send(body);
xhr.upload.onprogress = event => {if(event.loaded>lastLoaded){lastLoaded=event.loaded;reset();}onProgress?.({phase: "upload", loaded: event.loaded, total: event.lengthComputable ? event.total : 0});};
xhr.upload.onload = () => {reset();onProgress?.({phase: "processing"});};
xhr.onload = () => finish(resolve,xhr);
xhr.onerror = () => finish(reject,new Error(T("Network error while uploading image")));
xhr.onabort = () => finish(reject,new DOMException(T("Upload was cancelled"),'AbortError'));
try{xhr.send(body);}catch(error){finish(reject,error);}
});
// Some cloud frontends wrap fetchApi with an authentication mechanism that
@@ -20,12 +30,19 @@ export async function uploadImageWithProgress(api, body, onProgress) {
// frontend API, without inventing upload percentages for the fallback.
if (response.status === 401 || response.status === 403) {
onProgress?.({phase: "authenticated"});
const fallback = await api.fetchApi("/upload/image", {method: "POST", body});
// fetch cannot expose upload byte progress. Give authenticated uploads
// a larger bounded budget rather than applying XHR's inactivity limit.
reset(idleMs*5);
const fallback = await api.fetchApi("/upload/image", {method: "POST", body, signal:controller.signal});
if (!fallback.ok) throw new Error(`${T("Upload failed")} (${fallback.status})`);
onProgress?.({phase: "processing"});
return fallback.json();
return await fallback.json();
}
if (response.status < 200 || response.status >= 300) throw new Error(`${T("Upload failed")} (${response.status})`);
try { return JSON.parse(response.responseText); }
catch { throw new Error(T("Invalid upload response")); }
} catch(error) {
if(timedOut)throw Error(T('Image upload timed out; check the connection and try again'));
throw error;
} finally {clearTimeout(timer);signal?.removeEventListener('abort',abort);controller.abort();}
}
+5 -4
View File
@@ -114,13 +114,14 @@ export class WorkspaceBindings {
return binding;
}
async loadCatalog(fetchApi) {
const get = async path => {const r=await fetchApi(path);if(!r.ok)throw Error(T("Model list request failed ({status})",{status:r.status}));return r.json();};
this.catalogController?.abort();const controller=this.catalogController=new AbortController(),timer=setTimeout(()=>controller.abort(),15000);
const get = async path => {const r=await fetchApi(path,{signal:controller.signal});if(!r.ok)throw Error(T("Model list request failed ({status})",{status:r.status}));return r.json();};
const sources=[['checkpoints','/object_info/CheckpointLoaderSimple','CheckpointLoaderSimple','ckpt_name'],
['unets','/object_info/UNETLoader','UNETLoader','unet_name'],
['clips','/object_info/CLIPLoader','CLIPLoader','clip_name'],
['vaes','/object_info/VAELoader','VAELoader','vae_name'],['loras','/models/loras']];
const results=await Promise.allSettled(sources.map(([,path])=>get(path)));
if(this.destroyed)return;
let results;try{results=await Promise.allSettled(sources.map(([,path])=>get(path)));}finally{clearTimeout(timer);}
if(this.destroyed || this.catalogController!==controller)return;
results.forEach((result,i)=>{if(result.status!=='fulfilled')return;const [key,,type,input]=sources[i];
const values=type?result.value?.[type]?.input?.required?.[input]?.[0]:result.value;
if(Array.isArray(values)){this.catalog[key]=values.map(String);this.catalogReady.add(key);}
@@ -193,5 +194,5 @@ export class WorkspaceBindings {
}
}
destroy() {this.destroyed=true;this.root?.remove();}
destroy() {this.destroyed=true;this.catalogController?.abort();this.root?.remove();}
}
+1 -1
View File
@@ -44,5 +44,5 @@ async function prepareSnapshot(graph,{editorNode,samplerNode,compareNode,binding
const workflow=JSON.parse(JSON.stringify(graph.serialize()));
return {prompt,compare_id:compareId,client_id:api.clientId||'',extra_data:{extra_pnginfo:{workflow},auth_token_comfy_org:api.authToken,api_key_comfy_org:api.apiKey}};
}
export const validateWorkspace=prepared=>workspaceRequest(api,'validate',prepared);
export const validateWorkspace=(prepared,options)=>workspaceRequest(api,'validate',prepared,options);
export const submitWorkspace=prepared=>workspaceRequest(api,'submit',prepared);
+6 -3
View File
@@ -57,16 +57,19 @@ export async function restoreSourceState(node, state, {decode = decodeCanvas, on
const editor = node.adachiEditor, crop = node.adachiCrop;
if (!editor || editor.destroyed || !node.graph) throw Error(T("The source image node was removed"));
if (editor.stroke || editor.pointerId != null || crop?.busy) throw Error(T("Finish the current edit first"));
const loadVersion = editor.loadVersion;
const loadVersion = editor.loadVersion, maskRevision = editor.maskRevision, cropRevision=crop?.revision;
const contexts = {};
try {
// Decode all masks before altering live state. A failed decode leaves
// the working image, mask and crop completely untouched.
for (const [name, context] of Object.entries(state.contexts)) {
const [mask, baseMask] = await Promise.all([decode(context.mask), decode(context.baseMask)]);
const decoded=await Promise.allSettled([decode(context.mask), decode(context.baseMask)]);
const failed=decoded.find(item=>item.status==='rejected');
if(failed){for(const item of decoded)if(item.status==='fulfilled')item.value.width=item.value.height=0;throw failed.reason;}
const [mask,baseMask]=decoded.map(item=>item.value);
contexts[name] = {...context, mask, baseMask, history:copyCommands(context.history), future:copyCommands(context.future)};
}
if (editor.destroyed || editor.loadVersion !== loadVersion || editor.stroke || editor.pointerId != null || crop?.busy) {
if (editor.destroyed || !node.graph || editor.loadVersion !== loadVersion || editor.maskRevision !== maskRevision || crop?.revision !== cropRevision || editor.stroke || editor.pointerId != null || crop?.busy) {
throw Error(T("The image changed during restoration; try again"));
}
} catch (error) { releaseContexts(contexts); throw error; }
+6 -2
View File
@@ -2,10 +2,13 @@ import {T} from './i18n.js';
// Check protocol support before POSTing: old ComfyUI processes may serve the
// updated JavaScript while still running the previous Python node package.
const unavailable = () => new Error(T("The workbench backend is not loaded. Stop and restart ComfyUI, then check connections again; refreshing the browser is not enough."));
export async function workspaceRequest(api, path, prepared) {
export async function workspaceRequest(api, path, prepared, {signal}={}) {
const controller = new AbortController();
const abort=()=>controller.abort();
if(signal?.aborted)controller.abort();else signal?.addEventListener('abort',abort,{once:true});
const timer = setTimeout(() => controller.abort(), path === 'submit' ? 60000 : 20000);
try {
controller.signal.throwIfAborted();
let base;
for (const candidate of ['/adachi-inpaint/workspace', '/api/adachi-inpaint/workspace']) {
const probe = await api.fetchApi(`${candidate}/capabilities`, {signal:controller.signal});
@@ -34,7 +37,8 @@ export async function workspaceRequest(api, path, prepared) {
}
return data;
} catch (error) {
if(signal?.aborted)throw error;
if (error.name === 'AbortError') throw new Error(path === 'submit' ? T("Submission timed out. Check the task queue before retrying to avoid duplicate generations.") : T("Connection check timed out; check your network and retry"));
throw error;
} finally { clearTimeout(timer); }
} finally {clearTimeout(timer);signal?.removeEventListener('abort',abort);controller.abort();}
}
+39 -13
View File
@@ -17,6 +17,7 @@ let currentWorkspace=null;
export class Workspace {
constructor(samplerNode=null,options={}){
this.preferredSampler=samplerNode;this.pipelineOptions=options;this.mounts=[];this.disposers=[];this.closed=false;this.running=false;this.checkVersion=0;
this.sessionController=new AbortController();
this.root=el('div','','adachi-workspace');this.root.adachiWorkspace=this;this.root.setAttribute('role','dialog');this.root.setAttribute('aria-label','Adachi Workbench');
const head=el('header'),title=el('h1','Adachi Workbench');head.append(title);
this.status=el('span',T("Reading the current workflow…"),'aw-status');this.themeButton=iconButton(T("Switch theme"),'theme',()=>this.toggleTheme());head.append(this.status,this.themeButton,iconButton(T("Check connections"),'check',()=>this.check()),iconButton(T("Return to workflow"),'back',()=>this.close()));this.root.append(head);
@@ -28,7 +29,7 @@ export class Workspace {
this.surface=el('div','','aw-surface');this.still=el('img','','aw-result-image');this.still.alt=T("Selected source image or generated result");this.still.hidden=true;this.surface.append(this.still);
const foot=el('div','','aw-footer');this.undoButton=btn(T("↶ Undo"),()=>this.restoreHistory('undo'));this.originalButton=btn(T("Initial source"),()=>this.restoreHistory('original'));this.historySelect=el('select');this.historySelect.setAttribute('aria-label',T("Choose an adopted source image"));this.historySelect.onchange=()=>this.restoreHistory(Number(this.historySelect.value));foot.append(this.undoButton,this.originalButton,this.historySelect);
this.footer=foot;this.undoButton.title=T("Restore the previous source image, mask and crop");this.originalButton.title=T("Restore the initial source image of this session");this.historySelect.title=T("Choose an earlier adopted or uploaded source image");this.note=el('div','','aw-note');canvas.append(toolbar,this.surface,foot,this.note);
this.side=el('aside');this.generationPanel=el('div','','aw-generation-progress');this.generationPanel.hidden=true;this.generationLabel=el('span');this.generationBar=el('progress');this.generationBar.max=1;this.generationBar.setAttribute('aria-label',T("Current stage progress"));this.generationPanel.title=T("Actual progress from the current node; stages without step counts show an indeterminate bar");this.generationPanel.append(this.generationLabel,this.generationBar);this.side.append(this.generationPanel,el('h2',T("Workflow settings")));this.settings=el('div');this.params=el('div','','aw-parameters');this.runButton=btn(T("Generate"),()=>this.run());this.runButton.className='primary';this.runButton.style.width='100%';this.side.append(this.settings,this.runButton,this.params);this.divider=el('div','','aw-splitter');main.append(canvas,this.divider,this.side);this.root.append(main);
this.side=el('aside');this.generationPanel=el('div','','aw-generation-progress');this.generationPanel.hidden=true;this.generationLabel=el('span');this.generationBar=el('progress');this.generationBar.max=1;this.generationBar.setAttribute('aria-label',T("Current stage progress"));this.generationPanel.title=T("Actual progress from the current node; stages without step counts show an indeterminate bar");this.generationPanel.append(this.generationLabel,this.generationBar);this.side.append(this.generationPanel,el('h2',T("Workflow settings")));this.settings=el('div');this.params=el('div','','aw-parameters');this.runButton=btn(T("Generate"),()=>this.run());this.runButton.disabled=true;this.runButton.className='primary';this.runButton.style.width='100%';this.side.append(this.settings,this.runButton,this.params);this.divider=el('div','','aw-splitter');main.append(canvas,this.divider,this.side);this.root.append(main);
this.root.addEventListener('keydown',e=>{if(e.key==='Escape'&&e.target===this.root)this.close();});
this.root.addEventListener('pointerdown',e=>e.stopPropagation());document.body.append(this.root);document.body.classList.add('adachi-workspace-open');this.stopSplitter=installWorkspaceSplitter(this.root,main,this.divider);
// Render first. Discovery/catalog and actual server validation run afterward.
@@ -62,6 +63,8 @@ export class Workspace {
this.history.onChange=()=>this.syncHistory();
this.history.onRestore=()=>{syncCurrentDimensions(this.editorNode);app.canvas?.emitBeforeChange?.();app.canvas?.emitAfterChange?.();this.show('edit');this.scheduleCheck();};
if(this.editor.ready)await this.history.checkpoint(T("Source when opened"));
if(this.closed)return;
if((app.rootGraph||app.graph)!==this.graph){this.close();return;}
const originalTargets=this.compare.options.getTargets,originalManual=this.compare.manualTarget,originalTarget=this.compare.targetSelect.value;
this.compare.options.getTargets=()=>({...originalTargets(),preferred:executionId(this.graph,this.editorNode)});this.compare.manualTarget=false;this.compare.refreshTargets();
this.disposers.push(()=>{this.compare.options.getTargets=originalTargets;this.compare.manualTarget=originalManual;this.compare.targetSelect.value=originalTarget;this.compare.refreshTargets();});
@@ -97,11 +100,14 @@ export class Workspace {
const event=(name,fn)=>{api.addEventListener(name,fn);this.disposers.push(()=>api.removeEventListener(name,fn));};
for(const name of ['execution_start','executing','execution_cached','progress','progress_state','execution_success','execution_error','execution_interrupted'])event(name,e=>this.taskProgress.handle(name,e.detail));
event('reconnecting',()=>this.taskProgress.connection(false));event('reconnected',()=>this.taskProgress.connection(true));
const recovered=e=>{if(e.detail.node!==this.compareNode || e.detail.promptId!==this.promptId)return;this.taskProgress.finish(e.detail.entry.status?.status_str==='success');this.running=false;this.sampler.adachiWorkspaceTask=null;this.runButton.disabled=false;this.runButton.textContent=T("Generate");this.setStatus(e.detail.entry.status?.status_str==='success'?T("Generation complete"):T("Generation failed; check the ComfyUI task history"),e.detail.entry.status?.status_str==='success'?'ok':'error');this.updateButtons();};
const queued=e=>{if(e.detail.sampler!==this.sampler)return;this.promptId=e.detail.promptId;this.running=!['complete','error'].includes(this.taskProgress.state);this.runButton.textContent=this.running?T("Generating…"):T("Generate");this.renderTaskProgress();this.updateButtons();};
document.addEventListener('adachi-workspace-queued',queued);this.disposers.push(()=>document.removeEventListener('adachi-workspace-queued',queued));
const recovered=e=>{if(e.detail.node!==this.compareNode || e.detail.promptId!==this.promptId)return;if(e.detail.entry.outputs?.[executionId(this.graph,this.compareNode)]?.adachi_results?.length)this.showGeneratedResult();this.taskProgress.finish(e.detail.entry.status?.status_str==='success');this.running=false;this.sampler.adachiWorkspaceTask=null;this.runButton.disabled=false;this.runButton.textContent=T("Generate");this.setStatus(e.detail.entry.status?.status_str==='success'?T("Generation complete"):T("Generation failed; check the ComfyUI task history"),e.detail.entry.status?.status_str==='success'?'ok':'error');this.updateButtons();};
document.addEventListener('adachi-result-recovered',recovered);this.disposers.push(()=>document.removeEventListener('adachi-result-recovered',recovered));
for(const name of ['execution_success','execution_error','execution_interrupted'])event(name,e=>{if(e.detail.prompt_id===this.promptId){this.running=false;this.sampler.adachiWorkspaceTask=null;this.runButton.disabled=false;this.runButton.textContent=T("Generate");this.setStatus(name==='execution_success'?T("Generation complete"):e.detail.exception_message||T("Generation stopped"),name==='execution_success'?'ok':'error');}});
this.timer=setInterval(()=>{if((app.rootGraph||app.graph)!==this.graph||this.editor.destroyed)this.close();else{this.updateButtons();if(this.pendingResultView)this.showGeneratedResult();}},500);
this.pollTimer=setInterval(()=>this.pollResult(),2500);
// The comparison's exact-task recovery also serves the workspace. A second
// history poller doubled weak-network traffic and could replay old results.
this.promptId=this.sampler.adachiWorkspaceTask;this.running=!!this.promptId;if(this.running){if(this.taskProgress.state==='idle')this.taskProgress.begin({},this.promptId);this.compareNode.adachiTrackPrompt?.(this.promptId);this.runButton.disabled=true;this.runButton.textContent=T("Generating…");}this.show('edit');this.syncHistory();this.scheduleCheck();
}
connectionPicker(){
@@ -120,11 +126,11 @@ export class Workspace {
});apply.title=T("Select nodes from this workflow; this inpaint node remembers your choices");panel.append(apply);return panel;
}
syncHistory(){if(!this.history)return;this.historySelect.replaceChildren();this.history.entries.forEach((item,i)=>{const o=el('option',`${i+1} · ${item.label}`);o.value=i;this.historySelect.append(o);});this.historySelect.value=String(this.history.index);this.undoButton.disabled=!this.history.canUndo;this.originalButton.disabled=this.history.index===0;}
updateButtons(){const busy=this.running||this.preparing;this.adoptButton.disabled=!this.compare?.ready||!!this.compare?.busy||this.compare?.pendingOutputs>0||busy;this.tabs.compare.disabled=!this.compare?.ready;this.sideButton.disabled=!this.compare?.ready;if(!this.preparing)this.bindings?.refreshProfiles();if(this.editor)this.editor.root.inert=(!this.connectionsConfirmed)||!!this.preparing&&!this.discovering;this.settings.inert=!!this.preparing;this.params.inert=!!this.preparing;this.historySelect.disabled=this.preparing||!!this.history?.busy;this.undoButton.disabled=this.preparing||!this.history?.canUndo;this.originalButton.disabled=this.preparing||this.history?.index<=0;if(this.compare?.adoptButton)this.compare.adoptButton.disabled=this.adoptButton.disabled;}
updateButtons(){const busy=this.running||this.preparing||this.submitting||this.sampler?.adachiWorkspaceSubmitting;this.runButton.disabled=!!(this.running||this.submitting||this.sampler?.adachiWorkspaceSubmitting);this.adoptButton.disabled=!this.compare?.ready||!!this.compare?.busy||this.compare?.pendingOutputs>0||busy;this.tabs.compare.disabled=!this.compare?.ready;this.sideButton.disabled=!this.compare?.ready;if(!this.preparing)this.bindings?.refreshProfiles();if(this.editor)this.editor.root.inert=(!this.connectionsConfirmed)||!!this.preparing&&!this.discovering;this.settings.inert=!!this.preparing;this.params.inert=!!this.preparing;this.historySelect.disabled=this.preparing||!!this.history?.busy;this.undoButton.disabled=this.preparing||!this.history?.canUndo;this.originalButton.disabled=this.preparing||this.history?.index<=0;if(this.compare?.adoptButton)this.compare.adoptButton.disabled=this.adoptButton.disabled;}
show(which){this.view=which;if(this.editor)this.editor.root.hidden=which!=='edit';if(this.compare)this.compare.root.hidden=which!=='compare';this.still.hidden=!['before','after'].includes(which);if(!this.still.hidden){const image=which==='before'?this.compare?.before:this.compare?.after;if(image)this.still.src=image.src;}for(const [id,b]of Object.entries(this.tabs)){b.classList.toggle('active',id===which);b.setAttribute('aria-pressed',String(id===which));}this.sideButton.classList.toggle('active',['before','after'].includes(which));this.sideButton.setAttribute('aria-pressed',String(which==='before'));this.sideButton.title=which==='before'?T("Show after"):T("Show before");this.sideButton.setAttribute('aria-label',this.sideButton.title);this.editor?.schedule();this.compare?.schedule();}
async adopt(){if(!this.compare?.ready||this.running)return;await this.compare.adopt();this.syncHistory();this.updateButtons();if(!this.compare.message.classList.contains('dd-error')){this.show(this.editorNode.adachiCrop?.mode==='crop'?'edit':'compare');this.note.textContent='';this.scheduleCheck();}}
async restoreHistory(which){try{if(this.preparing||this.history?.busy)return;if(which==='undo')await this.history.undo();else if(which==='original')await this.history.original();else await this.history.restore(which);this.syncHistory();}catch(e){this.setStatus(e.message,'error');}}
scheduleCheck(){clearTimeout(this.checkTimer);this.checkTimer=setTimeout(()=>this.check(),200);}
scheduleCheck(){++this.checkVersion;this.checkController?.abort();clearTimeout(this.checkTimer);if(!this.closed)this.checkTimer=setTimeout(()=>this.check(),200);}
prepare(discovery=false){
const task=(this.prepareTask||Promise.resolve()).catch(()=>{}).then(async()=>{
if(this.closed||(app.rootGraph||app.graph)!==this.graph)throw Error(T("The workflow changed"));
@@ -136,10 +142,27 @@ export class Workspace {
});
this.prepareTask=task;return task;
}
async check(){const version=++this.checkVersion;if(this.closed||this.running)return false;this.setStatus(T("Checking in the background…"));try{const prepared=await this.prepare();const result=await validateWorkspace(prepared);if(this.closed||this.running||version!==this.checkVersion)return false;if(result.valid===false||result.ok===false)throw Error(result.error?.message||result.error||T("Workflow validation failed"));this.setStatus(T("Ready to generate"),'ok');return true;}catch(e){if(version===this.checkVersion && !this.running)this.setStatus(e.message||String(e),'error');return false;}}
async check(){
if(this.closed||this.running||this.submitting||this.sampler?.adachiWorkspaceSubmitting)return false;
if(this.checking){this.recheck=true;return false;}
const version=++this.checkVersion,controller=this.checkController=new AbortController();this.checking=true;
this.setStatus(T("Checking in the background…"));
try{
const prepared=await this.prepare();
if(this.closed||this.recheck||version!==this.checkVersion)return false;
const result=await validateWorkspace(prepared,{signal:controller.signal});
if(this.closed||this.running||this.recheck||version!==this.checkVersion)return false;
if(result.valid===false||result.ok===false)throw Error(result.error?.message||result.error||T("Workflow validation failed"));
this.setStatus(T("Ready to generate"),'ok');return true;
}catch(e){if(version===this.checkVersion&&!this.running)this.setStatus(e.message||String(e),'error');return false;}
finally{this.checking=false;if(this.recheck){this.recheck=false;this.scheduleCheck();}}
}
async run() {
if(this.running)return;
++this.checkVersion;clearTimeout(this.checkTimer);
if(this.closed||this.running||this.submitting||this.sampler?.adachiWorkspaceSubmitting)return;
if(!this.sampler||!this.taskProgress){this.setStatus(T("Controls are not ready; reopen the workbench shortly"),'error');return;}
this.submitting=true;this.sampler.adachiWorkspaceSubmitting=true;
++this.checkVersion;this.checkController?.abort();clearTimeout(this.checkTimer);
this.runButton.disabled=true; this.runButton.textContent=T("Checking…");
const seed=this.sampler?.widgets?.find(w=>w.name==='seed'), oldSeed=seed?.value;
let queuedSeed=oldSeed;
@@ -147,16 +170,19 @@ export class Workspace {
for(const widget of this.sampler.widgets || []) widget.beforeQueued?.();
queuedSeed=seed?.value;
const prepared=structuredClone(await this.prepare());
await validateWorkspace(prepared);
await validateWorkspace(prepared,{signal:this.sessionController.signal});
if(this.closed)return;
if((app.rootGraph||app.graph)!==this.graph||!this.sampler.graph||!this.compareNode.graph||this.editor.destroyed)throw Error(T("The workflow changed; check connections again"));
this.running=true;this.taskProgress.begin(prepared.prompt);this.promptId=null; this.updateButtons();
const queued=await submitWorkspace(prepared);
this.promptId=queued.prompt_id;this.taskProgress.attach(this.promptId);
this.compareNode.adachiTrackPrompt?.(this.promptId);
for(const widget of this.sampler.widgets || []) widget.afterQueued?.();
this.sampler.adachiParameters.refresh();
this.running=!['complete','error'].includes(this.taskProgress.state);
this.sampler.adachiWorkspaceTask=this.running?this.promptId:null;
document.dispatchEvent(new CustomEvent('adachi-workspace-queued',{detail:{sampler:this.sampler,promptId:this.promptId}}));
this.compareNode.adachiTrackPrompt?.(this.promptId,this.graph);
if(this.closed)return;
this.runButton.textContent=this.running?T("Generating…"):T("Generate");
this.setStatus(this.running?T("Queued for generation"):this.taskProgress.label,this.running?'pending':this.taskProgress.state==='error'?'error':'ok');
} catch(e) {
@@ -165,13 +191,13 @@ export class Workspace {
if(seed && seed.value===queuedSeed)seed.value=oldSeed;
this.setStatus(e.message||String(e),'error');
} finally {
this.submitting=false;this.sampler.adachiWorkspaceSubmitting=false;
if(!this.running){this.runButton.disabled=false;this.runButton.textContent=T("Generate");}
this.updateButtons();
if(!this.closed)this.updateButtons();
}
}
async pollResult(){if(!this.running||!this.promptId||this.polling||this.closed)return;this.polling=true;try{const r=await api.fetchApi(`/history/${encodeURIComponent(this.promptId)}`);if(!r.ok)return;const all=await r.json(),entry=all[this.promptId];if(!entry?.status?.completed)return;const output=entry.outputs?.[executionId(this.graph,this.compareNode)];if(output)this.compareNode.onExecuted(output);this.taskProgress.finish(entry.status.status_str==='success');this.running=false;this.sampler.adachiWorkspaceTask=null;this.runButton.disabled=false;this.runButton.textContent=T("Generate");this.setStatus(entry.status.status_str==='success'?T("Generation complete"):T("Generation failed; check the ComfyUI error log"),entry.status.status_str==='success'?'ok':'error');}catch{}finally{this.polling=false;}}
close(){if(this.closed)return;this.closed=true;++this.checkVersion;clearInterval(this.timer);clearInterval(this.pollTimer);clearTimeout(this.checkTimer);this.stopSplitter?.();for(const dispose of this.disposers.reverse())dispose();if(this.history){this.history.onChange=null;this.history.onRestore=null;}if(this.taskProgress)this.taskProgress.onChange=()=>{};this.bindings?.destroy();for(const {element,marker,style}of this.mounts.reverse()){element.classList.remove('aw-embedded');element.hidden=false;element.inert=false;if(marker.parentNode)marker.replaceWith(element);if(style===null)element.removeAttribute('style');else element.setAttribute('style',style);}this.root.remove();document.body.classList.remove('adachi-workspace-open');this.editor?.schedule();this.compare?.schedule();this.compare?.updateButton();currentWorkspace=null;}
close(){if(this.closed)return;this.closed=true;this.sessionController.abort();this.checkController?.abort();++this.checkVersion;clearInterval(this.timer);clearInterval(this.pollTimer);clearTimeout(this.checkTimer);this.stopSplitter?.();for(const dispose of this.disposers.reverse())dispose();if(this.history){this.history.onChange=null;this.history.onRestore=null;}if(this.taskProgress)this.taskProgress.onChange=()=>{};this.bindings?.destroy();for(const {element,marker,style}of this.mounts.reverse()){element.classList.remove('aw-embedded');element.hidden=false;element.inert=false;if(marker.parentNode)marker.replaceWith(element);if(style===null)element.removeAttribute('style');else element.setAttribute('style',style);}this.root.remove();document.body.classList.remove('adachi-workspace-open');this.editor?.schedule();this.compare?.schedule();this.compare?.updateButton();currentWorkspace=null;}
}
app.registerExtension({name:'Adachi.InpaintWorkspace',setup(){
const css=document.createElement('link');css.rel='stylesheet';css.href=new URL('./workspace.css?v=2.0.0',import.meta.url).href;document.head.append(css);
+14 -1
View File
@@ -6,6 +6,14 @@ import time
import uuid
def validate_input_mappings(prompt):
# Core replacement hooks inspect inputs before validate_prompt runs. Reject
# malformed mappings here so a bad request cannot crash those hooks first.
for key, node in prompt.items():
if not isinstance(node, dict) or not isinstance(node.get("inputs", {}), dict):
raise ValueError(f"Invalid input mapping for execution node: {key}")
def dependency_prompt(prompt, output_id):
"""Keep one output and its dependencies, including references into subgraphs."""
output_id = str(output_id)
@@ -22,7 +30,10 @@ def dependency_prompt(prompt, output_id):
node = prompt.get(key)
if not isinstance(node, dict):
raise ValueError(f"The execution graph references a missing node: {key}")
for value in node.get("inputs", {}).values():
inputs = node.get("inputs", {})
if not isinstance(inputs, dict):
raise ValueError(f"Invalid input mapping for execution node: {key}")
for value in inputs.values():
if isinstance(value, list) and len(value) == 2 and isinstance(value[0], str) and isinstance(value[1], int):
pending.append(value[0])
return {key: value for key, value in prompt.items() if key in found}
@@ -34,10 +45,12 @@ async def prepare_workspace(server, execution, payload):
raise ValueError("No workflow execution graph was provided.")
target = str(payload.get("compare_id", ""))
data = deepcopy(payload)
validate_input_mappings(data["prompt"])
# Hooks may restore Get/Set or subgraph links; prune only after they ran.
data = server.trigger_on_prompt(data)
if not isinstance(data, dict) or not isinstance(data.get("prompt"), dict):
raise ValueError("A workflow extension rejected the execution graph.")
validate_input_mappings(data["prompt"])
replacements = getattr(server, "node_replace_manager", None)
if replacements is not None:
replacements.apply_replacements(data["prompt"])