Compare commits

..
Author SHA1 Message Date
ausboss 0960817698 Example notes: say what the graph does
- Krea 2 Inpaint Masked: the saved-by-name copies of two notes now
  match the positional text.
- Krea 2 Rotate + Outpaint: the note describes the tone match the
  graph runs.
- Qwen Image 2.1 Inpaint: the note says the seed is fixed.
- Qwen Image 2.1 Upscale + Restore: the Megapixels note mentions the
  roughly 2 MP minimum redraw.
2026-11-04 00:35:38 -06:00
17 changed files with 45 additions and 493 deletions
@@ -1913,7 +1913,7 @@
"The painted area, grown a little, is filled with flat gray, so Krea 2 knows where to paint. The plain crop goes along too."
],
"widgets_values_named": {
"text": "The painted area, grown a little, is filled with flat gray. Krea 2 never sees what you are replacing."
"text": "The painted area, grown a little, is filled with flat gray, so Krea 2 knows where to paint. The plain crop goes along too."
},
"color": "#1f2c38",
"bgcolor": "#2a3b4a"
@@ -3052,7 +3052,7 @@
"The gray-filled crop is the reference. Painting starts from the plain crop, inside the noise mask: the painted area, widened by 24 px."
],
"widgets_values_named": {
"text": "The gray-filled crop is the reference. The noise mask is the grown painted area, widened by 24 px. Krea 2 paints only inside it."
"text": "The gray-filled crop is the reference. Painting starts from the plain crop, inside the noise mask: the painted area, widened by 24 px."
},
"color": "#1f2c38",
"bgcolor": "#2a3b4a"
@@ -2139,10 +2139,10 @@
"title": "Note",
"properties": {},
"widgets_values": [
"Blend in fades the model's picture into yours just inside its edge, with no tone shift. The new area stays as painted."
"Blend in fades the model's picture into yours just inside its edge. Tone match is fully on, so the new area is adjusted to your picture's tone."
],
"widgets_values_named": {
"text": "Blend in fades the model's picture into yours just inside its edge, with no tone shift. The new area stays as painted."
"text": "Blend in fades the model's picture into yours just inside its edge. Tone match is fully on, so the new area is adjusted to your picture's tone."
},
"color": "#1f2c38",
"bgcolor": "#2a3b4a"
@@ -37,10 +37,10 @@
"Node name for S&R": "AUSBOSS_NODES_WorkflowNote"
},
"widgets_values": [
"{\"v\": 1, \"title\": \"Qwen Image 2.1 Inpaint\", \"subtitle\": \"Click a thing in your picture and say what to change. Only that part is repainted.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"1. Load your image. You can crop it right there with the blue handles.\\n2. On **Mask by Touch**, click the thing you want to change. It lights up before you click.\\n3. In **What to change**, say it the way you would ask a person: \\\"Replace the cup with a glass of orange juice.\\\"\\n4. Press **Run**.\\n\\nOnly the picked part is repainted. Everything outside it stays your own picture.\\n\\n### Tips\\n- Wrong thing picked? The arrows under the picture swap it for the next thing it could mean.\\n- Name everything that should go: \\\"the iced coffee and its straw\\\". What you do not name can stay.\\n- To remove something, pick it and write \\\"Remove the cup.\\\" Pick its shadow too, or the shadow stays.\\n- To add something, switch to **Paint** and paint where it should go, with some room. Say how big: \\\"Add small black sunglasses on her eyes.\\\"\\n- Not what you wanted? Run it again. The seed changes each run.\\n\\n### Switches\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA from the list below. Off: 25 steps without it, about twice as long.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.12.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"sam3.1_multiplex_fp16.safetensors\", \"dir\": \"checkpoints\", \"size\": \"1.75 GB\", \"url\": \"https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors\", \"note\": \"by Meta, packaged by Comfy-Org\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"optional, for Fast · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MaskByTouch\"}], \"links\": [{\"label\": \"YouTube · this workflow\", \"url\": \"https://youtu.be/iMgaiXoB0v4\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
"{\"v\": 1, \"title\": \"Qwen Image 2.1 Inpaint\", \"subtitle\": \"Click a thing in your picture and say what to change. Only that part is repainted.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"1. Load your image. You can crop it right there with the blue handles.\\n2. On **Mask by Touch**, click the thing you want to change. It lights up before you click.\\n3. In **What to change**, say it the way you would ask a person: \\\"Replace the cup with a glass of orange juice.\\\"\\n4. Press **Run**.\\n\\nOnly the picked part is repainted. Everything outside it stays your own picture.\\n\\n### Tips\\n- Wrong thing picked? The arrows under the picture swap it for the next thing it could mean.\\n- Name everything that should go: \\\"the iced coffee and its straw\\\". What you do not name can stay.\\n- To remove something, pick it and write \\\"Remove the cup.\\\" Pick its shadow too, or the shadow stays.\\n- To add something, switch to **Paint** and paint where it should go, with some room. Say how big: \\\"Add small black sunglasses on her eyes.\\\"\\n- Not what you wanted? Change **Seed** and run it again. It stays fixed between runs.\\n\\n### Switches\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA from the list below. Off: 25 steps without it, about twice as long.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.12.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"sam3.1_multiplex_fp16.safetensors\", \"dir\": \"checkpoints\", \"size\": \"1.75 GB\", \"url\": \"https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors\", \"note\": \"by Meta, packaged by Comfy-Org\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"optional, for Fast · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MaskByTouch\"}], \"links\": [{\"label\": \"YouTube · this workflow\", \"url\": \"https://youtu.be/iMgaiXoB0v4\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
],
"widgets_values_named": {
"note": "{\"v\": 1, \"title\": \"Qwen Image 2.1 Inpaint\", \"subtitle\": \"Click a thing in your picture and say what to change. Only that part is repainted.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"1. Load your image. You can crop it right there with the blue handles.\\n2. On **Mask by Touch**, click the thing you want to change. It lights up before you click.\\n3. In **What to change**, say it the way you would ask a person: \\\"Replace the cup with a glass of orange juice.\\\"\\n4. Press **Run**.\\n\\nOnly the picked part is repainted. Everything outside it stays your own picture.\\n\\n### Tips\\n- Wrong thing picked? The arrows under the picture swap it for the next thing it could mean.\\n- Name everything that should go: \\\"the iced coffee and its straw\\\". What you do not name can stay.\\n- To remove something, pick it and write \\\"Remove the cup.\\\" Pick its shadow too, or the shadow stays.\\n- To add something, switch to **Paint** and paint where it should go, with some room. Say how big: \\\"Add small black sunglasses on her eyes.\\\"\\n- Not what you wanted? Run it again. The seed changes each run.\\n\\n### Switches\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA from the list below. Off: 25 steps without it, about twice as long.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.12.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"sam3.1_multiplex_fp16.safetensors\", \"dir\": \"checkpoints\", \"size\": \"1.75 GB\", \"url\": \"https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors\", \"note\": \"by Meta, packaged by Comfy-Org\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"optional, for Fast · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MaskByTouch\"}], \"links\": [{\"label\": \"YouTube · this workflow\", \"url\": \"https://youtu.be/iMgaiXoB0v4\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
"note": "{\"v\": 1, \"title\": \"Qwen Image 2.1 Inpaint\", \"subtitle\": \"Click a thing in your picture and say what to change. Only that part is repainted.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"1. Load your image. You can crop it right there with the blue handles.\\n2. On **Mask by Touch**, click the thing you want to change. It lights up before you click.\\n3. In **What to change**, say it the way you would ask a person: \\\"Replace the cup with a glass of orange juice.\\\"\\n4. Press **Run**.\\n\\nOnly the picked part is repainted. Everything outside it stays your own picture.\\n\\n### Tips\\n- Wrong thing picked? The arrows under the picture swap it for the next thing it could mean.\\n- Name everything that should go: \\\"the iced coffee and its straw\\\". What you do not name can stay.\\n- To remove something, pick it and write \\\"Remove the cup.\\\" Pick its shadow too, or the shadow stays.\\n- To add something, switch to **Paint** and paint where it should go, with some room. Say how big: \\\"Add small black sunglasses on her eyes.\\\"\\n- Not what you wanted? Change **Seed** and run it again. It stays fixed between runs.\\n\\n### Switches\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA from the list below. Off: 25 steps without it, about twice as long.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.12.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"sam3.1_multiplex_fp16.safetensors\", \"dir\": \"checkpoints\", \"size\": \"1.75 GB\", \"url\": \"https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors\", \"note\": \"by Meta, packaged by Comfy-Org\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"optional, for Fast · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MaskByTouch\"}], \"links\": [{\"label\": \"YouTube · this workflow\", \"url\": \"https://youtu.be/iMgaiXoB0v4\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
},
"color": "#14424d",
"bgcolor": "#161f21"
@@ -37,10 +37,10 @@
"ver": "2.9.0"
},
"widgets_values": [
"{\"v\": 1, \"title\": \"Qwen Image 2.1 Upscale + Restore\", \"subtitle\": \"Load a small, soft or worn picture. Qwen Image 2.1 redraws it sharp at the megapixels you set, in its own colors.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your image. You can crop it right there with the blue handles.\\n2. Set **Megapixels**: the size of the result.\\n3. Press **Run**.\\n4. Slide **Before and after** to compare. The result is saved as a PNG.\\n\\n### What you get\\n\\n- Your picture redrawn sharp, at the size you asked for.\\n- Its own colors, also on a noisy or faded photo.\\n\\n### Megapixels\\n\\n- Up to about 2: one pass.\\n- Over that: redrawn in tiles of about 2 megapixels and joined. 4 takes two tiles, 8 takes four.\\n- A small picture is redrawn at up to 8 times its own megapixels, then enlarged the plain way.\\n- A picture of 1 megapixel or more only looks better when it grows about 1.6 times per side.\\n\\n### Switches\\n\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA. Off: 25 steps without it, about three times as long.\\n\\n### Tips\\n\\n- It is a redraw. Fine detail is made up to fit, so a face stays the same person, not the same pixels.\\n- Small lettering on signs and labels can come back misspelled.\\n- Each tile is matched to your picture's colors. To let Qwen's own colors through, lower **Keep colors** inside the box.\\n- If the run stops at a red **LoRA Loader**, a LoRA that is switched on is missing. Download it from the list below.\\n- Not tested on blur from a moving camera or missed focus.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.9.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"qwen-image-2.1-restore.safetensors\", \"dir\": \"loras\", \"size\": \"159 MB\", \"url\": \"https://huggingface.co/ausboss/Qwen-Image-2.1-Restore-LoRA/resolve/main/qwen-image-2.1-restore.safetensors\", \"note\": \"keeps the result close to your picture\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"for Fast on · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_TiledUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
"{\"v\": 1, \"title\": \"Qwen Image 2.1 Upscale + Restore\", \"subtitle\": \"Load a small, soft or worn picture. Qwen Image 2.1 redraws it sharp at the megapixels you set, in its own colors.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your image. You can crop it right there with the blue handles.\\n2. Set **Megapixels**: the size of the result.\\n3. Press **Run**.\\n4. Slide **Before and after** to compare. The result is saved as a PNG.\\n\\n### What you get\\n\\n- Your picture redrawn sharp, at the size you asked for.\\n- Its own colors, also on a noisy or faded photo.\\n\\n### Megapixels\\n\\n- Up to about 2: one pass.\\n- Over that: redrawn in tiles of about 2 megapixels and joined. 4 takes two tiles, 8 takes four.\\n- The redraw limit for a small picture is 8 times its own megapixels or one roughly 2 megapixel tile, whichever is larger. The rest is enlarged the plain way.\\n- A picture of 1 megapixel or more only looks better when it grows about 1.6 times per side.\\n\\n### Switches\\n\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA. Off: 25 steps without it, about three times as long.\\n\\n### Tips\\n\\n- It is a redraw. Fine detail is made up to fit, so a face stays the same person, not the same pixels.\\n- Small lettering on signs and labels can come back misspelled.\\n- Each tile is matched to your picture's colors. To let Qwen's own colors through, lower **Keep colors** inside the box.\\n- If the run stops at a red **LoRA Loader**, a LoRA that is switched on is missing. Download it from the list below.\\n- Not tested on blur from a moving camera or missed focus.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.9.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"qwen-image-2.1-restore.safetensors\", \"dir\": \"loras\", \"size\": \"159 MB\", \"url\": \"https://huggingface.co/ausboss/Qwen-Image-2.1-Restore-LoRA/resolve/main/qwen-image-2.1-restore.safetensors\", \"note\": \"keeps the result close to your picture\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"for Fast on · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_TiledUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
],
"widgets_values_named": {
"note": "{\"v\": 1, \"title\": \"Qwen Image 2.1 Upscale + Restore\", \"subtitle\": \"Load a small, soft or worn picture. Qwen Image 2.1 redraws it sharp at the megapixels you set, in its own colors.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your image. You can crop it right there with the blue handles.\\n2. Set **Megapixels**: the size of the result.\\n3. Press **Run**.\\n4. Slide **Before and after** to compare. The result is saved as a PNG.\\n\\n### What you get\\n\\n- Your picture redrawn sharp, at the size you asked for.\\n- Its own colors, also on a noisy or faded photo.\\n\\n### Megapixels\\n\\n- Up to about 2: one pass.\\n- Over that: redrawn in tiles of about 2 megapixels and joined. 4 takes two tiles, 8 takes four.\\n- A small picture is redrawn at up to 8 times its own megapixels, then enlarged the plain way.\\n- A picture of 1 megapixel or more only looks better when it grows about 1.6 times per side.\\n\\n### Switches\\n\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA. Off: 25 steps without it, about three times as long.\\n\\n### Tips\\n\\n- It is a redraw. Fine detail is made up to fit, so a face stays the same person, not the same pixels.\\n- Small lettering on signs and labels can come back misspelled.\\n- Each tile is matched to your picture's colors. To let Qwen's own colors through, lower **Keep colors** inside the box.\\n- If the run stops at a red **LoRA Loader**, a LoRA that is switched on is missing. Download it from the list below.\\n- Not tested on blur from a moving camera or missed focus.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.9.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"qwen-image-2.1-restore.safetensors\", \"dir\": \"loras\", \"size\": \"159 MB\", \"url\": \"https://huggingface.co/ausboss/Qwen-Image-2.1-Restore-LoRA/resolve/main/qwen-image-2.1-restore.safetensors\", \"note\": \"keeps the result close to your picture\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"for Fast on · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_TiledUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
"note": "{\"v\": 1, \"title\": \"Qwen Image 2.1 Upscale + Restore\", \"subtitle\": \"Load a small, soft or worn picture. Qwen Image 2.1 redraws it sharp at the megapixels you set, in its own colors.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your image. You can crop it right there with the blue handles.\\n2. Set **Megapixels**: the size of the result.\\n3. Press **Run**.\\n4. Slide **Before and after** to compare. The result is saved as a PNG.\\n\\n### What you get\\n\\n- Your picture redrawn sharp, at the size you asked for.\\n- Its own colors, also on a noisy or faded photo.\\n\\n### Megapixels\\n\\n- Up to about 2: one pass.\\n- Over that: redrawn in tiles of about 2 megapixels and joined. 4 takes two tiles, 8 takes four.\\n- The redraw limit for a small picture is 8 times its own megapixels or one roughly 2 megapixel tile, whichever is larger. The rest is enlarged the plain way.\\n- A picture of 1 megapixel or more only looks better when it grows about 1.6 times per side.\\n\\n### Switches\\n\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA. Off: 25 steps without it, about three times as long.\\n\\n### Tips\\n\\n- It is a redraw. Fine detail is made up to fit, so a face stays the same person, not the same pixels.\\n- Small lettering on signs and labels can come back misspelled.\\n- Each tile is matched to your picture's colors. To let Qwen's own colors through, lower **Keep colors** inside the box.\\n- If the run stops at a red **LoRA Loader**, a LoRA that is switched on is missing. Download it from the list below.\\n- Not tested on blur from a moving camera or missed focus.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.9.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"qwen-image-2.1-restore.safetensors\", \"dir\": \"loras\", \"size\": \"159 MB\", \"url\": \"https://huggingface.co/ausboss/Qwen-Image-2.1-Restore-LoRA/resolve/main/qwen-image-2.1-restore.safetensors\", \"note\": \"keeps the result close to your picture\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"for Fast on · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_TiledUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
},
"color": "#14424d",
"bgcolor": "#161f21"
+2 -5
View File
@@ -862,11 +862,8 @@ app.registerExtension({
chainCallback(nodeType.prototype, "onExecuted", function (output) {
if (isForeignRun()) return;
const state = buildPanel(this, config);
// Count-only results carry no replacement for a stale picture.
if (state) {
if (outputRecordQuery(output)) { state.stale = false; state.resultGone = false; }
scheduleRefresh(state);
}
// A result arrived (Save Image sends one even with its preview off).
if (state) { state.stale = false; state.resultGone = false; scheduleRefresh(state); }
// Saved: the file's picture replaces the steps.
if (state?.stepOverlay) {
state.stepsSaved = stepRun().promptId;
+3 -31
View File
@@ -170,7 +170,7 @@ api.addEventListener("execution_interrupted", () => settleAll());
// second or two, so things light up while it is still looking.
api.addEventListener(LOOK_EVENT, ({ detail }) => {
try {
if (!faces.size || !detail?.note || isForeignRun(detail.prompt_id)) return;
if (!faces.size || !detail?.note) return;
const id = String(detail.node ?? "");
const root = app.rootGraph ?? app.graph;
for (const face of faces) {
@@ -183,39 +183,11 @@ api.addEventListener(LOOK_EVENT, ({ detail }) => {
// A run that left the queue without ever reaching the node (it was deleted
// there) would leave a face waiting for good. Once the queue is empty and a
// face has waited a while, it stops waiting; the next touch asks again.
let emptyQueueTimer = 0;
let emptyQueueVersion = 0;
function settleEmptyQueue() {
const now = performance.now();
let delay = Infinity;
for (const face of faces) {
if (!face.alive || !face.inFlight) continue;
const left = 1501 - (now - face.askedAt);
if (left <= 0) face.settle();
else delay = Math.min(delay, left);
}
if (Number.isFinite(delay)) {
const version = emptyQueueVersion;
emptyQueueTimer = setTimeout(async () => {
emptyQueueTimer = 0;
try {
const queue = await (await api.fetchApi("/queue")).json();
if (version !== emptyQueueVersion) return;
if (Array.isArray(queue?.queue_pending) && Array.isArray(queue?.queue_running)
&& !queue.queue_pending.length && !queue.queue_running.length) settleEmptyQueue();
} catch {
// A failed queue read never cancels a request that may still run.
}
}, delay);
}
}
api.addEventListener("status", ({ detail }) => {
try {
emptyQueueVersion += 1;
clearTimeout(emptyQueueTimer);
emptyQueueTimer = 0;
if (!faces.size || detail?.exec_info?.queue_remaining !== 0) return;
settleEmptyQueue();
const now = performance.now();
for (const face of faces) if (face.inFlight && now - face.askedAt > 1500) face.settle();
} catch {
// Never in the way of a run.
}
+1 -1
View File
@@ -1046,7 +1046,7 @@ export function pieceAt(planes, index, { sizes, inside, adding = true, ranks = n
const size = sizes?.[piece] ?? 0;
if (!size) continue;
const picked = inside?.[piece] ?? 0;
const slack = Math.min(size - 1, Math.max(4, size * NEARLY));
const slack = Math.max(4, size * NEARLY);
if (adding ? size - picked > slack : picked > slack) return piece;
}
return -1;
+15 -48
View File
@@ -141,12 +141,11 @@ const TOUCH_REACH = 2;
// The areas of picked things a picture keeps, as the node sent them.
const AREAS_KEPT = 96;
// Pictures the faces of a page have shown, newest last. A picture keeps its
// pieces and the picked areas it has shown, so a tab switch, an undo or
// another visit to the same picture costs nothing. Results belong to one face.
// pieces and the picked areas it has shown, so a tab switch, an undo or a
// second node on the same picture costs nothing.
const PICTURES_KEPT = 3;
const STATES_KEPT = 14;
const pictures = new Map();
let pictureSerial = 0;
// A face asks for a run again on its own at most this often in a row.
const RETRIES = 2;
// The long side of the small copy the masks are worked on, when the face
@@ -285,14 +284,13 @@ const widgetByName = (node, name) => node.widgets?.find((widget) => widget?.name
// ---- the pictures a page has shown ---------------------------------------
function pictureFor(note, face) {
function pictureFor(note) {
const width = Math.round(Number(note.width));
const height = Math.round(Number(note.height));
const scale = Number(note.scale) > 0 ? Number(note.scale) : 1;
const cacheKey = face ? `${face.pictureId ??= ++pictureSerial}|${note.key}` : note.key;
let entry = pictures.get(cacheKey);
let entry = pictures.get(note.key);
if (entry && (entry.width !== width || entry.height !== height)) entry = null;
pictures.delete(cacheKey);
pictures.delete(note.key);
entry ??= {
key: note.key,
width,
@@ -310,7 +308,7 @@ function pictureFor(note, face) {
nameSeeds: new Map(),
states: new Map(),
};
pictures.set(cacheKey, entry);
pictures.set(note.key, entry);
while (pictures.size > PICTURES_KEPT) pictures.delete(pictures.keys().next().value);
return entry;
}
@@ -388,7 +386,7 @@ function validNote(note) {
// whether the picture has points left to ask about.
const piecesTag = (pieces) => `${pieces?.list?.length ?? 0}:${(Array.isArray(pieces?.planes) ? pieces.planes : []).map((file) => file?.filename).join(",")}`;
async function loadPieces(entry, pieces, generation) {
async function loadPieces(entry, pieces) {
const tag = piecesTag(pieces);
const list = (Array.isArray(pieces.list) ? pieces.list : [])
.filter((piece) => piece && Number.isFinite(Number(piece.x)) && Number.isFinite(Number(piece.y)))
@@ -412,7 +410,6 @@ async function loadPieces(entry, pieces, generation) {
});
const files = (Array.isArray(pieces.planes) ? pieces.planes : []).filter(isFile);
const images = await Promise.all(files.map((file) => loadImage(viewUrl(file, entry.key))));
if (entry.generation !== generation) return;
const planes = packPlanes(images.map((image) => readPixels(image, entry.pw, entry.ph)), entry.pw, entry.ph);
const sizes = pieceSizes(planes, list.length);
const overlaps = pieceOverlaps(planes, list.length);
@@ -1404,7 +1401,7 @@ function syncTools(face) {
// A click that waits for the look can be taken back like a pick.
const waiting = live && face.pending.length > 0;
face.undo.disabled = !live || (!some && !face.past.length && !waiting);
face.clear.disabled = !some && !waiting && !String(face.touches?.value ?? "").trim();
face.clear.disabled = !some && !waiting;
if (face.lookButton) {
const offer = live && face.snap && !face.held && !face.inFlight && !face.pic.scoped && Boolean(face.host.lookOnButton?.());
face.lookButton.style.display = offer ? "" : "none";
@@ -1483,28 +1480,6 @@ function ownLoader(node) {
// for: a crop that is being dragged changes them many times a second.
const OWN_SETTLE_MS = 700;
// One callback per source widget; removing a face removes its subscription.
const sourceWatchers = new WeakMap();
function sourceListeners(widget) {
let listeners = sourceWatchers.get(widget);
if (!listeners) {
listeners = new Set();
sourceWatchers.set(widget, listeners);
chainCallback(widget, "callback", () => {
for (const listener of listeners) listener();
});
}
return listeners;
}
function watchSource(face, widget, changed) {
if (face.watchedSources.has(widget)) return;
face.watchedSources.add(widget);
const listeners = sourceListeners(widget);
listeners.add(changed);
face.forgetSources.push(() => listeners.delete(changed));
}
// Show the loader's picture without a press on Show picture: the same run
// that button asks for. Only ever in answer to something the person did (a
// file chosen, a setting changed, a wire made), never while a workflow is
@@ -1545,8 +1520,9 @@ function followOwnLoader(face, now = false) {
};
for (const widget of upstream.widgets ?? []) {
if (!widget || face.watchedSources.has(widget)) continue;
face.watchedSources.add(widget);
// A workflow being opened sets its values too: that is not the person.
watchSource(face, widget, () => { if (face.alive && !face.host.loading?.()) later(); });
chainCallback(widget, "callback", () => { if (face.alive && !face.host.loading?.()) later(); });
}
if (now) later();
}
@@ -1576,7 +1552,8 @@ function syncSource(face) {
const url = source?.url ?? "";
const watched = loaderWidget(face.node);
if (watched && !face.watchedSources.has(watched)) {
watchSource(face, watched, () => queueMicrotask(() => { if (face.alive) syncState(face); }));
face.watchedSources.add(watched);
chainCallback(watched, "callback", () => queueMicrotask(() => { if (face.alive) syncState(face); }));
}
if (url === face.sourceUrl) return;
face.sourceUrl = url;
@@ -1814,8 +1791,8 @@ function show(face, entry, note, answered, { restored = false, own = false, inte
refreshHover(face);
draw(face);
drawInk(face);
if (restored) return;
wantFine(face);
if (restored) return;
const waited = settlePending(face);
// More is on its way (the look goes on, or a run sent behind it): what to
// ask next is decided when the last of it is in.
@@ -1865,8 +1842,7 @@ async function takeNote(face, note, { restored = false, interim = false } = {})
}
const turn = ++face.turn;
const sentStamp = sent?.stamp ?? "";
const entry = pictureFor(note, face);
const generation = entry.generation = (entry.generation ?? 0) + 1;
const entry = pictureFor(note);
// How many looks at this picture have come back, and how far through its
// list of words the node is: an early click waits for the words.
if (own && sent.ask === "scope") entry.looks = (entry.looks ?? 0) + 1;
@@ -1878,13 +1854,12 @@ async function takeNote(face, note, { restored = false, interim = false } = {})
const jobs = [];
if (!entry.image || entry.file !== note.picture.filename) {
jobs.push(loadImage(viewUrl(note.picture, note.key)).then((image) => {
if (entry.generation !== generation) return;
entry.image = image;
entry.file = note.picture.filename;
}));
}
if (note.pieces && typeof note.pieces === "object" && piecesTag(note.pieces) !== entry.piecesTag) {
jobs.push(loadPieces(entry, note.pieces, generation));
jobs.push(loadPieces(entry, note.pieces));
} else if (note.pieces && typeof note.pieces === "object") {
entry.scoped = note.pieces.more !== true;
}
@@ -1934,7 +1909,6 @@ async function takeNote(face, note, { restored = false, interim = false } = {})
}
return;
}
if (!face.alive || turn !== face.turn || entry.generation !== generation) return;
if (answered) remember(entry, stamp, answered);
if (fine) entry.fine = fine;
if (typeof note.fine?.trouble === "string" && note.fine.trouble) {
@@ -2896,7 +2870,6 @@ export function mountTouchStage(node, host) {
sourceEpoch: 0,
sentEpoch: 0,
watchedSources: new WeakSet(),
forgetSources: [],
// One of the pack's loaders wired straight in: what it was last asked for.
ownSeen: "",
ownTimer: 0,
@@ -2971,11 +2944,8 @@ export function mountTouchStage(node, host) {
face.active = 0;
face.fresh = 0;
if (!face.items.length) {
writeTouches(face, "");
readItems(face);
face.chipsShown = null;
syncReadout(face);
syncTools(face);
return;
}
change(face, []);
@@ -3140,9 +3110,6 @@ export function mountTouchStage(node, host) {
clearTimeout(face.ownTimer);
clearTimeout(face.dwell);
clearTimeout(face.fineTimer);
clearTimeout(face.pendingTimer);
for (const forget of face.forgetSources) forget();
face.forgetSources.length = 0;
if (face.frameRequest) cancelAnimationFrame(face.frameRequest);
abort.abort();
};
+5 -10
View File
@@ -170,21 +170,16 @@ def _core_node(key: str):
return node if hasattr(node, "execute") else None
def _model_stamp(name: str):
path = folder_paths.get_full_path(MODEL_FOLDER, name) if folder_paths is not None else None
if not path:
raise FileNotFoundError(NOT_FOUND.format(name=name) if list_subject_models() else NEEDS_MODEL)
info = os.stat(path)
return (path, info.st_mtime_ns, info.st_size)
def _load(name: str):
"""The model, through ComfyUI's own loader node, as a workflow would
load it. Kept while its file does not change."""
loader = _core_node("LoadBackgroundRemovalModel")
if loader is None or _core_node("RemoveBackground") is None:
raise RuntimeError(NEEDS_NEWER)
stamp = _model_stamp(name)
path = folder_paths.get_full_path(MODEL_FOLDER, name) if folder_paths is not None else None
if not path:
raise FileNotFoundError(NOT_FOUND.format(name=name) if list_subject_models() else NEEDS_MODEL)
stamp = (path, os.path.getmtime(path), os.path.getsize(path))
kept = _MODELS.get(name)
if kept is not None and kept[0] == stamp:
_MODELS.move_to_end(name)
@@ -211,7 +206,7 @@ def subject_matte(frame: torch.Tensor, choice, key: str | None = None) -> torch.
set to. With `key` (the picture's own key) the answer is remembered: a
picture is asked about once, however many picks follow."""
name = subject_model_name(choice)
slot = (name, _model_stamp(name), key)
slot = (name, key)
if key is not None and slot in _SUBJECTS:
_SUBJECTS.move_to_end(slot)
return _SUBJECTS[slot].float() / 255.0
+8 -10
View File
@@ -100,9 +100,8 @@ def _torch_fill_holes(solid: torch.Tensor) -> torch.Tensor:
reachable[:, :, -1] = background[:, :, -1]
while True:
grown = (
torch.maximum(
functional.max_pool2d(reachable.float().unsqueeze(1), (3, 1), 1, (1, 0)),
functional.max_pool2d(reachable.float().unsqueeze(1), (1, 3), 1, (0, 1)),
functional.max_pool2d(
reachable.float().unsqueeze(1), kernel_size=3, stride=1, padding=1
)
.squeeze(1)
.bool()
@@ -130,10 +129,10 @@ def _hole_limit(max_hole_size: float, height: int, width: int) -> float | None:
def _torch_hole_areas(holes: torch.Tensor) -> torch.Tensor:
"""Each hole pixel's hole size in pixels, 0 elsewhere, for one HW frame.
Holes are 4-connected, the way _torch_fill_holes floods them. Every hole
Holes are 8-connected, the way _torch_fill_holes floods them. Every hole
pixel starts with its own index; each pass takes the largest index in
its cross-shaped neighbourhood, then the index held by the pixel that
index names (both stay inside the same hole), until every hole holds one index.
its 3x3 neighbourhood, then the index held by the pixel that index names
(both stay inside the same hole), until every hole holds one index.
"""
height, width = holes.shape
inside = holes.reshape(-1)
@@ -141,9 +140,8 @@ def _torch_hole_areas(holes: torch.Tensor) -> torch.Tensor:
index = torch.arange(1, height * width + 1, dtype=torch.float64, device=holes.device)
labels = torch.where(inside, index, zero)
while True:
spread = torch.maximum(
functional.max_pool2d(labels.view(1, 1, height, width), (3, 1), 1, (1, 0)),
functional.max_pool2d(labels.view(1, 1, height, width), (1, 3), 1, (0, 1)),
spread = functional.max_pool2d(
labels.view(1, 1, height, width), kernel_size=3, stride=1, padding=1
).reshape(-1)
spread = torch.where(inside, spread, zero)
spread = torch.where(inside, spread[(spread.long() - 1).clamp(min=0)], zero)
@@ -190,7 +188,7 @@ def fill_mask_holes(mask: torch.Tensor, max_hole_size: float = 0.0) -> torch.Ten
).to(mask.device)
else:
filled = _torch_fill_holes(solid)
return torch.where(filled & ~solid, 1.0, mask)
return torch.maximum(mask, filled.to(mask.dtype))
def blur_mask(mask: torch.Tensor, sigma: float) -> torch.Tensor:
+1 -3
View File
@@ -220,11 +220,9 @@ def _tell(node_id, note: dict, end: bool) -> bool:
if server is None or node_id is None:
return False
try:
read_context = getattr(sys.modules.get("comfy_execution.utils"), "get_executing_context", lambda: None)
prompt_id = getattr(read_context(), "prompt_id", None)
server.send_sync(
LOOK_EVENT,
{"node": str(node_id), "note": note, "end": bool(end), "prompt_id": prompt_id},
{"node": str(node_id), "note": note, "end": bool(end)},
getattr(server, "client_id", None),
)
except Exception: # noqa: BLE001
-1
View File
@@ -53,7 +53,6 @@ CORE_NODES = {
INTERNALS = [
("comfy.utils", "PROGRESS_BAR_HOOK", "Mask by Touch holds progress reports back while it looks at a picture"),
("comfy_execution.progress", "get_progress_state", "Mask by Touch holds progress reports back while it looks at a picture"),
("comfy_execution.utils", "get_executing_context", "Mask by Touch identifies the run sending an interim picture"),
("comfy.model_management", "load_model_gpu", "Mask by Touch asks SAM 3 the quick way"),
("comfy.model_management", "current_loaded_models", "Mask by Touch asks SAM 3 the quick way"),
("comfy.model_management", "intermediate_dtype", "the pack's tensors take ComfyUI's working type"),
-12
View File
@@ -191,18 +191,6 @@ class AskTests(ModelCase):
subject_matte(self.frame, OFF, key="two")
self.assertEqual(len(Loader.calls), 2)
def test_replacing_a_model_invalidates_the_same_pictures_subject(self):
Remover.answer = torch.full((1, 40, 60), 0.25)
first = subject_matte(self.frame, OFF, key="one")
self.file(PREFERRED, "replacement weights, longer")
os.utime(self.folder / PREFERRED, (1, 1))
Remover.answer = torch.full((1, 40, 60), 0.9)
second = subject_matte(self.frame, OFF, key="one")
self.assertEqual(len(Loader.calls), 2)
self.assertEqual(len(Remover.calls), 2)
self.assertLess(float(first.mean()), 0.3)
self.assertGreater(float(second.mean()), 0.8)
def test_an_older_comfyui_gets_a_plain_message(self):
self.nodes.NODE_CLASS_MAPPINGS = {"LoadBackgroundRemovalModel": Loader}
with self.assertRaises(RuntimeError) as raised:
+2 -16
View File
@@ -937,7 +937,7 @@ class RoundTests(TouchCase):
self.addCleanup(sys.modules.pop, "server", None)
sys.modules["server"] = server
self.assertIs(node_module._tell(5, {"key": "k"}, True), True)
self.assertEqual(sent, [("ausboss.mask_by_touch.look", {"node": "5", "note": {"key": "k"}, "end": True, "prompt_id": None}, "a page")], "to the page that asked")
self.assertEqual(sent, [("ausboss.mask_by_touch.look", {"node": "5", "note": {"key": "k"}, "end": True}, "a page")], "to the page that asked")
self.assertIs(node_module._tell(None, {}, True), False, "no id: nobody to tell")
server.PromptServer = types.SimpleNamespace(instance=None)
self.assertIs(node_module._tell(5, {}, True), False, "no page to tell")
@@ -945,20 +945,6 @@ class RoundTests(TouchCase):
self.assertIs(node_module._tell(5, {}, True), False, "outside ComfyUI")
self.assertEqual(len(sent), 1)
def test_interim_notes_carry_the_executing_prompt_id(self):
sent = []
context = types.ModuleType("comfy_execution.utils")
context.get_executing_context = lambda: types.SimpleNamespace(prompt_id="tab-a-prompt")
prior = sys.modules.get("comfy_execution.utils")
self.addCleanup(lambda: sys.modules.__setitem__("comfy_execution.utils", prior) if prior else sys.modules.pop("comfy_execution.utils", None))
sys.modules["comfy_execution.utils"] = context
server = types.ModuleType("server")
server.PromptServer = types.SimpleNamespace(instance=types.SimpleNamespace(send_sync=lambda *args: sent.append(args)))
self.addCleanup(sys.modules.pop, "server", None)
sys.modules["server"] = server
self.assertTrue(node_module._tell(5, {}, False))
self.assertEqual(sent[0][1]["prompt_id"], "tab-a-prompt")
def test_a_first_look_stopped_half_way_is_gone_on_with(self):
class Stopped(BaseException):
pass
@@ -1717,7 +1703,7 @@ class TellTests(TouchCase):
self.assertEqual(asked_at, list(range(4, total + 1, 3)) + [total + 1], "after every third half-second question, and at the end")
for event, data, sid in self.sent:
self.assertEqual((event, sid), ("ausboss.mask_by_touch.look", "the page that asked"))
self.assertEqual(sorted(data), ["end", "node", "note", "prompt_id"])
self.assertEqual(sorted(data), ["end", "node", "note"])
self.assertEqual(data["node"], "7", "the node's id, as text")
json.dumps(data) # it has to travel as JSON
self.assertEqual([data["end"] for _, data, _ in self.sent], [False] * (len(self.sent) - 1) + [True])
-27
View File
@@ -63,33 +63,6 @@ class GrowShrinkTests(unittest.TestCase):
class FillHolesTests(unittest.TestCase):
def test_fill_preserves_existing_soft_alpha(self):
from nodes._mask_by_touch_helpers import finish_mask
mask = torch.full((1, 20, 20), 0.75)
for limit in (0.0, 1.0):
self.assertTrue(torch.equal(fill_mask_holes(mask, limit), mask))
self.assertTrue(torch.equal(finish_mask(mask, fill_holes=True), mask))
mask[:, 9:11, 9:11] = 0.2
filled = fill_mask_holes(mask)
self.assertTrue(torch.all(filled[:, 9:11, 9:11] == 1.0))
self.assertEqual(float(filled[0, 0, 0]), 0.75)
def test_fallback_uses_the_same_diagonal_connectivity(self):
mask = torch.ones(1, 20, 20)
mask[0, 0, 0] = mask[0, 1, 1] = 0
# Two diagonally touching enclosed holes are separate components.
mask[0, 8, 8:11] = 0
mask[0, 9, 11:14] = 0
for limit in (0.0, 1.0):
expected = fill_mask_holes(mask, limit)
with mock.patch.object(mask_helpers, "_scipy_fill_holes", None):
actual = fill_mask_holes(mask, limit)
self.assertTrue(torch.equal(actual, expected))
self.assertEqual(float(actual[0, 1, 1]), 1.0)
self.assertEqual(float(actual[0, 8, 8]), 1.0)
self.assertEqual(float(actual[0, 0, 0]), 0.0)
def test_enclosed_hole_is_filled_but_outside_stays_empty(self):
filled = fill_mask_holes(donut_mask())
self.assertTrue(torch.all(filled[:, 6:10, 6:10] == 1.0))
-116
View File
@@ -1,116 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
import { outputRecordQuery } from "../js/shared/input_preview.mjs";
const strip = (text) => text.replace(/^import\s+[\s\S]*?\sfrom\s+["'][^"']+["'];\s*/gm, "").replace(/^export /gm, "");
function entry() {
const events = new Map();
const timers = new Map();
let now = 1000, next = 0, queue = { queue_pending: [], queue_running: [] };
const checked = [];
const context = vm.createContext({
console, Map, Set, Promise, performance: { now: () => now },
setTimeout(fn, delay) {
const id = ++next;
timers.set(id, { fn: () => { timers.delete(id); return fn(); }, delay });
return id;
},
clearTimeout(id) { timers.delete(id); },
api: { addEventListener: (name, fn) => events.set(name, fn), fetchApi: async () => ({ json: async () => queue }) },
app: { graph: {}, registerExtension() {} },
executionIdOf: (_graph, node) => String(node.id),
isForeignRun(id) { checked.push(id); return id === "foreign"; },
});
vm.runInContext(strip(readFileSync(new URL("../js/mask_by_touch/index.js", import.meta.url), "utf8")), context);
vm.runInContext(`
globalThis.taken = [];
globalThis.face = {alive:true,inFlight:true,askedAt:0,node:{id:7},settle(){this.inFlight=false;},takeNote(note){taken.push(note.key);}};
faces.add(face);
`, context);
return { context, events, timers, checked, advance: (value) => { now = value; }, queue: (value) => { queue = value; } };
}
test("interim notes check their prompt's workflow before delivery", () => {
const { context, events, checked } = entry();
const look = events.get("ausboss.mask_by_touch.look");
look({ detail: { node: "7", prompt_id: "foreign", note: { key: "wrong tab" } } });
assert.equal(context.taken.length, 0);
look({ detail: { node: "7", prompt_id: "current", note: { key: "this tab" } } });
assert.deepEqual([...context.taken], ["this tab"]);
assert.deepEqual(checked, ["foreign", "current"]);
});
test("an early empty status rechecks a deleted request without another event", async () => {
const { context, events, timers, advance } = entry();
events.get("status")({ detail: { exec_info: { queue_remaining: 0 } } });
assert.equal(context.face.inFlight, true);
assert.equal(timers.size, 1);
const { fn, delay } = [...timers.values()][0];
assert.ok(delay > 0 && delay <= 501);
advance(1600);
await fn();
assert.equal(context.face.inFlight, false);
});
test("the delayed empty check leaves a running request waiting", async () => {
const { context, events, timers, advance, queue } = entry();
events.get("status")({ detail: { exec_info: { queue_remaining: 0 } } });
assert.equal(timers.size, 1);
queue({ queue_pending: [], queue_running: [[0, "current"]] });
advance(1600);
await [...timers.values()][0].fn();
assert.equal(context.face.inFlight, true);
});
test("a newer queue status wins over a delayed empty response", async () => {
const { context, events, timers, advance, queue } = entry();
let finish;
queue(new Promise((resolve) => { finish = resolve; }));
const status = events.get("status");
status({ detail: { exec_info: { queue_remaining: 0 } } });
advance(1600);
const checking = [...timers.values()][0].fn();
status({ detail: { exec_info: { queue_remaining: 1 } } });
finish({ queue_pending: [], queue_running: [] });
await checking;
assert.equal(context.face.inFlight, true);
assert.equal(timers.size, 0);
});
test("a failed queue response never cancels a waiting request", async () => {
const { context, events, timers, advance, queue } = entry();
events.get("status")({ detail: { exec_info: { queue_remaining: 0 } } });
queue({ error: "Unavailable" });
advance(1600);
await [...timers.values()][0].fn();
assert.equal(context.face.inFlight, true);
});
test("a count-only result cannot make an obsolete preview current", () => {
let extension;
const context = vm.createContext({
console, Map, Set, WeakMap, Promise, queueMicrotask() {}, setTimeout: () => 1, clearTimeout() {},
api: { addEventListener() {}, apiURL: (path) => path },
app: { graph: {}, nodeOutputs: {}, registerExtension(value) { extension = value; } },
chainCallback(target, name, fn) {
const prior = target[name];
target[name] = function (...args) { const result = prior?.apply(this, args); fn.apply(this, args); return result; };
},
isForeignRun: () => false, hideInputsInDef() {}, outputLocatorId: () => "7", outputRecordQuery,
});
vm.runInContext(strip(readFileSync(new URL("../js/input_preview/index.js", import.meta.url), "utf8")), context);
vm.runInContext("scheduleRefresh = () => {}; setNote = () => {}; setReadout = () => {};", context);
class PreviewNode {}
extension.beforeRegisterNodeDef(PreviewNode, { name: "AUSBOSS_NODES_MaskByName" });
const node = new PreviewNode();
const state = node.__ausbossInputPreview = { node, alive: true, stale: true, resultGone: true };
node.onExecuted({ ausboss_mask_by_name: [{ found: 1, name: "new search", sure: [0.9] }] });
assert.equal(state.stale, true);
assert.equal(state.resultGone, true);
node.onExecuted({ images: [{ filename: "new.png", type: "temp" }] });
assert.equal(state.stale, false);
assert.equal(state.resultGone, false);
});
-205
View File
@@ -1,205 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
import * as math from "../js/shared/touch_math.mjs";
import * as brush from "../js/shared/brush.mjs";
const source = readFileSync(new URL("../js/shared/touch_stage.mjs", import.meta.url), "utf8");
const strip = (text) => text.replace(/^import\s+[\s\S]*?\sfrom\s+["'][^"']+["'];\s*/gm, "").replace(/^export /gm, "");
function stage() {
const timers = new Map();
let nextTimer = 0;
const context = vm.createContext({
...math, ...brush, console, URLSearchParams, Uint8Array, Uint32Array, Float32Array,
Map, Set, WeakMap, WeakSet, Promise,
performance: { now: () => 1000 },
setTimeout(fn, delay) { const id = ++nextTimer; timers.set(id, { fn, delay }); return id; },
clearTimeout(id) { timers.delete(id); },
requestAnimationFrame: () => 1, cancelAnimationFrame() {}, queueMicrotask,
api: { apiURL: (path) => path }, notifyAusbossChange() {},
chainCallback(target, name, fn) {
const prior = target[name];
target[name] = function (...args) { const result = prior?.apply(this, args); fn.apply(this, args); return result; };
},
});
vm.runInContext(strip(source), context);
const run = (code) => vm.runInContext(code, context);
return { context, timers, run };
}
test("late piece and picture downloads cannot replace a newer note", async () => {
const { context, run } = stage();
const downloads = new Map();
context.downloads = downloads;
run(`
loadImage = url => new Promise(resolve => downloads.set(new URLSearchParams(url.split('?')[1]).get('filename'), resolve));
readPixels = (_image, w, h) => new Uint8Array(w*h*4).fill(255);
syncState = () => {};
show = (_face, entry) => { globalThis.shown = entry.pieces.list[0].name; };
const face = {alive:true,flying:[],turn:0,sourceEpoch:0,node:{widgets:[]}};
const base = {key:'4x4-picture',width:4,height:4,scale:1,stamp:''};
globalThis.entry = pictureFor(base, face);
const note = (name, more) => ({...base,picture:{filename:name+'.jpg'},pieces:{list:[{x:1,y:1,kind:'name',name,sure:1}],planes:[{filename:name+'.png'}],more}});
globalThis.oldJob = takeNote(face, note('old', true));
globalThis.newJob = takeNote(face, note('new', false));
`);
downloads.get("new.jpg")({});
downloads.get("new.png")({});
await context.newJob;
downloads.get("old.jpg")({});
downloads.get("old.png")({});
await context.oldJob;
assert.equal(context.shown, "new");
assert.equal(context.entry.pieces.list[0].name, "new");
assert.equal(context.entry.file, "new.jpg");
assert.equal(context.entry.scoped, true);
});
test("selection, area and fine caches belong to each face", () => {
const { run } = stage();
run(`
const note = {key:'same-picture',width:2,height:2,scale:1};
const first = pictureFor(note, {});
const second = pictureFor(note, {});
const text = encodeTouches(first.key,[{t:'subject',op:1}]);
const state = {pic:first,items:decodeTouches(text).items,stamp:stampOf(text),stamps:prefixStamps(first.key,decodeTouches(text).items)};
remember(first,state.stamp,Uint8Array.from([1,0,0,0]));
keepArea(first,'subject',Uint8Array.from([1,0,0,0]));
first.fine = {made:'first model'};
remember(second,state.stamp,Uint8Array.from([0,0,0,1]));
keepArea(second,'subject',Uint8Array.from([0,0,0,1]));
second.fine = {made:'second model'};
resolve(state);
globalThis.result = {raw:Array.from(state.raw),area:Array.from(first.areas.get('subject')),fine:first.fine.made};
`);
const result = JSON.parse(run("JSON.stringify(result)"));
assert.deepEqual(result, { raw: [1, 0, 0, 0], area: [1, 0, 0, 0], fine: "first model" });
});
test("late selection and fine downloads cannot overwrite the current stamp", async () => {
const { context, run } = stage();
const downloads = context.downloads = new Map();
run(`
loadImage = url => new Promise(resolve => downloads.set(new URLSearchParams(url.split('?')[1]).get('filename'), resolve));
readPixels = (image, w, h) => new Uint8Array(w*h*4).fill(image.value);
syncState = () => {}; show = () => {};
const face = {alive:true,flying:[],turn:0,sourceEpoch:0,node:{widgets:[]}};
const base = {key:'2x2-picture',width:2,height:2,scale:1,stamp:'same',picture:{filename:'picture.jpg'}};
globalThis.entry = pictureFor(base,face);
entry.image = {}; entry.file = 'picture.jpg';
const note = name => ({...base,selection:{filename:name+'-selection.png'},fine:{file:{filename:name+'-fine.png'},made:name}});
globalThis.oldJob = takeNote(face,note('old'));
globalThis.newJob = takeNote(face,note('new'));
`);
downloads.get("new-selection.png")({ value: 0 });
downloads.get("new-fine.png")({ value: 0 });
await context.newJob;
downloads.get("old-selection.png")({ value: 255 });
downloads.get("old-fine.png")({ value: 255 });
await context.oldJob;
assert.deepEqual([...context.entry.states.get("same")], [0, 0, 0, 0]);
assert.equal(context.entry.fine.made, "new");
});
test("restored picks never schedule fine processing", () => {
const { context, timers, run } = stage();
run(`
adopt = () => false; readItems = () => {}; resolve = () => {};
syncState = () => {}; refreshHover = () => {}; draw = () => {}; drawInk = () => {};
settings = () => ({fine:'model.safetensors'}); fineOf = () => null;
globalThis.requested = [];
requestRun = (_face, ask) => requested.push(ask);
const pic = {key:'picture',piecesTag:''};
const face = {alive:true,pic,node:{},items:[{t:'paint',op:1,r:10,pts:[[1,1]]}],exact:true,stamp:'same',shownPieces:'',pending:[],inFlight:false};
show(face,pic,{stamp:'same'},null,{restored:true});
`);
for (const { fn } of [...timers.values()]) fn();
assert.deepEqual([...context.requested], []);
});
test("source subscriptions release removed faces while the loader survives", () => {
const { context, run } = stage();
run(`
globalThis.reads = 0;
globalThis.priorCalls = 0;
const loader = {widgets:[{name:'image',value:'picture.png',callback(){priorCalls++;}}]};
ownLoaderNode = () => loader;
for (let n=0;n<100;n++) {
const face = {node:{},watchedSources:new WeakSet(),forgetSources:[],host:{},get alive(){reads++;return false;}};
followOwnLoader(face);
for (const forget of face.forgetSources) forget();
}
loader.widgets[0].callback();
`);
assert.equal(context.reads, 0);
assert.equal(context.priorCalls, 1);
});
test("plain Load Image subscriptions are removable too", async () => {
const { context, run } = stage();
run(`
globalThis.reads = 0;
const widget = {};
loaderSource = () => null; loaderWidget = () => widget;
for (let n=0;n<100;n++) {
const face = {node:{},sourceUrl:'',watchedSources:new WeakSet(),forgetSources:[],get alive(){reads++;return false;}};
syncSource(face);
for (const forget of face.forgetSources) forget();
}
widget.callback();
`);
await Promise.resolve();
assert.equal(context.reads, 0);
});
test("destroy cancels the pending click and releases source subscriptions", () => {
const { context, timers, run } = stage();
run(`
globalThis.forgotten = 0; globalThis.aborted = false;
globalThis.face = {alive:true,pendingTimer:setTimeout(()=>{},500),forgetSources:[()=>forgotten++]};
globalThis.abort = {abort(){aborted=true;}};
`);
const start = source.indexOf(" face.destroy = () => {");
const end = source.indexOf("\n };", start) + "\n };".length;
run(source.slice(start, end));
context.face.destroy();
assert.equal(context.face.alive, false);
assert.equal(timers.size, 0);
assert.equal(context.forgotten, 1);
assert.equal(context.aborted, true);
});
test("Clear is enabled for malformed stored touches and erases the text", () => {
const { context, run } = stage();
run(`
const toggle = {toggle(){}};
globalThis.face = {node:{},pic:null,items:[],pending:[],past:[],touches:{value:'{'},host:{},
undo:{},clear:{},toolSwitch:{set(){},root:{classList:toggle}},softField:{classList:toggle},softBox:{set(){}},
subjectButton:{},backgroundButton:{},eye:{classList:toggle},size:{set(){}},view:'tint'};
syncTools(face);
`);
assert.equal(context.face.clear.disabled, false);
run(`
dropPending = () => {}; syncReadout = () => {}; syncState = () => {}; refreshHover = () => {};
button = (_class, _text, _title, click) => ({click});
globalThis.signal = null;
`);
const start = source.indexOf(" face.clear = button(");
const end = source.indexOf(" }, signal);", start) + " }, signal);".length;
run(source.slice(start, end));
context.face.clear.click();
assert.equal(context.face.touches.value, "");
});
test("named pieces of one through four pixels can be added and removed", () => {
for (let size = 1; size <= 4; size++) {
const planes = [new Uint32Array(size).fill(1)];
const sizes = math.pieceSizes(planes, 1);
assert.equal(math.pieceAt(planes, 0, { sizes, inside: [0] }), 0);
assert.equal(math.pieceAt(planes, 0, { sizes, inside: [size], adding: false }), 0);
assert.equal(math.pieceAt(planes, 0, { sizes, inside: [size] }), -1);
assert.equal(math.pieceAt(planes, 0, { sizes, inside: [0], adding: false }), -1);
}
});