Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fa67923b5 | ||
|
|
5393f9f1fb | ||
|
|
6439d8558b | ||
|
|
3112eea855 | ||
|
|
be73993cbe | ||
|
|
c0f7c4e33a | ||
|
|
d1e5042e2a | ||
|
|
5bc3bc8bf6 | ||
|
|
a941f71142 | ||
|
|
6559de8255 | ||
|
|
1b67cb7ccb | ||
|
|
49a6a5a2a8 | ||
|
|
bb0a8bf5ed |
@@ -312,6 +312,12 @@ Both nodes support the same metadata fields:
|
||||
|
||||
Both nodes will automatically save prompts to the database and link any generated images to the prompt.
|
||||
|
||||
- **Save to library** (`save_to_library`, on by default; "Save to library" in the Focused, Split and Pair editors):
|
||||
turn it off while iterating and PromptManager ignores that node's runs. The prompt is not saved, the run is not
|
||||
counted and its images are not linked (a scan skips them too), while the node's output is unchanged. Workflows
|
||||
saved before this switch existed load with it on. If any PromptManager node feeding a sampler's positive input
|
||||
has it off, none of that run's images are linked, because the image cannot show which sampler produced it.
|
||||
|
||||
### 🖼️ Using the Output-Wide Gallery
|
||||
|
||||
The comprehensive gallery system provides complete access to all your ComfyUI output:
|
||||
|
||||
+13
-6
@@ -26,13 +26,13 @@ except ImportError:
|
||||
InputTypeDict = dict
|
||||
|
||||
try:
|
||||
from .prompt_manager_base import PromptManagerBase
|
||||
from .prompt_manager_base import SAVE_TO_LIBRARY_INPUT, PromptManagerBase
|
||||
except ImportError:
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from prompt_manager_base import PromptManagerBase
|
||||
from prompt_manager_base import SAVE_TO_LIBRARY_INPUT, PromptManagerBase
|
||||
|
||||
|
||||
class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
@@ -115,6 +115,7 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
)
|
||||
},
|
||||
),
|
||||
"save_to_library": SAVE_TO_LIBRARY_INPUT,
|
||||
},
|
||||
"hidden": {
|
||||
# The queued graph and this node's id: used to tell the positive
|
||||
@@ -152,6 +153,7 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
append_text: str = "",
|
||||
prompt: Optional[Dict[str, Any]] = None,
|
||||
unique_id: Optional[str] = None,
|
||||
save_to_library: bool = True,
|
||||
) -> Tuple[Any]:
|
||||
"""
|
||||
Encode the text prompt and save it to the database.
|
||||
@@ -164,6 +166,7 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
search_text: Text to search for in past prompts
|
||||
prepend_text: Text to prepend to the main prompt
|
||||
append_text: Text to append to the main prompt
|
||||
save_to_library: Off: nothing is saved, counted or tracked (#90)
|
||||
|
||||
Returns:
|
||||
Tuple containing the conditioning for the diffusion model and the final
|
||||
@@ -205,7 +208,9 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
# Save prompt to database and set execution context for gallery tracking
|
||||
prompt_id = None
|
||||
extended_tags = []
|
||||
if storage_text and storage_text.strip():
|
||||
if not save_to_library:
|
||||
self._skip_not_saved(prompt, unique_id)
|
||||
elif storage_text and storage_text.strip():
|
||||
self.logger.debug(f"Processing prompt text: {storage_text[:100]}...")
|
||||
|
||||
# Add prepend/append info to tags if they exist
|
||||
@@ -249,6 +254,11 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
tokens = clip.tokenize(encoding_text)
|
||||
conditioning = clip.encode_from_tokens_scheduled(tokens)
|
||||
|
||||
self.logger.info(f"CLIP encoding completed, text: {repr(encoding_text)[:80]}")
|
||||
result = (conditioning, encoding_text)
|
||||
if not save_to_library:
|
||||
return result # #90: no metadata registration, no history text
|
||||
|
||||
# Register with ComfyUI integration for standard metadata compatibility
|
||||
node_id = f"promptmanager_{int(time.time() * 1000)}"
|
||||
self.comfyui_integration.register_prompt(
|
||||
@@ -262,9 +272,6 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
"append_text": append_text.strip() if append_text else None,
|
||||
},
|
||||
)
|
||||
|
||||
self.logger.info(f"CLIP encoding completed, text: {repr(encoding_text)[:80]}")
|
||||
result = (conditioning, encoding_text)
|
||||
record_runtime_context(
|
||||
type(self).__name__, text, prepend_text, append_text, encoding_text
|
||||
)
|
||||
|
||||
+38
-2
@@ -23,7 +23,7 @@ try:
|
||||
from .utils.image_monitor import get_image_monitor
|
||||
from .utils.prompt_tracker import get_prompt_tracker
|
||||
from .utils.hashing import generate_prompt_hash
|
||||
from .utils.prompt_graph import resolve_text, run_prompt_nodes
|
||||
from .utils.prompt_graph import resolve_text, run_prompt_nodes, save_setting
|
||||
from .utils.run_keys import executing_run_id, prompt_node_key
|
||||
from .utils.usage_tracking import PENDING_FIRST_USE
|
||||
from .utils.cached_runs import (
|
||||
@@ -42,7 +42,7 @@ except ImportError:
|
||||
from utils.image_monitor import get_image_monitor
|
||||
from utils.prompt_tracker import get_prompt_tracker
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.prompt_graph import resolve_text, run_prompt_nodes
|
||||
from utils.prompt_graph import resolve_text, run_prompt_nodes, save_setting
|
||||
from utils.run_keys import executing_run_id, prompt_node_key
|
||||
from utils.usage_tracking import PENDING_FIRST_USE
|
||||
from utils.cached_runs import (
|
||||
@@ -54,6 +54,22 @@ except ImportError:
|
||||
)
|
||||
|
||||
|
||||
# The optional input every PromptManager node declares last (#90): declared
|
||||
# last so no widget value of a workflow saved before it shifts.
|
||||
SAVE_TO_LIBRARY_INPUT = (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"label_on": "save",
|
||||
"label_off": "skip",
|
||||
"tooltip": (
|
||||
"Save this node's prompt to the library, count its runs and link its "
|
||||
"images. Off: PromptManager ignores the run; the output is unchanged."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class PromptManagerBase:
|
||||
"""Mixin providing shared prompt management logic for ComfyUI nodes.
|
||||
|
||||
@@ -156,7 +172,9 @@ class PromptManagerBase:
|
||||
resolved = resolve_text(prompt_graph, node_id)
|
||||
return {
|
||||
"is_run_prompt": node_id in run_prompt_nodes(prompt_graph),
|
||||
# The hook skips a switch it cannot read (#90): the node counts
|
||||
"seen_at_queue": resolved is not None
|
||||
and save_setting(prompt_graph, node_id) is True
|
||||
and generate_prompt_hash(resolved) == generate_prompt_hash(text),
|
||||
"known": True,
|
||||
}
|
||||
@@ -245,6 +263,24 @@ class PromptManagerBase:
|
||||
"run_id": executing_run_id(),
|
||||
}
|
||||
|
||||
def _skip_not_saved(self, prompt_graph: Any = None, unique_id: Any = None) -> None:
|
||||
"""``save_to_library`` is off: nothing is saved, counted or tracked (#90).
|
||||
|
||||
A literal off makes the node no run prompt (``utils.prompt_graph``), so
|
||||
no image looks up its run key. A switch wired from a node the graph
|
||||
cannot read keeps the node a run prompt: its run key is marked like an
|
||||
empty editor's, so an earlier run's entry of that key never links this
|
||||
run's images.
|
||||
"""
|
||||
message = (
|
||||
"Save to library is off for this node: its prompt is not saved, the "
|
||||
"run is not counted and its images are not linked"
|
||||
)
|
||||
if isinstance(prompt_graph, dict) and unique_id is not None:
|
||||
self._mark_untracked(prompt_graph, unique_id, message)
|
||||
else:
|
||||
self.logger.info(message)
|
||||
|
||||
def _skip_empty_prompt(self, prompt_graph: Any, unique_id: Any) -> None:
|
||||
"""The ``text`` editor is empty: save and track nothing (#342).
|
||||
|
||||
|
||||
+23
-4
@@ -22,7 +22,11 @@ try:
|
||||
)
|
||||
from .utils.hashing import generate_prompt_hash
|
||||
from .utils.pair_execution import record_pair_execution
|
||||
from .utils.prompt_graph import intrinsic_pair_nodes, run_prompt_components
|
||||
from .utils.prompt_graph import (
|
||||
intrinsic_pair_nodes,
|
||||
run_prompt_components,
|
||||
save_setting,
|
||||
)
|
||||
from .utils.prompt_pair_resolver import resolve_text_input
|
||||
from .utils.usage_tracking import (
|
||||
PENDING_FIRST_USE,
|
||||
@@ -41,7 +45,11 @@ except ImportError:
|
||||
)
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.pair_execution import record_pair_execution
|
||||
from utils.prompt_graph import intrinsic_pair_nodes, run_prompt_components
|
||||
from utils.prompt_graph import (
|
||||
intrinsic_pair_nodes,
|
||||
run_prompt_components,
|
||||
save_setting,
|
||||
)
|
||||
from utils.prompt_pair_resolver import resolve_text_input
|
||||
from utils.usage_tracking import (
|
||||
PENDING_FIRST_USE,
|
||||
@@ -90,8 +98,13 @@ class PromptManagerPair(PromptManagerFocused):
|
||||
prompt: Optional[Dict[str, Any]] = None,
|
||||
unique_id: Optional[str] = None,
|
||||
extra_pnginfo: Optional[Dict[str, Any]] = None,
|
||||
save_to_library: bool = True,
|
||||
) -> Tuple[Any, Any, str, str]:
|
||||
"""Encode both sides once; database and tracking failures never block CLIP."""
|
||||
"""Encode both sides once; database and tracking failures never block CLIP.
|
||||
|
||||
With ``save_to_library`` off nothing is saved, counted, tracked or
|
||||
recorded (#90); the four outputs are the same.
|
||||
"""
|
||||
if clip is None:
|
||||
raise RuntimeError("ERROR: clip input is invalid: None")
|
||||
if not isinstance(text, str) or not isinstance(negative_text, str):
|
||||
@@ -109,6 +122,10 @@ class PromptManagerPair(PromptManagerFocused):
|
||||
negative_conditioning = clip.encode_from_tokens_scheduled(
|
||||
clip.tokenize(negative_text)
|
||||
)
|
||||
result = positive_conditioning, negative_conditioning, positive, negative_text
|
||||
if not save_to_library:
|
||||
self._skip_not_saved(prompt, unique_id)
|
||||
return result
|
||||
record_pair_execution(
|
||||
extra_pnginfo,
|
||||
unique_id,
|
||||
@@ -169,7 +186,6 @@ class PromptManagerPair(PromptManagerFocused):
|
||||
)
|
||||
except Exception as error:
|
||||
self.logger.warning(f"Could not register paired metadata: {error}")
|
||||
result = positive_conditioning, negative_conditioning, positive, negative_text
|
||||
record_runtime_context(
|
||||
type(self).__name__,
|
||||
text,
|
||||
@@ -219,6 +235,9 @@ class PromptManagerPair(PromptManagerFocused):
|
||||
resolved_negative = (
|
||||
resolve_text_input(prompt, node_id, "negative_text") if known else None
|
||||
)
|
||||
if known and save_setting(prompt, node_id) is not True:
|
||||
# The hook skips a switch it cannot read (#90): the node counts
|
||||
resolved_positive = resolved_negative = None
|
||||
positive_seen = resolved_positive is not None and generate_prompt_hash(
|
||||
resolved_positive
|
||||
) == generate_prompt_hash(text)
|
||||
|
||||
+13
-6
@@ -26,13 +26,13 @@ except ImportError:
|
||||
InputTypeDict = dict
|
||||
|
||||
try:
|
||||
from .prompt_manager_base import PromptManagerBase
|
||||
from .prompt_manager_base import SAVE_TO_LIBRARY_INPUT, PromptManagerBase
|
||||
except ImportError:
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from prompt_manager_base import PromptManagerBase
|
||||
from prompt_manager_base import SAVE_TO_LIBRARY_INPUT, PromptManagerBase
|
||||
|
||||
|
||||
class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
@@ -115,6 +115,7 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
),
|
||||
},
|
||||
),
|
||||
"save_to_library": SAVE_TO_LIBRARY_INPUT,
|
||||
},
|
||||
"hidden": {
|
||||
# The queued graph and this node's id: used to tell the positive
|
||||
@@ -149,6 +150,7 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
append_text: str = "",
|
||||
prompt: Optional[Dict[str, Any]] = None,
|
||||
unique_id: Optional[str] = None,
|
||||
save_to_library: bool = True,
|
||||
) -> Tuple[str]:
|
||||
"""
|
||||
Process the text prompt and save it to the database.
|
||||
@@ -160,6 +162,7 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
search_text: Text to search for in past prompts
|
||||
prepend_text: Text to prepend to the main prompt
|
||||
append_text: Text to append to the main prompt
|
||||
save_to_library: Off: nothing is saved, counted or tracked (#90)
|
||||
|
||||
Returns:
|
||||
Tuple containing the final processed text string
|
||||
@@ -184,7 +187,9 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
# Save prompt to database and set execution context for gallery tracking
|
||||
prompt_id = None
|
||||
extended_tags = []
|
||||
if storage_text and storage_text.strip():
|
||||
if not save_to_library:
|
||||
self._skip_not_saved(prompt, unique_id)
|
||||
elif storage_text and storage_text.strip():
|
||||
self.logger.debug(f"Processing prompt text: {storage_text[:100]}...")
|
||||
|
||||
# Add prepend/append info to tags if they exist
|
||||
@@ -220,6 +225,11 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
else:
|
||||
self._skip_empty_prompt(prompt, unique_id)
|
||||
|
||||
self.logger.debug(f"Text processing completed: {final_text[:100]}...")
|
||||
result = (final_text,)
|
||||
if not save_to_library:
|
||||
return result # #90: no metadata registration, no history text
|
||||
|
||||
# Register with ComfyUI integration for standard metadata compatibility
|
||||
node_id = f"promptmanagertext_{int(time.time() * 1000)}"
|
||||
self.comfyui_integration.register_prompt(
|
||||
@@ -233,9 +243,6 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
"append_text": append_text.strip() if append_text else None,
|
||||
},
|
||||
)
|
||||
|
||||
self.logger.debug(f"Text processing completed: {final_text[:100]}...")
|
||||
result = (final_text,)
|
||||
record_runtime_context(
|
||||
type(self).__name__, text, prepend_text, append_text, final_text
|
||||
)
|
||||
|
||||
@@ -52,6 +52,7 @@ try:
|
||||
extract_generation_params,
|
||||
parse_page_json,
|
||||
)
|
||||
from ...utils.prompt_graph import metadata_opted_out, side_opted_out
|
||||
from ...utils.prompt_pair_resolver import resolve_prompt_pair, sampler_less_prompt
|
||||
from ...utils.review_capture import reviewable
|
||||
except ImportError:
|
||||
@@ -67,6 +68,7 @@ except ImportError:
|
||||
extract_generation_params,
|
||||
parse_page_json,
|
||||
)
|
||||
from utils.prompt_graph import metadata_opted_out, side_opted_out
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair, sampler_less_prompt
|
||||
from utils.review_capture import reviewable
|
||||
|
||||
@@ -260,6 +262,11 @@ SCAN_DETAILS = {
|
||||
"failed": "Error: the prompt could not be stored",
|
||||
"error": "Error: the file could not be processed (see the server log)",
|
||||
"found": "Metadata found, but no prompt text to store",
|
||||
"not_saved": "Skipped · its PromptManager node does not save to the library",
|
||||
"not_saved_wired": (
|
||||
"Skipped · its Save to library switch is wired from another node, so only "
|
||||
"the run itself can link it"
|
||||
),
|
||||
None: "No prompt metadata in the file",
|
||||
}
|
||||
|
||||
@@ -1720,10 +1727,34 @@ class AdminRoutesMixin:
|
||||
except (ValueError, TypeError):
|
||||
# Preserve malformed metadata for conservative resolver rejection.
|
||||
pass
|
||||
if metadata_opted_out(canonical):
|
||||
# Save to library was off for the run's prompt: no prompt, link,
|
||||
# recorded-run fallback or Needs review item (#90)
|
||||
return "not_saved"
|
||||
if metadata_opted_out(canonical, unreadable=True):
|
||||
# The switch is wired from a node the file cannot show: only the
|
||||
# live run (the node saw the value) can link it, never a scan
|
||||
return "not_saved_wired"
|
||||
if not canonical.get("prompt") and not canonical.get("parameters"):
|
||||
return self._link_recorded_run(media_file, canonical, detailed, history)
|
||||
_with_file_size(canonical, media_file)
|
||||
resolution = resolve_prompt_pair(canonical)
|
||||
if resolution["state"] == "resolved" and side_opted_out(
|
||||
canonical.get("prompt"), resolution["negative"]
|
||||
):
|
||||
# Only the negative's node is off: store the positive alone (#90)
|
||||
prompt_text = (
|
||||
resolution["positive"]
|
||||
.get("storage_text", resolution["positive"]["text"])
|
||||
.strip()
|
||||
)
|
||||
status = self._store_scanned_prompt(media_file, canonical, prompt_text)
|
||||
if not detailed or status not in SCAN_TITLED_DETAILS:
|
||||
return status
|
||||
return {
|
||||
"status": status,
|
||||
"title": self._scan_title(media_file, prompt_text),
|
||||
}
|
||||
if blank_positive(resolution):
|
||||
# Only prepend/append text: no stored prompt can own it (#342)
|
||||
resolution = {**resolution, "state": "unresolved"}
|
||||
|
||||
@@ -19,7 +19,7 @@ test.beforeEach(async ({page, browserName}) => {
|
||||
await import('/extensions/promptmanager/prompt_manager.js');
|
||||
class Node {
|
||||
constructor(){
|
||||
this.widgets=['text','category','tags','search_text'].map(name=>({name,value:'',inputEl:document.createElement('textarea')}));
|
||||
this.widgets=[...['text','category','tags','search_text'].map(name=>({name,value:'',inputEl:document.createElement('textarea')})),{name:'save_to_library',type:'toggle',value:true}];
|
||||
this.widgets_values=[]; this.inputs=[];
|
||||
}
|
||||
addDOMWidget(name,type,element){document.body.append(element);return {element};}
|
||||
@@ -36,6 +36,14 @@ test.afterEach(async ({page,browserName}) => {
|
||||
expect(page.classicErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('Classic loads an older workflow with Save to library on and keeps a saved off (#90)',async({page})=>{
|
||||
// A workflow saved before the switch held the search UI's slot ('') at the switch's index
|
||||
const loaded=await page.evaluate(()=>{const widget=node.widgets.find(w=>w.name==='save_to_library');widget.value='';node.configure({});return widget.value;});
|
||||
expect(loaded).toBe(true);
|
||||
const off=await page.evaluate(()=>{const widget=node.widgets.find(w=>w.name==='save_to_library');widget.value=false;node.configure({});return [widget.value,node.serialize().widgets_values[4]];});
|
||||
expect(off).toEqual([false,false]);
|
||||
});
|
||||
|
||||
test('Classic keeps immediate selection, search query and metadata serialization',async({page})=>{
|
||||
await page.route('**/prompt_manager/search?**', route=>route.fulfill({json:{success:true,results:[{text:'A new portrait',category:'Portrait',tags:['film grain'],rating:5}]}}));
|
||||
await page.evaluate(()=>{node.widgets.find(w=>w.name==='search_text').value='tag:"film grain" portrait';});
|
||||
|
||||
@@ -26,7 +26,7 @@ test.beforeEach(async({page,browserName})=>{
|
||||
window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings:new EventTarget()},graph:{setDirtyCanvas(){}},canvas:{}};
|
||||
await import('/extensions/promptmanager/prompt_manager.js');
|
||||
class Node {
|
||||
constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=[...['clip','prepend_text','append_text'].map(name=>({name,type:name==='clip'?'CLIP':'STRING',link:null})),...['text','category','tags','search_text'].map(name=>({name,type:'STRING',link:null,widget:{name}}))];this.properties={};this.size=[550,700];}
|
||||
constructor(){this.widgets=[...['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')})),{name:'save_to_library',type:'toggle',value:true,options:{}}];this.inputs=[...['clip','prepend_text','append_text'].map(name=>({name,type:name==='clip'?'CLIP':'STRING',link:null})),...['text','category','tags','search_text'].map(name=>({name,type:'STRING',link:null,widget:{name}})),{name:'save_to_library',type:'BOOLEAN',link:null,widget:{name:'save_to_library'}}];this.properties={};this.size=[550,700];}
|
||||
findInputByType(type){const index=this.inputs.findIndex(input=>input.type===type&&input.link==null);return index<0?undefined:{index,slot:this.inputs[index]};}
|
||||
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
|
||||
addDOMWidget(name,type,element){const host=document.createElement('div');host.style.height='650px';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};}
|
||||
@@ -41,6 +41,32 @@ test.beforeEach(async({page,browserName})=>{
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);});
|
||||
|
||||
test(`${variant} Save to library switch saves its value and loads older workflows on (#90)`, async ({page}) => {
|
||||
const toggle=page.getByRole('checkbox',{name:'Save to library',exact:true});
|
||||
await expect(toggle).toBeChecked();
|
||||
expect(await page.evaluate(()=>node.widgets.map(widget=>widget.name))).toEqual(['text','category','tags','search_text','prepend_text','append_text','save_to_library']);
|
||||
// The native switch is drawn in the toolbar only: the host keeps it hidden and zero-height, like the text fallbacks
|
||||
expect(await page.evaluate(()=>{const widget=node.widgets.find(item=>item.name==='save_to_library');return {type:widget.type,hidden:widget.hidden,size:widget.computeSize()};})).toEqual({type:'hidden',hidden:true,size:[0,-4]});
|
||||
expect(await page.evaluate(()=>node.widgets.find(item=>item.name==='text').computeSize())).toEqual([0,-4]);
|
||||
await toggle.uncheck();
|
||||
await expect(page.locator('.pm-focused > .pm-editor-status[role=status]')).toHaveText('Save to library is off: runs of this node are not added to the library.');
|
||||
const saved=await page.evaluate(()=>node.serialize());
|
||||
expect(saved.widgets_values[6]).toBe(false);
|
||||
await page.evaluate(saved=>node.configure(saved),saved);
|
||||
await expect(toggle).not.toBeChecked();
|
||||
await page.getByLabel('Main prompt',{exact:true}).fill('Typed while off');
|
||||
await expect(page.locator('.pm-focused > .pm-editor-status[role=status]')).toHaveText('Save to library is off: runs of this node are not added to the library.');
|
||||
// A workflow saved before the switch held the editor's own slot ('') at its index
|
||||
await page.evaluate(()=>node.configure({widgets_values:['Old text','','','','','',''],properties:{}}));
|
||||
await expect(toggle).toBeChecked();
|
||||
expect(await page.evaluate(()=>node.widgets.find(widget=>widget.name==='save_to_library').value)).toBe(true);
|
||||
await toggle.check();
|
||||
await page.getByLabel('Main prompt',{exact:true}).fill('Typed while on');
|
||||
await expect(page.locator('.pm-focused > .pm-editor-status[role=status]')).toHaveText('Main prompt is saved when this node runs.');
|
||||
await page.evaluate(()=>{node.inputs.find(input=>input.name==='save_to_library').link=9;node.onConnectionsChange?.();});
|
||||
await expect(toggle).toBeDisabled();
|
||||
});
|
||||
|
||||
test(`${variant} library autocompletes filters and searches without changing editor metadata`, async ({page}) => {
|
||||
const requests=[];
|
||||
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait']}}));
|
||||
@@ -257,7 +283,7 @@ test(`${variant} restores workflow values and disclosures and protects connected
|
||||
test(`${variant} offers text as a link socket, keeps typed text while linked and through a reload (#343)`,async({page})=>{
|
||||
const sockets=()=>page.evaluate(()=>Object.fromEntries(node.inputs.map(input=>[input.name,input.widget?.name??null])));
|
||||
// The hidden text widget no longer owns the input, so the host draws it as a socket; the other widget inputs are unchanged.
|
||||
expect(await sockets()).toEqual({clip:null,prepend_text:null,append_text:null,text:null,category:'category',tags:'tags',search_text:'search_text'});
|
||||
expect(await sockets()).toEqual({clip:null,prepend_text:null,append_text:null,text:null,category:'category',tags:'tags',search_text:'search_text',save_to_library:'save_to_library'});
|
||||
expect(await page.evaluate(()=>node.findInputByType('STRING').slot.name)).toBe('text');
|
||||
expect(await page.evaluate(()=>node.findInputByType('CLIP').slot.name)).toBe('clip');
|
||||
const main=page.getByRole('textbox',{name:'Main prompt',exact:true});
|
||||
|
||||
@@ -17,7 +17,7 @@ test.beforeEach(async({page,browserName})=>{
|
||||
window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings},graph:{_nodes:[],setDirtyCanvas(){}},canvas:{},graphToPrompt:async()=>({})};
|
||||
await import('/extensions/promptmanager/prompt_manager.js');
|
||||
class Node {
|
||||
constructor(){this.id=1;this.type='PromptManagerPair';this.widgets=['text','negative_text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=[...['clip','prepend_text','append_text'].map(name=>({name,type:name==='clip'?'CLIP':'STRING',link:null})),...['text','negative_text','category','tags','search_text'].map(name=>({name,type:'STRING',link:null,widget:{name}}))];this.properties={};this.size=[1100,700];}
|
||||
constructor(){this.id=1;this.type='PromptManagerPair';this.widgets=[...['text','negative_text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')})),{name:'save_to_library',type:'toggle',value:true,options:{}}];this.inputs=[...['clip','prepend_text','append_text'].map(name=>({name,type:name==='clip'?'CLIP':'STRING',link:null})),...['text','negative_text','category','tags','search_text'].map(name=>({name,type:'STRING',link:null,widget:{name}})),{name:'save_to_library',type:'BOOLEAN',link:null,widget:{name:'save_to_library'}}];this.properties={};this.size=[1100,700];}
|
||||
findInputByType(type){const index=this.inputs.findIndex(input=>input.type===type&&input.link==null);return index<0?undefined:{index,slot:this.inputs[index]};}
|
||||
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
|
||||
addDOMWidget(name,type,element){const host=document.createElement('div');host.style.cssText='height:700px;width:1100px;max-width:100%';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};}
|
||||
@@ -34,6 +34,18 @@ test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);
|
||||
async function selectPair(page){await page.getByRole('button',{name:'Recent',exact:true}).click();await page.locator('.pm-editor-result').click();}
|
||||
async function draft(page,positive='Draft positive',negative='Draft negative'){await page.getByRole('textbox',{name:'Positive prompt',exact:true}).fill(positive);await page.getByRole('textbox',{name:'Negative prompt',exact:true}).fill(negative);}
|
||||
|
||||
test('paired editor Save to library switch keeps the widget order and loads older workflows on (#90)',async({page})=>{
|
||||
await expect(page.locator('.pm-pair')).toBeVisible();
|
||||
const toggle=page.getByRole('checkbox',{name:'Save to library',exact:true});
|
||||
await expect(toggle).toBeChecked();
|
||||
expect(await page.evaluate(()=>node.widgets.map(widget=>widget.name))).toEqual(['text','negative_text','category','tags','search_text','prepend_text','append_text','save_to_library']);
|
||||
await toggle.uncheck();
|
||||
expect(await page.evaluate(()=>node.serialize().widgets_values[7])).toBe(false);
|
||||
await expect(page.locator('.pm-pair > .pm-editor-status[role=status]').last()).toHaveText('Save to library is off: runs of this node are not added to the library.');
|
||||
await page.evaluate(()=>node.configure({widgets_values:['P','N','','','','','',''],properties:{}}));
|
||||
await expect(toggle).toBeChecked();
|
||||
});
|
||||
|
||||
test('paired editor previews both texts before explicitly replacing both',async({page})=>{
|
||||
await expect(page.locator('.pm-pair')).toBeVisible();
|
||||
await draft(page);
|
||||
|
||||
@@ -16,8 +16,8 @@ installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptManagerFocu
|
||||
// `primitive`: a core string node the queue-time resolver follows. `legacy`: the frontend's virtual Primitive node,
|
||||
// whose value is queued as literal text. `preview`: PrimitiveString -> PreviewAny, the shape of the maintainer's
|
||||
// TextGenerate -> PreviewAny chain, whose text is only known when the node runs.
|
||||
function build(page, { kind, positive, negative, prefix }) {
|
||||
return page.evaluate(async ({ kind, positive, negative, prefix }) => {
|
||||
function build(page, { kind, positive, negative, prefix, saveToLibrary }) {
|
||||
return page.evaluate(async ({ kind, positive, negative, prefix, saveToLibrary }) => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph.clear();
|
||||
const add = (type, x, y) => { const node = LiteGraph.createNode(type); app.graph.add(node); node.pos = [x, y]; return node; };
|
||||
@@ -28,6 +28,7 @@ function build(page, { kind, positive, negative, prefix }) {
|
||||
const set = (name, value) => { const widget = prompt.widgets.find(item => item.name === name); widget.value = value; if (widget.inputEl) widget.inputEl.value = value; };
|
||||
set('text', positive.typed);
|
||||
if (negative) set('negative_text', negative.typed);
|
||||
if (saveToLibrary !== undefined) prompt.widgets.find(item => item.name === 'save_to_library').value = saveToLibrary;
|
||||
const drive = (name, side, y) => {
|
||||
if (!side.via) return;
|
||||
if (side.via === 'legacy') {
|
||||
@@ -73,7 +74,7 @@ function build(page, { kind, positive, negative, prefix }) {
|
||||
await prompt._pmTextSerializerReady;
|
||||
const { output, workflow } = await app.graphToPrompt();
|
||||
return { output, workflow, promptNode: String(prompt.id) };
|
||||
}, { kind, positive, negative, prefix });
|
||||
}, { kind, positive, negative, prefix, saveToLibrary });
|
||||
}
|
||||
|
||||
async function run(execution, page, testCase, built) {
|
||||
@@ -195,6 +196,47 @@ for (const [label, linked, negativeVia] of [['positive', ['text'], 'primitive'],
|
||||
});
|
||||
}
|
||||
|
||||
// #90: with Save to library off the run still produces its image, but PromptManager saves, counts and links nothing,
|
||||
// and the run's history records no prompt text for it.
|
||||
for (const kind of ['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']) {
|
||||
test(`${kind} with Save to library off saves, counts and links nothing (#90)`, async ({ execution, page }) => {
|
||||
test.setTimeout(90000);
|
||||
const text = unique(`Not saved ${kind}`);
|
||||
const negative = kind === 'PromptManagerPair' ? { typed: unique('Not saved negative') } : undefined;
|
||||
const prefix = `notsaved/${randomUUID()}`;
|
||||
const { output, workflow, promptNode } = await build(page, { kind, positive: { typed: text }, negative, prefix, saveToLibrary: false });
|
||||
expect(output[promptNode].inputs.save_to_library).toBe(false);
|
||||
const id = randomUUID();
|
||||
const response = await execution.request.post('/prompt', { data: { prompt: output, prompt_id: id, extra_data: { extra_pnginfo: { workflow } } } });
|
||||
expect(response.ok(), await response.text()).toBe(true);
|
||||
let item;
|
||||
await expect.poll(async () => {
|
||||
item = (await (await execution.request.get(`/history/${id}`)).json())[id];
|
||||
return item?.status?.status_str ?? null;
|
||||
}, { timeout: 20000 }).toBe('success');
|
||||
let result;
|
||||
await expect.poll(async () => {
|
||||
result = await readExecutionRuns(execution.databasePath, id);
|
||||
const run = result.rows[0];
|
||||
return Boolean(run && result.captures[run.run_id]);
|
||||
}, { timeout: 10000, message: 'the run reaches SQLite' }).toBe(true);
|
||||
const run = result.rows[0];
|
||||
for (const context of result.captures[run.run_id].contexts || []) {
|
||||
expect(context.positive_prompt_id ?? null).toBeNull();
|
||||
expect(context.pair_id ?? null).toBeNull();
|
||||
}
|
||||
expect((result.usage[run.run_id] || []).every(entry => entry.run_count === 0)).toBe(true);
|
||||
const files = Object.values(item.outputs).flatMap(value => value.images || []).map(image => image.filename);
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
await scan(execution);
|
||||
expect(Object.values(await linkedPrompts(execution, files))).toEqual(files.map(() => null));
|
||||
for (const query of [text, negative?.typed].filter(Boolean)) {
|
||||
const found = await (await execution.request.get(`/prompt_manager/search?text=${encodeURIComponent(query)}`)).json();
|
||||
expect(found.results ?? [], query).toEqual([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// #347: a cached PromptManager node counts every job, as an executing one does. Jobs after the first change only
|
||||
// the sampler seed, so ComfyUI serves the PromptManager node (and its PreviewAny source) from its output cache.
|
||||
const COUNTS = `import json, sqlite3, sys, pathlib
|
||||
|
||||
@@ -370,6 +370,7 @@ test('workflows saved before #343 load unchanged and their linked text keeps wor
|
||||
const nodes = Object.fromEntries(app.graph._nodes.map(node => [node.id, node]));
|
||||
const reloaded = app.graph.serialize();
|
||||
return {
|
||||
saves: Object.fromEntries(Object.entries(output).filter(([, node]) => node.class_type.startsWith('PromptManager')).map(([id, node]) => [id, node.inputs.save_to_library])),
|
||||
values: Object.fromEntries(reloaded.nodes.map(node => [node.id, node.widgets_values])),
|
||||
links: Object.fromEntries(Object.entries(output).map(([id, node]) => [id, { text: node.inputs.text, negative_text: node.inputs.negative_text }])),
|
||||
widgetBound: Object.fromEntries(Object.values(nodes).filter(node => node.type.startsWith('PromptManager')).map(node => [node.id, node.inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).map(input => input.name)])),
|
||||
@@ -377,8 +378,16 @@ test('workflows saved before #343 load unchanged and their linked text keeps wor
|
||||
}, workflow);
|
||||
for (const node of workflow.nodes) {
|
||||
// Every saved widget value comes back as it was saved, except the typed text main lost under a link (null).
|
||||
if (node.widgets_values?.[0] !== null) expect(result.values[node.id], `${node.type} ${node.id}`).toEqual(node.widgets_values);
|
||||
// #90: the last saved value is the editor's own slot (''), where the Save to library switch is now: it loads on.
|
||||
if (node.widgets_values?.[0] === null) continue;
|
||||
if (!node.type.startsWith('PromptManager')) { expect(result.values[node.id], `${node.type} ${node.id}`).toEqual(node.widgets_values); continue; }
|
||||
const saved = node.widgets_values.length - 1;
|
||||
expect(result.values[node.id].slice(0, saved), `${node.type} ${node.id}`).toEqual(node.widgets_values.slice(0, saved));
|
||||
expect(result.values[node.id][saved], `${node.type} ${node.id} save_to_library`).toBe(true);
|
||||
}
|
||||
// Every PromptManager node of the old workflow queues with Save to library on.
|
||||
for (const save of Object.values(result.saves)) expect(save).toBe(true);
|
||||
expect(Object.keys(result.saves).length).toBeGreaterThan(0);
|
||||
expect(result.links).toMatchObject({
|
||||
1: { text: 'Saved PromptManager text' }, 2: { text: 'Saved PromptManagerText text' }, 3: { text: 'Saved PromptManagerFocused text' },
|
||||
4: { text: 'Saved PromptManagerSplit text' }, 5: { text: 'Saved PromptManagerPair text', negative_text: 'Saved pair negative' },
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// #90: the node's save_to_library switch (web/js/save-to-library.js).
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/save-to-library.js');
|
||||
|
||||
function classicNode(saved) {
|
||||
const names = ['text', 'category', 'tags', 'search_text', 'prepend_text', 'append_text', 'save_to_library', 'prompt_manager_search_ui'];
|
||||
const widgets = names.map(name => ({name, value: name === 'save_to_library' ? true : ''}));
|
||||
const node = {widgets, widgets_values: saved ? [...saved] : undefined};
|
||||
// What a host configure does: each saved value goes to the widget at its index.
|
||||
(saved || []).forEach((value, index) => { if (widgets[index]) widgets[index].value = value; });
|
||||
return node;
|
||||
}
|
||||
|
||||
test('an older workflow stored the editor slot where the switch is now: it loads on', async () => {
|
||||
const {normalizeSaveWidget, savesToLibrary} = await source;
|
||||
const node = classicNode(['a cat', 'c', 't', '', '', '', '']);
|
||||
assert.equal(node.widgets[6].value, '', 'the host put the old DOM slot value into the switch');
|
||||
assert.equal(normalizeSaveWidget(node), true);
|
||||
assert.equal(node.widgets[6].value, true);
|
||||
assert.equal(node.widgets_values[6], true);
|
||||
assert.equal(savesToLibrary(node), true);
|
||||
assert.deepEqual(node.widgets_values.slice(0, 6), ['a cat', 'c', 't', '', '', ''], 'no earlier value moved');
|
||||
});
|
||||
|
||||
test('null, undefined and missing slots load on; saved booleans are kept', async () => {
|
||||
const {normalizeSaveWidget, savesToLibrary} = await source;
|
||||
for (const value of [null, undefined, 0, 'false']) {
|
||||
const node = classicNode(['a', '', '', '', '', '', value]);
|
||||
normalizeSaveWidget(node);
|
||||
assert.equal(savesToLibrary(node), true, String(value));
|
||||
}
|
||||
const short = classicNode(['a', '', '', '', '', '']);
|
||||
assert.equal(normalizeSaveWidget(short), false);
|
||||
assert.equal(savesToLibrary(short), true);
|
||||
const off = classicNode(['a', '', '', '', '', '', false]);
|
||||
assert.equal(normalizeSaveWidget(off), false);
|
||||
assert.equal(savesToLibrary(off), false);
|
||||
});
|
||||
|
||||
test('a node without the widget (an older backend) saves', async () => {
|
||||
const {normalizeSaveWidget, savesToLibrary} = await source;
|
||||
assert.equal(savesToLibrary({widgets: [{name: 'text', value: ''}]}), true);
|
||||
assert.equal(normalizeSaveWidget({widgets: []}), false);
|
||||
assert.equal(savesToLibrary(undefined), true);
|
||||
});
|
||||
|
||||
test('the switch moves after the editor fallbacks so every frontend has one order', async () => {
|
||||
const {placeSaveWidgetLast} = await source;
|
||||
const widgets = ['text', 'category', 'tags', 'search_text', 'save_to_library', 'prepend_text', 'append_text'].map(name => ({name}));
|
||||
const node = {widgets};
|
||||
assert.equal(placeSaveWidgetLast(node), true);
|
||||
assert.deepEqual(node.widgets.map(widget => widget.name), ['text', 'category', 'tags', 'search_text', 'prepend_text', 'append_text', 'save_to_library']);
|
||||
assert.equal(placeSaveWidgetLast(node), false, 'already last');
|
||||
assert.equal(placeSaveWidgetLast({widgets: [{name: 'text'}]}), false);
|
||||
});
|
||||
|
||||
test('setting the switch updates the widget, its saved value and notifies the host', async () => {
|
||||
const {setSaveToLibrary, savesToLibrary} = await source;
|
||||
const calls = [];
|
||||
const node = classicNode(['a', '', '', '', '', '', true]);
|
||||
node.widgets[6].callback = value => calls.push(value);
|
||||
setSaveToLibrary(node, false);
|
||||
assert.equal(savesToLibrary(node), false);
|
||||
assert.equal(node.widgets_values[6], false);
|
||||
assert.deepEqual(calls, [false]);
|
||||
setSaveToLibrary({widgets: []}, false);
|
||||
});
|
||||
@@ -210,13 +210,24 @@ class JobCase(unittest.TestCase):
|
||||
node = self.node(kind)
|
||||
self.context = (self.context[0], index)
|
||||
clip = mock.Mock()
|
||||
# The node receives its switch as the queued graph holds it (#90)
|
||||
save = g[node_id]["inputs"].get("save_to_library", True)
|
||||
if kind == "PromptManagerText":
|
||||
return node.process_text(text, prompt=g, unique_id=node_id)
|
||||
return node.process_text(
|
||||
text, prompt=g, unique_id=node_id, save_to_library=save
|
||||
)
|
||||
if kind == "PromptManagerPair":
|
||||
return node.encode_prompt(
|
||||
clip, text, negative_text=negative, prompt=g, unique_id=node_id
|
||||
clip,
|
||||
text,
|
||||
negative_text=negative,
|
||||
prompt=g,
|
||||
unique_id=node_id,
|
||||
save_to_library=save,
|
||||
)
|
||||
return node.encode_prompt(clip, text, prompt=g, unique_id=node_id)
|
||||
return node.encode_prompt(
|
||||
clip, text, prompt=g, unique_id=node_id, save_to_library=save
|
||||
)
|
||||
|
||||
def cache(self, node_id, *results):
|
||||
"""Store executed results (one per list index) as ComfyUI's cache entry."""
|
||||
@@ -313,6 +324,35 @@ class TestCachedJobsCount(JobCase):
|
||||
self.job(g, cached=["pm"])
|
||||
self.assertEqual(self.db.search_prompts(), [])
|
||||
|
||||
def test_disabled_node_then_cached_jobs_counts_0(self):
|
||||
# #90 x #347: a node with Save to library off records no receipt and is
|
||||
# no run prompt, so its cached jobs replay nothing. The prompt exists
|
||||
# (an enabled run saved it), so any replayed count would show.
|
||||
for kind in (
|
||||
"PromptManager",
|
||||
"PromptManagerFocused",
|
||||
"PromptManagerSplit",
|
||||
"PromptManagerText",
|
||||
"PromptManagerPair",
|
||||
):
|
||||
with self.subTest(kind=kind):
|
||||
text = f"{LLM} not saved by {kind}"
|
||||
negative = "blurry" if kind == "PromptManagerPair" else ""
|
||||
self.first(graph(kind, negative_text=negative), text, negative)
|
||||
before = (self.count(text), self.pair_count(text, negative))
|
||||
|
||||
def off(seed):
|
||||
g = graph(kind, negative_text=negative, seed=seed)
|
||||
g["pm"]["inputs"]["save_to_library"] = False
|
||||
return g
|
||||
|
||||
self.first(off(1), text, negative)
|
||||
self.job(off(2), cached=["pm"])
|
||||
self.job(off(3), cached=["pm"])
|
||||
after = (self.count(text), self.pair_count(text, negative))
|
||||
self.assertEqual(before[0], 1)
|
||||
self.assertEqual(after, before)
|
||||
|
||||
def test_alternating_upstream_values_each_count_their_own_prompt(self):
|
||||
g = graph()
|
||||
self.first(g, text="value A")
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
"""#90 item 3: a node's ``save_to_library`` switch.
|
||||
|
||||
Off: PromptManager ignores the run completely (no prompt saved, no run count,
|
||||
no image link), while the node still returns its normal outputs. On (the
|
||||
default, and every workflow or image made before the switch existed): as before.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
from tests.test_prompt_attribution import MonitorCase, NodeCase
|
||||
from tests.test_execution_contexts import load_module
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from test_prompt_attribution import MonitorCase, NodeCase
|
||||
from test_execution_contexts import load_module
|
||||
|
||||
from prompt_manager import PromptManager, PromptManagerFocused, PromptManagerSplit
|
||||
from prompt_manager_pair import PromptManagerPair
|
||||
from prompt_manager_text import PromptManagerText
|
||||
from prompt_search_list import PromptSearchList
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.prompt_graph import (
|
||||
intrinsic_pair_nodes,
|
||||
positive_opt_outs,
|
||||
run_prompt_components,
|
||||
run_prompt_nodes,
|
||||
save_setting,
|
||||
)
|
||||
from utils.run_keys import prompt_node_key
|
||||
from utils.usage_tracking import (
|
||||
PendingFirstUse,
|
||||
QueuedRuns,
|
||||
handle_queued_prompt,
|
||||
on_dequeue,
|
||||
queued_pair_runs,
|
||||
queued_prompt_runs,
|
||||
)
|
||||
|
||||
POSITIVE = "a lighthouse at dusk"
|
||||
NEGATIVE = "blurry"
|
||||
NODE_CLASSES = (
|
||||
PromptManager,
|
||||
PromptManagerText,
|
||||
PromptManagerFocused,
|
||||
PromptManagerSplit,
|
||||
PromptManagerPair,
|
||||
)
|
||||
|
||||
|
||||
def run_graph(save=None, kind="PromptManager", negative_save=None):
|
||||
"""A PromptManager positive feeding a KSampler; ``save`` None leaves it out."""
|
||||
inputs = {"text": POSITIVE, "clip": ["4", 1]}
|
||||
if save is not None:
|
||||
inputs["save_to_library"] = save
|
||||
negative_inputs = {"text": NEGATIVE, "clip": ["4", 1]}
|
||||
if negative_save is not None:
|
||||
negative_inputs["save_to_library"] = negative_save
|
||||
return {
|
||||
"1": {"class_type": kind, "inputs": inputs},
|
||||
"2": {"class_type": "PromptManager", "inputs": negative_inputs},
|
||||
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": ["1", 0],
|
||||
"negative": ["2", 0],
|
||||
"latent_image": ["6", 0],
|
||||
},
|
||||
},
|
||||
"6": {"class_type": "EmptyLatentImage", "inputs": {"batch_size": 1}},
|
||||
}
|
||||
|
||||
|
||||
def pair_graph(save=None):
|
||||
inputs = {"text": POSITIVE, "negative_text": NEGATIVE, "clip": ["4", 1]}
|
||||
if save is not None:
|
||||
inputs["save_to_library"] = save
|
||||
return {
|
||||
"1": {"class_type": "PromptManagerPair", "inputs": inputs},
|
||||
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": ["1", 0],
|
||||
"negative": ["1", 1],
|
||||
"latent_image": ["6", 0],
|
||||
},
|
||||
},
|
||||
"6": {"class_type": "EmptyLatentImage", "inputs": {"batch_size": 1}},
|
||||
}
|
||||
|
||||
|
||||
class TestInputDeclaration(unittest.TestCase):
|
||||
def test_every_prompt_manager_node_declares_a_boolean_defaulting_on(self):
|
||||
for cls in NODE_CLASSES:
|
||||
optional = cls.INPUT_TYPES()["optional"]
|
||||
kind, options = optional["save_to_library"]
|
||||
self.assertEqual(kind, "BOOLEAN", cls.__name__)
|
||||
self.assertIs(options["default"], True, cls.__name__)
|
||||
# Declared last: no saved widget value of an older workflow shifts
|
||||
self.assertEqual(list(optional)[-1], "save_to_library", cls.__name__)
|
||||
|
||||
def test_search_list_has_no_switch(self):
|
||||
inputs = PromptSearchList.INPUT_TYPES()
|
||||
self.assertNotIn("save_to_library", inputs.get("optional", {}))
|
||||
self.assertNotIn("save_to_library", inputs.get("required", {}))
|
||||
|
||||
|
||||
class TestSaveSetting(unittest.TestCase):
|
||||
def test_missing_input_is_on(self):
|
||||
self.assertIs(save_setting(run_graph(), "1"), True)
|
||||
|
||||
def test_literals_follow_comfyui_boolean_coercion(self):
|
||||
for value, expected in (
|
||||
(True, True),
|
||||
(False, False),
|
||||
("", False),
|
||||
(0, False),
|
||||
({"__value__": False}, False),
|
||||
({"__value__": True}, True),
|
||||
):
|
||||
with self.subTest(value=value):
|
||||
self.assertIs(save_setting(run_graph(save=value), "1"), expected)
|
||||
|
||||
def test_link_to_primitive_boolean_is_read(self):
|
||||
for value in (True, False):
|
||||
graph = run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "PrimitiveBoolean", "inputs": {"value": value}}
|
||||
self.assertIs(save_setting(graph, "1"), value)
|
||||
|
||||
def test_link_to_an_unknown_node_is_not_knowable(self):
|
||||
graph = run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
self.assertIsNone(save_setting(graph, "1"))
|
||||
self.assertIsNone(save_setting(run_graph(save=["missing", 0]), "1"))
|
||||
|
||||
|
||||
class TestGraphRoles(unittest.TestCase):
|
||||
def test_opted_out_positive_is_not_a_run_prompt(self):
|
||||
graph = run_graph(save=False)
|
||||
self.assertEqual(run_prompt_nodes(graph), [])
|
||||
self.assertEqual(run_prompt_components(graph), [])
|
||||
self.assertEqual(positive_opt_outs(graph), ["1"])
|
||||
|
||||
def test_enabled_positive_is_unchanged(self):
|
||||
for save in (None, True):
|
||||
graph = run_graph(save=save)
|
||||
self.assertEqual(run_prompt_nodes(graph), ["1"])
|
||||
self.assertEqual(positive_opt_outs(graph), [])
|
||||
|
||||
def test_opted_out_negative_is_no_positive_opt_out(self):
|
||||
graph = run_graph(negative_save=False)
|
||||
self.assertEqual(run_prompt_nodes(graph), ["1"])
|
||||
self.assertEqual(positive_opt_outs(graph), [])
|
||||
|
||||
def test_walk_stops_at_an_opted_out_node(self):
|
||||
graph = run_graph(save=False)
|
||||
graph["1"]["inputs"]["text"] = ["7", 0]
|
||||
graph["7"] = {"class_type": "PromptManagerText", "inputs": {"text": "x"}}
|
||||
self.assertEqual(run_prompt_nodes(graph), [])
|
||||
|
||||
def test_sampler_less_fallback_skips_opted_out_nodes(self):
|
||||
graph = {
|
||||
"1": {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": "a", "save_to_library": False},
|
||||
},
|
||||
"2": {"class_type": "PromptManagerText", "inputs": {"text": "b"}},
|
||||
}
|
||||
self.assertEqual(run_prompt_nodes(graph), ["2"])
|
||||
self.assertEqual(run_prompt_components(graph), [("2", "text")])
|
||||
self.assertEqual(positive_opt_outs(graph), ["1"])
|
||||
|
||||
def test_opted_out_pair_is_not_an_intrinsic_pair(self):
|
||||
self.assertEqual(intrinsic_pair_nodes(pair_graph()), ["1"])
|
||||
self.assertEqual(intrinsic_pair_nodes(pair_graph(save=False)), [])
|
||||
|
||||
|
||||
class TestQueueHook(unittest.TestCase):
|
||||
def test_opted_out_node_is_never_stashed(self):
|
||||
self.assertEqual(queued_prompt_runs(run_graph(save=False)), [])
|
||||
self.assertEqual(queued_pair_runs(pair_graph(save=False)), [])
|
||||
|
||||
def test_unknown_setting_is_left_to_the_node(self):
|
||||
graph = run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
self.assertEqual(queued_prompt_runs(graph), [])
|
||||
pair = pair_graph(save=["9", 0])
|
||||
pair["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
self.assertEqual(queued_pair_runs(pair), [])
|
||||
|
||||
def test_primitive_boolean_on_still_counts(self):
|
||||
graph = run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "PrimitiveBoolean", "inputs": {"value": True}}
|
||||
self.assertEqual(
|
||||
queued_prompt_runs(graph), [(generate_prompt_hash(POSITIVE), POSITIVE)]
|
||||
)
|
||||
|
||||
def test_dequeue_counts_nothing_for_a_cached_opted_out_node(self):
|
||||
"""A cached node never executes: only the queued graph can say it is off."""
|
||||
db = mock.Mock()
|
||||
db.get_prompt_by_hash.return_value = {"id": 5}
|
||||
queued, pending = QueuedRuns(), PendingFirstUse()
|
||||
request = {"prompt": run_graph(save=False), "prompt_id": "job-1"}
|
||||
handle_queued_prompt(request, queued, pair_queued=QueuedRuns())
|
||||
on_dequeue("job-1", queued, lambda: db, pending, pair_queued=QueuedRuns())
|
||||
db.record_prompt_use.assert_not_called()
|
||||
self.assertEqual(pending.consume(generate_prompt_hash(POSITIVE)), 0)
|
||||
|
||||
|
||||
class TestNodesSaveNothing(NodeCase):
|
||||
def run_node(self, cls, graph, save):
|
||||
node = self.node(cls)
|
||||
node.record_pair = mock.Mock()
|
||||
kwargs = {"prompt": graph, "unique_id": "1", "save_to_library": save}
|
||||
if cls is PromptManagerText:
|
||||
return node, node.process_text(POSITIVE, **kwargs)
|
||||
if cls is PromptManagerPair:
|
||||
return node, node.encode_prompt(
|
||||
self.clip(), POSITIVE, negative_text=NEGATIVE, **kwargs
|
||||
)
|
||||
return node, node.encode_prompt(self.clip(), POSITIVE, **kwargs)
|
||||
|
||||
def test_off_saves_counts_and_tracks_nothing_but_returns_outputs(self):
|
||||
for cls in NODE_CLASSES:
|
||||
with self.subTest(node=cls.__name__):
|
||||
graph = (
|
||||
pair_graph(save=False)
|
||||
if cls is PromptManagerPair
|
||||
else run_graph(save=False, kind=cls.__name__)
|
||||
)
|
||||
node, result = self.run_node(cls, graph, False)
|
||||
self.assertEqual(self.count_rows("prompts"), 0)
|
||||
self.assertEqual(self.count_rows("prompt_pairs"), 0)
|
||||
self.assertEqual(self.tracker.keyed_snapshot(), {})
|
||||
self.assertEqual(self.tracker._prompt_queue, [])
|
||||
node.comfyui_integration.register_prompt.assert_not_called()
|
||||
if cls is PromptManagerText:
|
||||
self.assertEqual(result, (POSITIVE,))
|
||||
elif cls is PromptManagerPair:
|
||||
self.assertEqual(result[0], ("cond", POSITIVE))
|
||||
self.assertEqual(result[1], ("cond", NEGATIVE))
|
||||
self.assertEqual(result[2:], (POSITIVE, NEGATIVE))
|
||||
else:
|
||||
self.assertEqual(result, (("cond", POSITIVE), POSITIVE))
|
||||
|
||||
def test_off_without_graph_context_still_saves_nothing(self):
|
||||
node = self.node(PromptManager)
|
||||
node.encode_prompt(self.clip(), POSITIVE, save_to_library=False)
|
||||
self.assertEqual(self.count_rows("prompts"), 0)
|
||||
self.assertEqual(self.tracker._prompt_queue, [])
|
||||
|
||||
def test_off_leaves_an_existing_prompt_uncounted(self):
|
||||
prompt_id = self.save(POSITIVE)
|
||||
self.run_node(PromptManager, run_graph(save=False), False)
|
||||
row = self.db.get_prompt_by_id(prompt_id)
|
||||
self.assertFalse(row.get("run_count") or 0)
|
||||
|
||||
def test_on_is_unchanged(self):
|
||||
self.run_node(PromptManager, run_graph(save=True), True)
|
||||
self.assertIsNotNone(self.db.get_prompt_by_hash(generate_prompt_hash(POSITIVE)))
|
||||
self.assertIn(
|
||||
prompt_node_key(run_graph(save=True), "1"), self.tracker.keyed_snapshot()
|
||||
)
|
||||
|
||||
def test_unknown_setting_on_at_run_time_counts_at_execution(self):
|
||||
"""The hook could not read a wired switch, so the node counts its run."""
|
||||
graph = run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
prompt_id = self.save(POSITIVE)
|
||||
self.run_node(PromptManager, graph, True)
|
||||
self.assertEqual(self.db.get_prompt_by_id(prompt_id)["run_count"], 1)
|
||||
|
||||
|
||||
class TestWiredSwitchOffMarksItsRun(NodeCase):
|
||||
"""Review finding: a wired switch off at run time keeps the node a run prompt."""
|
||||
|
||||
def wired_graph(self):
|
||||
graph = run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
return graph
|
||||
|
||||
def test_an_earlier_entry_of_the_key_never_stands_in(self):
|
||||
graph = self.wired_graph()
|
||||
key = prompt_node_key(graph, "1")
|
||||
node = self.node(PromptManager)
|
||||
node.encode_prompt(
|
||||
self.clip(), POSITIVE, prompt=graph, unique_id="1", save_to_library=True
|
||||
)
|
||||
node.encode_prompt(
|
||||
self.clip(), POSITIVE, prompt=graph, unique_id="1", save_to_library=False
|
||||
)
|
||||
latest, _run, _count = self.tracker.keyed_snapshot()[key]
|
||||
self.assertTrue(latest.get("placeholder"))
|
||||
|
||||
def test_literal_off_marks_nothing(self):
|
||||
node = self.node(PromptManager)
|
||||
node.encode_prompt(
|
||||
self.clip(),
|
||||
POSITIVE,
|
||||
prompt=run_graph(save=False),
|
||||
unique_id="1",
|
||||
save_to_library=False,
|
||||
)
|
||||
self.assertEqual(self.tracker.keyed_snapshot(), {})
|
||||
|
||||
|
||||
class TestMonitorLeavesOptedOutFilesUnlinked(MonitorCase):
|
||||
def test_existing_prompt_is_not_linked_from_metadata(self):
|
||||
self.save(POSITIVE)
|
||||
self.assertIsNone(self.process({"prompt": run_graph(save=False)}))
|
||||
|
||||
def test_find_prompt_reports_opted_out(self):
|
||||
self.save(POSITIVE)
|
||||
state, prompt = self.handler._find_prompt_from_metadata(
|
||||
{"prompt": run_graph(save=False)}
|
||||
)
|
||||
self.assertEqual((state, prompt), ("opted_out", None))
|
||||
|
||||
def test_mixed_positive_settings_leave_the_file_unlinked(self):
|
||||
self.save(POSITIVE)
|
||||
graph = run_graph(save=True)
|
||||
graph["8"] = {
|
||||
"class_type": "PromptManager",
|
||||
"inputs": {"text": "other", "save_to_library": False},
|
||||
}
|
||||
graph["5"] = {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": ["8", 0],
|
||||
"negative": ["2", 0],
|
||||
"latent_image": ["6", 0],
|
||||
},
|
||||
}
|
||||
self.assertIsNone(self.process({"prompt": graph}))
|
||||
|
||||
def test_workflow_only_metadata_with_switch_off_is_opted_out(self):
|
||||
self.save(POSITIVE)
|
||||
metadata = {
|
||||
"text_encoder_nodes": [
|
||||
{
|
||||
"type": "PromptManager",
|
||||
"inputs": [],
|
||||
"widgets_values": [POSITIVE, "", "", "", "", "", False],
|
||||
}
|
||||
]
|
||||
}
|
||||
state, _prompt = self.handler._find_prompt_from_metadata(metadata)
|
||||
self.assertEqual(state, "opted_out")
|
||||
|
||||
def test_workflow_only_ignores_muted_nodes_and_reads_subgraphs(self):
|
||||
from utils.prompt_graph import metadata_opted_out
|
||||
|
||||
node = {
|
||||
"type": "PromptManager",
|
||||
"widgets_values": [POSITIVE, "", "", "", "", "", False],
|
||||
}
|
||||
self.assertFalse(
|
||||
metadata_opted_out({"workflow": {"nodes": [{**node, "mode": 4}]}})
|
||||
)
|
||||
self.assertTrue(
|
||||
metadata_opted_out(
|
||||
{
|
||||
"workflow": {
|
||||
"nodes": [],
|
||||
"definitions": {"subgraphs": [{"nodes": [node]}]},
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_unknown_setting_is_left_to_the_run(self):
|
||||
self.save(POSITIVE)
|
||||
graph = run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
state, _prompt = self.handler._find_prompt_from_metadata({"prompt": graph})
|
||||
self.assertEqual(state, "unknown")
|
||||
|
||||
def test_enabled_positive_still_links(self):
|
||||
prompt_id = self.save(POSITIVE)
|
||||
self.assertEqual(self.process({"prompt": run_graph(save=True)}), prompt_id)
|
||||
|
||||
|
||||
class TestLinkKeepsOptedOutNegativeOut(MonitorCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# The real link, not the recording stub of MonitorCase
|
||||
del self.handler.link_image_to_prompt
|
||||
|
||||
def test_opted_out_negative_is_never_stored_as_a_pair(self):
|
||||
prompt_id = self.save(POSITIVE)
|
||||
metadata = {"prompt": run_graph(save=True, negative_save=False)}
|
||||
self.handler.link_image_to_prompt(self.image, {"id": prompt_id}, metadata)
|
||||
self.assertIsNone(self.db.get_prompt_by_hash(generate_prompt_hash(NEGATIVE)))
|
||||
self.assertEqual(self.count_pairs(), 0)
|
||||
linked = (
|
||||
self.db.model.get_connection()
|
||||
.execute("SELECT prompt_id FROM generated_images")
|
||||
.fetchall()
|
||||
)
|
||||
self.assertEqual([row[0] for row in linked], [prompt_id])
|
||||
|
||||
def test_opted_out_positive_links_nothing(self):
|
||||
prompt_id = self.save(POSITIVE)
|
||||
metadata = {"prompt": run_graph(save=False)}
|
||||
self.handler.link_image_to_prompt(self.image, {"id": prompt_id}, metadata)
|
||||
count = (
|
||||
self.db.model.get_connection()
|
||||
.execute("SELECT COUNT(*) FROM generated_images")
|
||||
.fetchone()[0]
|
||||
)
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
def count_pairs(self):
|
||||
conn = self.db.model.get_connection()
|
||||
return conn.execute("SELECT COUNT(*) FROM prompt_pairs").fetchone()[0]
|
||||
|
||||
|
||||
class TestScanSkipsOptedOutFiles(NodeCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from py.api import admin
|
||||
|
||||
self.mixin = admin.AdminRoutesMixin.__new__(admin.AdminRoutesMixin)
|
||||
self.mixin.db = self.db
|
||||
self.mixin.logger = logging.getLogger("test.save_to_library.scan")
|
||||
self.file = Path(self.tmp.name) / "out.png"
|
||||
|
||||
def test_opted_out_file_stores_nothing(self):
|
||||
status = self.mixin._ingest_scanned_file(
|
||||
self.file, {"prompt": run_graph(save=False)}
|
||||
)
|
||||
self.assertEqual(status, "not_saved")
|
||||
self.assertEqual(self.count_rows("prompts"), 0)
|
||||
self.assertEqual(self.count_rows("generated_images"), 0)
|
||||
|
||||
def test_opted_out_file_is_never_queued_for_review(self):
|
||||
ambiguous = []
|
||||
graph = run_graph(save=False)
|
||||
graph["5"] = dict(graph["3"]) # a second sampler: ambiguous otherwise
|
||||
graph["5"]["inputs"] = {**graph["3"]["inputs"], "positive": ["2", 0]}
|
||||
status = self.mixin._ingest_scanned_file(
|
||||
self.file, {"prompt": graph}, ambiguous=ambiguous
|
||||
)
|
||||
self.assertEqual(status, "not_saved")
|
||||
self.assertEqual(ambiguous, [])
|
||||
|
||||
def test_opted_out_negative_stores_the_positive_only(self):
|
||||
status = self.mixin._ingest_scanned_file(
|
||||
self.file, {"prompt": run_graph(save=True, negative_save=False)}
|
||||
)
|
||||
self.assertEqual(status, "added")
|
||||
self.assertIsNotNone(self.db.get_prompt_by_hash(generate_prompt_hash(POSITIVE)))
|
||||
self.assertIsNone(self.db.get_prompt_by_hash(generate_prompt_hash(NEGATIVE)))
|
||||
self.assertEqual(self.count_rows("prompt_pairs"), 0)
|
||||
|
||||
def test_wired_switch_is_left_to_the_live_run(self):
|
||||
graph = run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
status = self.mixin._ingest_scanned_file(self.file, {"prompt": graph})
|
||||
self.assertEqual(status, "not_saved_wired")
|
||||
self.assertEqual(self.count_rows("prompts"), 0)
|
||||
|
||||
def test_status_has_a_scan_detail(self):
|
||||
from py.api import admin
|
||||
|
||||
group, row, _line = admin._scan_outcome(self.file, [], "not_saved")
|
||||
self.assertEqual(group, "no_metadata")
|
||||
self.assertIn("does not save", row["detail"])
|
||||
|
||||
|
||||
class TestHistoryRecordsNoText(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.m = load_module("execution_context_graph")
|
||||
|
||||
def test_opted_out_role_has_no_sources_and_no_text(self):
|
||||
recipe = self.m.project_contexts(run_graph(save=False), None).recipes[0]
|
||||
self.assertEqual(recipe.positive.sources, ())
|
||||
self.assertIsNone(recipe.positive.inferred)
|
||||
self.assertEqual(recipe.positive.reason, "not_saved")
|
||||
# The enabled negative is recorded as before
|
||||
self.assertEqual(recipe.negative.inferred.raw.value, NEGATIVE)
|
||||
|
||||
def test_unknown_setting_records_no_text_either(self):
|
||||
graph = run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
recipe = self.m.project_contexts(graph, None).recipes[0]
|
||||
self.assertIsNone(recipe.positive.inferred)
|
||||
self.assertEqual(recipe.positive.reason, "not_saved")
|
||||
|
||||
def test_enabled_role_is_unchanged(self):
|
||||
recipe = self.m.project_contexts(run_graph(save=True), None).recipes[0]
|
||||
self.assertEqual(recipe.positive.inferred.raw.value, POSITIVE)
|
||||
self.assertEqual(len(recipe.positive.sources), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Vendored
+4
-3
@@ -35,15 +35,16 @@ test('execution script discovers only the new matrix and preserves all 78 origin
|
||||
assert.match(executions, /\[nodes2-start\]/);
|
||||
// + the fresh-host library location case, + #342 image attribution, + #343 linked text,
|
||||
// + #332 image category routes on a generated output (image-attribution.spec.mjs, 2 projects),
|
||||
// + #347 cached runtime-linked runs: five node kinds and eight one-renderer cases (execution-text-link.spec.mjs, 2 projects)
|
||||
assert.match(executions, /Total: 118 tests in 5 files/);
|
||||
// + #347 cached runtime-linked runs: five node kinds and eight one-renderer cases (execution-text-link.spec.mjs, 2 projects),
|
||||
// + #90 Save to library off (five node types)
|
||||
assert.match(executions, /Total: 128 tests in 5 files/);
|
||||
for (const project of ['classic-start', 'nodes2-start']) {
|
||||
const cases = executions.split('\n').filter(line => line.includes(`[${project}]`));
|
||||
assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 15);
|
||||
assert.equal(cases.filter(line => line.includes('execution-outputs.spec.mjs')).length, 8);
|
||||
assert.equal(cases.filter(line => line.includes('library-location.spec.mjs')).length, 1);
|
||||
assert.equal(cases.filter(line => line.includes('image-attribution.spec.mjs')).length, 3);
|
||||
assert.equal(cases.filter(line => line.includes('execution-text-link.spec.mjs')).length, 32);
|
||||
assert.equal(cases.filter(line => line.includes('execution-text-link.spec.mjs')).length, 37);
|
||||
}
|
||||
assert.doesNotMatch(executions, /prompt-manager.spec/);
|
||||
});
|
||||
|
||||
@@ -107,6 +107,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"save-to-library.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"scan-progress.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
|
||||
@@ -11,6 +11,7 @@ import weakref
|
||||
|
||||
from .execution_context_budget import ContextBudget, PayloadLease, SealedContext
|
||||
from .execution_context_graph import (
|
||||
NOT_SAVED,
|
||||
GraphProjection,
|
||||
GraphRole,
|
||||
SourceRef,
|
||||
@@ -325,6 +326,8 @@ class ContextCapture:
|
||||
nonlocal ambiguous
|
||||
if role.reason == "source-transformed":
|
||||
return _unknown("unsupported")
|
||||
if role.reason == NOT_SAVED:
|
||||
return _unknown("unavailable")
|
||||
if not role.sources:
|
||||
return role.inferred or _unknown("unsupported")
|
||||
values = [
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from .execution_contexts import PromptSnapshot, TextEvidence, text_size
|
||||
from .prompt_graph import save_setting
|
||||
from .prompt_pair_resolver import _DEFAULT_ADAPTERS
|
||||
|
||||
# A role whose PromptManager source has save_to_library off, or wired from a
|
||||
# node the graph cannot read: no text is recorded for it (#90)
|
||||
NOT_SAVED = "not_saved"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceRef:
|
||||
@@ -332,6 +337,11 @@ class _Projector:
|
||||
self.transformed = False
|
||||
# Only the capture-source-to-consumer path determines transforms.
|
||||
sources = self.discover_sources(value, "conditioning")
|
||||
if any(
|
||||
save_setting(self.graph, source.node_id) is not True
|
||||
for source in sources
|
||||
):
|
||||
return GraphRole((), None, NOT_SAVED)
|
||||
snapshot, _ = self.text(value, "conditioning")
|
||||
return GraphRole(
|
||||
sources, snapshot, "source-transformed" if self.transformed else ""
|
||||
|
||||
+36
-3
@@ -37,7 +37,13 @@ from .hashing import generate_prompt_hash
|
||||
from .metadata_extractor import ComfyUIMetadataExtractor
|
||||
from .parallel import note_refusal, refused_at_shutdown
|
||||
from .logging_config import get_logger
|
||||
from .prompt_graph import resolve_text, run_prompt_nodes
|
||||
from .prompt_graph import (
|
||||
metadata_opted_out,
|
||||
resolve_text,
|
||||
run_prompt_nodes,
|
||||
save_setting,
|
||||
side_opted_out,
|
||||
)
|
||||
from .prompt_pair_resolver import resolve_prompt_pair
|
||||
from .run_keys import image_run_keys
|
||||
from .usage_tracking import survive_switch
|
||||
@@ -450,6 +456,12 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
# that produced this image, independent of node caching and of any
|
||||
# negative-prompt PromptManager nodes in the workflow.
|
||||
state, current_prompt = self._find_prompt_from_metadata(metadata)
|
||||
if state == "opted_out":
|
||||
self.logger.info(
|
||||
"Save to library is off for this run's prompt, skipping image: "
|
||||
f"{image_path}"
|
||||
)
|
||||
return
|
||||
if state == "foreign":
|
||||
# Another workflow saved this image: it belongs to no prompt of
|
||||
# ours, and must not consume a batch queue entry.
|
||||
@@ -715,16 +727,27 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
``("linked", prompt)`` with a context dict holding 'id' and 'text';
|
||||
``("foreign", None)`` when the workflow has no positive PromptManager
|
||||
node, so the image belongs to no prompt of ours;
|
||||
``("opted_out", None)`` when a PromptManager node that would be the
|
||||
run's prompt has its ``save_to_library`` switch off (#90): the image
|
||||
is linked to no prompt, by no fallback;
|
||||
``("unknown", None)`` when metadata can't decide (missing, a batch
|
||||
item whose text came from another node, or a lookup failure) and the
|
||||
caller's queue and tracker fallbacks apply.
|
||||
"""
|
||||
if not metadata:
|
||||
return "unknown", None
|
||||
if metadata_opted_out(metadata):
|
||||
return "opted_out", None
|
||||
graph = metadata.get("prompt")
|
||||
if isinstance(graph, dict) and any(
|
||||
save_setting(graph, node_id) is None for node_id in run_prompt_nodes(graph)
|
||||
):
|
||||
# A switch wired from a node the graph cannot read: only the node's
|
||||
# own run (it saw the real value) can link the image (#90)
|
||||
return "unknown", None
|
||||
|
||||
prompt_text = None
|
||||
resolution = resolve_prompt_pair(metadata)
|
||||
graph = metadata.get("prompt")
|
||||
if resolution["state"] == "resolved" and isinstance(graph, dict):
|
||||
positive = resolution["positive"]
|
||||
path = positive["provenance"].get("path", [])
|
||||
@@ -865,13 +888,23 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
metadata: Extracted metadata from the image file (workflow, parameters,
|
||||
etc.)
|
||||
"""
|
||||
if metadata_opted_out(metadata):
|
||||
# Reached from a run or history: the file's own graph says no (#90)
|
||||
self.logger.info(
|
||||
f"Save to library is off for {os.path.basename(image_path)}; "
|
||||
"left unlinked"
|
||||
)
|
||||
return
|
||||
try:
|
||||
resolution = resolve_prompt_pair(metadata)
|
||||
graph = metadata.get("prompt") if isinstance(metadata, dict) else None
|
||||
# An opted-out negative is never stored: link the positive alone
|
||||
pair_allowed = not side_opted_out(graph, resolution.get("negative"))
|
||||
|
||||
def link():
|
||||
# One write; run again on the moved library when a Move
|
||||
# library switched while it waited (#249 S6b).
|
||||
if resolution["state"] == "resolved":
|
||||
if resolution["state"] == "resolved" and pair_allowed:
|
||||
positive = resolution["positive"]
|
||||
expected = self.db_manager.get_prompt_by_hash(
|
||||
generate_prompt_hash(
|
||||
|
||||
+184
-18
@@ -21,6 +21,11 @@ PROMPT_MANAGER_TYPES = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# A node's switch (#90): False means PromptManager ignores that node's runs
|
||||
SAVE_INPUT = "save_to_library"
|
||||
# Core nodes whose output is a known boolean: (class_type) -> input holding it
|
||||
_BOOLEAN_SOURCES = {"PrimitiveBoolean": "value"}
|
||||
|
||||
# Upper bound on nodes expanded per graph; queued and embedded graphs are untrusted
|
||||
MAX_VISITS = 500
|
||||
# Longest text resolve_text will build; StringConcatenate chains can double
|
||||
@@ -59,6 +64,116 @@ def _inputs(node: Any) -> Dict[str, Any]:
|
||||
return inputs if isinstance(inputs, dict) else {}
|
||||
|
||||
|
||||
def _as_boolean(value: Any) -> bool:
|
||||
"""ComfyUI's coercion of a BOOLEAN input before the node runs."""
|
||||
if isinstance(value, dict) and "__value__" in value:
|
||||
value = value["__value__"]
|
||||
return bool(value)
|
||||
|
||||
|
||||
def save_setting(graph: Any, node_id: Any) -> Optional[bool]:
|
||||
"""A PromptManager node's ``save_to_library`` as the node will receive it.
|
||||
|
||||
True when the input is missing (every workflow and image from before the
|
||||
switch), the literal coerced like ComfyUI does, the value of a linked core
|
||||
``PrimitiveBoolean``, and None when the switch is wired from a node whose
|
||||
output cannot be known from the graph.
|
||||
"""
|
||||
node = graph.get(str(node_id)) if isinstance(graph, dict) else None
|
||||
inputs = _inputs(node)
|
||||
if SAVE_INPUT not in inputs:
|
||||
return True
|
||||
value = inputs[SAVE_INPUT]
|
||||
if not _is_link(value):
|
||||
return _as_boolean(value)
|
||||
source = graph.get(str(value[0]))
|
||||
if not isinstance(source, dict) or value[1] != 0:
|
||||
return None
|
||||
key = _BOOLEAN_SOURCES.get(str(source.get("class_type", "")))
|
||||
source_inputs = _inputs(source)
|
||||
if key is None or key not in source_inputs or _is_link(source_inputs[key]):
|
||||
return None
|
||||
return _as_boolean(source_inputs[key])
|
||||
|
||||
|
||||
def opted_out(graph: Any, node_id: Any) -> bool:
|
||||
"""Whether a PromptManager node's switch is off: PromptManager ignores it."""
|
||||
return save_setting(graph, node_id) is False
|
||||
|
||||
|
||||
def side_opted_out(graph: Any, side: Any) -> bool:
|
||||
"""Whether a resolved side's path passes a PromptManager node that is off.
|
||||
|
||||
``side`` is a resolver side (``positive`` or ``negative`` of a resolved
|
||||
pair): its text must not be stored when any PromptManager node on its
|
||||
provenance path has its switch off or wired from an unreadable node (#90).
|
||||
"""
|
||||
if not isinstance(graph, dict) or not isinstance(side, dict):
|
||||
return False
|
||||
provenance = side.get("provenance")
|
||||
path = provenance.get("path") if isinstance(provenance, dict) else None
|
||||
for step in path if isinstance(path, list) else []:
|
||||
node_id = step.get("node_id") if isinstance(step, dict) else None
|
||||
node = graph.get(str(node_id)) if node_id is not None else None
|
||||
if (
|
||||
isinstance(node, dict)
|
||||
and node.get("class_type") in PROMPT_MANAGER_TYPES
|
||||
and save_setting(graph, node_id) is not True
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _workflow_node_opted_out(node: Any) -> bool:
|
||||
"""A saved-workflow PromptManager node whose widget values hold ``False``.
|
||||
|
||||
No other PromptManager widget holds a boolean, so the value is the switch.
|
||||
"""
|
||||
if not isinstance(node, dict):
|
||||
return False
|
||||
if (node.get("type") or node.get("class_type")) not in PROMPT_MANAGER_TYPES:
|
||||
return False
|
||||
if node.get("mode") in (2, 4):
|
||||
return False # muted or bypassed: it did not run
|
||||
values = node.get("widgets_values")
|
||||
return isinstance(values, list) and any(value is False for value in values)
|
||||
|
||||
|
||||
def metadata_opted_out(metadata: Any, *, unreadable: bool = False) -> bool:
|
||||
"""Whether a file's metadata says its run's prompt does not save (#90).
|
||||
|
||||
The executed graph (``prompt``) decides when present: a node of
|
||||
``positive_opt_outs``, or with ``unreadable`` also a run prompt whose
|
||||
switch is wired from a node the graph cannot read. Without one, the
|
||||
saved workflow (``workflow`` nodes or the extractor's
|
||||
``text_encoder_nodes``) is opted out when a PromptManager node stores
|
||||
``False``.
|
||||
"""
|
||||
if not isinstance(metadata, dict):
|
||||
return False
|
||||
graph = metadata.get("prompt")
|
||||
if isinstance(graph, dict):
|
||||
if positive_opt_outs(graph):
|
||||
return True
|
||||
return unreadable and any(
|
||||
save_setting(graph, node_id) is None for node_id in run_prompt_nodes(graph)
|
||||
)
|
||||
workflow = metadata.get("workflow")
|
||||
nodes = list(metadata.get("text_encoder_nodes") or [])
|
||||
if isinstance(workflow, dict) and isinstance(workflow.get("nodes"), list):
|
||||
nodes.extend(workflow["nodes"])
|
||||
definitions = workflow.get("definitions")
|
||||
subgraphs = (
|
||||
definitions.get("subgraphs") if isinstance(definitions, dict) else None
|
||||
)
|
||||
for subgraph in subgraphs if isinstance(subgraphs, list) else []:
|
||||
inner = subgraph.get("nodes") if isinstance(subgraph, dict) else None
|
||||
nodes.extend(inner if isinstance(inner, list) else [])
|
||||
# Without the executed graph the node's role is unknown: any opted-out
|
||||
# PromptManager node opts the file out (never a guessed link).
|
||||
return any(_workflow_node_opted_out(node) for node in nodes[:MAX_VISITS])
|
||||
|
||||
|
||||
def _positive_roots(graph: Dict[str, Any]) -> List[Any]:
|
||||
"""Links into samplers' positive inputs (KSampler, CFGGuider, BasicGuider, ...).
|
||||
|
||||
@@ -100,11 +215,21 @@ def has_sampler(graph: Any) -> bool:
|
||||
|
||||
|
||||
def positive_prompt_components(graph: Any) -> List[Tuple[str, str]]:
|
||||
"""Node/input identities reaching actual sampler positive wires."""
|
||||
"""Node/input identities reaching actual sampler positive wires.
|
||||
|
||||
A node whose switch is off is not one, and nothing upstream of it is
|
||||
followed (#90): see ``positive_opt_outs``.
|
||||
"""
|
||||
return _walk_positive(graph)[0]
|
||||
|
||||
|
||||
def _walk_positive(graph: Any) -> Tuple[List[Tuple[str, str]], List[str]]:
|
||||
"""(components of enabled nodes, opted-out nodes) on sampler positive wires."""
|
||||
if not isinstance(graph, dict):
|
||||
return []
|
||||
return [], []
|
||||
|
||||
found: List[Tuple[str, str]] = []
|
||||
skipped: List[str] = []
|
||||
seen = set()
|
||||
stack = list(reversed(_positive_roots(graph)))
|
||||
while stack and len(seen) < MAX_VISITS:
|
||||
@@ -117,6 +242,10 @@ def positive_prompt_components(graph: Any) -> List[Tuple[str, str]]:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
if node.get("class_type") in PROMPT_MANAGER_TYPES:
|
||||
if opted_out(graph, node_id):
|
||||
if node_id not in skipped:
|
||||
skipped.append(node_id)
|
||||
continue
|
||||
field = "text"
|
||||
if node.get("class_type") == "PromptManagerPair":
|
||||
field = {
|
||||
@@ -132,7 +261,33 @@ def positive_prompt_components(graph: Any) -> List[Tuple[str, str]]:
|
||||
found.append(identity)
|
||||
continue
|
||||
stack.extend(reversed(_upstream_links(_inputs(node))))
|
||||
return found
|
||||
return found, skipped
|
||||
|
||||
|
||||
def _sampler_less_nodes(graph: Dict[str, Any]) -> List[str]:
|
||||
"""Every PromptManager node of a graph without a recognisable sampler."""
|
||||
return [
|
||||
str(node_id)
|
||||
for node_id, node in graph.items()
|
||||
if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES
|
||||
]
|
||||
|
||||
|
||||
def positive_opt_outs(graph: Any) -> List[str]:
|
||||
"""PromptManager nodes with their switch off that would be a run's prompt.
|
||||
|
||||
Those on a sampler's positive wires, or, in a graph without a recognisable
|
||||
sampler, any. A run with one of them links no file to any prompt (#90):
|
||||
the file's graph cannot always say which sampler wrote it.
|
||||
"""
|
||||
if not isinstance(graph, dict):
|
||||
return []
|
||||
found, skipped = _walk_positive(graph)
|
||||
if found or skipped or has_sampler(graph):
|
||||
return skipped
|
||||
return [
|
||||
node_id for node_id in _sampler_less_nodes(graph) if opted_out(graph, node_id)
|
||||
]
|
||||
|
||||
|
||||
def positive_prompt_nodes(graph: Any) -> List[str]:
|
||||
@@ -148,13 +303,13 @@ def positive_prompt_nodes(graph: Any) -> List[str]:
|
||||
|
||||
def run_prompt_components(graph: Any) -> List[Tuple[str, str]]:
|
||||
"""Actual positive components, retaining standalone main-editor fallback."""
|
||||
components = positive_prompt_components(graph)
|
||||
if components or not isinstance(graph, dict) or has_sampler(graph):
|
||||
components, skipped = _walk_positive(graph)
|
||||
if components or skipped or not isinstance(graph, dict) or has_sampler(graph):
|
||||
return components
|
||||
return [
|
||||
(str(node_id), "text")
|
||||
for node_id, node in graph.items()
|
||||
if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES
|
||||
(node_id, "text")
|
||||
for node_id in _sampler_less_nodes(graph)
|
||||
if not opted_out(graph, node_id)
|
||||
]
|
||||
|
||||
|
||||
@@ -165,16 +320,16 @@ def run_prompt_nodes(graph: Any) -> List[str]:
|
||||
say), every PromptManager node counts, as before 3.2.4, so an exotic workflow
|
||||
never silently loses usage counting or image linking. When a sampler exists
|
||||
and no PromptManager node feeds its positive input, nothing counts: the only
|
||||
PromptManager nodes are negative prompts.
|
||||
PromptManager nodes are negative prompts. A node whose switch is off never
|
||||
counts (#90).
|
||||
"""
|
||||
positive = positive_prompt_nodes(graph)
|
||||
if positive or not isinstance(graph, dict) or has_sampler(graph):
|
||||
return positive
|
||||
return [
|
||||
str(node_id)
|
||||
for node_id, node in graph.items()
|
||||
if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES
|
||||
]
|
||||
return list(
|
||||
dict.fromkeys(
|
||||
node_id
|
||||
for node_id, field in run_prompt_components(graph)
|
||||
if field == "text"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _join_text_inputs(
|
||||
@@ -270,7 +425,18 @@ def resolve_text(graph: Any, node_id: str) -> Optional[str]:
|
||||
|
||||
|
||||
def intrinsic_pair_nodes(graph: Any) -> List[str]:
|
||||
"""Paired nodes whose matching outputs feed the same sampler, or standalone."""
|
||||
"""Paired nodes whose matching outputs feed the same sampler, or standalone.
|
||||
|
||||
A pair whose switch is off is never one (#90).
|
||||
"""
|
||||
return [
|
||||
node_id
|
||||
for node_id in _intrinsic_pair_nodes(graph)
|
||||
if not opted_out(graph, node_id)
|
||||
]
|
||||
|
||||
|
||||
def _intrinsic_pair_nodes(graph: Any) -> List[str]:
|
||||
if not isinstance(graph, dict) or len(graph) > MAX_VISITS:
|
||||
return []
|
||||
if not has_sampler(graph):
|
||||
|
||||
+13
-1
@@ -30,7 +30,12 @@ from typing import Any, Callable, List, Optional, Tuple
|
||||
try:
|
||||
from .hashing import generate_prompt_hash
|
||||
from .logging_config import get_logger
|
||||
from .prompt_graph import intrinsic_pair_nodes, resolve_text, run_prompt_components
|
||||
from .prompt_graph import (
|
||||
intrinsic_pair_nodes,
|
||||
resolve_text,
|
||||
run_prompt_components,
|
||||
save_setting,
|
||||
)
|
||||
from .prompt_pair_resolver import resolve_text_input
|
||||
from .pair_execution import clear_pair_execution
|
||||
from .queue_observers import subscribe_dequeue
|
||||
@@ -41,6 +46,7 @@ except ImportError:
|
||||
intrinsic_pair_nodes,
|
||||
resolve_text,
|
||||
run_prompt_components,
|
||||
save_setting,
|
||||
)
|
||||
from utils.prompt_pair_resolver import resolve_text_input
|
||||
from utils.pair_execution import clear_pair_execution
|
||||
@@ -127,10 +133,14 @@ def queued_prompt_runs(graph: Any) -> List[Run]:
|
||||
"""(hash, text) of each positive PromptManager node whose text is knowable.
|
||||
|
||||
Each hash once: the same text in two positive nodes is one run of the prompt.
|
||||
A node whose switch is off is never counted; one whose switch is wired from
|
||||
a node the graph cannot read counts itself when it executes (#90).
|
||||
"""
|
||||
runs: List[Run] = []
|
||||
seen = set()
|
||||
for node_id, field in run_prompt_components(graph):
|
||||
if save_setting(graph, node_id) is not True:
|
||||
continue
|
||||
node = graph.get(node_id, {}) if isinstance(graph, dict) else {}
|
||||
text = (
|
||||
resolve_text_input(graph, node_id, field)
|
||||
@@ -156,6 +166,8 @@ def queued_pair_runs(graph: Any) -> List[Tuple[str, str, str]]:
|
||||
"""Resolve raw paired recipes only when both outputs form the used pair."""
|
||||
found, seen = [], set()
|
||||
for node_id in intrinsic_pair_nodes(graph):
|
||||
if save_setting(graph, node_id) is not True:
|
||||
continue # wired switch: the node counts itself (#90)
|
||||
positive = resolve_text_input(graph, node_id, "text")
|
||||
negative = resolve_text_input(graph, node_id, "negative_text")
|
||||
if positive is None or not positive.strip() or negative is None:
|
||||
|
||||
@@ -6,6 +6,7 @@ const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/
|
||||
const { installFocusedNode, installSplitNode, installPairNode } = await import(api.fileURL('/prompt_manager/js/focused-node.js'));
|
||||
const { installPromptBridge } = await import(api.fileURL('/prompt_manager/js/prompt-bridge-host.js'));
|
||||
const { collapseLinkedWidget, isLinked, linkNoteText, primitiveTextValue, storedWidgetValue } = await import(api.fileURL('/prompt_manager/js/text-links.js'));
|
||||
const { normalizeSaveWidget } = await import(api.fileURL('/prompt_manager/js/save-to-library.js'));
|
||||
const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']);
|
||||
|
||||
// ============================================================================
|
||||
@@ -525,6 +526,8 @@ app.registerExtension({
|
||||
if (originalConfigure) {
|
||||
originalConfigure.call(this, data);
|
||||
}
|
||||
// #90: an older workflow loads with Save to library on
|
||||
normalizeSaveWidget(this);
|
||||
if (data._userHasResized) {
|
||||
this._userHasResized = data._userHasResized;
|
||||
}
|
||||
|
||||
+31
-4
@@ -2,9 +2,13 @@ import {createScope, createButton, createField, mountTheme} from './shared/toolk
|
||||
import {createPromptLibrary} from './prompt-library.js';
|
||||
import {attachMetadataAutocomplete} from './metadata-autocomplete.js';
|
||||
import {exposeTextSockets, linkNoteText, preferTextInputs, textInputAtPoint} from './text-links.js';
|
||||
import {SAVE_WIDGET, normalizeSaveWidget, placeSaveWidgetLast, saveWidget, savesToLibrary, setSaveToLibrary} from './save-to-library.js';
|
||||
|
||||
const names = ['text', 'category', 'tags', 'search_text', 'prepend_text', 'append_text'];
|
||||
let nextEditorId = 0;
|
||||
// The editor draws these widgets itself; the host keeps them as hidden, zero-height values.
|
||||
function hiddenWidgetSize() { return [0, -4]; }
|
||||
function hideNativeWidget(widget) { widget.hidden = true; widget.type = 'hidden'; widget.computeSize = hiddenWidgetSize; }
|
||||
|
||||
function loadStyles() {
|
||||
const href = new URL('./prompt-editor.css', import.meta.url).href;
|
||||
@@ -43,10 +47,15 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
for (const name of pair ? [...names,'negative_text'] : names) {
|
||||
let widget = this.widgets?.find(item => item.name === name);
|
||||
if (!widget) widget = this.addWidget('text', name, '', () => {}, {});
|
||||
widget.hidden = true; widget.type = 'hidden'; widget.computeSize = () => [0, -4];
|
||||
hideNativeWidget(widget);
|
||||
widgets.set(name, widget);
|
||||
}
|
||||
exposeTextSockets(this, textInputs);
|
||||
// #90: the switch follows the hidden fallbacks above (one widget order on every frontend) and is drawn in the
|
||||
// toolbar, like the other hidden widgets are drawn by the editor.
|
||||
placeSaveWidgetLast(this);
|
||||
const nativeSave = saveWidget(this);
|
||||
if (nativeSave) hideNativeWidget(nativeSave);
|
||||
const element = document.createElement('div'); element.className = `ca-tools pm-focused${split ? ' pm-split' : ''}${pair ? ' pm-pair' : ''}`;
|
||||
element.setAttribute('aria-label',pair ? 'Paired prompt editor' : split ? 'Split prompt editor' : 'Focused prompt editor');
|
||||
loadStyles(); mountTheme(element, app.ui?.settings, scope);
|
||||
@@ -58,6 +67,11 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
toolbar.append(web);
|
||||
const save = pair ? createButton('Save pair','ca-primary') : null;
|
||||
if (save) toolbar.append(save);
|
||||
const saveSwitch = document.createElement('input'); saveSwitch.type = 'checkbox'; saveSwitch.dataset.saveToLibrary = '';
|
||||
const saveLabel = document.createElement('label'); saveLabel.className = 'pm-editor-save';
|
||||
saveLabel.title = 'Off: runs of this node are not saved, counted or linked to their images. The output is unchanged.';
|
||||
saveLabel.append(saveSwitch, document.createTextNode('Save to library'));
|
||||
if (nativeSave) toolbar.append(saveLabel);
|
||||
let saving = false;
|
||||
const workspace = document.createElement('div'); workspace.className = 'pm-editor-workspace';
|
||||
const editor = document.createElement('div'); editor.className = 'pm-editor-compose';
|
||||
@@ -138,7 +152,11 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
}
|
||||
const composed = disclosure('preview','Composed text preview');
|
||||
const preview = document.createElement('p'); preview.className = 'pm-editor-preview'; preview.dataset.composedPreview = ''; composed.append(preview);
|
||||
const footer = document.createElement('p'); footer.className = 'pm-editor-status'; footer.setAttribute('role','status'); footer.textContent = pair ? 'The prompt pair is saved when this node runs, or use Save pair.' : 'Main prompt is saved when this node runs.';
|
||||
const footer = document.createElement('p'); footer.className = 'pm-editor-status'; footer.setAttribute('role','status');
|
||||
const SAVE_OFF = 'Save to library is off: runs of this node are not added to the library.';
|
||||
const SAVE_LINKED = 'Save to library comes from a link: runs are added to the library only while it is on.';
|
||||
function runNote(saved) { return isConnected(SAVE_WIDGET) ? SAVE_LINKED : savesToLibrary(thisNode) ? saved : SAVE_OFF; }
|
||||
footer.textContent = runNote(pair ? 'The prompt pair is saved when this node runs, or use Save pair.' : 'Main prompt is saved when this node runs.');
|
||||
const saveConnectionNotice = pair ? document.createElement('p') : null;
|
||||
if (saveConnectionNotice) {
|
||||
saveConnectionNotice.className = 'pm-editor-status'; saveConnectionNotice.setAttribute('role','status');
|
||||
@@ -166,6 +184,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
mainField.hidden = Boolean(isConnected('text')); connected.hidden = !isConnected('text');
|
||||
if (negative) { negative.field.hidden = Boolean(isConnected('negative_text')); negative.note.hidden = !isConnected('negative_text'); }
|
||||
if (save) { save.disabled = saving || hasConnectedPairValues(); saveConnectionNotice.hidden = !hasConnectedPairValues(); }
|
||||
saveSwitch.checked = savesToLibrary(thisNode); saveSwitch.disabled = isConnected(SAVE_WIDGET);
|
||||
controls.forEach((input,name) => {
|
||||
input.value = value(name);
|
||||
if (name !== 'text' && name !== 'negative_text') {
|
||||
@@ -185,7 +204,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
write('category',prompt.category || '');
|
||||
write('tags',Array.isArray(prompt.tags) ? prompt.tags.join(', ') : prompt.tags || '');
|
||||
}
|
||||
render(); footer.setAttribute('role','status'); footer.textContent = pair ? 'Loaded pair. Saved when this node runs.' : 'Loaded prompt. Saved when this node runs.'; main.focus();
|
||||
render(); footer.setAttribute('role','status'); footer.textContent = runNote(pair ? 'Loaded pair. Saved when this node runs.' : 'Loaded prompt. Saved when this node runs.'); main.focus();
|
||||
}});
|
||||
workspace.append(library.element); element.append(footer);
|
||||
function snapshot() {
|
||||
@@ -197,7 +216,13 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
library.element.hidden = !split && !state.libraryOpen; browse.textContent = state.libraryOpen ? 'Hide library' : 'Browse library'; browse.setAttribute('aria-expanded', String(Boolean(state.libraryOpen)));
|
||||
library.setQuery(value('search_text')); library.restoreFilters(state.libraryFilters); render();
|
||||
}
|
||||
controls.forEach((input,name) => scope.listen(input,'input',() => {write(name,input.value);render();footer.setAttribute('role','status');footer.textContent=pair?'The pair is saved when this node runs.':'Main prompt is saved when this node runs.';}));
|
||||
controls.forEach((input,name) => scope.listen(input,'input',() => {write(name,input.value);render();footer.setAttribute('role','status');footer.textContent=runNote(pair?'The pair is saved when this node runs.':'Main prompt is saved when this node runs.');}));
|
||||
scope.listen(saveSwitch,'change',() => {
|
||||
if (isConnected(SAVE_WIDGET)) return;
|
||||
setSaveToLibrary(thisNode, saveSwitch.checked); render();
|
||||
footer.setAttribute('role','status'); footer.textContent = runNote(pair ? 'The pair is saved when this node runs.' : 'Main prompt is saved when this node runs.');
|
||||
thisNode.setDirtyCanvas(true,true); app.graph?.setDirtyCanvas(true,true);
|
||||
});
|
||||
scope.listen(browse,'click',() => {
|
||||
library.element.hidden = !library.element.hidden;
|
||||
library.closeCompletions();
|
||||
@@ -248,6 +273,8 @@ export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'fo
|
||||
const result = configure?.apply(this,args);
|
||||
// A saved workflow restores its inputs with their widget bindings; keep the text inputs as sockets.
|
||||
exposeTextSockets(this, textInputs);
|
||||
// #90: an older workflow loads with Save to library on
|
||||
normalizeSaveWidget(this);
|
||||
this._focusedView?.restore();
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
.pm-focused [hidden] { display: none !important; }
|
||||
.pm-editor-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.pm-editor-toolbar > strong { margin-inline-end: auto; font-weight: 500; }
|
||||
.pm-editor-save { display: inline-flex; align-items: center; gap: 6px; color: var(--ca-muted); cursor: pointer; }
|
||||
.pm-editor-save:has(input:checked) { color: inherit; }
|
||||
.pm-focused .ca-field-label { align-self: flex-start; margin-inline: 0; text-align: start; }
|
||||
.pm-editor-workspace, .pm-editor-compose { display: contents; }
|
||||
.pm-focused .pm-editor-main { display: flex; flex: 1; min-block-size: 180px; flex-direction: column; align-items: stretch; gap: 5px; text-align: start; }
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// #90: a PromptManager node's `save_to_library` switch. Off: PromptManager ignores the node's runs (nothing saved,
|
||||
// counted or linked); the node's output is unchanged.
|
||||
//
|
||||
// The input is declared last, so it takes the widget index an older workflow used for the editor's own
|
||||
// (non-serialized) DOM widget, where it stored `''` or nothing. ComfyUI coerces a BOOLEAN with `bool(value)`, so that
|
||||
// `''` would turn saving off: after a workflow loads, anything that is not a boolean is the default, on.
|
||||
|
||||
export const SAVE_WIDGET = 'save_to_library';
|
||||
|
||||
export function saveWidget(node) {
|
||||
return node?.widgets?.find(widget => widget?.name === SAVE_WIDGET);
|
||||
}
|
||||
|
||||
/** Whether the node saves its runs (a missing widget, as on an older backend, is on). */
|
||||
export function savesToLibrary(node) {
|
||||
return saveWidget(node)?.value !== false;
|
||||
}
|
||||
|
||||
/** Set a non-boolean switch value (from an older workflow) to on. Returns whether it changed. */
|
||||
export function normalizeSaveWidget(node) {
|
||||
const widget = saveWidget(node);
|
||||
if (!widget || typeof widget.value === 'boolean') return false;
|
||||
widget.value = true;
|
||||
const index = node.widgets.indexOf(widget);
|
||||
if (Array.isArray(node.widgets_values) && index >= 0) node.widgets_values[index] = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Move the switch after every other widget the node has now, so its index is the same on every frontend. */
|
||||
export function placeSaveWidgetLast(node) {
|
||||
const widget = saveWidget(node);
|
||||
const widgets = node?.widgets;
|
||||
if (!widget || widgets.at(-1) === widget) return false;
|
||||
widgets.splice(widgets.indexOf(widget), 1);
|
||||
widgets.push(widget);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Turn the switch on or off the way a native widget edit does. */
|
||||
export function setSaveToLibrary(node, value) {
|
||||
const widget = saveWidget(node);
|
||||
if (!widget) return;
|
||||
widget.value = Boolean(value);
|
||||
const index = node.widgets.indexOf(widget);
|
||||
if (Array.isArray(node.widgets_values) && index >= 0) node.widgets_values[index] = widget.value;
|
||||
widget.callback?.(widget.value, undefined, node);
|
||||
}
|
||||
Reference in New Issue
Block a user