src/prompt-modules/dynamic-prompt.js

/*
    Copyright 2022 juenbug12851

    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
    You may obtain a copy of the License at

        http://www.apache.org/licenses/LICENSE-2.0

    Unless required by applicable law or agreed to in writing, software
    distributed under the License is distributed on an "AS IS" BASIS,
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    See the License for the specific language governing permissions and
    limitations under the License.
*/

/**
 * @file
 * @brief Pipeline stage: expand #name tokens via createRequire plugin loading (v1 / user-submitted, auto-fx/artists, danbooru substitution). Notes: notes/reference/prompt-dsl.md.
 */

import { createRequire } from "node:module";

// Dynamic prompt files are resolved by config-driven path and expanded
// synchronously (inside string-replace callbacks). Node 24 can `require()` ES
// modules synchronously, so a scoped require is the right tool for this
// plugin-style loading. The catalog lives under the repo-root data/ folder (an
// intentional exception to "code lives in src/" — these are prompt content);
// this file is in src/prompt-modules/, so the require base is `../../data/`.
const require = createRequire(import.meta.url);

/**
 * When an anime/danbooru keyword dict is active, rewrite a trailing `, Person` to
 * `{d-person}` so the anime character list is used instead of the generic person list.
 * @param {string} prompt The prompt fragment.
 * @param {object} settings The merged generation settings (`keywordsFilename`).
 * @returns {string} The (possibly) substituted prompt.
 */
// Some keywords are better converted to danbooru if danbooru is in effect
function danbooruReplacer(prompt, settings) {
  if (
    settings.keywordsFilename == false ||
    (!String(settings.keywordsFilename).startsWith("d/") && settings.keywordsFilename != "danbooru")
  )
    return prompt;

  // Person (Case-Insensitive) followed by word-boundry, replace with
  // {d-person} and carry over word boundry
  prompt = prompt.replaceAll(/, ?Person/gim, "{d/person}");

  return prompt;
}

/**
 * Map a `user-`-prefixed dynamic-prompt name to its `user-submitted/` path.
 * @param {string} name The dynamic-prompt key.
 * @returns {string} The resolved path key.
 */
function convertToPath(name) {
  if (!name.startsWith("user-")) return name;

  name = name.replace("user-", "");
  return `user-submitted/${name}`;
}

/**
 * Load and run a v2 dynamic-prompt plugin (`#name`), applying danbooru substitution.
 * @param {string} name The dynamic-prompt name (a trailing `-v2` is stripped).
 * @param {object} settings The merged generation settings.
 * @param {object} imageSettings The image settings.
 * @param {object} upscaleSettings The upscale settings.
 * @returns {string} The generated prompt fragment.
 */
function expandDynamicPromptV2(name, settings, imageSettings, upscaleSettings) {
  // Remove -v2
  name = name.replace("-v2", "");

  // Read expansion file contents
  return danbooruReplacer(
    require(`../../data/${settings.dynamicPromptFiles}/${convertToPath(name)}`).default(
      settings,
      imageSettings,
      upscaleSettings,
    ),
    settings,
  );
}

/**
 * Load and run a frozen v1 dynamic-prompt plugin (`#name-v1`); v1 bakes in fx and
 * artists, so auto-add is forced off.
 * @param {string} name The dynamic-prompt name (a trailing `-v1` is stripped).
 * @param {object} settings The merged generation settings.
 * @param {object} imageSettings The image settings.
 * @param {object} upscaleSettings The upscale settings.
 * @returns {string} The generated prompt fragment.
 */
function expandDynamicPromptV1(name, settings, imageSettings, upscaleSettings) {
  // V1 already includes these
  settings.autoAddFx = false;
  settings.autoAddArtists = false;

  // Remove -v1
  name = name.replace("-v1", "");

  // Read expansion file contents
  return danbooruReplacer(
    require(`../../data/${settings.dynamicPromptFiles}/v1/${convertToPath(name)}`).default(
      settings,
      imageSettings,
      upscaleSettings,
    ),
    settings,
  );
}

/**
 * Dynamic-prompt pipeline stage: expand every `#name` token by running its plugin
 * (recursively up to 10 passes; `-v1` and `user-` variants supported), then
 * auto-append `#fx` / `#artists` when configured. Snapshots `imageSettings.origPostPrompt`.
 * @param {string} prompt The prompt after the first expansion pass.
 * @param {object} settings The merged generation settings.
 * @param {object} imageSettings Image settings; receives `origPostPrompt` and auto-include flags.
 * @param {object} [upscaleSettings] Passed through to plugins.
 * @returns {string} The prompt with all dynamic prompts expanded.
 */
export default function (prompt, settings, imageSettings, upscaleSettings) {
  // Check for these before expansion
  const includedArtists =
    prompt.includes("#artists") ||
    prompt.includes("artist") || // In case someone uses an artist list but not dyn prompt
    imageSettings.autoIncludedArtists;

  const includedFx = prompt.includes("#fx") || imageSettings.autoIncludedFx;

  // Max iterations in case of infinite loops
  let maxCount = 10;

  // Keep expanding expansions up to max levels
  for (let i = 0; i < maxCount && /#([\w\-_]+)/gm.test(prompt); i++) {
    prompt = prompt.replaceAll(/#([\w\-_]+)/gm, function (match, p1) {
      if (p1.endsWith("-v1"))
        return expandDynamicPromptV1(p1, settings, imageSettings, upscaleSettings);
      else return expandDynamicPromptV2(p1, settings, imageSettings, upscaleSettings);
    });
  }

  // Auto-append fx and artists if cofnigured to do so
  // We do this afterwards because some modules may change the settings
  // so we have to rprocess the modules first

  // Auto-add fx first if requested to do so
  if (settings.autoAddFx && !includedFx) {
    prompt += `, ${expandDynamicPromptV2("fx", settings, imageSettings, upscaleSettings)}`;
    imageSettings.autoIncludedFx = true;
  }

  // Auto-add artists second if requested to do so
  if (settings.autoAddArtists && !includedArtists) {
    prompt += `, ${expandDynamicPromptV2("artists", settings, imageSettings, upscaleSettings)}`;
    imageSettings.autoIncludedArtists = true;
  }

  // Save the original post prompt
  // The prompt after dynamic prompts but before the lists have been expanded on
  imageSettings.origPostPrompt = prompt;

  // Return prompt
  return prompt;
}