diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index 5a2d412..f261873 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -71,8 +71,6 @@ In `single` and `multiple` modes the default choice sampler is the one set in `d
Single mode with a default of cyclical sampler can be used as similar to combinatorial but in separated *ComfyUI* runs instead of one.
-In `multiple` and `combinatorial` modes, the prompt seed does not reset on each image. And, in `ComfyUI`, the image seed is the same for all images.
-
### ACB PPP Select Variable node
Lets you extract the variables used from the output (or just one of them). You can use this to send only part of the prompt to, for example, a detailer node. For example:
@@ -115,6 +113,11 @@ Options for the run mode, in case you want to change them from the defaults.
* **results_shuffle**: It shuffles the results.
* **comb_random_fixed**: If True all specified random samplers will have a fixed value across the combinations.
* **default_sampler**: The default choice sampler when not specified (in non combinatorial mode). Also applies to extranetwork mapping selection.
+* **next_seed**: Choose what to do with the seed in the following prompts in `multiple` or `combinatorial` mode. Value can be:
+ * `randomize`: Next prompts will have a random seed (default).
+ * `input`: Next prompts will have the same seed as the input (or first prompt).
+ * `increment`: The seeds in next prompts will increment.
+ * `decrement`: The seeds in next prompts will decrement.
### ACB PPP Wildcard Options node
@@ -185,7 +188,7 @@ The `Run mode` can be explained like this:
In single and multiple modes the default choice sampler is the one set in `Default sampler`. In combinatorial mode the default sampling is equivalent to cyclical. In all modes specified samplers are respected. The value of random samplers in combinatorial mode depends on `Fix random sampler across combinations`.
-Multiple mode with a default of cyclical sampler is very similar to combinatorial. The only difference is that `Fix random sampler across combinations` does not apply and random samplers are thus not cached. There is no need for this mode with a random default sampler, since it would be the same as in single mode.
+Multiple mode with a default of cyclical sampler is very similar to combinatorial. The only difference is that `Fix random sampler across combinations` does not apply and random samplers are thus not cached. There is no reason to use this mode with a random default sampler, since it would be the same as in single mode.
Single mode with a default of cyclical sampler can be used as similar to combinatorial but in separated runs instead of one.
diff --git a/docs/COOKBOOK.md b/docs/COOKBOOK.md
index 1ef1cf0..c3ceccd 100644
--- a/docs/COOKBOOK.md
+++ b/docs/COOKBOOK.md
@@ -496,6 +496,52 @@ You can leave the model and modelname inputs disconnected/empty and set the `_mo
You can also set and extract user variables for other ksampler inputs, like sampler, scheduler, steps, cfg and latent size.
+## Seed behavior
+
+The seed determines which choices are picked for wildcards/choices with `~` (random) samplers and other random selections.
+
+This seed can be different than the one used for the image.
+
+Each host handles it differently.
+
+### A1111 and derivatives
+
+By default, PPP uses the same seed for image and prompt, which A1111 auto-increments across the batch. Each image therefore gets independently seeded wildcard expansions.
+
+The seeds are pre-calculated before the prompt postprocess begins.
+
+The extension provides options to change this:
+
+- **Force equal seeds**: sets every seed in the batch to the first one before processing. All images get the same expansion.
+- **Unlink seed**: separates the prompt seed from the image seed. The table below shows how it behaves depending on the seed value and the *Incremental seed* toggle:
+
+ | Seed value | Incremental | Prompt seed per image |
+ |------------|-------------|------------------------------------------|
+ | -1 | Yes | Random base seed, then base+1, base+2, … |
+ | -1 | No | Independent random seed per image |
+ | N | Yes | N, N+1, N+2, … |
+ | N | No | N for every image |
+
+ If a subseed strength is set, the effective seed is `subseed × strength + seed × (1 − strength)` per image.
+
+In **multiple** or **combinatorial** run mode, results are generated using their corresponding seed in the batch, both for prompts and images. The `next_seed` option is set to `input` with these hosts (and a list of them is provided) because we need the image seeds pre-calculated.
+
+### ComfyUI
+
+The seed is an explicit node input (default: -1 for random, which gets an actual value as soon as possible and is considered the starting seed). In multiple result modes (`multiple` and `combinatorial`) the `next_seed` option is used to calculate the rest of the seeds.
+
+Each result seed is added as the output variable `_output_seed`, so it can be extracted and used as the image seed.
+
+## When the cyclical sampler resets
+
+The `@` (cyclical) sampler tracks its position so each call advances through combinations in order.
+
+The state is retained across executions within the same session. Each execution with the same prompts advances the position. The cycle only resets when either the positive or negative prompt text changes.
+
+## Keeping references up to date
+
+After updating your loras (deleting old ones, updating to new versions) run the `tools\check_loras.py` script to check if there are broken references in your wildcards or extranetwork mappings.
+
## Debugging tips
When something isn't generating as expected, the debug setting is your first tool. Enable it in the extension settings; it will log all system variables at generation time, which tells you exactly what values are available for your conditions.
@@ -550,12 +596,12 @@ Common use cases:
Set the `results_file` option (in the extension settings for *A1111*, or the `results_file` input on the main node for *ComfyUI*) to a filename. The extension determines the output format from the file extension:
-| Extension | Format |
-|------------------|----------------------------------------------------|
-| `.yaml` / `.yml` | YAML list of records |
-| `.jsonl` | JSON Lines, one JSON object per line |
-| `.csv` | CSV with a header row (semicolon-delimited) |
-| anything else | Plain text with labelled sections |
+| Extension | Format |
+|------------------|---------------------------------------------|
+| `.yaml` / `.yml` | YAML list of records |
+| `.jsonl` | JSON Lines, one JSON object per line |
+| `.csv` | CSV with a header row (semicolon-delimited) |
+| anything else | Plain text with labelled sections |
Each record contains five sections: `options` (the PPP settings that were active), `inputs` (seed, prompts), `system` (system variables like `_modelclass`), `results` (the final positive and negative prompts), and `variables` (any user variables that were set).
@@ -569,41 +615,3 @@ Relative paths are resolved against the `logs` folder inside the extension direc
> [!TIP]
> The `.jsonl` format is the most convenient for programmatic processing. The `.yaml` format is the easiest to read manually.
-
-## Seed behavior per host
-
-The seed determines which choices are picked for wildcards and `~` (random) samplers. Each host handles it differently.
-
-### A1111 and derivatives
-
-By default, PPP uses each image's own seed, which A1111 auto-increments across the batch. Each image therefore gets independently seeded wildcard expansions.
-
-The extension provides options to change this:
-
-- **Force equal seeds**: sets every image seed in the batch to the first one before processing. All images get the same expansion.
-- **Unlink seed**: separates the prompt seed from the image seed. The table below shows how it behaves depending on the seed value and the *Incremental seed* toggle:
-
- | Seed value | Incremental | Prompt seed per image |
- |------------|-------------|------------------------------------------|
- | -1 | Yes | Random base seed, then base+1, base+2, … |
- | -1 | No | Independent random seed per image |
- | N | Yes | N, N+1, N+2, … |
- | N | No | N for every image |
-
- If a subseed strength is set, the effective seed is `subseed × strength + seed × (1 − strength)` per image.
-
-In **multiple** or **combinatorial** run mode, all result variants are generated once using the first seed in the batch. The images then cycle through those pre-generated results in order; no additional seed is used for subsequent images.
-
-### ComfyUI
-
-The seed is an explicit node input (default: -1 for random). Each node execution uses exactly the provided seed. There is no batch; each execution processes one prompt independently.
-
-## When the cyclical sampler resets
-
-The `@` (cyclical) sampler tracks its position so each call advances through combinations in order.
-
-The state is retained across executions within the same session. Each execution with the same prompts advances the position. The cycle only resets when either the positive or negative prompt text changes.
-
-## Keeping references up to date
-
-After updating your loras (deleting old ones, updating to new versions) run the `tools\check_loras.py` script to check if there are broken references in your wildcards or extranetwork mappings.
diff --git a/docs/SYNTAX.md b/docs/SYNTAX.md
index d51355c..fe0fd60 100644
--- a/docs/SYNTAX.md
+++ b/docs/SYNTAX.md
@@ -67,7 +67,7 @@ The only command available is `include wildcard`, which will include the choices
These are examples of formats you can use to insert a choice construct:
| Construct | Result |
-| --------- | ------ |
+|---------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------|
| `{choice1\|5::choice2\|3::choice3}` | select 1 choice, two of them have weights |
| `{3$$choice1\|5 if _is_sd1::choice2\|choice3}` | select 3 choices, one has a weight and a condition |
| `{2-3$$2::choice1\|choice2\|choice3}` | select 2 to 3 choices, one of them has a weight |
@@ -116,7 +116,7 @@ The variable value only applies during the evaluation of the selected choices an
These are examples of formats you can use to insert a wildcard:
| Construct | Result |
-| --------- | ------ |
+|--------------------------------------|--------------------------------------------------------------------------|
| `__wildcard__` | select 1 choice |
| `__path/wildcard'0'__` | select the first choice |
| `__path/wildcard'1-2'__` | select the second or third choice |
@@ -198,7 +198,7 @@ This command can be used to set a default filter for a wildcard, before it is us
The format is:
| Construct | Meaning |
-| --------- | ------- |
+|-----------------------------------------------|--------------------|
| `` | Sets a filter |
| `` | Removes the filter |
@@ -217,25 +217,26 @@ All these variables can be used to output content or behave differently based on
Names starting with an underscore are reserved for system variables:
| System variable | Value |
-| --------------- | ----- |
-| `_model` | the model identifier (`sd1`, `sd2`, `sdxl`, `sd3`, `flux`, `auraflow`). `_sd` also works but is deprecated. |
-| `_modelname` | the model filename (without path). Do not confuse with the `modelname` input in *ComfyUI* which matches actually to the `_modelfullname` variable. `_sdname` also works but is deprecated. |
-| `_modelfullname` | the model filename (with path). `_sdfullname` also works but is deprecated. In *ComfyUI* this variable can also be **set** to override the filename used for model detection (see below). |
-| `_modelclass` | the class used for the model. Note that this is dependent on the webui. In A1111 all SD versions use the same class. Can be used for new models that are not supported yet with the `_is_*` variables. The debug setting will show all system variables when generating in case you need to see which one to use for a certain model. |
-| `_is_kkkk` | true if the model is of kind *kkkk* (the model identifier, f.e. sdxl; those set in the ppp_config.yaml file) |
-| `_is_vvvv` | true if the model matches the *vvvv* model variant definition (based on its filename). Note that the corresponding variable for the model kind will also be true. |
-| `_is_pure_kkkk` | true if the model is of kind *kkkk* and not a variant. |
-| `_is_variant_kkkk` | true if the model version is any variant of model kind *kkkk* and not the pure version. Note that the corresponding variable for the model kind will also be true. |
-| `_is_sd` | true if the model is any version of SD |
-| `_is_ssd` | true if the model is SSD (Segmind Stable Diffusion 1B). Note that for an SSD model `_is_sdxl` will also be true. |
-| `_is_sdxl_no_ssd` | true if the model is SDXL and not an SSD model. |
-| `_is_sdxl_no_pony` | true if the model is SDXL and not a Pony model (the `pony` variant must be defined in settings). Kept to maintain compatibility with previous versions. |
+|--------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `_model` | The model identifier (`sd1`, `sd2`, `sdxl`, `sd3`, `flux`, `auraflow`). `_sd` also works but is deprecated. |
+| `_modelname` | The model filename (without path). Do not confuse with the `modelname` input in *ComfyUI* which matches actually to the `_modelfullname` variable. `_sdname` also works but is deprecated. |
+| `_modelfullname` | The model filename (with path). `_sdfullname` also works but is deprecated. In *ComfyUI* this variable can also be **set** to override the filename used for model detection (see below). |
+| `_modelclass` | The class used for the model. Note that this is dependent on the webui. In A1111 all SD versions use the same class. Can be used for new models that are not supported yet with the `_is_*` variables. The debug setting will show all system variables when generating in case you need to see which one to use for a certain model. |
+| `_is_kkkk` | True if the model is of kind *kkkk* (the model identifier, f.e. sdxl; those set in the ppp_config.yaml file) |
+| `_is_vvvv` | True if the model matches the *vvvv* model variant definition (based on its filename). Note that the corresponding variable for the model kind will also be true. |
+| `_is_pure_kkkk` | True if the model is of kind *kkkk* and not a variant. |
+| `_is_variant_kkkk` | True if the model version is any variant of model kind *kkkk* and not the pure version. Note that the corresponding variable for the model kind will also be true. |
+| `_is_sd` | True if the model is any version of SD |
+| `_is_ssd` | True if the model is SSD (Segmind Stable Diffusion 1B). Note that for an SSD model `_is_sdxl` will also be true. |
+| `_is_sdxl_no_ssd` | True if the model is SDXL and not an SSD model. |
+| `_is_sdxl_no_pony` | True if the model is SDXL and not a Pony model (the `pony` variant must be defined in settings). Kept to maintain compatibility with previous versions. |
| `_opt_...` | All the options. |
-| `_input_seed` | The seed used. |
+| `_input_seed` | The starting seed used. |
| `_input_pos_prompt` | The original positive prompt. |
| `_input_neg_prompt` | The original negative prompt. |
| `_input_prev_pos_prompt` | The positive prompt result of the previous phase. Available only in the hires fix phase in A1111 compatible hosts and with no combinatorial generation. |
| `_input_prev_neg_prompt` | The negative prompt result of the previous phase. Available only in the hires fix phase in A1111 compatible hosts and with no combinatorial generation. |
+| `_output_seed` | The seed used for the specific result. This variable changes for each result depending on the `next_seed` option. You can feed it to the ksampler. |
> [!NOTE]
> The model path is relative to the checkpoint/difussion_models folder, just as it appears in the load nodes.
@@ -260,14 +261,14 @@ The `add` and `ifundefined` modifiers are mutually exclusive and cannot be used
The *Dynamic Prompts* format also works:
| Construct | Meaning |
-| --------- | ------- |
+|-----------------|----------------------|
| `${var=value}` | regular evaluation |
| `${var=!value}` | immediate evaluation |
If also supports the addition and undefined check as an extension of the *Dynamic Prompts* format:
| Construct | Meaning |
-| --------- | ------- |
+|------------------|--------------------------------------|
| `${var+=value}` | equivalent to `add` |
| `${var+=!value}` | equivalent to `evaluate add` |
| `${var?=value}` | equivalent to `ifundefined` |
@@ -301,14 +302,14 @@ This command prints the value of a variable, or the specified default if it does
The format is:
| Construct |
-| --------- |
+|----------------------------------------|
| `` |
| `default` |
The *Dynamic Prompts* format is:
| Construct |
-| --------- |
+|----------------------|
| `${varname}` |
| `${varname:default}` |
@@ -321,7 +322,7 @@ There is support for array variables. They use brackets `[]` to differenciate fr
They can be initialized in several ways:
| Construct | Meaning |
-| --------- | ------- |
+|----------------------------|------------------------------------------------------------------|
| `${var[]=value}` | initialize and set the first value |
| `${var[]=*()}` | initialize an empty array |
| `${var[]=*var2[]}` | initialize an array from another array |
@@ -341,13 +342,13 @@ And they can be accesed/echoed with:
* A hash inside the brackets is used to get the length of the array
* An ampersand followed by a string inside the brackets (with quotes) is used to get the full array joined with a separator.
-| Construct | Meaning |
-| --------- | ------- |
-| `${var[]}` | echo all elements with a default separator |
-| `${var[&' / ']}` | echo all elements with a specific separator |
-| `${var[n]}` | echo an element from the array |
-| `${var[n]:default}` | echo an element with a default |
-| `${var[#]}` | echo the length of the array |
+| Construct | Meaning |
+|---------------------|---------------------------------------------|
+| `${var[]}` | echo all elements with a default separator |
+| `${var[&' / ']}` | echo all elements with a specific separator |
+| `${var[n]}` | echo an element from the array |
+| `${var[n]:default}` | echo an element with a default |
+| `${var[#]}` | echo the length of the array |
## If command
@@ -362,7 +363,7 @@ Any `elif`s (there can be multiple) and the `else` are optional.
The `conditionN` is a boolean expression, which can use `and`, `or`, `not` and grouping with parentheses, and where the simplest expression can be:
| Construct | Meaning |
-| --------- | ------- |
+|-------------------------------------|---------------------------------------------------------------------------------|
| `operand` | check truthyness of the operand, meaning not zero, empty string nor empty array |
| `operand1 [not] operation operand2` | compare the operands |
@@ -379,7 +380,7 @@ The supported operations are: `eq`, `ne`, `gt`, `lt`, `ge`, `le`, `in`, `any_in`
This list shows what they do depending on the kind of operand (R = regular variable, A = array variable).
| Operation | R1 op R2 | A1 op A2 | A1 op R2 | R1 op A2 |
-| --------- | -------- | -------- | -------- | -------- |
+|----------------|--------------------------|-------------------|------------------------------------------------|------------------------------------------------|
| `eq` | OK | OK (pairwise) | Error in strict mode, all A1 with R2 otherwise | Error in strict mode, R1 with all A2 otherwise |
| `ne` | OK | OK (pairwise) | Error in strict mode, all A1 with R2 otherwise | Error in strict mode, R1 with all A2 otherwise |
| `gt` | OK | OK (pairwise) | Error in strict mode, all A1 with R2 otherwise | Error in strict mode, R1 with all A2 otherwise |
@@ -469,10 +470,10 @@ extnettype:
Used like this:
-| Construct | Meaning |
-| --------- | ------- |
-| `` | Mapping without additional triggers |
-| `inline triggers` | Mapping with additional triggers |
+| Construct | Meaning |
+|--------------------------------------------------------|-------------------------------------|
+| `` | Mapping without additional triggers |
+| `inline triggers` | Mapping with additional triggers |
Each mapping can have any number of elements in its list of mappings. There are no mandatory properties for a mapping. The properties mean the following:
@@ -491,7 +492,7 @@ See the file in the tests folder as an example.
The new format for this command is like this:
| Construct | Meaning |
-| --------- | ------- |
+|---------------------------------------|--------------------------------------------------------------------------------------|
| `content` | send to negative prompt |
| `` | insertion point to be used in the negative prompt as destination for the pN position |
diff --git a/ppp.py b/ppp.py
index 3d3a153..a6e13f5 100644
--- a/ppp.py
+++ b/ppp.py
@@ -38,6 +38,7 @@ from ppp_tree import TreeProcessor
from ppp_utils import escape_single_quotes, get_version_from_pyproject
from ppp_common import (
WARN_STOP_WHERE,
+ clamp_host_bits,
get_model_class_from_filename,
load_grammar,
parse_prompt,
@@ -86,6 +87,7 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in
DEFAULT_COMB_RANDOM_FIXED = defopt["comb_random_fixed"]
DEFAULT_DEFAULT_SAMPLER = defopt["default_sampler"].value
DEFAULT_RESULTS_FILE = defopt["results_file"]
+ DEFAULT_NEXT_SEED = defopt["next_seed"].value
WILDCARD_WARNING = '(WARNING TEXT "INVALID WILDCARD" IN BRIGHT RED:1.5)\nBREAK '
WILDCARD_STOP = "INVALID WILDCARD! {0}\nBREAK "
@@ -956,7 +958,11 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in
var_keys = sorted(variables_snapshot.keys())
for k in var_keys:
entry = variables_snapshot[k]
- if entry.last_echoed_evaluated_value is None and entry.value is not None:
+ if (
+ entry.last_echoed_evaluated_value is None
+ and entry.value is not None
+ and not k.startswith("_output_")
+ ):
unechoed_variables.append(k)
ev = entry.last_echoed_evaluated_value if entry.last_echoed_evaluated_value is not None else entry.value
if ev is not None:
@@ -1050,7 +1056,7 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in
self,
prompt: str,
negative_prompt: str,
- seed: int,
+ starting_seed: int | list[int],
jobinfo: Any = None,
input_vars: dict[str, Any] | None = None,
) -> list[tuple[str, str, dict[str, Any]]]:
@@ -1060,7 +1066,7 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in
Args:
prompt (str): The prompt.
negative_prompt (str): The negative prompt.
- seed (int): The seed for the random number generator.
+ starting_seed (int | list[int]): The starting seed for the random number generator.
jobinfo (Any): Additional job information to be stored in the input state.
input_vars (dict[str, Any] | None): Additional input variables to be set as system variables with the "_input_" prefix.
@@ -1070,10 +1076,11 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in
self.state.variables.clear_user()
# We update the input state
- # Truncate the seed to the host's configured bit width (-1 because we only want
- # positive numbers) so the value stays within the range the host expects
- # (e.g., 32-bit for SD-WebUI, 64-bit for ComfyUI).
- self.state.inputs.seed = int(seed & ((1 << (self.state.host_config.seed_bits - 1)) - 1))
+ self.state.inputs.seed = (
+ clamp_host_bits(self.state.host_config.seed_bits, starting_seed)
+ if not isinstance(starting_seed, list)
+ else [clamp_host_bits(self.state.host_config.seed_bits, s) for s in starting_seed]
+ )
self.state.inputs.pos_prompt = prompt
self.state.inputs.neg_prompt = negative_prompt
self.state.inputs.jobinfo = jobinfo
@@ -1103,7 +1110,9 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in
filtered_sysvars_inputs = {k: v for k, v in self.state.variables.all_system.items() if k.startswith("_input_")}
self.log(logging.INFO, f"Inputs: {filtered_sysvars_inputs}")
- rng = np.random.default_rng(self.state.inputs.seed)
+ rng = np.random.default_rng(
+ self.state.inputs.seed if not isinstance(self.state.inputs.seed, list) else self.state.inputs.seed[0]
+ )
# Parse both prompts
processor = TreeProcessor(self.state, rng, on_model_info_update=self.__on_model_info_update)
@@ -1150,13 +1159,20 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in
def process_prompts_group_start(self):
"""Start of a prompt processing group."""
- filtered_sysvars = {k: v for k, v in self.state.variables.all_system.items() if not k.startswith("_input_")}
+ filtered_sysvars = {
+ k: v for k, v in self.state.variables.all_system.items() if not k.startswith(("_input_", "_output_"))
+ }
self.log(logging.DEBUG, f"System variables: {filtered_sysvars}", DEBUG_LEVEL.minimal)
self.log(logging.INFO, f"Run mode: {self.state.options.run_mode.name}")
if self.state.options.run_mode == RUN_MODE.combinatorial:
- self.log(logging.INFO, f"Up to {self.state.options.results_limit} combinations")
+ if self.state.options.results_limit > 0:
+ self.log(logging.INFO, f"Up to {self.state.options.results_limit} combinations")
+ else:
+ self.log(logging.INFO, "No combinations limit")
elif self.state.options.run_mode == RUN_MODE.multiple:
- self.log(logging.INFO, f"Up to {self.state.options.results_limit} results")
+ if self.state.options.results_limit < 1:
+ self.state.options.results_limit = 1
+ self.log(logging.INFO, f"Returning {self.state.options.results_limit} results")
def _expand_filename(self) -> Path:
"""Expand %...% tokens in a filename template and resolve relative paths against the extension logs folder."""
@@ -1193,11 +1209,16 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in
"system_variables": {
k: v
for k, v in all_variables.items()
- if k.startswith("_") and not k.startswith("_opt_") and not k.startswith("_input_")
+ if k.startswith("_")
+ and not k.startswith("_opt_")
+ and not k.startswith(("_input_", "_output_"))
},
"inputs": {
k.removeprefix("_input_"): v for k, v in all_variables.items() if k.startswith("_input_")
},
+ "outputs": {
+ k.removeprefix("_output_"): v for k, v in all_variables.items() if k.startswith("_output_")
+ },
"prompt_results": {"prompt": result_prompt, "negative_prompt": result_neg_prompt},
"user_variables": {k: v for k, v in all_variables.items() if not k.startswith("_")},
}
@@ -1264,7 +1285,7 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in
self,
original_prompt: str,
original_negative_prompt: str,
- seed: int = -1,
+ starting_seed: int | list[int] = -1,
jobinfo: Any = None,
input_vars: dict[str, Any] | None = None,
) -> list[tuple[str, str, dict[str, Any]]]:
@@ -1274,7 +1295,7 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in
Args:
original_prompt (str): The original prompt.
original_negative_prompt (str): The original negative prompt.
- seed (int): The seed.
+ starting_seed (int | list[int]): The starting seed or list of starting seeds.
jobinfo (Any): Optional job information, available as `_input_jobinfo`.
input_vars (dict[str, Any] | None): Optional dictionary of input variables to set before processing.
@@ -1283,15 +1304,20 @@ class PromptPostProcessor: # pylint: disable=too-few-public-methods,too-many-in
"""
results: list[tuple[str, str, dict[str, Any]]]
try:
- if seed == -1:
- seed = np.random.randint(0, 2 ** (self.state.host_config.seed_bits - 1), dtype=np.int64)
+ if isinstance(starting_seed, list):
+ starting_seed = [
+ np.random.randint(0, 1 << (self.state.host_config.seed_bits - 1), dtype=np.int64) if s == -1 else s
+ for s in starting_seed
+ ]
+ elif starting_seed == -1:
+ starting_seed = np.random.randint(0, 1 << (self.state.host_config.seed_bits - 1), dtype=np.int64)
prompt = original_prompt
negative_prompt = original_negative_prompt
t1 = time.monotonic_ns()
if self.state.cyclical_state.last_prompt_pair != (original_prompt, original_negative_prompt):
self.state.cyclical_state.reset()
self.state.cyclical_state.last_prompt_pair = (original_prompt, original_negative_prompt)
- results = self.__processprompts(prompt, negative_prompt, seed, jobinfo, input_vars or {})
+ results = self.__processprompts(prompt, negative_prompt, starting_seed, jobinfo, input_vars or {})
t2 = time.monotonic_ns()
self.log(logging.INFO, f"Process prompt pair time: {(t2 - t1) / 1_000_000_000:.3f} seconds")
# self.log(logging.DEBUG,f"Wildcards memory usage: {self.state.wildcards_obj.__sizeof__()}")
diff --git a/ppp_classes.py b/ppp_classes.py
index ccaaaf8..939b67d 100644
--- a/ppp_classes.py
+++ b/ppp_classes.py
@@ -58,6 +58,13 @@ class DEFAULT_SAMPLER(Enum):
cyclical = "cyclical"
+class NEXT_SEED(Enum):
+ randomize = "randomize"
+ input = "input"
+ increment = "increment"
+ decrement = "decrement"
+
+
# ------------------- Host configuration -------------------
AttentionOption = Literal["ok", "parentheses", "disable", "remove", "error"]
@@ -226,6 +233,7 @@ class PPPStateOptions:
results_shuffle: bool = False
comb_random_fixed: bool = True # if True, the random sampler will be fixed across all DFS runs
default_sampler: DEFAULT_SAMPLER = DEFAULT_SAMPLER.random
+ next_seed: NEXT_SEED = NEXT_SEED.randomize # how to determine the next seed for each prompt
def __post_init__(self):
if not self.cup_do_cleanup:
@@ -248,7 +256,7 @@ class PPPStateOptions:
class PPPStateInputs:
"""Structured inputs for a single prompt processing call."""
- seed: int = -1
+ seed: int | list[int] = -1
pos_prompt: str = ""
neg_prompt: str = ""
jobinfo: Any = None
diff --git a/ppp_comfyui.py b/ppp_comfyui.py
index fddf7a7..76db18f 100644
--- a/ppp_comfyui.py
+++ b/ppp_comfyui.py
@@ -20,6 +20,7 @@ from ppp_classes import (
SUPPORTED_APPS,
PPPException,
RUN_MODE,
+ NEXT_SEED,
PPPStateOptions,
)
from ppp_common import get_model_class_from_filename, load_grammar
@@ -436,6 +437,7 @@ class PromptPostProcessorComfyUINode:
default_sampler=DEFAULT_SAMPLER(
rm_options["default_sampler"] if rm_options else PromptPostProcessor.DEFAULT_DEFAULT_SAMPLER
),
+ next_seed=NEXT_SEED(rm_options["next_seed"] if rm_options else PromptPostProcessor.DEFAULT_NEXT_SEED),
)
self.wildcards_obj.refresh_wildcards(
options.debug_level,
@@ -524,6 +526,14 @@ class PromptPostProcessorRunModeOptionsComfyUINode:
"tooltip": "Default choice sampler",
},
),
+ "next_seed": (
+ "COMBO",
+ {
+ "options": [e.value for e in NEXT_SEED],
+ "default": PromptPostProcessor.DEFAULT_NEXT_SEED,
+ "tooltip": "Next seed strategy",
+ },
+ ),
},
}
@@ -540,12 +550,14 @@ class PromptPostProcessorRunModeOptionsComfyUINode:
results_shuffle: bool,
comb_random_fixed: bool,
default_sampler: str,
+ next_seed: str,
):
options = {
"results_limit": results_limit,
"results_shuffle": results_shuffle,
"comb_random_fixed": comb_random_fixed,
"default_sampler": default_sampler,
+ "next_seed": next_seed,
}
return (options,)
diff --git a/ppp_common.py b/ppp_common.py
index 40897ac..6c90573 100644
--- a/ppp_common.py
+++ b/ppp_common.py
@@ -360,3 +360,17 @@ def convert_sdnext_styles_to_wildcard(inp: Path, out: Path):
f.write(f"# Converted from {name}\n")
yaml_writer = _YAML()
yaml_writer.dump(wcs, f)
+
+
+def clamp_host_bits(bits: int, seed: int) -> int:
+ """
+ Clamp the seed to the host's configured bit width so the value stays within the range the host expects (and positive).
+
+ Args:
+ bits (int): The host's configured bit width.
+ seed (int): The seed to clamp.
+
+ Returns:
+ int: The clamped seed.
+ """
+ return int(seed & ((1 << bits ) - 1))
diff --git a/ppp_tree.py b/ppp_tree.py
index 3debbbb..50a5376 100644
--- a/ppp_tree.py
+++ b/ppp_tree.py
@@ -11,11 +11,11 @@ from typing import Callable, Optional
import lark
import numpy as np
-from ppp_classes import DEFAULT_SAMPLER, IFWILDCARDS_CHOICES, RUN_MODE, SUPPORTED_APPS, PPPState
+from ppp_classes import DEFAULT_SAMPLER, IFWILDCARDS_CHOICES, NEXT_SEED, RUN_MODE, SUPPORTED_APPS, PPPState
from ppp_enmappings import PPPENMappingVariant
from ppp_logging import DEBUG_LEVEL, log
from ppp_utils import escape_single_quotes, repr_value
-from ppp_common import WARN_STOP_WHERE, parse_prompt, warn_or_stop
+from ppp_common import WARN_STOP_WHERE, clamp_host_bits, parse_prompt, warn_or_stop
from ppp_variables import ScalarValue, VariableEntry
from ppp_wildcards import PPPWildcard
@@ -62,6 +62,9 @@ class TreeProcessor(lark.visitors.Interpreter):
self.__on_model_info_update = on_model_info_update
self.__debug_level = state.options.debug_level
self.__rng = rng
+ self.__current_input_seed = (
+ self.state.inputs.seed if not isinstance(self.state.inputs.seed, list) else self.state.inputs.seed[0]
+ )
self.__shell: list[TreeProcessor.AccumulatedShell] = [] # type: ignore
self.__negtags: list[TreeProcessor.NegTag] = [] # type: ignore
self.__already_processed: list[str] = []
@@ -71,6 +74,7 @@ class TreeProcessor(lark.visitors.Interpreter):
self.__add_at: dict[str, list] = {"start": [], "insertion_point": [[] for _ in range(10)], "end": []}
self.__insertion_at: list[tuple[int, int]] = [None for _ in range(10)]
self.__detectedWildcards: list[tuple[str, bool]] = []
+ self.__current_run_index = 0
self.__result = ""
self.__forced_path: list[int] = []
self.__trace: list[int] = []
@@ -102,6 +106,35 @@ class TreeProcessor(lark.visitors.Interpreter):
if self.state.extranetwork_mappings_obj is not None:
self.state.extranetwork_mappings_obj.cached_mappings.clear()
+ def __prepare_next_rng(self):
+ """
+ Prepare the random number generator for a new run.
+
+ Note: All random generation should use default_rng to be reproducible.
+ """
+ if self.state.options.next_seed == NEXT_SEED.input:
+ # Use the input seed for every run, so the output is deterministic for a given input.
+ self.__current_input_seed = (
+ self.state.inputs.seed
+ if not isinstance(self.state.inputs.seed, list)
+ else self.state.inputs.seed[self.__current_run_index % len(self.state.inputs.seed)]
+ )
+ self.__rng = np.random.default_rng(self.__current_input_seed)
+ elif self.state.options.next_seed == NEXT_SEED.increment:
+ # Increment the seed for each run, so the output varies for a given input.
+ self.__current_input_seed = clamp_host_bits(self.state.host_config.seed_bits, self.__current_input_seed + 1)
+ self.__rng = np.random.default_rng(self.__current_input_seed)
+ elif self.state.options.next_seed == NEXT_SEED.decrement:
+ # Decrement the seed for each run, so the output varies for a given input.
+ self.__current_input_seed = clamp_host_bits(self.state.host_config.seed_bits, self.__current_input_seed - 1)
+ self.__rng = np.random.default_rng(self.__current_input_seed)
+ elif self.state.options.next_seed == NEXT_SEED.randomize:
+ # Randomize the seed for each run, so the output varies for a given input.
+ self.__current_input_seed = self.__rng.integers(
+ 0, 1 << (self.state.host_config.seed_bits - 1), dtype=np.int64
+ )
+ self.__rng = np.random.default_rng(self.__current_input_seed)
+
def start_visit(
self,
parsed: lark.Tree,
@@ -128,6 +161,7 @@ class TreeProcessor(lark.visitors.Interpreter):
or (self.state.options.run_mode == RUN_MODE.multiple and self.state.options.results_limit < 1)
else self.state.options.results_limit
)
+ self.__current_run_index = 0
initial_vars = self.state.variables.backup_user()
@@ -135,6 +169,7 @@ class TreeProcessor(lark.visitors.Interpreter):
initial_path = list(self.state.cyclical_state.current_path)
warned_cycle = False
for step in range(max_results):
+ self.log(logging.DEBUG, f"Using seed {self.__current_input_seed}")
self.__reset_run_state()
self.state.variables.restore_user(initial_vars)
self.__forced_path = list(self.state.cyclical_state.current_path)
@@ -156,12 +191,14 @@ class TreeProcessor(lark.visitors.Interpreter):
cycle_start = (initial_path + [0] * trace_len)[:trace_len]
if self.state.cyclical_state.current_path == cycle_start:
warn = True
- results.append((self.__result, self.__detectedWildcards.copy(), self.state.variables.backup_user()))
+ results.append((self.__result, self.__detectedWildcards.copy(), self.state.variables.backup_user_and_output()))
if self.state.options.run_mode == RUN_MODE.multiple:
self.log(logging.INFO, f"Added result {len(results)}")
if warn:
- self.log(logging.WARNING, "Cyclical combinations are repeating; results may start repeating.")
+ self.log(logging.WARNING, "Cyclical combinations are repeating; prompt results will start repeating.")
warned_cycle = True
+ self.__current_run_index += 1
+ self.__prepare_next_rng()
return results
# Combinatorial mode: explore every possible path through choices and wildcards via DFS.
@@ -173,17 +210,20 @@ class TreeProcessor(lark.visitors.Interpreter):
def _run(forced_path: tuple[int, ...]) -> tuple[int, ...]:
self.log(logging.DEBUG, f"Running combinatorial path: {forced_path}")
+ self.log(logging.DEBUG, f"Using seed {self.__current_input_seed}")
self.__forced_path = list(forced_path)
self.__trace = []
self.__reset_run_state()
self.state.variables.restore_user(initial_vars)
self.visit(parsed)
self.__finalize_variables()
- results.append((self.__result, self.__detectedWildcards.copy(), self.state.variables.backup_user()))
+ results.append((self.__result, self.__detectedWildcards.copy(), self.state.variables.backup_user_and_output()))
if len(results) == 1:
first_run_estimate = reduce(lambda x, y: x * y, self.__trace, 1)
self.log(logging.INFO, f"Estimated combinations (lower bound): {first_run_estimate}")
self.log(logging.INFO, f"Added combination {len(results)}")
+ self.__current_run_index += 1
+ self.__prepare_next_rng()
return tuple(self.__trace)
limit_reached = False
@@ -232,6 +272,8 @@ class TreeProcessor(lark.visitors.Interpreter):
name, specifier = self.__separate_arrayref(k)
value = self.get_final_scalar_variable(name, specifier)
self.state.variables.set_user(k, value)
+ # Set the system variable for the current input seed so it can be used afterwards if needed.
+ self.state.variables.set_system("_output_seed", int(self.__current_input_seed))
def __visit(
self,
diff --git a/ppp_variables.py b/ppp_variables.py
index fbff03e..cde82d3 100644
--- a/ppp_variables.py
+++ b/ppp_variables.py
@@ -138,6 +138,13 @@ class VariableRepository:
}
)
+ def backup_user_and_output(self) -> dict[str, VariableEntry]:
+ """Return a per-entry shallow-copy snapshot of all user variables and output variables."""
+ return {
+ name: VariableEntry(entry.value, entry.last_echoed_value, entry.last_echoed_evaluated_value)
+ for name, entry in self._vars.items()
+ } | {name: VariableEntry(entry) for name, entry in self._system.items() if name.startswith("_output_")}
+
# ---- Combined queries ----
def get(self, name: str, default: Any = None) -> Any:
diff --git a/scripts/ppp_script.py b/scripts/ppp_script.py
index b995710..072cb4e 100644
--- a/scripts/ppp_script.py
+++ b/scripts/ppp_script.py
@@ -26,6 +26,7 @@ from ppp_classes import (
SUPPORTED_APPS,
SUPPORTED_APPS_NAMES,
RUN_MODE,
+ NEXT_SEED,
PPPStateOptions,
)
from ppp_logging import DEBUG_LEVEL, PromptPostProcessorLogFactory, log
@@ -143,8 +144,6 @@ class PromptPostProcessorA1111Script(scripts.Script):
* A seed of -1 and "Incremental seed" unchecked will use a random seed for each prompt.
* Any other seed value and "Incremental seed" checked will use the specified seed for the first prompt and consecutive values for the rest.
* Any other seed value and "Incremental seed" unchecked will use the specified seed for all the prompts.
-
- Seeds are only used for the wildcards and choice constructs.
""")
gr.HTML("
")
with gr.Row(equal_height=True):
@@ -313,6 +312,7 @@ class PromptPostProcessorA1111Script(scripts.Script):
default_sampler=DEFAULT_SAMPLER(
input_default_sampler if input_default_sampler else PromptPostProcessor.DEFAULT_DEFAULT_SAMPLER
),
+ next_seed=NEXT_SEED.input, # we use the calculated seeds
)
if not self.ppp_init:
self.ppp_init = True
@@ -426,10 +426,10 @@ class PromptPostProcessorA1111Script(scripts.Script):
if input_unlink_seed:
log(self.ppp_logger, self.ppp_debug_level, logging.INFO, "Using unlinked seed")
if input_incremental_seed:
- first_seed = np.random.randint(0, 2**32, dtype=np.int64) if input_seed == -1 else input_seed
+ first_seed = np.random.randint(0, 1 << self.ppp.state.host_config.seed_bits, dtype=np.int64) if input_seed == -1 else input_seed
calculated_seeds = [first_seed + i for i in range(num_seeds)]
elif input_seed == -1:
- calculated_seeds = np.random.randint(0, 2**32, size=num_seeds, dtype=np.int64)
+ calculated_seeds = np.random.randint(0, 1 << self.ppp.state.host_config.seed_bits, size=num_seeds, dtype=np.int64)
else:
calculated_seeds = [input_seed for _ in range(num_seeds)]
else:
@@ -468,7 +468,6 @@ class PromptPostProcessorA1111Script(scripts.Script):
self.ppp.process_prompts_group_start()
if input_run_mode in (RUN_MODE.multiple.value, RUN_MODE.combinatorial.value):
- seed_for_comb = calculated_seeds[0] if calculated_seeds else 0
regular_copy = (rpr.copy() if rpr else None, rnr.copy() if rnr else None)
hiresfix_copy = (rph.copy() if rph else None, rnh.copy() if rnh else None)
regular_changes = False
@@ -482,7 +481,7 @@ class PromptPostProcessorA1111Script(scripts.Script):
comb_results = self.ppp.process_prompt(
rpr[0],
rnr[0],
- seed_for_comb,
+ calculated_seeds,
jobinfo={
"job_timestamp": shared.state.job_timestamp,
"job": shared.state.job,
@@ -523,7 +522,7 @@ class PromptPostProcessorA1111Script(scripts.Script):
comb_results_hr = self.ppp.process_prompt(
rph[0],
rnh[0],
- seed_for_comb,
+ calculated_seeds,
jobinfo={
"job_timestamp": shared.state.job_timestamp,
"job": shared.state.job,
diff --git a/tests/base_tests.py b/tests/base_tests.py
index 51375a6..0c6100b 100644
--- a/tests/base_tests.py
+++ b/tests/base_tests.py
@@ -9,6 +9,7 @@ import datetime
from ppp_classes import (
DEFAULT_SAMPLER,
IFWILDCARDS_CHOICES,
+ NEXT_SEED,
ONWARNING_CHOICES,
PPPEnvInfo,
RUN_MODE,
@@ -88,6 +89,7 @@ class TestPromptPostProcessorBase(unittest.TestCase):
results_shuffle=False,
comb_random_fixed=True,
default_sampler=DEFAULT_SAMPLER.random,
+ next_seed=NEXT_SEED.randomize,
)
self.def_env_info = PPPEnvInfo(
app=SUPPORTED_APPS.tests,
diff --git a/tests/tests_choices.py b/tests/tests_choices.py
index d51a79c..c6eaef8 100644
--- a/tests/tests_choices.py
+++ b/tests/tests_choices.py
@@ -1,4 +1,4 @@
-from ppp_classes import DEFAULT_SAMPLER, RUN_MODE # type: ignore
+from ppp_classes import DEFAULT_SAMPLER, NEXT_SEED, RUN_MODE # type: ignore
from .base_tests import OutputTuple, InputTuple, TestPromptPostProcessorBase
if __name__ == "__main__":
@@ -151,9 +151,9 @@ class TestChoices(TestPromptPostProcessorBase):
[
OutputTuple("choice1, option1, a", ""),
OutputTuple("choice1, option2, b", ""),
- OutputTuple("choice2, option1, b", ""),
+ OutputTuple("choice2, option1, a", ""),
OutputTuple("choice2, option2, b", ""),
- OutputTuple("choice3, option1, a", ""),
+ OutputTuple("choice3, option1, b", ""),
OutputTuple("choice3, option2, a", "", {"v": "option2"}),
],
ppp=self.init_ppp(
@@ -166,7 +166,7 @@ class TestChoices(TestPromptPostProcessorBase):
def test_ch_comb_random_consistent(self): # ~ sampler picks one value shared across all combinations
ppp_instance = self.init_ppp("nocup", run_mode=RUN_MODE.combinatorial)
ppp_instance.process_prompts_group_start()
- result = ppp_instance.process_prompt("{~a|b|c} {x|y}", "", seed=1)
+ result = ppp_instance.process_prompt("{~a|b|c} {x|y}", "", starting_seed=1)
ppp_instance.process_prompts_group_end()
self.assertEqual(len(result), 2, "Expected exactly 2 combinations ({x|y} expands to 2)")
rnd_choices = {r_prompt.split()[0] for r_prompt, _, _ in result}
@@ -176,6 +176,26 @@ class TestChoices(TestPromptPostProcessorBase):
f"The ~ sampler must yield the same value across all combinations, got: {rnd_choices}",
)
+ # Multiple
+
+ def test_ch_multiple(self):
+ self.process(
+ InputTuple("{choice1|choice2|choice3}, ${v:{option1|option2}}, {~a|b}", ""),
+ [
+ OutputTuple("choice2, option2, a", ""),
+ OutputTuple("choice1, option1, b", ""),
+ OutputTuple("choice1, option2, b", ""),
+ OutputTuple("choice3, option1, b", ""),
+ OutputTuple("choice1, option1, a", ""),
+ OutputTuple("choice2, option1, a", "", {"v": "option1"}),
+ ],
+ ppp=self.init_ppp(
+ None,
+ run_mode=RUN_MODE.multiple,
+ results_limit=6,
+ ),
+ )
+
# Default sampler
def test_ch_default_sampler_cyclical_single(self):
@@ -206,3 +226,50 @@ class TestChoices(TestPromptPostProcessorBase):
results_limit=4,
),
)
+
+ # next_seed / _output_seed
+
+ def test_ch_next_seed_input(self): # input mode keeps the same seed for every result
+ self.process(
+ InputTuple("{@a|b|c}", ""),
+ [
+ OutputTuple("a", "", {"_output_seed": 1}),
+ OutputTuple("b", "", {"_output_seed": 1}),
+ OutputTuple("c", "", {"_output_seed": 1}),
+ ],
+ seed=1,
+ ppp=self.init_ppp("nocup", run_mode=RUN_MODE.multiple, results_limit=3, next_seed=NEXT_SEED.input),
+ )
+
+ def test_ch_next_seed_increment(self): # increment mode increases the seed by 1 for each result
+ self.process(
+ InputTuple("{@a|b|c}", ""),
+ [
+ OutputTuple("a", "", {"_output_seed": 1}),
+ OutputTuple("b", "", {"_output_seed": 2}),
+ OutputTuple("c", "", {"_output_seed": 3}),
+ ],
+ seed=1,
+ ppp=self.init_ppp("nocup", run_mode=RUN_MODE.multiple, results_limit=3, next_seed=NEXT_SEED.increment),
+ )
+
+ def test_ch_next_seed_decrement(self): # decrement mode decreases the seed by 1 for each result
+ self.process(
+ InputTuple("{@a|b|c}", ""),
+ [
+ OutputTuple("a", "", {"_output_seed": 3}),
+ OutputTuple("b", "", {"_output_seed": 2}),
+ OutputTuple("c", "", {"_output_seed": 1}),
+ ],
+ seed=3,
+ ppp=self.init_ppp("nocup", run_mode=RUN_MODE.multiple, results_limit=3, next_seed=NEXT_SEED.decrement),
+ )
+
+ def test_ch_next_seed_randomize(self): # randomize mode produces a distinct seed for every result
+ ppp_instance = self.init_ppp("nocup", run_mode=RUN_MODE.multiple, results_limit=3, next_seed=NEXT_SEED.randomize)
+ ppp_instance.process_prompts_group_start()
+ results = ppp_instance.process_prompt("{@a|b|c}", "", starting_seed=1)
+ ppp_instance.process_prompts_group_end()
+ seeds = [r_vars.get("_output_seed") for _, _, r_vars in results]
+ self.assertEqual(len(seeds), 3, f"Expected 3 results, got {len(seeds)}")
+ self.assertEqual(len(set(seeds)), 3, f"Expected 3 distinct seeds, got: {seeds}")
diff --git a/web/docs/ACBPPPRunModeOptions.md b/web/docs/ACBPPPRunModeOptions.md
index bb61a73..6fd0501 100644
--- a/web/docs/ACBPPPRunModeOptions.md
+++ b/web/docs/ACBPPPRunModeOptions.md
@@ -8,6 +8,7 @@ Provides run mode options to the main PPP node.
* **results_shuffle**: It shuffles the results.
* **comb_random_fixed**: If True all specified random samplers will have a fixed value across the combinations.
* **default_sampler**: The default choice sampler when not specified (in non combinatorial mode). Also applies to extranetwork mapping selection.
+* **next_seed**: Choose what to do with the seed in the following prompts in `multiple` or `combinatorial` mode. Value can be: `randomize`, `input`, `increment`, `decrement`.
## Outputs