From 39c083db7987abb64145d5bfc028988671a6213e Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sun, 12 Apr 2026 21:25:54 +0800 Subject: [PATCH] fix(recipes): preserve legacy gen params in modal flows --- py/services/recipe_scanner.py | 34 +++++- static/js/components/RecipeModal.js | 89 ++++++++++++-- .../contextMenu.interactions.test.js | 111 ++++++++++++++++++ tests/services/test_recipe_scanner.py | 89 ++++++++++++++ 4 files changed, 312 insertions(+), 11 deletions(-) diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index 62608f44..240fa752 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -952,6 +952,30 @@ class RecipeScanner: except Exception as exc: logger.debug("Failed to update FTS index for recipe: %s", exc) + @staticmethod + def _normalize_recipe_gen_params(recipe_data: Dict[str, Any]) -> Dict[str, Any]: + """Return a recipe copy with normalized generation parameter aliases added.""" + + normalized_recipe = dict(recipe_data) + gen_params = recipe_data.get("gen_params") + if not isinstance(gen_params, dict): + return normalized_recipe + + normalized_gen_params = dict(gen_params) + for key, value in gen_params.items(): + if value in (None, ""): + continue + + normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(key, key) + if normalized_key not in GenParamsMerger.ALLOWED_KEYS: + continue + + if normalized_gen_params.get(normalized_key) in (None, ""): + normalized_gen_params[normalized_key] = value + + normalized_recipe["gen_params"] = normalized_gen_params + return normalized_recipe + async def _enrich_cache_metadata(self) -> None: """Perform remote metadata enrichment after the initial scan.""" @@ -1345,6 +1369,7 @@ class RecipeScanner: # Ensure gen_params exists if "gen_params" not in recipe_data: recipe_data["gen_params"] = {} + recipe_data = self._normalize_recipe_gen_params(recipe_data) # Update lora information with local paths and availability lora_metadata_updated = await self._update_lora_information(recipe_data) @@ -2055,7 +2080,10 @@ class RecipeScanner: end_idx = min(start_idx + page_size, total_items) # Get paginated items - paginated_items = filtered_data[start_idx:end_idx] + paginated_items = [ + self._normalize_recipe_gen_params(item) + for item in filtered_data[start_idx:end_idx] + ] # Add inLibrary information and URLs for each recipe for item in paginated_items: @@ -2116,7 +2144,7 @@ class RecipeScanner: # Prefer the on-disk recipe JSON for fields that are not persisted in the # SQLite cache yet, such as source_path. - merged_recipe = {**recipe} + merged_recipe = self._normalize_recipe_gen_params({**recipe}) recipe_json = await self._load_recipe_json(recipe_id) if recipe_json: for field in ("source_path", "checkpoint", "loras", "gen_params"): @@ -2181,7 +2209,7 @@ class RecipeScanner: if not isinstance(recipe_data, dict): return None - return recipe_data + return self._normalize_recipe_gen_params(recipe_data) def _format_file_url(self, file_path: str) -> str: """Format file path as URL for serving in web UI""" diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index 59fb83b7..37e3bf45 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -7,6 +7,36 @@ import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js'; import { downloadManager } from '../managers/DownloadManager.js'; import { MODEL_TYPES } from '../api/apiConfig.js'; +const ALLOWED_GEN_PARAM_KEYS = new Set([ + 'prompt', + 'negative_prompt', + 'steps', + 'sampler', + 'cfg_scale', + 'seed', + 'size', + 'clip_skip', + 'denoising_strength', +]); + +const GEN_PARAM_NORMALIZATION = { + cfg: 'cfg_scale', + cfgScale: 'cfg_scale', + clipSkip: 'clip_skip', + negativePrompt: 'negative_prompt', + Sampler: 'sampler', + sampler_name: 'sampler', + scheduler: 'sampler', + Steps: 'steps', + Seed: 'seed', + Size: 'size', + Prompt: 'prompt', + 'Negative prompt': 'negative_prompt', + 'Cfg scale': 'cfg_scale', + 'Clip skip': 'clip_skip', + 'Denoising strength': 'denoising_strength', +}; + class RecipeModal { constructor() { this.promptEditorState = {}; @@ -321,8 +351,13 @@ class RecipeModal { mediaContainer.appendChild(sourceUrlContainer); mediaContainer.appendChild(sourceUrlEditor); - // Set up event listeners for source URL functionality + // Delay binding slightly so modal layout is stable, but skip if this render was torn down. + const sourceUrlContainerRef = sourceUrlContainer; + const sourceUrlEditorRef = sourceUrlEditor; setTimeout(() => { + if (!document.body.contains(sourceUrlContainerRef) || !document.body.contains(sourceUrlEditorRef)) { + return; + } this.setupSourceUrlHandlers(); }, 50); } @@ -562,18 +597,19 @@ class RecipeModal { const promptInput = document.getElementById('recipePromptInput'); const negativePromptInput = document.getElementById('recipeNegativePromptInput'); const promptFieldsOnly = options.promptFieldsOnly === true; + const sanitizedGenParams = this.sanitizeGenParams(genParams); - if (genParams) { + if (sanitizedGenParams) { if (!promptFieldsOnly) { - this.renderPromptContent(promptElement, genParams.prompt, 'No prompt information available'); - this.renderPromptContent(negativePromptElement, genParams.negative_prompt, 'No negative prompt information available'); + this.renderPromptContent(promptElement, sanitizedGenParams.prompt, 'No prompt information available'); + this.renderPromptContent(negativePromptElement, sanitizedGenParams.negative_prompt, 'No negative prompt information available'); if (promptInput) { - promptInput.value = genParams.prompt || ''; + promptInput.value = sanitizedGenParams.prompt || ''; } if (negativePromptInput) { - negativePromptInput.value = genParams.negative_prompt || ''; + negativePromptInput.value = sanitizedGenParams.negative_prompt || ''; } } @@ -581,7 +617,7 @@ class RecipeModal { otherParamsElement.innerHTML = ''; const excludedParams = ['prompt', 'negative_prompt']; - for (const [key, value] of Object.entries(genParams)) { + for (const [key, value] of Object.entries(sanitizedGenParams)) { if (!excludedParams.includes(key) && value !== undefined && value !== null) { const paramTag = document.createElement('div'); paramTag.className = 'param-tag'; @@ -612,6 +648,43 @@ class RecipeModal { } } + sanitizeGenParams(genParams) { + if (!genParams || typeof genParams !== 'object') { + return null; + } + + const sanitized = {}; + + for (const [key, value] of Object.entries(genParams)) { + if (value === undefined || value === null || value === '') { + continue; + } + + if (!ALLOWED_GEN_PARAM_KEYS.has(key)) { + continue; + } + + sanitized[key] = value; + } + + for (const [key, value] of Object.entries(genParams)) { + if (value === undefined || value === null || value === '') { + continue; + } + + const normalizedKey = GEN_PARAM_NORMALIZATION[key] || key; + if (!ALLOWED_GEN_PARAM_KEYS.has(normalizedKey)) { + continue; + } + + if (sanitized[normalizedKey] === undefined || sanitized[normalizedKey] === null || sanitized[normalizedKey] === '') { + sanitized[normalizedKey] = value; + } + } + + return sanitized; + } + syncResourcesSection(recipe = {}) { const checkpointContainer = document.getElementById('recipeCheckpoint'); const resourceDivider = document.getElementById('recipeResourceDivider'); @@ -1117,7 +1190,7 @@ class RecipeModal { const currentGenParams = this.currentRecipe.gen_params || {}; const nextValue = input.value.trim() === '' ? '' : input.value; - const currentValue = currentGenParams[config.field] || ''; + const currentValue = this.sanitizeGenParams(currentGenParams)?.[config.field] || ''; if (nextValue === currentValue) { this.clearFieldDirty(config.field); diff --git a/tests/frontend/components/contextMenu.interactions.test.js b/tests/frontend/components/contextMenu.interactions.test.js index 4d351772..dcc4f2fa 100644 --- a/tests/frontend/components/contextMenu.interactions.test.js +++ b/tests/frontend/components/contextMenu.interactions.test.js @@ -1165,6 +1165,113 @@ describe('Interaction-level regression coverage', () => { expect(otherParamsText).not.toContain('cfg_scale'); }); + it('filters dirty generation params from recipe modal display', async () => { + document.body.innerHTML = ` + + `; + + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + + recipeModal.showRecipeDetails({ + id: '', + file_path: '/recipes/dirty-gen-params.json', + title: 'Dirty Gen Params Recipe', + tags: [], + file_url: '', + preview_url: '', + source_path: '', + gen_params: { + Prompt: 'visible prompt', + negativePrompt: 'visible negative', + Sampler: 'euler', + cfgScale: 7, + Version: 'ComfyUI', + raw_metadata: { prompt: 'hidden prompt' }, + RNG: 'cpu', + }, + loras: [], + }); + + 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).not.toContain('Version'); + expect(otherParamsText).not.toContain('raw_metadata'); + expect(otherParamsText).not.toContain('RNG'); + }); + + it('prefers canonical generation params over legacy aliases in modal display', async () => { + document.body.innerHTML = ` + + `; + + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + + recipeModal.showRecipeDetails({ + id: '', + file_path: '/recipes/canonical-wins.json', + title: 'Canonical Wins Recipe', + tags: [], + file_url: '', + preview_url: '', + source_path: '', + gen_params: { + Prompt: 'stale prompt', + prompt: 'fresh prompt', + negativePrompt: 'stale negative', + negative_prompt: 'fresh negative', + cfgScale: 3, + cfg_scale: 7, + }, + loras: [], + }); + + const otherParamsText = document.getElementById('recipeOtherParams').textContent; + expect(document.getElementById('recipePrompt').textContent).toContain('fresh prompt'); + 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('7'); + expect(otherParamsText).not.toContain('3'); + }); + it('replaces cached checkpoint and loras with hydrated resources', async () => { fetchRecipeDetailsMock.mockResolvedValueOnce({ id: 'recipe-resources', @@ -1854,6 +1961,8 @@ describe('Interaction-level regression coverage', () => { negative_prompt: 'keep negative', steps: 30, cfg_scale: 7, + raw_metadata: { prompt: 'preserve me' }, + Version: 'ComfyUI', }, loras: [], }); @@ -1882,6 +1991,8 @@ describe('Interaction-level regression coverage', () => { negative_prompt: 'keep negative', steps: 30, cfg_scale: 7, + raw_metadata: { prompt: 'preserve me' }, + Version: 'ComfyUI', }, }, { listFilePath: '/recipes/prompt.json' } diff --git a/tests/services/test_recipe_scanner.py b/tests/services/test_recipe_scanner.py index 3c9a666e..b53a1dec 100644 --- a/tests/services/test_recipe_scanner.py +++ b/tests/services/test_recipe_scanner.py @@ -402,6 +402,61 @@ async def test_get_recipe_by_id_merges_recipe_json_details(recipe_scanner): assert recipe["gen_params"]["prompt"] == "prompt from json" +@pytest.mark.asyncio +async def test_get_recipe_by_id_normalizes_gen_params_aliases_without_dropping_metadata( + recipe_scanner, +): + scanner, _ = recipe_scanner + recipes_dir = Path(scanner.recipes_dir) + recipe_id = "dirty-json-gen-params" + recipe_json_path = recipes_dir / f"{recipe_id}.recipe.json" + recipe_json_path.write_text( + json.dumps( + { + "id": recipe_id, + "file_path": "/tmp/dirty-json-gen-params.png", + "title": "Dirty Recipe", + "gen_params": { + "Prompt": "prompt from json", + "negativePrompt": "negative from json", + "cfgScale": 7, + "raw_metadata": {"prompt": "nested"}, + "Version": "ComfyUI", + "RNG": "cpu", + }, + "loras": [], + } + ), + encoding="utf-8", + ) + + scanner._cache.raw_data = [ + { + "id": recipe_id, + "file_path": "/tmp/dirty-json-gen-params.png", + "title": "Cached Recipe", + "folder": "", + "modified": 0.0, + "created_date": 0.0, + "loras": [], + "gen_params": {"prompt": "cached prompt", "raw_metadata": {"bad": True}}, + } + ] + + recipe = await scanner.get_recipe_by_id(recipe_id) + + assert recipe is not None + assert recipe["gen_params"]["Prompt"] == "prompt from json" + assert recipe["gen_params"]["negativePrompt"] == "negative from json" + assert recipe["gen_params"]["cfgScale"] == 7 + assert recipe["gen_params"]["raw_metadata"] == {"prompt": "nested"} + assert recipe["gen_params"]["Version"] == "ComfyUI" + assert recipe["gen_params"]["RNG"] == "cpu" + assert recipe["gen_params"]["prompt"] == "prompt from json" + assert recipe["gen_params"]["negative_prompt"] == "negative from json" + assert recipe["gen_params"]["cfg_scale"] == 7 + + @pytest.mark.asyncio async def test_get_recipe_by_id_prefers_json_file_path(recipe_scanner): scanner, _ = recipe_scanner @@ -528,6 +583,40 @@ async def test_get_paginated_data_filters_by_checkpoint_hash(recipe_scanner): assert [item["id"] for item in result["items"]] == ["checkpoint-match"] +@pytest.mark.asyncio +async def test_get_paginated_data_normalizes_gen_params_aliases_without_dropping_metadata( + recipe_scanner, +): + scanner, _ = recipe_scanner + await scanner.add_recipe( + { + "id": "dirty-listing", + "file_path": str(Path(config.loras_roots[0]) / "dirty-listing.webp"), + "title": "Dirty Listing", + "modified": 0.0, + "created_date": 0.0, + "loras": [], + "gen_params": { + "Prompt": "a beautiful forest landscape", + "cfgScale": 7, + "Version": "ComfyUI", + "raw_metadata": {"bad": True}, + }, + } + ) + await asyncio.sleep(0) + + result = await scanner.get_paginated_data(page=1, page_size=10) + item = next(entry for entry in result["items"] if entry["id"] == "dirty-listing") + + assert item["gen_params"]["Prompt"] == "a beautiful forest landscape" + assert item["gen_params"]["cfgScale"] == 7 + assert item["gen_params"]["Version"] == "ComfyUI" + assert item["gen_params"]["raw_metadata"] == {"bad": True} + assert item["gen_params"]["prompt"] == "a beautiful forest landscape" + assert item["gen_params"]["cfg_scale"] == 7 + + @pytest.mark.asyncio async def test_get_recipes_for_checkpoint_matches_hash_case_insensitively(recipe_scanner): scanner, _ = recipe_scanner