Stable Diffusion: primary/secondary settings presets with auto-fallback Reviewed-by: auto-review
| @@ -360,8 +360,83 @@ const defaultSettings = { | ||
| 360 | 360 | google_api: 'makersuite', |
| 361 | 361 | google_enhance: true, |
| 362 | 362 | google_duration: 6, |
| 363 | + | |
| 364 | + // Settings presets & auto-fallback | |
| 365 | + settings_preset_primary: null, | |
| 366 | + settings_preset_secondary: null, | |
| 367 | + settings_fallback_enabled: false, | |
| 363 | 368 | }; |
| 364 | 369 | |
| 370 | +/** | |
| 371 | + * Keys that are NOT part of a settings preset snapshot. | |
| 372 | + * A preset captures backend/connection params (source, model, sampler, dimensions, etc.) | |
| 373 | + * but not the prompt library, styles, custom entries, or UI prefs. | |
| 374 | + * @type {string[]} | |
| 375 | + */ | |
| 376 | +const PRESET_EXCLUDE_KEYS = [ | |
| 377 | + 'settings_preset_primary', | |
| 378 | + 'settings_preset_secondary', | |
| 379 | + 'settings_fallback_enabled', | |
| 380 | + 'prompts', | |
| 381 | + 'character_prompts', | |
| 382 | + 'character_negative_prompts', | |
| 383 | + 'styles', | |
| 384 | + 'custom_entries', | |
| 385 | + 'interactive_visible', | |
| 386 | + 'wand_visible', | |
| 387 | + 'command_visible', | |
| 388 | + 'tool_visible', | |
| 389 | + 'expand', | |
| 390 | +]; | |
| 391 | + | |
| 392 | +/** | |
| 393 | + * Creates a deep clone snapshot of the settings keys that belong to a preset. | |
| 394 | + * @returns {object} Snapshot of the included settings keys. | |
| 395 | + */ | |
| 396 | +function snapshotSdSettings() { | |
| 397 | + const snapshot = {}; | |
| 398 | + for (const key of Object.keys(extension_settings.sd)) { | |
| 399 | + if (PRESET_EXCLUDE_KEYS.includes(key)) { | |
| 400 | + continue; | |
| 401 | + } | |
| 402 | + snapshot[key] = structuredClone(extension_settings.sd[key]); | |
| 403 | + } | |
| 404 | + return snapshot; | |
| 405 | +} | |
| 406 | + | |
| 407 | +/** | |
| 408 | + * Applies a settings snapshot to the live settings object (in-memory only; does NOT touch the DOM). | |
| 409 | + * @param {object} snapshot Snapshot previously created by snapshotSdSettings(). | |
| 410 | + */ | |
| 411 | +function applySdSettingsSnapshot(snapshot) { | |
| 412 | + if (!snapshot || typeof snapshot !== 'object') { | |
| 413 | + return; | |
| 414 | + } | |
| 415 | + for (const key of Object.keys(snapshot)) { | |
| 416 | + extension_settings.sd[key] = structuredClone(snapshot[key]); | |
| 417 | + } | |
| 418 | +} | |
| 419 | + | |
| 420 | +/** | |
| 421 | + * Checks whether a preset is configured (a non-null object with at least one key). | |
| 422 | + * @param {object} preset Preset to check. | |
| 423 | + * @returns {boolean} True if the preset is configured. | |
| 424 | + */ | |
| 425 | +function isPresetConfigured(preset) { | |
| 426 | + return !!preset && typeof preset === 'object' && Object.keys(preset).length > 0; | |
| 427 | +} | |
| 428 | + | |
| 429 | +/** | |
| 430 | + * Refreshes all settings UI controls to reflect the current extension_settings.sd values. | |
| 431 | + * Used after loading a settings preset. | |
| 432 | + * @returns {Promise<void>} | |
| 433 | + */ | |
| 434 | +async function refreshSettingsUi() { | |
| 435 | + // loadSettings() appends to #sd_style without clearing it, so empty it first. | |
| 436 | + $('#sd_style').empty(); | |
| 437 | + await loadSettings(); | |
| 438 | +} | |
| 439 | + | |
| 365 | 440 | const writePromptFieldsDebounced = debounce(writePromptFields, debounce_timeout.relaxed); |
| 366 | 441 | const isVideo = (/** @type {string} */ format) => VIDEO_EXTENSIONS.includes(String(format || '').trim().toLowerCase()); |
| 367 | 442 | |
| @@ -495,6 +570,19 @@ async function loadSettings() { | ||
| 495 | 570 | extension_settings.sd.styles = defaultStyles; |
| 496 | 571 | } |
| 497 | 572 | |
| 573 | + // Settings presets & auto-fallback | |
| 574 | + if (extension_settings.sd.settings_preset_primary === undefined) { | |
| 575 | + extension_settings.sd.settings_preset_primary = null; | |
| 576 | + } | |
| 577 | + | |
| 578 | + if (extension_settings.sd.settings_preset_secondary === undefined) { | |
| 579 | + extension_settings.sd.settings_preset_secondary = null; | |
| 580 | + } | |
| 581 | + | |
| 582 | + if (extension_settings.sd.settings_fallback_enabled === undefined) { | |
| 583 | + extension_settings.sd.settings_fallback_enabled = false; | |
| 584 | + } | |
| 585 | + | |
| 498 | 586 | // Preserve an original seed if exists |
| 499 | 587 | if (extension_settings.sd.original_seed >= 0) { |
| 500 | 588 | extension_settings.sd.seed = extension_settings.sd.original_seed; |
| @@ -560,6 +648,7 @@ async function loadSettings() { | ||
| 560 | 648 | $('#sd_google_api').val(extension_settings.sd.google_api); |
| 561 | 649 | $('#sd_google_enhance').prop('checked', extension_settings.sd.google_enhance); |
| 562 | 650 | $('#sd_google_duration').val(extension_settings.sd.google_duration); |
| 651 | + $('#sd_fallback_enabled').prop('checked', extension_settings.sd.settings_fallback_enabled); | |
| 563 | 652 | |
| 564 | 653 | for (const style of extension_settings.sd.styles) { |
| 565 | 654 | const option = document.createElement('option'); |
| @@ -803,6 +892,47 @@ async function onRenameStyleClick() { | ||
| 803 | 892 | saveSettingsDebounced(); |
| 804 | 893 | } |
| 805 | 894 | |
| 895 | +function onSavePresetPrimaryClick() { | |
| 896 | + extension_settings.sd.settings_preset_primary = snapshotSdSettings(); | |
| 897 | + saveSettingsDebounced(); | |
| 898 | + toastr.success(t`Primary settings preset saved.`, t`Image Generation`); | |
| 899 | +} | |
| 900 | + | |
| 901 | +function onSavePresetSecondaryClick() { | |
| 902 | + extension_settings.sd.settings_preset_secondary = snapshotSdSettings(); | |
| 903 | + saveSettingsDebounced(); | |
| 904 | + toastr.success(t`Secondary settings preset saved.`, t`Image Generation`); | |
| 905 | +} | |
| 906 | + | |
| 907 | +async function onLoadPresetPrimaryClick() { | |
| 908 | + if (!isPresetConfigured(extension_settings.sd.settings_preset_primary)) { | |
| 909 | + toastr.info(t`No primary settings preset has been saved yet.`, t`Image Generation`); | |
| 910 | + return; | |
| 911 | + } | |
| 912 | + | |
| 913 | + applySdSettingsSnapshot(extension_settings.sd.settings_preset_primary); | |
| 914 | + saveSettingsDebounced(); | |
| 915 | + await refreshSettingsUi(); | |
| 916 | + toastr.success(t`Primary settings preset loaded.`, t`Image Generation`); | |
| 917 | +} | |
| 918 | + | |
| 919 | +async function onLoadPresetSecondaryClick() { | |
| 920 | + if (!isPresetConfigured(extension_settings.sd.settings_preset_secondary)) { | |
| 921 | + toastr.info(t`No secondary settings preset has been saved yet.`, t`Image Generation`); | |
| 922 | + return; | |
| 923 | + } | |
| 924 | + | |
| 925 | + applySdSettingsSnapshot(extension_settings.sd.settings_preset_secondary); | |
| 926 | + saveSettingsDebounced(); | |
| 927 | + await refreshSettingsUi(); | |
| 928 | + toastr.success(t`Secondary settings preset loaded.`, t`Image Generation`); | |
| 929 | +} | |
| 930 | + | |
| 931 | +function onFallbackEnabledChange() { | |
| 932 | + extension_settings.sd.settings_fallback_enabled = !!$(this).prop('checked'); | |
| 933 | + saveSettingsDebounced(); | |
| 934 | +} | |
| 935 | + | |
| 806 | 936 | /** |
| 807 | 937 | * Modifies prompt based on user inputs. |
| 808 | 938 | * @param {string} prompt Prompt to refine |
| @@ -3321,108 +3451,121 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP | ||
| 3321 | 3451 | |
| 3322 | 3452 | const skipCharPrefix = !ignoreNoCharForSwipe && noCharPrefix.includes(generationType); |
| 3323 | 3453 | |
| 3324 | - const prefix = skipCharPrefix | |
| 3454 | + /** | |
| 3325 | - ? extension_settings.sd.prompt_prefix | |
| 3455 | + * Performs a single image generation attempt against the live extension_settings.sd config. | |
| 3326 | - : combinePrefixes(extension_settings.sd.prompt_prefix, getCharacterPrefix()); | |
| 3456 | + * Reads settings live so it can be retried after applying a fallback preset. | |
| 3327 | - | |
| 3457 | + * @param {AbortSignal} attemptSignal Abort signal to cancel the request. | |
| 3328 | - const negativePrefix = skipCharPrefix | |
| 3458 | + * @returns {Promise<{result: {format: string, data: string}, prefixedPrompt: string}>} | |
| 3329 | - ? extension_settings.sd.negative_prompt | |
| 3459 | + * @throws {Error} On failure or when the endpoint returns no image data. | |
| 3330 | - : combinePrefixes(extension_settings.sd.negative_prompt, getCharacterNegativePrefix()); | |
| 3460 | + */ | |
| 3461 | + async function attemptImageGeneration(attemptSignal) { | |
| 3462 | + const prefix = skipCharPrefix | |
| 3463 | + ? extension_settings.sd.prompt_prefix | |
| 3464 | + : combinePrefixes(extension_settings.sd.prompt_prefix, getCharacterPrefix()); | |
| 3331 | 3465 | |
| 3332 | - const prefixedPrompt = substituteParams(combinePrefixes(prefix, prompt, '{prompt}')); | |
| 3466 | + const negativePrefix = skipCharPrefix | |
| 3333 | - const negativePrompt = substituteParams(combinePrefixes(additionalNegativePrefix, negativePrefix)); | |
| 3467 | + ? extension_settings.sd.negative_prompt | |
| 3468 | + : combinePrefixes(extension_settings.sd.negative_prompt, getCharacterNegativePrefix()); | |
| 3334 | 3469 | |
| 3335 | - let result = { format: '', data: '' }; | |
| 3470 | + const prefixedPrompt = substituteParams(combinePrefixes(prefix, prompt, '{prompt}')); | |
| 3336 | 3471 | const currentChatIdnegativePrompt = getCurrentChatIdsubstituteParams(combinePrefixes(additionalNegativePrefix, negativePrefix)); |
| 3337 | 3472 | |
| 3338 | - try { | |
| 3473 | + let result = { format: '', data: '' }; | |
| 3339 | 3474 | switch (extension_settings.sd.source) { |
| 3340 | 3475 | case sources.extras: |
| 3341 | 3476 | result = await generateExtrasImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3342 | 3477 | break; |
| 3343 | 3478 | case sources.horde: |
| 3344 | 3479 | result = await generateHordeImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3345 | 3480 | break; |
| 3346 | 3481 | case sources.vlad: |
| 3347 | 3482 | result = await generateAutoImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3348 | 3483 | break; |
| 3349 | 3484 | case sources.drawthings: |
| 3350 | 3485 | result = await generateDrawthingsImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3351 | 3486 | break; |
| 3352 | 3487 | case sources.auto: |
| 3353 | 3488 | result = await generateAutoImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3354 | 3489 | break; |
| 3355 | 3490 | case sources.sdcpp: |
| 3356 | 3491 | result = await generateSdcppImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3357 | 3492 | break; |
| 3358 | 3493 | case sources.novel: |
| 3359 | 3494 | result = await generateNovelImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3360 | 3495 | break; |
| 3361 | 3496 | case sources.openai: |
| 3362 | 3497 | result = await generateOpenAiImage(prefixedPrompt, signalattemptSignal); |
| 3363 | 3498 | break; |
| 3364 | 3499 | case sources.aimlapi: |
| 3365 | 3500 | result = await generateAimlapiImage(prefixedPrompt, signalattemptSignal); |
| 3366 | 3501 | break; |
| 3367 | 3502 | case sources.comfy: |
| 3368 | 3503 | switch (extension_settings.sd.comfy_type) { |
| 3369 | 3504 | case comfyTypes.runpod_serverless: |
| 3370 | 3505 | result = await generateComfyRunPodImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3371 | 3506 | break; |
| 3372 | 3507 | case comfyTypes.standard: |
| 3373 | 3508 | result = await generateComfyImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3374 | 3509 | break; |
| 3375 | 3510 | default: |
| 3376 | 3511 | throw new Error('Unknown comfyUI server type.'); |
| 3377 | 3512 | } |
| 3378 | 3513 | break; |
| 3379 | 3514 | case sources.togetherai: |
| 3380 | 3515 | result = await generateTogetherAIImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3381 | 3516 | break; |
| 3382 | 3517 | case sources.pollinations: |
| 3383 | 3518 | result = await generatePollinationsImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3384 | 3519 | break; |
| 3385 | 3520 | case sources.stability: |
| 3386 | 3521 | result = await generateStabilityImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3387 | 3522 | break; |
| 3388 | 3523 | case sources.huggingface: |
| 3389 | 3524 | result = await generateHuggingFaceImage(prefixedPrompt, signalattemptSignal); |
| 3390 | 3525 | break; |
| 3391 | 3526 | case sources.chutes: |
| 3392 | 3527 | result = await generateChutesImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3393 | 3528 | break; |
| 3394 | 3529 | case sources.electronhub: |
| 3395 | 3530 | result = await generateElectronHubImage(prefixedPrompt, signalattemptSignal); |
| 3396 | 3531 | break; |
| 3397 | 3532 | case sources.nanogpt: |
| 3398 | 3533 | result = await generateNanoGPTImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3399 | 3534 | break; |
| 3400 | 3535 | case sources.bfl: |
| 3401 | 3536 | result = await generateBflImage(prefixedPrompt, signalattemptSignal); |
| 3402 | 3537 | break; |
| 3403 | 3538 | case sources.falai: |
| 3404 | 3539 | result = await generateFalaiImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3405 | 3540 | break; |
| 3406 | 3541 | case sources.xai: |
| 3407 | 3542 | result = await generateXAIImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3408 | 3543 | break; |
| 3409 | 3544 | case sources.google: |
| 3410 | 3545 | result = await generateGoogleImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3411 | 3546 | break; |
| 3412 | 3547 | case sources.zai: |
| 3413 | 3548 | result = await generateZaiImage(prefixedPrompt, signalattemptSignal); |
| 3414 | 3549 | break; |
| 3415 | 3550 | case sources.openrouter: |
| 3416 | 3551 | result = await generateOpenRouterImage(prefixedPrompt, signalattemptSignal); |
| 3417 | 3552 | break; |
| 3418 | 3553 | case sources.workersai: |
| 3419 | 3554 | result = await generateWorkersAIImage(prefixedPrompt, negativePrompt, signalattemptSignal); |
| 3420 | 3555 | break; |
| 3421 | 3556 | } |
| 3422 | 3557 | |
| 3423 | 3558 | if (!result.data) { |
| 3424 | 3559 | throw new Error('Endpoint did not return image data.'); |
| 3425 | 3560 | } |
| 3561 | + | |
| 3562 | + return { result, prefixedPrompt }; | |
| 3563 | + } | |
| 3564 | + | |
| 3565 | + const currentChatId = getCurrentChatId(); | |
| 3566 | + let genOutput; | |
| 3567 | + try { | |
| 3568 | + genOutput = await attemptImageGeneration(signal); | |
| 3426 | 3569 | } catch (err) { |
| 3427 | 3570 | // Check if this was an intentional abort by user |
| 3428 | 3571 | if (signal?.aborted) { |
| @@ -3431,11 +3574,35 @@ async function sendGenerationRequest(generationType, prompt, additionalNegativeP | ||
| 3431 | 3574 | return; |
| 3432 | 3575 | } |
| 3433 | 3576 | |
| 3434 | - console.error('Image generation request error: ', err); | |
| 3577 | + if (extension_settings.sd.settings_fallback_enabled && isPresetConfigured(extension_settings.sd.settings_preset_secondary)) { | |
| 3435 | - toastr.error('Image generation failed. Please try again.' + '\n\n' + String(err), 'Image Generation'); | |
| 3578 | + console.warn('SD: primary generation failed, falling back to secondary preset', err); | |
| 3436 | - return; | |
| 3579 | + toastr.warning('Primary image generation failed. Retrying with secondary settings…', 'Image Generation'); | |
| 3580 | + const restore = snapshotSdSettings(); | |
| 3581 | + try { | |
| 3582 | + applySdSettingsSnapshot(extension_settings.sd.settings_preset_secondary); | |
| 3583 | + genOutput = await attemptImageGeneration(signal); | |
| 3584 | + } catch (err2) { | |
| 3585 | + applySdSettingsSnapshot(restore); | |
| 3586 | + if (signal?.aborted) { | |
| 3587 | + console.log('SD: Image generation aborted by user'); | |
| 3588 | + toastr.info('Image generation stopped.', 'Image Generation'); | |
| 3589 | + return; | |
| 3590 | + } | |
| 3591 | + console.error('SD: secondary generation also failed', err2); | |
| 3592 | + toastr.error('Image generation failed (primary and secondary).' + '\n\n' + String(err2), 'Image Generation'); | |
| 3593 | + return; | |
| 3594 | + } | |
| 3595 | + // Restore live (primary) settings after a successful fallback attempt. | |
| 3596 | + applySdSettingsSnapshot(restore); | |
| 3597 | + } else { | |
| 3598 | + console.error('Image generation request error: ', err); | |
| 3599 | + toastr.error('Image generation failed. Please try again.' + '\n\n' + String(err), 'Image Generation'); | |
| 3600 | + return; | |
| 3601 | + } | |
| 3437 | 3602 | } |
| 3438 | 3603 | |
| 3604 | + const { result, prefixedPrompt } = genOutput; | |
| 3605 | + | |
| 3439 | 3606 | if (currentChatId !== getCurrentChatId()) { |
| 3440 | 3607 | console.warn('Chat changed, aborting SD result saving'); |
| 3441 | 3608 | toastr.warning('Chat changed, generated image discarded.', 'Image Generation'); |
| @@ -5859,6 +6026,11 @@ export async function init() { | ||
| 5859 | 6026 | $('#sd_save_style').on('click', onSaveStyleClick); |
| 5860 | 6027 | $('#sd_rename_style').on('click', onRenameStyleClick); |
| 5861 | 6028 | $('#sd_delete_style').on('click', onDeleteStyleClick); |
| 6029 | + $('#sd_save_preset_primary').on('click', onSavePresetPrimaryClick); | |
| 6030 | + $('#sd_save_preset_secondary').on('click', onSavePresetSecondaryClick); | |
| 6031 | + $('#sd_load_preset_primary').on('click', onLoadPresetPrimaryClick); | |
| 6032 | + $('#sd_load_preset_secondary').on('click', onLoadPresetSecondaryClick); | |
| 6033 | + $('#sd_fallback_enabled').on('change', onFallbackEnabledChange); | |
| 5862 | 6034 | $('#sd_character_prompt_block').hide(); |
| 5863 | 6035 | $('#sd_interactive_mode').on('input', onInteractiveModeInput); |
| 5864 | 6036 | $('#sd_openai_style').on('change', onOpenAiStyleSelect); |
| @@ -39,6 +39,21 @@ | ||
| 39 | 39 | <input id="sd_minimal_prompt_processing" type="checkbox" /> |
| 40 | 40 | <span data-i18n="sd_minimal_prompt_processing_txt">Minimal response prompt processing</span> |
| 41 | 41 | </label> |
| 42 | + <hr> | |
| 43 | + <h4 data-i18n="Settings Presets & Fallback">Settings Presets & Fallback</h4> | |
| 44 | + <small data-i18n="sd_settings_presets_small">Save the current backend/connection settings (source, model, sampler, dimensions, etc.) as Primary or Secondary presets and load them back later. Prompt templates, styles, and custom entries are not included.</small> | |
| 45 | + <div class="flex-container marginTopBot5 flexWrap"> | |
| 46 | + <div id="sd_save_preset_primary" class="menu_button" data-i18n="Save as Primary">Save as Primary</div> | |
| 47 | + <div id="sd_load_preset_primary" class="menu_button" data-i18n="Load Primary">Load Primary</div> | |
| 48 | + <div id="sd_save_preset_secondary" class="menu_button" data-i18n="Save as Secondary">Save as Secondary</div> | |
| 49 | + <div id="sd_load_preset_secondary" class="menu_button" data-i18n="Load Secondary">Load Secondary</div> | |
| 50 | + </div> | |
| 51 | + <label for="sd_fallback_enabled" class="checkbox_label" data-i18n="[title]sd_fallback_enabled" title="If an image generation fails, automatically retry the same prompt using the Secondary preset's settings."> | |
| 52 | + <input id="sd_fallback_enabled" type="checkbox" /> | |
| 53 | + <span data-i18n="sd_fallback_enabled_txt">Auto-fallback to Secondary on failure</span> | |
| 54 | + </label> | |
| 55 | + <small data-i18n="sd_fallback_enabled_small">When enabled, a failed generation is retried once with the Secondary preset before reporting an error.</small> | |
| 56 | + <hr> | |
| 42 | 57 | <label for="sd_source" data-i18n="Source">Source</label> |
| 43 | 58 | <select id="sd_source" class="text_pole"> |
| 44 | 59 | <option value="aimlapi">AI/ML API</option> |