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)" />