From 4a8084cdbc2ee87cf4557eb7190e4b84a67f4918 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 16 Jun 2026 23:48:44 +0800 Subject: [PATCH] feat(save-image): support %NodeTitle.WidgetName% placeholders and fix %seed% None fallback (#314) --- README.md | 32 +++++- py/nodes/save_image.py | 7 +- web/comfyui/save_image_extra_output.js | 135 +++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 web/comfyui/save_image_extra_output.js diff --git a/README.md b/README.md index d263b56b..a8557232 100644 --- a/README.md +++ b/README.md @@ -185,12 +185,25 @@ The Save Image Node supports dynamic filename generation using pattern codes. Yo #### Available Pattern Codes +##### Cross-Node Placeholders (ComfyUI Standard) + +- `%NodeTitle.WidgetName%` - Reference any widget value from any node in your workflow, for example: + - `%KSampler.seed%` - The seed from a KSampler node + - `%Empty Latent Image.width%` - The width from an Empty Latent Image node + - `%KSampler.steps%` - The steps value from a KSampler node + - Nodes are matched by their "Node name for S&R" property, then by their title + +##### Generation Metadata Placeholders (LoRA Manager) + - `%seed%` - Inserts the generation seed number - `%width%` - Inserts the image width - `%height%` - Inserts the image height - `%pprompt:N%` - Inserts the positive prompt (limited to N characters) - `%nprompt:N%` - Inserts the negative prompt (limited to N characters) - `%model:N%` - Inserts the model/checkpoint name (limited to N characters) + +##### Date/Time Placeholders + - `%date%` - Inserts current date/time as "yyyyMMddhhmmss" - `%date:FORMAT%` - Inserts date using custom format with: - `yyyy` - 4-digit year @@ -209,8 +222,25 @@ The Save Image Node supports dynamic filename generation using pattern codes. Yo - `%date:yyyy-MM-dd%` → `2025-04-28` - `%pprompt:20%_%seed%` → `beautiful landscape_1234567890` - `%model%_%date:yyMMdd%_%seed%` → `dreamshaper_v8_250428_1234567890` +- `%KSampler.seed%` → `1234567890` (resolved from the KSampler node's widget) +- `%Empty Latent Image.width%x%Empty Latent Image.height%` → `512x768` +- `%KSampler.seed%_%KSampler.steps%` → `1234567890_25` -You can combine multiple patterns to create detailed, organized filenames for your generated images. +You can combine multiple patterns to create detailed, organized filenames for your generated images. Cross-node and metadata placeholders can be mixed freely — for example: `%KSampler.seed%_%model%_%date:yyyyMMdd%`. + +##### Organizing Images into Subdirectories + +Including a path separator (`/` on all platforms) in the filename prefix creates subdirectories automatically, which is especially powerful when combined with placeholders: + +| Pattern | Result | +|---|---| +| `%date:yyyy-MM-dd%/%seed%` | Saves to `2025-04-28/1234567890.png` | +| `%model%/%date:yyMMdd%_%seed%` | Saves to `dreamshaper_v8/250428_1234567890.png` | +| `%KSampler.seed%/%model%` | Saves to `1234567890/dreamshaper_v8.png` | +| `%date:yyyy/MM/dd%/%seed%` | Saves to `2025/04/28/1234567890.png` (nested year/month/day) | +| `%model%/training/%seed%` | Saves to `dreamshaper_v8/training/1234567890.png` | + +> **Note**: The subdirectory is created relative to your ComfyUI output directory (configurable via `--output-directory`). Characters invalid for folder names are automatically replaced with underscores. ### Standalone Mode diff --git a/py/nodes/save_image.py b/py/nodes/save_image.py index d3c9cf7a..d208f77c 100644 --- a/py/nodes/save_image.py +++ b/py/nodes/save_image.py @@ -298,7 +298,12 @@ class SaveImageLM: key = parts[0] if key == "seed" and "seed" in metadata_dict: - filename = filename.replace(segment, str(metadata_dict.get("seed", ""))) + seed_value = metadata_dict.get("seed") + if seed_value is not None: + filename = filename.replace(segment, str(seed_value)) + else: + # Fallback if seed was not captured by metadata collector + filename = filename.replace(segment, "0") elif key == "width" and "size" in metadata_dict: size = metadata_dict.get("size", "x") w = size.split("x")[0] if isinstance(size, str) else size[0] diff --git a/web/comfyui/save_image_extra_output.js b/web/comfyui/save_image_extra_output.js new file mode 100644 index 00000000..4fd0d613 --- /dev/null +++ b/web/comfyui/save_image_extra_output.js @@ -0,0 +1,135 @@ +import { app } from "../../scripts/app.js"; +import { chainCallback, getAllGraphNodes, getWidgetByName } from "./utils.js"; + +/** + * Format a date string using the given pattern (e.g. "yyyy-MM-dd"). + * Supports: yyyy, yy, MM, M, dd, d, hh, h, mm, m, ss, s + */ +function formatDate(text, date) { + const pad = (n, len) => n.toString().padStart(len, "0"); + // Order matters: longer patterns first to avoid partial substring matches. + // The original ComfyUI frontend uses the same ordered-alternation approach. + return text + .replace(/yyyy/g, () => date.getFullYear().toString()) + .replace(/yy/g, () => pad(date.getFullYear() % 100, 2)) + .replace(/MM/g, () => pad(date.getMonth() + 1, 2)) + .replace(/M/g, () => (date.getMonth() + 1).toString()) + .replace(/dd/g, () => pad(date.getDate(), 2)) + .replace(/d/g, () => date.getDate().toString()) + .replace(/hh/g, () => pad(date.getHours(), 2)) + .replace(/h/g, () => date.getHours().toString()) + .replace(/mm/g, () => pad(date.getMinutes(), 2)) + .replace(/m/g, () => date.getMinutes().toString()) + .replace(/ss/g, () => pad(date.getSeconds(), 2)) + .replace(/s/g, () => date.getSeconds().toString()); +} + +/** + * Resolve %NodeTitle.WidgetName% placeholders in a string using the current graph. + * + * Patterns supported: + * %NodeTitle.WidgetName% – widget value from a node (by title or "Node name for S&R") + * %date:format% – current date/time formatted (e.g. %date:yyyy-MM-dd%) + * %width%, %height% – left as-is, handled by the backend + * + * All other %text% patterns are passed through unchanged (they may be handled by + * the backend's format_filename, e.g. %seed%, %model%, %pprompt%). + */ +function applyTextReplacements(value) { + if (!value || typeof value !== "string" || !value.includes("%")) { + return value; + } + + // Collect all nodes from the entire graph hierarchy (including subgraphs) + const allNodes = getAllGraphNodes(app.graph); + + return value.replace(/%([^%]+)%/g, function (match, text) { + const split = text.split("."); + if (split.length !== 2) { + // Handle %date:format% patterns + if (split[0].startsWith("date:")) { + return formatDate(split[0].substring(5), new Date()); + } + + // %width% and %height% are left for the backend to handle + if (text !== "width" && text !== "height") { + console.warn( + "[Save Image (LoraManager)] Unknown placeholder: %" + text + "%" + ); + } + return match; + } + + // Try finding the node by its "Node name for S&R" property first + let nodes = allNodes + .filter((n) => n.node.properties?.["Node name for S&R"] === split[0]) + .map((n) => n.node); + + // Fall back to matching by node title + if (!nodes.length) { + nodes = allNodes + .filter((n) => n.node.title === split[0]) + .map((n) => n.node); + } + + if (!nodes.length) { + console.warn( + "[Save Image (LoraManager)] Node not found: " + split[0] + ); + return match; + } + + if (nodes.length > 1) { + console.warn( + "[Save Image (LoraManager)] Multiple nodes matched '" + + split[0] + + "', using first match" + ); + } + + const node = nodes[0]; + const widget = node.widgets?.find((w) => w.name === split[1]); + if (!widget) { + console.warn( + "[Save Image (LoraManager)] Widget '" + + split[1] + + "' not found on node " + + split[0] + ); + return match; + } + + // Sanitize the value: replace characters invalid for filenames + // eslint-disable-next-line no-control-regex + return ((widget.value ?? "") + "").replaceAll( + /[/?<>\\:*|"\x00-\x1F\x7F]/g, + "_" + ); + }); +} + +app.registerExtension({ + name: "LoraManager.SaveImageExtraOutput", + + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== "Save Image (LoraManager)") { + return; + } + + chainCallback(nodeType.prototype, "onNodeCreated", function () { + // Find the filename_prefix widget + const widget = getWidgetByName(this, "filename_prefix"); + if (!widget) { + console.warn( + "[Save Image (LoraManager)] filename_prefix widget not found" + ); + return; + } + + // Override serialization to resolve %NodeTitle.WidgetName% placeholders + widget.serializeValue = () => { + return applyTextReplacements(widget.value); + }; + }); + }, +});