Add white workflow inpainting workspace with validation and source history

This commit is contained in:
Adachi
2026-11-09 10:20:37 +08:00
parent 75e18a2558
commit 08d2153d3b
18 changed files with 889 additions and 2 deletions
+6
View File
@@ -37,6 +37,12 @@ English and Simplified Chinese interfaces follow the ComfyUI language setting. T
## Features
### White inpainting workspace (local preview)
Load the `gpt test` workflow and click **打开重绘工作台** at the bottom right. The editor opens immediately; background validation checks the selected branch without running inference, and Generate validates again before queueing. The panel reuses group presets, painting, sampling and comparison controls. Model and LoRA selectors are folded away, with no LoRA selected by default. WAI and Anima profiles bind their own model, CLIP, VAE and conditioning. Refiner routing is opt-in.
Generation never replaces the source automatically. Compare before/after or hover the divider, then adopt explicitly. Undo adoption or choose a previous source with its mask and crop state from the source history. History lasts for the currently loaded browser session. Closing the workspace returns live controls to the graph; parameter edits remain part of the current workflow. The local preview has been exercised with WAI v160 and no LoRA; other graphs require identifiable inpainting nodes and compatible model routing.
### Paint directly on the graph
Upload, drop or paste a source image and paint on **Inline Mask** itself. In Mask mode, uploading an image automatically fills the connected inpaint dimensions; a replacement of the same size keeps the mask, while a different-sized image starts with a fresh mask. Mask strokes stay in the browser and are saved with the workflow; painting does not require a disk save/load round-trip for every stroke.
+8
View File
@@ -37,6 +37,14 @@
## 功能介绍
### 白色重绘工作台(本地预览)
加载 `gpt test` 工作流后,点击网页右下角的「打开重绘工作台」。页面立即打开,上传图片后在后台验证重绘分支;点击生成时再次验证,通过才会进入队列。三个方案沿用工作流的分组预设,模型和 LoRA 收在折叠菜单中,默认不使用 LoRA。WAI 与 Anima 分支分别绑定各自的模型、CLIP、VAE 和提示词编码器,Refiner 默认关闭,可在模型菜单显式开启工作流中的兼容 Refiner。
结果只用于预览,点击「采用此结果」才替换原图。「前图」「后图」「滑动对比」用于检查结果;下方的「回退」「初始原图」及下拉列表可以恢复之前的原图、蒙版和裁切状态。历史保留在当前已加载工作流的浏览器会话内,刷新页面后不保留。关闭工作台会把控件放回节点,模型和参数编辑仍属于当前工作流。
本地预览已用 `gpt test` 的 WAI v160、无 LoRA 配置实际生成验证。其他工作流需具有可识别的重绘节点与匹配的模型分支;检查失败会显示原因,不会试跑模型。
### 直接在工作流里画蒙版
在**内联蒙版**上传、拖入或粘贴原图,然后直接在节点画布上涂抹。普通蒙版模式上传图片时会自动填写已连接的局部重绘宽高;替换同尺寸图片时保留蒙版,尺寸不同则载入新图并重置蒙版。蒙版在浏览器内更新,随工作流保存,不需要每画一笔都保存、加载磁盘文件。
+2
View File
@@ -1,5 +1,7 @@
# Verification record
- Local white workspace preview: real `gpt test` WAI v160 generation with no LoRA, 512×512, two sampler steps and a nonempty mask succeeds. Protected pixels remain exact. The UI run is also verified: immediate opening, background backend validation, invalid-model rejection without queueing, result preview without adopting, before/after, explicit adoption, mask-preserving rollback and source selection. Classic and Nodes 2.0 browser tests cover actual mouse painting, comparison, clean close and reopen; 768/390 layouts have no horizontal overflow. Source history tests include crop/layer/fusion-mask restoration. No remote cloud or Anima model inference is claimed by these checks.
- 1.0.0: all 54 CPU backend tests and all seven JavaScript regression suites pass; every frontend module passes syntax checking and all bundled examples parse as JSON. The release updates documentation and metadata on top of 0.9.0; no full model generation was run for this release.
- 0.9.0: 54 backend CPU tests cover in-memory PNG pixels, atomic batch storage, LRU eviction, cached execution invalidation and oversized batch rejection. Actual CPU image queues in frontend 1.52.7 and both node renderers verify comparison history, download bytes, ordinary and crop adoption, mask retention, layer blending, workflow reload and legacy disk-backed references; no new comparison temp files are created. Real Chrome network throttling confirms intermediate upload percentages, loading indication, user header, failure and concurrent upload behavior in both renderers. Proxy paths and auth fallback were separately tested; the user's actual cloud environment and full model inference were not tested.
+2
View File
@@ -3,6 +3,7 @@
from .backend import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS, normalize_exclusive_prompt
from .preview_cleanup import start_preview_cleanup
from .preview_store import PREVIEWS
from .workspace_execution import register_workspace_routes
from aiohttp import web
from server import PromptServer
import re
@@ -25,6 +26,7 @@ if not getattr(PromptServer.instance, "_adachi_preview_route", False):
PromptServer.instance._adachi_preview_route = True
start_preview_cleanup()
register_workspace_routes(PromptServer.instance)
WEB_DIRECTORY = "./web"
+11
View File
@@ -0,0 +1,11 @@
import assert from 'node:assert/strict';
import {inspectWorkbench} from '../web/workbench_analysis.js';
const graph={_nodes:[{type:'AdachiInlineMask'},{type:'AdachiWebUIInpaint'},
{type:'AdachiImageCompare'},{type:'AdachiGroupPresets'},
{type:'CheckpointLoaderSimple'},{type:'Power Lora Loader (rgthree)'}]};
const result=inspectWorkbench(graph);
assert.deepEqual(result.missing,[]);
assert.equal(result.checkpoints.length,1);
assert.equal(result.loras.length,1);
assert.deepEqual(inspectWorkbench(null).missing,['inline_mask','inpaint','compare']);
console.log('Workbench discovery checks passed');
+46
View File
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import {WorkspaceBindings} from '../web/workspace_bindings.js';
const nodes = [];
const add = (id,type,widgets=[]) => {const node={id,type,widgets,properties:{},inputs:[],mode:4};nodes.push(node);return node;};
const ckpt=add(518,'CheckpointLoaderSimple',[{name:'ckpt_name',value:'wai.safetensors'}]);
add(359,'CheckpointLoaderSimple',[{name:'ckpt_name',value:'anima.safetensors'}]);
add(357,'CLIPLoader');add(358,'VAELoader');
const sampler=add(501,'AdachiWebUIInpaint',[{name:'refiner_switch',value:1}]);
add(521,'CLIPTextEncode',[{name:'text',value:'positive'}]);
add(520,'CLIPTextEncode',[{name:'text',value:'negative'}]);
add(547,'CLIPTextEncode',[{name:'text',value:'anima positive'}]);
add(16,'CLIPTextEncode',[{name:'text',value:'anima negative'}]);
add(553,'Power Lora Loader (rgthree)',[
{name:'lora_1',value:{on:true,lora:'old1.safetensors',strength:1}},
{name:'lora_7',value:{on:true,lora:'old7.safetensors',strength:1}}
]);
add(360,'Power Lora Loader (rgthree)');
const preset=add(524,'AdachiGroupPresets');
const applied=[];
preset.adachiGroupPresets={model:{node:preset,data:{activeId:'anima',presets:[{id:'txt',name:'anima文生图'},{id:'anima',name:'anima修图'},{id:'wai',name:'wai修图'}]},apply:id=>applied.push(id)}};
const graph={_nodes:nodes,getNodeById:id=>nodes.find(n=>String(n.id)===String(id)),links:{}};
const bindings=new WorkspaceBindings({graph,sampler});
assert.equal(bindings.profiles.length,3);
assert.equal(bindings.profileId,'wai');
let result=bindings.getExecutionBinding();
assert.deepEqual(result.samplerInputs,{model:['518',0],vae:['518',2],positive:['521',0],negative:['520',0]});
assert.deepEqual(result.inputOverrides['521'],{clip:['518',1]});
assert.equal(result.enableNodes.some(n=>n.id===553),false,'no LoRA must bypass the loader');
assert.deepEqual(result.removeInputs['501'],['refiner_model','refiner_positive','refiner_negative']);
bindings.settings.loras=[{name:'new.safetensors',strength:.7}];
result=bindings.getExecutionBinding();
assert.deepEqual(result.samplerInputs.model,['553',0]);
assert.equal(result.inputOverrides['553'].lora_1.lora,'new.safetensors');
assert.equal(result.inputOverrides['553'].lora_1.strength,.7);
assert.equal(result.inputOverrides['553'].lora_7.on,false,'old enabled LoRA rows must not leak into the workspace choice');
bindings.settings.positive='new prompt';
assert.equal(bindings.getExecutionBinding().inputOverrides['521'].text,'new prompt');
bindings.selectProfile('anima');
assert.deepEqual(applied,['anima']);
assert.deepEqual(bindings.getExecutionBinding().samplerInputs.model,['359',0]);
assert.deepEqual(bindings.getExecutionBinding().inputOverrides['547'].clip,['357',0]);
bindings.selectProfile('wai');
assert.equal(bindings.settings.positive,'new prompt');
assert.equal(ckpt.widgets[0].value,'wai.safetensors','read-only discovery must not change a model widget');
console.log('Workspace bindings: existing presets, WAI/Anima mapping, no LoRA, LoRA strengths, prompt edits and profile isolation passed');
+67
View File
@@ -0,0 +1,67 @@
"""Workspace validation must not execute, save or modify the submitted graph."""
import importlib.util
from pathlib import Path
import types
import unittest
SPEC = importlib.util.spec_from_file_location("adachi_workspace_execution", Path(__file__).resolve().parents[1] / "workspace_execution.py")
workspace = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(workspace)
class WorkspaceTests(unittest.IsolatedAsyncioTestCase):
def prompt(self):
return {
"1": {"class_type": "ImageSource", "inputs": {}},
"2": {"class_type": "ImageInvert", "inputs": {"image": ["1", 0]}},
"3": {"class_type": "AdachiImageCompare", "inputs": {"original": ["1", 0], "result": ["2", 0]}},
"4": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"5": {"class_type": "NotInstalled", "inputs": {}},
}
async def test_only_comparison_branch_is_validated(self):
original = self.prompt()
calls = []
async def validate(prompt_id, prompt, targets):
calls.append((prompt_id, prompt, targets))
return True, None, ["3"], {}
server = types.SimpleNamespace(trigger_on_prompt=lambda data: data)
data, _, valid = await workspace.prepare_workspace(server, types.SimpleNamespace(validate_prompt=validate), {"prompt": original, "compare_id": "3"})
self.assertTrue(valid[0])
self.assertEqual(set(data["prompt"]), {"1", "2", "3"})
self.assertEqual(calls[0][2], ["3"])
self.assertEqual(set(original), {"1", "2", "3", "4", "5"})
async def test_hooks_restore_links_before_pruning(self):
original = self.prompt()
original["2"]["inputs"].clear()
def hook(data):
data["prompt"]["2"]["inputs"]["image"] = ["1", 0]
return data
def validate(prompt):
self.assertIn("1", prompt)
return True, None, ["3"], {}
server = types.SimpleNamespace(trigger_on_prompt=hook)
await workspace.prepare_workspace(server, types.SimpleNamespace(validate_prompt=validate), {"prompt": original, "compare_id": "3"})
self.assertEqual(original["2"]["inputs"], {})
def test_missing_dependency_and_non_comparison_output_rejected(self):
with self.assertRaises(ValueError):
workspace.dependency_prompt(self.prompt(), "4")
prompt = self.prompt()
del prompt["2"]
with self.assertRaisesRegex(ValueError, "missing node"):
workspace.dependency_prompt(prompt, "3")
async def test_validation_error_is_returned_without_queueing(self):
def validate(prompt):
return False, {"message": "missing checkpoint"}, [], {"1": {"errors": []}}
server = types.SimpleNamespace(trigger_on_prompt=lambda data: data)
_, _, valid = await workspace.prepare_workspace(server, types.SimpleNamespace(validate_prompt=validate), {"prompt": self.prompt(), "compare_id": "3"})
self.assertFalse(valid[0])
self.assertEqual(valid[1]["message"], "missing checkpoint")
+58
View File
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import {WorkspaceHistory,captureSourceState,restoreSourceState} from "../web/workspace_history.js";
class Raster {
constructor(pixels) { this.pixels = pixels; this.width = 4; this.height = 3; }
toDataURL() { return JSON.stringify({pixels:this.pixels}); }
}
const decode = async data => new Raster(JSON.parse(data).pixels);
const source = name => ({src:name, width:4,height:3});
const context = prefix => ({mask:new Raster(prefix+"-mask"), baseMask:new Raster(prefix+"-base"),history:[{type:"brush",points:[{x:1,y:2}]}],future:[{type:"erase",points:[{x:2,y:1}]}],lastMask:prefix+"-serialized",dirty:false});
const widgetNames=["image","mask_data","source_data","edit_mode","crop_state","blend_data","layer_image"];
function node() {
const n={properties:{},widgets:widgetNames.map(name=>({name,value:name})),graph:{setDirtyCanvas(){}}};
const e=n.adachiEditor={...context("repaint"),image:source("original"),ready:true,loadVersion:1,maskRevision:0,pan:{x:1,y:2},zoom:2,keepFit:true,view:"overlay",filename:{textContent:"original.png"},sizeLabel:{},embed:{checked:false},flush(){},schedule(){},updateButtons(){}};
n.adachiCrop={contexts:{blend:context("blend")},target:"mask",layer:source("layer"),cropWidth:512,cropHeight:768,revision:0,acceptVersion:0,sync(){},syncPreset(){},stopOriginalPreview(){}};
return n;
}
{
const n=node(),e=n.adachiEditor,c=n.adachiCrop;
n.widgets.find(w=>w.name==="edit_mode").value="crop";
n.widgets.find(w=>w.name==="crop_state").value=JSON.stringify({locked:true,x:1,y:2,width:512,height:768,job:"job-a"});
const before=captureSourceState(n);
e.mask.pixels="edited";e.history[0].points[0].x=99;e.image=source("adopted");c.layer=source("new-layer");c.target="blend";
n.widgets.find(w=>w.name==="crop_state").value="{}";
await restoreSourceState(n,before,{decode});
assert.equal(e.image.src,"original");assert.equal(c.layer.src,"layer");
assert.equal(e.mask.pixels,"repaint-mask");assert.equal(e.baseMask.pixels,"repaint-base");
assert.equal(c.contexts.blend.mask.pixels,"blend-mask");assert.equal(c.target,"mask");
assert.equal(e.history[0].points[0].x,1);assert.equal(e.future[0].type,"erase");
assert.equal(JSON.parse(n.widgets.find(w=>w.name==="crop_state").value).job,"job-a");
assert.deepEqual(e.pan,{x:1,y:2});assert.equal(e.zoom,2);assert.equal(e.keepFit,true);
assert.notEqual(e.mask,before.contexts.mask.mask);
e.mask.pixels="changed-again";await restoreSourceState(n,before,{decode});assert.equal(e.mask.pixels,"repaint-mask");
}
{
const n=node(),c=n.adachiCrop,e=n.adachiEditor;
c.contexts.mask=context("saved-repaint");Object.assign(e,context("active-blend"));c.target="blend";
const before=captureSourceState(n);e.mask.pixels="lost";await restoreSourceState(n,before,{decode});
assert.equal(e.mask.pixels,"active-blend-mask");assert.equal(c.contexts.mask.mask.pixels,"saved-repaint-mask");assert.equal(c.target,"blend");
}
{
const n=node(),before=captureSourceState(n),old=n.adachiEditor.image;
await assert.rejects(restoreSourceState(n,before,{decode:async()=>{throw Error("broken PNG")}}),/broken PNG/);
assert.equal(n.adachiEditor.image,old);
await assert.rejects(restoreSourceState(n,before,{decode:async data=>{n.adachiEditor.loadVersion++;return decode(data)}}),/发生变化/);
assert.equal(n.adachiEditor.image,old);
}
{
const n=node(),h=new WorkspaceHistory(n,{maxEntries:3});h.checkpoint("original");
await h.transact("adopted",async()=>{n.adachiEditor.image=source("adopted");n.widgets[0].value="adopted.png"});
assert.equal(h.entries.length,2);assert.equal(h.current.label,"adopted");assert.equal(h.entries[0].state.image.src,"original");
const count=h.entries.length;await assert.rejects(h.transact("failed",async()=>{throw Error("upload failed")}),/upload failed/);assert.equal(h.entries.length,count);assert.equal(h.busy,false);
for(let i=0;i<6;i++){n.adachiEditor.image=source("next"+i);n.widgets[0].value="next"+i;h.checkpoint("next"+i);}
assert.equal(h.entries.length,3);assert.equal(h.entries[0].label,"original");assert.equal(h.current.label,"next5");
n.adachiEditor.stroke={};assert.throws(()=>h.checkpoint("stroke"),/结束当前编辑/);
}
console.log("Workspace source history: repaint/blend pixels, crop state, layer, commands, failure atomicity and retention passed");
+1 -1
View File
@@ -105,7 +105,7 @@ app.registerExtension({
const fitNodeToImage = () => {
cancelAnimationFrame(fitFrame);
fitFrame = requestAnimationFrame(() => {
if (!editor.ready || editor.destroyed || !node.graph) return;
if (!editor.ready || editor.destroyed || !node.graph || editor.root.closest(".adachi-workspace")) return;
const width = editor.canvas.clientWidth, height = editor.canvas.clientHeight;
if (!width || !height) return;
const overhead = node.size[1] - height;
+1 -1
View File
@@ -47,7 +47,7 @@ export function installInpaintWorkbench(nodeType) {
const scheduleSize = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
if (removed || !node.graph || !root.isConnected) return;
if (removed || !node.graph || !root.isConnected || root.closest(".adachi-workspace")) return;
const required = node.computeSize()[1];
if (Math.abs(node.size[1] - required) > 1) node.setSize([node.size[0], required]);
node.graph.setDirtyCanvas(true, true);
+1
View File
@@ -3,6 +3,7 @@ import { app } from "../../scripts/app.js";
export function routeWheelOutsideImage(root, canvas, isReady) {
const route = event => {
if (!root.contains(event.target)) return;
if (root.closest(".adachi-workspace")) return;
if (event.target === canvas && isReady()) return;
event.preventDefault(); event.stopPropagation();
// Preserve the user's ComfyUI navigation mode (zoom or pan).
+18
View File
@@ -0,0 +1,18 @@
// Read-only discovery for the inpainting workbench. The live graph remains
// authoritative: virtual Get/Set routes and group bypasses are resolved by
// ComfyUI and its installed extensions when a prompt is actually queued.
export function inspectWorkbench(graph) {
const nodes = Array.from(graph?._nodes || []);
const find = type => nodes.filter(node => node.type === type);
const editors = find("AdachiInlineMask");
const samplers = find("AdachiWebUIInpaint");
const comparisons = find("AdachiImageCompare");
const presets = find("AdachiGroupPresets");
const checkpoints = find("CheckpointLoaderSimple");
const loras = nodes.filter(node => node.type === "LoraLoader" || node.type === "Power Lora Loader (rgthree)");
const missing = [];
if (!editors.length) missing.push("inline_mask");
if (!samplers.length) missing.push("inpaint");
if (!comparisons.length) missing.push("compare");
return { editors, samplers, comparisons, presets, checkpoints, loras, missing };
}
+9
View File
@@ -0,0 +1,9 @@
.adachi-workspace{position:fixed;inset:0;z-index:12000;background:#eef1f4;color:#273340;font:14px/1.5 'Segoe UI','Microsoft YaHei',sans-serif;display:flex;flex-direction:column;color-scheme:light}
.adachi-workspace *{box-sizing:border-box}.adachi-workspace [hidden]{display:none!important}
.adachi-workspace header{height:64px;flex-shrink:0;background:#fff;border-bottom:1px solid #d9dfe6;display:flex;align-items:center;gap:16px;padding:0 24px}.adachi-workspace h1{font-size:19px;font-weight:650;margin:0 auto 0 0;letter-spacing:.2px}.adachi-workspace h1 small{display:block;font-size:10px;letter-spacing:1.8px;color:#7d8c9d;font-weight:500}
.adachi-workspace button,.adachi-workspace select,.adachi-workspace input,.adachi-workspace textarea{font:inherit;color:#263747;background:#fff;border:1px solid #ced7e1;border-radius:5px;padding:7px 10px}.adachi-workspace button{cursor:pointer;white-space:nowrap}.adachi-workspace button:hover{border-color:#6685aa;background:#f3f7fb}.adachi-workspace button:disabled{opacity:.4;cursor:not-allowed}.adachi-workspace button:focus-visible{outline:2px solid #3475b9;outline-offset:2px}.adachi-workspace button.active{border-color:#457fb3;background:#edf5fb;color:#1c629a}.adachi-workspace .primary{background:#276da7;color:#fff;border-color:#276da7}.adachi-workspace .primary:hover{background:#1a598a;color:white}
.adachi-workspace main{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:14px;padding:14px;flex:1;min-height:0}.adachi-workspace .aw-canvas{background:white;border:1px solid #dce2e9;border-radius:8px;display:flex;flex-direction:column;min-height:0;overflow:hidden}.adachi-workspace .aw-toolbar{display:flex;flex-wrap:wrap;gap:6px;padding:10px 12px;align-items:center;border-bottom:1px solid #e3e8ed}.adachi-workspace .aw-toolbar .spacer{flex:1}.adachi-workspace .aw-surface{flex:1;min-height:200px;position:relative;background:#e9edf1}.adachi-workspace aside{overflow:auto;background:#fff;border:1px solid #dce2e9;border-radius:8px;padding:16px}.adachi-workspace aside h2{font-size:12px;letter-spacing:1px;color:#708096;margin:0 0 12px}.adachi-workspace aside label{margin-top:10px}.adachi-workspace aside select,.adachi-workspace aside textarea{max-width:100%}.adachi-workspace aside textarea{width:100%;min-height:85px;resize:vertical}.adachi-workspace details{margin:8px 0}.adachi-workspace summary{cursor:pointer}
.adachi-workspace .aw-status{font-size:12px;color:#6c7d8d;max-width:50vw}.adachi-workspace .aw-status[data-state=ok]{color:#277f61}.adachi-workspace .aw-status[data-state=error]{color:#ac443c}.adachi-workspace .aw-footer{display:flex;align-items:center;gap:8px;padding:8px 12px;border-top:1px solid #e1e6eb;font-size:12px;color:#738393}.adachi-workspace .aw-footer select{max-width:260px;padding:4px 7px}.adachi-workspace .aw-note{padding:8px 12px;border-top:1px solid #e1e6eb;color:#6c7d8d;font-size:12px}.adachi-workspace .aw-result-image{width:100%;height:100%;position:absolute;object-fit:contain;background:#e9edf1}
.adachi-workspace .aw-embedded{position:absolute!important;inset:0!important;width:100%!important;height:100%!important;max-height:none!important;transform:none!important;visibility:visible!important}.adachi-workspace .dd-inpaint{--dd-bg:#fff;--dd-panel:#f3f5f8;--dd-line:#d3dce6;--dd-text:#263747;--dd-muted:#718195;--dd-accent:#327aad;color:#263747;color-scheme:light;background:#fff}.adachi-workspace .dd-inpaint button{background:#f3f6f9;color:#263747}.adachi-workspace .dd-inpaint .dd-stage{background-color:#e9edf1;background-image:linear-gradient(45deg,#e0e5eb 25%,transparent 25%),linear-gradient(-45deg,#e0e5eb 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#e0e5eb 75%),linear-gradient(-45deg,transparent 75%,#e0e5eb 75%)}.adachi-workspace .dd-inpaint input,.adachi-workspace .dd-inpaint select{background:#f7f9fb;color:#273747}.adachi-workspace .dd-inline-minimal{background:transparent}.adachi-workspace .dd-crop-bar{background:#ffffffed}.adachi-workspace .dd-inpaint .dd-upload-progress{background:#ffffffed;color:#273747}.adachi-workspace .aw-parameters{position:relative;min-height:100px}.adachi-workspace .aw-parameters>.dd-inpaint{position:relative!important;transform:none!important;inset:auto!important;height:auto!important;max-height:none!important;overflow:visible!important;width:100%!important}.adachi-workspace .dd-param-advanced-body{padding:8px 0}.adachi-workspace .dd-workbench{border:0}.adachi-workspace .dd-editor-controls{background:#fff}.adachi-workspace .dd-inpaint .dd-actions{padding:8px 0}.adachi-workspace .dd-inpaint .dd-settings{gap:6px;padding:6px 0}.adachi-workspace .dd-inpaint .dd-tools{padding:5px 0}.adachi-workspace .dd-inpaint .dd-tools button{padding:4px;font-size:11px}.adachi-workspace .dd-inpaint .dd-stage canvas{outline:none}
@media(max-width:850px){.adachi-workspace main{grid-template-columns:1fr;overflow:auto}.adachi-workspace .aw-canvas{min-height:65vh}.adachi-workspace aside{overflow:visible}.adachi-workspace header{padding:8px 12px;height:auto;min-height:60px;flex-wrap:wrap}.adachi-workspace .aw-status{max-width:55vw}.adachi-workspace .aw-toolbar button{padding:6px}}
.adachi-workspace .aw-presets{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px}.adachi-workspace .aw-presets button{flex:1;padding:7px 5px;font-size:12px}.adachi-workspace .aw-presets button[aria-pressed=true]{background:#eaf3fb;border-color:#5185b2;color:#205b8b}.adachi-workspace .aw-bindings label{display:block;font-size:12px;color:#51667b}.adachi-workspace .aw-bindings label select{width:100%;display:block;margin:5px 0 10px}.adachi-workspace .aw-bindings textarea{display:block;margin-top:5px;line-height:1.5}.adachi-workspace .aw-bindings details{border-bottom:1px solid #e1e7ed;padding:5px 0 10px}.adachi-workspace .aw-bindings summary{font-size:12px;color:#44647e}.adachi-workspace .aw-lora-row{display:grid;grid-template-columns:minmax(0,1fr) 65px 30px;gap:4px;margin:6px 0}.adachi-workspace .aw-lora-row input{min-width:0;width:100%;padding:5px}.adachi-workspace .aw-lora-row button{padding:3px}.adachi-workspace .dd-inline-minimal>.dd-status:not([hidden]){background:#ffffffec;color:#963b36}.adachi-workspace .dd-inpaint .dd-param-grid{gap:8px}
+147
View File
@@ -0,0 +1,147 @@
// Bind the workbench to existing graph widgets; no model or LoRA is loaded here.
const widget = (node, name) => node?.widgets?.find(w => w.name === name);
const nodeList = graph => Array.from(graph?._nodes || []);
const ref = (node, slot = 0) => [String(node.id), slot];
const valueList = w => {
const values = typeof w?.options?.values === 'function' ? w.options.values() : w?.options?.values;
return Array.isArray(values) ? values.map(String) : [];
};
const stateKey = 'adachi_workspace_bindings';
export class WorkspaceBindings {
constructor({graph, sampler, onChange = () => {}, markChanged = () => {}}) {
Object.assign(this, {graph, sampler, onChange, markChanged});
this.nodes = nodeList(graph);
this.controller = this.nodes.find(n => n.type === 'AdachiGroupPresets')?.adachiGroupPresets?.model;
this.owner = this.controller?.node || sampler;
this.owner.properties ??= {};
this.owner.properties[stateKey] ??= {profiles:{}};
this.catalog = {checkpoints:[], loras:[]};
this.profiles = this.controller?.data?.presets?.map(p => ({id:p.id, name:p.name, preset:p})) || [];
if (!this.profiles.length) this.profiles = [{id:'current',name:'当前工作流'}];
this.profileId = this.profiles.find(p => /wai/i.test(p.name))?.id || this.controller?.data?.activeId || this.profiles[0].id;
this.resolve();
}
resolve() {
const profile = this.profiles.find(p => p.id === this.profileId) || this.profiles[0];
this.profileId = profile.id;
const byId = id => this.nodes.find(n => String(n.id) === String(id));
const checkpoints = this.nodes.filter(n => ['CheckpointLoaderSimple','UNETLoader','UNETLoaderGGUF'].includes(n.type));
const wanted = /wai/i.test(profile.name) ? /wai/i : /anima/i.test(profile.name) ? /anima/i : null;
this.model = checkpoints.find(n => wanted?.test(String(widget(n,'ckpt_name')?.value || widget(n,'unet_name')?.value))) || checkpoints.find(n=>n.mode===0) || checkpoints[0];
const isTemplate = byId(501)?.type === 'AdachiWebUIInpaint' && byId(518)?.type === 'CheckpointLoaderSimple' && byId(521)?.type === 'CLIPTextEncode';
this.isTemplate = isTemplate;
if (isTemplate && /wai/i.test(profile.name)) {
this.model = byId(518); this.positive = byId(521); this.negative = byId(520);
this.clip = this.model; this.clipSlot = 1; this.vae = this.model; this.vaeSlot = 2; this.lora = byId(553) || byId(519);
} else if (isTemplate && /anima/i.test(profile.name)) {
this.model = byId(359); this.positive = byId(547); this.negative = byId(16);
this.clip = byId(357); this.clipSlot = 0; this.vae = byId(358); this.vaeSlot = 0; this.lora = byId(360);
} else {
const source = name => {
const link = this.graph.links?.[this.sampler.inputs?.find(i=>i.name===name)?.link];
return link ? this.graph.getNodeById(link.origin_id) : null;
};
this.positive=source('positive');this.negative=source('negative');
this.clip=this.model?.type==='CheckpointLoaderSimple'?this.model:null;this.clipSlot=1;
this.vae=this.model?.type==='CheckpointLoaderSimple'?this.model:null;this.vaeSlot=2;
this.lora=this.nodes.find(n=>n.type==='Power Lora Loader (rgthree)' && n.mode===0) || this.nodes.find(n=>n.type==='LoraLoader' && n.mode===0);
}
const all=this.owner.properties[stateKey].profiles;
all[this.profileId] ??= {loras:[]};
this.settings=all[this.profileId];
this.settings.loras ??= [];
return profile;
}
changed() { this.markChanged(); this.onChange(this); }
selectProfile(id) {
if (!this.profiles.some(p=>p.id===id)) throw Error('分组预设不存在');
this.profileId=id;
this.controller?.apply(id);
this.resolve(); this.render(); this.changed();
}
getExecutionBinding(id=this.profileId) {
if (id!==this.profileId) { this.profileId=id; this.resolve(); }
const binding={enableNodes:[],samplerInputs:{},inputOverrides:{},removeInputs:{}};
const add=node=>{if(node && !binding.enableNodes.includes(node))binding.enableNodes.push(node);};
// Generic graphs keep their original routing unless complete core
// model/conditioning nodes can be identified.
if (!this.model || !this.clip || !this.vae || this.positive?.type!=='CLIPTextEncode' || this.negative?.type!=='CLIPTextEncode') return binding;
for(const n of [this.model,this.clip,this.vae,this.positive,this.negative])add(n);
let model=ref(this.model),clip=ref(this.clip,this.clipSlot);
const selected=this.settings.loras.filter(l=>l.name && l.on!==false);
if(selected.length) {
if(!this.lora)throw Error('工作流中没有可用于此方案的 LoRA 加载器');
add(this.lora);
const inputs={model,clip};
if(this.lora.type==='Power Lora Loader (rgthree)') {
for(const w of this.lora.widgets || [])if(w.value && typeof w.value.lora==='string')inputs[w.name]={...w.value,on:false};
selected.forEach((l,i)=>{inputs[`lora_${i+1}`]={on:true,lora:l.name,strength:Number(l.strength ?? 1),strengthTwo:Number(l.clipStrength ?? l.strength ?? 1)};});
} else {
if(selected.length>1)throw Error('当前普通 LoRA 节点只能选择一个 LoRA');
Object.assign(inputs,{lora_name:selected[0].name,strength_model:Number(selected[0].strength ?? 1),strength_clip:Number(selected[0].clipStrength ?? selected[0].strength ?? 1)});
}
binding.inputOverrides[String(this.lora.id)]=inputs;
model=ref(this.lora);clip=ref(this.lora,1);
}
Object.assign(binding.samplerInputs,{model,vae:ref(this.vae,this.vaeSlot),positive:ref(this.positive),negative:ref(this.negative)});
for(const [kind,node] of [['positive',this.positive],['negative',this.negative]]) {
binding.inputOverrides[String(node.id)]={clip};
if(Object.hasOwn(this.settings,kind))binding.inputOverrides[String(node.id)].text=this.settings[kind];
}
// A stale optional refiner link can point into a different model family.
// The workspace only uses a refiner when staging is explicitly enabled.
if(!this.settings.useRefiner || Number(widget(this.sampler,'refiner_switch')?.value ?? 1)>=1) binding.removeInputs[String(this.sampler.id)]=['refiner_model','refiner_positive','refiner_negative'];
return binding;
}
async loadCatalog(fetchApi) {
const get = async path => {const r=await fetchApi(path);if(!r.ok)throw Error(`模型列表加载失败(${r.status})`);return r.json();};
const results=await Promise.allSettled([get('/object_info/CheckpointLoaderSimple'),get('/models/loras')]);
if(this.destroyed)return;
if(results[0].status==='fulfilled')this.catalog.checkpoints=results[0].value?.CheckpointLoaderSimple?.input?.required?.ckpt_name?.[0] || [];
if(results[1].status==='fulfilled')this.catalog.loras=Array.isArray(results[1].value)?results[1].value:[];
this.catalogError=results.some(r=>r.status==='rejected')?'部分模型列表不可用,可保留工作流中的现有选择':'';
this.render();
}
createPanel() { this.root=document.createElement('section');this.root.className='aw-bindings';this.render();return this.root; }
render() {
if(!this.root || this.destroyed)return;
const doc=this.root.ownerDocument;
const el=(tag,text)=>{const e=doc.createElement(tag);if(text)e.textContent=text;return e;};
const row=(label,control)=>{const l=el('label',label);l.append(control);return l;};
const select=(values,current,change)=>{const s=el('select');for(const [value,label] of values){const o=el('option',label);o.value=value;s.append(o);}s.value=current;s.onchange=()=>change(s.value);return s;};
this.root.replaceChildren();
const presets=el('div');presets.className='aw-presets';
for(const p of this.profiles){const b=el('button',p.name);b.type='button';b.setAttribute('aria-pressed',String(p.id===this.profileId));b.onclick=()=>this.selectProfile(p.id);presets.append(b);}
this.root.append(presets);
const details=el('details'),summary=el('summary','主模型与 LoRA');details.append(summary);details.open=!!this.detailsOpen;details.ontoggle=()=>{this.detailsOpen=details.open;};
const modelWidget=widget(this.model,'ckpt_name') || widget(this.model,'unet_name');
if(modelWidget){const current=String(modelWidget.value||'');const values=[...new Set([...valueList(modelWidget),...(this.model.type==='CheckpointLoaderSimple'?this.catalog.checkpoints:[]),current])].filter(Boolean);details.append(row('主模型',select(values.map(v=>[v,v]),current,v=>{modelWidget.value=v;modelWidget.callback?.(v);this.changed();})));}
else details.append(el('p','当前方案的模型由原工作流控制'));
const loraTitle=el('div','LoRA');details.append(loraTitle);
if(!this.settings.loras.length)details.append(el('small','不使用 LoRA'));
this.settings.loras.forEach((l,index)=>{
const line=el('div');line.className='aw-lora-row';
const names=[...new Set([...this.catalog.loras,l.name].filter(Boolean))];
const pick=select([['','不使用'],...names.map(n=>[n,n])],l.name||'',v=>{l.name=v;this.changed();});pick.setAttribute('aria-label',`LoRA ${index+1}`);
const strength=el('input');strength.type='number';strength.min='-10';strength.max='10';strength.step='0.05';strength.value=l.strength??1;strength.setAttribute('aria-label',`LoRA ${index+1} 强度`);strength.onchange=()=>{const n=Number(strength.value);if(Number.isFinite(n)){l.strength=Math.max(-10,Math.min(10,n));strength.value=l.strength;this.changed();}};
const remove=el('button','×');remove.type='button';remove.title='移除此 LoRA';remove.onclick=()=>{this.settings.loras.splice(index,1);this.render();this.changed();};
line.append(pick,strength,remove);details.append(line);
});
const add=el('button','+ 添加 LoRA');add.type='button';add.disabled=!this.lora;add.onclick=()=>{this.settings.loras.push({name:'',strength:1});this.render();this.changed();};details.append(add);
const refiner=el('input');refiner.type='checkbox';refiner.checked=!!this.settings.useRefiner;
refiner.onchange=()=>{this.settings.useRefiner=refiner.checked;this.changed();};
details.append(row('使用工作流中的 Refiner(需与主模型兼容)',refiner));
if(this.catalogError)details.append(el('small',this.catalogError));
this.root.append(details);
for(const [kind,node,label] of [['positive',this.positive,'正面提示词'],['negative',this.negative,'负面提示词']]) {
const w=widget(node,'text');if(!w)continue;
const text=el('textarea');text.rows=kind==='positive'?6:3;
text.value=this.settings[kind] ?? w.value ?? '';
text.placeholder=node.inputs?.some(i=>i.name==='text' && i.link!=null)?'保持原工作流提示词;输入后以这里为准':'';
text.oninput=()=>{this.settings[kind]=text.value;this.changed();};
this.root.append(row(label,text));
}
}
destroy() {this.destroyed=true;this.root?.remove();}
}
+139
View File
@@ -0,0 +1,139 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
function apiNodeId(node) { return String(typeof node === "object" ? node.id : node); }
const graphTasks = new WeakMap();
export function prepareWorkspacePrompt(options) {
const graph = options.editorNode?.graph;
if (!graph) return Promise.reject(new Error("工作流已关闭"));
const previous = graphTasks.get(graph) || Promise.resolve();
const task = previous.catch(() => {}).then(() => prepareSnapshot(options));
graphTasks.set(graph, task);
return task;
}
async function prepareSnapshot({editorNode, samplerNode, compareNode, binding = {}}) {
if (!editorNode?.adachiEditor?.ready) throw new Error("请先上传原图");
const graph = editorNode.graph;
if (!graph || graph !== app.graph || samplerNode?.graph !== graph || compareNode?.graph !== graph) {
throw new Error("工作流已切换,请重新打开工作台");
}
const enabled = new Set([editorNode, samplerNode, compareNode, ...(binding.enableNodes || [])]);
const overrides = {...binding.inputOverrides};
overrides[apiNodeId(samplerNode)] = {...overrides[apiNodeId(samplerNode)], ...binding.samplerInputs,
image: [apiNodeId(editorNode), 0], mask: [apiNodeId(editorNode), 1]};
overrides[apiNodeId(compareNode)] = {...overrides[apiNodeId(compareNode)],
original: [apiNodeId(editorNode), 0], result: [apiNodeId(samplerNode), 0]};
const linked = id => graph.links instanceof Map ? graph.links.get(id) : graph.links[id];
const all = Array.from(graph._nodes || []), previous = all.map(node => [node, node.mode]);
const rollback = [];
// A dormant branch can contain missing virtual routes. Hide those branches
// during native serialization, and compile the explicitly bound links in
// a reversible graph transaction rather than relying on bypass resolution.
const pending = [...enabled];
while (pending.length) {
const node = pending.pop();
for (const input of node.inputs || []) {
if ((binding.removeInputs?.[apiNodeId(node)] || []).includes(input.name)) continue;
const override = overrides[apiNodeId(node)]?.[input.name];
if (override !== undefined) continue;
const link = input.link == null ? null : linked(input.link);
const upstream = link && graph.getNodeById(link.origin_id);
if (upstream && !enabled.has(upstream)) { enabled.add(upstream); pending.push(upstream); }
}
}
editorNode.adachiEditor.flush();
let prepared;
try {
for (const node of all) node.mode = enabled.has(node) ? 0 : 2;
let nextLink = -1000000;
const sampleLink = graph.links instanceof Map ? graph.links.values().next().value : Object.values(graph.links)[0];
for (const node of enabled) {
const changes = overrides[apiNodeId(node)] || {};
const removals = binding.removeInputs?.[apiNodeId(node)] || [];
for (const input of node.inputs || []) {
if (!(input.name in changes) && !removals.includes(input.name)) continue;
const old = input.link; rollback.push(() => { input.link = old; });
const value = changes[input.name];
if (removals.includes(input.name) || !Array.isArray(value) || value.length !== 2 || typeof value[0] !== "string") {
input.link = null; continue;
}
while (linked(nextLink)) nextLink--;
const id = nextLink--;
const origin = graph.getNodeById(value[0]);
if (!origin?.outputs?.[value[1]]) throw new Error(`所选模型分支缺少输出 #${value[0]}:${value[1]}`);
const originSlot = origin.outputs[value[1]], oldLinks = originSlot.links;
originSlot.links = [...(oldLinks || []), id];
const link = Object.assign(Object.create(sampleLink ? Object.getPrototypeOf(sampleLink) : Object.prototype),
{id, origin_id: origin.id, origin_slot: value[1], target_id: node.id, target_slot: node.inputs.indexOf(input), type: input.type});
if (graph.links instanceof Map) graph.links.set(id, link); else graph.links[id] = link;
input.link = id;
rollback.push(() => { originSlot.links = oldLinks; if (graph.links instanceof Map) graph.links.delete(id); else delete graph.links[id]; });
}
}
prepared = await app.graphToPrompt();
} finally {
for (const undo of rollback.reverse()) undo();
for (const [node, mode] of previous) node.mode = mode;
}
if (app.graph !== graph) throw new Error("工作流已切换,请重新检查");
// Save the user's real editable graph in PNG metadata, never the temporary
// compile-only modes or synthetic links used to select an execution path.
prepared.workflow = graph.serialize();
const output = prepared.output;
const samplerId = apiNodeId(samplerNode), compareId = apiNodeId(compareNode);
if (!output?.[samplerId] || !output?.[compareId]) throw new Error("重绘或对比节点未出现在执行图中");
Object.assign(output[samplerId].inputs, binding.samplerInputs || {});
for (const [id, inputs] of Object.entries(binding.inputOverrides || {})) {
if (!output[id]) throw new Error(`所选模型分支缺少节点 #${id}`);
Object.assign(output[id].inputs, inputs);
}
for (const [id, names] of Object.entries(binding.removeInputs || {})) {
for (const name of names) if (output[id]?.inputs) delete output[id].inputs[name];
}
// Force the requested inpaint editor and final result; old feedback wires or
// alternative comparison input routes must not launch other image branches.
output[samplerId].inputs.image = [apiNodeId(editorNode), 0];
output[samplerId].inputs.mask = [apiNodeId(editorNode), 1];
output[compareId].inputs.original = [apiNodeId(editorNode), 0];
output[compareId].inputs.result = [samplerId, 0];
return {
prompt: output, compare_id: compareId, client_id: api.clientId || "",
extra_data: {
extra_pnginfo: {workflow: prepared.workflow},
auth_token_comfy_org: api.authToken,
api_key_comfy_org: api.apiKey,
},
};
}
function errorMessage(data) {
const nodes = Object.entries(data.node_errors || {}).flatMap(([id, node]) =>
(node.errors || []).map(e => `#${id} ${e.message || ""}: ${e.details || ""}`));
return nodes.join("\n") || data.error?.message || "工作流检查未通过";
}
async function request(path, prepared) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), path === "submit" ? 60000 : 20000);
let response;
try {
response = await api.fetchApi(`/adachi-inpaint/workspace/${path}`, {
method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(prepared), signal: controller.signal,
});
} catch (error) {
if (error.name === "AbortError") throw new Error(path === "submit" ? "提交超时,请先检查任务队列,避免重复生成" : "连接检查超时,请检查网络后重试");
throw error;
} finally { clearTimeout(timer); }
let data;
try { data = await response.json(); }
catch { throw new Error(`工作流服务不可用 (${response.status}),请确认已更新并重启 ComfyUI`); }
if (!response.ok || !data.valid) {
const error = new Error(errorMessage(data)); error.nodeErrors = data.node_errors || {}; throw error;
}
return data;
}
export const validateWorkspace = prepared => request("validate", prepared);
export const submitWorkspace = prepared => request("submit", prepared);
+160
View File
@@ -0,0 +1,160 @@
// Session-only source checkpoints for the full-screen workspace. Capturing and
// restoring never uploads a new image or serializes the rest of the workflow.
const WIDGETS = ["image", "mask_data", "source_data", "edit_mode", "crop_state", "blend_data", "layer_image"];
const copyCommands = commands => (commands || []).map(command => ({ ...command,
...(command.points ? {points: command.points.map(point => ({...point}))} : {}),
}));
function contextSnapshot(context) {
return {
mask: context.mask.toDataURL("image/png"),
baseMask: context.baseMask.toDataURL("image/png"),
history: copyCommands(context.history), future: copyCommands(context.future),
lastMask: context.lastMask || "", dirty: !!context.dirty,
};
}
async function decodeCanvas(data) {
const image = new Image(); image.src = data;
await image.decode();
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth || image.width; canvas.height = image.naturalHeight || image.height;
canvas.getContext("2d").drawImage(image, 0, 0);
return canvas;
}
export function captureSourceState(node) {
const editor = node.adachiEditor, crop = node.adachiCrop;
if (!editor?.ready || !editor.image || editor.destroyed) throw Error("请先载入原图");
if (editor.stroke || editor.pointerId != null || crop?.busy) throw Error("请先结束当前编辑");
editor.flush();
const target = crop?.target || "mask", contexts = {};
for (const [name, context] of Object.entries(crop?.contexts || {})) {
if (name !== target && context?.mask && context?.baseMask) contexts[name] = contextSnapshot(context);
}
contexts[target] = contextSnapshot(editor);
const state = {
widgets: Object.fromEntries(WIDGETS.map(name => [name, node.widgets?.find(widget => widget.name === name)?.value])),
image: editor.image, layer: crop?.layer || null, contexts, target,
cropWidth: crop?.cropWidth, cropHeight: crop?.cropHeight,
filename: editor.filename?.textContent || "", embed: !!editor.embed?.checked,
view: editor.view, zoom: editor.zoom, pan: {...editor.pan}, keepFit: !!editor.keepFit,
};
state.key = JSON.stringify([state.widgets, state.image.src, state.layer?.src, state.contexts, state.target]);
return state;
}
function releaseContexts(contexts, active) {
const canvases = new Set();
for (const context of Object.values(contexts || {})) for (const name of ["mask", "baseMask"]) {
if (context?.[name] && context[name] !== active?.mask && context[name] !== active?.baseMask) canvases.add(context[name]);
}
for (const canvas of canvases) canvas.width = canvas.height = 0;
}
export async function restoreSourceState(node, state, {decode = decodeCanvas, onRestore} = {}) {
const editor = node.adachiEditor, crop = node.adachiCrop;
if (!editor || editor.destroyed || !node.graph) throw Error("原图节点已移除");
if (editor.stroke || editor.pointerId != null || crop?.busy) throw Error("请先结束当前编辑");
const loadVersion = editor.loadVersion;
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)]);
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) {
throw Error("图片在恢复过程中发生变化,请重试");
}
} catch (error) { releaseContexts(contexts); throw error; }
const active = contexts[state.target];
if (!active) { releaseContexts(contexts); throw Error("历史蒙版状态无效"); }
const oldContexts = {...(crop?.contexts || {}), live: {mask:editor.mask, baseMask:editor.baseMask}};
crop?.stopOriginalPreview?.();
for (const [name, value] of Object.entries(state.widgets)) {
const widget = node.widgets?.find(widget => widget.name === name);
if (widget && value !== undefined) widget.value = value;
}
editor.loadVersion++;
editor.image = state.image;
Object.assign(editor, active);
editor.view = state.view; editor.zoom = state.zoom; editor.pan = {...state.pan};
editor.keepFit = state.keepFit; editor.ready = true;
editor.stroke = null; editor.pointerId = null; editor.drag = null; editor.cursor = null;
editor.sceneKey = null; editor.maskRevision++;
if (editor.filename) { editor.filename.textContent = state.filename; editor.filename.title = state.filename; }
if (editor.sizeLabel) editor.sizeLabel.textContent = `${state.image.width} × ${state.image.height}`;
if (editor.embed) editor.embed.checked = state.embed;
node.properties ??= {}; node.properties.adachi_keep_fit = state.keepFit;
if (editor.empty) editor.empty.hidden = true;
if (crop) {
clearTimeout(crop.resultTimer); crop.pendingResult = null; crop.acceptVersion++;
crop.contexts = contexts; crop.target = state.target; crop.layer = state.layer;
crop.cropWidth = state.cropWidth; crop.cropHeight = state.cropHeight;
crop.hover = null; crop.revision++; crop.patchKey = null;
crop.syncPreset?.(); crop.sync?.();
}
releaseContexts(oldContexts, active);
editor.updateButtons?.(); editor.schedule?.();
node.graph.setDirtyCanvas?.(true, true);
await onRestore?.(state);
return state;
}
function estimateBytes(entries) {
const images = new Set(); let bytes = 0;
for (const {state} of entries) {
bytes += state.key.length * 2;
for (const image of [state.image, state.layer]) if (image && !images.has(image)) {
images.add(image); bytes += image.width * image.height * 4;
}
}
return bytes;
}
export class WorkspaceHistory {
constructor(node, {maxEntries = 12, maxBytes = 256 * 1024 * 1024, onChange, onRestore} = {}) {
Object.assign(this, {node, maxEntries:Math.max(2,maxEntries), maxBytes, onChange, onRestore});
this.entries = []; this.index = -1; this.busy = false;
}
get current() { return this.entries[this.index] || null; }
get canUndo() { return this.index > 0 && !this.busy; }
checkpoint(label = "原图") {
const state = captureSourceState(this.node);
if (this.current?.state.key === state.key) return this.current;
this.entries.splice(this.index + 1);
const entry = {id:crypto.randomUUID(), label:String(label), state};
this.entries.push(entry); this.index = this.entries.length - 1;
// Keep the original and current source reachable; discard intermediate
// checkpoints first when the session history reaches its budget.
while (this.entries.length > 2 && (this.entries.length > this.maxEntries || estimateBytes(this.entries) > this.maxBytes)) {
this.entries.splice(1,1); this.index--;
}
this.onChange?.(this); return entry;
}
async transact(label, operation) {
if (this.busy) throw Error("正在处理原图,请稍候");
this.busy = true; this.onChange?.(this);
try {
this.checkpoint(this.entries.length ? "编辑中的原图" : "初始原图");
const result = await operation();
this.checkpoint(label);
return result;
} finally { this.busy = false; this.onChange?.(this); }
}
async restore(index) {
if (this.busy) throw Error("正在处理原图,请稍候");
if (!Number.isInteger(index) || !this.entries[index]) throw Error("原图历史不存在");
this.busy = true; this.onChange?.(this);
try {
await restoreSourceState(this.node, this.entries[index].state, {onRestore:this.onRestore});
this.index = index;
return this.current;
} finally { this.busy = false; this.onChange?.(this); }
}
undo() { return this.restore(this.index - 1); }
original() { return this.restore(0); }
clear() { this.entries = []; this.index = -1; this.onChange?.(this); }
}
+106
View File
@@ -0,0 +1,106 @@
import { app } from '../../scripts/app.js';
import { api } from '../../scripts/api.js';
import { inspectWorkbench } from './workbench_analysis.js';
import { WorkspaceBindings } from './workspace_bindings.js';
import { WorkspaceHistory } from './workspace_history.js';
import { prepareWorkspacePrompt, validateWorkspace, submitWorkspace } from './workspace_execution.js';
import { syncCurrentDimensions } from './parameter_bridge.js?v=0.8.2';
const el=(tag,text,cls)=>{const e=document.createElement(tag);if(text)e.textContent=text;if(cls)e.className=cls;return e;};
const btn=(text,fn)=>{const b=el('button',text);b.type='button';b.onclick=fn;return b;};
let currentWorkspace=null;
export class Workspace {
constructor(){
this.mounts=[];this.disposers=[];this.closed=false;this.running=false;this.checkVersion=0;
this.root=el('div','','adachi-workspace');this.root.adachiWorkspace=this;this.root.setAttribute('role','dialog');this.root.setAttribute('aria-label','安达重绘工作台');
const head=el('header'),title=el('h1','重绘工作台');title.append(el('small','ADACHI / INPAINT'));head.append(title);
this.status=el('span','正在读取当前工作流…','aw-status');head.append(this.status,btn('检查连接',()=>this.check()),btn('返回工作流',()=>this.close()));this.root.append(head);
const main=el('main'),canvas=el('section','','aw-canvas'),toolbar=el('div','','aw-toolbar');
this.tabs={};for(const [id,name] of [['edit','编辑蒙版'],['compare','滑动对比'],['before','前图'],['after','后图']]){const b=btn(name,()=>this.show(id));this.tabs[id]=b;toolbar.append(b);}
toolbar.append(el('span','','spacer'));this.adoptButton=btn('采用此结果',()=>this.adopt());this.adoptButton.className='primary';toolbar.append(this.adoptButton);
this.surface=el('div','','aw-surface');this.still=el('img','','aw-result-image');this.still.alt='当前选择的前图或重绘结果';this.still.hidden=true;this.surface.append(this.still);
const foot=el('div','','aw-footer');this.undoButton=btn('↶ 回退',()=>this.restoreHistory('undo'));this.originalButton=btn('初始原图',()=>this.restoreHistory('original'));this.historySelect=el('select');this.historySelect.setAttribute('aria-label','选择已采用原图');this.historySelect.onchange=()=>this.restoreHistory(Number(this.historySelect.value));foot.append(this.undoButton,this.originalButton,this.historySelect);
this.note=el('div','生成结果供预览;点击采用后才会替换原图。','aw-note');canvas.append(toolbar,this.surface,foot,this.note);
this.side=el('aside');this.side.append(el('h2','工作流设置'));this.settings=el('div');this.params=el('div','','aw-parameters');this.runButton=btn('生成',()=>this.run());this.runButton.className='primary';this.runButton.style.width='100%';this.side.append(this.settings,this.runButton,this.params);main.append(canvas,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);
// Render first. Discovery/catalog and actual server validation run afterward.
setTimeout(()=>this.initialize().catch(e=>this.setStatus(e.message,'error')),0);
}
setStatus(text,state='pending'){if(this.closed)return;this.status.textContent=text;this.status.dataset.state=state;}
mount(element,target,canvas=false){
if(!element?.parentNode)throw Error('控件尚未初始化,请稍后重新打开');
const marker=document.createComment('workspace');element.before(marker);this.mounts.push({element,marker,style:element.getAttribute('style')});target.append(element);if(canvas)element.classList.add('aw-embedded');
}
async initialize(){
if(this.closed)return;this.graph=app.graph;this.nodes=inspectWorkbench(this.graph);
this.editorNode=this.nodes.editors.find(n=>n.mode===0)||this.nodes.editors[0];this.sampler=this.nodes.samplers.find(n=>n.mode===0)||this.nodes.samplers[0];this.compareNode=this.nodes.comparisons.find(n=>n.mode===0)||this.nodes.comparisons[0];
this.editor=this.editorNode?.adachiEditor;this.compare=this.compareNode?.adachiCompare;
if(!this.editor||!this.sampler?.adachiWorkbench||!this.compare)throw Error('当前工作流需要内联蒙版、WebUI局部重绘和图像对比节点');
this.history=this.editorNode.adachiWorkspaceHistory ||= new WorkspaceHistory(this.editorNode);
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('打开时原图');
this.mount(this.editor.root,this.surface,true);this.mount(this.compare.root,this.surface,true);this.mount(this.sampler.adachiWorkbench.root,this.params);
const editorWheel=this.editor.options.onGraphWheel,compareWheel=this.compare.options.onGraphWheel;
this.editor.options.onGraphWheel=()=>{};this.compare.options.onGraphWheel=()=>{};
this.disposers.push(()=>{this.editor.options.onGraphWheel=editorWheel;this.compare.options.onGraphWheel=compareWheel;});
this.bindings=new WorkspaceBindings({graph:this.graph,sampler:this.sampler,onChange:()=>this.scheduleCheck(),markChanged:()=>{app.canvas?.emitBeforeChange?.();app.canvas?.emitAfterChange?.();}});
this.settings.append(this.bindings.createPanel());
this.bindings.loadCatalog(api.fetchApi.bind(api)).then(()=>this.scheduleCheck()).catch(e=>this.setStatus(e.message,'error'));
const oldAdopt=this.compare.options.onAdopt;
this.compare.options.onAdopt=(...args)=>this.history.transact('采用重绘结果',()=>oldAdopt(...args));
this.disposers.push(()=>{this.compare.options.onAdopt=oldAdopt;});
const oldLoad=this.compare.loadSelected;
this.compare.loadSelected=async(...args)=>{const result=await oldLoad.apply(this.compare,args);if(!this.closed){this.show(this.view||'edit');this.updateButtons();}return result;};
this.disposers.push(()=>{this.compare.loadSelected=oldLoad;});
const oldUpload=this.editor.upload;
this.editor.upload=async(...args)=>{await oldUpload(...args);if(this.editor.ready&&!this.closed){await this.history.checkpoint('上传原图');this.syncHistory();this.scheduleCheck();}};
this.disposers.push(()=>{this.editor.upload=oldUpload;});
const onExecuted=this.compareNode.onExecuted;
this.compareNode.onExecuted=(output)=>{const ret=onExecuted?.call(this.compareNode,output);if(output?.adachi_results?.length){this.show('after');this.note.textContent='新结果已返回,原图尚未替换。';this.updateButtons();}return ret;};
this.disposers.push(()=>{this.compareNode.onExecuted=onExecuted;});
const event=(name,fn)=>{api.addEventListener(name,fn);this.disposers.push(()=>api.removeEventListener(name,fn));};
event('progress',e=>{if(this.running&&(!e.detail.prompt_id||e.detail.prompt_id===this.promptId))this.setStatus(`正在生成 ${e.detail.value} / ${e.detail.max}`);});
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='生成';this.setStatus(name==='execution_success'?'生成完成':e.detail.exception_message||'生成已停止',name==='execution_success'?'ok':'error');}});
this.timer=setInterval(()=>{if(app.graph!==this.graph||this.editor.destroyed)this.close();else this.updateButtons();},500);
this.pollTimer=setInterval(()=>this.pollResult(),2500);
this.promptId=this.sampler.adachiWorkspaceTask;this.running=!!this.promptId;if(this.running){this.runButton.disabled=true;this.runButton.textContent='生成中…';}this.show('edit');this.syncHistory();this.scheduleCheck();
}
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(){this.adoptButton.disabled=!this.compare?.ready||!!this.compare?.busy||this.running;for(const k of ['compare','before','after'])this.tabs[k].disabled=!this.compare?.ready;if(this.editor)this.editor.root.inert=this.running;this.settings.inert=this.running;this.params.inert=this.running;this.historySelect.disabled=this.running||!!this.history?.busy;this.undoButton.disabled=this.running||!this.history?.canUndo;this.originalButton.disabled=this.running||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);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('edit');this.note.textContent='已采用。可继续编辑,或从下方选择之前的原图。';this.scheduleCheck();}}
async restoreHistory(which){try{if(this.running||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);}
async prepare(){if(app.graph!==this.graph)throw Error('工作流已切换');if(!this.editor?.ready)throw Error('请先上传原图');return prepareWorkspacePrompt({editorNode:this.editorNode,samplerNode:this.sampler,compareNode:this.compareNode,binding:this.bindings.getExecutionBinding()});}
async check(){const version=++this.checkVersion;if(this.closed||this.running)return false;this.setStatus('后台检查中…');try{const prepared=await this.prepare();const result=await validateWorkspace(prepared);if(this.closed||version!==this.checkVersion)return false;if(result.valid===false||result.ok===false)throw Error(result.error?.message||result.error||'工作流检查未通过');this.setStatus('检查通过 · 可以生成','ok');return true;}catch(e){if(version===this.checkVersion)this.setStatus(e.message||String(e),'error');return false;}}
async run() {
if(this.running)return;
this.runButton.disabled=true; this.runButton.textContent='检查中…';
const seed=this.sampler?.widgets?.find(w=>w.name==='seed'), oldSeed=seed?.value;
try {
for(const widget of this.sampler.widgets || []) widget.beforeQueued?.();
const prepared=await this.prepare();
await validateWorkspace(prepared);
if(this.closed)return;
this.running=true; this.updateButtons();
const queued=await submitWorkspace(prepared);
this.promptId=queued.prompt_id;
this.sampler.adachiWorkspaceTask=this.promptId;
for(const widget of this.sampler.widgets || []) widget.afterQueued?.();
this.sampler.adachiParameters.refresh();
this.runButton.textContent='生成中…'; this.setStatus('已进入生成队列');
} catch(e) {
this.running=false;
if(seed)seed.value=oldSeed;
this.setStatus(e.message||String(e),'error');
} finally {
if(!this.running){this.runButton.disabled=false;this.runButton.textContent='生成';}
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?.[String(this.compareNode.id)];if(output)this.compareNode.onExecuted(output);this.running=false;this.sampler.adachiWorkspaceTask=null;this.runButton.disabled=false;this.runButton.textContent='生成';this.setStatus(entry.status.status_str==='success'?'生成完成':'生成失败,请查看 ComfyUI 错误信息',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);for(const dispose of this.disposers.reverse())dispose();if(this.history){this.history.onChange=null;this.history.onRestore=null;}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();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=preview1',import.meta.url).href;document.head.append(css);const launch=btn('打开重绘工作台',()=>{if(!currentWorkspace)currentWorkspace=new Workspace();});launch.id='adachi-workspace-launch';Object.assign(launch.style,{position:'fixed',right:'18px',bottom:'18px',zIndex:900,padding:'9px 16px',background:'#fff',color:'#29465e',border:'1px solid #b5c4d2',borderRadius:'6px',boxShadow:'0 2px 12px #1b3a5220'});document.body.append(launch);}});
+107
View File
@@ -0,0 +1,107 @@
"""Validate and queue only the selected inpainting output, without a test render."""
from copy import deepcopy
import inspect
import time
import uuid
def dependency_prompt(prompt, output_id):
"""Keep one output and its dependencies, including references into subgraphs."""
output_id = str(output_id)
if not isinstance(prompt, dict) or not isinstance(prompt.get(output_id), dict):
raise ValueError("The selected comparison is not present in the execution graph.")
if prompt[output_id].get("class_type") != "AdachiImageCompare":
raise ValueError("The workspace output must be an Adachi Image Compare node.")
found, pending = set(), [output_id]
while pending:
key = pending.pop()
if key in found:
continue
found.add(key)
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():
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}
async def prepare_workspace(server, execution, payload):
"""Use the same hooks, replacements and validation as ComfyUI /prompt."""
if not isinstance(payload, dict) or not isinstance(payload.get("prompt"), dict):
raise ValueError("No workflow execution graph was provided.")
target = str(payload.get("compare_id", ""))
data = deepcopy(payload)
# 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.")
replacements = getattr(server, "node_replace_manager", None)
if replacements is not None:
replacements.apply_replacements(data["prompt"])
data["prompt"] = dependency_prompt(data["prompt"], target)
data["partial_execution_targets"] = [target]
prompt_id = str(uuid.uuid4())
# Older supported ComfyUI builds have validate_prompt(prompt), newer builds
# include prompt_id and optional partial execution targets.
count = len(inspect.signature(execution.validate_prompt).parameters)
if count >= 3:
checked = execution.validate_prompt(prompt_id, data["prompt"], [target])
elif count == 2:
checked = execution.validate_prompt(prompt_id, data["prompt"])
else:
checked = execution.validate_prompt(data["prompt"])
valid = await checked if inspect.isawaitable(checked) else checked
return data, prompt_id, valid
def register_workspace_routes(server):
if getattr(server, "_adachi_workspace_execution_routes", False):
return
from aiohttp import web
import execution
async def handle(request, submit):
try:
payload = await request.json()
data, prompt_id, valid = await prepare_workspace(server, execution, payload)
response = {"valid": bool(valid[0]), "error": valid[1], "node_errors": valid[3]}
if not valid[0]:
return web.json_response(response, status=400 if submit else 200)
if submit:
extra = data.get("extra_data", {})
if not isinstance(extra, dict):
raise ValueError("Invalid workflow metadata.")
if isinstance(data.get("client_id"), str):
extra["client_id"] = data["client_id"]
extra["create_time"] = int(time.time() * 1000)
sensitive = {}
sensitive_keys = getattr(execution, "SENSITIVE_EXTRA_DATA_KEYS", None)
for key in sensitive_keys or ():
if key in extra:
sensitive[key] = extra.pop(key)
number = server.number
server.number += 1
item = (number, prompt_id, data["prompt"], extra, valid[2])
if sensitive_keys is not None:
item += (sensitive,)
server.prompt_queue.put(item)
response.update(prompt_id=prompt_id, number=number)
else:
response["outputs"] = valid[2]
response["node_count"] = len(data["prompt"])
return web.json_response(response)
except (ValueError, TypeError, KeyError) as exc:
return web.json_response({"valid": False, "error": {"message": str(exc)}, "node_errors": {}}, status=400)
@server.routes.post("/adachi-inpaint/workspace/validate")
async def validate(request):
return await handle(request, False)
@server.routes.post("/adachi-inpaint/workspace/submit")
async def submit(request):
return await handle(request, True)
server._adachi_workspace_execution_routes = True