mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-17 07:59:24 -03:00
feat(save-image): support %NodeTitle.WidgetName% placeholders and fix %seed% None fallback (#314)
This commit is contained in:
32
README.md
32
README.md
@@ -185,12 +185,25 @@ The Save Image Node supports dynamic filename generation using pattern codes. Yo
|
|||||||
|
|
||||||
#### Available Pattern Codes
|
#### 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
|
- `%seed%` - Inserts the generation seed number
|
||||||
- `%width%` - Inserts the image width
|
- `%width%` - Inserts the image width
|
||||||
- `%height%` - Inserts the image height
|
- `%height%` - Inserts the image height
|
||||||
- `%pprompt:N%` - Inserts the positive prompt (limited to N characters)
|
- `%pprompt:N%` - Inserts the positive prompt (limited to N characters)
|
||||||
- `%nprompt:N%` - Inserts the negative 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)
|
- `%model:N%` - Inserts the model/checkpoint name (limited to N characters)
|
||||||
|
|
||||||
|
##### Date/Time Placeholders
|
||||||
|
|
||||||
- `%date%` - Inserts current date/time as "yyyyMMddhhmmss"
|
- `%date%` - Inserts current date/time as "yyyyMMddhhmmss"
|
||||||
- `%date:FORMAT%` - Inserts date using custom format with:
|
- `%date:FORMAT%` - Inserts date using custom format with:
|
||||||
- `yyyy` - 4-digit year
|
- `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`
|
- `%date:yyyy-MM-dd%` → `2025-04-28`
|
||||||
- `%pprompt:20%_%seed%` → `beautiful landscape_1234567890`
|
- `%pprompt:20%_%seed%` → `beautiful landscape_1234567890`
|
||||||
- `%model%_%date:yyMMdd%_%seed%` → `dreamshaper_v8_250428_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
|
### Standalone Mode
|
||||||
|
|
||||||
|
|||||||
@@ -298,7 +298,12 @@ class SaveImageLM:
|
|||||||
key = parts[0]
|
key = parts[0]
|
||||||
|
|
||||||
if key == "seed" and "seed" in metadata_dict:
|
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:
|
elif key == "width" and "size" in metadata_dict:
|
||||||
size = metadata_dict.get("size", "x")
|
size = metadata_dict.get("size", "x")
|
||||||
w = size.split("x")[0] if isinstance(size, str) else size[0]
|
w = size.split("x")[0] if isinstance(size, str) else size[0]
|
||||||
|
|||||||
135
web/comfyui/save_image_extra_output.js
Normal file
135
web/comfyui/save_image_extra_output.js
Normal file
@@ -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);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user