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
16 changed files with 59 additions and 364 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"
-26
View File
@@ -11,32 +11,6 @@ export function lookupLink(graph, linkId) {
return graph?.links?.get?.(linkId) ?? graph?.links?.[linkId] ?? null;
}
// The live source of a named input. Bypass forwards a compatible input;
// mute, broken links and cycles provide no value.
export function linkSource(node, name) {
const graph = node.graph;
let input = node.inputs?.find((slot) => slot?.widget?.name === name || slot?.name === name);
const seen = new Set();
const compatible = (a, b) => !a || !b || a === "*" || b === "*"
|| String(a).split(",").some((type) => String(b).split(",").includes(type));
while (input?.link !== null && input?.link !== undefined) {
const link = lookupLink(graph, input.link) ?? graph?._links?.get?.(input.link);
if (!link || seen.has(link)) return null;
seen.add(link);
const source = graph?.getNodeById?.(link.origin_id);
if (!source || source.mode === 2) return null;
if (source.mode !== 4) return source;
const slot = link.origin_slot ?? 0;
const outputType = source.outputs?.[slot]?.type;
const type = input.type ?? outputType;
const inputs = source.inputs ?? [];
const matches = (entry) => entry && compatible(entry.type, outputType) && compatible(entry.type, type);
if (type === "*" || type === "") input = inputs[slot] ?? inputs[0];
else input = matches(inputs[slot]) ? inputs[slot] : inputs.find((entry) => entry.type === type) ?? inputs.find(matches);
}
return null;
}
// Wire origin's output slot to target's input slot, both by index. Returns
// the new link, or null when it could not be made.
//
+11 -6
View File
@@ -3,8 +3,7 @@
// dashed chip, so it reads as optional. The widgets underneath stay the
// source of truth: a chip only changes their value.
import { app } from "/scripts/app.js";
import { BRAND, chainCallback, notifyAusbossChange } from "./index.mjs";
import { linkSource } from "./graph_links.mjs";
import { BRAND, chainCallback } from "./index.mjs";
import { liftSocket } from "./widget_card.mjs";
import { commitWidgetValue, comboValues } from "./widget_card_math.mjs";
@@ -32,6 +31,15 @@ function ensureCss() {
document.head.append(style);
}
function linkSource(node, name) {
const slot = node.inputs?.find((input) => input?.widget?.name === name || input?.name === name);
if (slot?.link === null || slot?.link === undefined) return null;
const graph = node.graph;
const link = graph?.links?.[slot.link] ?? graph?._links?.get?.(slot.link);
const source = graph?.getNodeById?.(link?.origin_id);
return (source?.mode ?? 0) === 0 ? source ?? {} : null;
}
const widgetOf = (node, name) => node.widgets?.find((widget) => widget.name === name) ?? null;
function chip(className = "") {
@@ -55,10 +63,7 @@ function choice(node, name, into) {
select.value = widget?.value ?? "";
select.title = widget?.options?.tooltip ?? "";
select.addEventListener("change", () => {
if (widget && widget.value !== select.value) {
commitWidgetValue(node, widget, select.value, app.canvas);
notifyAusbossChange();
}
if (widget) commitWidgetValue(node, widget, select.value, app.canvas);
node.setDirtyCanvas?.(true, true);
});
return (values) => { if (document.activeElement !== select) select.value = values[name] ?? ""; };
+13 -16
View File
@@ -4,8 +4,7 @@
// words the node reads as instructions (@1, 3s:, a cut) stand out.
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { BRAND, chainCallback, notifyAusbossChange } from "./index.mjs";
import { linkSource } from "./graph_links.mjs";
import { BRAND, chainCallback } from "./index.mjs";
import { dragRange, moveMoment, retime, rulerSeconds, stripCaption, stripRows, toggleCut, wiredJobs } from "./moment_strip_math.mjs";
import { attachScriptAssist } from "./script_assist.mjs";
import { attachScriptColours } from "./script_colours.mjs";
@@ -48,6 +47,16 @@ function ensureCss() {
document.head.append(style);
}
// A bypassed or muted source doesn't reach the node when it runs, so it doesn't count.
function linkSource(node, name) {
const slot = node.inputs?.find((input) => input?.widget?.name === name || input?.name === name);
if (slot?.link === null || slot?.link === undefined) return null;
const graph = node.graph;
const link = graph?.links?.[slot.link] ?? graph?._links?.get?.(slot.link);
const source = graph?.getNodeById?.(link?.origin_id);
return (source?.mode ?? 0) === 0 ? source ?? {} : null;
}
function wired(node, name) {
return linkSource(node, name) !== null;
}
@@ -97,7 +106,6 @@ export function momentStrip(node) {
const next = change(String(widget.value ?? ""));
if (next === widget.value) return;
commitWidgetValue(node, widget, next, app.canvas);
notifyAusbossChange();
sync({ ...lastValues, script: widget.value });
};
// The left edge of a moment that opens a typed line: drag it to set when
@@ -258,7 +266,6 @@ export function momentStrip(node) {
let shown = null;
let timer = null;
let asked = 0;
let removed = false;
const ask = async (script, seconds, jobs, video) => {
const turn = ++asked;
try {
@@ -276,7 +283,6 @@ export function momentStrip(node) {
let latest = null;
const sync = (values) => {
if (removed) return;
latest = values;
lastValues = values;
colours.sync(values, [1, 2, 3, 4].filter((number) => wired(node, `picture_${number}`)));
@@ -296,22 +302,13 @@ export function momentStrip(node) {
lastSeconds = seconds;
const jobs = wiredJobs(values, (name) => wired(node, name));
const video = wired(node, "video") ? values.video_job : "";
const pictures = [1, 2, 3, 4].map((number) => pictureUrl(node, number));
const key = JSON.stringify([seconds, script, jobs, video, pictures]);
const key = JSON.stringify([seconds, script, jobs, video]);
if (key === shown) return;
shown = key;
clearTimeout(timer);
asked += 1;
timer = setTimeout(() => ask(script, seconds, jobs, video), WAIT);
};
const watch = setInterval(() => latest && sync(latest), WATCH);
chainCallback(node, "onRemoved", () => {
removed = true;
clearTimeout(timer);
asked += 1;
clearInterval(watch);
colours.dispose();
assist.dispose();
});
chainCallback(node, "onRemoved", () => { clearInterval(watch); colours.dispose(); assist.dispose(); });
return { element, sync };
}
+2 -2
View File
@@ -31,8 +31,8 @@ export function stripRows(report) {
}));
}
// The timestamp grammar in _h3_script_helpers.py, including clocks and ranges.
const TYPED_TIME = /^\[?(?:at\s+)?(?:\d+:\d\d(?:\.\d+)?|\d+(?:\.\d+)?\s*(?:(?:seconds?|secs?|s)\b|(?=[\-–—]\s*\d)))(?:\s*[\-–—]\s*\d+(?:\.\d+)?\s*(?:(?:seconds?|secs?|s)\b)?)?\]?\s*[:,\-]?\s*/i;
// A time typed at the start of a line: 3s:, 3 s, at 3 seconds, [3s].
const TYPED_TIME = /^\[?(?:at\s+)?\d+(?:\.\d+)?\s*(?:seconds?|secs?|s)\b\]?\s*[:,\-]?\s*/i;
// The script with one line set to start at a second: its old time, if any,
// gives way to the new one. line counts typed lines from 0.
+1 -1
View File
@@ -344,7 +344,7 @@ const CARDS = {
{ widget: "sound", label: "Sound", placeholder: "rain on a window" },
{ widget: "music", label: "Music", placeholder: "slow piano: music makes it louder" },
{ section: "Pictures and settings" },
{ panel: castChips, height: 82 },
{ panel: castChips, height: 30 },
{ widget: "seconds", label: "Length", suffix: "s" },
{ widget: "camera", label: "Camera", kind: "select" },
{ widget: "look", label: "Look", kind: "select" },
+13 -26
View File
@@ -100,29 +100,17 @@ def spoken_line(speaker, words, language, voice_label, on_screen, number=1, lips
return f"{speaker} (S{number}) says in an off-screen voiceover, {line}{closed}{timbre}"
def snap_cuts(detailed, beats, gap=0.4, seconds=None):
def snap_cuts(detailed, beats, gap=0.4):
"""Moves every timed moment to the nearest beat, keeping them in order and
`gap` seconds apart, or closer if needed to fit inside the clip."""
moments = list(CUT.finditer(detailed))
if not moments or not beats:
return detailed
limit = seconds if seconds is not None else max(
*beats, *(int(match.group(2)) * 60 + float(match.group(3)) for match in moments)
)
gap = min(gap, limit / len(moments))
at least `gap` seconds apart."""
last = 0.0
remaining = len(moments)
def move(match):
nonlocal last, remaining
remaining -= 1
nonlocal last
time = int(match.group(2)) * 60 + float(match.group(3))
low, high = last + gap, limit - remaining * gap
options = [beat for beat in beats if low <= beat <= high]
options = [beat for beat in beats if beat >= last + gap]
if options:
time = min(options, key=lambda beat: abs(beat - time))
else:
time = min(high, max(low, time))
last = time
minutes, seconds = divmod(time, 60)
return f"{match.group(1)}{int(minutes):02d}:{seconds:06.3f}"
@@ -143,14 +131,14 @@ def add_voice(parts, label, speaker=None):
)
def cut_on_beat(parts, beats, seconds=None):
def cut_on_beat(parts, beats):
if beats:
parts["detailed_description"] = snap_cuts(parts["detailed_description"], beats, seconds=seconds).replace(
parts["detailed_description"] = snap_cuts(parts["detailed_description"], beats).replace(
"the camera cuts to", "the camera cuts on the beat to"
)
def add_song(parts, label, beats, seconds=None):
def add_song(parts, label, beats):
parts["subject_definitions"] += f"\n{label} is the complete source soundtrack reused in the target video."
parts["summary"] = add_task(parts["summary"], "audio reuse")
if beats:
@@ -160,14 +148,14 @@ def add_song(parts, label, beats, seconds=None):
)
parts["overall_soundscape"] = "N/A"
parts["non_diegetic_music"] = f"{label} is directly reused as the complete audience-only score."
cut_on_beat(parts, beats, seconds)
cut_on_beat(parts, beats)
def add_song_under(parts, beats, seconds=None):
def add_song_under(parts, beats):
"""A song to lay under the finished clip. H3 drops a song it is asked to
keep when someone also speaks, so it hears none and makes no music."""
parts["non_diegetic_music"] = "N/A"
cut_on_beat(parts, beats, seconds)
cut_on_beat(parts, beats)
def write_prompt(script, seconds=15.0, camera="", look="", sound="", language="English", voice=False, song_beats=None,
@@ -225,9 +213,9 @@ def write_prompt(script, seconds=15.0, camera="", look="", sound="", language="E
if voice:
add_voice(parts, voice_label, speaker)
if song_beats is not None and voice:
add_song_under(parts, song_beats, seconds)
add_song_under(parts, song_beats)
elif song_beats is not None:
add_song(parts, f"<Audio {audio}>", song_beats, seconds)
add_song(parts, f"<Audio {audio}>", song_beats)
return re.sub(r"[ \t]{2,}", " ", join_sections(parts)).replace(" \n", "\n").strip()
@@ -249,8 +237,7 @@ def find_beats(samples, rate, seconds=30.0):
[np.abs(np.fft.rfft(samples[index * hop : index * hop + window] * taper)) for index in range(frames)]
)
rise = np.maximum(np.diff(np.log1p(1000 * spectra), axis=0), 0).sum(axis=1)
smooth = min(43, len(rise))
rise = np.maximum(rise - np.convolve(rise, np.ones(smooth) / smooth, "same"), 0)
rise = np.maximum(rise - np.convolve(rise, np.ones(43) / 43, "same"), 0)
if rise.max() <= 0:
return []
rise = rise / rise.max()
+1 -3
View File
@@ -298,7 +298,6 @@ def take_label(line, script):
def read_notes(text, script):
text, _ = mask_quotes(text)
if NO_SOUND.search(text):
script.silent = True
if NO_MUSIC.search(text):
@@ -386,7 +385,7 @@ def read_script(text):
if not line or take_label(line, script):
continue
opens = block_line + offset
for piece in split_line(line if TIMED.match(line) else NUMBER.sub("", line).strip()):
for piece in split_line(NUMBER.sub("", line).strip()):
note = SIDE_NOTE.match(piece)
if note: # "no music, just rain" on its own is a note, not a moment.
read_notes(piece, script)
@@ -409,7 +408,6 @@ def read_script(text):
put_in_order(script)
if script.one_take or not any(moment.cut for moment in script.moments):
for moment in script.moments:
moment.cut = False
script.camera, script.sound = moment.camera or script.camera, moment.sound or script.sound
moment.camera = moment.sound = ""
return script
+4 -8
View File
@@ -88,8 +88,8 @@ def clip_frames(seconds):
def video_frames(frames):
"""A clip's own length, on H3's grid and within what it can make."""
longest = clip_frames(LONGEST_SECONDS)
return max(0, (min(frames, longest) - 5) // 17) * 17 + 5
longest = LONGEST_SECONDS * FRAME_RATE
return max(1, (min(frames, longest) - 5) // 17) * 17 + 5
def cut_audio(audio, start, seconds, name):
@@ -104,14 +104,10 @@ def cut_audio(audio, start, seconds, name):
def last_of_clip(video, sound):
"""The end of a clip, picture and sound, to carry on from."""
frames = min(len(video), KEEP_FRAMES)
if frames >= 5:
frames = video_frames(frames)
if sound is not None:
rate = int(sound["sample_rate"])
count = int(round(frames / FRAME_RATE * rate))
sound = {"waveform": sound["waveform"][..., -count:] if count else sound["waveform"][..., :0], "sample_rate": rate}
return video[-frames:] if frames else video[:0], sound
sound = {"waveform": sound["waveform"][..., -int(round(KEEP_FRAMES / FRAME_RATE * rate)) :], "sample_rate": rate}
return video[-KEEP_FRAMES:], sound
def under_voice(song):
-34
View File
@@ -3,7 +3,6 @@ import test from "node:test";
import {
linkSlots,
linkSource,
lookupLink,
restoreOutputLinks,
snapshotLinks,
@@ -63,39 +62,6 @@ test("lookupLink reads Map and plain-object link tables", () => {
assert.equal(lookupLink(null, 7), null);
});
test("linkSource follows compatible bypass chains and rejects mute, missing links and cycles", () => {
const loader = { id: 1, mode: 0 };
const processor = { id: 2, mode: 4, inputs: [{ type: "MASK", link: null }, { type: "IMAGE", link: 2 }], outputs: [{ type: "IMAGE" }] };
const pass = { id: 3, mode: 4, inputs: [{ type: "IMAGE", link: 3 }], outputs: [{ type: "IMAGE" }] };
const nodes = new Map([[1, loader], [2, processor], [3, pass]]);
const graph = { links: new Map([[1, { origin_id: 3, origin_slot: 0 }], [2, { origin_id: 1, origin_slot: 0 }], [3, { origin_id: 2, origin_slot: 0 }]]), getNodeById: (id) => nodes.get(id) };
const target = { graph, inputs: [{ name: "picture_1", type: "IMAGE", link: 1 }] };
assert.equal(linkSource(target, "picture_1"), loader);
loader.mode = 2;
assert.equal(linkSource(target, "picture_1"), null);
loader.mode = 0;
processor.inputs[1].link = null;
assert.equal(linkSource(target, "picture_1"), null);
processor.inputs[1].link = 1;
assert.equal(linkSource(target, "picture_1"), null);
processor.inputs[1] = { type: "AUDIO", link: 2 };
assert.equal(linkSource(target, "picture_1"), null);
target.inputs[0].link = 99;
assert.equal(linkSource(target, "picture_1"), null);
});
test("linkSource prefers the matching input at the output's index", () => {
const first = { id: 1 }, second = { id: 2 };
const bypass = { id: 3, mode: 4, inputs: [{ type: "IMAGE", link: 2 }, { type: "IMAGE", link: 3 }], outputs: [{ type: "IMAGE" }, { type: "IMAGE" }] };
const graph = { _links: new Map([[1, { origin_id: 3, origin_slot: 1 }], [2, { origin_id: 1 }], [3, { origin_id: 2 }]]), getNodeById: (id) => [null, first, second, bypass][id] };
assert.equal(linkSource({ graph, inputs: [{ name: "picture_1", type: "IMAGE", link: 1 }] }, "picture_1"), second);
bypass.inputs[1].type = "MASK";
assert.equal(linkSource({ graph, inputs: [{ name: "picture_1", type: "IMAGE", link: 1 }] }, "picture_1"), first);
for (const type of ["*", ""]) {
assert.equal(linkSource({ graph, inputs: [{ name: "value", type, link: 1 }] }, "value"), second);
}
});
test("linkSlots hands the resolved slot objects to connectSlots", () => {
const { makeNode } = makeGraph();
const source = makeNode(1, [], ["IMAGE", "MASK"]);
-178
View File
@@ -1,178 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import vm from "node:vm";
import * as math from "../js/shared/moment_strip_math.mjs";
import * as links from "../js/shared/graph_links.mjs";
import { commitWidgetValue, comboValues } from "../js/shared/widget_card_math.mjs";
class Element {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.listeners = {};
this.style = {};
this.className = "";
this.value = "";
this.classList = {
add: (name) => { if (!this.classList.contains(name)) this.className += ` ${name}`; },
remove: (name) => { this.className = this.className.split(" ").filter((part) => part !== name).join(" "); },
contains: (name) => this.className.split(" ").includes(name),
toggle: (name, on) => on ? this.classList.add(name) : this.classList.remove(name),
};
}
append(...children) { for (const child of children) { child.parentElement = this; this.children.push(child); } }
set textContent(text) { this.text = text; this.children = []; }
get textContent() { return (this.text ?? "") + this.children.map((child) => child.textContent).join(""); }
get childElementCount() { return this.children.length; }
addEventListener(name, fn) { (this.listeners[name] ??= []).push(fn); }
removeEventListener(name, fn) { this.listeners[name] = (this.listeners[name] ?? []).filter((entry) => entry !== fn); }
dispatch(name, event = {}) {
for (const fn of [...(this.listeners[name] ?? [])]) fn({ button: 0, clientX: 0, preventDefault() {}, stopPropagation() {}, ...event });
}
getBoundingClientRect() { return { left: 0, width: 100 }; }
remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter((child) => child !== this); }
}
function page() {
let now = 0, next = 0, captures = 0;
const timers = new Map(), intervals = new Map(), pending = [];
const document = { head: new Element("head"), createElement: (tag) => new Element(tag), getElementById: () => null };
const app = { canvas: {}, extensionManager: { workflow: { activeWorkflow: { changeTracker: { captureCanvasState() { captures++; } } } } } };
const globals = {
document, app, console, ...math, ...links, commitWidgetValue, comboValues,
api: { fetchApi: (url, options) => new Promise((resolve) => pending.push({ body: JSON.parse(options.body), resolve })) },
setTimeout: (fn, delay) => { const id = ++next; timers.set(id, { fn, at: now + delay }); return id; },
clearTimeout: (id) => timers.delete(id),
setInterval: (fn) => { const id = ++next; intervals.set(id, fn); return id; },
clearInterval: (id) => intervals.delete(id),
queueMicrotask: (fn) => fn(),
liftSocket() {},
attachScriptColours: () => ({ sync() {}, dispose() {} }),
attachScriptAssist: () => ({ dispose() {} }),
};
// Only browser imports are replaced; the controls and history notifier run as shipped.
const load = (file, names) => {
const source = readFileSync(new URL(`../js/shared/${file}`, import.meta.url), "utf8")
.replace(/^import .*;\n/gm, "").replace(/\bexport (?=(?:function|const)\b)/g, "");
return vm.runInNewContext(`${source}\n;({${names.join(",")}})`, { ...globals }, { filename: file });
};
Object.assign(globals, load("index.mjs", ["BRAND", "chainCallback", "notifyAusbossChange"]));
return {
...load("h3_cast_chips.mjs", ["castChips"]), ...load("moment_strip.mjs", ["momentStrip"]),
pending, intervals, timers, get captures() { return captures; },
advance(ms) {
now += ms;
for (const [id, timer] of [...timers]) if (timer.at <= now) { timers.delete(id); timer.fn(); }
},
};
}
const find = (element, predicate) => predicate(element) ? element : element.children.map((child) => find(child, predicate)).find(Boolean);
const named = (name) => (element) => element.classList.contains(name);
const makeNode = (script = "She walks.\nShe stops.") => ({ widgets: [{ name: "script", value: script }], inputs: [], graph: {}, setDirtyCanvas() {} });
const values = (node) => ({ script: node.widgets[0].value, seconds: 8, job_1: "person" });
const report = (text = "line", moments = [0, 1]) => ({ ok: true, json: async () => ({ seconds: 8, moments: moments.map((line, index) => ({ start: index * 4, end: (index + 1) * 4, text: `${text}${index}`, line })) }) });
const flush = async () => { for (let i = 0; i < 6; i++) await Promise.resolve(); };
function pictureGraph(bypass = false) {
const loader = { id: 10, mode: 0, imgs: [{ src: "test.png" }], outputs: [{ type: "IMAGE" }] };
const processor = { id: 11, mode: 4, inputs: [{ type: "IMAGE", link: 2 }], outputs: [{ type: "IMAGE" }] };
const graph = { links: { 1: { origin_id: bypass ? 11 : 10, origin_slot: 0 }, 2: { origin_id: 10, origin_slot: 0 } }, getNodeById: (id) => id === 10 ? loader : id === 11 ? processor : null };
return { graph, loader, processor };
}
test("a chip job change captures history once, after the widget write", () => {
const p = page(), { graph } = pictureGraph();
const widget = { name: "job_1", value: "person", options: { values: ["person", "place"] } };
const node = { graph, widgets: [widget], inputs: [{ name: "picture_1", link: 1 }] };
const panel = p.castChips(node);
panel.sync({ job_1: "person" });
p.advance(120);
assert.equal(p.captures, 0, "sync does not dirty the workflow");
const select = find(panel.element, (element) => element.tagName === "SELECT");
select.value = "place"; select.dispatch("change");
assert.equal(widget.value, "place");
assert.equal(p.captures, 0, "capture waits for the completed write");
p.advance(120);
assert.equal(p.captures, 1);
select.dispatch("change"); p.advance(120);
assert.equal(p.captures, 1, "an unchanged job stays clean");
node.onRemoved();
});
test("a completed timeline cut captures history once", async () => {
const p = page(), node = makeNode(), panel = p.momentStrip(node);
panel.sync(values(node)); p.advance(200);
p.pending[0].resolve(report()); await flush();
const handle = find(panel.element, named("ausboss-strip-handle"));
handle.dispatch("pointerdown"); handle.dispatch("pointerup");
assert.equal(node.widgets[0].value, "She walks.\n\nShe stops.");
p.advance(120);
assert.equal(p.captures, 1);
node.onRemoved();
});
test("editing invalidates an old report before the next debounce fires", async () => {
const p = page(), node = makeNode(), panel = p.momentStrip(node);
panel.sync(values(node)); p.advance(200);
node.widgets[0].value = "new"; panel.sync(values(node));
p.pending[0].resolve(report("OLD")); await flush();
assert.doesNotMatch(panel.element.textContent, /OLD/);
p.advance(200);
p.pending[1].resolve(report("NEW", [0])); await flush();
assert.match(panel.element.textContent, /NEW/);
node.onRemoved();
});
test("removal cancels debounce, polling, and in-flight repaint", async () => {
const p = page(), node = makeNode(), panel = p.momentStrip(node);
panel.sync(values(node)); node.onRemoved(); p.advance(200);
assert.equal(p.pending.length, 0, "no request starts after removal");
assert.equal(p.intervals.size, 0);
const other = makeNode(), otherPanel = p.momentStrip(other);
otherPanel.sync(values(other)); p.advance(200); other.onRemoved();
p.pending.at(-1).resolve(report("REMOVED")); await flush();
assert.doesNotMatch(otherPanel.element.textContent, /REMOVED/);
otherPanel.sync({ script: "later", seconds: 8 }); p.advance(200);
assert.equal(p.pending.length, 1, "a detached panel stays stopped");
});
test("replacing a loader image refreshes the timeline thumbnail", async () => {
const p = page(), node = makeNode("@1 runs."), { graph, loader } = pictureGraph();
node.graph = graph; node.inputs = [{ name: "picture_1", link: 1 }];
const panel = p.momentStrip(node);
panel.sync(values(node)); p.advance(200);
p.pending.at(-1).resolve(report("@1 runs", [0])); await flush();
assert.equal(find(panel.element, (element) => element.tagName === "IMG").src, "test.png");
loader.imgs[0].src = "replacement.png";
panel.sync(values(node)); p.advance(200);
p.pending.at(-1).resolve(report("@1 runs", [0])); await flush();
assert.equal(find(panel.element, (element) => element.tagName === "IMG").src, "replacement.png");
node.onRemoved();
});
test("bypassed image processors forward pictures to chips and timeline", async () => {
const p = page(), node = makeNode("@1 runs."), { graph, processor } = pictureGraph(true);
node.graph = graph; node.inputs = [{ name: "picture_1", link: 1 }];
node.widgets.push({ name: "job_1", value: "person", options: { values: ["person"] } });
const chips = p.castChips(node), strip = p.momentStrip(node);
chips.sync(values(node)); strip.sync(values(node)); p.advance(200);
assert.equal(find(chips.element, (element) => element.tagName === "IMG")?.src, "test.png");
assert.deepEqual(p.pending.at(-1).body.jobs, { 1: "person" });
p.pending.at(-1).resolve(report("@1 runs", [0])); await flush();
assert.equal(find(strip.element, (element) => element.tagName === "IMG")?.src, "test.png");
processor.mode = 2; chips.sync(values(node)); strip.sync(values(node)); p.advance(200);
assert.match(chips.element.textContent, /\+ @1/);
assert.deepEqual(p.pending.at(-1).body.jobs, {});
node.onRemoved();
assert.equal(p.intervals.size, 0);
});
test("the chips row reserves room for three wrapped rows at minimum width", () => {
const cards = readFileSync(new URL("../js/widget_cards/index.js", import.meta.url), "utf8");
const css = readFileSync(new URL("../js/shared/h3_cast_chips.mjs", import.meta.url), "utf8");
const height = Number(cards.match(/panel: castChips, height: (\d+)/)[1]);
const chipHeight = Number(css.match(/\.ausboss-chip\{[^}]*height:(\d+)px/)[1]);
const gap = Number(css.match(/\.ausboss-chips\{[^}]*gap:(\d+)px/)[1]);
assert.ok(height >= 3 * chipHeight + 2 * gap, `${height}px cannot hold three chip rows`);
});
+1 -11
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { moveMoment, retime, stripCaption, stripRows, wiredJobs } from "../js/shared/moment_strip_math.mjs";
import { stripCaption, stripRows, wiredJobs } from "../js/shared/moment_strip_math.mjs";
const row = (start, end, extra = {}) => ({ start, end, text: "She runs", cut: false, speech: false, ...extra });
@@ -43,13 +43,3 @@ test("only connected pictures send their job", () => {
test("a warning goes first", () => {
assert.deepEqual(stripCaption({ warning: "Too many.", moments: [row(0, 5)] }), { text: "Too many.", warn: true });
});
test("retiming replaces every timestamp format the script accepts", () => {
for (const prefix of ["0:03:", "0:03.500:", "3-6s:", "3s-6s:", "[at 3 seconds]", "at 3 secs,", "2.5s:", "3–6s:"]) {
assert.equal(retime(`${prefix} She waves.`, 0, 5), "5s: She waves.", prefix);
}
});
test("moving moments leaves clocks and time ranges in their original slots", () => {
assert.equal(moveMoment("0:03: She waves.\n3-6s: She runs.", [0, 1], 1, 0), "0:03: She runs.\n3-6s: She waves.");
});
+5 -45
View File
@@ -111,26 +111,6 @@ class ReadingTests(unittest.TestCase):
def test_lines_that_all_name_their_second_play_by_the_second(self):
self.assertEqual(moments("5s: she sits\n2s: she stands"), ["@0 holds still", "She stands", "She sits"])
def test_fractional_seconds_are_not_list_numbers(self):
for second in (0.5, 2.5):
script = read_script(f"She waits.\n{second}s: She waves.")
self.assertEqual(script.moments[1].at, second)
self.assertEqual(script.moments[1].text, "She waves")
def test_spoken_directives_do_not_change_the_clip(self):
script = read_script('She says "no cuts, no sound, no music."\n\nShe walks.')
self.assertFalse(script.one_take)
self.assertFalse(script.silent)
self.assertNotEqual(script.music, "N/A")
self.assertTrue(script.moments[1].cut)
self.assertTrue(parts_of('She says "no sound."\n\nsound: rain')["overall_soundscape"].startswith("Rain."))
def test_no_cuts_clears_effective_cuts_everywhere(self):
text = "no cuts\nIt starts to rain.\n\nShe stops."
self.assertFalse(any(moment.cut for moment in read_script(text).moments))
self.assertFalse(any(moment["cut"] for moment in script_report(text, 8)["moments"]))
self.assertIn("The rain keeps falling", write_prompt(text))
def test_an_opening_and_an_ending_go_where_they_belong(self):
script = read_script("ends on her crying\nstarts on a wide shot of the city\nshe walks out")
self.assertEqual([moment.text for moment in script.moments], ["A wide shot of the city", "She walks out", "Her crying"])
@@ -840,17 +820,6 @@ class SongTests(unittest.TestCase):
def test_minutes_carry_over(self):
self.assertIn("At 01:00.100,", snap_cuts("[Shot 2] At 00:59.900, a", [60.1]))
def test_beat_exhaustion_keeps_times_in_order_and_inside_the_clip(self):
detail = "\n".join(f"At 00:0{time:.3f}, a" for time in (4.0, 4.1, 4.2))
fallback = snap_cuts(detail, [1, 3, 4.5])
times = [float(second) for second in re.findall(r"At 00:(\d+\.\d+)", fallback)]
self.assertEqual(times, sorted(times))
text = snap_cuts(detail, [1, 3, 4.5], seconds=5)
times = [float(second) for second in re.findall(r"At 00:(\d+\.\d+)", text)]
self.assertEqual(len(times), 3)
self.assertTrue(all(b - a >= 0.399 for a, b in zip(times, times[1:])))
self.assertLessEqual(times[-1], 5)
def test_six_parts_take_a_song_and_a_voice_too(self):
for voice in (False, True):
for song in (None, [2.0, 4.0, 6.0]):
@@ -1049,10 +1018,9 @@ class NodeTests(unittest.TestCase):
sound = {"waveform": torch.arange(48000.0).reshape(1, 1, -1), "sample_rate": 12000}
output = AusBossH3PromptWriter().write("", 6, video=frames, video_sound=sound, video_job="keep going")
video, heard = output[5], output[6]
self.assertEqual(video.shape[0], 39)
self.assertEqual(float(video[0, 0, 0, 0]), 61.0)
self.assertEqual(video.shape[0], 44)
self.assertEqual(float(video[-1, 0, 0, 0]), 99.0)
self.assertEqual(heard["waveform"].shape[-1], 19500)
self.assertEqual(heard["waveform"].shape[-1], 22000)
self.assertEqual(float(heard["waveform"][0, 0, -1]), 47999.0)
def test_the_clip_takes_the_shape_of_the_video_or_else_the_first_picture(self):
@@ -1064,17 +1032,9 @@ class NodeTests(unittest.TestCase):
self.assertEqual(node.write("@2 waves.", 6, picture_2=wide, job_2="person")[-2:], (832, 480))
self.assertEqual(node.write("@1 dances.", 6, picture_1=tall, video=video)[-2:], (1280, 720))
def test_a_short_clip_keeps_a_valid_tail_and_matching_sound(self):
def test_a_short_clip_keeps_all_it_has(self):
frames = torch.zeros(20, 2, 2, 3)
output = AusBossH3PromptWriter().write("", 6, video=frames, video_sound=self.audio(), video_job="keep going")
self.assertEqual(output[5].shape[0], 5)
self.assertEqual(output[6]["waveform"].shape[-1], round(5 / 24 * 22050))
def test_a_short_song_segment_does_not_crash(self):
song = {"waveform": torch.from_numpy(clicks(100, seconds=10)).reshape(1, 1, -1), "sample_rate": 22050}
for duration in (0.15, 0.2, 0.3, 0.4, 0.55):
output = AusBossH3PromptWriter().write(LINES, 6, picture_1=PICTURE, song=song, song_start=10 - duration)
self.assertEqual(output[SONG]["waveform"].shape[-1], round(duration * 22050))
self.assertEqual(AusBossH3PromptWriter().write("", 6, video=frames, video_job="keep going")[5].shape[0], 20)
def test_a_swap_keeps_the_videos_length(self):
frames = torch.zeros(100, 8, 8, 3)
@@ -1083,7 +1043,7 @@ class NodeTests(unittest.TestCase):
self.assertEqual(output[FRAMES], 90)
def test_a_video_length_is_one_h3_can_make(self):
self.assertEqual([video_frames(count) for count in (5, 124, 125, 141, 362, 5000)], [5, 124, 124, 141, 362, 362])
self.assertEqual([video_frames(count) for count in (124, 125, 141, 5000)], [124, 124, 141, 345])
def test_cut_on_beat_off_keeps_the_times(self):
text = AusBossH3PromptWriter().write(LINES, 6, picture_1=PICTURE, song=self.audio(), cut_on_beat=False)[0]