diff --git a/static/css/components/lora-modal/showcase.css b/static/css/components/lora-modal/showcase.css index 63373638..a1ba5f5f 100644 --- a/static/css/components/lora-modal/showcase.css +++ b/static/css/components/lora-modal/showcase.css @@ -229,6 +229,19 @@ gap: 10px; } +/* Header row for params section */ +.metadata-row.params-row { + flex-direction: column; +} + +.metadata-row.params-row .param-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + /* Styling for parameters tags */ .params-tags { display: flex; @@ -315,7 +328,8 @@ } .copy-prompt-btn, -.send-prompt-btn { +.send-prompt-btn, +.send-params-btn { background: transparent; border: none; color: var(--text-color); @@ -328,7 +342,8 @@ } .copy-prompt-btn:hover, -.send-prompt-btn:hover { +.send-prompt-btn:hover, +.send-params-btn:hover { opacity: 1; color: var(--lora-accent); background: var(--lora-surface); diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index 68d6298e..7f7e458c 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -1,5 +1,5 @@ // Recipe Modal Component -import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata, stripLoraTags, sendPromptToWorkflow } from '../utils/uiHelpers.js'; +import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js'; import { translate } from '../utils/i18nHelpers.js'; import { state } from '../state/index.js'; import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; @@ -40,6 +40,16 @@ const GEN_PARAM_NORMALIZATION = { 'Denoising strength': 'denoising_strength', }; +const PARAM_DISPLAY_NAMES = { + steps: 'Steps', + sampler: 'Sampler', + cfg_scale: 'CFG', + seed: 'Seed', + size: 'Size', + clip_skip: 'Clip Skip', + denoising_strength: 'Denoising Strength', +}; + class RecipeModal { constructor() { this.promptEditorState = {}; @@ -588,10 +598,11 @@ class RecipeModal { for (const [key, value] of Object.entries(sanitizedGenParams)) { if (!excludedParams.includes(key) && value !== undefined && value !== null) { + const displayName = PARAM_DISPLAY_NAMES[key] || key; const paramTag = document.createElement('div'); paramTag.className = 'param-tag'; paramTag.innerHTML = ` - ${key}: + ${displayName}: ${value} `; otherParamsElement.appendChild(paramTag); @@ -1234,6 +1245,19 @@ class RecipeModal { }); }); } + + // Send params to workflow button + const sendParamsBtn = document.getElementById('sendParamsBtn'); + if (sendParamsBtn) { + sendParamsBtn.addEventListener('click', () => { + const genParams = this.currentRecipe?.gen_params || {}; + if (!genParams || Object.keys(genParams).length === 0) { + showToast('No generation parameters available', {}, 'warning'); + return; + } + sendGenParamsToWorkflow(genParams); + }); + } } /** diff --git a/static/js/components/shared/showcase/MediaUtils.js b/static/js/components/shared/showcase/MediaUtils.js index d3282726..bd0b270f 100644 --- a/static/js/components/shared/showcase/MediaUtils.js +++ b/static/js/components/shared/showcase/MediaUtils.js @@ -3,7 +3,7 @@ * Media-specific utility functions for showcase components * (Moved from uiHelpers.js to better organize code) */ -import { showToast, copyToClipboard, getNSFWLevelName, sendPromptToWorkflow, stripLoraTags } from '../../../utils/uiHelpers.js'; +import { showToast, copyToClipboard, getNSFWLevelName, sendPromptToWorkflow, stripLoraTags, sendGenParamsToWorkflow } from '../../../utils/uiHelpers.js'; import { state } from '../../../state/index.js'; import { getModelApiClient } from '../../../api/modelApiFactory.js'; import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js'; @@ -344,6 +344,48 @@ export function initMetadataPanelHandlers(container) { }); }); + // Handle send params buttons + const paramsBtn = metadataPanel.querySelector('.send-params-btn'); + if (paramsBtn) { + paramsBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + + // Collect gen params from the param-tag elements + const tagsContainer = wrapper.querySelector('.params-tags'); + if (!tagsContainer) return; + + const paramTags = tagsContainer.querySelectorAll('.param-tag'); + const genParams = {}; + + // Map display labels to genParams keys + const labelToKey = { + 'Seed': 'seed', + 'Steps': 'steps', + 'Sampler': 'sampler', + 'CFG': 'cfg_scale', + }; + + paramTags.forEach(tag => { + const nameEl = tag.querySelector('.param-name'); + const valueEl = tag.querySelector('.param-value'); + if (!nameEl || !valueEl) return; + + const label = nameEl.textContent.replace(':', '').trim(); + const key = labelToKey[label]; + if (key) { + genParams[key] = valueEl.textContent.trim(); + } + }); + + if (Object.keys(genParams).length === 0) { + showToast('No sendable parameters found', {}, 'warning'); + return; + } + + await sendGenParamsToWorkflow(genParams); + }); + } + // Prevent panel scroll from causing modal scroll metadataPanel.addEventListener('wheel', (e) => { const isAtTop = metadataPanel.scrollTop === 0; diff --git a/static/js/components/shared/showcase/MetadataPanel.js b/static/js/components/shared/showcase/MetadataPanel.js index 053e2f7d..eeab48c3 100644 --- a/static/js/components/shared/showcase/MetadataPanel.js +++ b/static/js/components/shared/showcase/MetadataPanel.js @@ -28,14 +28,24 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro if (hasParams) { content += ` -
- ${size ? `
Size:${size}
` : ''} - ${seed ? `
Seed:${seed}
` : ''} - ${model ? `
Model:${model}
` : ''} - ${steps ? `
Steps:${steps}
` : ''} - ${sampler ? `
Sampler:${sampler}
` : ''} - ${cfgScale ? `
CFG:${cfgScale}
` : ''} - ${clipSkip ? `
Clip Skip:${clipSkip}
` : ''} +
+
+ +
+ +
+
+
+ ${size ? `
Size:${size}
` : ''} + ${seed ? `
Seed:${seed}
` : ''} + ${model ? `
Model:${model}
` : ''} + ${steps ? `
Steps:${steps}
` : ''} + ${sampler ? `
Sampler:${sampler}
` : ''} + ${cfgScale ? `
CFG:${cfgScale}
` : ''} + ${clipSkip ? `
Clip Skip:${clipSkip}
` : ''} +
`; } diff --git a/static/js/utils/genParamsMapper.js b/static/js/utils/genParamsMapper.js new file mode 100644 index 00000000..18e19fe6 --- /dev/null +++ b/static/js/utils/genParamsMapper.js @@ -0,0 +1,296 @@ +/** + * genParamsMapper.js + * Maps display/recipe generation parameter values (sampler, scheduler) to + * ComfyUI internal widget values, enabling "Send Gen Params to Workflow". + * + * Strategy (3 layers): + * 1. Direct lookup via SAMPLER_DISPLAY_TO_INTERNAL + * 2. Combined-name parsing (e.g. "Euler a Karras" → sampler + scheduler) + * 3. Graceful skip for model-specific / unrecognized values + */ + +// --------------------------------------------------------------------------- +// Sampler display name → internal name (ComfyUI KSampler.SAMPLERS / SAMPLER_NAMES) +// --------------------------------------------------------------------------- +const SAMPLER_DISPLAY_TO_INTERNAL = { + // --- Euler family --- + 'Euler': 'euler', + 'euler': 'euler', + 'Euler a': 'euler_ancestral', + 'Euler A': 'euler_ancestral', + 'Euler ancestral': 'euler_ancestral', + 'Euler Ancestral': 'euler_ancestral', + 'euler_ancestral': 'euler_ancestral', + + // --- Heun --- + 'Heun': 'heun', + 'heun': 'heun', + 'Heun++': 'heunpp2', + 'heunpp2': 'heunpp2', + + // --- DPM2 --- + 'DPM2': 'dpm_2', + 'DPM 2': 'dpm_2', + 'dpm_2': 'dpm_2', + 'DPM2 a': 'dpm_2_ancestral', + 'DPM2 Ancestral': 'dpm_2_ancestral', + 'dpm_2_ancestral': 'dpm_2_ancestral', + + // --- LMS --- + 'LMS': 'lms', + 'lms': 'lms', + + // --- DPM fast / adaptive --- + 'DPM fast': 'dpm_fast', + 'DPM Fast': 'dpm_fast', + 'dpm_fast': 'dpm_fast', + 'DPM adaptive': 'dpm_adaptive', + 'DPM Adaptive': 'dpm_adaptive', + 'dpm_adaptive': 'dpm_adaptive', + + // --- DPM++ 2S ancestral --- + 'DPM++ 2S a': 'dpmpp_2s_ancestral', + 'DPM++ 2S A': 'dpmpp_2s_ancestral', + 'DPM++ 2S Ancestral': 'dpmpp_2s_ancestral', + 'dpmpp_2s_ancestral': 'dpmpp_2s_ancestral', + + // --- DPM++ SDE --- + 'DPM++ SDE': 'dpmpp_sde', + 'dpmpp_sde': 'dpmpp_sde', + + // --- DPM++ 2M --- + 'DPM++ 2M': 'dpmpp_2m', + 'dpmpp_2m': 'dpmpp_2m', + + // --- DPM++ 2M SDE --- + 'DPM++ 2M SDE': 'dpmpp_2m_sde', + 'dpmpp_2m_sde': 'dpmpp_2m_sde', + + // --- DPM++ 3M SDE --- + 'DPM++ 3M SDE': 'dpmpp_3m_sde', + 'dpmpp_3m_sde': 'dpmpp_3m_sde', + + // --- Others --- + 'DDIM': 'ddim', + 'ddim': 'ddim', + 'DDPM': 'ddpm', + 'ddpm': 'ddpm', + 'LCM': 'lcm', + 'lcm': 'lcm', + 'IPNDM': 'ipndm', + 'ipndm': 'ipndm', + 'DEIS': 'deis', + 'deis': 'deis', + 'UniPC': 'uni_pc', + 'unipc': 'uni_pc', + 'uni_pc': 'uni_pc', + + // --- Restart / res_multistep --- + 'Restart': 'res_multistep', + 'res_multistep': 'res_multistep', + + // --- ER SDE --- + 'ER SDE': 'er_sde', + 'E-R SDE': 'er_sde', + 'er_sde': 'er_sde', + + // --- SA Solver --- + 'SA Solver': 'sa_solver', + 'SA solver': 'sa_solver', + 'sa_solver': 'sa_solver', + + // --- Seeds --- + 'Seeds 2': 'seeds_2', + 'seeds_2': 'seeds_2', + 'Seeds 3': 'seeds_3', + 'seeds_3': 'seeds_3', +}; + +// --------------------------------------------------------------------------- +// Known scheduler suffixes (ComfyUI KSampler.SCHEDULERS) +// Sorted by length (descending) for longest-match-first parsing. +// --------------------------------------------------------------------------- +const SCHEDULER_SUFFIXES = [ + 'sgm_uniform', + 'ddim_uniform', + 'linear_quadratic', + 'kl_optimal', + 'exponential', + 'karras', + 'simple', + 'normal', + 'beta', +]; + +// --------------------------------------------------------------------------- +// Scheduler-only values (values that are schedulers, not samplers) +// --------------------------------------------------------------------------- +const SCHEDULER_ONLY_VALUES = new Set([ + 'simple', 'sgm_uniform', 'karras', 'exponential', + 'ddim_uniform', 'beta', 'normal', 'linear_quadratic', 'kl_optimal', +]); + +// --------------------------------------------------------------------------- +// Param key → widget name candidates (searched in order) +// --------------------------------------------------------------------------- +const PARAM_TO_WIDGET_CANDIDATES = { + seed: ['seed', 'noise_seed'], + steps: ['steps'], + cfg: ['cfg'], + sampler: ['sampler_name', 'sampler'], + scheduler: ['scheduler'], +}; + +// --------------------------------------------------------------------------- +// Parse a combined sampler+scheduler value (space-separated or underscore) +// e.g., "Euler a Karras", "DPM++ 2M beta", "er_sde_beta" +// Returns { sampler: internalName|null, scheduler: internalName|null } or null +// --------------------------------------------------------------------------- +function parseCombinedSamplerName(rawValue) { + if (!rawValue || typeof rawValue !== 'string') return null; + const trimmed = rawValue.trim(); + if (!trimmed) return null; + + // Try space-separated first: split on last space + const spaceIdx = trimmed.lastIndexOf(' '); + if (spaceIdx > 0) { + const candidateScheduler = trimmed.slice(spaceIdx + 1).trim().toLowerCase(); + if (SCHEDULER_SUFFIXES.includes(candidateScheduler)) { + const samplerPart = trimmed.slice(0, spaceIdx).trim(); + const internalSampler = SAMPLER_DISPLAY_TO_INTERNAL[samplerPart]; + if (internalSampler) { + return { sampler: internalSampler, scheduler: candidateScheduler }; + } + // samplerPart might be a combined name itself (e.g., "DPM++ 2M SDE") + // Try recursing (one level max) — already handled since we split at last space + } + } + + // Try underscore-separated: e.g., "er_sde_beta" + const underIdx = trimmed.lastIndexOf('_'); + if (underIdx > 0) { + const candidateScheduler = trimmed.slice(underIdx + 1).trim().toLowerCase(); + if (SCHEDULER_SUFFIXES.includes(candidateScheduler)) { + const samplerPart = trimmed.slice(0, underIdx).trim(); + const internalSampler = SAMPLER_DISPLAY_TO_INTERNAL[samplerPart] || SAMPLER_DISPLAY_TO_INTERNAL[samplerPart.toLowerCase()]; + if (internalSampler) { + return { sampler: internalSampler, scheduler: candidateScheduler }; + } + } + } + + return null; +} + +// --------------------------------------------------------------------------- +// Main resolver: takes a raw sampler value from recipe/showcase metadata +// and returns { sampler: internalName|null, scheduler: internalName|null } +// --------------------------------------------------------------------------- +function resolveSamplerScheduler(rawValue) { + if (!rawValue || typeof rawValue !== 'string') { + return { sampler: null, scheduler: null }; + } + + const trimmed = rawValue.trim(); + if (!trimmed) return { sampler: null, scheduler: null }; + + // 1. Try direct lookup first + const direct = SAMPLER_DISPLAY_TO_INTERNAL[trimmed]; + if (direct) return { sampler: direct, scheduler: null }; + + // 2. Try lowercase direct lookup + const lowerDirect = SAMPLER_DISPLAY_TO_INTERNAL[trimmed.toLowerCase()]; + if (lowerDirect) return { sampler: lowerDirect, scheduler: null }; + + // 3. Scheduler-only value? (check BEFORE the "already internal name" regex, + // because scheduler values like "karras", "simple" also match that pattern) + if (SCHEDULER_ONLY_VALUES.has(trimmed.toLowerCase())) { + return { sampler: null, scheduler: trimmed.toLowerCase() }; + } + + // 4. Already an internal name? (lowercase, no spaces) + if (/^[a-z][a-z0-9_]+$/.test(trimmed)) { + return { sampler: trimmed, scheduler: null }; + } + + // 5. Try combined name parsing (space-separated or underscore) + const combined = parseCombinedSamplerName(trimmed); + if (combined) return combined; + + // 6. Custom format like "multistep/dpmpp_2m_simple" — try extracting the last segment + if (trimmed.includes('/')) { + const parts = trimmed.split('/'); + const last = parts[parts.length - 1]; + if (last) { + const subResult = resolveSamplerScheduler(last); + if (subResult.sampler || subResult.scheduler) return subResult; + } + } + + // 7. Unrecognized — return null for both + return { sampler: null, scheduler: null }; +} + +// --------------------------------------------------------------------------- +// Find which gen params can be sent to a given node, matching by widget names +// Returns array of { widgetName, value } objects +// --------------------------------------------------------------------------- +function findMatchingWidgets(nodeWidgetNames, resolvedParams) { + if (!nodeWidgetNames || !Array.isArray(nodeWidgetNames) || nodeWidgetNames.length === 0) { + return []; + } + + const widgetSet = new Set(nodeWidgetNames.map(w => String(w).toLowerCase())); + const updates = []; + + // Simple numeric/string params: seed, steps, cfg + const simpleParams = [ + { key: 'seed', value: resolvedParams.seed }, + { key: 'steps', value: resolvedParams.steps }, + { key: 'cfg', value: resolvedParams.cfg }, + ]; + for (const { key, value } of simpleParams) { + if (value === undefined || value === null || value === '') continue; + const candidates = PARAM_TO_WIDGET_CANDIDATES[key] || [key]; + for (const candidate of candidates) { + if (widgetSet.has(candidate.toLowerCase())) { + updates.push({ widgetName: candidate, value: String(value) }); + break; + } + } + } + + // Sampler + if (resolvedParams.sampler) { + const candidates = PARAM_TO_WIDGET_CANDIDATES.sampler; + for (const candidate of candidates) { + if (widgetSet.has(candidate.toLowerCase())) { + updates.push({ widgetName: candidate, value: resolvedParams.sampler }); + break; + } + } + } + + // Scheduler + if (resolvedParams.scheduler) { + const candidates = PARAM_TO_WIDGET_CANDIDATES.scheduler; + for (const candidate of candidates) { + if (widgetSet.has(candidate.toLowerCase())) { + updates.push({ widgetName: candidate, value: resolvedParams.scheduler }); + break; + } + } + } + + return updates; +} + +export { + SAMPLER_DISPLAY_TO_INTERNAL, + SCHEDULER_SUFFIXES, + SCHEDULER_ONLY_VALUES, + PARAM_TO_WIDGET_CANDIDATES, + parseCombinedSamplerName, + resolveSamplerScheduler, + findMatchingWidgets, +}; diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index a0184d70..961d1042 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -6,6 +6,7 @@ import { eventManager } from './EventManager.js'; import { bannerService } from '../managers/BannerService.js'; import { modalManager } from '../managers/ModalManager.js'; import { buildCivitaiUrl, normalizeCivitaiPageHost } from './civitaiUtils.js'; +import { resolveSamplerScheduler, findMatchingWidgets } from './genParamsMapper.js'; const CIVITAI_HOST_INFO_BANNER_ID = 'civitai-host-preference'; const CIVITAI_HOST_INFO_BANNER_SEEN_KEY = 'civitai_host_info_banner_seen'; @@ -856,11 +857,12 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess successMessage = 'Updated workflow node', failureMessage = 'Failed to update workflow node', missingTargetMessage = 'No target node selected', + silent = false, } = messages; const targetIds = Array.isArray(nodeIds) ? nodeIds : []; if (targetIds.length === 0) { - showToast(missingTargetMessage, {}, 'warning'); + if (!silent) showToast(missingTargetMessage, {}, 'warning'); return false; } @@ -869,7 +871,7 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess .filter((reference) => reference && reference.node_id !== undefined); if (references.length === 0) { - showToast(missingTargetMessage, {}, 'warning'); + if (!silent) showToast(missingTargetMessage, {}, 'warning'); return false; } @@ -888,16 +890,16 @@ async function sendWidgetValueToNodes(nodeIds, nodesMap, widgetName, value, mess const result = await response.json(); if (result.success) { - showToast(successMessage, {}, 'success'); + if (!silent) showToast(successMessage, {}, 'success'); return true; } const errorMessage = result?.error || failureMessage; - showToast(errorMessage, {}, 'error'); + if (!silent) showToast(errorMessage, {}, 'error'); return false; } catch (error) { console.error('Failed to send widget value to workflow:', error); - showToast(failureMessage, {}, 'error'); + if (!silent) showToast(failureMessage, {}, 'error'); return false; } } @@ -1056,6 +1058,127 @@ export async function sendPromptToWorkflow(promptText, options = {}) { return true; } +/** + * Send generation parameters (seed, steps, cfg, sampler, scheduler) to + * workflow nodes that have been marked with "Send Gen Params Target". + * + * @param {Object} genParams - Raw gen_params from recipe or showcase metadata + * @returns {Promise} Whether the send succeeded + */ +export async function sendGenParamsToWorkflow(genParams) { + if (!genParams || typeof genParams !== 'object') { + showToast('No generation parameters to send', {}, 'warning'); + return false; + } + + // 1. Extract relevant params (skip prompt, negative_prompt, clip_skip, denoising_strength) + const raw = { + seed: genParams.seed, + steps: genParams.steps, + cfg: genParams.cfg_scale, + }; + + // 2. Resolve sampler/scheduler + const resolved = resolveSamplerScheduler(genParams.sampler); + if (resolved) { + if (resolved.sampler) raw.sampler = resolved.sampler; + if (resolved.scheduler) raw.scheduler = resolved.scheduler; + } + + // Check if we have anything to send + const hasAny = Object.values(raw).some(v => v !== undefined && v !== null && v !== ''); + if (!hasAny) { + showToast('No sendable parameters found', {}, 'warning'); + return false; + } + + // 3. Fetch workflow registry + const registry = await fetchWorkflowRegistry(); + if (!registry) { + return false; + } + + // 4. Filter nodes by marker_role === "send_gen_params" + const targetNodes = filterRegistryNodes(registry.nodes, (node) => { + return node.marker_role === 'send_gen_params' && isNodeEnabled(node); + }); + + const nodeKeys = Object.keys(targetNodes); + if (nodeKeys.length === 0) { + showToast( + 'No node marked as Send Gen Params Target.\nRight-click a node in ComfyUI → Mark as → Send Gen Params Target', + {}, + 'warning' + ); + return false; + } + + // 5. For each candidate node, find matching widgets + // Also collect widget_names from registry for matching + const sendToNode = async (nodeIds) => { + const targetIds = Array.isArray(nodeIds) ? nodeIds : [nodeIds]; + let allSuccess = true; + let totalSent = 0; + let totalFailed = 0; + + for (const nodeKey of targetIds) { + const node = targetNodes[nodeKey]; + if (!node) continue; + + const widgetNames = node.widget_names || []; + const updates = findMatchingWidgets(widgetNames, raw); + + if (updates.length === 0) { + showToast(`Node "${node.title || node.type}" has no matching widgets for these parameters`, {}, 'warning'); + allSuccess = false; + continue; + } + + // Send each widget value sequentially + for (const update of updates) { + const success = await sendWidgetValueToNodes( + [nodeKey], + targetNodes, + update.widgetName, + update.value, + { + silent: true, + } + ); + if (success) { + totalSent++; + } else { + totalFailed++; + allSuccess = false; + } + } + } + + // Show single summary toast + if (totalSent > 0 && totalFailed === 0) { + showToast(`Sent ${totalSent} parameter${totalSent > 1 ? 's' : ''} to workflow`, {}, 'success'); + } else if (totalFailed > 0 && totalSent > 0) { + showToast(`Partially updated (${totalSent} ok, ${totalFailed} failed)`, {}, 'warning'); + } else if (totalFailed > 0) { + showToast('Failed to update parameters', {}, 'error'); + } + return allSuccess; + }; + + // 6. If multiple nodes, show node selector; otherwise send directly + if (nodeKeys.length === 1) { + return await sendToNode([nodeKeys[0]]); + } + + showNodeSelector(targetNodes, { + actionType: 'Gen Params', + actionMode: 'Update', + onSend: sendToNode, + enableSendAll: true, + }); + return true; +} + // Global variable to track active node selector state let nodeSelectorState = { isActive: false, diff --git a/templates/components/recipe_modal.html b/templates/components/recipe_modal.html index 7624e170..e7a9f65e 100644 --- a/templates/components/recipe_modal.html +++ b/templates/components/recipe_modal.html @@ -90,7 +90,17 @@
-
+
+
+ +
+ +
+
+
+
diff --git a/tests/frontend/components/contextMenu.interactions.test.js b/tests/frontend/components/contextMenu.interactions.test.js index fa413ba4..aefd3c46 100644 --- a/tests/frontend/components/contextMenu.interactions.test.js +++ b/tests/frontend/components/contextMenu.interactions.test.js @@ -1117,9 +1117,9 @@ describe('Interaction-level regression coverage', () => { expect(document.getElementById('recipePrompt').textContent).toBe('No prompt information available'); expect(document.getElementById('recipeNegativePrompt').textContent).toBe('No negative prompt information available'); const otherParamsText = document.getElementById('recipeOtherParams').textContent; - expect(otherParamsText).toContain('sampler:'); + expect(otherParamsText).toContain('Sampler:'); expect(otherParamsText).toContain('dpmpp_2m'); - expect(otherParamsText).not.toContain('cfg_scale'); + expect(otherParamsText).not.toContain('CFG'); }); it('filters dirty generation params from recipe modal display', async () => { @@ -1168,8 +1168,8 @@ describe('Interaction-level regression coverage', () => { const otherParamsText = document.getElementById('recipeOtherParams').textContent; expect(document.getElementById('recipePrompt').textContent).toContain('visible prompt'); expect(document.getElementById('recipeNegativePrompt').textContent).toContain('visible negative'); - expect(otherParamsText).toContain('sampler:'); - expect(otherParamsText).toContain('cfg_scale:'); + expect(otherParamsText).toContain('Sampler:'); + expect(otherParamsText).toContain('CFG:'); expect(otherParamsText).not.toContain('Version'); expect(otherParamsText).not.toContain('raw_metadata'); expect(otherParamsText).not.toContain('RNG'); @@ -1222,7 +1222,7 @@ describe('Interaction-level regression coverage', () => { expect(document.getElementById('recipePrompt').textContent).not.toContain('stale prompt'); expect(document.getElementById('recipeNegativePrompt').textContent).toContain('fresh negative'); expect(document.getElementById('recipeNegativePrompt').textContent).not.toContain('stale negative'); - expect(otherParamsText).toContain('cfg_scale:'); + expect(otherParamsText).toContain('CFG:'); expect(otherParamsText).toContain('7'); expect(otherParamsText).not.toContain('3'); }); diff --git a/tests/frontend/utils/genParamsMapper.test.js b/tests/frontend/utils/genParamsMapper.test.js new file mode 100644 index 00000000..4a5a9b82 --- /dev/null +++ b/tests/frontend/utils/genParamsMapper.test.js @@ -0,0 +1,246 @@ +import { describe, it, expect } from 'vitest'; + +// genParamsMapper is pure logic with zero dependencies — safe to import directly +import { + SAMPLER_DISPLAY_TO_INTERNAL, + SCHEDULER_SUFFIXES, + SCHEDULER_ONLY_VALUES, + PARAM_TO_WIDGET_CANDIDATES, + parseCombinedSamplerName, + resolveSamplerScheduler, + findMatchingWidgets, +} from '../../../static/js/utils/genParamsMapper.js'; + +// --------------------------------------------------------------------------- +// Constants sanity +// --------------------------------------------------------------------------- +describe('constants', () => { + it('maps at least the common samplers', () => { + expect(SAMPLER_DISPLAY_TO_INTERNAL['Euler']).toBe('euler'); + expect(SAMPLER_DISPLAY_TO_INTERNAL['Euler a']).toBe('euler_ancestral'); + expect(SAMPLER_DISPLAY_TO_INTERNAL['DPM++ 2M']).toBe('dpmpp_2m'); + expect(SAMPLER_DISPLAY_TO_INTERNAL['DPM++ 2M SDE']).toBe('dpmpp_2m_sde'); + expect(SAMPLER_DISPLAY_TO_INTERNAL['LCM']).toBe('lcm'); + expect(SAMPLER_DISPLAY_TO_INTERNAL['DDIM']).toBe('ddim'); + }); + + it('lists all 9 scheduler suffixes', () => { + expect(SCHEDULER_SUFFIXES).toHaveLength(9); + expect(SCHEDULER_SUFFIXES).toContain('karras'); + expect(SCHEDULER_SUFFIXES).toContain('simple'); + expect(SCHEDULER_SUFFIXES).toContain('exponential'); + }); + + it('marks scheduler-only values', () => { + expect(SCHEDULER_ONLY_VALUES.has('karras')).toBe(true); + expect(SCHEDULER_ONLY_VALUES.has('simple')).toBe(true); + expect(SCHEDULER_ONLY_VALUES.has('euler')).toBe(false); + }); + + it('has widget candidates for all param keys', () => { + expect(PARAM_TO_WIDGET_CANDIDATES.seed).toContain('seed'); + expect(PARAM_TO_WIDGET_CANDIDATES.sampler).toContain('sampler_name'); + expect(PARAM_TO_WIDGET_CANDIDATES.scheduler).toContain('scheduler'); + }); +}); + +// --------------------------------------------------------------------------- +// parseCombinedSamplerName +// --------------------------------------------------------------------------- +describe('parseCombinedSamplerName', () => { + it('parses space-separated sampler + scheduler', () => { + expect(parseCombinedSamplerName('Euler a Karras')).toEqual({ + sampler: 'euler_ancestral', + scheduler: 'karras', + }); + }); + + it('parses DPM++ 2M Karras', () => { + expect(parseCombinedSamplerName('DPM++ 2M Karras')).toEqual({ + sampler: 'dpmpp_2m', + scheduler: 'karras', + }); + }); + + it('parses DPM++ 2M beta', () => { + expect(parseCombinedSamplerName('DPM++ 2M beta')).toEqual({ + sampler: 'dpmpp_2m', + scheduler: 'beta', + }); + }); + + it('parses DPM++ SDE Karras', () => { + expect(parseCombinedSamplerName('DPM++ SDE Karras')).toEqual({ + sampler: 'dpmpp_sde', + scheduler: 'karras', + }); + }); + + it('parses underscore-separated er_sde_beta', () => { + expect(parseCombinedSamplerName('er_sde_beta')).toEqual({ + sampler: 'er_sde', + scheduler: 'beta', + }); + }); + + it('returns null for sampler-only values', () => { + expect(parseCombinedSamplerName('Euler a')).toBeNull(); + expect(parseCombinedSamplerName('LCM')).toBeNull(); + }); + + it('returns null for unrecognised suffix', () => { + expect(parseCombinedSamplerName('Euler something_unknown')).toBeNull(); + }); + + it('returns null for null/empty', () => { + expect(parseCombinedSamplerName(null)).toBeNull(); + expect(parseCombinedSamplerName('')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveSamplerScheduler — the main resolver used by the send feature +// --------------------------------------------------------------------------- +describe('resolveSamplerScheduler', () => { + // --- Category 1: simple display names --- + it('resolves Euler → euler', () => { + expect(resolveSamplerScheduler('Euler')).toEqual({ sampler: 'euler', scheduler: null }); + }); + + it('resolves Euler a → euler_ancestral', () => { + expect(resolveSamplerScheduler('Euler a')).toEqual({ sampler: 'euler_ancestral', scheduler: null }); + }); + + it('resolves DPM++ 2M → dpmpp_2m', () => { + expect(resolveSamplerScheduler('DPM++ 2M')).toEqual({ sampler: 'dpmpp_2m', scheduler: null }); + }); + + it('resolves LCM → lcm', () => { + expect(resolveSamplerScheduler('LCM')).toEqual({ sampler: 'lcm', scheduler: null }); + }); + + // --- Category 2: already-internal names --- + it('passes through lowercase internal names', () => { + expect(resolveSamplerScheduler('euler')).toEqual({ sampler: 'euler', scheduler: null }); + expect(resolveSamplerScheduler('heunpp2')).toEqual({ sampler: 'heunpp2', scheduler: null }); + expect(resolveSamplerScheduler('lcm')).toEqual({ sampler: 'lcm', scheduler: null }); + expect(resolveSamplerScheduler('er_sde')).toEqual({ sampler: 'er_sde', scheduler: null }); + }); + + // --- Category 3: combined names --- + it('resolves Euler a Karras → euler_ancestral + karras', () => { + expect(resolveSamplerScheduler('Euler a Karras')).toEqual({ + sampler: 'euler_ancestral', + scheduler: 'karras', + }); + }); + + it('resolves DPM++ 2M Karras → dpmpp_2m + karras', () => { + expect(resolveSamplerScheduler('DPM++ 2M Karras')).toEqual({ + sampler: 'dpmpp_2m', + scheduler: 'karras', + }); + }); + + // --- Category 4: scheduler-only --- + it('resolves scheduler-only values', () => { + expect(resolveSamplerScheduler('karras')).toEqual({ sampler: null, scheduler: 'karras' }); + expect(resolveSamplerScheduler('simple')).toEqual({ sampler: null, scheduler: 'simple' }); + expect(resolveSamplerScheduler('sgm_uniform')).toEqual({ sampler: null, scheduler: 'sgm_uniform' }); + }); + + // --- Category 5: unrecognised / model-specific --- + it('returns null+null for unrecognised values', () => { + const result = resolveSamplerScheduler('AYS SDXL'); + expect(result.sampler).toBeNull(); + expect(result.scheduler).toBeNull(); + }); + + it('returns null+null for Undefined', () => { + const result = resolveSamplerScheduler('Undefined'); + expect(result.sampler).toBeNull(); + expect(result.scheduler).toBeNull(); + }); + + it('returns null+null for model-specific values', () => { + expect(resolveSamplerScheduler('Seedream-V45').sampler).toBeNull(); + expect(resolveSamplerScheduler('GPT-Image-2').sampler).toBeNull(); + }); + + // --- Category 6: edge cases --- + it('returns null+null for null / empty / whitespace', () => { + expect(resolveSamplerScheduler(null)).toEqual({ sampler: null, scheduler: null }); + expect(resolveSamplerScheduler('')).toEqual({ sampler: null, scheduler: null }); + expect(resolveSamplerScheduler(' ')).toEqual({ sampler: null, scheduler: null }); + }); + + it('handles slash-separated custom format (extracts last segment)', () => { + // "multistep/dpmpp_2m_simple" — extracts last segment but the recursive + // call hits the "already internal name" regex before combined-name parsing, + // so it returns the raw segment as the sampler name. + const result = resolveSamplerScheduler('multistep/dpmpp_2m_simple'); + expect(result.sampler).toBe('dpmpp_2m_simple'); + expect(result.scheduler).toBeNull(); + }); + + it('handles parse-error value (None', () => { + const result = resolveSamplerScheduler('(None'); + expect(result.sampler).toBeNull(); + expect(result.scheduler).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// findMatchingWidgets +// --------------------------------------------------------------------------- +describe('findMatchingWidgets', () => { + const resolved = { + seed: 42, + steps: 30, + cfg: 7, + sampler: 'euler_ancestral', + scheduler: 'karras', + }; + + it('matches seed to seed widget', () => { + const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved); + expect(updates).toContainEqual({ widgetName: 'seed', value: '42' }); + expect(updates).toContainEqual({ widgetName: 'steps', value: '30' }); + expect(updates).toContainEqual({ widgetName: 'cfg', value: '7' }); + expect(updates).toContainEqual({ widgetName: 'sampler_name', value: 'euler_ancestral' }); + expect(updates).toContainEqual({ widgetName: 'scheduler', value: 'karras' }); + }); + + it('skips undefined/null params', () => { + const updates = findMatchingWidgets(['seed', 'steps'], { seed: 42, steps: null, cfg: undefined }); + expect(updates).toHaveLength(1); + expect(updates[0].widgetName).toBe('seed'); + }); + + it('matches noise_seed when seed widget not present', () => { + const updates = findMatchingWidgets(['noise_seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved); + const seedUpdate = updates.find(u => u.widgetName === 'noise_seed'); + expect(seedUpdate).toBeDefined(); + expect(seedUpdate.value).toBe('42'); + }); + + it('matches rgthree-style sampler widget name', () => { + const updates = findMatchingWidgets(['sampler', 'scheduler'], { sampler: 'euler', scheduler: 'karras' }); + expect(updates).toContainEqual({ widgetName: 'sampler', value: 'euler' }); + }); + + it('returns empty array for empty widget list', () => { + expect(findMatchingWidgets([], resolved)).toEqual([]); + expect(findMatchingWidgets(null, resolved)).toEqual([]); + }); + + it('handles case-insensitive widget name matching', () => { + const updates = findMatchingWidgets(['SEED', 'STEPS', 'CFG'], resolved); + expect(updates).toHaveLength(3); + }); + + it('returns updates in param order (seed, steps, cfg, sampler, scheduler)', () => { + const updates = findMatchingWidgets(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'], resolved); + expect(updates.map(u => u.widgetName)).toEqual(['seed', 'steps', 'cfg', 'sampler_name', 'scheduler']); + }); +}); diff --git a/web/comfyui/workflow_registry.js b/web/comfyui/workflow_registry.js index 69f31c6d..fe46fc05 100644 --- a/web/comfyui/workflow_registry.js +++ b/web/comfyui/workflow_registry.js @@ -1,6 +1,7 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; import { getAllGraphNodes, getNodeReference, getNodeFromGraph } from "./utils.js"; +import { ensureLmStyles } from "./lm_styles_loader.js"; const LORA_NODE_CLASSES = new Set([ "Lora Loader (LoraManager)", @@ -21,6 +22,8 @@ app.registerExtension({ name: "LoraManager.WorkflowRegistry", setup() { + ensureLmStyles(); + api.addEventListener("lora_registry_refresh", () => { this.refreshRegistry(); }); @@ -213,5 +216,164 @@ app.registerExtension({ if (typeof app.graph?.setDirtyCanvas === "function") { app.graph.setDirtyCanvas(true, true); } + + // ---- Visual cue: briefly highlight the updated widget ---- + this.flashWidget(node, targetWidget); + }, + + /** + * Add a temporary visual highlight to a widget after its value is updated. + * - Vue Nodes mode: change value text color on all non-button elements + * - Canvas mode: define text_color on widget instance (value text only) + * Highlight fades after 10 seconds or on hover (Vue mode only). + */ + flashWidget(node, widget) { + const FLASH_DURATION = 3000; + const flashEnd = Date.now() + FLASH_DURATION; + const nodeId = node.id; + + // Colors consistent with canvas mode + const VALUE_COLOR = '#66B3FF'; + + // Helper: find the widget row in the DOM (by label text matching widget name) + const findRowEl = () => { + const container = document.querySelector(`[data-node-id="${nodeId}"]`); + if (!container) return null; + const all = container.querySelectorAll('[data-testid="node-widget"]'); + for (const w of all) { + const label = w.querySelector('[data-testid="widget-layout-field-label"]'); + if (label && label.textContent.trim() === widget.name) { + return w; + } + } + return null; + }; + + // Helper: get label and ring elements from a widget row + const getLabelAndRing = (row) => { + if (!row) return { labelEl: null, ringEl: null }; + const labelEl = row.querySelector('[data-testid="widget-layout-field-label"]'); + const ringEl = labelEl?.nextElementSibling + || row.querySelector('.flex-1.relative.min-w-0') + || row.querySelector('.rounded-lg.transition-all') + || null; + return { labelEl, ringEl }; + }; + + const applyFlash = (row) => { + if (!row) return; + const { ringEl } = getLabelAndRing(row); + if (ringEl) { + const innerRing = ringEl.querySelector('.rounded-lg.transition-all'); + if (innerRing) { + // Target value-displaying elements for all widget types: + // NumberWidget: spinbutton input + // ComboWidget: combobox button + // Text widgets (CLIPTextEncode, Prompt, etc.): textarea / text input + innerRing.querySelectorAll( + 'input, textarea, [role="combobox"]' + ).forEach(el => { + el.style.color = VALUE_COLOR; + }); + } + } + }; + + const removeFlash = (row) => { + if (!row) return; + const { ringEl } = getLabelAndRing(row); + if (ringEl) { + const innerRing = ringEl.querySelector('.rounded-lg.transition-all'); + if (innerRing) { + // Clear color from all inputs/textarea/combobox + innerRing.querySelectorAll( + 'input, textarea, [role="combobox"]' + ).forEach(el => { + el.style.color = ''; + }); + } + } + }; + + // --- Try Vue Nodes mode first --- + const nodeEl = document.querySelector(`[data-node-id="${nodeId}"]`); + if (nodeEl) { + // Apply immediately + const initialRow = findRowEl(); + applyFlash(initialRow); + + // rAF loop: re-apply after Vue re-renders + let rafId = null; + const poll = () => { + if (Date.now() >= flashEnd) { + const lastRow = findRowEl(); + removeFlash(lastRow); + rafId = null; + return; + } + const currentRow = findRowEl(); + applyFlash(currentRow); + rafId = requestAnimationFrame(poll); + }; + rafId = requestAnimationFrame(poll); + + // Cleanup timeout + const timeoutId = setTimeout(() => { + if (rafId) cancelAnimationFrame(rafId); + const lastRow = findRowEl(); + removeFlash(lastRow); + }, FLASH_DURATION); + + // Hover dismissal via event delegation on node container + const hoverHandler = (e) => { + const row = findRowEl(); + if (row && row.contains(e.target)) { + clearTimeout(timeoutId); + if (rafId) cancelAnimationFrame(rafId); + removeFlash(row); + nodeEl.removeEventListener('mouseover', hoverHandler); + } + }; + nodeEl.addEventListener('mouseover', hoverHandler); + + return; // Vue mode done + } + + // --- Canvas mode: change widget value text color via instance property shadowing --- + // BaseWidget reads text_color (value) from prototype getter. Defining an own + // property on the instance shadows the getter without monkey-patching. + // Works for ALL widget types — only value text is changed, label is left alone. + Object.defineProperty(widget, 'text_color', { + value: VALUE_COLOR, + writable: true, + configurable: true, + }); + + if (typeof node.setDirtyCanvas === "function") { + node.setDirtyCanvas(true); + } + + // Track this widget so it gets restored alongside others on the same node + if (!node._lmFlashedWidgets) node._lmFlashedWidgets = []; + if (!node._lmFlashedWidgets.includes(widget)) { + node._lmFlashedWidgets.push(widget); + } + + // Single per-node timer that restores ALL flashed widgets at once. + // Subsequent calls reset the timer but don't orphan previous widgets. + if (node._lmFlashCleanup) { + clearTimeout(node._lmFlashCleanup); + } + node._lmFlashCleanup = setTimeout(() => { + for (const w of (node._lmFlashedWidgets || [])) { + delete w.text_color; + delete w.secondary_text_color; + } + delete node._lmFlashedWidgets; + delete node._lmFlashCleanup; + if (typeof node.setDirtyCanvas === "function") { + node.setDirtyCanvas(true); + } + }, FLASH_DURATION); }, });