Files

199 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {T} from './i18n.js';
import {inspectExecutionFlow,executionNode,executionId,graphNodeEntries} from './workspace_flow.js';
// 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 => graphNodeEntries(graph).map(entry=>entry.node);
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';
const presetController=(nodes,sampler,graph)=>{
const saved=sampler.properties?.adachi_workspace_connections;
if(saved && Object.hasOwn(saved,'controllerId'))return nodes.find(n=>n.type==='AdachiGroupPresets'&&n.mode===0&&executionId(graph,n)===saved.controllerId)?.adachiGroupPresets?.model;
const active=nodes.filter(n=>n.type==='AdachiGroupPresets'&&n.mode===0),local=active.filter(n=>n.graph===sampler.graph),pool=local.length?local:active;
return pool.length===1?pool[0].adachiGroupPresets?.model:undefined;
};
export class WorkspaceBindings {
constructor({graph, sampler, canvas, onChange = () => {}, markChanged = () => {}}) {
Object.assign(this, {graph, sampler, canvas, onChange, markChanged});
this.nodes = nodeList(graph);
this.controller = presetController(this.nodes,sampler,graph);
this.owner = this.controller?.node || sampler;
this.owner.properties ??= {};
this.owner.properties[stateKey] ??= {profiles:{}};
this.catalog = {checkpoints:[], unets:[], ggufs:[], clipggufs:[], clips:[], vaes:[], loras:[]};
this.catalogReady = new Set();
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:T("Current workflow")}];
this.profileId = 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 all=this.owner.properties[stateKey].profiles;
if(!all[this.profileId])all[this.profileId]={loraMode:'inherit',loras:[],useRefiner:!!this.sampler.inputs?.find(i=>i.name==='refiner_model'&&i.link!=null)};
this.settings=all[this.profileId];this.settings.loras??=[];
// Retain explicit selections made in older versions without retaining
// any of the old template IDs or model-family name matching.
this.settings.loraMode??='override';
this.settings.textOverrides??={};
if(this.compiledProfileId===this.profileId)this.updateExecutionGraph(this.compiled,this.samplerId,false);
else {this.flow=null;this.model=this.clip=this.vae=this.positive=this.negative=this.lora=null;this.promptSources={positive:T("Reading workflow connections"),negative:T("Reading workflow connections")};}
return profile;
}
updateExecutionGraph(output,samplerId=executionId(this.graph,this.sampler),render=true) {
this.compiled=output;this.compiledProfileId=this.profileId;this.samplerId=String(samplerId);
this.flow=inspectExecutionFlow(output,this.samplerId,id=>executionNode(this.graph,id));
this.model=this.flow.modelNodes[0]?.node||null;this.clip=this.flow.clipNodes[0]?.node||null;this.vae=this.flow.vaeNodes[0]?.node||null;
this.positive=this.flow.encoders.positive.length===1?this.flow.encoders.positive[0].node:null;
this.negative=this.flow.encoders.negative.length===1?this.flow.encoders.negative[0].node:null;
this.lora=this.flow.loras[0]?.node||null;
this.promptSources={positive:T("Actual sampler connections"),negative:T("Actual sampler connections")};
const signature=JSON.stringify([this.profileId,this.flow.refs,[...this.flow.modelNodes,...this.flow.clipNodes,...this.flow.vaeNodes].map(e=>e.id),Object.entries(this.flow.encoders).map(([kind,items])=>[kind,items.map(e=>[e.id,e.fields])])]);
// Do not replace a focused textarea for ordinary background validation.
if(render && signature!==this.routingSignature){this.routingSignature=signature;this.render();}
}
changed() { this.markChanged(); this.onChange(this); }
modelDependencies() {
const specs={CheckpointLoader:['ckpt_name','checkpoints',T("Main model"),'checkpoints'],CheckpointLoaderSimple:['ckpt_name','checkpoints',T("Main model"),'checkpoints'],
UNETLoader:['unet_name','unets',T("Main model"),'diffusion_models'],
UNETLoaderGGUF:['unet_name','ggufs',T("Main model"),'diffusion_models'],
CLIPLoader:['clip_name','clips',T("Text encoder"),'text_encoders'],
CLIPLoaderGGUF:['clip_name','clipggufs',T("Text encoder"),'text_encoders'],
VAELoader:['vae_name','vaes','VAE','vae']};
const entries=this.flow?[...this.flow.modelNodes,...this.flow.clipNodes,...this.flow.vaeNodes]:[];
const unique=entries.filter((e,i,all)=>all.findIndex(x=>x.id===e.id)===i);
return unique.flatMap(entry=>{
const node=entry.node;
if(['DualCLIPLoader','TripleCLIPLoader','DualCLIPLoaderGGUF'].includes(node?.type))return [1,2,3].flatMap(index=>{const w=widget(node,'clip_name'+index);return w?[{...entry,widget:w,key:node.type.endsWith('GGUF')?'clipggufs':'clips',label:T("Text encoder {index}",{index}),folder:'text_encoders'}]:[];});
const spec=specs[node?.type];if(!spec)return [];
const [name,key,label,folder]=spec,w=widget(node,name);
if(w)return [{...entry,widget:w,key,label,folder}];
return [];
});
}
missingDependencies() {
return this.modelDependencies().filter(d=>this.catalogReady.has(d.key) && d.widget.value && !this.catalog[d.key].includes(String(d.widget.value)));
}
refreshProfiles() {
this.nodes=nodeList(this.graph);
const controller = presetController(this.nodes,this.sampler,this.graph);
const signature = JSON.stringify([controller?.node?.id, controller?.data?.activeId, controller?.data?.presets?.map(p=>[p.id,p.name])]);
if(signature===this.presetSignature)return;
this.presetSignature=signature;
this.controller=controller;
this.owner=controller?.node || this.sampler;
this.owner.properties ??= {};
this.owner.properties[stateKey] ??= {profiles:{}};
this.profiles=controller?.data?.presets?.map(p=>({id:p.id,name:p.name,preset:p})) || [];
if(!this.profiles.length)this.profiles=[{id:'current',name:T("Current workflow")}];
this.profileId=controller?.data?.activeId || (this.profiles.some(p=>p.id===this.profileId)?this.profileId:this.profiles[0].id);
this.resolve();this.render();this.onChange(this);
}
selectProfile(id) {
if (!this.profiles.some(p=>p.id===id)) throw Error(T("The group preset no longer exists"));
this.profileId=id;
this.controller?.apply(id);
this.resolve(); this.render(); this.changed();
}
getExecutionBinding() {
const binding={samplerInputs:{},inputOverrides:{},removeInputs:{},
loraMode:this.settings.loraMode,loras:this.settings.loras,clipConsumers:this.flow?.clipConsumers||[]};
if(!this.flow)throw Error(T("Reading workflow connections; please wait"));
// Final MODEL and CONDITIONING refs remain untouched, including custom
// model patches, ControlNet and combinations of multiple encoders.
for(const [kind,entries] of Object.entries(this.flow.encoders))for(const entry of entries)for(const field of entry.fields){
const target=this.settings.textOverrides[entry.id]||{};
const legacy=entries.length===1 && field==='text' && Object.hasOwn(this.settings,kind);
if(Object.hasOwn(target,field)||legacy){binding.inputOverrides[entry.id]??={};binding.inputOverrides[entry.id][field]=Object.hasOwn(target,field)?target[field]:this.settings[kind];}
}
if(!this.settings.useRefiner || Number(widget(this.sampler,'refiner_switch')?.value??1)>=1)binding.removeInputs[this.samplerId]=['refiner_model','refiner_positive','refiner_negative'];
return binding;
}
async loadCatalog(fetchApi) {
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']];
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);}
});
this.catalogError=results.some(r=>r.status==='rejected')?T("Some model lists are unavailable; existing workflow selections remain usable"):'';
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';
const controllerActive = this.controller && this.controller.node.mode === 0;
if (!controllerActive) {const multiple=this.nodes.filter(n=>n.type==='AdachiGroupPresets'&&n.mode===0).length>1;const warning=el('p',multiple?T("Multiple group preset controllers found; choose one in Workflow connections."):T("No enabled Group Bypass Presets node; using the current workflow settings."));warning.className='aw-profile-warning';warning.setAttribute('role','status');this.root.append(warning);}
for(const p of controllerActive ? 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 missing=this.missingDependencies();
if(missing.length){const warning=el('details'),head=el('summary',T("Missing {count} model files",{count:missing.length}));warning.className='aw-model-warning';warning.title=missing.map(d=>T("{label}: {value}",{label:d.label,value:d.widget.value})).join('\n');warning.append(head);
const list=el('ul');for(const d of missing){const item=el('li',T("{label}: {value}",{label:d.label,value:d.widget.value}));item.append(el('small',`ComfyUI/models/${d.folder}/`));list.append(item);}warning.append(list);this.root.append(warning);}
const details=el('details'),summary=el('summary',T("Main model and LoRA"));details.append(summary);details.open=!!this.detailsOpen;details.ontoggle=()=>{this.detailsOpen=details.open;};
const dependencies=this.modelDependencies();
for(const d of dependencies){const w=d.widget,current=String(w.value||'');const values=[...new Set([...valueList(w),...this.catalog[d.key],current])].filter(Boolean);
const label=dependencies.filter(x=>x.label===d.label).length>1?`${d.label} · ${d.node.title||d.node.type} #${d.id}`:d.label;const field=row(label,select(values.map(v=>[v,v]),current,v=>{w.value=v;w.callback?.(v,this.canvas,d.node);this.render();this.changed();}));field.title=`ComfyUI/models/${d.folder}/`;details.append(field);}
if(!dependencies.some(d=>d.node===this.model))details.append(el('p',T("This profile uses the model from the workflow")));
const loraTitle=el('div','LoRA');details.append(loraTitle);
const inherit=el('input');inherit.type='checkbox';inherit.checked=this.settings.loraMode==='inherit';inherit.onchange=()=>{this.settings.loraMode=inherit.checked?'inherit':'override';this.render();this.changed();};const inheritRow=row(T("Inherit workflow LoRAs"),inherit);inheritRow.title=T("Keep existing LoRA nodes and both CLIP branches; uncheck to use the custom list below. An empty custom list disables recognized LoRAs.");details.append(inheritRow);
if(inherit.checked){
const active=[];for(const entry of this.flow?.loras||[]){const input=entry.api.inputs||{};if(entry.api.class_type==='Power Lora Loader (rgthree)'){for(const value of Object.values(input))if(value?.on && value.lora)active.push(`${value.lora} · ${value.strength??1}`);}else if(input.lora_name && (Number(input.strength_model)||Number(input.strength_clip)))active.push(`${input.lora_name} · ${input.strength_model??1}`);}
const info=el('small',active.length?active.join(' / '):T("No active LoRAs in the original workflow"));info.title=active.join('\n');details.append(info);
}
const customLoras=el('div');customLoras.hidden=inherit.checked;details.append(customLoras);
if(!this.settings.loras.length)customLoras.append(el('small',T("No 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([['',T("None")],...names.map(n=>[n,n])],l.name||'',v=>{l.name=v;this.changed();});pick.setAttribute('aria-label',T("LoRA {index}",{index: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',T("LoRA {index} strength",{index: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=T("Remove this LoRA");remove.onclick=()=>{this.settings.loras.splice(index,1);this.render();this.changed();};
line.append(pick,strength,remove);customLoras.append(line);
});
const add=el('button',T("+ Add LoRA"));add.type='button';add.disabled=!this.flow?.refs.model;add.onclick=()=>{this.settings.loras.push({name:'',strength:1});this.render();this.changed();};customLoras.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(T("Use the workflow Refiner (must be compatible with the main model)"),refiner));
if(this.catalogError)details.append(el('small',this.catalogError));
this.root.append(details);
for(const [kind,label] of [['positive',T("Positive prompt")],['negative',T("Negative prompt")]]) {
const entries=this.flow?.encoders[kind]||[];
if(!entries.length){const info=el('p',label+": "+(this.flow?T("Controlled by the original workflow"):T("Reading connections…")));info.title=T("Encoders are identified from the execution graph; unrecognized conditioning stays controlled by the original workflow");this.root.append(info);continue;}
const container=entries.length>1?el('details'):this.root;
if(entries.length>1){container.append(el('summary',T("{label} · {count} encoders",{label,count:entries.length})));this.root.append(container);}
for(const entry of entries)for(const name of entry.fields){
const w=widget(entry.node,name),linked=Array.isArray(entry.api.inputs?.[name]);
const saved=this.settings.textOverrides[entry.id]||{},legacy=entries.length===1&&name==='text'?this.settings[kind]:undefined;
const text=el('textarea');text.rows=kind==='positive'?6:3;
text.value=saved[name]??legacy??(linked?'':w?.value??entry.api.inputs?.[name]??'');
text.placeholder=linked?T("Inherit upstream dynamic text; type to override"):'';
text.dataset.executionId=entry.id;text.dataset.widget=name;
text.oninput=()=>{this.settings.textOverrides[entry.id]??={};this.settings.textOverrides[entry.id][name]=text.value;this.changed();};
const title=entries.length>1||entry.fields.length>1?`${label} · ${entry.node?.title||entry.api.class_type} · ${name}`:label;
text.setAttribute('aria-label',title);const field=row(title,text);
field.title=T("Actual sampler connections · {node} #{id} · {field}. {description}",{node:entry.node?.title||entry.api.class_type,id:entry.id,field:name,description:linked?T("Text comes from upstream; typing creates an override."):T("Changes affect only this encoder; downstream conditioning is preserved.")});
const reset=el('button','↶');reset.type='button';reset.title=T("Restore {label} from the workflow",{label:title});reset.onclick=()=>{delete this.settings.textOverrides[entry.id]?.[name];if(entries.length===1&&name==='text')delete this.settings[kind];this.render();this.changed();};field.append(reset);container.append(field);
}
}
}
destroy() {this.destroyed=true;this.catalogController?.abort();this.root?.remove();}
}