From d9f168c317f107c45d090e6c436d6fe759bdb0fe Mon Sep 17 00:00:00 2001 From: Vito Date: Wed, 28 Oct 2026 18:09:16 +0000 Subject: [PATCH] feat(nodes): per-node Save to library switch (#90) (#427) * feat(nodes): per-node Save to library switch (#90) Every PromptManager node (Classic, Text, Focused, Split, Pair) gets an optional save_to_library BOOLEAN, default on and declared last. Off, the node still returns its normal outputs but PromptManager ignores the run: - the node saves, counts, tracks and records nothing; - the queue hook reads the switch from the queued graph (literal, or a linked PrimitiveBoolean), so a cached node is not counted either; a switch wired from an unreadable node is left to the node itself; - an opted-out node is no run prompt, so no tracker entry, run key, embedded-metadata match or history attribution links its images, and a run with any opted-out positive node links none of its files; - the scan reports such files as not_saved (never stored or queued for Needs review), and a file whose only opted-out node is its negative is stored positive-only, without the pair; - execution history records no prompt text for an opted-out role. PromptSearchList only reads the library and gets no switch. The switch takes the widget index older workflows used for the editor's own DOM slot (''), so a configure step turns any non-boolean value on: workflows saved before the switch load with it on and no earlier widget value moves. The Focused, Split and Pair editors draw it as a "Save to library" checkbox in their toolbar; Classic and Text show the native toggle. * fix(nodes): Save to library review fixes (#90) - A switch wired from a node the graph cannot read and off at run time marks the node's run key, so an earlier run's tracker entry never links this run's images. - The scan reports such files as not_saved_wired ("only the run itself can link it") instead of not_saved. - Workflow-only metadata ignores muted and bypassed PromptManager nodes and reads nodes inside subgraph definitions. - The Focused, Split and Pair footer says when the switch comes from a link. * fix(usage): count cached re-runs of runtime-linked prompt text (#347) A PromptManager-family node whose text comes from a runtime link (PreviewAny, an LLM, a list node) counted only the job that executed it; later jobs served from ComfyUI's output cache counted nothing. Each execution now records a receipt (digests of its STRING outputs and hashes of the text it stores) per run key and queue item. On execution_cached the receipt whose digests equal the cache entry's text is replayed once for the job, with the node's role and what the queue hook already saw recomputed from the job's own graph. The executing node and the replay count through one shared own_counts. Unmatched cache entries count nothing, as before; a node that executes after its replay in the same job skips its own count. No schema change, no backfill. * fix(usage): never replay a node twice in one job; dedupe receipts once complete (#347) A repeated execution_cached id or message for the same job now counts the node once. Receipts replace equal older ones only when their job finishes (or the next job starts), so a list still recording [A] of [A, B] no longer drops an older [A] receipt. Node-level tests check the receipt each execution records. * test(coverage): baseline for the Save to library module (#90) * test(usage): disabled node then cached jobs counts 0 (#90, #347) A PromptManager node with Save to library off records no cached-run receipt and is no run prompt in the replayed job's graph, so ComfyUI serving it from its cache on later jobs counts nothing. The prompt is saved first by an enabled run, so a replayed count would show. Covers PromptManager, Focused, Split, Text and the Pair. * fix(nodes): hide the editor's native widgets through one helper (#90) The Save to library switch copied the text fallbacks' zero-height computeSize arrow, and neither copy was ever called, so focused-node.js function coverage fell to 87.5 (floor 90). One hideNativeWidget helper now hides both, and the spec checks that the hidden switch and a text fallback take no node height. --- README.md | 6 + prompt_manager.py | 19 +- prompt_manager_base.py | 40 +- prompt_manager_pair.py | 27 +- prompt_manager_text.py | 19 +- py/api/admin.py | 31 ++ tests/browser/classic-node.spec.mjs | 10 +- tests/browser/focused-node.spec.mjs | 30 +- tests/browser/pair-node.spec.mjs | 14 +- tests/comfyui/execution-text-link.spec.mjs | 48 +- tests/comfyui/text-link.spec.mjs | 11 +- tests/js/save-to-library.test.js | 68 +++ tests/test_cached_runs.py | 46 +- tests/test_save_to_library.py | 508 +++++++++++++++++++ tests/vendor/comfyui-config.test.mjs | 7 +- tools/vendor/frontend-coverage-baseline.json | 6 + utils/execution_context_capture.py | 3 + utils/execution_context_graph.py | 10 + utils/image_monitor.py | 39 +- utils/prompt_graph.py | 202 +++++++- utils/usage_tracking.py | 14 +- web/comfy/prompt_manager.js | 3 + web/js/focused-node.js | 35 +- web/js/prompt-editor.css | 2 + web/js/save-to-library.js | 47 ++ 25 files changed, 1187 insertions(+), 58 deletions(-) create mode 100644 tests/js/save-to-library.test.js create mode 100644 tests/test_save_to_library.py create mode 100644 web/js/save-to-library.js diff --git a/README.md b/README.md index 6142e6fc..946967d1 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/prompt_manager.py b/prompt_manager.py index 2dcf0031..b7bcdc1a 100644 --- a/prompt_manager.py +++ b/prompt_manager.py @@ -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 ) diff --git a/prompt_manager_base.py b/prompt_manager_base.py index 62977b1f..921e1e2a 100644 --- a/prompt_manager_base.py +++ b/prompt_manager_base.py @@ -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). diff --git a/prompt_manager_pair.py b/prompt_manager_pair.py index df62d633..d406e23e 100644 --- a/prompt_manager_pair.py +++ b/prompt_manager_pair.py @@ -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) diff --git a/prompt_manager_text.py b/prompt_manager_text.py index 6e316f98..c6e3f070 100644 --- a/prompt_manager_text.py +++ b/prompt_manager_text.py @@ -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 ) diff --git a/py/api/admin.py b/py/api/admin.py index a96d07d2..e010e3fb 100644 --- a/py/api/admin.py +++ b/py/api/admin.py @@ -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"} diff --git a/tests/browser/classic-node.spec.mjs b/tests/browser/classic-node.spec.mjs index bd627e22..0f6cdead 100644 --- a/tests/browser/classic-node.spec.mjs +++ b/tests/browser/classic-node.spec.mjs @@ -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';}); diff --git a/tests/browser/focused-node.spec.mjs b/tests/browser/focused-node.spec.mjs index 4f2026bb..6ab2818c 100644 --- a/tests/browser/focused-node.spec.mjs +++ b/tests/browser/focused-node.spec.mjs @@ -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}); diff --git a/tests/browser/pair-node.spec.mjs b/tests/browser/pair-node.spec.mjs index 2d554a84..e1ccda8a 100644 --- a/tests/browser/pair-node.spec.mjs +++ b/tests/browser/pair-node.spec.mjs @@ -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); diff --git a/tests/comfyui/execution-text-link.spec.mjs b/tests/comfyui/execution-text-link.spec.mjs index 20e60de6..813a0345 100644 --- a/tests/comfyui/execution-text-link.spec.mjs +++ b/tests/comfyui/execution-text-link.spec.mjs @@ -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 diff --git a/tests/comfyui/text-link.spec.mjs b/tests/comfyui/text-link.spec.mjs index 6bfaf01a..612d34b4 100644 --- a/tests/comfyui/text-link.spec.mjs +++ b/tests/comfyui/text-link.spec.mjs @@ -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' }, diff --git a/tests/js/save-to-library.test.js b/tests/js/save-to-library.test.js new file mode 100644 index 00000000..7d64aa0e --- /dev/null +++ b/tests/js/save-to-library.test.js @@ -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); +}); diff --git a/tests/test_cached_runs.py b/tests/test_cached_runs.py index d6ea4954..b337b6a6 100644 --- a/tests/test_cached_runs.py +++ b/tests/test_cached_runs.py @@ -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") diff --git a/tests/test_save_to_library.py b/tests/test_save_to_library.py new file mode 100644 index 00000000..3d8bee47 --- /dev/null +++ b/tests/test_save_to_library.py @@ -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() diff --git a/tests/vendor/comfyui-config.test.mjs b/tests/vendor/comfyui-config.test.mjs index 2cb9587c..bf5b1c0d 100644 --- a/tests/vendor/comfyui-config.test.mjs +++ b/tests/vendor/comfyui-config.test.mjs @@ -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/); }); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index de6c2416..dd070d4b 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -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, diff --git a/utils/execution_context_capture.py b/utils/execution_context_capture.py index 3a07985f..0fd22d24 100644 --- a/utils/execution_context_capture.py +++ b/utils/execution_context_capture.py @@ -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 = [ diff --git a/utils/execution_context_graph.py b/utils/execution_context_graph.py index 740cfe43..ee884a34 100644 --- a/utils/execution_context_graph.py +++ b/utils/execution_context_graph.py @@ -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 "" diff --git a/utils/image_monitor.py b/utils/image_monitor.py index 7c70d876..78cfcb10 100644 --- a/utils/image_monitor.py +++ b/utils/image_monitor.py @@ -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( diff --git a/utils/prompt_graph.py b/utils/prompt_graph.py index fc1ee362..b21021af 100644 --- a/utils/prompt_graph.py +++ b/utils/prompt_graph.py @@ -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): diff --git a/utils/usage_tracking.py b/utils/usage_tracking.py index 6fb6d39c..761019da 100644 --- a/utils/usage_tracking.py +++ b/utils/usage_tracking.py @@ -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: diff --git a/web/comfy/prompt_manager.js b/web/comfy/prompt_manager.js index 51c1bdca..706c9d78 100644 --- a/web/comfy/prompt_manager.js +++ b/web/comfy/prompt_manager.js @@ -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; } diff --git a/web/js/focused-node.js b/web/js/focused-node.js index 55727284..f4aae5a1 100644 --- a/web/js/focused-node.js +++ b/web/js/focused-node.js @@ -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; }; diff --git a/web/js/prompt-editor.css b/web/js/prompt-editor.css index ba6fbf4a..27c78330 100644 --- a/web/js/prompt-editor.css +++ b/web/js/prompt-editor.css @@ -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; } diff --git a/web/js/save-to-library.js b/web/js/save-to-library.js new file mode 100644 index 00000000..fdd39fe3 --- /dev/null +++ b/web/js/save-to-library.js @@ -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); +}