From 3e9445934065bc68a4b4a05fbadcb40566a3ce7e Mon Sep 17 00:00:00 2001
From: Comfy Org PR Bot
Date: Sat, 9 May 2026 11:07:47 +0900
Subject: [PATCH 01/48] 1.45.2 (#12096)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Patch version increment to 1.45.2
**Base branch:** `main`
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12096-1-45-2-35b6d73d36508193be00c1c878d42c2a)
by [Unito](https://www.unito.io)
---------
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions
---
package.json | 2 +-
src/locales/ar/main.json | 10 +--
src/locales/ar/nodeDefs.json | 68 ++++++++++++++++++++
src/locales/en/main.json | 18 +++---
src/locales/en/nodeDefs.json | 106 ++++++++++++++++++++++++++------
src/locales/es/main.json | 10 +--
src/locales/es/nodeDefs.json | 68 ++++++++++++++++++++
src/locales/fa/main.json | 10 +--
src/locales/fa/nodeDefs.json | 68 ++++++++++++++++++++
src/locales/fr/main.json | 10 +--
src/locales/fr/nodeDefs.json | 68 ++++++++++++++++++++
src/locales/ja/main.json | 10 +--
src/locales/ja/nodeDefs.json | 68 ++++++++++++++++++++
src/locales/ko/main.json | 10 +--
src/locales/ko/nodeDefs.json | 68 ++++++++++++++++++++
src/locales/pt-BR/main.json | 10 +--
src/locales/pt-BR/nodeDefs.json | 68 ++++++++++++++++++++
src/locales/ru/main.json | 10 +--
src/locales/ru/nodeDefs.json | 68 ++++++++++++++++++++
src/locales/tr/main.json | 10 +--
src/locales/tr/nodeDefs.json | 68 ++++++++++++++++++++
src/locales/zh-TW/main.json | 10 +--
src/locales/zh-TW/nodeDefs.json | 68 ++++++++++++++++++++
src/locales/zh/main.json | 10 +--
src/locales/zh/nodeDefs.json | 68 ++++++++++++++++++++
25 files changed, 876 insertions(+), 108 deletions(-)
diff --git a/package.json b/package.json
index eff283dc2d..275f3c49d5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
- "version": "1.45.1",
+ "version": "1.45.2",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
diff --git a/src/locales/ar/main.json b/src/locales/ar/main.json
index bd8ededb22..948f2818ba 100644
--- a/src/locales/ar/main.json
+++ b/src/locales/ar/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "مُشَفِّر الصوت",
"AUDIO_ENCODER_OUTPUT": "مخرجات مُشَفِّر الصوت",
"AUDIO_RECORD": "تسجيل صوتي",
+ "BACKGROUND_REMOVAL": "إزالة الخلفية",
"BOOLEAN": "منطقي",
"BOUNDING_BOX": "مربع التحديد",
"CAMERA_CONTROL": "تحكم الكاميرا",
@@ -2284,15 +2285,13 @@
"Vidu": "فيدو",
"Wan": "وان",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_للاختبار",
"advanced": "متقدم",
"animation": "الرسوم المتحركة",
- "api": "API",
"api node": "عقدة API",
"attention_experiments": "تجارب الانتباه",
"audio": "صوت",
+ "background removal": "إزالة الخلفية",
"batch": "دفعة",
- "camera": "كاميرا",
"chroma_radiance": "تألق اللون",
"clip": "clip",
"color": "لون",
@@ -2301,7 +2300,6 @@
"cond pair": "زوج شرطي",
"cond single": "شرط فردي",
"conditioning": "التكييف",
- "context": "سياق",
"controlnet": "كونترول نت",
"create": "إنشاء",
"custom_sampling": "تجميع مخصص",
@@ -2310,6 +2308,7 @@
"deprecated": "مهمل",
"detection": "الكشف",
"edit_models": "تحرير النماذج",
+ "experimental": "تجريبي",
"flux": "تدفق",
"gligen": "gligen",
"guidance": "التوجيه",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "قناع",
- "math": "رياضيات",
"model": "نموذج",
"model_merging": "دمج النماذج",
"model_patches": "تصحيحات النموذج",
@@ -2342,7 +2340,6 @@
"save": "حفظ",
"schedulers": "الجدولة",
"scheduling": "الجدولة",
- "sd": "sd",
"sd3": "sd3",
"shader": "shader",
"sigmas": "سيجمات",
@@ -2350,7 +2347,6 @@
"style_model": "نموذج النمط",
"supir": "supir",
"text": "نص",
- "textgen": "textgen",
"training": "تدريب",
"transform": "تحويل",
"unet": "unet",
diff --git a/src/locales/ar/nodeDefs.json b/src/locales/ar/nodeDefs.json
index 1f9980faf4..17f0b7a092 100644
--- a/src/locales/ar/nodeDefs.json
+++ b/src/locales/ar/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "حجم الدفعة"
+ },
+ "height": {
+ "name": "الارتفاع"
+ },
+ "length": {
+ "name": "الطول"
+ },
+ "model": {
+ "name": "model"
+ },
+ "start_image": {
+ "name": "الصورة_البدء"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "العرض"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "إضافة ضجيج",
"inputs": {
@@ -8011,6 +8045,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "تحميل نموذج إزالة الخلفية",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "اسم_إزالة_الخلفية",
+ "tooltip": "النموذج المستخدم لإزالة الخلفيات من الصور"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "نموذج_الخلفية",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "تحميل صورة",
"inputs": {
@@ -13460,6 +13509,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "إزالة الخلفية",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "نموذج_إزالة_الخلفية",
+ "tooltip": "نموذج إزالة الخلفية المستخدم لتوليد القناع"
+ },
+ "image": {
+ "name": "الصورة",
+ "tooltip": "صورة الإدخال لإزالة الخلفية منها"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "القناع",
+ "tooltip": "قناع المقدمة المُنتج"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "إعادة تهيئة CFG",
"inputs": {
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index 75b947fb10..85d6009b5f 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -1650,7 +1650,7 @@
"Directories": "Directories"
},
"nodeCategories": {
- "_for_testing": "_for_testing",
+ "experimental": "experimental",
"custom_sampling": "custom_sampling",
"noise": "noise",
"dataset": "dataset",
@@ -1658,8 +1658,9 @@
"image": "image",
"sampling": "sampling",
"schedulers": "schedulers",
- "audio": "audio",
"conditioning": "conditioning",
+ "video_models": "video_models",
+ "audio": "audio",
"loaders": "loaders",
"guiders": "guiders",
"batch": "batch",
@@ -1682,17 +1683,14 @@
"postprocessing": "postprocessing",
"hooks": "hooks",
"combine": "combine",
- "math": "math",
"logic": "logic",
"cond single": "cond single",
- "context": "context",
"controlnet": "controlnet",
"inpaint": "inpaint",
"scheduling": "scheduling",
"create": "create",
"deprecated": "deprecated",
"detection": "detection",
- "": "",
"debug": "debug",
"model": "model",
"ElevenLabs": "ElevenLabs",
@@ -1703,14 +1701,14 @@
"unet": "unet",
"sigmas": "sigmas",
"BFL": "BFL",
+ "": "",
"Gemini": "Gemini",
- "video_models": "video_models",
"gligen": "gligen",
"shader": "shader",
"Grok": "Grok",
"Wan": "Wan",
"HitPaw": "HitPaw",
- "sd": "sd",
+ "3d_models": "3d_models",
"Ideogram": "Ideogram",
"transform": "transform",
"color": "color",
@@ -1737,27 +1735,24 @@
"Quiver": "Quiver",
"Recraft": "Recraft",
"edit_models": "edit_models",
+ "background removal": "background removal",
"Reve": "Reve",
"Rodin": "Rodin",
"Runway": "Runway",
"animation": "animation",
- "api": "api",
"save": "save",
"upscale_diffusion": "upscale_diffusion",
"clip": "clip",
"Sonilo": "Sonilo",
"Stability AI": "Stability AI",
"stable_cascade": "stable_cascade",
- "3d_models": "3d_models",
"style_model": "style_model",
"supir": "supir",
"Tencent": "Tencent",
- "textgen": "textgen",
"Topaz": "Topaz",
"Tripo": "Tripo",
"Veo": "Veo",
"Vidu": "Vidu",
- "camera": "camera",
"WaveSpeed": "WaveSpeed",
"zimage": "zimage"
},
@@ -1767,6 +1762,7 @@
"AUDIO_ENCODER": "AUDIO_ENCODER",
"AUDIO_ENCODER_OUTPUT": "AUDIO_ENCODER_OUTPUT",
"AUDIO_RECORD": "AUDIO_RECORD",
+ "BACKGROUND_REMOVAL": "BACKGROUND_REMOVAL",
"BOOLEAN": "BOOLEAN",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "CAMERA_CONTROL",
diff --git a/src/locales/en/nodeDefs.json b/src/locales/en/nodeDefs.json
index 47882a007d..407b77bdd2 100644
--- a/src/locales/en/nodeDefs.json
+++ b/src/locales/en/nodeDefs.json
@@ -141,6 +141,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "model": {
+ "name": "model"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "start_image": {
+ "name": "start_image"
+ },
+ "width": {
+ "name": "width"
+ },
+ "height": {
+ "name": "height"
+ },
+ "length": {
+ "name": "length"
+ },
+ "batch_size": {
+ "name": "batch_size"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AudioAdjustVolume": {
"display_name": "Audio Adjust Volume",
"inputs": {
@@ -196,7 +230,7 @@
}
},
"AudioEncoderLoader": {
- "display_name": "AudioEncoderLoader",
+ "display_name": "Load Audio Encoder",
"inputs": {
"audio_encoder_name": {
"name": "audio_encoder_name"
@@ -4290,7 +4324,7 @@
}
},
"GLIGENLoader": {
- "display_name": "GLIGENLoader",
+ "display_name": "Load GLIGEN Model",
"inputs": {
"gligen_name": {
"name": "gligen_name"
@@ -4887,7 +4921,7 @@
}
},
"HunyuanRefinerLatent": {
- "display_name": "HunyuanRefinerLatent",
+ "display_name": "Hunyuan Latent Refiner",
"inputs": {
"positive": {
"name": "positive"
@@ -4992,7 +5026,7 @@
}
},
"HunyuanVideo15SuperResolution": {
- "display_name": "HunyuanVideo15SuperResolution",
+ "display_name": "Hunyuan Video 1.5 Super Resolution",
"inputs": {
"positive": {
"name": "positive"
@@ -5032,7 +5066,7 @@
}
},
"HypernetworkLoader": {
- "display_name": "HypernetworkLoader",
+ "display_name": "Load Hypernetwork",
"inputs": {
"model": {
"name": "model"
@@ -5328,9 +5362,6 @@
"ImageCompositeMasked": {
"display_name": "Image Composite Masked",
"inputs": {
- "destination": {
- "name": "destination"
- },
"source": {
"name": "source"
},
@@ -5343,6 +5374,9 @@
"resize_source": {
"name": "resize_source"
},
+ "destination": {
+ "name": "destination"
+ },
"mask": {
"name": "mask"
}
@@ -5592,7 +5626,7 @@
}
},
"ImageQuantize": {
- "display_name": "ImageQuantize",
+ "display_name": "Quantize Image",
"inputs": {
"image": {
"name": "image"
@@ -5724,7 +5758,7 @@
}
},
"ImageSharpen": {
- "display_name": "ImageSharpen",
+ "display_name": "Sharpen Image",
"inputs": {
"image": {
"name": "image"
@@ -7598,6 +7632,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "Load Background Removal Model",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "bg_removal_name",
+ "tooltip": "The model used to remove backgrounds from images"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "bg_model",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "Load Image",
"inputs": {
@@ -8259,7 +8308,7 @@
}
},
"LTXVPreprocess": {
- "display_name": "LTXVPreprocess",
+ "display_name": "LTXV Preprocess",
"inputs": {
"image": {
"name": "image"
@@ -12189,7 +12238,7 @@
}
},
"PerpNeg": {
- "display_name": "Perp-Neg (DEPRECATED by PerpNegGuider)",
+ "display_name": "Perp-Neg (DEPRECATED by Perp-Neg Guider)",
"inputs": {
"model": {
"name": "model"
@@ -12208,7 +12257,7 @@
}
},
"PerpNegGuider": {
- "display_name": "PerpNegGuider",
+ "display_name": "Perp-Neg Guider",
"inputs": {
"model": {
"name": "model"
@@ -13432,6 +13481,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "Remove Background",
+ "inputs": {
+ "image": {
+ "name": "image",
+ "tooltip": "Input image to remove the background from"
+ },
+ "bg_removal_model": {
+ "name": "bg_removal_model",
+ "tooltip": "Background removal model used to generate the mask"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "mask",
+ "tooltip": "Generated foreground mask"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
@@ -14759,7 +14827,7 @@
}
},
"SaveImageWebsocket": {
- "display_name": "SaveImageWebsocket",
+ "display_name": "Save Image (Websocket)",
"inputs": {
"images": {
"name": "images"
@@ -16681,7 +16749,7 @@
}
},
"TextGenerate": {
- "display_name": "TextGenerate",
+ "display_name": "Generate Text",
"inputs": {
"clip": {
"name": "clip"
@@ -16743,7 +16811,7 @@
}
},
"TextGenerateLTX2Prompt": {
- "display_name": "TextGenerateLTX2Prompt",
+ "display_name": "Generate LTX2 Prompt",
"inputs": {
"clip": {
"name": "clip"
@@ -17569,7 +17637,7 @@
}
},
"unCLIPCheckpointLoader": {
- "display_name": "unCLIPCheckpointLoader",
+ "display_name": "Load unCLIP Checkpoint",
"inputs": {
"ckpt_name": {
"name": "ckpt_name"
@@ -18759,7 +18827,7 @@
}
},
"VoxelToMesh": {
- "display_name": "VoxelToMesh",
+ "display_name": "Voxel to Mesh",
"inputs": {
"voxel": {
"name": "voxel"
@@ -18778,7 +18846,7 @@
}
},
"VoxelToMeshBasic": {
- "display_name": "VoxelToMeshBasic",
+ "display_name": "Voxel to Mesh (Basic)",
"inputs": {
"voxel": {
"name": "voxel"
diff --git a/src/locales/es/main.json b/src/locales/es/main.json
index f1da390e45..5362f01042 100644
--- a/src/locales/es/main.json
+++ b/src/locales/es/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "CODIFICADOR_AUDIO",
"AUDIO_ENCODER_OUTPUT": "SALIDA_CODIFICADOR_AUDIO",
"AUDIO_RECORD": "GRABACIÓN_AUDIO",
+ "BACKGROUND_REMOVAL": "ELIMINACIÓN_DE_FONDO",
"BOOLEAN": "BOOLEANO",
"BOUNDING_BOX": "CUADRO DELIMITADOR",
"CAMERA_CONTROL": "CONTROL DE CÁMARA",
@@ -2284,15 +2285,13 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_para_pruebas",
"advanced": "avanzado",
"animation": "animación",
- "api": "api",
"api node": "nodo api",
"attention_experiments": "experimentos_de_atención",
"audio": "audio",
+ "background removal": "eliminación de fondo",
"batch": "lote",
- "camera": "cámara",
"chroma_radiance": "chroma_radiance",
"clip": "clip",
"color": "color",
@@ -2301,7 +2300,6 @@
"cond pair": "par_cond",
"cond single": "cond único",
"conditioning": "acondicionamiento",
- "context": "contexto",
"controlnet": "controlnet",
"create": "crear",
"custom_sampling": "muestreo_personalizado",
@@ -2310,6 +2308,7 @@
"deprecated": "obsoleto",
"detection": "detección",
"edit_models": "editar_modelos",
+ "experimental": "experimental",
"flux": "flux",
"gligen": "gligen",
"guidance": "orientación",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "mask",
- "math": "matemáticas",
"model": "modelo",
"model_merging": "fusión_de_modelos",
"model_patches": "parches_de_modelo",
@@ -2342,7 +2340,6 @@
"save": "guardar",
"schedulers": "programadores",
"scheduling": "programación",
- "sd": "sd",
"sd3": "sd3",
"shader": "shader",
"sigmas": "sigmas",
@@ -2350,7 +2347,6 @@
"style_model": "modelo_de_estilo",
"supir": "supir",
"text": "texto",
- "textgen": "textgen",
"training": "entrenamiento",
"transform": "transformar",
"unet": "unet",
diff --git a/src/locales/es/nodeDefs.json b/src/locales/es/nodeDefs.json
index 4600adfc14..38e8db0c86 100644
--- a/src/locales/es/nodeDefs.json
+++ b/src/locales/es/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "tamaño_de_lote"
+ },
+ "height": {
+ "name": "alto"
+ },
+ "length": {
+ "name": "longitud"
+ },
+ "model": {
+ "name": "modelo"
+ },
+ "start_image": {
+ "name": "imagen_inicial"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "ancho"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "AñadirRuido",
"inputs": {
@@ -8011,6 +8045,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "Cargar modelo de eliminación de fondo",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "nombre_del_modelo_de_eliminación_de_fondo",
+ "tooltip": "El modelo utilizado para eliminar fondos de imágenes"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "modelo_de_fondo",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "Cargar Imagen",
"inputs": {
@@ -13460,6 +13509,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "Eliminar fondo",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "modelo_de_eliminación_de_fondo",
+ "tooltip": "Modelo de eliminación de fondo utilizado para generar la máscara"
+ },
+ "image": {
+ "name": "imagen",
+ "tooltip": "Imagen de entrada para eliminar el fondo"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "máscara",
+ "tooltip": "Máscara de primer plano generada"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
diff --git a/src/locales/fa/main.json b/src/locales/fa/main.json
index 6f7a5ff67b..a78c38b643 100644
--- a/src/locales/fa/main.json
+++ b/src/locales/fa/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "رمزگذار صوت",
"AUDIO_ENCODER_OUTPUT": "خروجی رمزگذار صوت",
"AUDIO_RECORD": "ضبط صوت",
+ "BACKGROUND_REMOVAL": "حذف پسزمینه",
"BOOLEAN": "بولی",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "کنترل دوربین",
@@ -2284,15 +2285,13 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_for_testing",
"advanced": "پیشرفته",
"animation": "انیمیشن",
- "api": "API",
"api node": "گره API",
"attention_experiments": "آزمایشهای توجه",
"audio": "صدا",
+ "background removal": "حذف پسزمینه",
"batch": "دستهای",
- "camera": "دوربین",
"chroma_radiance": "درخشندگی رنگی",
"clip": "clip",
"color": "رنگ",
@@ -2301,7 +2300,6 @@
"cond pair": "جفت شرط",
"cond single": "شرط تکی",
"conditioning": "شرطگذاری",
- "context": "زمینه",
"controlnet": "controlnet",
"create": "ایجاد",
"custom_sampling": "نمونهگیری سفارشی",
@@ -2310,6 +2308,7 @@
"deprecated": "منسوخ",
"detection": "شناسایی",
"edit_models": "ویرایش مدلها",
+ "experimental": "آزمایشی",
"flux": "flux",
"gligen": "gligen",
"guidance": "راهنمایی",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "ماسک",
- "math": "ریاضی",
"model": "مدل",
"model_merging": "ادغام مدل",
"model_patches": "وصلههای مدل",
@@ -2342,7 +2340,6 @@
"save": "ذخیره",
"schedulers": "زمانبندیها",
"scheduling": "زمانبندی",
- "sd": "sd",
"sd3": "sd3",
"shader": "shader",
"sigmas": "سیگماها",
@@ -2350,7 +2347,6 @@
"style_model": "مدل سبک",
"supir": "supir",
"text": "متن",
- "textgen": "textgen",
"training": "آموزش",
"transform": "تبدیل",
"unet": "unet",
diff --git a/src/locales/fa/nodeDefs.json b/src/locales/fa/nodeDefs.json
index 07df825175..72a2372c3a 100644
--- a/src/locales/fa/nodeDefs.json
+++ b/src/locales/fa/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "اندازه بچ"
+ },
+ "height": {
+ "name": "ارتفاع"
+ },
+ "length": {
+ "name": "طول"
+ },
+ "model": {
+ "name": "مدل"
+ },
+ "start_image": {
+ "name": "تصویر اولیه"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "عرض"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "AddNoise",
"inputs": {
@@ -8011,6 +8045,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "بارگذاری مدل حذف پسزمینه",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "نام مدل حذف پسزمینه",
+ "tooltip": "مدلی که برای حذف پسزمینه از تصاویر استفاده میشود"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "مدل حذف پسزمینه",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "بارگذاری تصویر",
"inputs": {
@@ -13460,6 +13509,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "حذف پسزمینه",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "مدل حذف پسزمینه",
+ "tooltip": "مدل حذف پسزمینه که برای تولید ماسک استفاده میشود"
+ },
+ "image": {
+ "name": "تصویر",
+ "tooltip": "تصویر ورودی برای حذف پسزمینه"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "ماسک",
+ "tooltip": "ماسک پیشزمینه تولید شده"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json
index 0991c97681..c0a6ca2ee9 100644
--- a/src/locales/fr/main.json
+++ b/src/locales/fr/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "ENCODEUR_AUDIO",
"AUDIO_ENCODER_OUTPUT": "SORTIE_ENCODEUR_AUDIO",
"AUDIO_RECORD": "ENREGISTREMENT_AUDIO",
+ "BACKGROUND_REMOVAL": "SUPPRESSION_ARRIÈRE-PLAN",
"BOOLEAN": "BOOLEAN",
"BOUNDING_BOX": "BOÎTE ENGLOBANTE",
"CAMERA_CONTROL": "Contrôle de la caméra",
@@ -2284,15 +2285,13 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_pour_test",
"advanced": "avancé",
"animation": "animation",
- "api": "api",
"api node": "nœud api",
"attention_experiments": "expériences_d'attention",
"audio": "audio",
+ "background removal": "suppression de l’arrière-plan",
"batch": "lot",
- "camera": "caméra",
"chroma_radiance": "chroma_radiance",
"clip": "clip",
"color": "couleur",
@@ -2301,7 +2300,6 @@
"cond pair": "cond pair",
"cond single": "cond unique",
"conditioning": "conditionnement",
- "context": "contexte",
"controlnet": "controlnet",
"create": "créer",
"custom_sampling": "échantillonnage_personnalisé",
@@ -2310,6 +2308,7 @@
"deprecated": "déprécié",
"detection": "détection",
"edit_models": "edit_models",
+ "experimental": "expérimental",
"flux": "flux",
"gligen": "gligen",
"guidance": "guidance",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "masque",
- "math": "math",
"model": "modèle",
"model_merging": "fusion_de_modèles",
"model_patches": "patches_de_modèle",
@@ -2342,7 +2340,6 @@
"save": "enregistrer",
"schedulers": "planificateurs",
"scheduling": "planification",
- "sd": "sd",
"sd3": "sd3",
"shader": "shader",
"sigmas": "sigmas",
@@ -2350,7 +2347,6 @@
"style_model": "modèle_de_style",
"supir": "supir",
"text": "texte",
- "textgen": "textgen",
"training": "entraînement",
"transform": "transformer",
"unet": "unet",
diff --git a/src/locales/fr/nodeDefs.json b/src/locales/fr/nodeDefs.json
index 3b29ea6c07..99a53e9821 100644
--- a/src/locales/fr/nodeDefs.json
+++ b/src/locales/fr/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "taille_du_lot"
+ },
+ "height": {
+ "name": "hauteur"
+ },
+ "length": {
+ "name": "longueur"
+ },
+ "model": {
+ "name": "modèle"
+ },
+ "start_image": {
+ "name": "image_de_départ"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "largeur"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "AjouterBruit",
"inputs": {
@@ -8011,6 +8045,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "Charger le modèle de suppression d’arrière-plan",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "nom_du_modèle_de_suppression_arrière-plan",
+ "tooltip": "Le modèle utilisé pour supprimer les arrière-plans des images"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "modèle_bg",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "Charger Image",
"inputs": {
@@ -13460,6 +13509,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "Supprimer l’arrière-plan",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "modèle_de_suppression_arrière-plan",
+ "tooltip": "Modèle de suppression d’arrière-plan utilisé pour générer le masque"
+ },
+ "image": {
+ "name": "image",
+ "tooltip": "Image d’entrée dont l’arrière-plan doit être supprimé"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "masque",
+ "tooltip": "Masque de premier plan généré"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json
index 9807504eb1..a9eb05adea 100644
--- a/src/locales/ja/main.json
+++ b/src/locales/ja/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "オーディオエンコーダ",
"AUDIO_ENCODER_OUTPUT": "オーディオエンコーダ出力",
"AUDIO_RECORD": "オーディオ録音",
+ "BACKGROUND_REMOVAL": "背景除去",
"BOOLEAN": "ブール",
"BOUNDING_BOX": "バウンディングボックス",
"CAMERA_CONTROL": "カメラコントロール",
@@ -2284,15 +2285,13 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_テスト用",
"advanced": "高度な機能",
"animation": "アニメーション",
- "api": "API",
"api node": "apiノード",
"attention_experiments": "アテンション実験",
"audio": "オーディオ",
+ "background removal": "背景除去",
"batch": "バッチ",
- "camera": "カメラ",
"chroma_radiance": "chroma_radiance",
"clip": "クリップ",
"color": "カラー",
@@ -2301,7 +2300,6 @@
"cond pair": "条件ペア",
"cond single": "条件単体",
"conditioning": "条件付け",
- "context": "コンテキスト",
"controlnet": "コントロールネット",
"create": "作成",
"custom_sampling": "カスタムサンプリング",
@@ -2310,6 +2308,7 @@
"deprecated": "非推奨",
"detection": "検出",
"edit_models": "モデル編集",
+ "experimental": "実験的",
"flux": "flux",
"gligen": "グライジェン",
"guidance": "ガイダンス",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "LTXV",
"mask": "マスク",
- "math": "数学",
"model": "モデル",
"model_merging": "モデルマージ",
"model_patches": "モデルパッチ",
@@ -2342,7 +2340,6 @@
"save": "保存",
"schedulers": "スケジューラー",
"scheduling": "スケジューリング",
- "sd": "sd",
"sd3": "SD3",
"shader": "shader",
"sigmas": "シグマ",
@@ -2350,7 +2347,6 @@
"style_model": "スタイルモデル",
"supir": "supir",
"text": "テキスト",
- "textgen": "textgen",
"training": "トレーニング",
"transform": "変換",
"unet": "U-Net",
diff --git a/src/locales/ja/nodeDefs.json b/src/locales/ja/nodeDefs.json
index 75ed4a69df..13687c982e 100644
--- a/src/locales/ja/nodeDefs.json
+++ b/src/locales/ja/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "バッチサイズ"
+ },
+ "height": {
+ "name": "高さ"
+ },
+ "length": {
+ "name": "長さ"
+ },
+ "model": {
+ "name": "model"
+ },
+ "start_image": {
+ "name": "start_image"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "幅"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "ノイズを追加",
"inputs": {
@@ -8011,6 +8045,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "背景除去モデルの読み込み",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "bg_removal_name",
+ "tooltip": "画像から背景を除去するために使用するモデル"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "bg_model",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "画像を読み込む",
"inputs": {
@@ -13460,6 +13509,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "背景を除去",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "bg_removal_model",
+ "tooltip": "マスクを生成するために使用する背景除去モデル"
+ },
+ "image": {
+ "name": "画像",
+ "tooltip": "背景を除去する入力画像"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "マスク",
+ "tooltip": "生成された前景マスク"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json
index c628030434..c21959d27e 100644
--- a/src/locales/ko/main.json
+++ b/src/locales/ko/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "AUDIO_ENCODER",
"AUDIO_ENCODER_OUTPUT": "AUDIO_ENCODER_OUTPUT",
"AUDIO_RECORD": "AUDIO_RECORD",
+ "BACKGROUND_REMOVAL": "배경 제거",
"BOOLEAN": "논리값",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "카메라 제어",
@@ -2284,15 +2285,13 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_테스트용",
"advanced": "고급",
"animation": "애니메이션",
- "api": "API",
"api node": "api 노드",
"attention_experiments": "어텐션 실험",
"audio": "오디오",
+ "background removal": "배경 제거",
"batch": "배치",
- "camera": "카메라",
"chroma_radiance": "chroma_radiance",
"clip": "클립",
"color": "색상",
@@ -2301,7 +2300,6 @@
"cond pair": "조건 쌍",
"cond single": "단일 조건",
"conditioning": "조건화",
- "context": "컨텍스트",
"controlnet": "컨트롤넷",
"create": "생성",
"custom_sampling": "사용자 정의 샘플링",
@@ -2310,6 +2308,7 @@
"deprecated": "지원 중단",
"detection": "감지",
"edit_models": "edit_models",
+ "experimental": "실험적",
"flux": "flux",
"gligen": "글리젠",
"guidance": "가이드",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "마스크",
- "math": "수학",
"model": "모델",
"model_merging": "모델 병합",
"model_patches": "모델 패치",
@@ -2342,7 +2340,6 @@
"save": "저장",
"schedulers": "스케줄러",
"scheduling": "스케줄링",
- "sd": "sd",
"sd3": "sd3",
"shader": "shader",
"sigmas": "시그마",
@@ -2350,7 +2347,6 @@
"style_model": "스타일 모델",
"supir": "supir",
"text": "텍스트",
- "textgen": "textgen",
"training": "학습",
"transform": "변환",
"unet": "UNet",
diff --git a/src/locales/ko/nodeDefs.json b/src/locales/ko/nodeDefs.json
index 7b97c442a0..08d35217cf 100644
--- a/src/locales/ko/nodeDefs.json
+++ b/src/locales/ko/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "batch_size"
+ },
+ "height": {
+ "name": "height"
+ },
+ "length": {
+ "name": "length"
+ },
+ "model": {
+ "name": "model"
+ },
+ "start_image": {
+ "name": "start_image"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "width"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "노이즈 추가",
"inputs": {
@@ -8011,6 +8045,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "배경 제거 모델 불러오기",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "bg_removal_name",
+ "tooltip": "이미지에서 배경을 제거하는 데 사용되는 모델"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "bg_model",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "이미지 로드",
"inputs": {
@@ -13460,6 +13509,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "배경 제거",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "bg_removal_model",
+ "tooltip": "마스크 생성을 위해 사용되는 배경 제거 모델"
+ },
+ "image": {
+ "name": "image",
+ "tooltip": "배경을 제거할 입력 이미지"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "mask",
+ "tooltip": "생성된 전경 마스크"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
diff --git a/src/locales/pt-BR/main.json b/src/locales/pt-BR/main.json
index 0c453e0c7f..a6b50bcb04 100644
--- a/src/locales/pt-BR/main.json
+++ b/src/locales/pt-BR/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "CODIFICADOR DE ÁUDIO",
"AUDIO_ENCODER_OUTPUT": "SAÍDA DO CODIFICADOR DE ÁUDIO",
"AUDIO_RECORD": "GRAVAÇÃO DE ÁUDIO",
+ "BACKGROUND_REMOVAL": "REMOÇÃO_DE_FUNDO",
"BOOLEAN": "BOOLEANO",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "CONTROLE DE CÂMERA",
@@ -2284,15 +2285,13 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_for_testing",
"advanced": "avançado",
"animation": "animação",
- "api": "api",
"api node": "nó da API",
"attention_experiments": "experimentos_de_atenção",
"audio": "áudio",
+ "background removal": "remoção de fundo",
"batch": "lote",
- "camera": "câmera",
"chroma_radiance": "radiância_de_croma",
"clip": "clip",
"color": "cor",
@@ -2301,7 +2300,6 @@
"cond pair": "par_de_condições",
"cond single": "condição_simples",
"conditioning": "condicionamento",
- "context": "contexto",
"controlnet": "controlnet",
"create": "criar",
"custom_sampling": "amostragem_personalizada",
@@ -2310,6 +2308,7 @@
"deprecated": "obsoleto",
"detection": "detecção",
"edit_models": "editar_modelos",
+ "experimental": "experimental",
"flux": "flux",
"gligen": "gligen",
"guidance": "orientação",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "máscara",
- "math": "matemática",
"model": "modelo",
"model_merging": "mesclagem_de_modelos",
"model_patches": "correções_de_modelo",
@@ -2342,7 +2340,6 @@
"save": "salvar",
"schedulers": "agendadores",
"scheduling": "agendamento",
- "sd": "sd",
"sd3": "sd3",
"shader": "shader",
"sigmas": "sigmas",
@@ -2350,7 +2347,6 @@
"style_model": "modelo_de_estilo",
"supir": "supir",
"text": "texto",
- "textgen": "textgen",
"training": "treinamento",
"transform": "transformar",
"unet": "unet",
diff --git a/src/locales/pt-BR/nodeDefs.json b/src/locales/pt-BR/nodeDefs.json
index 599126ee22..45c9222ff5 100644
--- a/src/locales/pt-BR/nodeDefs.json
+++ b/src/locales/pt-BR/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "tamanho_do_lote"
+ },
+ "height": {
+ "name": "altura"
+ },
+ "length": {
+ "name": "duração"
+ },
+ "model": {
+ "name": "modelo"
+ },
+ "start_image": {
+ "name": "imagem_inicial"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "largura"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "AddNoise",
"inputs": {
@@ -8011,6 +8045,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "Carregar Modelo de Remoção de Fundo",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "nome_remoção_fundo",
+ "tooltip": "O modelo usado para remover fundos de imagens"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "modelo_fundo",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "Carregar Imagem",
"inputs": {
@@ -13460,6 +13509,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "Remover Fundo",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "modelo_remoção_fundo",
+ "tooltip": "Modelo de remoção de fundo usado para gerar a máscara"
+ },
+ "image": {
+ "name": "imagem",
+ "tooltip": "Imagem de entrada para remover o fundo"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "máscara",
+ "tooltip": "Máscara de primeiro plano gerada"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json
index 1b954b9dba..cd220e8a9f 100644
--- a/src/locales/ru/main.json
+++ b/src/locales/ru/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "АУДИО_КОДЕР",
"AUDIO_ENCODER_OUTPUT": "ВЫХОД_АУДИО_КОДЕРА",
"AUDIO_RECORD": "АУДИО_ЗАПИСЬ",
+ "BACKGROUND_REMOVAL": "УДАЛЕНИЕ_ФОНА",
"BOOLEAN": "БУЛЕВО",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "УПРАВЛЕНИЕ_КАМЕРОЙ",
@@ -2284,15 +2285,13 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_для_тестирования",
"advanced": "расширенный",
"animation": "анимация",
- "api": "api",
"api node": "api-узел",
"attention_experiments": "эксперименты_внимания",
"audio": "аудио",
+ "background removal": "удаление фона",
"batch": "пакет",
- "camera": "камера",
"chroma_radiance": "chroma_radiance",
"clip": "clip",
"color": "цвет",
@@ -2301,7 +2300,6 @@
"cond pair": "условие_пара",
"cond single": "условие_одиночное",
"conditioning": "условие",
- "context": "контекст",
"controlnet": "controlnet",
"create": "создать",
"custom_sampling": "пользовательский_семплинг",
@@ -2310,6 +2308,7 @@
"deprecated": "устаревший",
"detection": "детекция",
"edit_models": "редактировать_модели",
+ "experimental": "экспериментальное",
"flux": "flux",
"gligen": "gligen",
"guidance": "направление",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "маска",
- "math": "математика",
"model": "модель",
"model_merging": "слияние_моделей",
"model_patches": "патчи_моделей",
@@ -2342,7 +2340,6 @@
"save": "сохранить",
"schedulers": "schedulers",
"scheduling": "scheduling",
- "sd": "sd",
"sd3": "sd3",
"shader": "шейдер",
"sigmas": "сигмы",
@@ -2350,7 +2347,6 @@
"style_model": "модель_стиля",
"supir": "supir",
"text": "текст",
- "textgen": "textgen",
"training": "обучение",
"transform": "преобразование",
"unet": "unet",
diff --git a/src/locales/ru/nodeDefs.json b/src/locales/ru/nodeDefs.json
index 20d9ea08e1..e8420d1054 100644
--- a/src/locales/ru/nodeDefs.json
+++ b/src/locales/ru/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "размер_пакета"
+ },
+ "height": {
+ "name": "высота"
+ },
+ "length": {
+ "name": "длина"
+ },
+ "model": {
+ "name": "model"
+ },
+ "start_image": {
+ "name": "start_image"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "ширина"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "Добавить шум",
"inputs": {
@@ -8011,6 +8045,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "Загрузить модель удаления фона",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "bg_removal_name",
+ "tooltip": "Модель, используемая для удаления фона с изображений"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "bg_model",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "Загрузить изображение",
"inputs": {
@@ -13460,6 +13509,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "Удалить фон",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "bg_removal_model",
+ "tooltip": "Модель удаления фона, используемая для создания маски"
+ },
+ "image": {
+ "name": "изображение",
+ "tooltip": "Входное изображение для удаления фона"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "mask",
+ "tooltip": "Сгенерированная маска переднего плана"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
diff --git a/src/locales/tr/main.json b/src/locales/tr/main.json
index f4a109a759..64c60916e0 100644
--- a/src/locales/tr/main.json
+++ b/src/locales/tr/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "SES_KODLAYICI",
"AUDIO_ENCODER_OUTPUT": "SES_KODLAYICI_ÇIKIŞI",
"AUDIO_RECORD": "SES_KAYDI",
+ "BACKGROUND_REMOVAL": "ARKA_PLAN_KALDIRMA",
"BOOLEAN": "BOOLEAN",
"BOUNDING_BOX": "BOUNDING_BOX",
"CAMERA_CONTROL": "KAMERA_KONTROL",
@@ -2284,15 +2285,13 @@
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_test_için",
"advanced": "gelişmiş",
"animation": "animasyon",
- "api": "api",
"api node": "api düğümü",
"attention_experiments": "dikkat_deneyleri",
"audio": "ses",
+ "background removal": "arka plan kaldırma",
"batch": "toplu",
- "camera": "kamera",
"chroma_radiance": "chroma_radiance",
"clip": "klip",
"color": "renk",
@@ -2301,7 +2300,6 @@
"cond pair": "çift koşul",
"cond single": "tek koşul",
"conditioning": "koşullandırma",
- "context": "bağlam",
"controlnet": "controlnet",
"create": "oluştur",
"custom_sampling": "özel_örnekleme",
@@ -2310,6 +2308,7 @@
"deprecated": "kullanımdan kaldırılmış",
"detection": "tespit",
"edit_models": "modelleri_düzenle",
+ "experimental": "deneysel",
"flux": "flux",
"gligen": "gligen",
"guidance": "rehberlik",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "maske",
- "math": "matematik",
"model": "model",
"model_merging": "model_birleştirme",
"model_patches": "model_yamaları",
@@ -2342,7 +2340,6 @@
"save": "kaydet",
"schedulers": "zamanlayıcılar",
"scheduling": "zamanlama",
- "sd": "sd",
"sd3": "sd3",
"shader": "shader",
"sigmas": "sigmalar",
@@ -2350,7 +2347,6 @@
"style_model": "stil_modeli",
"supir": "supir",
"text": "metin",
- "textgen": "textgen",
"training": "eğitim",
"transform": "dönüştür",
"unet": "unet",
diff --git a/src/locales/tr/nodeDefs.json b/src/locales/tr/nodeDefs.json
index 96295d3b28..b0db028d67 100644
--- a/src/locales/tr/nodeDefs.json
+++ b/src/locales/tr/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "toplu_boyut"
+ },
+ "height": {
+ "name": "yükseklik"
+ },
+ "length": {
+ "name": "uzunluk"
+ },
+ "model": {
+ "name": "model"
+ },
+ "start_image": {
+ "name": "başlangıç_görseli"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "genişlik"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "Gürültü Ekle",
"inputs": {
@@ -8011,6 +8045,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "Arka Plan Kaldırma Modelini Yükle",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "arka_plan_kaldırma_adı",
+ "tooltip": "Görsellerden arka planı kaldırmak için kullanılan model"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "bg_model",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "Görüntü Yükle",
"inputs": {
@@ -13460,6 +13509,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "Arka Planı Kaldır",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "arka_plan_kaldırma_modeli",
+ "tooltip": "Maskeyi oluşturmak için kullanılan arka plan kaldırma modeli"
+ },
+ "image": {
+ "name": "görsel",
+ "tooltip": "Arka planı kaldırılacak giriş görseli"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "mask",
+ "tooltip": "Oluşturulan ön plan maskesi"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "YenidenNormalleştirCFG",
"inputs": {
diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json
index d021ce28fb..41a741b3ba 100644
--- a/src/locales/zh-TW/main.json
+++ b/src/locales/zh-TW/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "音訊編碼器",
"AUDIO_ENCODER_OUTPUT": "音訊編碼器輸出",
"AUDIO_RECORD": "音訊錄製",
+ "BACKGROUND_REMOVAL": "去背",
"BOOLEAN": "布林值",
"BOUNDING_BOX": "邊界框",
"CAMERA_CONTROL": "攝影機控制",
@@ -2284,15 +2285,13 @@
"Vidu": "維度",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_for_testing",
"advanced": "進階",
"animation": "動畫",
- "api": "API",
"api node": "API 節點",
"attention_experiments": "注意力實驗",
"audio": "音訊",
+ "background removal": "去背",
"batch": "批次",
- "camera": "相機",
"chroma_radiance": "色度光輝",
"clip": "CLIP",
"color": "顏色",
@@ -2301,7 +2300,6 @@
"cond pair": "條件配對",
"cond single": "單一條件",
"conditioning": "條件設定",
- "context": "上下文",
"controlnet": "ControlNet",
"create": "建立",
"custom_sampling": "自訂取樣",
@@ -2310,6 +2308,7 @@
"deprecated": "已棄用",
"detection": "偵測",
"edit_models": "編輯模型",
+ "experimental": "實驗性",
"flux": "Flux",
"gligen": "GLIGEN",
"guidance": "引導",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "ltxv",
"mask": "遮罩",
- "math": "數學",
"model": "模型",
"model_merging": "模型合併",
"model_patches": "模型修補",
@@ -2342,7 +2340,6 @@
"save": "儲存",
"schedulers": "排程器",
"scheduling": "排程",
- "sd": "SD",
"sd3": "sd3",
"shader": "著色器",
"sigmas": "西格瑪值",
@@ -2350,7 +2347,6 @@
"style_model": "風格模型",
"supir": "supir",
"text": "文字",
- "textgen": "文字生成",
"training": "訓練",
"transform": "轉換",
"unet": "UNet",
diff --git a/src/locales/zh-TW/nodeDefs.json b/src/locales/zh-TW/nodeDefs.json
index 031837db41..30f2d2684c 100644
--- a/src/locales/zh-TW/nodeDefs.json
+++ b/src/locales/zh-TW/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "批次大小"
+ },
+ "height": {
+ "name": "高度"
+ },
+ "length": {
+ "name": "長度"
+ },
+ "model": {
+ "name": "model"
+ },
+ "start_image": {
+ "name": "起始圖像"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "寬度"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "新增雜訊",
"inputs": {
@@ -8011,6 +8045,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "載入背景移除模型",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "bg_removal_name",
+ "tooltip": "用於從圖像中移除背景的模型"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "bg_model",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "載入圖片",
"inputs": {
@@ -13460,6 +13509,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "移除背景",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "bg_removal_model",
+ "tooltip": "用於產生 mask 的背景移除模型"
+ },
+ "image": {
+ "name": "圖像",
+ "tooltip": "要移除背景的輸入圖像"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "mask",
+ "tooltip": "產生的前景 mask"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json
index 97f66694ec..6a580f0ee2 100644
--- a/src/locales/zh/main.json
+++ b/src/locales/zh/main.json
@@ -785,6 +785,7 @@
"AUDIO_ENCODER": "音频编码器",
"AUDIO_ENCODER_OUTPUT": "音频编码器输出",
"AUDIO_RECORD": "音频录制",
+ "BACKGROUND_REMOVAL": "背景移除",
"BOOLEAN": "布尔",
"BOUNDING_BOX": "边界框",
"CAMERA_CONTROL": "相机控制",
@@ -2284,15 +2285,13 @@
"Vidu": "Vidu",
"Wan": "Wan万相",
"WaveSpeed": "WaveSpeed",
- "_for_testing": "_用于测试",
"advanced": "高级",
"animation": "动画",
- "api": "API",
"api node": "api 节点",
"attention_experiments": "注意力实验",
"audio": "音频",
+ "background removal": "背景移除",
"batch": "批处理",
- "camera": "相机",
"chroma_radiance": "chroma_radiance",
"clip": "CLIP",
"color": "颜色",
@@ -2301,7 +2300,6 @@
"cond pair": "条件对",
"cond single": "条件单",
"conditioning": "条件",
- "context": "上下文",
"controlnet": "ControlNet",
"create": "创建",
"custom_sampling": "自定义采样",
@@ -2310,6 +2308,7 @@
"deprecated": "已弃用",
"detection": "检测",
"edit_models": "编辑模型",
+ "experimental": "实验性",
"flux": "Flux",
"gligen": "GLIGEN",
"guidance": "引导",
@@ -2325,7 +2324,6 @@
"lotus": "lotus",
"ltxv": "LTXV",
"mask": "遮罩",
- "math": "数学",
"model": "模型",
"model_merging": "模型合并",
"model_patches": "模型微调",
@@ -2342,7 +2340,6 @@
"save": "保存",
"schedulers": "调度器",
"scheduling": "调度",
- "sd": "sd",
"sd3": "SD3",
"shader": "shader",
"sigmas": "Sigmas",
@@ -2350,7 +2347,6 @@
"style_model": "风格模型",
"supir": "supir",
"text": "文本",
- "textgen": "textgen",
"training": "训练",
"transform": "变换",
"unet": "U-Net",
diff --git a/src/locales/zh/nodeDefs.json b/src/locales/zh/nodeDefs.json
index a949ba29d1..0804f73bf8 100644
--- a/src/locales/zh/nodeDefs.json
+++ b/src/locales/zh/nodeDefs.json
@@ -24,6 +24,40 @@
}
}
},
+ "ARVideoI2V": {
+ "display_name": "ARVideoI2V",
+ "inputs": {
+ "batch_size": {
+ "name": "批量大小"
+ },
+ "height": {
+ "name": "高度"
+ },
+ "length": {
+ "name": "长度"
+ },
+ "model": {
+ "name": "模型"
+ },
+ "start_image": {
+ "name": "起始图像"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "宽度"
+ }
+ },
+ "outputs": {
+ "0": {
+ "tooltip": null
+ },
+ "1": {
+ "tooltip": null
+ }
+ }
+ },
"AddNoise": {
"display_name": "添加噪波",
"inputs": {
@@ -8011,6 +8045,21 @@
}
}
},
+ "LoadBackgroundRemovalModel": {
+ "display_name": "加载背景移除模型",
+ "inputs": {
+ "bg_removal_name": {
+ "name": "背景移除模型名称",
+ "tooltip": "用于从图像中移除背景的模型"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "bg_model",
+ "tooltip": null
+ }
+ }
+ },
"LoadImage": {
"display_name": "加载图像",
"inputs": {
@@ -13460,6 +13509,25 @@
}
}
},
+ "RemoveBackground": {
+ "display_name": "移除背景",
+ "inputs": {
+ "bg_removal_model": {
+ "name": "背景移除模型",
+ "tooltip": "用于生成 mask 的背景移除模型"
+ },
+ "image": {
+ "name": "图像",
+ "tooltip": "要移除背景的输入图像"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "mask",
+ "tooltip": "生成的前景 mask"
+ }
+ }
+ },
"RenormCFG": {
"display_name": "RenormCFG",
"inputs": {
From c16052e2e39ecc5f02fe1adbbc7d6d541b9a1042 Mon Sep 17 00:00:00 2001
From: Christian Byrne
Date: Fri, 8 May 2026 20:31:11 -0700
Subject: [PATCH 02/48] feat: sort right-click context menu categories
alphabetically (#12039)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
*PR Created by the Glary-Bot Agent*
---
## Summary
Sort the canvas right-click "Add Node" context menu by display name
(case-insensitive, natural numeric). Previously, both category submenus
and leaf nodes appeared in node-registration order, making the menu hard
to scan for users browsing for nodes.
This change is scoped specifically to the **smaller right-click
contextual menu**. It does NOT affect the double-click search menu or
the left-side Nodes panel.
## Changes
- `src/lib/litegraph/src/LGraphCanvas.ts` — In `onMenuAdd` →
`inner_onMenuAdded`, sort the deduplicated category submenu entries and
the leaf-node entries by `content` using `localeCompare` with `{
numeric: true, sensitivity: 'base' }`. Categories still appear before
leaf nodes within a level (preserves existing UX).
- `src/lib/litegraph/src/LGraphCanvas.onMenuAdd.test.ts` — New unit
tests that mock `LiteGraph.ContextMenu` and assert: case-insensitive
sort, natural numeric ordering (`Cat1`, `Cat2`, `Cat10`), leaf-node
sorting inside a category, and category-before-leaf placement.
## Verification
- `pnpm vitest run src/lib/litegraph/src/LGraphCanvas.onMenuAdd.test.ts`
— 4/4 pass
- `pnpm typecheck` — clean (ran via pre-commit hook on initial commit)
- `oxfmt` / `oxlint` / `eslint` — clean
- Oracle review against `main` returned 0 critical / 1 warning (test
coverage) / 1 suggestion (numeric sort) — both addressed in this PR.
## Notes
- The sort is applied at the menu-build site rather than inside
`LiteGraphGlobal.getNodeTypesCategories`/`getNodeTypesInCategory` to
keep the change scoped to the menu UX and avoid changing the iteration
order seen by extensions that consume those public methods.
- Per user request, this is opening as a draft PR for self-review +
CodeRabbit feedback in a single follow-up pass; manual browser
verification (right-click screenshots) was deferred to that pass.
- Slack thread context: user reported the contextual menu is "a mess"
for discovering native nodes; alphabetical sorting addresses the
discoverability problem without touching the search-oriented menus.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12039-feat-sort-right-click-context-menu-categories-alphabetically-3596d73d36508107a87ffec1c353994e)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Glary-Bot
Co-authored-by: Alexis Rolland
---
.../src/LGraphCanvas.onMenuAdd.test.ts | 173 ++++++++++++++++++
src/lib/litegraph/src/LGraphCanvas.ts | 20 +-
2 files changed, 189 insertions(+), 4 deletions(-)
create mode 100644 src/lib/litegraph/src/LGraphCanvas.onMenuAdd.test.ts
diff --git a/src/lib/litegraph/src/LGraphCanvas.onMenuAdd.test.ts b/src/lib/litegraph/src/LGraphCanvas.onMenuAdd.test.ts
new file mode 100644
index 0000000000..f774267491
--- /dev/null
+++ b/src/lib/litegraph/src/LGraphCanvas.onMenuAdd.test.ts
@@ -0,0 +1,173 @@
+import { fromPartial } from '@total-typescript/shoehorn'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
+import {
+ LGraph,
+ LGraphCanvas,
+ LGraphNode,
+ LiteGraph
+} from '@/lib/litegraph/src/litegraph'
+
+class TestNode extends LGraphNode {
+ static override type = 'TestNode'
+
+ constructor(title?: string) {
+ super(title ?? 'TestNode')
+ }
+}
+
+function makeNodeClass(title: string) {
+ class N extends TestNode {
+ static override title = title
+
+ constructor() {
+ super(title)
+ }
+ }
+ return N
+}
+
+function createCanvas(graph: LGraph): LGraphCanvas {
+ const el = document.createElement('canvas')
+ el.width = 800
+ el.height = 600
+ const ctx = fromPartial({
+ measureText: vi.fn().mockReturnValue({ width: 50 }),
+ getTransform: vi
+ .fn()
+ .mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 })
+ })
+
+ el.getContext = vi.fn().mockReturnValue(ctx)
+ el.getBoundingClientRect = vi.fn().mockReturnValue({
+ left: 0,
+ top: 0,
+ width: 800,
+ height: 600
+ })
+
+ return new LGraphCanvas(el, graph, { skip_render: true })
+}
+
+type MenuEntry = IContextMenuValue
+
+describe('LGraphCanvas.onMenuAdd category sorting', () => {
+ let graph: LGraph
+ let canvas: LGraphCanvas
+ const registeredTypes: string[] = []
+ let originalContextMenu: typeof LiteGraph.ContextMenu
+ const capturedEntries: MenuEntry[][] = []
+
+ beforeEach(() => {
+ graph = new LGraph()
+ canvas = createCanvas(graph)
+ LGraphCanvas.active_canvas = canvas
+
+ capturedEntries.length = 0
+ originalContextMenu = LiteGraph.ContextMenu
+ const MockContextMenu = vi.fn(function (
+ this: unknown,
+ values: MenuEntry[]
+ ) {
+ capturedEntries.push(values)
+ }) as unknown as typeof LiteGraph.ContextMenu
+ LiteGraph.ContextMenu = MockContextMenu
+ })
+
+ afterEach(() => {
+ LiteGraph.ContextMenu = originalContextMenu
+ for (const type of registeredTypes) {
+ delete LiteGraph.registered_node_types[type]
+ }
+ registeredTypes.length = 0
+ })
+
+ function register(type: string, title: string) {
+ LiteGraph.registerNodeType(type, makeNodeClass(title))
+ registeredTypes.push(type)
+ }
+
+ function openTopLevelMenu() {
+ const event = new MouseEvent('contextmenu', { clientX: 10, clientY: 10 })
+ LGraphCanvas.onMenuAdd(undefined, undefined, event)
+ return event
+ }
+
+ function drillInto(label: string, sourceEvent: MouseEvent) {
+ const top = capturedEntries[capturedEntries.length - 1]
+ const entry = top.find((e) => e.content === label)
+ expect(entry, `submenu entry "${label}" should exist`).toBeDefined()
+ expect(entry!.callback).toBeDefined()
+ expect(typeof entry!.value).toBe('string')
+ const callback = entry!.callback!
+ const menuThis = document.createElement('div') as ThisParameterType<
+ typeof callback
+ >
+ void callback.call(menuThis, entry, undefined, sourceEvent, undefined)
+ }
+
+ it('sorts top-level category submenus alphabetically (case-insensitive)', () => {
+ register('zebra/zNode', 'Zebra Node')
+ register('Apple/aNode', 'Apple Node')
+ register('middle/mNode', 'Middle Node')
+
+ openTopLevelMenu()
+
+ const submenuLabels = capturedEntries[0]
+ .filter((e) => e.has_submenu)
+ .map((e) => e.content)
+ const ours = submenuLabels.filter((label) =>
+ ['Apple', 'middle', 'zebra'].includes(label ?? '')
+ )
+ expect(ours).toEqual(['Apple', 'middle', 'zebra'])
+ })
+
+ it('uses natural numeric ordering for numbered category names', () => {
+ register('Cat10/n10', 'Item10')
+ register('Cat2/n2', 'Item2')
+ register('Cat1/n1', 'Item1')
+
+ openTopLevelMenu()
+
+ const ours = capturedEntries[0]
+ .filter(
+ (e) =>
+ e.has_submenu && ['Cat1', 'Cat2', 'Cat10'].includes(e.content ?? '')
+ )
+ .map((e) => e.content)
+ expect(ours).toEqual(['Cat1', 'Cat2', 'Cat10'])
+ })
+
+ it('sorts leaf nodes inside a category alphabetically', () => {
+ register('leafsort/Zeta', 'Zeta')
+ register('leafsort/Alpha', 'Alpha')
+ register('leafsort/Mike', 'Mike')
+
+ const event = openTopLevelMenu()
+ drillInto('leafsort', event)
+
+ const leafLabels = capturedEntries[1]
+ .filter((e) => !e.has_submenu)
+ .map((e) => e.content)
+ expect(leafLabels).toEqual(['Alpha', 'Mike', 'Zeta'])
+ })
+
+ it('places category submenus before leaf entries within a category level', () => {
+ register('mixed/leafA', 'A Leaf')
+ register('mixed/leafZ', 'Z Leaf')
+ register('mixed/inner/deep', 'Deep')
+
+ const event = openTopLevelMenu()
+ drillInto('mixed', event)
+
+ const inside = capturedEntries[1]
+ const ours = inside.filter((e) =>
+ ['inner', 'A Leaf', 'Z Leaf'].includes(e.content ?? '')
+ )
+ expect(ours[0].content).toBe('inner')
+ expect(ours[0].has_submenu).toBe(true)
+ expect(ours[1].content).toBe('A Leaf')
+ expect(ours[2].content).toBe('Z Leaf')
+ })
+})
diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts
index 26accd0e8d..b3159f5cc5 100644
--- a/src/lib/litegraph/src/LGraphCanvas.ts
+++ b/src/lib/litegraph/src/LGraphCanvas.ts
@@ -1179,7 +1179,7 @@ export class LGraphCanvas implements CustomEventDispatcher
const categories = LiteGraph.getNodeTypesCategories(
canvas.filter || graph.filter
).filter((category) => category.startsWith(base_category))
- const entries: AddNodeMenu[] = []
+ const categoryEntries: AddNodeMenu[] = []
for (const category of categories) {
if (!category) continue
@@ -1197,11 +1197,11 @@ export class LGraphCanvas implements CustomEventDispatcher
// in case it has a namespace like "shader::math/rand" it hides the namespace
if (name.includes('::')) name = name.split('::', 2)[1]
- const index = entries.findIndex(
+ const index = categoryEntries.findIndex(
(entry) => entry.value === category_path
)
if (index === -1) {
- entries.push({
+ categoryEntries.push({
value: category_path,
content: name,
has_submenu: true,
@@ -1212,11 +1212,19 @@ export class LGraphCanvas implements CustomEventDispatcher
}
}
+ const compareByContent = (a: AddNodeMenu, b: AddNodeMenu) =>
+ (a.content ?? '').localeCompare(b.content ?? '', undefined, {
+ numeric: true,
+ sensitivity: 'base'
+ })
+ categoryEntries.sort(compareByContent)
+
const nodes = LiteGraph.getNodeTypesInCategory(
base_category.slice(0, -1),
canvas.filter || graph.filter
)
+ const nodeEntries: AddNodeMenu[] = []
for (const node of nodes) {
if (node.skip_list) continue
@@ -1246,9 +1254,13 @@ export class LGraphCanvas implements CustomEventDispatcher
}
}
- entries.push(entry)
+ nodeEntries.push(entry)
}
+ nodeEntries.sort(compareByContent)
+
+ const entries: AddNodeMenu[] = [...categoryEntries, ...nodeEntries]
+
new LiteGraph.ContextMenu(entries, { event: e, parentMenu: prev_menu })
}
}
From 653ef1a4f064245a1a30cd51cf62b1e66f7fa822 Mon Sep 17 00:00:00 2001
From: Terry Jia
Date: Sat, 9 May 2026 01:26:37 -0400
Subject: [PATCH 03/48] Handle Load3D "none" model selection in frontend
(#11178)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Load3D now supports panoramic images and HDRI loading, it can serve as a
viewer for those without requiring a 3D model. Previously, the node
required a model file to execute. Rather than making the input optional
(which would break existing workflows that rely on it being required), a
"none" option is added to the combo list, allowing users to run Load3D
with no model loaded.
BE change is https://github.com/Comfy-Org/ComfyUI/pull/13379
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11178-Handle-Load3D-none-model-selection-in-frontend-3416d73d365081e589b3d89bc67f75e7)
by [Unito](https://www.unito.io)
---
src/extensions/core/load3d.ts | 11 ++-
.../core/load3d/Load3DConfiguration.test.ts | 88 +++++++++++++++++++
.../core/load3d/Load3DConfiguration.ts | 8 +-
src/extensions/core/load3d/constants.ts | 2 +
4 files changed, 101 insertions(+), 8 deletions(-)
diff --git a/src/extensions/core/load3d.ts b/src/extensions/core/load3d.ts
index 0a0fe143a2..4626587523 100644
--- a/src/extensions/core/load3d.ts
+++ b/src/extensions/core/load3d.ts
@@ -9,7 +9,10 @@ import type {
CameraState
} from '@/extensions/core/load3d/interfaces'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
-import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
+import {
+ LOAD3D_NONE_MODEL,
+ SUPPORTED_EXTENSIONS_ACCEPT
+} from '@/extensions/core/load3d/constants'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -290,13 +293,9 @@ useExtensionService().registerExtension({
)
node.addWidget('button', 'clear', 'clear', () => {
- useLoad3d(node).waitForLoad3d((load3d) => {
- load3d.clearModel()
- })
-
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
- modelWidget.value = ''
+ modelWidget.value = LOAD3D_NONE_MODEL
}
})
diff --git a/src/extensions/core/load3d/Load3DConfiguration.test.ts b/src/extensions/core/load3d/Load3DConfiguration.test.ts
index 39db356fa5..3f9638cca9 100644
--- a/src/extensions/core/load3d/Load3DConfiguration.test.ts
+++ b/src/extensions/core/load3d/Load3DConfiguration.test.ts
@@ -594,3 +594,91 @@ describe('Load3DConfiguration.configure forwards persisted + settings to load3d'
expect(load3d.setLightIntensity).toHaveBeenCalledWith(9)
})
})
+
+describe('Load3DConfiguration "none" model handling', () => {
+ let load3d: Load3d
+ let loadModelSpy: ReturnType
+ let clearModelSpy: ReturnType
+
+ function makeLoad3dMock(): Load3d {
+ loadModelSpy = vi.fn().mockResolvedValue(undefined)
+ clearModelSpy = vi.fn()
+ return {
+ loadModel: loadModelSpy,
+ clearModel: clearModelSpy,
+ setUpDirection: vi.fn(),
+ setMaterialMode: vi.fn(),
+ setTargetSize: vi.fn(),
+ setCameraState: vi.fn(),
+ toggleGrid: vi.fn(),
+ setBackgroundColor: vi.fn(),
+ setBackgroundImage: vi.fn().mockResolvedValue(undefined),
+ setBackgroundRenderMode: vi.fn(),
+ toggleCamera: vi.fn(),
+ setFOV: vi.fn(),
+ setLightIntensity: vi.fn(),
+ setHDRIIntensity: vi.fn(),
+ setHDRIAsBackground: vi.fn(),
+ setHDRIEnabled: vi.fn(),
+ emitModelReady: vi.fn()
+ } as unknown as Load3d
+ }
+
+ async function flush() {
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ }
+
+ beforeEach(() => {
+ load3d = makeLoad3dMock()
+ vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb'])
+ vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view')
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('does not load or clear a model when the initial widget value is "none"', async () => {
+ const config = new Load3DConfiguration(load3d)
+ config.configure({
+ modelWidget: { value: 'none' } as unknown as IBaseWidget,
+ loadFolder: 'input'
+ })
+ await flush()
+
+ expect(loadModelSpy).not.toHaveBeenCalled()
+ expect(clearModelSpy).not.toHaveBeenCalled()
+ })
+
+ it('clears the model (and skips loadModel) when the widget value changes to "none"', async () => {
+ const config = new Load3DConfiguration(load3d)
+ const widget = { value: 'model.glb' } as unknown as IBaseWidget
+ config.configure({ modelWidget: widget, loadFolder: 'input' })
+ await flush()
+
+ loadModelSpy.mockClear()
+ clearModelSpy.mockClear()
+
+ widget.value = 'none'
+ await flush()
+
+ expect(clearModelSpy).toHaveBeenCalledTimes(1)
+ expect(loadModelSpy).not.toHaveBeenCalled()
+ })
+
+ it('loads a model when the widget value transitions from "none" to a real path', async () => {
+ const config = new Load3DConfiguration(load3d)
+ const widget = { value: 'none' } as unknown as IBaseWidget
+ config.configure({ modelWidget: widget, loadFolder: 'input' })
+ await flush()
+
+ expect(loadModelSpy).not.toHaveBeenCalled()
+
+ widget.value = 'model.glb'
+ await flush()
+
+ expect(loadModelSpy).toHaveBeenCalledWith(expect.any(String), 'model.glb', {
+ silentOnNotFound: false
+ })
+ })
+})
diff --git a/src/extensions/core/load3d/Load3DConfiguration.ts b/src/extensions/core/load3d/Load3DConfiguration.ts
index fa9fba7eb2..57e3da6453 100644
--- a/src/extensions/core/load3d/Load3DConfiguration.ts
+++ b/src/extensions/core/load3d/Load3DConfiguration.ts
@@ -1,3 +1,4 @@
+import { LOAD3D_NONE_MODEL } from '@/extensions/core/load3d/constants'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
@@ -109,7 +110,7 @@ class Load3DConfiguration {
cameraState,
silentOnNotFound
)
- if (modelWidget.value) {
+ if (modelWidget.value && modelWidget.value !== LOAD3D_NONE_MODEL) {
void onModelWidgetUpdate(modelWidget.value)
}
@@ -280,7 +281,10 @@ class Load3DConfiguration {
) {
let isFirstLoad = true
return async (value: string | number | boolean | object) => {
- if (!value) return
+ if (!value || value === LOAD3D_NONE_MODEL) {
+ this.load3d.clearModel()
+ return
+ }
const { filename, folder } = parseAnnotatedFilename(
value as string,
diff --git a/src/extensions/core/load3d/constants.ts b/src/extensions/core/load3d/constants.ts
index fb9cc0d985..08898e1860 100644
--- a/src/extensions/core/load3d/constants.ts
+++ b/src/extensions/core/load3d/constants.ts
@@ -22,3 +22,5 @@ export const SUPPORTED_HDRI_EXTENSIONS = new Set(['.hdr', '.exr'])
export const SUPPORTED_HDRI_EXTENSIONS_ACCEPT = [
...SUPPORTED_HDRI_EXTENSIONS
].join(',')
+
+export const LOAD3D_NONE_MODEL = 'none'
From 8f68be5699b99dcd046bde8c0b107fde87ab9a8c Mon Sep 17 00:00:00 2001
From: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Date: Sat, 9 May 2026 14:36:09 +0900
Subject: [PATCH 04/48] fix: handle annotated output media paths in missing
media scan (#12069)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
This PR fixes missing-media false positives for annotated media widget
values such as:
```txt
photo.png [output]
clip.mp4 [input]
147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png [output]
clip.mp4[input] // Cloud compact form
```
The change is intentionally scoped to the missing-media detection
pipeline for:
- `LoadImage`
- `LoadImageMask`
- `LoadVideo`
- `LoadAudio`
It preserves the raw widget value on `MissingMediaCandidate.name` for UI
display, grouping, replacement, and user-facing missing-media rows.
Normalized values are used only as comparison keys during verification.
## Diff Size
`main...HEAD` line diff is currently:
- Production/runtime code: `+478 / -37` (`515` changed lines)
- Unit test code: `+960 / -47` (`1,007` changed lines)
- Total: `+1,438 / -84` (`1,522` changed lines)
The PR looks large mostly because it locks both Cloud and OSS/Core
runtime paths with unit coverage; the production/runtime change is about
one third of the total diff.
## What Changed
- Added missing-media-scoped annotation helpers for detection-only path
normalization.
- Core/OSS recognizes spaced suffixes like `file.png [output]`.
- Cloud also recognizes compact suffixes like `file.png[output]`.
- User-selectable trailing `input` and `output` annotations are
normalized for matching.
- Unknown annotations and middle-of-filename annotations are left
unchanged.
- Added shared file-path helpers in `formatUtil`:
- `joinFilePath(subfolder, filename)`
- `getFilePathSeparatorVariants(filepath)`
- Updated media verification to compare candidates against both raw and
normalized match keys.
- Kept input candidates and generated output candidates in separate
identifier sets so an input asset cannot accidentally satisfy an output
reference with the same name.
- Moved missing-media source loading into `missingMediaAssetResolver` so
`missingMediaScan` remains focused on scan/verification orchestration.
- Updated Cloud generated-media verification to use the Cloud assets API
instead of job history:
- Cloud input candidates use input/public assets.
- Cloud output candidates use `output` tagged assets.
- Kept OSS/Core generated-media verification history-based, matching the
current generated-picker/widget availability model.
## Runtime Verification Paths
### Cloud
Cloud stores generated outputs as asset records. For an annotated output
value, this PR verifies against the `output` asset tag rather than job
history.
```txt
Widget value
"147257...d6e.png [output]"
|
v
Detection keys
"147257...d6e.png [output]"
"147257...d6e.png"
|
v
Cloud asset sources
input candidates -> /api/assets?include_tags=input&include_public=true
output candidates -> /api/assets?include_tags=output&include_public=true
|
v
Match against
asset.name
asset.asset_hash
subfolder/asset.name
subfolder/asset.asset_hash
slash and backslash separator variants
```
Example:
```ts
candidate.name = 'abc123.png [output]'
asset.name = 'ComfyUI_00001_.png'
asset.asset_hash = 'abc123.png'
asset.tags = ['output']
// Result: not missing
```
### OSS / Core
Core widget options for the normal loader nodes are input-folder based.
Annotated output values are resolved by Core through
`folder_paths.get_annotated_filepath()`, but the current generated
picker path is history-backed. This PR keeps OSS generated verification
aligned with that widget availability model instead of treating the full
output folder as the source of truth.
```txt
Widget value
"subfolder/photo.png [output]"
|
v
Detection keys
"subfolder/photo.png [output]"
"subfolder/photo.png"
|
v
OSS generated source
fetchHistoryPage(...)
|
v
History preview_output
filename: "photo.png"
subfolder: "subfolder"
|
v
Generated match keys
"subfolder/photo.png"
"subfolder\\photo.png"
```
This means OSS/Core verification is about whether the generated media is
currently available through the same generated/history-backed path the
widget uses, not a full disk-level executability check across the entire
output directory.
## Why Not Consolidate All Annotated Path Parsers
There are existing annotated-path parsers in image widget, Load3D, and
path creation code. This PR does not replace them.
The helper added here is detection-only: it strips annotations to build
comparison keys for missing-media verification. Parser consolidation
across widget implementations is intentionally left out of scope to keep
this fix narrow.
## Known Follow-Ups / Out Of Scope
- FE-620 tracks the separate video drag-and-drop upload race between
upload completion and missing-media detection.
- Published/shared workflow assets are still not fully represented by
`/api/assets?include_public=true`; that remains a backend/API contract
issue.
- A future backend/API contract that answers “is this workflow media
executable?” would be preferable to stitching together runtime-specific
FE sources.
- OSS/Core full output-folder scanning via `/internal/files/output` was
considered, but that endpoint is internal, shallow (`os.scandir`), and
not the same source currently used by the generated picker flow.
## Validation
- `pnpm test:unit -- missingMediaAssetResolver missingMediaScan
mediaPathDetectionUtil formatUtil`
- touched files `oxfmt`
- touched files `oxlint --fix`
- touched files `eslint --cache --fix --no-warn-ignored`
- `pnpm typecheck`
- pre-commit `pnpm knip --cache`
- pre-push `pnpm knip --cache`
`knip` passes with the existing tag hint:
```txt
Unused tag in src/scripts/metadata/flac.ts: getFromFlacBuffer → @knipIgnoreUnusedButUsedByCustomNodes
```
## Screenshots
Before
https://github.com/user-attachments/assets/50eab565-3160-4a57-a758-87ec2c09071e
After
https://github.com/user-attachments/assets/08adcbbd-c3fc-43f9-b86c-327e4eb5abd8
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12069-fix-handle-annotated-output-media-paths-in-missing-media-scan-3596d73d365081f4afa3d4dd45cad3da)
by [Unito](https://www.unito.io)
---
.../src/formatUtil.test.ts | 38 ++
.../shared-frontend-utils/src/formatUtil.ts | 28 +-
.../graph/useErrorClearingHooks.test.ts | 4 +-
.../graph/useErrorClearingHooks.ts | 8 +-
src/platform/assets/services/assetService.ts | 37 +-
.../mediaPathDetectionUtil.test.ts | 80 +++
.../missingMedia/mediaPathDetectionUtil.ts | 44 ++
.../missingMediaAssetResolver.test.ts | 325 ++++++++++
.../missingMedia/missingMediaAssetResolver.ts | 286 +++++++++
.../missingMedia/missingMediaScan.test.ts | 568 ++++++++++++++++--
src/platform/missingMedia/missingMediaScan.ts | 133 +++-
src/platform/missingMedia/types.ts | 4 +-
.../remote/comfyui/jobs/fetchJobs.test.ts | 43 +-
src/platform/remote/comfyui/jobs/fetchJobs.ts | 51 +-
src/scripts/app.ts | 10 +-
15 files changed, 1562 insertions(+), 97 deletions(-)
create mode 100644 src/platform/missingMedia/mediaPathDetectionUtil.test.ts
create mode 100644 src/platform/missingMedia/mediaPathDetectionUtil.ts
create mode 100644 src/platform/missingMedia/missingMediaAssetResolver.test.ts
create mode 100644 src/platform/missingMedia/missingMediaAssetResolver.ts
diff --git a/packages/shared-frontend-utils/src/formatUtil.test.ts b/packages/shared-frontend-utils/src/formatUtil.test.ts
index 3a1e6c877d..b05829448b 100644
--- a/packages/shared-frontend-utils/src/formatUtil.test.ts
+++ b/packages/shared-frontend-utils/src/formatUtil.test.ts
@@ -3,12 +3,14 @@ import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
+ getFilePathSeparatorVariants,
getFilenameDetails,
getMediaTypeFromFilename,
getPathDetails,
highlightQuery,
isCivitaiModelUrl,
isPreviewableMediaType,
+ joinFilePath,
truncateFilename
} from './formatUtil'
@@ -299,6 +301,42 @@ describe('formatUtil', () => {
})
})
+ describe('joinFilePath', () => {
+ it('joins subfolder and filename with normalized slash separators', () => {
+ expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe(
+ 'nested/folder/child/file.png'
+ )
+ })
+
+ it('trims boundary separators without changing the filename body', () => {
+ expect(joinFilePath('/nested/folder/', '/file.png')).toBe(
+ 'nested/folder/file.png'
+ )
+ })
+
+ it('returns the normalized filename when no subfolder is provided', () => {
+ expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png')
+ })
+
+ it('returns the normalized subfolder without a trailing slash when no filename is provided', () => {
+ expect(joinFilePath('nested\\folder', '')).toBe('nested/folder')
+ expect(joinFilePath('nested\\folder', null)).toBe('nested/folder')
+ })
+ })
+
+ describe('getFilePathSeparatorVariants', () => {
+ it('returns slash and backslash variants for nested paths', () => {
+ expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([
+ 'nested/folder/file.png',
+ 'nested\\folder\\file.png'
+ ])
+ })
+
+ it('returns a single value when no separator is present', () => {
+ expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png'])
+ })
+ })
+
describe('appendWorkflowJsonExt', () => {
it('appends .app.json when isApp is true', () => {
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')
diff --git a/packages/shared-frontend-utils/src/formatUtil.ts b/packages/shared-frontend-utils/src/formatUtil.ts
index 3e52190092..206401e4a4 100644
--- a/packages/shared-frontend-utils/src/formatUtil.ts
+++ b/packages/shared-frontend-utils/src/formatUtil.ts
@@ -256,6 +256,31 @@ export function isValidUrl(url: string): boolean {
}
}
+export function joinFilePath(
+ subfolder: string | null | undefined,
+ filename: string | null | undefined
+): string {
+ const normalizedSubfolder = normalizeFilePathSeparators(
+ subfolder ?? ''
+ ).replace(/^\/+|\/+$/g, '')
+ const normalizedFilename = normalizeFilePathSeparators(
+ filename ?? ''
+ ).replace(/^\/+/g, '')
+ if (!normalizedSubfolder) return normalizedFilename
+ if (!normalizedFilename) return normalizedSubfolder
+ return `${normalizedSubfolder}/${normalizedFilename}`
+}
+
+export function getFilePathSeparatorVariants(filepath: string): string[] {
+ const slashPath = normalizeFilePathSeparators(filepath)
+ const backslashPath = slashPath.replace(/\//g, '\\')
+ return slashPath === backslashPath ? [slashPath] : [slashPath, backslashPath]
+}
+
+function normalizeFilePathSeparators(filepath: string): string {
+ return filepath.replace(/[\\/]+/g, '/')
+}
+
/**
* Parses a filepath into its filename and subfolder components.
*
@@ -274,8 +299,7 @@ export function parseFilePath(filepath: string): {
} {
if (!filepath?.trim()) return { filename: '', subfolder: '' }
- const normalizedPath = filepath
- .replace(/[\\/]+/g, '/') // Normalize path separators
+ const normalizedPath = normalizeFilePathSeparators(filepath)
.replace(/^\//, '') // Remove leading slash
.replace(/\/$/, '') // Remove trailing slash
diff --git a/src/composables/graph/useErrorClearingHooks.test.ts b/src/composables/graph/useErrorClearingHooks.test.ts
index 8bb07a76bb..e132c00575 100644
--- a/src/composables/graph/useErrorClearingHooks.test.ts
+++ b/src/composables/graph/useErrorClearingHooks.test.ts
@@ -543,7 +543,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
}
])
const verifySpy = vi
- .spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
+ .spyOn(missingMediaScan, 'verifyMediaCandidates')
.mockImplementation(async (candidates) => {
for (const c of candidates) c.isMissing = true
})
@@ -686,7 +686,7 @@ describe('realtime verification staleness guards', () => {
let resolveVerify: (() => void) | undefined
const verifyPromise = new Promise((r) => (resolveVerify = r))
const verifySpy = vi
- .spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
+ .spyOn(missingMediaScan, 'verifyMediaCandidates')
.mockImplementation(async (candidates) => {
await verifyPromise
for (const c of candidates) c.isMissing = true
diff --git a/src/composables/graph/useErrorClearingHooks.ts b/src/composables/graph/useErrorClearingHooks.ts
index 3a6f00929a..5fcd9dd129 100644
--- a/src/composables/graph/useErrorClearingHooks.ts
+++ b/src/composables/graph/useErrorClearingHooks.ts
@@ -28,7 +28,7 @@ import {
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import {
scanNodeMediaCandidates,
- verifyCloudMediaCandidates
+ verifyMediaCandidates
} from '@/platform/missingMedia/missingMediaScan'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
@@ -209,8 +209,8 @@ function scanSingleNodeErrors(node: LGraphNode): void {
if (confirmedMedia.length) {
useMissingMediaStore().addMissingMedia(confirmedMedia)
}
- // Cloud media scans always return isMissing: undefined pending
- // verification against the input-assets list.
+ // Cloud media scans return pending for asset verification. OSS scans only
+ // return pending for generated output/temp media.
const pendingMedia = mediaCandidates.filter((c) => c.isMissing === undefined)
if (pendingMedia.length) {
void verifyAndAddPendingMedia(pendingMedia)
@@ -282,7 +282,7 @@ async function verifyAndAddPendingMedia(
): Promise {
const rootGraphAtScan = app.rootGraph
try {
- await verifyCloudMediaCandidates(pending)
+ await verifyMediaCandidates(pending, { isCloud })
if (app.rootGraph !== rootGraphAtScan) return
const verified = pending.filter(
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts
index 135303778d..3ac5dc2c71 100644
--- a/src/platform/assets/services/assetService.ts
+++ b/src/platform/assets/services/assetService.ts
@@ -480,12 +480,27 @@ function createAssetService() {
includePublic: boolean = true,
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
): Promise {
- const data = await handleAssetRequest(
+ const data = await getAssetsPageByTag(tag, includePublic, {
+ limit,
+ offset,
+ signal
+ })
+
+ return data.assets
+ }
+
+ /**
+ * Gets one paginated asset response filtered by a specific tag.
+ */
+ async function getAssetsPageByTag(
+ tag: string,
+ includePublic: boolean = true,
+ { limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
+ ): Promise {
+ return await handleAssetRequest(
{ includeTags: [tag], limit, offset, includePublic, signal },
`assets for tag ${tag}`
)
-
- return data.assets
}
/**
@@ -511,16 +526,11 @@ function createAssetService() {
while (true) {
if (signal?.aborted) throw createAbortError()
- const data = await handleAssetRequest(
- {
- includeTags: [tag],
- limit: pageSize,
- offset,
- includePublic,
- signal
- },
- `assets for tag ${tag}`
- )
+ const data = await getAssetsPageByTag(tag, includePublic, {
+ limit: pageSize,
+ offset,
+ signal
+ })
const batch = data.assets
if (batch.length === 0) {
return assets
@@ -935,6 +945,7 @@ function createAssetService() {
getAssetsForNodeType,
getAssetDetails,
getAssetsByTag,
+ getAssetsPageByTag,
getAllAssetsByTag,
getInputAssetsIncludingPublic,
invalidateInputAssetsIncludingPublic,
diff --git a/src/platform/missingMedia/mediaPathDetectionUtil.test.ts b/src/platform/missingMedia/mediaPathDetectionUtil.test.ts
new file mode 100644
index 0000000000..d9a1dba670
--- /dev/null
+++ b/src/platform/missingMedia/mediaPathDetectionUtil.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ getAnnotatedMediaPathTypeForDetection,
+ getMediaPathDetectionNames,
+ normalizeAnnotatedMediaPathForDetection
+} from './mediaPathDetectionUtil'
+
+describe('normalizeAnnotatedMediaPathForDetection', () => {
+ it.each([
+ ['photo.png [input]', 'photo.png'],
+ ['result.png [output]', 'result.png'],
+ ['photo.png [input]', 'photo.png'],
+ ['with spaces.png [output]', 'with spaces.png'],
+ ['nested/folder/video.mp4 [output]', 'nested/folder/video.mp4']
+ ])('strips Core-style annotation from %s', (value, expected) => {
+ expect(normalizeAnnotatedMediaPathForDetection(value)).toBe(expected)
+ })
+
+ it.each([
+ ['photo.png[input]', 'photo.png'],
+ ['result.png[output]', 'result.png'],
+ ['with spaces.png [output]', 'with spaces.png']
+ ])('strips Cloud compact annotation from %s', (value, expected) => {
+ expect(
+ normalizeAnnotatedMediaPathForDetection(value, {
+ allowCompactSuffix: true
+ })
+ ).toBe(expected)
+ })
+
+ it('does not strip compact annotations in Core mode', () => {
+ expect(normalizeAnnotatedMediaPathForDetection('photo.png[input]')).toBe(
+ 'photo.png[input]'
+ )
+ })
+
+ it.each(['photo.png [draft]', 'photo [output] copy.png', 'photo.png', ''])(
+ 'leaves non-matching values unchanged: %s',
+ (value) => {
+ expect(normalizeAnnotatedMediaPathForDetection(value)).toBe(value)
+ }
+ )
+})
+
+describe('getMediaPathDetectionNames', () => {
+ it('returns raw and normalized names when an annotation is stripped', () => {
+ expect(getMediaPathDetectionNames('photo.png [input]')).toEqual([
+ 'photo.png [input]',
+ 'photo.png'
+ ])
+ })
+
+ it('returns only the raw name when no annotation is stripped', () => {
+ expect(getMediaPathDetectionNames('photo.png')).toEqual(['photo.png'])
+ })
+})
+
+describe('getAnnotatedMediaPathTypeForDetection', () => {
+ it.each([
+ ['photo.png [input]', 'input'],
+ ['photo.png [output]', 'output']
+ ])('returns the Core-style annotation type from %s', (value, expected) => {
+ expect(getAnnotatedMediaPathTypeForDetection(value)).toBe(expected)
+ })
+
+ it('returns the compact annotation type in Cloud mode', () => {
+ expect(
+ getAnnotatedMediaPathTypeForDetection('photo.png[output]', {
+ allowCompactSuffix: true
+ })
+ ).toBe('output')
+ })
+
+ it('returns undefined when no supported annotation is present', () => {
+ expect(getAnnotatedMediaPathTypeForDetection('photo.png [draft]')).toBe(
+ undefined
+ )
+ })
+})
diff --git a/src/platform/missingMedia/mediaPathDetectionUtil.ts b/src/platform/missingMedia/mediaPathDetectionUtil.ts
new file mode 100644
index 0000000000..2e27311f08
--- /dev/null
+++ b/src/platform/missingMedia/mediaPathDetectionUtil.ts
@@ -0,0 +1,44 @@
+// Missing-media-scoped helpers for deriving comparison keys from media widget paths.
+const CORE_ANNOTATED_MEDIA_PATTERN = /\s+\[(input|output)\]$/
+const CLOUD_ANNOTATED_MEDIA_PATTERN = /\s*\[(input|output)\]$/
+
+type AnnotatedMediaPathType = 'input' | 'output'
+
+interface AnnotatedMediaPathOptions {
+ allowCompactSuffix?: boolean
+}
+
+function getAnnotatedMediaPathMatch(
+ value: string,
+ options: AnnotatedMediaPathOptions = {}
+): RegExpMatchArray | null {
+ const pattern = options.allowCompactSuffix
+ ? CLOUD_ANNOTATED_MEDIA_PATTERN
+ : CORE_ANNOTATED_MEDIA_PATTERN
+ return value.match(pattern)
+}
+
+export function getAnnotatedMediaPathTypeForDetection(
+ value: string,
+ options: AnnotatedMediaPathOptions = {}
+): AnnotatedMediaPathType | undefined {
+ return getAnnotatedMediaPathMatch(value, options)?.[1] as
+ | AnnotatedMediaPathType
+ | undefined
+}
+
+export function normalizeAnnotatedMediaPathForDetection(
+ value: string,
+ options: AnnotatedMediaPathOptions = {}
+): string {
+ const match = getAnnotatedMediaPathMatch(value, options)
+ return match ? value.slice(0, match.index) : value
+}
+
+export function getMediaPathDetectionNames(
+ value: string,
+ options: AnnotatedMediaPathOptions = {}
+): string[] {
+ const normalized = normalizeAnnotatedMediaPathForDetection(value, options)
+ return normalized === value ? [value] : [value, normalized]
+}
diff --git a/src/platform/missingMedia/missingMediaAssetResolver.test.ts b/src/platform/missingMedia/missingMediaAssetResolver.test.ts
new file mode 100644
index 0000000000..c6eee64c47
--- /dev/null
+++ b/src/platform/missingMedia/missingMediaAssetResolver.test.ts
@@ -0,0 +1,325 @@
+import { fromAny } from '@total-typescript/shoehorn'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import type * as AssetServiceModule from '@/platform/assets/services/assetService'
+import type * as FetchJobsModule from '@/platform/remote/comfyui/jobs/fetchJobs'
+import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
+import {
+ getAssetDetectionNames,
+ resolveMissingMediaAssetSources
+} from './missingMediaAssetResolver'
+
+const { mockGetInputAssetsIncludingPublic, mockGetAssetsPageByTag } =
+ vi.hoisted(() => ({
+ mockGetInputAssetsIncludingPublic: vi.fn(),
+ mockGetAssetsPageByTag: vi.fn()
+ }))
+
+const { mockFetchHistoryPage } = vi.hoisted(() => ({
+ mockFetchHistoryPage: vi.fn()
+}))
+
+vi.mock('@/platform/assets/services/assetService', async () => {
+ const actual = await vi.importActual(
+ '@/platform/assets/services/assetService'
+ )
+
+ return {
+ ...actual,
+ assetService: {
+ ...actual.assetService,
+ getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic,
+ getAssetsPageByTag: mockGetAssetsPageByTag
+ }
+ }
+})
+
+vi.mock('@/platform/remote/comfyui/jobs/fetchJobs', async () => {
+ const actual = await vi.importActual(
+ '@/platform/remote/comfyui/jobs/fetchJobs'
+ )
+
+ return {
+ ...actual,
+ fetchHistoryPage: mockFetchHistoryPage
+ }
+})
+
+function makeAsset(name: string, assetHash: string | null = null): AssetItem {
+ return {
+ id: name,
+ name,
+ asset_hash: assetHash,
+ mime_type: null,
+ tags: ['input']
+ }
+}
+
+function makeHistoryJob(
+ filename: string,
+ options: { id?: string; subfolder?: string } = {}
+): JobListItem {
+ return fromAny({
+ id: options.id ?? filename,
+ status: 'completed',
+ create_time: 0,
+ priority: 0,
+ preview_output: {
+ filename,
+ subfolder: options.subfolder ?? '',
+ type: 'output',
+ nodeId: '1',
+ mediaType: 'images'
+ }
+ })
+}
+
+function makeHistoryPage(
+ jobs: JobListItem[],
+ options: { offset?: number; hasMore?: boolean; total?: number } = {}
+) {
+ return {
+ jobs,
+ total: options.total ?? jobs.length,
+ offset: options.offset ?? 0,
+ limit: 200,
+ hasMore: options.hasMore ?? false
+ }
+}
+
+function makeAssetPage(
+ assets: AssetItem[],
+ options: { hasMore?: boolean; total?: number } = {}
+) {
+ return {
+ assets,
+ total: options.total ?? assets.length,
+ has_more: options.hasMore ?? false
+ }
+}
+
+describe('resolveMissingMediaAssetSources', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetInputAssetsIncludingPublic.mockResolvedValue([])
+ mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([]))
+ mockFetchHistoryPage.mockResolvedValue(makeHistoryPage([]))
+ })
+
+ it('loads cloud input assets when requested', async () => {
+ const inputAsset = makeAsset('photo.png')
+ mockGetInputAssetsIncludingPublic.mockResolvedValue([inputAsset])
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: true,
+ includeGeneratedAssets: false,
+ generatedMatchNames: new Set(),
+ allowCompactSuffix: true
+ })
+
+ expect(result.inputAssets).toEqual([inputAsset])
+ expect(result.generatedAssets).toEqual([])
+ expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
+ expect.any(AbortSignal)
+ )
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ })
+
+ it('loads cloud output assets by tag when generated candidates need verification', async () => {
+ const outputAsset = makeAsset('output.png')
+ mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([outputAsset]))
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: true,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set(['output.png']),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toEqual([outputAsset])
+ expect(mockGetAssetsPageByTag).toHaveBeenCalledWith(
+ 'output',
+ true,
+ expect.objectContaining({
+ limit: 500,
+ offset: 0,
+ signal: expect.any(AbortSignal)
+ })
+ )
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ })
+
+ it('stops reading cloud output asset pages once all requested names are found', async () => {
+ const target = 'target-output.png'
+ mockGetAssetsPageByTag.mockResolvedValueOnce(
+ makeAssetPage([makeAsset(target)], { hasMore: true, total: 501 })
+ )
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: true,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set([target]),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toEqual([makeAsset(target)])
+ expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
+ })
+
+ it('aborts cloud output asset loading when input asset loading fails', async () => {
+ const inputError = new Error('input failed')
+ let rejectInputAssets!: (err: Error) => void
+ let resolveOutputAssets!: (page: ReturnType) => void
+ mockGetInputAssetsIncludingPublic.mockReturnValueOnce(
+ new Promise((_, reject) => {
+ rejectInputAssets = reject
+ })
+ )
+ mockGetAssetsPageByTag.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveOutputAssets = resolve
+ })
+ )
+
+ const promise = resolveMissingMediaAssetSources({
+ isCloud: true,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set(['target.png']),
+ allowCompactSuffix: true
+ })
+
+ await Promise.resolve()
+ expect(mockGetAssetsPageByTag).toHaveBeenCalledOnce()
+
+ rejectInputAssets(inputError)
+ await expect(promise).rejects.toBe(inputError)
+
+ resolveOutputAssets(makeAssetPage([makeAsset('other.png')]))
+ await Promise.resolve()
+
+ const outputSignal = mockGetAssetsPageByTag.mock.calls[0]?.[2]?.signal
+ expect(outputSignal).toBeInstanceOf(AbortSignal)
+ expect(outputSignal.aborted).toBe(true)
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ })
+
+ it('stops reading generated history once all requested names are found', async () => {
+ const target = 'target.png'
+ mockFetchHistoryPage.mockResolvedValueOnce(
+ makeHistoryPage([makeHistoryJob(target)], {
+ hasMore: true,
+ total: 400
+ })
+ )
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: false,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set([target]),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toHaveLength(1)
+ expect(result.generatedAssets[0].name).toBe(target)
+ expect(mockFetchHistoryPage).toHaveBeenCalledOnce()
+ })
+
+ it('advances pagination from the requested offset, not the echoed offset', async () => {
+ const target = 'target.png'
+ mockFetchHistoryPage
+ .mockResolvedValueOnce(
+ makeHistoryPage(
+ Array.from({ length: 200 }, (_, index) =>
+ makeHistoryJob(`other-${index}.png`)
+ ),
+ { offset: 0, hasMore: true, total: 201 }
+ )
+ )
+ .mockResolvedValueOnce(
+ makeHistoryPage([makeHistoryJob(target)], {
+ offset: 0,
+ hasMore: true,
+ total: 201
+ })
+ )
+
+ await resolveMissingMediaAssetSources({
+ isCloud: false,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set([target]),
+ allowCompactSuffix: true
+ })
+
+ expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
+ 1,
+ expect.any(Function),
+ 200,
+ 0
+ )
+ expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
+ 2,
+ expect.any(Function),
+ 200,
+ 200
+ )
+ })
+
+ it('stops if history reports hasMore but returns an empty page', async () => {
+ mockFetchHistoryPage.mockResolvedValueOnce(
+ makeHistoryPage([], { hasMore: true, total: 1 })
+ )
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: false,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set(['missing.png']),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toEqual([])
+ expect(mockFetchHistoryPage).toHaveBeenCalledOnce()
+ })
+
+ it('stops if history repeats the same job page', async () => {
+ const repeatedJob = makeHistoryJob('other.png', { id: 'same-job' })
+ mockFetchHistoryPage
+ .mockResolvedValueOnce(
+ makeHistoryPage([repeatedJob], { hasMore: true, total: 2 })
+ )
+ .mockResolvedValueOnce(
+ makeHistoryPage([repeatedJob], { offset: 1, hasMore: true, total: 2 })
+ )
+
+ const result = await resolveMissingMediaAssetSources({
+ isCloud: false,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set(['missing.png']),
+ allowCompactSuffix: true
+ })
+
+ expect(result.generatedAssets).toHaveLength(1)
+ expect(mockFetchHistoryPage).toHaveBeenCalledTimes(2)
+ })
+
+ it('includes slash and backslash subfolder identifiers for detection', () => {
+ const names = getAssetDetectionNames(
+ {
+ ...makeAsset('child\\photo.png', 'hash.png'),
+ user_metadata: { subfolder: 'nested\\folder' }
+ },
+ { allowCompactSuffix: true }
+ )
+
+ expect(names).toEqual(
+ expect.arrayContaining([
+ 'child\\photo.png',
+ 'hash.png',
+ 'nested/folder/child/photo.png',
+ 'nested\\folder\\child\\photo.png'
+ ])
+ )
+ expect(names).not.toContain('nested/folder/hash.png')
+ expect(names).not.toContain('nested\\folder\\hash.png')
+ })
+})
diff --git a/src/platform/missingMedia/missingMediaAssetResolver.ts b/src/platform/missingMedia/missingMediaAssetResolver.ts
new file mode 100644
index 0000000000..00732f8dc5
--- /dev/null
+++ b/src/platform/missingMedia/missingMediaAssetResolver.ts
@@ -0,0 +1,286 @@
+import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
+import { assetService } from '@/platform/assets/services/assetService'
+import { fetchHistoryPage } from '@/platform/remote/comfyui/jobs/fetchJobs'
+import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
+import { api } from '@/scripts/api'
+import { getFilePathSeparatorVariants, joinFilePath } from '@/utils/formatUtil'
+import { getMediaPathDetectionNames } from './mediaPathDetectionUtil'
+
+const HISTORY_MEDIA_ASSETS_PAGE_SIZE = 200
+const CLOUD_OUTPUT_ASSETS_PAGE_SIZE = 500
+
+interface MediaPathDetectionOptions {
+ allowCompactSuffix: boolean
+}
+
+export interface MissingMediaAssetSources {
+ inputAssets: AssetItem[]
+ generatedAssets: AssetItem[]
+}
+
+export interface ResolveMissingMediaAssetSourcesOptions {
+ signal?: AbortSignal
+ isCloud: boolean
+ includeGeneratedAssets: boolean
+ generatedMatchNames: ReadonlySet
+ allowCompactSuffix: boolean
+}
+
+export type MissingMediaAssetResolver = (
+ options: ResolveMissingMediaAssetSourcesOptions
+) => Promise
+
+export async function resolveMissingMediaAssetSources({
+ signal,
+ isCloud,
+ includeGeneratedAssets,
+ generatedMatchNames,
+ allowCompactSuffix
+}: ResolveMissingMediaAssetSourcesOptions): Promise {
+ const pathOptions = { allowCompactSuffix }
+
+ const controller = new AbortController()
+ const abortFromCaller = () => controller.abort(signal?.reason)
+ if (signal?.aborted) {
+ abortFromCaller()
+ } else {
+ signal?.addEventListener('abort', abortFromCaller, { once: true })
+ }
+
+ try {
+ const [inputAssets, generatedAssets] = await Promise.all([
+ abortSiblingsOnFailure(
+ isCloud
+ ? assetService.getInputAssetsIncludingPublic(controller.signal)
+ : Promise.resolve([]),
+ controller
+ ),
+ abortSiblingsOnFailure(
+ includeGeneratedAssets
+ ? fetchGeneratedAssets(controller.signal, {
+ isCloud,
+ generatedMatchNames,
+ pathOptions
+ })
+ : Promise.resolve([]),
+ controller
+ )
+ ])
+
+ return { inputAssets, generatedAssets }
+ } finally {
+ signal?.removeEventListener('abort', abortFromCaller)
+ }
+}
+
+interface FetchGeneratedAssetsOptions {
+ isCloud: boolean
+ generatedMatchNames: ReadonlySet
+ pathOptions: MediaPathDetectionOptions
+}
+
+export function getAssetDetectionNames(
+ asset: AssetItem,
+ options: MediaPathDetectionOptions
+): string[] {
+ const names = new Set()
+ // Treat names and hashes as opaque match keys because Cloud may use either in widget values.
+ addPathDetectionNames(names, asset.asset_hash, options)
+ addPathDetectionNames(names, asset.name, options)
+
+ const subfolder = asset.user_metadata?.subfolder
+ if (typeof subfolder === 'string' && subfolder) {
+ addSubfolderPathDetectionNames(names, subfolder, asset.name, options)
+ }
+
+ return Array.from(names)
+}
+
+async function fetchGeneratedAssets(
+ signal: AbortSignal | undefined,
+ { isCloud, generatedMatchNames, pathOptions }: FetchGeneratedAssetsOptions
+): Promise {
+ if (isCloud) {
+ return await fetchCloudGeneratedAssets(
+ signal,
+ generatedMatchNames,
+ pathOptions
+ )
+ }
+
+ return await fetchGeneratedHistoryAssets(
+ signal,
+ generatedMatchNames,
+ pathOptions
+ )
+}
+
+async function fetchCloudGeneratedAssets(
+ signal: AbortSignal | undefined,
+ targetNames: ReadonlySet,
+ pathOptions: MediaPathDetectionOptions
+): Promise {
+ const assets: AssetItem[] = []
+ const foundTargetNames = new Set()
+ let offset = 0
+
+ while (true) {
+ signal?.throwIfAborted()
+
+ const assetPage = await assetService.getAssetsPageByTag('output', true, {
+ limit: CLOUD_OUTPUT_ASSETS_PAGE_SIZE,
+ offset,
+ signal
+ })
+
+ signal?.throwIfAborted()
+
+ const batch = assetPage.assets
+ if (batch.length === 0) return assets
+
+ for (const asset of batch) {
+ assets.push(asset)
+ rememberResolvedTargetNames(
+ asset,
+ targetNames,
+ foundTargetNames,
+ pathOptions
+ )
+ }
+
+ if (
+ !assetPage.has_more ||
+ hasResolvedAllTargetNames(targetNames, foundTargetNames)
+ ) {
+ return assets
+ }
+
+ offset += batch.length
+ }
+}
+
+async function fetchGeneratedHistoryAssets(
+ signal: AbortSignal | undefined,
+ targetNames: ReadonlySet,
+ pathOptions: MediaPathDetectionOptions
+): Promise {
+ const assets: AssetItem[] = []
+ const foundTargetNames = new Set()
+ const seenJobIds = new Set()
+ let offset = 0
+
+ while (true) {
+ signal?.throwIfAborted()
+
+ const requestedOffset = offset
+ const historyPage = await fetchHistoryPage(
+ api.fetchApi.bind(api),
+ HISTORY_MEDIA_ASSETS_PAGE_SIZE,
+ requestedOffset
+ )
+
+ signal?.throwIfAborted()
+
+ let newJobCount = 0
+ for (const job of historyPage.jobs) {
+ if (seenJobIds.has(job.id)) continue
+ seenJobIds.add(job.id)
+ newJobCount += 1
+
+ const asset = mapHistoryJobToAsset(job)
+ if (!asset) continue
+
+ assets.push(asset)
+ rememberResolvedTargetNames(
+ asset,
+ targetNames,
+ foundTargetNames,
+ pathOptions
+ )
+ }
+
+ if (
+ !historyPage.hasMore ||
+ historyPage.jobs.length === 0 ||
+ newJobCount === 0 ||
+ hasResolvedAllTargetNames(targetNames, foundTargetNames)
+ ) {
+ return assets
+ }
+
+ offset = requestedOffset + historyPage.jobs.length
+ }
+}
+
+async function abortSiblingsOnFailure(
+ promise: Promise,
+ controller: AbortController
+): Promise {
+ try {
+ return await promise
+ } catch (err) {
+ if (!controller.signal.aborted) controller.abort(err)
+ throw err
+ }
+}
+
+function addPathDetectionNames(
+ names: Set,
+ value: string | null | undefined,
+ options: MediaPathDetectionOptions
+) {
+ if (!value) return
+ for (const name of getMediaPathDetectionNames(value, options)) {
+ names.add(name)
+ }
+}
+
+function addSubfolderPathDetectionNames(
+ names: Set,
+ subfolder: string,
+ value: string | null | undefined,
+ options: MediaPathDetectionOptions
+) {
+ if (!value) return
+
+ const filePath = joinFilePath(subfolder, value)
+ for (const path of getFilePathSeparatorVariants(filePath)) {
+ addPathDetectionNames(names, path, options)
+ }
+}
+
+function rememberResolvedTargetNames(
+ asset: AssetItem,
+ targetNames: ReadonlySet,
+ foundTargetNames: Set,
+ options: MediaPathDetectionOptions
+) {
+ if (targetNames.size === 0) return
+
+ for (const name of getAssetDetectionNames(asset, options)) {
+ if (targetNames.has(name)) foundTargetNames.add(name)
+ }
+}
+
+function hasResolvedAllTargetNames(
+ targetNames: ReadonlySet,
+ foundTargetNames: ReadonlySet
+): boolean {
+ return targetNames.size > 0 && foundTargetNames.size === targetNames.size
+}
+
+function mapHistoryJobToAsset(job: JobListItem): AssetItem | null {
+ const output = job.preview_output
+ if (job.status !== 'completed' || !output?.filename) return null
+
+ return {
+ id: `${job.id}-${output.filename}`,
+ name: output.filename,
+ display_name: output.display_name,
+ mime_type: null,
+ tags: ['output'],
+ user_metadata: {
+ subfolder: output.subfolder
+ }
+ }
+}
diff --git a/src/platform/missingMedia/missingMediaScan.test.ts b/src/platform/missingMedia/missingMediaScan.test.ts
index 275e2450d9..78073743bc 100644
--- a/src/platform/missingMedia/missingMediaScan.test.ts
+++ b/src/platform/missingMedia/missingMediaScan.test.ts
@@ -6,17 +6,26 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type * as AssetServiceModule from '@/platform/assets/services/assetService'
+import type * as FetchJobsModule from '@/platform/remote/comfyui/jobs/fetchJobs'
+import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
+import type { MissingMediaAssetResolver } from './missingMediaAssetResolver'
import {
scanAllMediaCandidates,
scanNodeMediaCandidates,
- verifyCloudMediaCandidates,
+ verifyMediaCandidates,
groupCandidatesByName,
groupCandidatesByMediaType
} from './missingMediaScan'
import type { MissingMediaCandidate } from './types'
-const { mockGetInputAssetsIncludingPublic } = vi.hoisted(() => ({
- mockGetInputAssetsIncludingPublic: vi.fn()
+const { mockGetInputAssetsIncludingPublic, mockGetAssetsPageByTag } =
+ vi.hoisted(() => ({
+ mockGetInputAssetsIncludingPublic: vi.fn(),
+ mockGetAssetsPageByTag: vi.fn()
+ }))
+
+const { mockFetchHistoryPage } = vi.hoisted(() => ({
+ mockFetchHistoryPage: vi.fn()
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
@@ -36,11 +45,23 @@ vi.mock('@/platform/assets/services/assetService', async () => {
...actual,
assetService: {
...actual.assetService,
- getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic
+ getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic,
+ getAssetsPageByTag: mockGetAssetsPageByTag
}
}
})
+vi.mock('@/platform/remote/comfyui/jobs/fetchJobs', async () => {
+ const actual = await vi.importActual(
+ '@/platform/remote/comfyui/jobs/fetchJobs'
+ )
+
+ return {
+ ...actual,
+ fetchHistoryPage: mockFetchHistoryPage
+ }
+})
+
function makeCandidate(
nodeId: string,
name: string,
@@ -100,6 +121,43 @@ function makeAsset(name: string, assetHash: string | null = null): AssetItem {
}
}
+function makeAssetResolver(
+ inputAssets: AssetItem[],
+ generatedAssets: AssetItem[] = []
+): MissingMediaAssetResolver {
+ return vi.fn(async () => ({ inputAssets, generatedAssets }))
+}
+
+function makeAssetPage(
+ assets: AssetItem[],
+ options: { hasMore?: boolean; total?: number } = {}
+) {
+ return {
+ assets,
+ total: options.total ?? assets.length,
+ has_more: options.hasMore ?? false
+ }
+}
+
+function makeHistoryJob(
+ filename: string,
+ options: { id?: string; subfolder?: string } = {}
+): JobListItem {
+ return fromAny({
+ id: options.id ?? filename,
+ status: 'completed',
+ create_time: 0,
+ priority: 0,
+ preview_output: {
+ filename,
+ subfolder: options.subfolder ?? '',
+ type: 'output',
+ nodeId: '1',
+ mediaType: 'images'
+ }
+ })
+}
+
describe('scanNodeMediaCandidates', () => {
it('returns candidate for a LoadImage node with missing image', () => {
const graph = makeGraph([])
@@ -145,6 +203,131 @@ describe('scanNodeMediaCandidates', () => {
expect(result).toEqual([])
})
+
+ it.each([
+ {
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ value: 'photo.png [input]',
+ option: 'photo.png'
+ },
+ {
+ nodeType: 'LoadImageMask',
+ widgetName: 'image',
+ mediaType: 'image',
+ value: 'mask.png [input]',
+ option: 'mask.png'
+ },
+ {
+ nodeType: 'LoadVideo',
+ widgetName: 'file',
+ mediaType: 'video',
+ value: 'clip.mp4 [input]',
+ option: 'clip.mp4'
+ },
+ {
+ nodeType: 'LoadAudio',
+ widgetName: 'audio',
+ mediaType: 'audio',
+ value: 'sound.wav [input]',
+ option: 'sound.wav'
+ }
+ ])(
+ 'matches annotated $nodeType values against clean OSS options',
+ ({ nodeType, widgetName, mediaType, value, option }) => {
+ const graph = makeGraph([])
+ const node = makeMediaNode(
+ 1,
+ nodeType,
+ [makeMediaCombo(widgetName, value, [option])],
+ 0
+ )
+
+ const result = scanNodeMediaCandidates(graph, node, false)
+
+ expect(result).toHaveLength(1)
+ expect(result[0]).toMatchObject({
+ nodeType,
+ widgetName,
+ mediaType,
+ name: value,
+ isMissing: false
+ })
+ }
+ )
+
+ it.each([
+ {
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ value: 'photo.png [output]'
+ },
+ {
+ nodeType: 'LoadVideo',
+ widgetName: 'file',
+ value: 'clip.mp4 [output]'
+ },
+ {
+ nodeType: 'LoadAudio',
+ widgetName: 'audio',
+ value: 'sound.wav [output]'
+ }
+ ])(
+ 'leaves OSS $nodeType output annotations pending when not in options',
+ ({ nodeType, widgetName, value }) => {
+ const graph = makeGraph([])
+ const node = makeMediaNode(
+ 1,
+ nodeType,
+ [makeMediaCombo(widgetName, value, ['other-file.png', value])],
+ 0
+ )
+
+ const result = scanNodeMediaCandidates(graph, node, false)
+
+ expect(result[0]).toMatchObject({
+ nodeType,
+ widgetName,
+ name: value,
+ isMissing: undefined
+ })
+ }
+ )
+
+ it('marks OSS input annotations missing when the clean option is absent', () => {
+ const graph = makeGraph([])
+ const node = makeMediaNode(
+ 1,
+ 'LoadImage',
+ [makeMediaCombo('image', 'photo.png [input]', ['other.png'])],
+ 0
+ )
+
+ const result = scanNodeMediaCandidates(graph, node, false)
+
+ expect(result[0]).toMatchObject({
+ name: 'photo.png [input]',
+ isMissing: true
+ })
+ })
+
+ it('does not treat compact Cloud annotations as valid OSS options', () => {
+ const graph = makeGraph([])
+ const node = makeMediaNode(
+ 1,
+ 'LoadImage',
+ [makeMediaCombo('image', 'photo.png[input]', ['photo.png'])],
+ 0
+ )
+
+ const result = scanNodeMediaCandidates(graph, node, false)
+
+ expect(result[0]).toMatchObject({
+ name: 'photo.png[input]',
+ isMissing: true
+ })
+ })
})
describe('scanAllMediaCandidates', () => {
@@ -261,7 +444,7 @@ describe('groupCandidatesByMediaType', () => {
})
})
-describe('verifyCloudMediaCandidates', () => {
+describe('verifyMediaCandidates', () => {
const existingHash =
'blake3:1111111111111111111111111111111111111111111111111111111111111111'
const missingHash =
@@ -270,6 +453,14 @@ describe('verifyCloudMediaCandidates', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetInputAssetsIncludingPublic.mockResolvedValue([])
+ mockGetAssetsPageByTag.mockResolvedValue(makeAssetPage([]))
+ mockFetchHistoryPage.mockResolvedValue({
+ jobs: [],
+ total: 0,
+ offset: 0,
+ limit: 200,
+ hasMore: false
+ })
})
it('matches candidates by available input asset name or hash', async () => {
@@ -278,16 +469,25 @@ describe('verifyCloudMediaCandidates', () => {
makeCandidate('2', existingHash, { isMissing: undefined }),
makeCandidate('3', missingHash, { isMissing: undefined })
]
- const fetchInputAssets = vi.fn(async () => [
+ const resolveAssetSources = makeAssetResolver([
makeAsset('photo.png', existingHash)
])
- await verifyCloudMediaCandidates(candidates, undefined, fetchInputAssets)
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
expect(candidates[0].isMissing).toBe(false)
expect(candidates[1].isMissing).toBe(false)
expect(candidates[2].isMissing).toBe(true)
- expect(fetchInputAssets).toHaveBeenCalledOnce()
+ expect(resolveAssetSources).toHaveBeenCalledWith({
+ signal: undefined,
+ isCloud: true,
+ includeGeneratedAssets: false,
+ generatedMatchNames: new Set(),
+ allowCompactSuffix: true
+ })
})
it('matches asset names when asset_hash is null', async () => {
@@ -295,22 +495,202 @@ describe('verifyCloudMediaCandidates', () => {
makeCandidate('1', 'legacy-photo.png', { isMissing: undefined }),
makeCandidate('2', 'missing-photo.png', { isMissing: undefined })
]
- const fetchInputAssets = vi.fn(async () => [
+ const resolveAssetSources = makeAssetResolver([
makeAsset('legacy-photo.png', null)
])
- await verifyCloudMediaCandidates(candidates, undefined, fetchInputAssets)
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
expect(candidates[0].isMissing).toBe(false)
expect(candidates[1].isMissing).toBe(true)
})
+ it('matches annotated candidate names against clean asset names', async () => {
+ const candidates = [
+ makeCandidate('1', 'photo.png [input]', { isMissing: undefined }),
+ makeCandidate('2', 'clip.mp4[input]', {
+ nodeType: 'LoadVideo',
+ widgetName: 'file',
+ mediaType: 'video',
+ isMissing: undefined
+ }),
+ makeCandidate('3', 'missing.wav [output]', {
+ nodeType: 'LoadAudio',
+ widgetName: 'audio',
+ mediaType: 'audio',
+ isMissing: undefined
+ })
+ ]
+ const resolveAssetSources = makeAssetResolver(
+ [makeAsset('photo.png'), makeAsset('clip.mp4')],
+ []
+ )
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(candidates[0]).toMatchObject({
+ name: 'photo.png [input]',
+ isMissing: false
+ })
+ expect(candidates[1]).toMatchObject({
+ name: 'clip.mp4[input]',
+ isMissing: false
+ })
+ expect(candidates[2]).toMatchObject({
+ name: 'missing.wav [output]',
+ isMissing: true
+ })
+ })
+
+ it('matches output hash filenames against generated media assets', async () => {
+ const candidates = [
+ makeCandidate(
+ '1',
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png [output]',
+ {
+ isMissing: undefined
+ }
+ )
+ ]
+ const resolveAssetSources = makeAssetResolver(
+ [],
+ [
+ makeAsset(
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
+ )
+ ]
+ )
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(resolveAssetSources).toHaveBeenCalledWith({
+ signal: undefined,
+ isCloud: true,
+ includeGeneratedAssets: true,
+ generatedMatchNames: new Set([
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
+ ]),
+ allowCompactSuffix: true
+ })
+ expect(candidates[0]).toMatchObject({
+ name: '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png [output]',
+ isMissing: false
+ })
+ })
+
+ it('does not satisfy output annotations with input assets of the same name', async () => {
+ const candidates = [
+ makeCandidate('1', 'photo.png [output]', { isMissing: undefined })
+ ]
+ const resolveAssetSources = makeAssetResolver([makeAsset('photo.png')])
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(candidates[0].isMissing).toBe(true)
+ })
+
+ it('does not satisfy input candidates with output assets of the same name', async () => {
+ const candidates = [
+ makeCandidate('1', 'photo.png', { isMissing: undefined })
+ ]
+ const resolveAssetSources = makeAssetResolver([], [makeAsset('photo.png')])
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(candidates[0].isMissing).toBe(true)
+ })
+
+ it('verifies OSS output candidates against generated history without cloud assets', async () => {
+ const candidates = [
+ makeCandidate('1', 'subfolder/photo.png [output]', {
+ isMissing: undefined
+ })
+ ]
+
+ mockFetchHistoryPage.mockResolvedValueOnce({
+ jobs: [makeHistoryJob('photo.png', { subfolder: 'subfolder' })],
+ total: 1,
+ offset: 0,
+ limit: 200,
+ hasMore: false
+ })
+
+ await verifyMediaCandidates(candidates, { isCloud: false })
+
+ expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
+ expect(mockFetchHistoryPage).toHaveBeenCalledWith(
+ expect.any(Function),
+ 200,
+ 0
+ )
+ expect(candidates[0]).toMatchObject({
+ name: 'subfolder/photo.png [output]',
+ isMissing: false
+ })
+ })
+
+ it('does not normalize compact annotations when verifying OSS candidates', async () => {
+ const candidates = [
+ makeCandidate('1', 'photo.png[output]', { isMissing: undefined })
+ ]
+ const resolveAssetSources = makeAssetResolver([makeAsset('photo.png')])
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: false,
+ resolveAssetSources
+ })
+
+ expect(resolveAssetSources).toHaveBeenCalledWith({
+ signal: undefined,
+ isCloud: false,
+ includeGeneratedAssets: false,
+ generatedMatchNames: new Set(),
+ allowCompactSuffix: false
+ })
+ expect(candidates[0].isMissing).toBe(true)
+ })
+
+ it('matches when the asset identifier itself is annotated', async () => {
+ const candidates = [
+ makeCandidate('1', 'clip.mp4[output]', { isMissing: undefined })
+ ]
+ const resolveAssetSources = makeAssetResolver(
+ [],
+ [makeAsset('clip.mp4 [output]')]
+ )
+
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources
+ })
+
+ expect(candidates[0].isMissing).toBe(false)
+ })
+
it('marks pending candidates missing when no input assets are available', async () => {
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
]
- await verifyCloudMediaCandidates(candidates, undefined, async () => [])
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ resolveAssetSources: makeAssetResolver([])
+ })
expect(candidates[0].isMissing).toBe(true)
})
@@ -323,10 +703,104 @@ describe('verifyCloudMediaCandidates', () => {
makeAsset('stored-photo.png', existingHash)
])
- await verifyCloudMediaCandidates(candidates)
+ await verifyMediaCandidates(candidates, { isCloud: true })
expect(candidates[0].isMissing).toBe(false)
- expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(undefined)
+ expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
+ expect.any(AbortSignal)
+ )
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ })
+
+ it('reads cloud output assets by tag for output candidates', async () => {
+ const outputHash =
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
+ const candidates = [
+ makeCandidate('1', `${outputHash} [output]`, { isMissing: undefined })
+ ]
+ mockGetAssetsPageByTag.mockResolvedValue(
+ makeAssetPage([makeAsset(outputHash)])
+ )
+
+ await verifyMediaCandidates(candidates, { isCloud: true })
+
+ expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
+ expect.any(AbortSignal)
+ )
+ expect(mockGetAssetsPageByTag).toHaveBeenCalledWith(
+ 'output',
+ true,
+ expect.objectContaining({
+ limit: 500,
+ offset: 0,
+ signal: expect.any(AbortSignal)
+ })
+ )
+ expect(mockFetchHistoryPage).not.toHaveBeenCalled()
+ expect(candidates[0].isMissing).toBe(false)
+ })
+
+ it('walks OSS generated history pages until hasMore is false', async () => {
+ const outputHash =
+ '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
+ const candidates = [
+ makeCandidate('1', `${outputHash} [output]`, { isMissing: undefined })
+ ]
+ mockFetchHistoryPage
+ .mockResolvedValueOnce({
+ jobs: Array.from({ length: 200 }, (_, index) =>
+ makeHistoryJob(`other-${index}.png`)
+ ),
+ total: 201,
+ offset: 0,
+ limit: 200,
+ hasMore: true
+ })
+ .mockResolvedValueOnce({
+ jobs: [makeHistoryJob(outputHash)],
+ total: 201,
+ offset: 200,
+ limit: 200,
+ hasMore: false
+ })
+
+ await verifyMediaCandidates(candidates, { isCloud: false })
+
+ expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
+ 1,
+ expect.any(Function),
+ 200,
+ 0
+ )
+ expect(mockFetchHistoryPage).toHaveBeenNthCalledWith(
+ 2,
+ expect.any(Function),
+ 200,
+ 200
+ )
+ expect(candidates[0].isMissing).toBe(false)
+ })
+
+ it('trusts OSS history hasMore instead of page length', async () => {
+ const candidates = [
+ makeCandidate('1', 'missing-output.png [output]', {
+ isMissing: undefined
+ })
+ ]
+ mockFetchHistoryPage.mockResolvedValueOnce({
+ jobs: Array.from({ length: 200 }, (_, index) =>
+ makeHistoryJob(`other-${index}.png`)
+ ),
+ total: 200,
+ offset: 0,
+ limit: 200,
+ hasMore: false
+ })
+
+ await verifyMediaCandidates(candidates, { isCloud: false })
+
+ expect(mockFetchHistoryPage).toHaveBeenCalledOnce()
+ expect(candidates[0].isMissing).toBe(true)
})
it('respects abort signal before execution', async () => {
@@ -337,7 +811,10 @@ describe('verifyCloudMediaCandidates', () => {
makeCandidate('1', missingHash, { isMissing: undefined })
]
- await verifyCloudMediaCandidates(candidates, controller.signal)
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ signal: controller.signal
+ })
expect(candidates[0].isMissing).toBeUndefined()
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
@@ -348,16 +825,19 @@ describe('verifyCloudMediaCandidates', () => {
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
- const fetchInputAssets = vi.fn(async () => {
+ const resolveAssetSources: MissingMediaAssetResolver = vi.fn(async () => {
controller.abort()
- return [makeAsset('stored-photo.png', existingHash)]
+ return {
+ inputAssets: [makeAsset('stored-photo.png', existingHash)],
+ generatedAssets: []
+ }
})
- await verifyCloudMediaCandidates(
- candidates,
- controller.signal,
- fetchInputAssets
- )
+ await verifyMediaCandidates(candidates, {
+ isCloud: true,
+ signal: controller.signal,
+ resolveAssetSources
+ })
expect(candidates[0].isMissing).toBeUndefined()
})
@@ -365,7 +845,7 @@ describe('verifyCloudMediaCandidates', () => {
it('skips candidates already resolved as true', async () => {
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
- await verifyCloudMediaCandidates(candidates)
+ await verifyMediaCandidates(candidates, { isCloud: true })
expect(candidates[0].isMissing).toBe(true)
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
@@ -374,7 +854,7 @@ describe('verifyCloudMediaCandidates', () => {
it('skips candidates already resolved as false', async () => {
const candidates = [makeCandidate('1', existingHash, { isMissing: false })]
- await verifyCloudMediaCandidates(candidates)
+ await verifyMediaCandidates(candidates, { isCloud: true })
expect(candidates[0].isMissing).toBe(false)
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
@@ -383,7 +863,7 @@ describe('verifyCloudMediaCandidates', () => {
it('skips entirely when no pending candidates', async () => {
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
- await verifyCloudMediaCandidates(candidates)
+ await verifyMediaCandidates(candidates, { isCloud: true })
expect(mockGetInputAssetsIncludingPublic).not.toHaveBeenCalled()
})
@@ -398,9 +878,11 @@ describe('verifyCloudMediaCandidates', () => {
inputAssets[42] = makeAsset('public-asset-record', 'public-photo.png')
mockGetInputAssetsIncludingPublic.mockResolvedValue(inputAssets)
- await verifyCloudMediaCandidates(candidates)
+ await verifyMediaCandidates(candidates, { isCloud: true })
- expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(undefined)
+ expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
+ expect.any(AbortSignal)
+ )
expect(candidates[0].isMissing).toBe(false)
})
@@ -411,17 +893,17 @@ describe('verifyCloudMediaCandidates', () => {
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
]
- const fetchInputAssets = vi.fn(async () => {
+ const resolveAssetSources: MissingMediaAssetResolver = vi.fn(async () => {
controller.abort()
throw abortError
})
await expect(
- verifyCloudMediaCandidates(
- candidates,
- controller.signal,
- fetchInputAssets
- )
+ verifyMediaCandidates(candidates, {
+ isCloud: true,
+ signal: controller.signal,
+ resolveAssetSources
+ })
).resolves.toBeUndefined()
expect(candidates[0].isMissing).toBeUndefined()
@@ -434,18 +916,24 @@ describe('verifyCloudMediaCandidates', () => {
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
]
- mockGetInputAssetsIncludingPublic.mockImplementationOnce(async () => {
- controller.abort()
- throw abortError
- })
+ let serviceSignal: AbortSignal | undefined
+ mockGetInputAssetsIncludingPublic.mockImplementationOnce(
+ async (signal?: AbortSignal) => {
+ serviceSignal = signal
+ controller.abort()
+ throw abortError
+ }
+ )
await expect(
- verifyCloudMediaCandidates(candidates, controller.signal)
+ verifyMediaCandidates(candidates, {
+ isCloud: true,
+ signal: controller.signal
+ })
).resolves.toBeUndefined()
- expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
- controller.signal
- )
+ expect(serviceSignal).toBeInstanceOf(AbortSignal)
+ expect(serviceSignal?.aborted).toBe(true)
expect(candidates[0].isMissing).toBeUndefined()
})
})
diff --git a/src/platform/missingMedia/missingMediaScan.ts b/src/platform/missingMedia/missingMediaScan.ts
index b8a2257c64..afbd3bcf27 100644
--- a/src/platform/missingMedia/missingMediaScan.ts
+++ b/src/platform/missingMedia/missingMediaScan.ts
@@ -19,8 +19,17 @@ import {
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
-import { assetService } from '@/platform/assets/services/assetService'
import { isAbortError } from '@/utils/typeGuardUtil'
+import {
+ getAnnotatedMediaPathTypeForDetection,
+ getMediaPathDetectionNames,
+ normalizeAnnotatedMediaPathForDetection
+} from './mediaPathDetectionUtil'
+import {
+ getAssetDetectionNames,
+ resolveMissingMediaAssetSources
+} from './missingMediaAssetResolver'
+import type { MissingMediaAssetResolver } from './missingMediaAssetResolver'
/** Map of node types to their media widget name and media type. */
const MEDIA_NODE_WIDGETS: Record<
@@ -28,6 +37,7 @@ const MEDIA_NODE_WIDGETS: Record<
{ widgetName: string; mediaType: MediaType }
> = {
LoadImage: { widgetName: 'image', mediaType: 'image' },
+ LoadImageMask: { widgetName: 'image', mediaType: 'image' },
LoadVideo: { widgetName: 'file', mediaType: 'video' },
LoadAudio: { widgetName: 'audio', mediaType: 'audio' }
}
@@ -39,7 +49,8 @@ function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
/**
* Scan combo widgets on media nodes for file values that may be missing.
*
- * OSS: `isMissing` resolved immediately via widget options.
+ * OSS: `isMissing` is resolved immediately via widget options unless an
+ * output annotation needs generated-history verification.
* Cloud: `isMissing` left `undefined` for async verification.
*/
export function scanAllMediaCandidates(
@@ -92,8 +103,17 @@ export function scanNodeMediaCandidates(
if (isCloud) {
isMissing = undefined
} else {
- const options = resolveComboValues(widget)
- isMissing = !options.includes(value)
+ const type = getAnnotatedMediaPathTypeForDetection(value)
+ if (type === 'output') {
+ isMissing = undefined
+ } else {
+ const options = resolveComboValues(widget)
+ const detectionNames = getMediaPathDetectionNames(value)
+ const existsInOptions = detectionNames.some((name) =>
+ options.includes(name)
+ )
+ isMissing = !existsInOptions
+ }
}
candidates.push({
@@ -109,29 +129,57 @@ export function scanNodeMediaCandidates(
return candidates
}
-type InputAssetFetcher = (signal?: AbortSignal) => Promise
+interface MediaVerificationOptions {
+ isCloud: boolean
+ signal?: AbortSignal
+ resolveAssetSources?: MissingMediaAssetResolver
+}
/**
- * Verify cloud media candidates against input assets available to the user,
- * including public assets returned by the asset list API.
+ * Verify media candidates against assets available to the current runtime.
*
* A candidate's `name` may be either a filename or an opaque asset hash.
* Cloud-side `asset_hash` is not guaranteed to follow a single shape, so we
- * match against the union of `asset.name` and `asset.asset_hash`.
+ * match against the union of `asset.name` and `asset.asset_hash`. Output
+ * candidates are matched against Cloud output assets or Core generated-history
+ * assets because Core resolves those annotations against output folders, not
+ * input files.
+ * Cloud accepts compact annotated media paths, so only Cloud verification
+ * normalizes compact suffixes.
*/
-export async function verifyCloudMediaCandidates(
+export async function verifyMediaCandidates(
candidates: MissingMediaCandidate[],
- signal?: AbortSignal,
- fetchInputAssets: InputAssetFetcher = fetchMissingInputAssets
+ {
+ isCloud,
+ signal,
+ resolveAssetSources = resolveMissingMediaAssetSources
+ }: MediaVerificationOptions
): Promise {
if (signal?.aborted) return
const pending = candidates.filter((c) => c.isMissing === undefined)
if (pending.length === 0) return
+ // Core stores spaced annotations such as `file.png [output]`; Cloud also
+ // accepts compact forms such as `file.png[output]`.
+ const pathOptions = { allowCompactSuffix: isCloud }
+ const generatedMatchNames = getGeneratedCandidateMatchNames(
+ pending,
+ pathOptions
+ )
+
let inputAssets: AssetItem[]
+ let generatedAssets: AssetItem[]
try {
- inputAssets = await fetchInputAssets(signal)
+ const assetSources = await resolveAssetSources({
+ signal,
+ isCloud,
+ includeGeneratedAssets: generatedMatchNames.size > 0,
+ generatedMatchNames,
+ allowCompactSuffix: isCloud
+ })
+ inputAssets = assetSources.inputAssets
+ generatedAssets = assetSources.generatedAssets
} catch (err) {
if (signal?.aborted || isAbortError(err)) return
throw err
@@ -139,21 +187,62 @@ export async function verifyCloudMediaCandidates(
if (signal?.aborted) return
- const assetIdentifiers = new Set()
- for (const asset of inputAssets) {
- if (asset.asset_hash) assetIdentifiers.add(asset.asset_hash)
- if (asset.name) assetIdentifiers.add(asset.name)
- }
+ const inputAssetIdentifiers = new Set()
+ const outputAssetIdentifiers = new Set()
+ addAssetIdentifiers(inputAssetIdentifiers, inputAssets, pathOptions)
+ addAssetIdentifiers(outputAssetIdentifiers, generatedAssets, pathOptions)
for (const candidate of pending) {
- candidate.isMissing = !assetIdentifiers.has(candidate.name)
+ const detectionNames = getMediaPathDetectionNames(
+ candidate.name,
+ pathOptions
+ )
+ const type = getAnnotatedMediaPathTypeForDetection(
+ candidate.name,
+ pathOptions
+ )
+ const identifiers =
+ type === 'output' ? outputAssetIdentifiers : inputAssetIdentifiers
+ candidate.isMissing = !detectionNames.some((name) => identifiers.has(name))
}
}
-async function fetchMissingInputAssets(
- signal?: AbortSignal
-): Promise {
- return await assetService.getInputAssetsIncludingPublic(signal)
+function getGeneratedCandidateMatchNames(
+ candidates: MissingMediaCandidate[],
+ pathOptions: { allowCompactSuffix: boolean }
+): Set {
+ const names = new Set()
+ for (const candidate of candidates) {
+ if (!isGeneratedCandidate(candidate, pathOptions)) continue
+
+ names.add(
+ normalizeAnnotatedMediaPathForDetection(candidate.name, pathOptions)
+ )
+ }
+ return names
+}
+
+function isGeneratedCandidate(
+ candidate: MissingMediaCandidate,
+ pathOptions: { allowCompactSuffix: boolean }
+): boolean {
+ const type = getAnnotatedMediaPathTypeForDetection(
+ candidate.name,
+ pathOptions
+ )
+ return type === 'output'
+}
+
+function addAssetIdentifiers(
+ identifiers: Set,
+ assets: AssetItem[],
+ pathOptions: { allowCompactSuffix: boolean }
+) {
+ for (const asset of assets) {
+ for (const name of getAssetDetectionNames(asset, pathOptions)) {
+ identifiers.add(name)
+ }
+ }
}
/** Group confirmed-missing candidates by file name into view models. */
diff --git a/src/platform/missingMedia/types.ts b/src/platform/missingMedia/types.ts
index a07433dc34..8f1f08a69b 100644
--- a/src/platform/missingMedia/types.ts
+++ b/src/platform/missingMedia/types.ts
@@ -16,7 +16,9 @@ export interface MissingMediaCandidate {
/**
* - `true` — confirmed missing
* - `false` — confirmed present
- * - `undefined` — pending async verification (cloud only)
+ * - `undefined` — pending async verification. Cloud candidates start pending;
+ * OSS output annotated paths may also be deferred to generated-history
+ * verification.
*/
isMissing: boolean | undefined
}
diff --git a/src/platform/remote/comfyui/jobs/fetchJobs.test.ts b/src/platform/remote/comfyui/jobs/fetchJobs.test.ts
index 41b01606e2..53ad431f84 100644
--- a/src/platform/remote/comfyui/jobs/fetchJobs.test.ts
+++ b/src/platform/remote/comfyui/jobs/fetchJobs.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
extractWorkflow,
fetchHistory,
+ fetchHistoryPage,
fetchJobDetail,
fetchQueue
} from '@/platform/remote/comfyui/jobs/fetchJobs'
@@ -29,15 +30,16 @@ function createMockJob(
function createMockResponse(
jobs: RawJobListItem[],
- total: number = jobs.length
+ total: number = jobs.length,
+ pagination: Partial = {}
): JobsListResponse {
return {
jobs,
pagination: {
- offset: 0,
- limit: 200,
+ offset: pagination.offset ?? 0,
+ limit: pagination.limit ?? 200,
total,
- has_more: false
+ has_more: pagination.has_more ?? false
}
}
}
@@ -100,7 +102,8 @@ describe('fetchJobs', () => {
createMockJob('job4', 'completed'),
createMockJob('job5', 'completed')
],
- 10 // total of 10 jobs
+ 10, // total of 10 jobs
+ { offset: 5 }
)
)
})
@@ -185,6 +188,36 @@ describe('fetchJobs', () => {
expect(result[1].id).toBe('text-job')
expect(result[2].id).toBe('no-preview-job')
})
+
+ it('returns server pagination metadata for history pages', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve(
+ createMockResponse(
+ [
+ createMockJob('job4', 'completed'),
+ createMockJob('job5', 'completed')
+ ],
+ 10,
+ { offset: 5, limit: 2, has_more: true }
+ )
+ )
+ })
+
+ const result = await fetchHistoryPage(mockFetch, 2, 5)
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/jobs?status=completed,failed,cancelled&limit=2&offset=5'
+ )
+ expect(result.jobs).toHaveLength(2)
+ expect(result.offset).toBe(5)
+ expect(result.limit).toBe(2)
+ expect(result.total).toBe(10)
+ expect(result.hasMore).toBe(true)
+ expect(result.jobs[0].priority).toBe(5)
+ expect(result.jobs[1].priority).toBe(4)
+ })
})
describe('fetchQueue', () => {
diff --git a/src/platform/remote/comfyui/jobs/fetchJobs.ts b/src/platform/remote/comfyui/jobs/fetchJobs.ts
index 6eee0e959c..25790a5ecd 100644
--- a/src/platform/remote/comfyui/jobs/fetchJobs.ts
+++ b/src/platform/remote/comfyui/jobs/fetchJobs.ts
@@ -22,6 +22,16 @@ interface FetchJobsRawResult {
jobs: RawJobListItem[]
total: number
offset: number
+ limit: number
+ hasMore: boolean
+}
+
+export interface FetchHistoryPageResult {
+ jobs: JobListItem[]
+ total: number
+ offset: number
+ limit: number
+ hasMore: boolean
}
/**
@@ -40,13 +50,25 @@ async function fetchJobsRaw(
const res = await fetchApi(url)
if (!res.ok) {
console.error(`[Jobs API] Failed to fetch jobs: ${res.status}`)
- return { jobs: [], total: 0, offset: 0 }
+ return {
+ jobs: [],
+ total: 0,
+ offset,
+ limit: maxItems,
+ hasMore: false
+ }
}
const data = zJobsListResponse.parse(await res.json())
- return { jobs: data.jobs, total: data.pagination.total, offset }
+ return {
+ jobs: data.jobs,
+ total: data.pagination.total,
+ offset: data.pagination.offset,
+ limit: data.pagination.limit,
+ hasMore: data.pagination.has_more
+ }
} catch (error) {
console.error('[Jobs API] Error fetching jobs:', error)
- return { jobs: [], total: 0, offset: 0 }
+ return { jobs: [], total: 0, offset, limit: maxItems, hasMore: false }
}
}
@@ -76,14 +98,33 @@ export async function fetchHistory(
maxItems: number = 200,
offset: number = 0
): Promise {
- const { jobs, total } = await fetchJobsRaw(
+ const { jobs } = await fetchHistoryPage(fetchApi, maxItems, offset)
+ return jobs
+}
+
+/**
+ * Fetches one page of history with server-provided pagination metadata.
+ */
+export async function fetchHistoryPage(
+ fetchApi: (url: string) => Promise,
+ maxItems: number = 200,
+ offset: number = 0
+): Promise {
+ const result = await fetchJobsRaw(
fetchApi,
['completed', 'failed', 'cancelled'],
maxItems,
offset
)
+
// History gets priority based on total count (lower than queue)
- return assignPriority(jobs, total - offset)
+ return {
+ jobs: assignPriority(result.jobs, result.total - result.offset),
+ total: result.total,
+ offset: result.offset,
+ limit: result.limit,
+ hasMore: result.hasMore
+ }
}
/**
diff --git a/src/scripts/app.ts b/src/scripts/app.ts
index 5b6c8b214b..741b0633a6 100644
--- a/src/scripts/app.ts
+++ b/src/scripts/app.ts
@@ -96,7 +96,7 @@ import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import {
scanAllMediaCandidates,
- verifyCloudMediaCandidates
+ verifyMediaCandidates
} from '@/platform/missingMedia/missingMediaScan'
import { anyItemOverlapsRect } from '@/utils/mathUtil'
@@ -1508,9 +1508,13 @@ export class ComfyApp {
return
}
- if (isCloud) {
+ const pending = candidates.some((c) => c.isMissing === undefined)
+ if (pending) {
const controller = missingMediaStore.createVerificationAbortController()
- void verifyCloudMediaCandidates(candidates, controller.signal)
+ void verifyMediaCandidates(candidates, {
+ isCloud,
+ signal: controller.signal
+ })
.then(() => {
if (controller.signal.aborted) return
// Re-check ancestor after async verification (see model pipeline).
From 1c2ae703430ff63f00cea66af65de86338bf3970 Mon Sep 17 00:00:00 2001
From: Christian Byrne
Date: Sat, 9 May 2026 22:21:36 -0700
Subject: [PATCH 05/48] chore(#11843): replace bare string NodeId typings in
parameters tab components (#12014)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Replace `nodeId: string` with canonical `NodeId` type in right-side
panel parameters tab components, eliminating redundant `String()`
conversions at call sites.
## Changes
- `TabNodes.vue`: `isSectionCollapsed` and `setSectionCollapsed` now
accept `NodeId` instead of `string`; callers updated to pass `node.id`
directly (removing `String()` wrapping)
- `TabNormalInputs.vue`: same pattern
## Notes
The other 6 files listed in the issue use `nodeId` parameters that carry
execution IDs (`NodeExecutionId = string`), not graph node IDs (`NodeId
= number | string`). Changing those to `NodeId` would be semantically
incorrect. The two files changed here are the clear-cut cases where
`node.id` (a `NodeId`) was being unnecessarily stringified before being
passed.
## Testing
### Automated
- `pnpm typecheck` — passes
- `pnpm lint` — passes (0 warnings, 0 errors)
- `pnpm format:check` — passes
### E2E Verification Steps
1. Open ComfyUI frontend
2. Load a workflow with multiple nodes
3. Open the right side panel (Parameters tab)
4. Verify node sections collapse/expand correctly per node
5. Verify "Collapse All" / "Expand All" toggle works correctly
6. Repeat with both TabNodes and TabNormalInputs views
Fixes #11843
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12014-chore-11843-replace-bare-string-NodeId-typings-in-parameters-tab-components-3586d73d365081ed84caf560277f0553)
by [Unito](https://www.unito.io)
---
.../rightSidePanel/parameters/TabNodes.vue | 14 +++++++-------
.../rightSidePanel/parameters/TabNormalInputs.vue | 14 +++++++-------
2 files changed, 14 insertions(+), 14 deletions(-)
diff --git a/src/components/rightSidePanel/parameters/TabNodes.vue b/src/components/rightSidePanel/parameters/TabNodes.vue
index da5da33d26..8f7da538fc 100644
--- a/src/components/rightSidePanel/parameters/TabNodes.vue
+++ b/src/components/rightSidePanel/parameters/TabNodes.vue
@@ -4,7 +4,7 @@ import { computed, reactive, ref, shallowRef, watch } from 'vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
-import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
@@ -44,24 +44,24 @@ watch(
}
)
-function isSectionCollapsed(nodeId: string): boolean {
+function isSectionCollapsed(nodeId: NodeId): boolean {
// Defaults to collapsed when not explicitly set by the user
return collapseMap[nodeId] ?? true
}
-function setSectionCollapsed(nodeId: string, collapsed: boolean) {
+function setSectionCollapsed(nodeId: NodeId, collapsed: boolean) {
collapseMap[nodeId] = collapsed
}
const isAllCollapsed = computed({
get() {
return searchedWidgetsSectionDataList.value.every(({ node }) =>
- isSectionCollapsed(String(node.id))
+ isSectionCollapsed(node.id)
)
},
set(collapse: boolean) {
for (const { node } of widgetsSectionDataList.value) {
- setSectionCollapsed(String(node.id), collapse)
+ setSectionCollapsed(node.id, collapse)
}
}
})
@@ -101,7 +101,7 @@ async function searcher(query: string) {
:key="node.id"
:node
:widgets
- :collapse="isSectionCollapsed(String(node.id)) && !isSearching"
+ :collapse="isSectionCollapsed(node.id) && !isSearching"
:tooltip="
isSearching || widgets.length
? ''
@@ -109,7 +109,7 @@ async function searcher(query: string) {
"
show-locate-button
class="border-b border-interface-stroke"
- @update:collapse="setSectionCollapsed(String(node.id), $event)"
+ @update:collapse="setSectionCollapsed(node.id, $event)"
/>
diff --git a/src/components/rightSidePanel/parameters/TabNormalInputs.vue b/src/components/rightSidePanel/parameters/TabNormalInputs.vue
index e104b98f4c..ba89698667 100644
--- a/src/components/rightSidePanel/parameters/TabNormalInputs.vue
+++ b/src/components/rightSidePanel/parameters/TabNormalInputs.vue
@@ -3,7 +3,7 @@ import { storeToRefs } from 'pinia'
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
-import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -68,19 +68,19 @@ watch(
}
)
-function isSectionCollapsed(nodeId: string): boolean {
+function isSectionCollapsed(nodeId: NodeId): boolean {
// When not explicitly set, sections are collapsed if multiple nodes are selected
return collapseMap[nodeId] ?? isMultipleNodesSelected.value
}
-function setSectionCollapsed(nodeId: string, collapsed: boolean) {
+function setSectionCollapsed(nodeId: NodeId, collapsed: boolean) {
collapseMap[nodeId] = collapsed
}
const isAllCollapsed = computed({
get() {
const normalAllCollapsed = searchedWidgetsSectionDataList.value.every(
- ({ node }) => isSectionCollapsed(String(node.id))
+ ({ node }) => isSectionCollapsed(node.id)
)
const hasAdvanced = advancedWidgetsSectionDataList.value.length > 0
return hasAdvanced
@@ -89,7 +89,7 @@ const isAllCollapsed = computed({
},
set(collapse: boolean) {
for (const { node } of widgetsSectionDataList.value) {
- setSectionCollapsed(String(node.id), collapse)
+ setSectionCollapsed(node.id, collapse)
}
advancedCollapsed.value = collapse
}
@@ -154,7 +154,7 @@ const advancedLabel = computed(() => {
:node
:label
:widgets
- :collapse="isSectionCollapsed(String(node.id)) && !isSearching"
+ :collapse="isSectionCollapsed(node.id) && !isSearching"
:show-locate-button="isMultipleNodesSelected"
:tooltip="
isSearching || widgets.length
@@ -162,7 +162,7 @@ const advancedLabel = computed(() => {
: t('rightSidePanel.inputsNoneTooltip')
"
class="border-b border-interface-stroke"
- @update:collapse="setSectionCollapsed(String(node.id), $event)"
+ @update:collapse="setSectionCollapsed(node.id, $event)"
/>
From fe1de3b2542b83b504d216885c144a9c5c76cbcf Mon Sep 17 00:00:00 2001
From: Christian Byrne
Date: Sat, 9 May 2026 22:22:30 -0700
Subject: [PATCH 06/48] refactor: remove dedup complexity from
reportInactiveTrackerCall (#11833)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Remove the module-level `reportedInactiveCalls: Set` and the
early-return dedup check from `reportInactiveTrackerCall()` in
`src/scripts/changeTracker.ts`. Every invocation now emits
`console.warn` and (on Desktop) `Sentry.captureMessage` unconditionally.
## Why
The dedup was added in #11328 but is unnecessary:
- Every first-party call site already goes through the
`activeWorkflow?.changeTracker` guard, so flooding from in-repo code is
unlikely.
- Repeated identical alerts may actually provide more diagnostic signal
than the first-only approach suppresses.
## Changes
- Drop `reportedInactiveCalls` Set
- Drop the per-`(method, workflowPath)` early-return
- Trim the JSDoc accordingly
No behavior change for callers (`deactivate`, `captureCanvasState`);
only the reporting frequency increases.
## Verification
- `pnpm test:unit -- src/scripts/changeTracker.test.ts` — 16/16 passing
- `pnpm typecheck` — clean
- ESLint / oxfmt — clean
- Fixes #11372
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11833-refactor-remove-dedup-complexity-from-reportInactiveTrackerCall-3546d73d365081fabf57cbf1fa17051f)
by [Unito](https://www.unito.io)
---
src/scripts/changeTracker.ts | 10 +---------
1 file changed, 1 insertion(+), 9 deletions(-)
diff --git a/src/scripts/changeTracker.ts b/src/scripts/changeTracker.ts
index 0218a793ae..da7d861405 100644
--- a/src/scripts/changeTracker.ts
+++ b/src/scripts/changeTracker.ts
@@ -24,20 +24,12 @@ function isActiveTracker(tracker: ChangeTracker): boolean {
return useWorkflowStore().activeWorkflow?.changeTracker === tracker
}
-const reportedInactiveCalls = new Set()
-
/**
* Report a ChangeTracker method being called on an inactive tracker —
* a lifecycle violation that usually indicates stale extension state or
- * an incorrect call ordering. Reports once per method per workflow per
- * session so the signal is not drowned out by hot-path invocations while
- * still distinguishing between workflows.
+ * an incorrect call ordering.
*/
function reportInactiveTrackerCall(method: string, workflowPath: string) {
- const key = `${method}:${workflowPath}`
- if (reportedInactiveCalls.has(key)) return
- reportedInactiveCalls.add(key)
-
console.warn(`${method}() called on inactive tracker for: ${workflowPath}`)
if (isDesktop) {
From 48b5e0165adf1d73975f2465a822be8deb9cad7c Mon Sep 17 00:00:00 2001
From: Comfy Org PR Bot
Date: Mon, 11 May 2026 09:01:07 +0900
Subject: [PATCH 07/48] 1.45.3 (#12113)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Patch version increment to 1.45.3
**Base branch:** `main`
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12113-1-45-3-35c6d73d365081468180cefef02dca03)
by [Unito](https://www.unito.io)
---------
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions
---
package.json | 2 +-
src/locales/ar/nodeDefs.json | 153 +++++++++++++++++++++++++++++++-
src/locales/en/nodeDefs.json | 153 +++++++++++++++++++++++++++++++-
src/locales/es/nodeDefs.json | 153 +++++++++++++++++++++++++++++++-
src/locales/fa/nodeDefs.json | 153 +++++++++++++++++++++++++++++++-
src/locales/fr/nodeDefs.json | 153 +++++++++++++++++++++++++++++++-
src/locales/ja/nodeDefs.json | 153 +++++++++++++++++++++++++++++++-
src/locales/ko/nodeDefs.json | 153 +++++++++++++++++++++++++++++++-
src/locales/pt-BR/nodeDefs.json | 153 +++++++++++++++++++++++++++++++-
src/locales/ru/nodeDefs.json | 153 +++++++++++++++++++++++++++++++-
src/locales/tr/nodeDefs.json | 153 +++++++++++++++++++++++++++++++-
src/locales/zh-TW/nodeDefs.json | 153 +++++++++++++++++++++++++++++++-
src/locales/zh/nodeDefs.json | 153 +++++++++++++++++++++++++++++++-
13 files changed, 1825 insertions(+), 13 deletions(-)
diff --git a/package.json b/package.json
index 275f3c49d5..aa88c5c132 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
- "version": "1.45.2",
+ "version": "1.45.3",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
diff --git a/src/locales/ar/nodeDefs.json b/src/locales/ar/nodeDefs.json
index 17f0b7a092..294ed2d41d 100644
--- a/src/locales/ar/nodeDefs.json
+++ b/src/locales/ar/nodeDefs.json
@@ -17421,7 +17421,8 @@
"name": "PBR"
},
"quad": {
- "name": "رباعي"
+ "name": "رباعي",
+ "tooltip": "هذا المعامل قديم ولم يعد له أي تأثير."
},
"texture": {
"name": "الملمس"
@@ -19389,6 +19390,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "الصوت"
+ },
+ "audio_inject_scale": {
+ "name": "مقياس حقن الصوت",
+ "tooltip": "المقياس لميزات الصوت عند حقنها في نموذج الفيديو."
+ },
+ "video_frames": {
+ "name": "إطارات الفيديو"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "مخرجات مشفر الصوت",
+ "tooltip": null
+ },
+ "1": {
+ "name": "سلسلة معدل الإطارات (fps)",
+ "tooltip": "معدل الإطارات المحسوب بناءً على طول الصوت وعدد إطارات الفيديو. يُستخدم في الموجه."
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "الصوت",
+ "tooltip": "الصوت المستخدم لحساب إجمالي إطارات الإخراج واستخراج صوت المقطع."
+ },
+ "images": {
+ "name": "الصور"
+ },
+ "segment_index": {
+ "name": "فهرس المقطع",
+ "tooltip": "أي مقطع هذا (٠ للأول، ١ للثاني، إلخ.)"
+ },
+ "segment_length": {
+ "name": "طول المقطع",
+ "tooltip": "طول هذا المقطع (عادةً ١٤٩ إطاراً)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "تسلسل الإطارات الرئيسية المبطنة",
+ "tooltip": "تسلسل الإطارات الرئيسية بعد التبطين"
+ },
+ "1": {
+ "name": "قناع الإطارات الرئيسية",
+ "tooltip": "قناع يحدد الإطارات الصالحة"
+ },
+ "2": {
+ "name": "مقطع الصوت",
+ "tooltip": "مقطع الصوت لهذا الجزء من الفيديو"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "الصوت",
+ "tooltip": "الصوت الذي سيتم تقطيعه لكل مقطع صادر."
+ },
+ "images": {
+ "name": "الصور"
+ },
+ "num_segments": {
+ "name": "عدد المقاطع",
+ "tooltip": "عدد المقاطع المبطنة التي سيتم إصدارها كقوائم."
+ },
+ "segment_length": {
+ "name": "طول المقطع",
+ "tooltip": "طول كل مقطع (عادةً ١٤٩ إطاراً)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "تسلسلات الإطارات الرئيسية المبطنة",
+ "tooltip": "تسلسلات الإطارات الرئيسية بعد التبطين"
+ },
+ "1": {
+ "name": "أقنعة الإطارات الرئيسية",
+ "tooltip": "أقنعة تحدد الإطارات الصالحة"
+ },
+ "2": {
+ "name": "مقطع الصوت",
+ "tooltip": "مقطع الصوت لكل جزء من الفيديو"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "مخرجات ترميز الصوت"
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "تضمينات CLIP للرؤية للإطار الأول."
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "تضمينات CLIP للرؤية لصورة المرجع."
+ },
+ "height": {
+ "name": "الارتفاع"
+ },
+ "length": {
+ "name": "الطول",
+ "tooltip": "عدد الإطارات في الفيديو المُنتج. يجب أن يبقى ١٤٩ لـ WanDancer."
+ },
+ "mask": {
+ "name": "قناع",
+ "tooltip": "قناع معالجة الصورة للصورة/الصور الابتدائية. الأبيض يبقى، الأسود يُولّد. يُستخدم للتوليد المحلي."
+ },
+ "negative": {
+ "name": "سلبي"
+ },
+ "positive": {
+ "name": "إيجابي"
+ },
+ "start_image": {
+ "name": "الصورة الابتدائية",
+ "tooltip": "الصورة أو الصور الأولية التي سيتم ترميزها، يمكن أن تكون أي عدد من الإطارات."
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "العرض"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "إيجابي",
+ "tooltip": null
+ },
+ "1": {
+ "name": "سلبي",
+ "tooltip": null
+ },
+ "2": {
+ "name": "كامِن",
+ "tooltip": "كامِن فارغ."
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "وان إطار أول وآخر إلى فيديو",
"inputs": {
diff --git a/src/locales/en/nodeDefs.json b/src/locales/en/nodeDefs.json
index 407b77bdd2..93944418c3 100644
--- a/src/locales/en/nodeDefs.json
+++ b/src/locales/en/nodeDefs.json
@@ -17430,7 +17430,8 @@
"name": "face_limit"
},
"quad": {
- "name": "quad"
+ "name": "quad",
+ "tooltip": "This parameter is deprecated and does nothing."
},
"geometry_quality": {
"name": "geometry_quality"
@@ -19428,6 +19429,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "audio"
+ },
+ "video_frames": {
+ "name": "video_frames"
+ },
+ "audio_inject_scale": {
+ "name": "audio_inject_scale",
+ "tooltip": "The scale for the audio features when injected into the video model."
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "audio_encoder_output",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps_string",
+ "tooltip": "The calculated fps based on the audio length and the number of video frames. Used in the prompt."
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "images": {
+ "name": "images"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "Length of this segment (usually 149 frames)"
+ },
+ "segment_index": {
+ "name": "segment_index",
+ "tooltip": "Which segment this is (0 for first, 1 for second, etc.)"
+ },
+ "audio": {
+ "name": "audio",
+ "tooltip": "Audio to calculate total output frames from and extract segment audio."
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "Padded keyframe sequence"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "Mask indicating valid frames"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "Audio segment for this video segment"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "images": {
+ "name": "images"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "Length of each segment (usually 149 frames)"
+ },
+ "num_segments": {
+ "name": "num_segments",
+ "tooltip": "How many padded segments to emit as lists."
+ },
+ "audio": {
+ "name": "audio",
+ "tooltip": "Audio to slice for each emitted segment."
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "Padded keyframe sequences"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "Masks indicating valid frames"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "Audio segment for each video segment"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "positive": {
+ "name": "positive"
+ },
+ "negative": {
+ "name": "negative"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "width"
+ },
+ "height": {
+ "name": "height"
+ },
+ "length": {
+ "name": "length",
+ "tooltip": "The number of frames in the generated video. Should stay 149 for WanDancer."
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "The CLIP vision embeds for the first frame."
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "The CLIP vision embeds for the reference image."
+ },
+ "start_image": {
+ "name": "start_image",
+ "tooltip": "The initial image(s) to be encoded, can be any number of frames."
+ },
+ "mask": {
+ "name": "mask",
+ "tooltip": "Image conditioning mask for the start image(s). White is kept, black is generated. Used for the local generations."
+ },
+ "audio_encoder_output": {
+ "name": "audio_encoder_output"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positive",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negative",
+ "tooltip": null
+ },
+ "2": {
+ "name": "latent",
+ "tooltip": "Empty latent."
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "WanFirstLastFrameToVideo",
"inputs": {
diff --git a/src/locales/es/nodeDefs.json b/src/locales/es/nodeDefs.json
index 38e8db0c86..c4a44726d6 100644
--- a/src/locales/es/nodeDefs.json
+++ b/src/locales/es/nodeDefs.json
@@ -17421,7 +17421,8 @@
"name": "pbr"
},
"quad": {
- "name": "cuadrilátero"
+ "name": "cuadrilátero",
+ "tooltip": "Este parámetro está obsoleto y no hace nada."
},
"texture": {
"name": "textura"
@@ -19389,6 +19390,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "audio"
+ },
+ "audio_inject_scale": {
+ "name": "audio_inject_scale",
+ "tooltip": "La escala para las características de audio cuando se inyectan en el modelo de video."
+ },
+ "video_frames": {
+ "name": "video_frames"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "audio_encoder_output",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps_string",
+ "tooltip": "Los fps calculados en base a la duración del audio y el número de fotogramas de video. Se usa en el prompt."
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "Audio para calcular el total de fotogramas de salida y extraer el audio del segmento."
+ },
+ "images": {
+ "name": "images"
+ },
+ "segment_index": {
+ "name": "segment_index",
+ "tooltip": "Qué segmento es este (0 para el primero, 1 para el segundo, etc.)"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "Longitud de este segmento (usualmente 149 fotogramas)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "Secuencia de keyframes rellenada"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "Máscara que indica los fotogramas válidos"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "Segmento de audio para este segmento de video"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "Audio para dividir para cada segmento emitido."
+ },
+ "images": {
+ "name": "images"
+ },
+ "num_segments": {
+ "name": "num_segments",
+ "tooltip": "Cuántos segmentos rellenados emitir como listas."
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "Longitud de cada segmento (usualmente 149 fotogramas)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "Secuencias de keyframes rellenadas"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "Máscaras que indican los fotogramas válidos"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "Segmento de audio para cada segmento de video"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "audio_encoder_output"
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "Las incrustaciones de visión de CLIP para el primer fotograma."
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "Las incrustaciones de visión de CLIP para la imagen de referencia."
+ },
+ "height": {
+ "name": "alto"
+ },
+ "length": {
+ "name": "longitud",
+ "tooltip": "El número de fotogramas en el video generado. Debe mantenerse en 149 para WanDancer."
+ },
+ "mask": {
+ "name": "máscara",
+ "tooltip": "Máscara de acondicionamiento de imagen para la(s) imagen(es) inicial(es). El blanco se mantiene, el negro se genera. Se utiliza para las generaciones locales."
+ },
+ "negative": {
+ "name": "negativo"
+ },
+ "positive": {
+ "name": "positivo"
+ },
+ "start_image": {
+ "name": "imagen_inicial",
+ "tooltip": "La(s) imagen(es) inicial(es) a codificar, puede ser cualquier cantidad de fotogramas."
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "ancho"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positivo",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negativo",
+ "tooltip": null
+ },
+ "2": {
+ "name": "latente",
+ "tooltip": "Latente vacío."
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "WanFirstLastFrameToVideo",
"inputs": {
diff --git a/src/locales/fa/nodeDefs.json b/src/locales/fa/nodeDefs.json
index 72a2372c3a..c4cbb030a8 100644
--- a/src/locales/fa/nodeDefs.json
+++ b/src/locales/fa/nodeDefs.json
@@ -17421,7 +17421,8 @@
"name": "PBR"
},
"quad": {
- "name": "چهارضلعی"
+ "name": "چهارضلعی",
+ "tooltip": "این پارامتر منسوخ شده است و هیچ تأثیری ندارد."
},
"texture": {
"name": "تکسچر"
@@ -19389,6 +19390,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "audio"
+ },
+ "audio_inject_scale": {
+ "name": "audio_inject_scale",
+ "tooltip": "مقیاس ویژگیهای صوتی هنگام تزریق به مدل ویدیو."
+ },
+ "video_frames": {
+ "name": "video_frames"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "audio_encoder_output",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps_string",
+ "tooltip": "نرخ فریم بر ثانیه (fps) محاسبهشده بر اساس طول صوت و تعداد فریمهای ویدیو. در prompt استفاده میشود."
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "صوت برای محاسبه تعداد کل فریمهای خروجی و استخراج صوت بخش."
+ },
+ "images": {
+ "name": "images"
+ },
+ "segment_index": {
+ "name": "segment_index",
+ "tooltip": "این بخش کدام است (۰ برای اول، ۱ برای دوم و ...)"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "طول این بخش (معمولاً ۱۴۹ فریم)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "دنباله keyframe با padding"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "ماسک نشاندهنده فریمهای معتبر"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "بخش صوتی برای این بخش ویدیو"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "صوت برای برش هر بخش خروجی."
+ },
+ "images": {
+ "name": "images"
+ },
+ "num_segments": {
+ "name": "num_segments",
+ "tooltip": "تعداد بخشهای padding که به صورت لیست خروجی داده میشود."
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "طول هر بخش (معمولاً ۱۴۹ فریم)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "دنبالههای keyframe با padding"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "ماسکها برای نشان دادن فریمهای معتبر"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "بخش صوتی برای هر بخش ویدیو"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "خروجی رمزگذار صوتی"
+ },
+ "clip_vision_output": {
+ "name": "خروجی بینایی clip",
+ "tooltip": "بردارهای بینایی CLIP برای اولین فریم."
+ },
+ "clip_vision_output_ref": {
+ "name": "خروجی مرجع بینایی clip",
+ "tooltip": "بردارهای بینایی CLIP برای تصویر مرجع."
+ },
+ "height": {
+ "name": "ارتفاع"
+ },
+ "length": {
+ "name": "طول",
+ "tooltip": "تعداد فریمهای ویدئوی تولیدشده. برای WanDancer باید ۱۴۹ باقی بماند."
+ },
+ "mask": {
+ "name": "ماسک",
+ "tooltip": "ماسک شرطیسازی تصویر برای تصویر(ها)ی شروع. سفید حفظ میشود، سیاه تولید میشود. برای تولیدات محلی استفاده میشود."
+ },
+ "negative": {
+ "name": "منفی"
+ },
+ "positive": {
+ "name": "مثبت"
+ },
+ "start_image": {
+ "name": "تصویر شروع",
+ "tooltip": "تصویر(ها)ی اولیه برای رمزگذاری؛ میتواند هر تعداد فریم باشد."
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "عرض"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "مثبت",
+ "tooltip": null
+ },
+ "1": {
+ "name": "منفی",
+ "tooltip": null
+ },
+ "2": {
+ "name": "لاتنت",
+ "tooltip": "لاتنت خالی."
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "WanFirstLastFrameToVideo",
"inputs": {
diff --git a/src/locales/fr/nodeDefs.json b/src/locales/fr/nodeDefs.json
index 99a53e9821..82a0a15bf1 100644
--- a/src/locales/fr/nodeDefs.json
+++ b/src/locales/fr/nodeDefs.json
@@ -17421,7 +17421,8 @@
"name": "pbr"
},
"quad": {
- "name": "quad"
+ "name": "quad",
+ "tooltip": "Ce paramètre est obsolète et n'a aucun effet."
},
"texture": {
"name": "texture"
@@ -19389,6 +19390,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "audio"
+ },
+ "audio_inject_scale": {
+ "name": "audio_inject_scale",
+ "tooltip": "L'échelle des caractéristiques audio lors de leur injection dans le modèle vidéo."
+ },
+ "video_frames": {
+ "name": "video_frames"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "audio_encoder_output",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps_string",
+ "tooltip": "Le nombre d'images par seconde calculé en fonction de la durée de l'audio et du nombre d'images vidéo. Utilisé dans l'invite."
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "Audio pour calculer le nombre total d'images de sortie et extraire l'audio du segment."
+ },
+ "images": {
+ "name": "images"
+ },
+ "segment_index": {
+ "name": "segment_index",
+ "tooltip": "Quel segment est-ce (0 pour le premier, 1 pour le second, etc.)"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "Longueur de ce segment (généralement 149 images)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "Séquence de keyframes complétée"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "Masque indiquant les images valides"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "Segment audio pour ce segment vidéo"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "Audio à découper pour chaque segment émis."
+ },
+ "images": {
+ "name": "images"
+ },
+ "num_segments": {
+ "name": "num_segments",
+ "tooltip": "Combien de segments complétés à émettre sous forme de listes."
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "Longueur de chaque segment (généralement 149 images)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "Séquences de keyframes complétées"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "Masques indiquant les images valides"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "Segment audio pour chaque segment vidéo"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "audio_encoder_output"
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "Les embeddings CLIP vision pour la première image."
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "Les embeddings CLIP vision pour l’image de référence."
+ },
+ "height": {
+ "name": "hauteur"
+ },
+ "length": {
+ "name": "longueur",
+ "tooltip": "Le nombre d’images dans la vidéo générée. Doit rester à 149 pour WanDancer."
+ },
+ "mask": {
+ "name": "masque",
+ "tooltip": "Masque de conditionnement d’image pour l’image ou les images de départ. Le blanc est conservé, le noir est généré. Utilisé pour les générations locales."
+ },
+ "negative": {
+ "name": "négatif"
+ },
+ "positive": {
+ "name": "positif"
+ },
+ "start_image": {
+ "name": "image_de_départ",
+ "tooltip": "L’image ou les images initiales à encoder, peut contenir n’importe quel nombre d’images."
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "largeur"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positif",
+ "tooltip": null
+ },
+ "1": {
+ "name": "négatif",
+ "tooltip": null
+ },
+ "2": {
+ "name": "latent",
+ "tooltip": "Latent vide."
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "WanFirstLastFrameToVideo",
"inputs": {
diff --git a/src/locales/ja/nodeDefs.json b/src/locales/ja/nodeDefs.json
index 13687c982e..9051c95cbd 100644
--- a/src/locales/ja/nodeDefs.json
+++ b/src/locales/ja/nodeDefs.json
@@ -17421,7 +17421,8 @@
"name": "pbr"
},
"quad": {
- "name": "quad"
+ "name": "quad",
+ "tooltip": "このパラメータは非推奨であり、何も行いません。"
},
"texture": {
"name": "texture"
@@ -19389,6 +19390,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "audio"
+ },
+ "audio_inject_scale": {
+ "name": "audio_inject_scale",
+ "tooltip": "オーディオ特徴量をビデオモデルに注入する際のスケール。"
+ },
+ "video_frames": {
+ "name": "video_frames"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "audio_encoder_output",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps_string",
+ "tooltip": "オーディオの長さとビデオフレーム数から計算されたfps。プロンプトで使用されます。"
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "出力フレーム総数の計算やセグメントオーディオの抽出に使用するオーディオ。"
+ },
+ "images": {
+ "name": "images"
+ },
+ "segment_index": {
+ "name": "segment_index",
+ "tooltip": "このセグメントがどれか(最初は0、次は1など)"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "このセグメントの長さ(通常は149フレーム)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "パディングされたキーフレームシーケンス"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "有効なフレームを示すマスク"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "このビデオセグメント用のオーディオセグメント"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "各出力セグメント用にスライスするオーディオ。"
+ },
+ "images": {
+ "name": "images"
+ },
+ "num_segments": {
+ "name": "num_segments",
+ "tooltip": "リストとして出力するパディング済みセグメントの数。"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "各セグメントの長さ(通常は149フレーム)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "パディングされたキーフレームシーケンス"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "有効なフレームを示すマスク"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "各ビデオセグメント用のオーディオセグメント"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "audio_encoder_output"
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "最初のフレームのCLIP vision埋め込み。"
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "参照画像のCLIP vision埋め込み。"
+ },
+ "height": {
+ "name": "高さ"
+ },
+ "length": {
+ "name": "長さ",
+ "tooltip": "生成される動画のフレーム数。WanDancerの場合は149のままにしてください。"
+ },
+ "mask": {
+ "name": "マスク",
+ "tooltip": "開始画像の画像処理用マスク。白は保持、黒は生成されます。ローカル生成に使用されます。"
+ },
+ "negative": {
+ "name": "negative"
+ },
+ "positive": {
+ "name": "positive"
+ },
+ "start_image": {
+ "name": "開始画像",
+ "tooltip": "エンコードする初期画像。任意のフレーム数を指定できます。"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "幅"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positive",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negative",
+ "tooltip": null
+ },
+ "2": {
+ "name": "latent",
+ "tooltip": "空のlatent。"
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "WanFirstLastFrameToVideo",
"inputs": {
diff --git a/src/locales/ko/nodeDefs.json b/src/locales/ko/nodeDefs.json
index 08d35217cf..a5c4cae5c0 100644
--- a/src/locales/ko/nodeDefs.json
+++ b/src/locales/ko/nodeDefs.json
@@ -17421,7 +17421,8 @@
"name": "PBR"
},
"quad": {
- "name": "쿼드"
+ "name": "쿼드",
+ "tooltip": "이 매개변수는 더 이상 사용되지 않으며 아무런 동작도 하지 않습니다."
},
"texture": {
"name": "텍스처"
@@ -19389,6 +19390,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "audio"
+ },
+ "audio_inject_scale": {
+ "name": "audio_inject_scale",
+ "tooltip": "오디오 특징을 비디오 모델에 주입할 때의 스케일입니다."
+ },
+ "video_frames": {
+ "name": "video_frames"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "audio_encoder_output",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps_string",
+ "tooltip": "오디오 길이와 비디오 프레임 수를 기반으로 계산된 fps입니다. 프롬프트에 사용됩니다."
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "총 출력 프레임 계산 및 구간 오디오 추출에 사용할 오디오입니다."
+ },
+ "images": {
+ "name": "images"
+ },
+ "segment_index": {
+ "name": "segment_index",
+ "tooltip": "이 구간이 몇 번째인지 (첫 번째는 0, 두 번째는 1 등)"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "이 구간의 길이 (보통 149 프레임)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "패딩된 키프레임 시퀀스"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "유효한 프레임을 나타내는 마스크"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "이 비디오 구간에 해당하는 오디오 구간"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "각 출력 구간에 맞게 오디오를 분할합니다."
+ },
+ "images": {
+ "name": "images"
+ },
+ "num_segments": {
+ "name": "num_segments",
+ "tooltip": "리스트로 출력할 패딩된 구간의 개수"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "각 구간의 길이 (보통 149 프레임)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "패딩된 키프레임 시퀀스들"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "유효한 프레임을 나타내는 마스크들"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "각 비디오 구간에 해당하는 오디오 구간"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "audio_encoder_output"
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "첫 번째 프레임의 CLIP 비전 임베딩입니다."
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "참조 이미지의 CLIP 비전 임베딩입니다."
+ },
+ "height": {
+ "name": "높이"
+ },
+ "length": {
+ "name": "길이",
+ "tooltip": "생성된 비디오의 프레임 수입니다. WanDancer의 경우 149로 유지해야 합니다."
+ },
+ "mask": {
+ "name": "마스크",
+ "tooltip": "시작 이미지(들)에 대한 이미지 조건 마스크입니다. 흰색은 유지되고, 검은색은 생성됩니다. 로컬 생성에 사용됩니다."
+ },
+ "negative": {
+ "name": "negative"
+ },
+ "positive": {
+ "name": "positive"
+ },
+ "start_image": {
+ "name": "시작 이미지",
+ "tooltip": "인코딩할 초기 이미지(들)입니다. 프레임 수는 자유롭게 설정할 수 있습니다."
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "너비"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positive",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negative",
+ "tooltip": null
+ },
+ "2": {
+ "name": "latent",
+ "tooltip": "비어 있는 latent."
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "WAN 비디오 생성 (시작-끝 프레임)",
"inputs": {
diff --git a/src/locales/pt-BR/nodeDefs.json b/src/locales/pt-BR/nodeDefs.json
index 45c9222ff5..a72ba55bd3 100644
--- a/src/locales/pt-BR/nodeDefs.json
+++ b/src/locales/pt-BR/nodeDefs.json
@@ -17421,7 +17421,8 @@
"name": "pbr"
},
"quad": {
- "name": "quad"
+ "name": "quad",
+ "tooltip": "Este parâmetro está obsoleto e não faz nada."
},
"texture": {
"name": "textura"
@@ -19389,6 +19390,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "áudio"
+ },
+ "audio_inject_scale": {
+ "name": "escala_de_injeção_de_áudio",
+ "tooltip": "A escala para as características do áudio ao serem injetadas no modelo de vídeo."
+ },
+ "video_frames": {
+ "name": "quadros_de_vídeo"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "saída_do_codificador_de_áudio",
+ "tooltip": null
+ },
+ "1": {
+ "name": "string_fps",
+ "tooltip": "O fps calculado com base na duração do áudio e no número de quadros de vídeo. Usado no prompt."
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "áudio",
+ "tooltip": "Áudio para calcular o total de quadros de saída e extrair o áudio do segmento."
+ },
+ "images": {
+ "name": "imagens"
+ },
+ "segment_index": {
+ "name": "índice_do_segmento",
+ "tooltip": "Qual segmento é este (0 para o primeiro, 1 para o segundo, etc.)"
+ },
+ "segment_length": {
+ "name": "comprimento_do_segmento",
+ "tooltip": "Comprimento deste segmento (geralmente 149 quadros)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "sequência_de_keyframes",
+ "tooltip": "Sequência de keyframes preenchida"
+ },
+ "1": {
+ "name": "máscara_de_keyframes",
+ "tooltip": "Máscara indicando quadros válidos"
+ },
+ "2": {
+ "name": "segmento_de_áudio",
+ "tooltip": "Segmento de áudio para este segmento de vídeo"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "áudio",
+ "tooltip": "Áudio a ser dividido para cada segmento emitido."
+ },
+ "images": {
+ "name": "imagens"
+ },
+ "num_segments": {
+ "name": "número_de_segmentos",
+ "tooltip": "Quantos segmentos preenchidos emitir como listas."
+ },
+ "segment_length": {
+ "name": "comprimento_do_segmento",
+ "tooltip": "Comprimento de cada segmento (geralmente 149 quadros)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "sequências_de_keyframes",
+ "tooltip": "Sequências de keyframes preenchidas"
+ },
+ "1": {
+ "name": "máscaras_de_keyframes",
+ "tooltip": "Máscaras indicando quadros válidos"
+ },
+ "2": {
+ "name": "segmento_de_áudio",
+ "tooltip": "Segmento de áudio para cada segmento de vídeo"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "audio_encoder_output"
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "Os embeddings de visão do CLIP para o primeiro quadro."
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "Os embeddings de visão do CLIP para a imagem de referência."
+ },
+ "height": {
+ "name": "altura"
+ },
+ "length": {
+ "name": "duração",
+ "tooltip": "O número de quadros no vídeo gerado. Deve permanecer 149 para WanDancer."
+ },
+ "mask": {
+ "name": "máscara",
+ "tooltip": "Máscara de condicionamento de imagem para a(s) imagem(ns) inicial(is). Branco é mantido, preto é gerado. Usado para as gerações locais."
+ },
+ "negative": {
+ "name": "negativo"
+ },
+ "positive": {
+ "name": "positivo"
+ },
+ "start_image": {
+ "name": "imagem_inicial",
+ "tooltip": "A(s) imagem(ns) inicial(is) a serem codificadas, pode ser qualquer quantidade de quadros."
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "largura"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positivo",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negativo",
+ "tooltip": null
+ },
+ "2": {
+ "name": "latente",
+ "tooltip": "Latente vazio."
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "WanFirstLastFrameToVideo",
"inputs": {
diff --git a/src/locales/ru/nodeDefs.json b/src/locales/ru/nodeDefs.json
index e8420d1054..5d7035a7f7 100644
--- a/src/locales/ru/nodeDefs.json
+++ b/src/locales/ru/nodeDefs.json
@@ -17421,7 +17421,8 @@
"name": "PBR"
},
"quad": {
- "name": "квад"
+ "name": "квад",
+ "tooltip": "Этот параметр устарел и больше не используется."
},
"texture": {
"name": "текстура"
@@ -19389,6 +19390,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "audio"
+ },
+ "audio_inject_scale": {
+ "name": "audio_inject_scale",
+ "tooltip": "Масштаб аудиофич при внедрении в видеомодель."
+ },
+ "video_frames": {
+ "name": "video_frames"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "audio_encoder_output",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps_string",
+ "tooltip": "Вычисленный fps на основе длины аудио и количества видеокадров. Используется в prompt."
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "Аудио для расчёта общего количества выходных кадров и извлечения сегмента аудио."
+ },
+ "images": {
+ "name": "images"
+ },
+ "segment_index": {
+ "name": "segment_index",
+ "tooltip": "Какой это сегмент (0 — первый, 1 — второй и т.д.)"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "Длина этого сегмента (обычно 149 кадров)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "Дополненная последовательность ключевых кадров"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "Маска, указывающая валидные кадры"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "Аудиосегмент для этого видеосегмента"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "Аудио для нарезки для каждого выдаваемого сегмента."
+ },
+ "images": {
+ "name": "images"
+ },
+ "num_segments": {
+ "name": "num_segments",
+ "tooltip": "Сколько дополненных сегментов выдавать в виде списков."
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "Длина каждого сегмента (обычно 149 кадров)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "Дополненные последовательности ключевых кадров"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "Маски, указывающие валидные кадры"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "Аудиосегмент для каждого видеосегмента"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "audio_encoder_output"
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "Визуальные эмбеддинги CLIP для первого кадра."
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "Визуальные эмбеддинги CLIP для референсного изображения."
+ },
+ "height": {
+ "name": "высота"
+ },
+ "length": {
+ "name": "длина",
+ "tooltip": "Количество кадров в сгенерированном видео. Для WanDancer должно оставаться 149."
+ },
+ "mask": {
+ "name": "маска",
+ "tooltip": "Маска для обработки изображения начального кадра(ов). Белое сохраняется, черное генерируется. Используется для локальной генерации."
+ },
+ "negative": {
+ "name": "негативный"
+ },
+ "positive": {
+ "name": "позитивный"
+ },
+ "start_image": {
+ "name": "начальное изображение",
+ "tooltip": "Исходное изображение(я) для кодирования, может быть любое количество кадров."
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "ширина"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "позитивный",
+ "tooltip": null
+ },
+ "1": {
+ "name": "негативный",
+ "tooltip": null
+ },
+ "2": {
+ "name": "латентный",
+ "tooltip": "Пустое латентное пространство."
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "WanFirstLastFrameToVideo",
"inputs": {
diff --git a/src/locales/tr/nodeDefs.json b/src/locales/tr/nodeDefs.json
index b0db028d67..7c6724a2e3 100644
--- a/src/locales/tr/nodeDefs.json
+++ b/src/locales/tr/nodeDefs.json
@@ -17421,7 +17421,8 @@
"name": "pbr"
},
"quad": {
- "name": "dörtgen"
+ "name": "dörtgen",
+ "tooltip": "Bu parametre kullanımdan kaldırılmıştır ve hiçbir şey yapmaz."
},
"texture": {
"name": "doku"
@@ -19389,6 +19390,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "ses"
+ },
+ "audio_inject_scale": {
+ "name": "ses_enjeksiyon_ölçeği",
+ "tooltip": "Ses özelliklerinin video modeline enjekte edilirken kullanılacak ölçek."
+ },
+ "video_frames": {
+ "name": "video_kareleri"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "ses_kodlayıcı_çıktısı",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps_dizgesi",
+ "tooltip": "Ses uzunluğu ve video kare sayısına göre hesaplanan fps. İstem içinde kullanılır."
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "ses",
+ "tooltip": "Toplam çıktı karelerini hesaplamak ve segment sesini çıkarmak için ses."
+ },
+ "images": {
+ "name": "görseller"
+ },
+ "segment_index": {
+ "name": "segment_indeksi",
+ "tooltip": "Bu hangi segment (ilk için 0, ikinci için 1, vb.)"
+ },
+ "segment_length": {
+ "name": "segment_uzunluğu",
+ "tooltip": "Bu segmentin uzunluğu (genellikle 149 kare)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "anahtar_kare_dizisi",
+ "tooltip": "Doldurulmuş anahtar kare dizisi"
+ },
+ "1": {
+ "name": "anahtar_kare_maskesi",
+ "tooltip": "Geçerli kareleri gösteren maske"
+ },
+ "2": {
+ "name": "ses_segmenti",
+ "tooltip": "Bu video segmenti için ses segmenti"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "ses",
+ "tooltip": "Her üretilen segment için bölünecek ses."
+ },
+ "images": {
+ "name": "görseller"
+ },
+ "num_segments": {
+ "name": "segment_sayısı",
+ "tooltip": "Liste olarak kaç doldurulmuş segment üretileceği."
+ },
+ "segment_length": {
+ "name": "segment_uzunluğu",
+ "tooltip": "Her segmentin uzunluğu (genellikle 149 kare)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "anahtar_kare_dizileri",
+ "tooltip": "Doldurulmuş anahtar kare dizileri"
+ },
+ "1": {
+ "name": "anahtar_kare_maskeleri",
+ "tooltip": "Geçerli kareleri gösteren maskeler"
+ },
+ "2": {
+ "name": "ses_segmenti",
+ "tooltip": "Her video segmenti için ses segmenti"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "audio_encoder_output"
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "İlk kare için CLIP vision gömüleri."
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "Referans görsel için CLIP vision gömüleri."
+ },
+ "height": {
+ "name": "yükseklik"
+ },
+ "length": {
+ "name": "uzunluk",
+ "tooltip": "Oluşturulan videodaki kare sayısı. WanDancer için 149 olarak kalmalıdır."
+ },
+ "mask": {
+ "name": "mask",
+ "tooltip": "Başlangıç görsel(ler)i için görsel koşullandırma maskesi. Beyaz korunur, siyah üretilir. Yerel üretimler için kullanılır."
+ },
+ "negative": {
+ "name": "negatif"
+ },
+ "positive": {
+ "name": "pozitif"
+ },
+ "start_image": {
+ "name": "başlangıç görseli",
+ "tooltip": "Kodlanacak ilk görsel(ler), herhangi bir kare sayısı olabilir."
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "genişlik"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "pozitif",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negatif",
+ "tooltip": null
+ },
+ "2": {
+ "name": "latent",
+ "tooltip": "Boş latent."
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "WanİlkSonKaredenVideoya",
"inputs": {
diff --git a/src/locales/zh-TW/nodeDefs.json b/src/locales/zh-TW/nodeDefs.json
index 30f2d2684c..eadc136dd1 100644
--- a/src/locales/zh-TW/nodeDefs.json
+++ b/src/locales/zh-TW/nodeDefs.json
@@ -17421,7 +17421,8 @@
"name": "pbr"
},
"quad": {
- "name": "quad"
+ "name": "quad",
+ "tooltip": "此參數已棄用,無任何作用。"
},
"texture": {
"name": "texture"
@@ -19389,6 +19390,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "audio"
+ },
+ "audio_inject_scale": {
+ "name": "audio_inject_scale",
+ "tooltip": "將音訊特徵注入到影片模型時的縮放比例。"
+ },
+ "video_frames": {
+ "name": "video_frames"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "audio_encoder_output",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps_string",
+ "tooltip": "根據音訊長度與影片影格數計算出的 fps,會用於提示詞中。"
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "用於計算總輸出影格數並擷取片段音訊的音訊。"
+ },
+ "images": {
+ "name": "images"
+ },
+ "segment_index": {
+ "name": "segment_index",
+ "tooltip": "這是第幾個片段(第一個為 0,第二個為 1,以此類推)"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "此片段的長度(通常為 149 影格)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "已補齊的關鍵影格序列"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "標示有效影格的 mask"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "此影片片段對應的音訊片段"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "audio",
+ "tooltip": "要為每個輸出片段切割的音訊。"
+ },
+ "images": {
+ "name": "images"
+ },
+ "num_segments": {
+ "name": "num_segments",
+ "tooltip": "要以清單形式輸出的補齊片段數量。"
+ },
+ "segment_length": {
+ "name": "segment_length",
+ "tooltip": "每個片段的長度(通常為 149 影格)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "keyframes_sequence",
+ "tooltip": "已補齊的關鍵影格序列"
+ },
+ "1": {
+ "name": "keyframes_mask",
+ "tooltip": "標示有效影格的 mask"
+ },
+ "2": {
+ "name": "audio_segment",
+ "tooltip": "每個影片片段對應的音訊片段"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "audio_encoder_output"
+ },
+ "clip_vision_output": {
+ "name": "clip_vision_output",
+ "tooltip": "第一幀的 CLIP 視覺嵌入。"
+ },
+ "clip_vision_output_ref": {
+ "name": "clip_vision_output_ref",
+ "tooltip": "參考圖像的 CLIP 視覺嵌入。"
+ },
+ "height": {
+ "name": "高度"
+ },
+ "length": {
+ "name": "長度",
+ "tooltip": "生成影片的幀數。對於 WanDancer,應保持 149。"
+ },
+ "mask": {
+ "name": "遮罩",
+ "tooltip": "起始圖像的圖像處理遮罩。白色保留,黑色生成。用於局部生成。"
+ },
+ "negative": {
+ "name": "negative"
+ },
+ "positive": {
+ "name": "positive"
+ },
+ "start_image": {
+ "name": "起始圖像",
+ "tooltip": "要編碼的初始圖像,可為任意幀數。"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "寬度"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "positive",
+ "tooltip": null
+ },
+ "1": {
+ "name": "negative",
+ "tooltip": null
+ },
+ "2": {
+ "name": "latent",
+ "tooltip": "空的 latent。"
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "Wan 首尾影格轉影片",
"inputs": {
diff --git a/src/locales/zh/nodeDefs.json b/src/locales/zh/nodeDefs.json
index 0804f73bf8..8856fe0eed 100644
--- a/src/locales/zh/nodeDefs.json
+++ b/src/locales/zh/nodeDefs.json
@@ -17421,7 +17421,8 @@
"name": "PBR"
},
"quad": {
- "name": "四边形"
+ "name": "四边形",
+ "tooltip": "此参数已弃用,无任何作用。"
},
"texture": {
"name": "纹理"
@@ -19389,6 +19390,156 @@
}
}
},
+ "WanDancerEncodeAudio": {
+ "display_name": "WanDancerEncodeAudio",
+ "inputs": {
+ "audio": {
+ "name": "音频"
+ },
+ "audio_inject_scale": {
+ "name": "音频注入比例",
+ "tooltip": "将音频特征注入到视频模型时的比例。"
+ },
+ "video_frames": {
+ "name": "视频帧"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "音频编码器输出",
+ "tooltip": null
+ },
+ "1": {
+ "name": "fps字符串",
+ "tooltip": "根据音频长度和视频帧数计算得到的fps。用于提示词中。"
+ }
+ }
+ },
+ "WanDancerPadKeyframes": {
+ "display_name": "WanDancerPadKeyframes",
+ "inputs": {
+ "audio": {
+ "name": "音频",
+ "tooltip": "用于计算总输出帧数并提取片段音频。"
+ },
+ "images": {
+ "name": "图像"
+ },
+ "segment_index": {
+ "name": "片段索引",
+ "tooltip": "这是第几个片段(第一个为0,第二个为1,以此类推)"
+ },
+ "segment_length": {
+ "name": "片段长度",
+ "tooltip": "该片段的长度(通常为149帧)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "关键帧序列",
+ "tooltip": "填充后的关键帧序列"
+ },
+ "1": {
+ "name": "关键帧掩码",
+ "tooltip": "指示有效帧的掩码"
+ },
+ "2": {
+ "name": "音频片段",
+ "tooltip": "该视频片段对应的音频片段"
+ }
+ }
+ },
+ "WanDancerPadKeyframesList": {
+ "display_name": "WanDancerPadKeyframesList",
+ "inputs": {
+ "audio": {
+ "name": "音频",
+ "tooltip": "为每个输出片段切分音频。"
+ },
+ "images": {
+ "name": "图像"
+ },
+ "num_segments": {
+ "name": "片段数量",
+ "tooltip": "要以列表形式输出多少个填充片段。"
+ },
+ "segment_length": {
+ "name": "片段长度",
+ "tooltip": "每个片段的长度(通常为149帧)"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "关键帧序列",
+ "tooltip": "填充后的关键帧序列"
+ },
+ "1": {
+ "name": "关键帧掩码",
+ "tooltip": "指示有效帧的掩码"
+ },
+ "2": {
+ "name": "音频片段",
+ "tooltip": "每个视频片段对应的音频片段"
+ }
+ }
+ },
+ "WanDancerVideo": {
+ "display_name": "WanDancerVideo",
+ "inputs": {
+ "audio_encoder_output": {
+ "name": "音频编码器输出"
+ },
+ "clip_vision_output": {
+ "name": "clip视觉输出",
+ "tooltip": "第一帧的CLIP视觉嵌入。"
+ },
+ "clip_vision_output_ref": {
+ "name": "clip视觉参考输出",
+ "tooltip": "参考图像的CLIP视觉嵌入。"
+ },
+ "height": {
+ "name": "高度"
+ },
+ "length": {
+ "name": "长度",
+ "tooltip": "生成视频的帧数。对于WanDancer应保持为149。"
+ },
+ "mask": {
+ "name": "掩码",
+ "tooltip": "用于起始图像的图像条件掩码。白色保留,黑色生成。用于局部生成。"
+ },
+ "negative": {
+ "name": "负向"
+ },
+ "positive": {
+ "name": "正向"
+ },
+ "start_image": {
+ "name": "起始图像",
+ "tooltip": "要编码的初始图像,可以为任意帧数。"
+ },
+ "vae": {
+ "name": "vae"
+ },
+ "width": {
+ "name": "宽度"
+ }
+ },
+ "outputs": {
+ "0": {
+ "name": "正向",
+ "tooltip": null
+ },
+ "1": {
+ "name": "负向",
+ "tooltip": null
+ },
+ "2": {
+ "name": "latent",
+ "tooltip": "空的latent。"
+ }
+ }
+ },
"WanFirstLastFrameToVideo": {
"display_name": "Wan首尾帧视频",
"inputs": {
From e68d50e677a773a3969f5072480e0990d42e86dd Mon Sep 17 00:00:00 2001
From: Comfy Org PR Bot
Date: Mon, 11 May 2026 13:43:31 +0900
Subject: [PATCH 08/48] 1.45.4 (#12118)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Patch version increment to 1.45.4
**Base branch:** `main`
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12118-1-45-4-35d6d73d365081fcb5f5d06dec17bb59)
by [Unito](https://www.unito.io)
---------
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions
---
package.json | 2 +-
src/locales/ar/nodeDefs.json | 4 ++++
src/locales/en/nodeDefs.json | 4 ++++
src/locales/es/nodeDefs.json | 4 ++++
src/locales/fa/nodeDefs.json | 4 ++++
src/locales/fr/nodeDefs.json | 4 ++++
src/locales/ja/nodeDefs.json | 4 ++++
src/locales/ko/nodeDefs.json | 4 ++++
src/locales/pt-BR/nodeDefs.json | 4 ++++
src/locales/ru/nodeDefs.json | 4 ++++
src/locales/tr/nodeDefs.json | 4 ++++
src/locales/zh-TW/nodeDefs.json | 4 ++++
src/locales/zh/nodeDefs.json | 4 ++++
13 files changed, 49 insertions(+), 1 deletion(-)
diff --git a/package.json b/package.json
index aa88c5c132..b02c50813f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
- "version": "1.45.3",
+ "version": "1.45.4",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
diff --git a/src/locales/ar/nodeDefs.json b/src/locales/ar/nodeDefs.json
index 294ed2d41d..9ab1f600cf 100644
--- a/src/locales/ar/nodeDefs.json
+++ b/src/locales/ar/nodeDefs.json
@@ -1689,6 +1689,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "قيمة منطقية",
+ "tooltip": null
}
}
},
diff --git a/src/locales/en/nodeDefs.json b/src/locales/en/nodeDefs.json
index 93944418c3..b970790a6b 100644
--- a/src/locales/en/nodeDefs.json
+++ b/src/locales/en/nodeDefs.json
@@ -1689,6 +1689,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "BOOL",
+ "tooltip": null
}
}
},
diff --git a/src/locales/es/nodeDefs.json b/src/locales/es/nodeDefs.json
index c4a44726d6..62bbf9c95f 100644
--- a/src/locales/es/nodeDefs.json
+++ b/src/locales/es/nodeDefs.json
@@ -1689,6 +1689,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "BOOL",
+ "tooltip": null
}
}
},
diff --git a/src/locales/fa/nodeDefs.json b/src/locales/fa/nodeDefs.json
index c4cbb030a8..0423225329 100644
--- a/src/locales/fa/nodeDefs.json
+++ b/src/locales/fa/nodeDefs.json
@@ -1689,6 +1689,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "بولین",
+ "tooltip": null
}
}
},
diff --git a/src/locales/fr/nodeDefs.json b/src/locales/fr/nodeDefs.json
index 82a0a15bf1..c77d85db19 100644
--- a/src/locales/fr/nodeDefs.json
+++ b/src/locales/fr/nodeDefs.json
@@ -1689,6 +1689,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "BOOL",
+ "tooltip": null
}
}
},
diff --git a/src/locales/ja/nodeDefs.json b/src/locales/ja/nodeDefs.json
index 9051c95cbd..14b1f1b93b 100644
--- a/src/locales/ja/nodeDefs.json
+++ b/src/locales/ja/nodeDefs.json
@@ -1689,6 +1689,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "BOOL",
+ "tooltip": null
}
}
},
diff --git a/src/locales/ko/nodeDefs.json b/src/locales/ko/nodeDefs.json
index a5c4cae5c0..94860defd6 100644
--- a/src/locales/ko/nodeDefs.json
+++ b/src/locales/ko/nodeDefs.json
@@ -1689,6 +1689,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "BOOL",
+ "tooltip": null
}
}
},
diff --git a/src/locales/pt-BR/nodeDefs.json b/src/locales/pt-BR/nodeDefs.json
index a72ba55bd3..b608b12d01 100644
--- a/src/locales/pt-BR/nodeDefs.json
+++ b/src/locales/pt-BR/nodeDefs.json
@@ -1689,6 +1689,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "BOOL",
+ "tooltip": null
}
}
},
diff --git a/src/locales/ru/nodeDefs.json b/src/locales/ru/nodeDefs.json
index 5d7035a7f7..d0bb7c469f 100644
--- a/src/locales/ru/nodeDefs.json
+++ b/src/locales/ru/nodeDefs.json
@@ -1689,6 +1689,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "BOOL",
+ "tooltip": null
}
}
},
diff --git a/src/locales/tr/nodeDefs.json b/src/locales/tr/nodeDefs.json
index 7c6724a2e3..67155fbe3f 100644
--- a/src/locales/tr/nodeDefs.json
+++ b/src/locales/tr/nodeDefs.json
@@ -1689,6 +1689,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "BOOL",
+ "tooltip": null
}
}
},
diff --git a/src/locales/zh-TW/nodeDefs.json b/src/locales/zh-TW/nodeDefs.json
index eadc136dd1..ea6e0e475a 100644
--- a/src/locales/zh-TW/nodeDefs.json
+++ b/src/locales/zh-TW/nodeDefs.json
@@ -1689,6 +1689,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "BOOL",
+ "tooltip": null
}
}
},
diff --git a/src/locales/zh/nodeDefs.json b/src/locales/zh/nodeDefs.json
index 8856fe0eed..195b1d8d9c 100644
--- a/src/locales/zh/nodeDefs.json
+++ b/src/locales/zh/nodeDefs.json
@@ -1689,6 +1689,10 @@
},
"1": {
"tooltip": null
+ },
+ "2": {
+ "name": "布尔值",
+ "tooltip": null
}
}
},
From 15b8771cc2957d72f6ee7ba216d2eae8bc99afb7 Mon Sep 17 00:00:00 2001
From: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Date: Mon, 11 May 2026 10:28:23 +0100
Subject: [PATCH 09/48] fix: clear active job on reconnect if no longer in
queue (#12067)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
When a socket disconnects messages can be missed and lead to a stale UI
state, this updates the state on reconnect and clears the active job if
it is no longer running
## Changes
- **What**:
- add call to update queue on reconnect
- clear active job if job not in queue response
- tests
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12067-fix-clear-active-job-on-reconnect-if-no-longer-in-queue-3596d73d365081f79d42d73966420c50)
by [Unito](https://www.unito.io)
---
.../fixtures/helpers/ExecutionHelper.ts | 16 +-
.../tests/wsReconnectStaleJob.spec.ts | 211 +++++++++++++++++
.../useReconnectQueueRefresh.test.ts | 88 +++++++
src/composables/useReconnectQueueRefresh.ts | 25 ++
src/scripts/api.ts | 3 +-
src/stores/executionStore.test.ts | 51 +++++
src/stores/executionStore.ts | 11 +
src/stores/queueStore.test.ts | 94 +++++++-
src/stores/queueStore.ts | 98 ++++----
src/views/GraphView.test.ts | 214 ++++++++++++++++++
src/views/GraphView.vue | 9 +-
11 files changed, 765 insertions(+), 55 deletions(-)
create mode 100644 browser_tests/tests/wsReconnectStaleJob.spec.ts
create mode 100644 src/composables/useReconnectQueueRefresh.test.ts
create mode 100644 src/composables/useReconnectQueueRefresh.ts
create mode 100644 src/views/GraphView.test.ts
diff --git a/browser_tests/fixtures/helpers/ExecutionHelper.ts b/browser_tests/fixtures/helpers/ExecutionHelper.ts
index 3483a13667..e13f8c0db0 100644
--- a/browser_tests/fixtures/helpers/ExecutionHelper.ts
+++ b/browser_tests/fixtures/helpers/ExecutionHelper.ts
@@ -1,6 +1,10 @@
import type { WebSocketRoute } from '@playwright/test'
-import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
+import type {
+ NodeError,
+ NodeProgressState,
+ PromptResponse
+} from '@/schemas/apiSchema'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
@@ -230,6 +234,16 @@ export class ExecutionHelper {
)
}
+ /** Send `progress_state` WS event with per-node execution state. */
+ progressState(jobId: string, nodes: Record): void {
+ this.requireWs().send(
+ JSON.stringify({
+ type: 'progress_state',
+ data: { prompt_id: jobId, nodes }
+ })
+ )
+ }
+
/**
* Complete a job by adding it to mock history, sending execution_success,
* and triggering a history refresh via a status event.
diff --git a/browser_tests/tests/wsReconnectStaleJob.spec.ts b/browser_tests/tests/wsReconnectStaleJob.spec.ts
new file mode 100644
index 0000000000..d67d404c7d
--- /dev/null
+++ b/browser_tests/tests/wsReconnectStaleJob.spec.ts
@@ -0,0 +1,211 @@
+import type { WebSocketRoute } from '@playwright/test'
+import { mergeTests } from '@playwright/test'
+import type { z } from 'zod'
+
+import {
+ comfyExpect as expect,
+ comfyPageFixture
+} from '@e2e/fixtures/ComfyPage'
+import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
+import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
+import { webSocketFixture } from '@e2e/fixtures/ws'
+import type {
+ RawJobListItem,
+ zJobsListResponse
+} from '@/platform/remote/comfyui/jobs/jobTypes'
+
+type JobsListResponse = z.infer
+
+const test = mergeTests(comfyPageFixture, webSocketFixture)
+
+const KSAMPLER_NODE = '3'
+const EXECUTING_CLASS = /outline-node-stroke-executing/
+
+const QUEUE_ROUTE = /\/api\/jobs\?[^/]*status=in_progress,pending/
+const HISTORY_ROUTE = /\/api\/jobs\?[^/]*status=completed/
+
+function jobsResponse(jobs: RawJobListItem[]): JobsListResponse {
+ return {
+ jobs,
+ pagination: { offset: 0, limit: 200, total: jobs.length, has_more: false }
+ }
+}
+
+async function mockJobsRoute(
+ comfyPage: ComfyPage,
+ pattern: RegExp,
+ body: string,
+ status: number = 200
+): Promise<() => number> {
+ let count = 0
+ await comfyPage.page.route(pattern, async (route) => {
+ count += 1
+ await route.fulfill({
+ status,
+ contentType: 'application/json',
+ body
+ })
+ })
+ return () => count
+}
+
+const emptyJobsBody = JSON.stringify(jobsResponse([]))
+
+type Scenario = {
+ name: string
+ /** Built per-test so it can incorporate the runtime-assigned jobId. */
+ queueBody: (jobId: string) => string
+ /** Whether the active job state should still be reflected after reconnect. */
+ expectsActiveAfter: boolean
+}
+
+const scenarios: Scenario[] = [
+ {
+ name: 'clears stale active job when queue is empty after reconnect',
+ queueBody: () => emptyJobsBody,
+ expectsActiveAfter: false
+ },
+ {
+ name: 'preserves active job when the job is still in the queue',
+ queueBody: (jobId) =>
+ JSON.stringify(
+ jobsResponse([
+ { id: jobId, status: 'in_progress', create_time: Date.now() }
+ ])
+ ),
+ expectsActiveAfter: true
+ }
+]
+
+/**
+ * Stub the queue/history endpoints per `scenario`, close the WS, and wait
+ * for the auto-reconnect to issue a fresh queue fetch.
+ */
+async function triggerReconnect(
+ comfyPage: ComfyPage,
+ ws: WebSocketRoute,
+ scenario: Scenario,
+ jobId: string
+): Promise {
+ await mockJobsRoute(comfyPage, HISTORY_ROUTE, emptyJobsBody)
+ const queueFetches = await mockJobsRoute(
+ comfyPage,
+ QUEUE_ROUTE,
+ scenario.queueBody(jobId)
+ )
+ const fetchesBeforeClose = queueFetches()
+ await ws.close()
+ await expect.poll(queueFetches).toBeGreaterThan(fetchesBeforeClose)
+}
+
+test.describe('WebSocket reconnect with stale job', { tag: '@ui' }, () => {
+ test.describe('app mode skeleton', () => {
+ test.beforeEach(async ({ comfyPage }) => {
+ await comfyPage.appMode.enterAppModeWithInputs([[KSAMPLER_NODE, 'seed']])
+ await expect(comfyPage.appMode.linearWidgets).toBeVisible()
+ })
+
+ for (const scenario of scenarios) {
+ test(scenario.name, async ({ comfyPage, getWebSocket }) => {
+ const ws = await getWebSocket()
+ const exec = new ExecutionHelper(comfyPage, ws)
+
+ const jobId = await exec.run()
+ exec.executionStart(jobId)
+
+ // Skeleton visibility is the deterministic sync point: it appears
+ // once both `storeJob` (HTTP) and `executionStart` (WS) have been
+ // processed, regardless of arrival order.
+ const firstSkeleton = comfyPage.appMode.outputHistory.skeletons.first()
+ await expect(firstSkeleton).toBeVisible()
+
+ await triggerReconnect(comfyPage, ws, scenario, jobId)
+
+ if (scenario.expectsActiveAfter) {
+ await expect(firstSkeleton).toBeVisible()
+ } else {
+ await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
+ }
+ })
+ }
+
+ test('preserves active job when the queue endpoint fails on reconnect', async ({
+ comfyPage,
+ getWebSocket
+ }) => {
+ const ws = await getWebSocket()
+ const exec = new ExecutionHelper(comfyPage, ws)
+
+ const jobId = await exec.run()
+ exec.executionStart(jobId)
+
+ const firstSkeleton = comfyPage.appMode.outputHistory.skeletons.first()
+ await expect(firstSkeleton).toBeVisible()
+
+ await mockJobsRoute(comfyPage, HISTORY_ROUTE, emptyJobsBody)
+
+ // Prime queueStore.runningTasks with the active job — a WS status
+ // event drives GraphView.onStatus -> queueStore.update().
+ const primer = await mockJobsRoute(
+ comfyPage,
+ QUEUE_ROUTE,
+ JSON.stringify(
+ jobsResponse([
+ { id: jobId, status: 'in_progress', create_time: Date.now() }
+ ])
+ )
+ )
+ exec.status(1)
+ await expect.poll(primer).toBeGreaterThanOrEqual(1)
+
+ // Swap to a failing handler so the reconnect-driven fetch 500s.
+ // The fix should preserve runningTasks from the priming call rather
+ // than overwriting it with empty/error state.
+ await comfyPage.page.unroute(QUEUE_ROUTE)
+ const failed = await mockJobsRoute(comfyPage, QUEUE_ROUTE, '{}', 500)
+
+ const before = failed()
+ await ws.close()
+ await expect.poll(failed).toBeGreaterThan(before)
+
+ await expect(firstSkeleton).toBeVisible()
+ })
+ })
+
+ test.describe('vue node executing class', { tag: '@vue-nodes' }, () => {
+ for (const scenario of scenarios) {
+ test(scenario.name, async ({ comfyPage, getWebSocket }) => {
+ const ws = await getWebSocket()
+ const exec = new ExecutionHelper(comfyPage, ws)
+
+ // The executing outline lives on the outer `[data-node-id]`
+ // container, not the inner wrapper.
+ const ksamplerNode = comfyPage.vueNodes.getNodeLocator(KSAMPLER_NODE)
+ await expect(ksamplerNode).toBeVisible()
+
+ const jobId = await exec.run()
+ exec.executionStart(jobId)
+ exec.progressState(jobId, {
+ [KSAMPLER_NODE]: {
+ value: 0,
+ max: 1,
+ state: 'running',
+ node_id: KSAMPLER_NODE,
+ display_node_id: KSAMPLER_NODE,
+ prompt_id: jobId
+ }
+ })
+
+ await expect(ksamplerNode).toHaveClass(EXECUTING_CLASS)
+
+ await triggerReconnect(comfyPage, ws, scenario, jobId)
+
+ if (scenario.expectsActiveAfter) {
+ await expect(ksamplerNode).toHaveClass(EXECUTING_CLASS)
+ } else {
+ await expect(ksamplerNode).not.toHaveClass(EXECUTING_CLASS)
+ }
+ })
+ }
+ })
+})
diff --git a/src/composables/useReconnectQueueRefresh.test.ts b/src/composables/useReconnectQueueRefresh.test.ts
new file mode 100644
index 0000000000..dc21ff7b47
--- /dev/null
+++ b/src/composables/useReconnectQueueRefresh.test.ts
@@ -0,0 +1,88 @@
+import { createTestingPinia } from '@pinia/testing'
+import { setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useReconnectQueueRefresh } from '@/composables/useReconnectQueueRefresh'
+import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
+import { api } from '@/scripts/api'
+import { useExecutionStore } from '@/stores/executionStore'
+
+function makeJob(id: string, status: JobListItem['status']): JobListItem {
+ return {
+ id,
+ status,
+ create_time: 0,
+ update_time: 0,
+ last_state_update: 0,
+ priority: 0
+ }
+}
+
+vi.mock('@/scripts/api', () => ({
+ api: {
+ getQueue: vi.fn(),
+ getHistory: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ apiURL: vi.fn((p: string) => `/api${p}`)
+ }
+}))
+
+describe('useReconnectQueueRefresh', () => {
+ beforeEach(() => {
+ setActivePinia(createTestingPinia({ stubActions: false }))
+ vi.restoreAllMocks()
+ vi.mocked(api.getQueue).mockResolvedValue({ Running: [], Pending: [] })
+ vi.mocked(api.getHistory).mockResolvedValue([])
+ })
+
+ it('forwards running+pending job ids to clearActiveJobIfStale', async () => {
+ vi.mocked(api.getQueue).mockResolvedValue({
+ Running: [makeJob('run-1', 'in_progress')],
+ Pending: [makeJob('pend-1', 'pending'), makeJob('pend-2', 'pending')]
+ })
+ const executionStore = useExecutionStore()
+ const clearSpy = vi
+ .spyOn(executionStore, 'clearActiveJobIfStale')
+ .mockImplementation(() => {})
+
+ const refresh = useReconnectQueueRefresh()
+ await refresh()
+
+ expect(clearSpy).toHaveBeenCalledTimes(1)
+ expect(clearSpy).toHaveBeenCalledWith(
+ new Set(['run-1', 'pend-1', 'pend-2'])
+ )
+ })
+
+ it('passes an empty set when the queue is genuinely empty', async () => {
+ const executionStore = useExecutionStore()
+ const clearSpy = vi
+ .spyOn(executionStore, 'clearActiveJobIfStale')
+ .mockImplementation(() => {})
+
+ const refresh = useReconnectQueueRefresh()
+ await refresh()
+
+ expect(clearSpy).toHaveBeenCalledWith(new Set())
+ })
+
+ it('reuses the prior queue snapshot when the fetch fails, so a still-running job is not falsely cleared', async () => {
+ vi.mocked(api.getQueue)
+ .mockResolvedValueOnce({
+ Running: [makeJob('run-1', 'in_progress')],
+ Pending: []
+ })
+ .mockRejectedValueOnce(new Error('network down'))
+ const executionStore = useExecutionStore()
+ const clearSpy = vi
+ .spyOn(executionStore, 'clearActiveJobIfStale')
+ .mockImplementation(() => {})
+
+ const refresh = useReconnectQueueRefresh()
+ await refresh() // primes the store with run-1
+ await refresh() // network failure here — store must not go empty
+
+ expect(clearSpy).toHaveBeenLastCalledWith(new Set(['run-1']))
+ })
+})
diff --git a/src/composables/useReconnectQueueRefresh.ts b/src/composables/useReconnectQueueRefresh.ts
new file mode 100644
index 0000000000..267ade9850
--- /dev/null
+++ b/src/composables/useReconnectQueueRefresh.ts
@@ -0,0 +1,25 @@
+import { useExecutionStore } from '@/stores/executionStore'
+import { useQueueStore } from '@/stores/queueStore'
+
+/**
+ * After a WebSocket reconnect, refresh the queue from the server and clear
+ * any active job that finished during the disconnect window. Returns the
+ * handler so the caller can wire it to the `reconnected` api event.
+ *
+ * `update()` preserves the previous queue snapshot when the fetch fails, so
+ * if the network is still flaky we reconcile against the last known good
+ * state rather than an empty (and falsely "stale") set.
+ */
+export function useReconnectQueueRefresh() {
+ const queueStore = useQueueStore()
+ const executionStore = useExecutionStore()
+
+ return async function refreshOnReconnect() {
+ await queueStore.update()
+ const activeJobIds = new Set([
+ ...queueStore.runningTasks.map((t) => t.jobId),
+ ...queueStore.pendingTasks.map((t) => t.jobId)
+ ])
+ executionStore.clearActiveJobIfStale(activeJobIds)
+ }
+}
diff --git a/src/scripts/api.ts b/src/scripts/api.ts
index 23f074b99c..1acc69717d 100644
--- a/src/scripts/api.ts
+++ b/src/scripts/api.ts
@@ -1005,13 +1005,14 @@ export class ComfyApi extends EventTarget {
* Gets the current state of the queue
* @returns The currently running and queued items
*/
- async getQueue(): Promise<{
+ async getQueue(options?: { throwOnError?: boolean }): Promise<{
Running: JobListItem[]
Pending: JobListItem[]
}> {
try {
return await fetchQueue(this.fetchApi.bind(this))
} catch (error) {
+ if (options?.throwOnError) throw error
console.error('Failed to fetch queue:', error)
return { Running: [], Pending: [] }
}
diff --git a/src/stores/executionStore.test.ts b/src/stores/executionStore.test.ts
index dc143d844f..392883f7a7 100644
--- a/src/stores/executionStore.test.ts
+++ b/src/stores/executionStore.test.ts
@@ -440,6 +440,57 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
})
})
+describe('useExecutionStore - clearActiveJobIfStale', () => {
+ let store: ReturnType
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ setActivePinia(createTestingPinia({ stubActions: false }))
+ store = useExecutionStore()
+ })
+
+ it('clears the active job and progress state when not in the active set', () => {
+ store.activeJobId = 'job-1'
+ store.queuedJobs = { 'job-1': { nodes: { 'node-1': false } } }
+ store.nodeProgressStates = {
+ 'node-1': {
+ value: 5,
+ max: 10,
+ state: 'running',
+ node_id: 'node-1',
+ display_node_id: 'node-1',
+ prompt_id: 'job-1'
+ }
+ }
+
+ store.clearActiveJobIfStale(new Set(['job-2']))
+
+ expect(store.activeJobId).toBeNull()
+ expect(store.queuedJobs['job-1']).toBeUndefined()
+ expect(store.nodeProgressStates).toEqual({})
+ })
+
+ it('preserves the active job when present in the active set', () => {
+ store.activeJobId = 'job-1'
+ store.queuedJobs = { 'job-1': { nodes: {} } }
+
+ store.clearActiveJobIfStale(new Set(['job-1', 'job-2']))
+
+ expect(store.activeJobId).toBe('job-1')
+ expect(store.queuedJobs['job-1']).toBeDefined()
+ })
+
+ it('is a no-op when there is no active job', () => {
+ store.activeJobId = null
+ store.queuedJobs = { other: { nodes: {} } }
+
+ store.clearActiveJobIfStale(new Set())
+
+ expect(store.activeJobId).toBeNull()
+ expect(store.queuedJobs['other']).toBeDefined()
+ })
+})
+
describe('useExecutionStore - progress_text startup guard', () => {
let store: ReturnType
diff --git a/src/stores/executionStore.ts b/src/stores/executionStore.ts
index 1df4702858..d438dfd220 100644
--- a/src/stores/executionStore.ts
+++ b/src/stores/executionStore.ts
@@ -485,6 +485,16 @@ export const useExecutionStore = defineStore('execution', () => {
clearInitializationByJobIds(orphaned)
}
+ /**
+ * Clears the active job if the server's queue snapshot doesn't list it.
+ * Used after WS reconnect to recover from stale state when a job finished
+ * during the disconnect window.
+ */
+ function clearActiveJobIfStale(activeJobIds: Set) {
+ const id = activeJobId.value
+ if (id && !activeJobIds.has(id)) resetExecutionState(id)
+ }
+
function isJobInitializing(jobId: JobId | number | undefined): boolean {
if (!jobId) return false
return initializingJobIds.value.has(String(jobId))
@@ -643,6 +653,7 @@ export const useExecutionStore = defineStore('execution', () => {
clearInitializationByJobId,
clearInitializationByJobIds,
reconcileInitializingJobs,
+ clearActiveJobIfStale,
bindExecutionEvents,
unbindExecutionEvents,
storeJob,
diff --git a/src/stores/queueStore.test.ts b/src/stores/queueStore.test.ts
index 9b5a62d2d6..2f5a72611a 100644
--- a/src/stores/queueStore.test.ts
+++ b/src/stores/queueStore.test.ts
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { TaskOutput } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
+import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
// Fixture factory for JobListItem
@@ -340,11 +341,11 @@ describe('useQueueStore', () => {
expect(store.isLoading).toBe(false)
})
- it('should clear loading state even if API fails', async () => {
+ it('should clear loading state even if the queue fetch fails', async () => {
mockGetQueue.mockRejectedValue(new Error('API error'))
mockGetHistory.mockResolvedValue([])
- await expect(store.update()).rejects.toThrow('API error')
+ await store.update()
expect(store.isLoading).toBe(false)
})
})
@@ -1018,10 +1019,9 @@ describe('useQueueStore', () => {
const firstUpdate = store.update()
void store.update() // coalesces, sets dirty
- // First call rejects — but dirty flag triggers re-fetch
- await expect(firstUpdate).rejects.toThrow('network error')
-
- // Re-fetch was triggered
+ // First call resolves (allSettled absorbs the failure) but the dirty
+ // flag still triggers a re-fetch when the in-flight request finishes.
+ await firstUpdate
expect(mockGetQueue).toHaveBeenCalledTimes(2)
resolveSecond({ Running: [], Pending: [createPendingJob(2, 'new-job')] })
@@ -1032,4 +1032,86 @@ describe('useQueueStore', () => {
expect(store.isLoading).toBe(false)
})
})
+
+ describe('update() partial failures', () => {
+ it('reconciles when the queue fetch succeeds, even with an empty snapshot', async () => {
+ mockGetQueue.mockResolvedValue({ Running: [], Pending: [] })
+ mockGetHistory.mockResolvedValue([])
+ const executionStore = useExecutionStore()
+ const reconcileSpy = vi.spyOn(executionStore, 'reconcileInitializingJobs')
+
+ await store.update()
+
+ expect(reconcileSpy).toHaveBeenCalledWith(new Set())
+ })
+
+ it('preserves prior queue state and skips reconcile when the queue fetch fails', async () => {
+ mockGetQueue
+ .mockResolvedValueOnce({
+ Running: [createRunningJob(0, 'run-1')],
+ Pending: []
+ })
+ .mockRejectedValueOnce(new Error('network down'))
+ mockGetHistory.mockResolvedValue([])
+ const executionStore = useExecutionStore()
+ const reconcileSpy = vi.spyOn(executionStore, 'reconcileInitializingJobs')
+
+ await store.update()
+ await store.update()
+
+ // First update reconciles with run-1; second update's queue fetch
+ // rejects, so reconcile must not be called again.
+ expect(reconcileSpy).toHaveBeenCalledTimes(1)
+ expect(reconcileSpy).toHaveBeenLastCalledWith(new Set(['run-1']))
+ expect(store.runningTasks).toHaveLength(1)
+ expect(store.runningTasks[0].jobId).toBe('run-1')
+ })
+
+ it('still updates history when only the queue fetch fails', async () => {
+ mockGetQueue.mockRejectedValue(new Error('queue down'))
+ mockGetHistory.mockResolvedValue([createHistoryJob(0, 'hist-1')])
+
+ await store.update()
+
+ expect(store.historyTasks).toHaveLength(1)
+ expect(store.historyTasks[0].jobId).toBe('hist-1')
+ })
+
+ it('still updates queue when only the history fetch fails', async () => {
+ mockGetQueue.mockResolvedValue({
+ Running: [createRunningJob(0, 'run-1')],
+ Pending: []
+ })
+ mockGetHistory.mockRejectedValue(new Error('history down'))
+
+ await store.update()
+
+ expect(store.runningTasks).toHaveLength(1)
+ expect(store.runningTasks[0].jobId).toBe('run-1')
+ })
+
+ it('preserves prior state and skips reconcile when both fetches fail', async () => {
+ mockGetQueue
+ .mockResolvedValueOnce({
+ Running: [createRunningJob(0, 'run-1')],
+ Pending: []
+ })
+ .mockRejectedValueOnce(new Error('queue down'))
+ mockGetHistory
+ .mockResolvedValueOnce([createHistoryJob(0, 'hist-1')])
+ .mockRejectedValueOnce(new Error('history down'))
+ const executionStore = useExecutionStore()
+ const reconcileSpy = vi.spyOn(executionStore, 'reconcileInitializingJobs')
+
+ await store.update()
+ await store.update()
+
+ expect(reconcileSpy).toHaveBeenCalledTimes(1)
+ expect(store.runningTasks).toHaveLength(1)
+ expect(store.runningTasks[0].jobId).toBe('run-1')
+ expect(store.historyTasks).toHaveLength(1)
+ expect(store.historyTasks[0].jobId).toBe('hist-1')
+ expect(store.isLoading).toBe(false)
+ })
+ })
})
diff --git a/src/stores/queueStore.ts b/src/stores/queueStore.ts
index f0f660c1db..d1a909fe09 100644
--- a/src/stores/queueStore.ts
+++ b/src/stores/queueStore.ts
@@ -525,68 +525,74 @@ export const useQueueStore = defineStore('queue', () => {
dirty = false
isLoading.value = true
try {
- const [queue, history] = await Promise.all([
- api.getQueue(),
+ const [queueResult, historyResult] = await Promise.allSettled([
+ api.getQueue({ throwOnError: true }),
api.getHistory(maxHistoryItems.value)
])
- // API returns pre-sorted data (sort_by=create_time&order=desc)
- runningTasks.value = queue.Running.map((job) => new TaskItemImpl(job))
- pendingTasks.value = queue.Pending.map((job) => new TaskItemImpl(job))
+ if (queueResult.status === 'fulfilled') {
+ const queue = queueResult.value
+ // API returns pre-sorted data (sort_by=create_time&order=desc)
+ runningTasks.value = queue.Running.map((job) => new TaskItemImpl(job))
+ pendingTasks.value = queue.Pending.map((job) => new TaskItemImpl(job))
- const currentHistory = toValue(historyTasks)
+ const appearedTasks = [...pendingTasks.value, ...runningTasks.value]
+ const executionStore = useExecutionStore()
+ appearedTasks.forEach((task) => {
+ const jobIdString = String(task.jobId)
+ const workflowId = task.workflowId
+ if (workflowId && jobIdString) {
+ executionStore.registerJobWorkflowIdMapping(jobIdString, workflowId)
+ }
+ })
- const appearedTasks = [...pendingTasks.value, ...runningTasks.value]
- const executionStore = useExecutionStore()
- appearedTasks.forEach((task) => {
- const jobIdString = String(task.jobId)
- const workflowId = task.workflowId
- if (workflowId && jobIdString) {
- executionStore.registerJobWorkflowIdMapping(jobIdString, workflowId)
- }
- })
-
- // Only reconcile when the queue fetch returned data. api.getQueue()
- // returns empty Running/Pending on transient errors, which would
- // incorrectly clear all initializing prompts.
- const queueHasData = queue.Running.length > 0 || queue.Pending.length > 0
- if (queueHasData) {
const activeJobIds = new Set([
...queue.Running.map((j) => j.id),
...queue.Pending.map((j) => j.id)
])
executionStore.reconcileInitializingJobs(activeJobIds)
+ } else {
+ console.error('Failed to fetch queue:', queueResult.reason)
}
- // Sort by create_time descending and limit to maxItems
- const sortedHistory = [...history]
- .sort((a, b) => b.create_time - a.create_time)
- .slice(0, toValue(maxHistoryItems))
+ if (historyResult.status === 'fulfilled') {
+ const history = historyResult.value
+ const currentHistory = toValue(historyTasks)
- // Reuse existing TaskItemImpl instances or create new
- // Must recreate if outputs_count changed (e.g., API started returning it)
- const existingByJobId = new Map(
- currentHistory.map((impl) => [impl.jobId, impl])
- )
+ // Sort by create_time descending and limit to maxItems
+ const sortedHistory = [...history]
+ .sort((a, b) => b.create_time - a.create_time)
+ .slice(0, toValue(maxHistoryItems))
- const nextHistoryTasks = sortedHistory.map((job) => {
- const existing = existingByJobId.get(job.id)
- if (!existing) return new TaskItemImpl(job)
- // Recreate if outputs_count changed to ensure lazy loading works
- if (existing.outputsCount !== (job.outputs_count ?? undefined)) {
- return new TaskItemImpl(job)
+ // Reuse existing TaskItemImpl instances or create new
+ // Must recreate if outputs_count changed (e.g., API started returning it)
+ const existingByJobId = new Map(
+ currentHistory.map((impl) => [impl.jobId, impl])
+ )
+
+ const nextHistoryTasks = sortedHistory.map((job) => {
+ const existing = existingByJobId.get(job.id)
+ if (!existing) return new TaskItemImpl(job)
+ // Recreate if outputs_count changed to ensure lazy loading works
+ if (existing.outputsCount !== (job.outputs_count ?? undefined)) {
+ return new TaskItemImpl(job)
+ }
+ return existing
+ })
+
+ const isHistoryUnchanged =
+ nextHistoryTasks.length === currentHistory.length &&
+ nextHistoryTasks.every(
+ (task, index) => task === currentHistory[index]
+ )
+
+ if (!isHistoryUnchanged) {
+ historyTasks.value = nextHistoryTasks
}
- return existing
- })
-
- const isHistoryUnchanged =
- nextHistoryTasks.length === currentHistory.length &&
- nextHistoryTasks.every((task, index) => task === currentHistory[index])
-
- if (!isHistoryUnchanged) {
- historyTasks.value = nextHistoryTasks
+ hasFetchedHistorySnapshot.value = true
+ } else {
+ console.error('Failed to fetch history:', historyResult.reason)
}
- hasFetchedHistorySnapshot.value = true
} finally {
isLoading.value = false
inFlight = false
diff --git a/src/views/GraphView.test.ts b/src/views/GraphView.test.ts
new file mode 100644
index 0000000000..a1fc5242c2
--- /dev/null
+++ b/src/views/GraphView.test.ts
@@ -0,0 +1,214 @@
+import { createTestingPinia } from '@pinia/testing'
+import { render } from '@testing-library/vue'
+import { setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ref } from 'vue'
+
+import type * as VueUseCore from '@vueuse/core'
+import { useReconnectQueueRefresh } from '@/composables/useReconnectQueueRefresh'
+import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
+import type * as DistTypes from '@/platform/distribution/types'
+import type * as I18nModule from '@/i18n'
+
+const apiMock = vi.hoisted(() => new EventTarget())
+
+vi.mock('@/scripts/api', () => ({ api: apiMock }))
+
+vi.mock('@/scripts/app', () => ({
+ app: {
+ rootGraph: { getNodeById: vi.fn(), nodes: [] },
+ ui: {
+ menuContainer: { style: { setProperty: vi.fn() } },
+ restoreMenuPosition: vi.fn()
+ }
+ }
+}))
+
+vi.mock('@/composables/useReconnectQueueRefresh', () => {
+ const refreshOnReconnect = vi.fn(async () => {})
+ return { useReconnectQueueRefresh: () => refreshOnReconnect }
+})
+
+vi.mock('@/composables/useReconnectingNotification', () => {
+ const onReconnected = vi.fn()
+ const onReconnecting = vi.fn()
+ return {
+ useReconnectingNotification: () => ({ onReconnected, onReconnecting })
+ }
+})
+
+vi.mock('@vueuse/core', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, useIntervalFn: vi.fn(() => ({ pause: vi.fn() })) }
+})
+
+vi.mock('@/base/common/async', () => ({ runWhenGlobalIdle: vi.fn() }))
+vi.mock('@/composables/useBrowserTabTitle', () => ({
+ useBrowserTabTitle: vi.fn()
+}))
+vi.mock('@/composables/useCoreCommands', () => ({ useCoreCommands: () => [] }))
+vi.mock('@/platform/remote/comfyui/useQueuePolling', () => ({
+ useQueuePolling: vi.fn()
+}))
+vi.mock('@/composables/useErrorHandling', () => ({
+ useErrorHandling: () => ({
+ wrapWithErrorHandling: (f: unknown) => f,
+ wrapWithErrorHandlingAsync: (f: unknown) => f
+ })
+}))
+vi.mock('@/composables/useProgressFavicon', () => ({
+ useProgressFavicon: vi.fn()
+}))
+vi.mock('@/i18n', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, loadLocale: vi.fn().mockResolvedValue(undefined) }
+})
+vi.mock('@/platform/distribution/types', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, isCloud: false, isDesktop: false }
+})
+vi.mock('@/platform/settings/settingStore', () => ({
+ useSettingStore: () => ({ get: vi.fn(() => undefined), set: vi.fn() })
+}))
+vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
+vi.mock('@/platform/updates/common/useFrontendVersionMismatchWarning', () => ({
+ useFrontendVersionMismatchWarning: vi.fn()
+}))
+vi.mock('@/platform/updates/common/versionCompatibilityStore', () => ({
+ useVersionCompatibilityStore: () => ({
+ initialize: vi.fn().mockResolvedValue(undefined)
+ })
+}))
+vi.mock('@/renderer/core/canvas/canvasStore', async () => {
+ const { defineStore } = await import('pinia')
+ return {
+ useCanvasStore: defineStore('canvas-test-stub', () => ({
+ linearMode: ref(false)
+ }))
+ }
+})
+vi.mock('@/services/autoQueueService', () => ({
+ setupAutoQueueHandler: vi.fn()
+}))
+vi.mock('@/platform/keybindings/keybindingService', () => ({
+ useKeybindingService: () => ({
+ registerCoreKeybindings: vi.fn(),
+ keybindHandler: vi.fn()
+ })
+}))
+vi.mock('@/composables/useAppMode', () => ({
+ useAppMode: () => ({ isBuilderMode: ref(false) })
+}))
+vi.mock('@/stores/assetsStore', () => ({
+ useAssetsStore: () => ({ updateHistory: vi.fn() })
+}))
+vi.mock('@/stores/commandStore', () => ({
+ useCommandStore: () => ({ registerCommands: vi.fn() })
+}))
+vi.mock('@/stores/executionStore', () => ({
+ useExecutionStore: () => ({
+ bindExecutionEvents: vi.fn(),
+ unbindExecutionEvents: vi.fn(),
+ activeJobId: null,
+ clearActiveJobIfStale: vi.fn()
+ })
+}))
+vi.mock('@/stores/authStore', () => ({
+ useAuthStore: () => ({ isAuthenticated: false })
+}))
+vi.mock('@/stores/menuItemStore', () => ({
+ useMenuItemStore: () => ({ registerCoreMenuCommands: vi.fn() })
+}))
+vi.mock('@/stores/modelStore', () => ({ useModelStore: () => ({}) }))
+vi.mock('@/stores/nodeDefStore', () => ({
+ useNodeDefStore: () => ({}),
+ useNodeFrequencyStore: () => ({})
+}))
+vi.mock('@/stores/queueStore', () => ({
+ useQueueStore: () => ({
+ update: vi.fn(),
+ runningTasks: [],
+ pendingTasks: [],
+ tasks: [],
+ maxHistoryItems: 64
+ }),
+ useQueuePendingTaskCountStore: () => ({ update: vi.fn() })
+}))
+vi.mock('@/stores/serverConfigStore', () => ({
+ useServerConfigStore: () => ({})
+}))
+vi.mock('@/stores/workspace/bottomPanelStore', () => ({
+ useBottomPanelStore: () => ({
+ registerCoreBottomPanelTabs: vi.fn().mockResolvedValue(undefined)
+ })
+}))
+vi.mock('@/stores/workspace/colorPaletteStore', () => ({
+ useColorPaletteStore: () => ({
+ completedActivePalette: { light_theme: true, colors: { comfy_base: {} } }
+ })
+}))
+vi.mock('@/stores/workspace/sidebarTabStore', () => ({
+ useSidebarTabStore: () => ({
+ registerCoreSidebarTabs: vi.fn(),
+ activeSidebarTabId: null
+ })
+}))
+vi.mock('@/utils/envUtil', () => ({
+ electronAPI: () => ({
+ changeTheme: vi.fn(),
+ Events: { incrementUserProperty: vi.fn(), trackEvent: vi.fn() }
+ })
+}))
+
+// Module-mock heavy child components so we don't pay their import cost.
+const stubModule = { default: { template: '' } }
+vi.mock('@/components/graph/GraphCanvas.vue', () => stubModule)
+vi.mock('@/views/LinearView.vue', () => stubModule)
+vi.mock('@/components/builder/BuilderToolbar.vue', () => stubModule)
+vi.mock('@/components/builder/BuilderMenu.vue', () => stubModule)
+vi.mock('@/components/builder/BuilderFooterToolbar.vue', () => stubModule)
+vi.mock(
+ '@/workbench/extensions/manager/components/ManagerProgressToast.vue',
+ () => stubModule
+)
+vi.mock(
+ '@/platform/cloud/notification/components/DesktopCloudNotificationController.vue',
+ () => stubModule
+)
+vi.mock(
+ '@/platform/assets/components/ModelImportProgressDialog.vue',
+ () => stubModule
+)
+vi.mock(
+ '@/platform/assets/components/AssetExportProgressDialog.vue',
+ () => stubModule
+)
+vi.mock(
+ '@/platform/workspace/components/toasts/InviteAcceptedToast.vue',
+ () => stubModule
+)
+vi.mock('@/components/toast/GlobalToast.vue', () => stubModule)
+vi.mock('@/components/toast/RerouteMigrationToast.vue', () => stubModule)
+vi.mock('@/components/MenuHamburger.vue', () => stubModule)
+vi.mock('@/components/dialog/UnloadWindowConfirmDialog.vue', () => stubModule)
+
+describe('GraphView - reconnect wiring', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ setActivePinia(createTestingPinia({ stubActions: false }))
+ })
+
+ it('wires the reconnected event to the toast and queue refresh', async () => {
+ const GraphView = (await import('./GraphView.vue')).default
+ render(GraphView)
+
+ apiMock.dispatchEvent(new Event('reconnected'))
+
+ const { onReconnected } = useReconnectingNotification()
+ const refreshOnReconnect = useReconnectQueueRefresh()
+ await vi.waitFor(() => {
+ expect(onReconnected).toHaveBeenCalledTimes(1)
+ expect(refreshOnReconnect).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/src/views/GraphView.vue b/src/views/GraphView.vue
index f9f42823c7..2d70873794 100644
--- a/src/views/GraphView.vue
+++ b/src/views/GraphView.vue
@@ -56,6 +56,7 @@ import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { useQueuePolling } from '@/platform/remote/comfyui/useQueuePolling'
import { useErrorHandling } from '@/composables/useErrorHandling'
+import { useReconnectQueueRefresh } from '@/composables/useReconnectQueueRefresh'
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
import { useProgressFavicon } from '@/composables/useProgressFavicon'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
@@ -248,11 +249,17 @@ const onExecutionSuccess = async () => {
}
const { onReconnecting, onReconnected } = useReconnectingNotification()
+const refreshOnReconnect = useReconnectQueueRefresh()
+
+const handleReconnected = async () => {
+ onReconnected()
+ await refreshOnReconnect()
+}
useEventListener(api, 'status', onStatus)
useEventListener(api, 'execution_success', onExecutionSuccess)
useEventListener(api, 'reconnecting', onReconnecting)
-useEventListener(api, 'reconnected', onReconnected)
+useEventListener(api, 'reconnected', handleReconnected)
onMounted(() => {
executionStore.bindExecutionEvents()
From 02e1ba29689c6ff957881615029504c0d3d31753 Mon Sep 17 00:00:00 2001
From: Dante
Date: Mon, 11 May 2026 21:53:53 +0900
Subject: [PATCH 10/48] fix: Load Image preview retains deleted asset (FE-230)
(#11493)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
After deleting an asset, the Load Image node kept displaying the deleted
thumbnail — both in the node body and in the picker dropdown (All /
Imported / Generated tabs), even after a workflow reload.
- Fixes FE-230
- Source: Slack
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776715727656809
## Root Cause
Three distinct paths kept the deleted asset visible:
1. **Node-body preview cache** — `useMediaAssetActions.deleteAssets`
never cleared `node.imgs` / `node.videoContainer` / the
`nodeOutputStore` Vue ref, so the canvas renderer kept its cached frame.
2. **Live-delete dropdown gap** — the picker reads from
`outputMediaAssets.media` (the asset list) and from
`missingMediaStore.missingMediaCandidates` (verified-missing names). On
live delete, neither was updated for the deleted asset, so the dropdown
filter had nothing to drop.
3. **Synthetic "selected" placeholder** —
`useWidgetSelectItems.missingValueItem` rebuilt any orphaned
`modelValue` as a fake item with a `/api/view?filename=...` preview URL.
Browsers had cached that URL pre-delete, so the deleted thumbnail still
rendered with a blue checkmark even after the filter dropped the real
asset entry.
A subtler issue compounded #2/#3: candidate names stored in
`missingMediaStore` are raw widget values (e.g. `sub/foo.png [output]`),
but the dropdown computed comparison keys differently per source (asset
list uses bare `asset.name`, widget option list uses bare filename).
Names with a subfolder prefix slipped through the filter.
## Fix
- **`clearNodePreviewCacheForFilenames`** (existing helper, refactored):
exports `findNodesReferencingFilenames` +
`extractFilenameFromWidgetValue`. Uses
`nodeOutputStore.removeNodeOutputs` so the **reactive** Pinia ref
updates, not just the legacy `app.nodeOutputs` mirror. Also clears
`node.videoContainer` for Load Video.
- **`markDeletedAssetsAsMissingMedia`** (new): on successful deletion,
surfaces the affected widgets through `missingMediaStore` immediately so
the dropdown filter has something to drop without waiting for
verification.
- **`useMissingMediaPreviewSync`** (new): watches `missingMediaStore`
and clears `node.imgs` / `node.videoContainer` / Vue preview source for
nodes referencing confirmed-missing media on workflow load — covers the
post-reload case.
- **`useWidgetSelectItems`**: normalizes both sides of the missing-media
filter via `extractFilenameFromWidgetValue` (strips
`[input|output|temp]` annotation + subfolder prefix), and suppresses
`missingValueItem` when the value is in the missing-media store so the
cached-thumbnail "selected" placeholder doesn't appear.
## Red-Green Verification
| Commit | CI Status | Run |
|--------|-----------|-----|
| `test: FE-230 add failing test for Load Image preview cache clearing`
| :red_circle: Failure — test caught the bug |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/24700188700 |
| `fix: FE-230 clear Load Image preview cache when asset is deleted` |
:green_circle: Success |
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/24700265884 |
## Test Plan
- [x] Unit coverage: 78 tests across 5 files (preview-cache helper,
mark-deleted-as-missing, missing-media-preview-sync, widget-select-items
missing-media filter incl. subfolder-prefix case, useMediaAssetActions
integration)
- [x] Live delete: Load Image node preview clears, dropdown drops the
asset across All / Imported / Generated, no synthetic "selected"
placeholder
- [x] Post-reload: missing-media verification →
`useMissingMediaPreviewSync` clears the preview, dropdown drops the
asset
- [x] Linear FE-230 auto-links via the Source line
## Scope note
In-session and session-restore are both covered. If the backend/CDN
continues serving the deleted `filename`/`asset_hash` after deletion, a
cross-session reopen may still render stale bytes from cache — that's a
backend/CDN concern tracked separately.
## demo
### before
https://github.com/user-attachments/assets/e4d3a40e-0d46-43ad-985c-22ce7e0d3faf
### after
https://github.com/user-attachments/assets/fcac9387-4c07-4be2-bcdd-d1a6192fe962
---
.../composables/useMediaAssetActions.test.ts | 162 ++++++++++++
.../composables/useMediaAssetActions.ts | 60 +++++
.../clearDeletedAssetWidgetValues.test.ts | 173 +++++++++++++
.../utils/clearDeletedAssetWidgetValues.ts | 40 +++
.../clearNodePreviewCacheForValues.test.ts | 241 ++++++++++++++++++
.../utils/clearNodePreviewCacheForValues.ts | 65 +++++
.../markDeletedAssetsAsMissingMedia.test.ts | 185 ++++++++++++++
.../utils/markDeletedAssetsAsMissingMedia.ts | 50 ++++
.../composables/useWidgetSelectItems.test.ts | 132 ++++++++++
.../composables/useWidgetSelectItems.ts | 28 +-
.../missingMediaPreviewRegression.test.ts | 93 +++++++
src/stores/nodeOutputStore.ts | 30 ++-
12 files changed, 1241 insertions(+), 18 deletions(-)
create mode 100644 src/platform/assets/utils/clearDeletedAssetWidgetValues.test.ts
create mode 100644 src/platform/assets/utils/clearDeletedAssetWidgetValues.ts
create mode 100644 src/platform/assets/utils/clearNodePreviewCacheForValues.test.ts
create mode 100644 src/platform/assets/utils/clearNodePreviewCacheForValues.ts
create mode 100644 src/platform/assets/utils/markDeletedAssetsAsMissingMedia.test.ts
create mode 100644 src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts
create mode 100644 src/stores/missingMediaPreviewRegression.test.ts
diff --git a/src/platform/assets/composables/useMediaAssetActions.test.ts b/src/platform/assets/composables/useMediaAssetActions.test.ts
index 375a90abec..c5f558e0b6 100644
--- a/src/platform/assets/composables/useMediaAssetActions.test.ts
+++ b/src/platform/assets/composables/useMediaAssetActions.test.ts
@@ -167,6 +167,52 @@ vi.mock('@/scripts/api', () => ({
}
}))
+const mockAppGraph = vi.hoisted(() => ({ value: { _nodes: [] as unknown[] } }))
+vi.mock('@/scripts/app', () => ({
+ app: {
+ get graph() {
+ return mockAppGraph.value
+ },
+ get rootGraph() {
+ return mockAppGraph.value
+ }
+ }
+}))
+
+const mockRemoveNodeOutputs = vi.hoisted(() => vi.fn())
+const mockRemoveNodeOutputsForNode = vi.hoisted(() => vi.fn())
+vi.mock('@/stores/nodeOutputStore', () => ({
+ useNodeOutputStore: () => ({
+ removeNodeOutputs: mockRemoveNodeOutputs,
+ removeNodeOutputsForNode: mockRemoveNodeOutputsForNode
+ })
+}))
+
+const mockCaptureCanvasState = vi.hoisted(() => vi.fn())
+vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
+ useWorkflowStore: () => ({
+ activeWorkflow: {
+ changeTracker: { captureCanvasState: mockCaptureCanvasState }
+ }
+ })
+}))
+
+const mockClearNodePreviewCache = vi.hoisted(() => vi.fn())
+vi.mock('../utils/clearNodePreviewCacheForValues', () => ({
+ clearNodePreviewCacheForValues: mockClearNodePreviewCache,
+ findNodesReferencingValues: vi.fn(() => [])
+}))
+
+const mockClearWidgetValues = vi.hoisted(() => vi.fn())
+vi.mock('../utils/clearDeletedAssetWidgetValues', () => ({
+ clearDeletedAssetWidgetValues: mockClearWidgetValues
+}))
+
+const mockMarkMissingMedia = vi.hoisted(() => vi.fn())
+vi.mock('../utils/markDeletedAssetsAsMissingMedia', () => ({
+ markDeletedAssetsAsMissingMedia: mockMarkMissingMedia
+}))
+
function createMockAsset(overrides: Partial = {}): AssetItem {
return {
id: 'test-asset-id',
@@ -793,4 +839,120 @@ describe('useMediaAssetActions', () => {
expect(dialogProps.itemList).toEqual(['fallback-image.png'])
})
})
+
+ describe('deleteAssets — FE-230 preview cache clearing', () => {
+ beforeEach(() => {
+ mockIsCloud.value = true
+ mockGetAssetType.mockReturnValue('input')
+ mockDeleteAsset.mockReset()
+ mockShowDialog.mockImplementation(
+ (opts: {
+ props: {
+ onConfirm: () => Promise | void
+ }
+ }) => {
+ void opts.props.onConfirm()
+ }
+ )
+ mockAppGraph.value = { _nodes: [] }
+ })
+
+ it('invokes clearNodePreviewCacheForValues with canonical widget-value variants', async () => {
+ mockDeleteAsset.mockResolvedValue(undefined)
+ const actions = useMediaAssetActions()
+ const asset = createMockAsset({
+ id: 'asset-match',
+ name: 'foo.png',
+ asset_hash: 'abc123.png',
+ tags: ['input']
+ })
+
+ await actions.deleteAssets(asset)
+
+ await vi.waitFor(() => {
+ expect(mockClearNodePreviewCache).toHaveBeenCalledTimes(1)
+ })
+ const [graphArg, valuesArg, removeArg] =
+ mockClearNodePreviewCache.mock.calls[0]
+ expect(graphArg).toBe(mockAppGraph.value)
+ expect(valuesArg).toEqual(
+ new Set(['foo.png', 'foo.png [input]', 'abc123.png'])
+ )
+ expect(typeof removeArg).toBe('function')
+
+ const sampleNode = { id: 42 }
+ removeArg(sampleNode)
+ expect(mockRemoveNodeOutputsForNode).toHaveBeenCalledWith(sampleNode)
+ // Locator is resolved from the node's own graph, not from the raw id —
+ // covers Load Image / Load Video nodes nested inside subgraphs.
+ expect(mockRemoveNodeOutputs).not.toHaveBeenCalled()
+
+ expect(mockClearWidgetValues).toHaveBeenCalledWith(
+ mockAppGraph.value,
+ new Set(['foo.png', 'foo.png [input]', 'abc123.png'])
+ )
+
+ expect(mockMarkMissingMedia).toHaveBeenCalledWith(
+ mockAppGraph.value,
+ new Set(['foo.png', 'foo.png [input]', 'abc123.png'])
+ )
+
+ // markMissing + previewCache must run before widget-value clearing,
+ // otherwise findNodesReferencingValues sees blanked widgets and matches
+ // nothing.
+ const markOrder = mockMarkMissingMedia.mock.invocationCallOrder[0]
+ const cacheOrder = mockClearNodePreviewCache.mock.invocationCallOrder[0]
+ const clearOrder = mockClearWidgetValues.mock.invocationCallOrder[0]
+ expect(markOrder).toBeLessThan(clearOrder)
+ expect(cacheOrder).toBeLessThan(clearOrder)
+
+ // Programmatic widget mutation doesn't go through DOM events, so the
+ // workflow won't be flagged as modified unless we capture explicitly.
+ expect(mockCaptureCanvasState).toHaveBeenCalled()
+ })
+
+ it('emits the [output]-annotated variant for output assets, including subfolder', async () => {
+ mockDeleteAsset.mockResolvedValue(undefined)
+ mockGetAssetType.mockReturnValue('output')
+ mockGetOutputAssetMetadata.mockReturnValue({
+ subfolder: 'outputs/2025'
+ })
+ const actions = useMediaAssetActions()
+ const asset = createMockAsset({
+ id: 'asset-output',
+ name: 'gen.png',
+ tags: ['output']
+ })
+
+ await actions.deleteAssets(asset)
+
+ await vi.waitFor(() => {
+ expect(mockClearNodePreviewCache).toHaveBeenCalledTimes(1)
+ })
+ const [, valuesArg] = mockClearNodePreviewCache.mock.calls[0]
+ expect(valuesArg).toEqual(new Set(['outputs/2025/gen.png [output]']))
+ expect(valuesArg.has('gen.png')).toBe(false)
+ expect(valuesArg.has('gen.png [input]')).toBe(false)
+ })
+
+ it('omits filenames of failed deletions and skips the helper when nothing was deleted', async () => {
+ mockDeleteAsset.mockRejectedValue(new Error('boom'))
+ const actions = useMediaAssetActions()
+ const asset = createMockAsset({
+ id: 'asset-failed',
+ name: 'failed.png',
+ asset_hash: 'failhash.png'
+ })
+
+ await actions.deleteAssets(asset)
+
+ await vi.waitFor(() => {
+ expect(mockDeleteAsset).toHaveBeenCalled()
+ })
+ expect(mockClearNodePreviewCache).not.toHaveBeenCalled()
+ expect(mockClearWidgetValues).not.toHaveBeenCalled()
+ expect(mockMarkMissingMedia).not.toHaveBeenCalled()
+ expect(mockCaptureCanvasState).not.toHaveBeenCalled()
+ })
+ })
})
diff --git a/src/platform/assets/composables/useMediaAssetActions.ts b/src/platform/assets/composables/useMediaAssetActions.ts
index 3f9b8e6ab7..6b0a9a631c 100644
--- a/src/platform/assets/composables/useMediaAssetActions.ts
+++ b/src/platform/assets/composables/useMediaAssetActions.ts
@@ -7,16 +7,22 @@ import { downloadFile } from '@/base/common/downloadUtil'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'
import { useWorkflowActionsService } from '@/platform/workflow/core/services/workflowActionsService'
+import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
import { api } from '@/scripts/api'
+import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { getOutputAssetMetadata } from '../schemas/assetMetadataSchema'
import { useAssetsStore } from '@/stores/assetsStore'
import { useDialogStore } from '@/stores/dialogStore'
+import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
import { getAssetType } from '../utils/assetTypeUtil'
import { getAssetUrl } from '../utils/assetUrlUtil'
+import { clearDeletedAssetWidgetValues } from '../utils/clearDeletedAssetWidgetValues'
+import { clearNodePreviewCacheForValues } from '../utils/clearNodePreviewCacheForValues'
+import { markDeletedAssetsAsMissingMedia } from '../utils/markDeletedAssetsAsMissingMedia'
import { getAssetOutputCount } from '../utils/outputAssetUtil'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
@@ -30,6 +36,35 @@ import { assetService } from '../services/assetService'
const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])
+/**
+ * Canonical widget-value strings that may reference this asset, scoped by the
+ * asset's source type so basenames cannot cross-match across input/output.
+ *
+ * Output assets emit ` [output]` (and the subfolder-prefixed form when
+ * present in metadata). Input/temp assets emit the bare name plus the explicit
+ * annotation. `asset_hash` is included whenever present, since cloud-stored
+ * assets can be referenced by hash.
+ */
+function widgetValueVariantsForAsset(asset: AssetItem): string[] {
+ const variants: string[] = []
+ const type = getAssetType(asset, 'input')
+ const name = asset.name
+ if (name) {
+ if (type === 'output') {
+ const subfolder = getOutputAssetMetadata(asset.user_metadata)?.subfolder
+ const path = subfolder ? `${subfolder}/${name}` : name
+ variants.push(`${path} [output]`)
+ } else if (type === 'temp') {
+ variants.push(`${name} [temp]`)
+ } else {
+ variants.push(name)
+ variants.push(`${name} [input]`)
+ }
+ }
+ if (asset.asset_hash) variants.push(asset.asset_hash)
+ return variants
+}
+
export function useMediaAssetActions() {
const { t } = useI18n()
const toast = useToast()
@@ -639,6 +674,31 @@ export function useMediaAssetActions() {
await assetsStore.updateInputs()
}
+ const rootGraph = app.rootGraph
+ if (rootGraph) {
+ const deletedValues = new Set()
+ assetArray.forEach((asset, index) => {
+ if (results[index].status !== 'fulfilled') return
+ for (const value of widgetValueVariantsForAsset(asset)) {
+ deletedValues.add(value)
+ }
+ })
+ if (deletedValues.size > 0) {
+ const nodeOutputStore = useNodeOutputStore()
+ // Order matters: mark + cache-clear both look up nodes by
+ // current widget.value, so they must run before
+ // clearDeletedAssetWidgetValues blanks those values.
+ markDeletedAssetsAsMissingMedia(rootGraph, deletedValues)
+ clearNodePreviewCacheForValues(
+ rootGraph,
+ deletedValues,
+ (node) => nodeOutputStore.removeNodeOutputsForNode(node)
+ )
+ clearDeletedAssetWidgetValues(rootGraph, deletedValues)
+ useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
+ }
+ }
+
// Invalidate model caches for affected categories
const modelCategories = new Set()
diff --git a/src/platform/assets/utils/clearDeletedAssetWidgetValues.test.ts b/src/platform/assets/utils/clearDeletedAssetWidgetValues.test.ts
new file mode 100644
index 0000000000..239654eaa9
--- /dev/null
+++ b/src/platform/assets/utils/clearDeletedAssetWidgetValues.test.ts
@@ -0,0 +1,173 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import type { LGraph } from '@/lib/litegraph/src/litegraph'
+
+import { clearDeletedAssetWidgetValues } from './clearDeletedAssetWidgetValues'
+
+type MockWidget = {
+ name: string
+ value: unknown
+ callback?: (value: unknown) => void
+}
+type MockNode = {
+ id: number
+ widgets?: MockWidget[]
+ graph?: { setDirtyCanvas: (v: boolean) => void }
+ isSubgraphNode?: () => boolean
+ subgraph?: { nodes: MockNode[] }
+}
+
+function makeGraph(nodes: MockNode[]): LGraph {
+ return { nodes } as unknown as LGraph
+}
+
+describe('FE-230 clearDeletedAssetWidgetValues', () => {
+ it('clears widget.value and invokes widget.callback so consumers run their own change-handling', () => {
+ const setDirty = vi.fn()
+ const callback = vi.fn()
+ const node: MockNode = {
+ id: 1,
+ widgets: [{ name: 'image', value: 'outputs/foo.png [output]', callback }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/foo.png [output]'])
+ )
+
+ expect(node.widgets![0].value).toBe('')
+ expect(callback).toHaveBeenCalledWith('')
+ expect(setDirty).toHaveBeenCalledWith(true)
+ })
+
+ it('leaves untouched widgets that do not match deleted values', () => {
+ const matchedCallback = vi.fn()
+ const keptCallback = vi.fn()
+ const node: MockNode = {
+ id: 2,
+ widgets: [
+ {
+ name: 'image',
+ value: 'outputs/foo.png [output]',
+ callback: matchedCallback
+ },
+ { name: 'mask', value: 'inputs/keep.png', callback: keptCallback }
+ ],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/foo.png [output]'])
+ )
+
+ expect(node.widgets![0].value).toBe('')
+ expect(node.widgets![1].value).toBe('inputs/keep.png')
+ expect(matchedCallback).toHaveBeenCalledWith('')
+ expect(keptCallback).not.toHaveBeenCalled()
+ })
+
+ it('leaves nodes alone when none of their widgets reference a deleted value (mask-editor case)', () => {
+ const setDirty = vi.fn()
+ const callback = vi.fn()
+ const node: MockNode = {
+ id: 3,
+ widgets: [
+ {
+ name: 'image',
+ value: 'clipspace/clipspace-painted-masked-1.png [input]',
+ callback
+ }
+ ],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/some-other-asset.png [output]'])
+ )
+
+ expect(node.widgets![0].value).toBe(
+ 'clipspace/clipspace-painted-masked-1.png [input]'
+ )
+ expect(callback).not.toHaveBeenCalled()
+ expect(setDirty).not.toHaveBeenCalled()
+ })
+
+ it('no-ops when the deleted-values set is empty', () => {
+ const setDirty = vi.fn()
+ const callback = vi.fn()
+ const node: MockNode = {
+ id: 4,
+ widgets: [{ name: 'image', value: 'outputs/foo.png [output]', callback }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearDeletedAssetWidgetValues(makeGraph([node]), new Set())
+
+ expect(node.widgets![0].value).toBe('outputs/foo.png [output]')
+ expect(callback).not.toHaveBeenCalled()
+ expect(setDirty).not.toHaveBeenCalled()
+ })
+
+ it('handles widgets without a callback (legacy nodes) without throwing', () => {
+ const node: MockNode = {
+ id: 5,
+ widgets: [{ name: 'image', value: 'outputs/foo.png [output]' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ expect(() =>
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/foo.png [output]'])
+ )
+ ).not.toThrow()
+
+ expect(node.widgets![0].value).toBe('')
+ })
+
+ it('clears all matching widgets across multiple nodes', () => {
+ const cbA = vi.fn()
+ const cbB = vi.fn()
+ const nodeA: MockNode = {
+ id: 6,
+ widgets: [
+ { name: 'image', value: 'outputs/a.png [output]', callback: cbA }
+ ],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+ const nodeB: MockNode = {
+ id: 7,
+ widgets: [
+ { name: 'image', value: 'outputs/a.png [output]', callback: cbB }
+ ],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearDeletedAssetWidgetValues(
+ makeGraph([nodeA, nodeB]),
+ new Set(['outputs/a.png [output]'])
+ )
+
+ expect(nodeA.widgets![0].value).toBe('')
+ expect(nodeB.widgets![0].value).toBe('')
+ expect(cbA).toHaveBeenCalledWith('')
+ expect(cbB).toHaveBeenCalledWith('')
+ })
+
+ it('does not affect nodes without widgets', () => {
+ const node: MockNode = {
+ id: 8,
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ expect(() =>
+ clearDeletedAssetWidgetValues(
+ makeGraph([node]),
+ new Set(['outputs/foo.png [output]'])
+ )
+ ).not.toThrow()
+ })
+})
diff --git a/src/platform/assets/utils/clearDeletedAssetWidgetValues.ts b/src/platform/assets/utils/clearDeletedAssetWidgetValues.ts
new file mode 100644
index 0000000000..daf3ba6299
--- /dev/null
+++ b/src/platform/assets/utils/clearDeletedAssetWidgetValues.ts
@@ -0,0 +1,40 @@
+import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
+
+import { findNodesReferencingValues } from './clearNodePreviewCacheForValues'
+
+/**
+ * Clear widget values that reference deleted assets so the persisted workflow
+ * JSON stops claiming the deleted asset is in use.
+ *
+ * Without this, after `useMediaAssetActions.deleteAssets` succeeds the
+ * in-memory preview is cleared (`clearNodePreviewCacheForValues`) but the
+ * widget value still points at the deleted asset. On reload the workflow JSON
+ * is restored verbatim and `useImageUploadWidget` re-fetches the URL — for
+ * output assets the file is still served (history-soft-delete), so the
+ * preview re-renders despite the asset being "deleted" everywhere else.
+ *
+ * Mutates `widget.value` (which `LGraphNode.serialize` re-reads to rebuild
+ * `widgets_values`) and invokes `widget.callback` so widgets like Load Image
+ * run their own change-handling (clearing `node.imgs`, calling
+ * `setNodeOutputs`, etc.).
+ *
+ * FE-230 — covers the post-reload case without re-introducing
+ * useMissingMediaPreviewSync, which couldn't distinguish deletion from
+ * verification false-positives (e.g. mask-editor saved values).
+ */
+export function clearDeletedAssetWidgetValues(
+ rootGraph: LGraph | Subgraph,
+ deletedValues: ReadonlySet
+): void {
+ if (deletedValues.size === 0) return
+ for (const node of findNodesReferencingValues(rootGraph, deletedValues)) {
+ if (!node.widgets) continue
+ for (const widget of node.widgets) {
+ if (typeof widget.value !== 'string') continue
+ if (!deletedValues.has(widget.value)) continue
+ widget.value = ''
+ widget.callback?.('')
+ }
+ node.graph?.setDirtyCanvas(true)
+ }
+}
diff --git a/src/platform/assets/utils/clearNodePreviewCacheForValues.test.ts b/src/platform/assets/utils/clearNodePreviewCacheForValues.test.ts
new file mode 100644
index 0000000000..4d772db7f2
--- /dev/null
+++ b/src/platform/assets/utils/clearNodePreviewCacheForValues.test.ts
@@ -0,0 +1,241 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
+
+import {
+ clearNodePreviewCacheForValues,
+ findNodesReferencingValues
+} from './clearNodePreviewCacheForValues'
+
+type MockWidget = { name: string; value: unknown }
+type MockNode = {
+ id: number
+ widgets?: MockWidget[]
+ imgs?: unknown
+ videoContainer?: unknown
+ graph?: { setDirtyCanvas: (v: boolean) => void }
+ isSubgraphNode?: () => boolean
+ subgraph?: { nodes: MockNode[] }
+}
+
+function makeGraph(nodes: MockNode[]): LGraph {
+ return { nodes } as unknown as LGraph
+}
+
+describe('FE-230 clearNodePreviewCacheForValues', () => {
+ it('clears node.imgs and removes outputs when a widget value matches a deleted value', () => {
+ const setDirty = vi.fn()
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 7,
+ widgets: [{ name: 'image', value: 'foo.png' }],
+ imgs: [{ src: 'blob:stale' }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['foo.png']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ expect(setDirty).toHaveBeenCalledWith(true)
+ })
+
+ it('leaves unrelated nodes untouched', () => {
+ const setDirty = vi.fn()
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 8,
+ widgets: [{ name: 'image', value: 'unrelated.png' }],
+ imgs: [{ src: 'blob:keep' }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['foo.png']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toEqual([{ src: 'blob:keep' }])
+ expect(remove).not.toHaveBeenCalled()
+ expect(setDirty).not.toHaveBeenCalled()
+ })
+
+ it('no-ops when the deleted value set is empty', () => {
+ const setDirty = vi.fn()
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 9,
+ widgets: [{ name: 'image', value: 'foo.png' }],
+ imgs: [{ src: 'blob:keep' }],
+ graph: { setDirtyCanvas: setDirty }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toEqual([{ src: 'blob:keep' }])
+ expect(remove).not.toHaveBeenCalled()
+ expect(setDirty).not.toHaveBeenCalled()
+ })
+
+ it('matches the [output]-annotated form for output assets', () => {
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 12,
+ widgets: [{ name: 'image', value: 'foo.png [output]' }],
+ imgs: [{ src: 'blob:stale' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['foo.png [output]']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ })
+
+ it('matches the subfolder-prefixed annotated form when provided', () => {
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 13,
+ widgets: [{ name: 'image', value: 'sub/foo.png [output]' }],
+ imgs: [{ src: 'blob:stale' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['sub/foo.png [output]']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ })
+
+ it('does not cross-match basenames across input/output sources', () => {
+ const remove = vi.fn()
+ const inputNode: MockNode = {
+ id: 1,
+ widgets: [{ name: 'image', value: 'foo.png' }],
+ imgs: [{ src: 'blob:input' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+ const outputNode: MockNode = {
+ id: 2,
+ widgets: [{ name: 'image', value: 'foo.png [output]' }],
+ imgs: [{ src: 'blob:output' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([inputNode, outputNode]),
+ new Set(['foo.png']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(inputNode.imgs).toBeUndefined()
+ expect(outputNode.imgs).toEqual([{ src: 'blob:output' }])
+ expect(remove).toHaveBeenCalledWith(inputNode)
+ expect(remove).not.toHaveBeenCalledWith(outputNode)
+ })
+
+ it('also clears videoContainer for video previews', () => {
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 15,
+ widgets: [{ name: 'video', value: 'clip.mp4' }],
+ videoContainer: { foo: 'bar' },
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['clip.mp4']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.videoContainer).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ })
+
+ it('matches any widget on the node, not just "image"', () => {
+ const remove = vi.fn()
+ const node: MockNode = {
+ id: 10,
+ widgets: [
+ { name: 'seed', value: 42 },
+ { name: 'video', value: 'clip.mp4' }
+ ],
+ imgs: [{ src: 'blob:videostale' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+
+ clearNodePreviewCacheForValues(
+ makeGraph([node]),
+ new Set(['clip.mp4']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(node.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(node)
+ })
+
+ it('walks subgraph interiors and matches nested nodes', () => {
+ const inner: MockNode = {
+ id: 100,
+ widgets: [{ name: 'image', value: 'nested.png [output]' }],
+ imgs: [{ src: 'blob:nested' }],
+ graph: { setDirtyCanvas: vi.fn() }
+ }
+ const wrapper: MockNode = {
+ id: 50,
+ widgets: [],
+ isSubgraphNode: () => true,
+ subgraph: { nodes: [inner] }
+ }
+ const remove = vi.fn()
+
+ clearNodePreviewCacheForValues(
+ makeGraph([wrapper]),
+ new Set(['nested.png [output]']),
+ remove as unknown as (node: LGraphNode) => void
+ )
+
+ expect(inner.imgs).toBeUndefined()
+ expect(remove).toHaveBeenCalledWith(inner)
+ })
+})
+
+describe('FE-230 findNodesReferencingValues', () => {
+ it('skips subgraph wrapper nodes (only their interior nodes match)', () => {
+ const inner: MockNode = {
+ id: 100,
+ widgets: [{ name: 'image', value: 'foo.png' }]
+ }
+ const wrapper: MockNode = {
+ id: 50,
+ widgets: [{ name: 'image', value: 'foo.png' }],
+ isSubgraphNode: () => true,
+ subgraph: { nodes: [inner] }
+ }
+
+ const matches = findNodesReferencingValues(
+ makeGraph([wrapper]),
+ new Set(['foo.png'])
+ )
+
+ expect(matches).toEqual([inner])
+ })
+})
diff --git a/src/platform/assets/utils/clearNodePreviewCacheForValues.ts b/src/platform/assets/utils/clearNodePreviewCacheForValues.ts
new file mode 100644
index 0000000000..bbd6d9c5f2
--- /dev/null
+++ b/src/platform/assets/utils/clearNodePreviewCacheForValues.ts
@@ -0,0 +1,65 @@
+import type {
+ LGraph,
+ LGraphNode,
+ Subgraph
+} from '@/lib/litegraph/src/litegraph'
+import { collectAllNodes } from '@/utils/graphTraversalUtil'
+
+/**
+ * Clear cached Load Image / Load Video preview state on any node whose widget
+ * value matches one of the given values. Covers:
+ * - the canvas renderer cache (`node.imgs`, `node.videoContainer`)
+ * - the Vue preview source — must be cleared via `removeOutputsForNode`
+ * so the Pinia reactive ref (`nodeOutputStore.nodeOutputs.value`) updates,
+ * not just the legacy `app.nodeOutputs` mirror
+ *
+ * Comparison is full-string against the widget value as stored — callers must
+ * provide the canonical widget-value variants for each deleted asset (e.g.
+ * `foo.png`, `foo.png [output]`, `sub/foo.png [output]`, ``). This
+ * avoids false matches when two distinct assets share a basename across
+ * input/output sources.
+ *
+ * Walks the full graph hierarchy via `collectAllNodes`, so Load Image / Load
+ * Video nodes inside subgraphs are also matched.
+ *
+ * FE-230 — invoked after successful asset deletion so the Load Image / Load
+ * Video node preview does not keep displaying a thumbnail for an asset that
+ * no longer exists.
+ */
+export function clearNodePreviewCacheForValues(
+ rootGraph: LGraph | Subgraph,
+ deletedValues: ReadonlySet,
+ removeOutputsForNode: (node: LGraphNode) => void
+): void {
+ if (deletedValues.size === 0) return
+ for (const node of findNodesReferencingValues(rootGraph, deletedValues)) {
+ removeOutputsForNode(node)
+ node.imgs = undefined
+ node.videoContainer = undefined
+ node.graph?.setDirtyCanvas(true)
+ }
+}
+
+/**
+ * Walk the graph hierarchy and yield each leaf node whose widget value matches
+ * one of `deletedValues`. Used by both the preview-clearing path and the
+ * missing-media-marking path so the two stay in lockstep.
+ *
+ * Skips subgraph wrapper nodes — only their interior nodes are inspected.
+ */
+export function findNodesReferencingValues(
+ rootGraph: LGraph | Subgraph,
+ deletedValues: ReadonlySet
+): LGraphNode[] {
+ if (deletedValues.size === 0) return []
+ const matches: LGraphNode[] = []
+ for (const node of collectAllNodes(rootGraph)) {
+ if (!node.widgets?.length) continue
+ if (node.isSubgraphNode?.()) continue
+ const referencesDeleted = node.widgets.some(
+ (w) => typeof w.value === 'string' && deletedValues.has(w.value)
+ )
+ if (referencesDeleted) matches.push(node)
+ }
+ return matches
+}
diff --git a/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.test.ts b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.test.ts
new file mode 100644
index 0000000000..705d65499e
--- /dev/null
+++ b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.test.ts
@@ -0,0 +1,185 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { LGraph } from '@/lib/litegraph/src/litegraph'
+import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
+
+import { markDeletedAssetsAsMissingMedia } from './markDeletedAssetsAsMissingMedia'
+
+vi.mock('@/platform/distribution/types', () => ({
+ isCloud: true
+}))
+
+const mockScanNodeMediaCandidates = vi.hoisted(() => vi.fn())
+vi.mock('@/platform/missingMedia/missingMediaScan', () => ({
+ scanNodeMediaCandidates: mockScanNodeMediaCandidates
+}))
+
+vi.mock('@/renderer/core/canvas/canvasStore', () => ({
+ useCanvasStore: () => ({ currentGraph: null })
+}))
+
+function makeGraph(nodes: unknown[]): LGraph {
+ return { nodes } as unknown as LGraph
+}
+
+describe('FE-230 markDeletedAssetsAsMissingMedia', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ mockScanNodeMediaCandidates.mockReset()
+ mockScanNodeMediaCandidates.mockReturnValue([])
+ })
+
+ it('adds missing-media candidates only for widgets whose value is in the deleted set', () => {
+ const node = {
+ id: 1,
+ type: 'LoadImage',
+ widgets: [
+ { name: 'image', value: 'sub/foo.png [output]' },
+ { name: 'mask', value: 'unrelated.png' }
+ ]
+ }
+ mockScanNodeMediaCandidates.mockReturnValue([
+ {
+ nodeId: '1',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'sub/foo.png [output]'
+ },
+ {
+ nodeId: '1',
+ nodeType: 'LoadImage',
+ widgetName: 'mask',
+ mediaType: 'image',
+ name: 'unrelated.png'
+ }
+ ])
+
+ markDeletedAssetsAsMissingMedia(
+ makeGraph([node]),
+ new Set(['sub/foo.png [output]'])
+ )
+
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toEqual([
+ {
+ nodeId: '1',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'sub/foo.png [output]',
+ isMissing: true
+ }
+ ])
+ })
+
+ it('does not cross-match basenames across input/output sources', () => {
+ const inputNode = {
+ id: 2,
+ type: 'LoadImage',
+ widgets: [{ name: 'image', value: 'foo.png' }]
+ }
+ const outputNode = {
+ id: 3,
+ type: 'LoadImage',
+ widgets: [{ name: 'image', value: 'foo.png [output]' }]
+ }
+
+ markDeletedAssetsAsMissingMedia(
+ makeGraph([inputNode, outputNode]),
+ new Set(['foo.png'])
+ )
+
+ expect(mockScanNodeMediaCandidates).toHaveBeenCalledTimes(1)
+ expect(mockScanNodeMediaCandidates).toHaveBeenCalledWith(
+ expect.anything(),
+ inputNode,
+ true
+ )
+ })
+
+ it('skips nodes with NEVER or BYPASS mode', () => {
+ const bypassed = {
+ id: 4,
+ type: 'LoadImage',
+ mode: 4,
+ widgets: [{ name: 'image', value: 'foo.png [output]' }]
+ }
+ const never = {
+ id: 5,
+ type: 'LoadImage',
+ mode: 2,
+ widgets: [{ name: 'image', value: 'foo.png [output]' }]
+ }
+
+ markDeletedAssetsAsMissingMedia(
+ makeGraph([bypassed, never]),
+ new Set(['foo.png [output]'])
+ )
+
+ expect(mockScanNodeMediaCandidates).not.toHaveBeenCalled()
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toBeNull()
+ })
+
+ it('walks subgraph interiors and marks nested nodes', () => {
+ const inner = {
+ id: 100,
+ type: 'LoadImage',
+ widgets: [{ name: 'image', value: 'nested.png [output]' }]
+ }
+ const wrapper = {
+ id: 50,
+ widgets: [],
+ isSubgraphNode: () => true,
+ subgraph: { nodes: [inner] }
+ }
+ mockScanNodeMediaCandidates.mockReturnValue([
+ {
+ nodeId: '50:100',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'nested.png [output]'
+ }
+ ])
+
+ markDeletedAssetsAsMissingMedia(
+ makeGraph([wrapper]),
+ new Set(['nested.png [output]'])
+ )
+
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toEqual([
+ {
+ nodeId: '50:100',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'nested.png [output]',
+ isMissing: true
+ }
+ ])
+ })
+
+ it('is a no-op when no nodes reference any deleted value', () => {
+ const node = {
+ id: 2,
+ type: 'LoadImage',
+ widgets: [{ name: 'image', value: 'kept.png' }]
+ }
+
+ markDeletedAssetsAsMissingMedia(makeGraph([node]), new Set(['gone.png']))
+
+ expect(mockScanNodeMediaCandidates).not.toHaveBeenCalled()
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toBeNull()
+ })
+
+ it('does nothing when the deleted value set is empty', () => {
+ markDeletedAssetsAsMissingMedia(makeGraph([]), new Set())
+ const store = useMissingMediaStore()
+ expect(store.missingMediaCandidates).toBeNull()
+ })
+})
diff --git a/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts
new file mode 100644
index 0000000000..800e685147
--- /dev/null
+++ b/src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts
@@ -0,0 +1,50 @@
+import type { LGraph } from '@/lib/litegraph/src/litegraph'
+import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
+import { isCloud } from '@/platform/distribution/types'
+import { scanNodeMediaCandidates } from '@/platform/missingMedia/missingMediaScan'
+import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
+import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
+
+import { findNodesReferencingValues } from './clearNodePreviewCacheForValues'
+
+/**
+ * After a successful asset deletion, surface the affected Load Image / Load
+ * Video / Load Audio nodes through the missing-media store. Without this, UI
+ * surfaces that filter against `missingMediaCandidates` (e.g. the Vue node
+ * widget dropdown) keep listing the deleted asset because the verification
+ * pipeline only runs on workflow load — there is no signal that the live
+ * deletion just invalidated some references.
+ *
+ * Walks the full graph hierarchy (including subgraphs) and skips bypassed /
+ * never-execute nodes, mirroring `scanAllMediaCandidates` so the live-delete
+ * path stays in lockstep with the workflow-load verification.
+ *
+ * Comparison is full-string against the widget value, so two distinct assets
+ * that share a basename across input/output sources do not cross-match.
+ */
+export function markDeletedAssetsAsMissingMedia(
+ rootGraph: LGraph,
+ deletedValues: ReadonlySet
+): void {
+ if (deletedValues.size === 0) return
+
+ const matchedNodes = findNodesReferencingValues(rootGraph, deletedValues)
+ if (!matchedNodes.length) return
+
+ const candidates: MissingMediaCandidate[] = []
+ for (const node of matchedNodes) {
+ if (
+ node.mode === LGraphEventMode.NEVER ||
+ node.mode === LGraphEventMode.BYPASS
+ )
+ continue
+ for (const candidate of scanNodeMediaCandidates(rootGraph, node, isCloud)) {
+ if (!deletedValues.has(candidate.name)) continue
+ candidates.push({ ...candidate, isMissing: true })
+ }
+ }
+
+ if (candidates.length) {
+ useMissingMediaStore().addMissingMedia(candidates)
+ }
+}
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.test.ts b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.test.ts
index 924a3a6f65..5b6bff0145 100644
--- a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.test.ts
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.test.ts
@@ -871,4 +871,136 @@ describe('useWidgetSelectItems', () => {
expect(selectedSet.value.has('missing-nonexistent.png')).toBe(true)
})
})
+
+ describe('FE-230 missing-media filtering', () => {
+ it('drops input items whose name is in the missing-media store', async () => {
+ const { useMissingMediaStore } =
+ await import('@/platform/missingMedia/missingMediaStore')
+ const store = useMissingMediaStore()
+ store.setMissingMedia([
+ {
+ nodeId: '1',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'photo_abc.jpg',
+ isMissing: true
+ }
+ ])
+
+ const { dropdownItems } = useWidgetSelectItems(createDefaultOptions())
+ const names = dropdownItems.value.map((i) => i.name)
+ expect(names).not.toContain('photo_abc.jpg')
+ expect(names).toContain('img_001.png')
+ })
+
+ it('drops output items whose annotated path is in the missing-media store', async () => {
+ mockMediaAssets = createMockMediaAssets()
+ mockMediaAssets.media.value = [
+ {
+ id: 'a1',
+ name: 'gone.png',
+ size: 0,
+ tags: [],
+ created_at: '2025-01-01T00:00:00Z'
+ } as AssetItem,
+ {
+ id: 'a2',
+ name: 'kept.png',
+ size: 0,
+ tags: [],
+ created_at: '2025-01-01T00:00:00Z'
+ } as AssetItem
+ ]
+
+ const { useMissingMediaStore } =
+ await import('@/platform/missingMedia/missingMediaStore')
+ const store = useMissingMediaStore()
+ store.setMissingMedia([
+ {
+ nodeId: '7',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'gone.png [output]',
+ isMissing: true
+ }
+ ])
+
+ const { dropdownItems } = useWidgetSelectItems(
+ createDefaultOptions({
+ values: () => [],
+ outputMediaAssets: mockMediaAssets
+ })
+ )
+ await nextTick()
+
+ const names = dropdownItems.value.map((i) => i.name)
+ expect(names).not.toContain('gone.png [output]')
+ expect(names).toContain('kept.png [output]')
+ })
+
+ it('does not cross-match basenames across input and output sources', async () => {
+ mockMediaAssets = createMockMediaAssets()
+ mockMediaAssets.media.value = [
+ {
+ id: 'a1',
+ name: 'photo_abc.jpg',
+ size: 0,
+ tags: [],
+ created_at: '2025-01-01T00:00:00Z'
+ } as AssetItem
+ ]
+
+ const { useMissingMediaStore } =
+ await import('@/platform/missingMedia/missingMediaStore')
+ const store = useMissingMediaStore()
+ store.setMissingMedia([
+ {
+ nodeId: '1',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'photo_abc.jpg',
+ isMissing: true
+ }
+ ])
+
+ const { dropdownItems } = useWidgetSelectItems(
+ createDefaultOptions({ outputMediaAssets: mockMediaAssets })
+ )
+ await nextTick()
+
+ const names = dropdownItems.value.map((i) => i.name)
+ expect(names).not.toContain('photo_abc.jpg')
+ expect(names).toContain('photo_abc.jpg [output]')
+ })
+
+ it('does not surface a missing-value placeholder when the modelValue is confirmed missing', async () => {
+ const modelValue = ref('gone.png [output]')
+
+ const { useMissingMediaStore } =
+ await import('@/platform/missingMedia/missingMediaStore')
+ const store = useMissingMediaStore()
+ store.setMissingMedia([
+ {
+ nodeId: '7',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'gone.png [output]',
+ isMissing: true
+ }
+ ])
+
+ const { dropdownItems, selectedSet } = useWidgetSelectItems(
+ createDefaultOptions({ modelValue, values: () => [] })
+ )
+ await nextTick()
+
+ const names = dropdownItems.value.map((i) => i.name)
+ expect(names).not.toContain('gone.png [output]')
+ expect(selectedSet.value.size).toBe(0)
+ })
+ })
})
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.ts b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.ts
index 86a11321aa..8450bb0f46 100644
--- a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.ts
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.ts
@@ -5,6 +5,7 @@ import type { MaybeRefOrGetter, Ref } from 'vue'
import { t } from '@/i18n'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
+import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import {
filterItemByBaseModels,
filterItemByOwnership
@@ -72,6 +73,14 @@ interface UseWidgetSelectItemsOptions {
export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
const { modelValue, outputMediaAssets, assetData } = options
+ const missingMediaStore = useMissingMediaStore()
+ const missingMediaValues = computed>(
+ () =>
+ new Set(
+ missingMediaStore.missingMediaCandidates?.map((c) => c.name) ?? []
+ )
+ )
+
const filterSelected = ref('all')
const filterOptions = computed(() => {
const isAsset = toValue(options.isAssetMode)
@@ -153,12 +162,15 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
const labelFn = toValue(options.getOptionLabel)
const kind = toValue(options.assetKind)
- return values.map((value, index) => ({
- id: `input-${index}`,
- preview_url: getMediaUrl(String(value), 'input', kind),
- name: String(value),
- label: getDisplayLabel(String(value), labelFn)
- }))
+ const missing = missingMediaValues.value
+ return values
+ .filter((value) => !missing.has(String(value)))
+ .map((value, index) => ({
+ id: `input-${index}`,
+ preview_url: getMediaUrl(String(value), 'input', kind),
+ name: String(value),
+ label: getDisplayLabel(String(value), labelFn)
+ }))
})
const outputItems = computed(() => {
@@ -176,6 +188,7 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
return resolved ?? [asset]
})
+ const missing = missingMediaValues.value
for (const asset of assets) {
if (getMediaTypeFromFilename(asset.name) !== targetMediaType) continue
if (seen.has(asset.id)) continue
@@ -188,6 +201,7 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
? `${subfolder}/${asset.name}`
: asset.name
const annotatedPath = `${pathWithSubfolder} [output]`
+ if (missing.has(annotatedPath)) continue
const displayLabel = `${getAssetDisplayFilename(asset)} [output]`
items.push({
id: `output-${asset.id}`,
@@ -209,6 +223,8 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
const labelFn = toValue(options.getOptionLabel)
const kind = toValue(options.assetKind)
+ if (missingMediaValues.value.has(currentValue)) return undefined
+
if (toValue(options.isAssetMode) && assetData) {
const existsInAssets = assetData.assets.value.some(
(asset) => getAssetFilename(asset) === currentValue
diff --git a/src/stores/missingMediaPreviewRegression.test.ts b/src/stores/missingMediaPreviewRegression.test.ts
new file mode 100644
index 0000000000..d8f349c18b
--- /dev/null
+++ b/src/stores/missingMediaPreviewRegression.test.ts
@@ -0,0 +1,93 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick } from 'vue'
+
+import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
+import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
+import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
+
+const mockRemoveNodeOutputs = vi.hoisted(() => vi.fn())
+vi.mock('@/stores/nodeOutputStore', () => ({
+ useNodeOutputStore: () => ({ removeNodeOutputs: mockRemoveNodeOutputs })
+}))
+
+const mockApp = vi.hoisted(() => ({
+ isGraphReady: true,
+ rootGraph: { nodes: [], _nodes: [] } as unknown as LGraph
+}))
+vi.mock('@/scripts/app', () => ({ app: mockApp }))
+
+const mockGetNodeByExecutionId = vi.hoisted(() => vi.fn())
+vi.mock('@/utils/graphTraversalUtil', async () => {
+ const actual = await vi.importActual(
+ '@/utils/graphTraversalUtil'
+ )
+ return {
+ ...actual,
+ getNodeByExecutionId: mockGetNodeByExecutionId
+ }
+})
+
+vi.mock('@/i18n', () => ({
+ st: vi.fn((_key: string, fallback: string) => fallback)
+}))
+
+vi.mock('@/platform/distribution/types', () => ({ isCloud: false }))
+
+vi.mock('@/stores/settingStore', () => ({
+ useSettingStore: vi.fn(() => ({ get: vi.fn(() => false) }))
+}))
+
+vi.mock('@/platform/settings/settingStore', () => ({
+ useSettingStore: vi.fn(() => ({ get: vi.fn(() => false) }))
+}))
+
+vi.mock(
+ '@/platform/missingModel/composables/useMissingModelInteractions',
+ () => ({ clearMissingModelState: vi.fn() })
+)
+
+import { useExecutionErrorStore } from './executionErrorStore'
+
+function makeNodeWithPreview(id: number): LGraphNode {
+ return {
+ id,
+ imgs: [{ src: 'blob:mask-edited' }],
+ videoContainer: undefined,
+ graph: { setDirtyCanvas: vi.fn() }
+ } as unknown as LGraphNode
+}
+
+describe('FE-230 regression — workflow-load missing-media flagging must not wipe node previews', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ mockApp.isGraphReady = true
+ mockApp.rootGraph = { nodes: [], _nodes: [] } as unknown as LGraph
+ mockRemoveNodeOutputs.mockReset()
+ mockGetNodeByExecutionId.mockReset()
+ })
+
+ it('does not clear node.imgs when verification flags a Load Image as missing on workflow load (e.g. mask-editor saved value)', async () => {
+ const node = makeNodeWithPreview(42)
+ mockGetNodeByExecutionId.mockReturnValue(node)
+
+ useExecutionErrorStore()
+ const missingMediaStore = useMissingMediaStore()
+
+ missingMediaStore.setMissingMedia([
+ {
+ nodeId: '42',
+ nodeType: 'LoadImage',
+ widgetName: 'image',
+ mediaType: 'image',
+ name: 'clipspace/clipspace-painted-masked-1.png [input]',
+ isMissing: true
+ }
+ ])
+ await nextTick()
+ await nextTick()
+
+ expect(node.imgs).toEqual([{ src: 'blob:mask-edited' }])
+ expect(mockRemoveNodeOutputs).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/stores/nodeOutputStore.ts b/src/stores/nodeOutputStore.ts
index 74cdcdedeb..e8cc0752fb 100644
--- a/src/stores/nodeOutputStore.ts
+++ b/src/stores/nodeOutputStore.ts
@@ -367,22 +367,11 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
}
}
- /**
- * Remove node outputs for a specific node
- * Clears both outputs and preview images
- */
- function removeNodeOutputs(nodeId: number | string) {
- const nodeLocatorId = nodeIdToNodeLocatorId(Number(nodeId))
- if (!nodeLocatorId) return false
-
- // Clear from app.nodeOutputs
+ function removeOutputsByLocatorId(nodeLocatorId: NodeLocatorId) {
const hadOutputs = !!app.nodeOutputs[nodeLocatorId]
delete app.nodeOutputs[nodeLocatorId]
-
- // Clear from reactive state
delete nodeOutputs.value[nodeLocatorId]
- // Clear preview images
if (app.nodePreviewImages[nodeLocatorId]) {
const previews = app.nodePreviewImages[nodeLocatorId]
if (previews?.[Symbol.iterator]) {
@@ -397,6 +386,22 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
return hadOutputs
}
+ /**
+ * Remove node outputs for a specific node
+ * Clears both outputs and preview images
+ */
+ function removeNodeOutputs(nodeId: number | string) {
+ const nodeLocatorId = nodeIdToNodeLocatorId(Number(nodeId))
+ if (!nodeLocatorId) return false
+ return removeOutputsByLocatorId(nodeLocatorId)
+ }
+
+ // Resolves the locator from the node's own graph, so interior subgraph nodes
+ // are addressed correctly even when the user has a different graph active.
+ function removeNodeOutputsForNode(node: LGraphNode) {
+ return removeOutputsByLocatorId(nodeToNodeLocatorId(node))
+ }
+
function snapshotOutputs(): Record {
return clone(app.nodeOutputs)
}
@@ -493,6 +498,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
revokeAllPreviews,
revokeSubgraphPreviews,
removeNodeOutputs,
+ removeNodeOutputsForNode,
snapshotOutputs,
restoreOutputs,
resetAllOutputsAndPreviews,
From c64343860128c317770827a89c4c4fe12da4a927 Mon Sep 17 00:00:00 2001
From: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Date: Mon, 11 May 2026 18:33:34 +0100
Subject: [PATCH 11/48] fix: hide image buttons if load failed (#12136)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
When an image fails to load in the image preview, the context buttons
are still visible - clicking these does not work (Mask editor opens and
closes, download does nothing) - this hides the buttons if load fails.
## Changes
- **What**:
- hide buttons if load failed
- tests
## Review Focus
## Screenshots (if applicable)
Current:
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12136-fix-hide-image-buttons-if-load-failed-35d6d73d365081579c71f1849b9ab1bd)
by [Unito](https://www.unito.io)
---
.../load_image_widget_missing_file.json | 42 +++++++++++++++++++
.../fixtures/utils/vueNodeFixtures.ts | 2 +
.../interactions/node/imagePreview.spec.ts | 24 +++++++++--
.../vueNodes/components/ImagePreview.test.ts | 23 ++++++++++
.../vueNodes/components/ImagePreview.vue | 3 +-
5 files changed, 90 insertions(+), 4 deletions(-)
create mode 100644 browser_tests/assets/widgets/load_image_widget_missing_file.json
diff --git a/browser_tests/assets/widgets/load_image_widget_missing_file.json b/browser_tests/assets/widgets/load_image_widget_missing_file.json
new file mode 100644
index 0000000000..4e101b8d44
--- /dev/null
+++ b/browser_tests/assets/widgets/load_image_widget_missing_file.json
@@ -0,0 +1,42 @@
+{
+ "last_node_id": 10,
+ "last_link_id": 10,
+ "nodes": [
+ {
+ "id": 10,
+ "type": "LoadImage",
+ "pos": [50, 50],
+ "size": [315, 314],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "links": null
+ },
+ {
+ "name": "MASK",
+ "type": "MASK",
+ "links": null
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "LoadImage"
+ },
+ "widgets_values": ["this-image-does-not-exist-deadbeef.png", "image"]
+ }
+ ],
+ "links": [],
+ "groups": [],
+ "config": {},
+ "extra": {
+ "ds": {
+ "offset": [0, 0],
+ "scale": 1
+ }
+ },
+ "version": 0.4
+}
diff --git a/browser_tests/fixtures/utils/vueNodeFixtures.ts b/browser_tests/fixtures/utils/vueNodeFixtures.ts
index 4ce084f38e..b2ce49d994 100644
--- a/browser_tests/fixtures/utils/vueNodeFixtures.ts
+++ b/browser_tests/fixtures/utils/vueNodeFixtures.ts
@@ -14,6 +14,7 @@ export class VueNodeFixture {
public readonly collapseIcon: Locator
public readonly root: Locator
public readonly widgets: Locator
+ public readonly imagePreview: Locator
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
@@ -25,6 +26,7 @@ export class VueNodeFixture {
this.collapseIcon = this.collapseButton.locator('i')
this.root = locator
this.widgets = this.locator.locator('.lg-node-widget')
+ this.imagePreview = locator.locator('.image-preview')
}
async getTitle(): Promise {
diff --git a/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts b/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts
index 0f22d70569..4050cafaff 100644
--- a/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts
+++ b/browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts
@@ -21,9 +21,8 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
})
const nodeId = String(loadImageNode.id)
- const imagePreview = comfyPage.vueNodes
- .getNodeLocator(nodeId)
- .locator('.image-preview')
+ const { imagePreview } =
+ await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible({ timeout: 30_000 })
@@ -44,6 +43,25 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
})
+ test('hides mask and download buttons when image is missing', async ({
+ comfyPage
+ }) => {
+ await comfyPage.workflow.loadWorkflow(
+ 'widgets/load_image_widget_missing_file'
+ )
+
+ const { imagePreview } =
+ await comfyPage.vueNodes.getFixtureByTitle('Load Image')
+
+ await expect(imagePreview).toBeVisible()
+ await expect(imagePreview.getByTestId('error-loading-image')).toBeVisible()
+
+ await imagePreview.getByRole('region').hover()
+
+ await expect(imagePreview.getByLabel('Edit or mask image')).toHaveCount(0)
+ await expect(imagePreview.getByLabel('Download image')).toHaveCount(0)
+ })
+
test('shows image context menu options', async ({ comfyPage }) => {
const { nodeId } = await loadImageOnNode(comfyPage)
diff --git a/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts b/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts
index b45fce2fe1..7a1f2e6c1e 100644
--- a/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts
+++ b/src/renderer/extensions/vueNodes/components/ImagePreview.test.ts
@@ -134,6 +134,29 @@ describe('ImagePreview', () => {
screen.getByRole('button', { name: 'Edit or mask image' })
})
+ it('hides mask and download buttons when image fails to load', async () => {
+ renderImagePreview({
+ imageUrls: [defaultProps.imageUrls[0]]
+ })
+
+ expect(
+ screen.getByRole('button', { name: 'Edit or mask image' })
+ ).toBeInTheDocument()
+ expect(
+ screen.getByRole('button', { name: 'Download image' })
+ ).toBeInTheDocument()
+
+ await fireEvent.error(screen.getByTestId('main-image'))
+ await nextTick()
+
+ expect(
+ screen.queryByRole('button', { name: 'Edit or mask image' })
+ ).not.toBeInTheDocument()
+ expect(
+ screen.queryByRole('button', { name: 'Download image' })
+ ).not.toBeInTheDocument()
+ })
+
it('handles download button click', async () => {
renderImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
diff --git a/src/renderer/extensions/vueNodes/components/ImagePreview.vue b/src/renderer/extensions/vueNodes/components/ImagePreview.vue
index 5548f55d40..1fe1b8162e 100644
--- a/src/renderer/extensions/vueNodes/components/ImagePreview.vue
+++ b/src/renderer/extensions/vueNodes/components/ImagePreview.vue
@@ -80,7 +80,7 @@
>
{{ dept.name }}
From d23e86d9a422cb9db3c52cee1142fc81aab657d5 Mon Sep 17 00:00:00 2001
From: Comfy Org PR Bot
Date: Tue, 12 May 2026 03:47:18 +0900
Subject: [PATCH 18/48] [chore] Update Ingest API types from cloud@0a03f3a
(#12043)
## Automated Ingest API Type Update
This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.
- Cloud commit: 0a03f3a
- Generated using @hey-api/openapi-ts with Zod plugin
These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.
---------
Co-authored-by: MillerMedia <7741082+MillerMedia@users.noreply.github.com>
Co-authored-by: GitHub Action
---
packages/ingest-types/src/types.gen.ts | 4 ----
packages/ingest-types/src/zod.gen.ts | 3 +--
2 files changed, 1 insertion(+), 6 deletions(-)
diff --git a/packages/ingest-types/src/types.gen.ts b/packages/ingest-types/src/types.gen.ts
index 3ce132cc2a..8340251939 100644
--- a/packages/ingest-types/src/types.gen.ts
+++ b/packages/ingest-types/src/types.gen.ts
@@ -523,10 +523,6 @@ export type ImportPublishedAssetsRequest = {
* IDs of published assets (inputs and models) to import.
*/
published_asset_ids: Array
- /**
- * The share ID of the published workflow these assets belong to. Required for authorization.
- */
- share_id: string
}
/**
diff --git a/packages/ingest-types/src/zod.gen.ts b/packages/ingest-types/src/zod.gen.ts
index 807cb9d7e4..42a6029bfd 100644
--- a/packages/ingest-types/src/zod.gen.ts
+++ b/packages/ingest-types/src/zod.gen.ts
@@ -310,8 +310,7 @@ export const zImportPublishedAssetsResponse = z.object({
* Request body for importing assets from a published workflow.
*/
export const zImportPublishedAssetsRequest = z.object({
- published_asset_ids: z.array(z.string().min(1).max(64)).max(1000),
- share_id: z.string().min(1).max(64)
+ published_asset_ids: z.array(z.string())
})
/**
From 5d53e75d23cf004b502cbb55de53e31f477d7e10 Mon Sep 17 00:00:00 2001
From: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Date: Mon, 11 May 2026 19:51:52 +0100
Subject: [PATCH 19/48] test: add tests for deprecated & api node badge
visibility settings (#11681)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Adds coverage for untested node badges
## Changes
- **What**:
- add shared addNode helper - will follow up to standardize across tests
- add deprecated & api node badge tests in LiteGraph & Vue nodes
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11681-test-add-tests-for-deprecated-api-node-badge-visibility-settings-34f6d73d365081569129ecffa608122e)
by [Unito](https://www.unito.io)
---------
Co-authored-by: github-actions
---
browser_tests/tests/nodeBadge.spec.ts | 73 ++++++++++++++++++
...api-pricing-off-classic-chromium-linux.png | Bin 0 -> 52099 bytes
.../api-pricing-off-vue-chromium-linux.png | Bin 0 -> 53787 bytes
.../api-pricing-on-classic-chromium-linux.png | Bin 0 -> 54224 bytes
.../api-pricing-on-vue-chromium-linux.png | Bin 0 -> 56866 bytes
...-lifecycle-None-classic-chromium-linux.png | Bin 0 -> 40920 bytes
...node-lifecycle-None-vue-chromium-linux.png | Bin 0 -> 43331 bytes
...ecycle-Show-all-classic-chromium-linux.png | Bin 0 -> 41700 bytes
...-lifecycle-Show-all-vue-chromium-linux.png | Bin 0 -> 44135 bytes
9 files changed, 73 insertions(+)
create mode 100644 browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-off-classic-chromium-linux.png
create mode 100644 browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-off-vue-chromium-linux.png
create mode 100644 browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-on-classic-chromium-linux.png
create mode 100644 browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-on-vue-chromium-linux.png
create mode 100644 browser_tests/tests/nodeBadge.spec.ts-snapshots/node-lifecycle-None-classic-chromium-linux.png
create mode 100644 browser_tests/tests/nodeBadge.spec.ts-snapshots/node-lifecycle-None-vue-chromium-linux.png
create mode 100644 browser_tests/tests/nodeBadge.spec.ts-snapshots/node-lifecycle-Show-all-classic-chromium-linux.png
create mode 100644 browser_tests/tests/nodeBadge.spec.ts-snapshots/node-lifecycle-Show-all-vue-chromium-linux.png
diff --git a/browser_tests/tests/nodeBadge.spec.ts b/browser_tests/tests/nodeBadge.spec.ts
index e33237be11..975225b389 100644
--- a/browser_tests/tests/nodeBadge.spec.ts
+++ b/browser_tests/tests/nodeBadge.spec.ts
@@ -8,6 +8,9 @@ test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
+const DEPRECATED_NODE_TYPE = 'ImageBatch'
+const API_NODE_TYPE = 'FluxProUltraImageNode'
+
test.describe('Node Badge', { tag: ['@screenshot', '@smoke', '@node'] }, () => {
test('Can add badge', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
@@ -141,3 +144,73 @@ test.describe(
})
}
)
+
+for (const vueEnabled of [false, true] as const) {
+ const renderer = vueEnabled ? 'vue' : 'classic'
+ const tag = vueEnabled
+ ? ['@vue-nodes', '@screenshot', '@node']
+ : ['@screenshot', '@node']
+
+ test.describe(`Node lifecycle badge (${renderer})`, { tag }, () => {
+ test.beforeEach(async ({ comfyPage }) => {
+ await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
+ })
+
+ for (const mode of [NodeBadgeMode.ShowAll, NodeBadgeMode.None] as const) {
+ test(`renders deprecated node with mode=${mode}`, async ({
+ comfyPage
+ }) => {
+ await comfyPage.settings.setSetting(
+ 'Comfy.NodeBadge.NodeLifeCycleBadgeMode',
+ mode
+ )
+ await comfyPage.nodeOps.clearGraph()
+ await comfyPage.nodeOps.addNode(DEPRECATED_NODE_TYPE, undefined, {
+ x: 100,
+ y: 100
+ })
+ await comfyPage.canvasOps.resetView()
+ await expect(comfyPage.canvas).toHaveScreenshot(
+ `node-lifecycle-${mode}-${renderer}.png`
+ )
+ })
+ }
+ })
+
+ test.describe(`API pricing badge (${renderer})`, { tag }, () => {
+ test.beforeEach(async ({ comfyPage }) => {
+ await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
+ await comfyPage.page.evaluate((type) => {
+ const registered = window.LiteGraph!.registered_node_types[type] as {
+ nodeData?: { price_badge?: unknown }
+ }
+ if (!registered?.nodeData) throw new Error(`No nodeData for ${type}`)
+ registered.nodeData.price_badge = {
+ engine: 'jsonata',
+ expr: "{'type': 'text', 'text': '99.9 credits/Run'}",
+ depends_on: { widgets: [], inputs: [], input_groups: [] }
+ }
+ }, API_NODE_TYPE)
+ })
+
+ for (const enabled of [true, false] as const) {
+ test(`renders api node with showApiPricing=${enabled}`, async ({
+ comfyPage
+ }) => {
+ await comfyPage.settings.setSetting(
+ 'Comfy.NodeBadge.ShowApiPricing',
+ enabled
+ )
+ await comfyPage.nodeOps.clearGraph()
+ await comfyPage.nodeOps.addNode(API_NODE_TYPE, undefined, {
+ x: 100,
+ y: 100
+ })
+ await comfyPage.canvasOps.resetView()
+ await expect(comfyPage.canvas).toHaveScreenshot(
+ `api-pricing-${enabled ? 'on' : 'off'}-${renderer}.png`
+ )
+ })
+ }
+ })
+}
diff --git a/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-off-classic-chromium-linux.png b/browser_tests/tests/nodeBadge.spec.ts-snapshots/api-pricing-off-classic-chromium-linux.png
new file mode 100644
index 0000000000000000000000000000000000000000..e60b6050058e109f4558208d19ff2ef14866cebb
GIT binary patch
literal 52099
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%0DoAOfKNN3j9%+VUim+Ezm52iIJ!TA%h}R
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