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
20 changed files with 98 additions and 603 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"
+17 -38
View File
@@ -85,7 +85,7 @@ function isSingleFrame(state) {
// trim window's IN point.
function homeTime(state) {
return isSingleFrame(state)
? singleFrameTime(state.video.duration, state.startWidget.value, state.sourceFps, state.sourceFrameCount)
? singleFrameTime(state.video.duration, state.startWidget.value)
: currentBounds(state).start;
}
@@ -126,7 +126,6 @@ function trimLoadSummary(state, duration, bounds) {
// on any error the label simply omits the count and the timecodes stand.
async function probeSourceFps(state, name) {
state.sourceFps = 0;
state.sourceFrameCount = 0;
try {
const query = new URLSearchParams({ source_mode: "input folder", video: name });
const response = await api.fetchApi(`/ausboss/transform/video/metadata?${query}`);
@@ -134,15 +133,14 @@ async function probeSourceFps(state, name) {
const metadata = await response.json();
if (state.videoWidget.value !== name) return; // a newer selection won
state.sourceFps = Math.max(0, Number(metadata?.fps) || 0);
state.sourceFrameCount = Math.max(0, Number(metadata?.frame_count) || 0);
updateTrimFace(state);
} catch (_error) {
/* no fps, no frame count */
}
}
function writeWidget(widget, value, exact = false) {
const next = exact ? value : roundHundredth(value);
function writeWidget(widget, value) {
const next = roundHundredth(value);
if (Math.abs(Number(widget.value || 0) - next) < 0.0001) return;
widget.value = next;
widget.callback?.(next);
@@ -168,10 +166,10 @@ function writeTrim(state, bounds, seekEdge = null) {
function writeFrameTime(state, timeValue) {
const duration = Number.isFinite(state.video.duration) ? state.video.duration : 0;
const time = singleFrameTime(duration, timeValue, state.sourceFps, state.sourceFrameCount);
const time = singleFrameTime(duration, timeValue);
state.writing = true;
try {
writeWidget(state.startWidget, time, true);
writeWidget(state.startWidget, time);
} finally {
state.writing = false;
}
@@ -184,8 +182,8 @@ function writeFrameTime(state, timeValue) {
function updateTrimFace(state) {
const duration = Number.isFinite(state.video.duration) ? state.video.duration : 0;
if (isSingleFrame(state)) {
const time = singleFrameTime(duration, state.startWidget.value, state.sourceFps, state.sourceFrameCount);
const percent = singleFrameFraction(duration, time, state.sourceFps, state.sourceFrameCount) * 100;
const time = singleFrameTime(duration, state.startWidget.value);
const percent = singleFrameFraction(duration, time) * 100;
state.selection.style.width = "0%";
state.startHandle.style.left = `${percent}%`;
if (document.activeElement !== state.startInput) state.startInput.value = formatTimecode(time);
@@ -260,9 +258,6 @@ function installTrimDrag(state) {
if (initial.duration <= 0) return;
event.preventDefault();
event.stopPropagation();
const gesture = new AbortController();
state.gestureAbort?.abort();
state.gestureAbort = gesture;
if (isSingleFrame(state)) {
// Frame-picker gesture: the strip is one scrub rail. A click already
// sets the frame; a drag keeps refining it until release.
@@ -280,15 +275,15 @@ function installTrimDrag(state) {
const finish = () => {
if (!state.dragging) return;
state.dragging = false;
gesture.abort();
range.removeEventListener("pointermove", move);
try { range.releasePointerCapture(event.pointerId); } catch (_error) { /* already released */ }
// One notification per gesture: the frame changed on pointerdown.
notifyAusbossChange();
};
range.addEventListener("pointermove", move, { signal: gesture.signal });
range.addEventListener("pointerup", finish, { signal: gesture.signal });
range.addEventListener("pointercancel", finish, { signal: gesture.signal });
range.addEventListener("lostpointercapture", finish, { signal: gesture.signal });
range.addEventListener("pointermove", move);
range.addEventListener("pointerup", finish, { once: true });
range.addEventListener("pointercancel", finish, { once: true });
range.addEventListener("lostpointercapture", finish, { once: true });
return;
}
state.dragging = true;
@@ -332,7 +327,7 @@ function installTrimDrag(state) {
const finish = (finishEvent) => {
if (!state.dragging) return;
state.dragging = false;
gesture.abort();
range.removeEventListener("pointermove", move);
try { range.releasePointerCapture(event.pointerId); } catch (_error) { /* already released */ }
if (!moved && initial.zone === "jump") {
const next = infoAt(finishEvent?.clientX ?? downX);
@@ -347,10 +342,10 @@ function installTrimDrag(state) {
// One notification per gesture, on release — never per pointermove.
if (moved || initial.zone === "jump") notifyAusbossChange();
};
range.addEventListener("pointermove", move, { signal: gesture.signal });
range.addEventListener("pointerup", finish, { signal: gesture.signal });
range.addEventListener("pointercancel", finish, { signal: gesture.signal });
range.addEventListener("lostpointercapture", finish, { signal: gesture.signal });
range.addEventListener("pointermove", move);
range.addEventListener("pointerup", finish, { once: true });
range.addEventListener("pointercancel", finish, { once: true });
range.addEventListener("lostpointercapture", finish, { once: true });
});
}
@@ -632,15 +627,6 @@ function buildPreview(node) {
watchTrimWidget(state, startWidget);
watchTrimWidget(state, endWidget);
watchFrameWidget(state, frameWidget);
for (const widget of [state.everyNthWidget, state.maxFramesWidget]) {
if (!widget) continue;
const prior = widget.callback;
widget.callback = function (...args) {
const result = prior?.apply(this, args);
updateTrimFace(state);
return result;
};
}
applyMode(state);
startValue.input.addEventListener("change", () => commitTimeInput(state, "start", startValue.input));
endValue.input.addEventListener("change", () => commitTimeInput(state, "end", endValue.input));
@@ -682,14 +668,7 @@ app.registerExtension({
const state = this.__ausbossLoadVideo;
state?.stopTick?.();
state?.abort?.abort();
state?.gestureAbort?.abort();
state?.video?.pause();
state?.video?.removeAttribute("src");
state?.video?.load();
this.__ausbossLoadVideo = null;
});
chainCallback(nodeType.prototype, "onConnectionsChange", function () {
if (this.__ausbossLoadVideo) updateTrimFace(this.__ausbossLoadVideo);
});
},
});
+5 -13
View File
@@ -72,22 +72,17 @@ export function slideTrimWindow(durationValue, bounds, deltaValue) {
// Single-frame mode: the chosen instant, kept just inside the source so the
// decoder always finds a frame at or after it. An unknown duration passes the
// value through, so a typed time survives until metadata arrives.
export function singleFrameTime(durationValue, timeValue, fpsValue = 0, countValue = 0) {
export function singleFrameTime(durationValue, timeValue) {
const duration = Math.max(0, finiteNumber(durationValue, 0));
const time = Math.max(0, finiteNumber(timeValue, 0));
if (duration <= 0) return time;
const fps = Math.max(0, finiteNumber(fpsValue, 0));
if (fps > 0) {
const count = Math.max(1, Math.floor(finiteNumber(countValue, 0)) || Math.round(duration * fps));
return Math.min(time, (count - 1) / fps);
}
return Math.min(time, Math.max(0, duration - MIN_TRIM_SECONDS));
}
export function singleFrameFraction(durationValue, timeValue, fpsValue = 0, countValue = 0) {
export function singleFrameFraction(durationValue, timeValue) {
const duration = Math.max(0, finiteNumber(durationValue, 0));
if (duration <= 0) return 0;
return clamp(singleFrameTime(duration, timeValue, fpsValue, countValue) / duration, 0, 1);
return clamp(singleFrameTime(duration, timeValue) / duration, 0, 1);
}
// Leaving single-frame mode, the picked frame is IN. An OUT at or before it
@@ -112,7 +107,7 @@ export function formatFps(value) {
// strip - or "" when there is nothing honest to say (unknown fps, an empty
// window). The preview cannot re-render frame drops, so it reports them
// instead, mirroring the backend's own arithmetic (decode_video_range):
// source timestamps inside the trim, one kept in every_nth, capped by
// ceil(window x fps) frames in the trim, one kept in every_nth, capped by
// max_frames; single-frame mode always loads exactly one.
// Mirror of the clip node's snap_frame_count: the largest step*n + 1 not
// above count for a rule like "8n+1"; "free" keeps count.
@@ -144,10 +139,7 @@ export function loadSummary(
if (end <= start) return "";
const nth = Math.max(1, Math.floor(finiteNumber(everyNthValue, 1)));
const cap = Math.max(0, Math.floor(finiteNumber(maxFramesValue, 0)));
// Mirror the backend's inclusive timestamp comparison and epsilon.
const first = Math.max(0, Math.ceil((start - 1e-4) * fps));
const last = Math.floor((end - 1e-4) * fps);
let frames = Math.ceil(Math.max(0, last - first + 1) / nth);
let frames = Math.ceil(Math.ceil((end - start) * fps) / nth);
if (cap > 0) frames = Math.min(frames, cap);
frames = snapFrameCount(frames, frameSnap);
if (frames <= 0) return "";
+1 -2
View File
@@ -191,7 +191,6 @@ function buildPreview(node) {
video.loop = enabled;
loopButton.classList.toggle("active", enabled);
};
state.updateLoopButton = updateLoopButton;
updateLoopButton();
loopButton.addEventListener("click", (event) => {
@@ -255,7 +254,7 @@ app.registerExtension({
queueMicrotask(() => {
const state = buildPreview(this);
suppressCoreVideoPreview(this);
if (state) { state.updateLoopButton?.(); syncSteps(state); }
if (state) syncSteps(state);
});
});
chainCallback(nodeType.prototype, "onExecuted", function (message) {
+8 -12
View File
@@ -1,6 +1,9 @@
// Source-workflow parsing and defaults for importing a rendered video.
// Embedded source settings only apply when reopening that original source;
// applying them to its rendered output would trim and sample it twice.
// What a video dropped ONTO a Load Video 🆎 node restores. An AusBoss-saved
// file carries the workflow that made it; if that workflow holds a Load
// Video node, its trim and sampling values repopulate the target node so the
// clip reloads the way it was loaded before. Pure parsing and sanitizing
// only - the drop wiring lives in js/video_workflow_drop/index.js and
// node:test drives this module directly.
export const LOAD_VIDEO_CLASS = "AUSBOSS_NODES_LoadVideo";
@@ -38,13 +41,6 @@ export function isVideoFileName(name) {
return VIDEO_FILE_PATTERN.test(String(name || ""));
}
export function renderedVideoValues() {
return {
start_seconds: 0, end_seconds: 0, custom_width: 0, custom_height: 0,
every_nth: 1, max_frames: 0, single_frame: false,
};
}
function sanitizedNumber(name, value) {
const range = NUMERIC_RANGES[name];
const number = Number(value);
@@ -69,8 +65,8 @@ function namedValues(raw) {
}
// name -> sanitized value for the first Load Video node in the workflow, or
// null when there is none (or nothing usable on it). Use these settings
// only with the original source, never with a rendered export. A save from before
// null when there is none (or nothing usable on it). "video" is never
// restored: the dropped file itself is the source now. A save from before
// the single_frame widget existed was a trim, so the toggle restores off
// rather than staying whatever the target node happened to have.
export function loadVideoRestoreValues(workflow) {
+6 -12
View File
@@ -54,10 +54,12 @@ export function frameWindow(info, startSeconds, endSeconds) {
return { first, last };
}
// The seconds to store for a first/last frame pair. Prefer the widgets'
// hundredth-second step where it can represent individual frames; faster
// sources need precise timestamps. A window reaching the source's end stores
// 0 so the whole tail always stays in.
// The seconds to store for a first/last frame pair. The frontend rounds
// these FLOAT widgets to hundredths (their step), so the values are chosen
// on that grid: IN is the largest hundredth that still keeps `first`, OUT
// the smallest that still keeps `last` and drops the frame after it. Both
// resolve back to the same frames under the backend's rule, and a window
// that reaches the source's end stores 0 so the whole tail always stays in.
export const SECONDS_GRID = 0.01;
function gridFloor(value) { return Math.round(Math.floor(value / SECONDS_GRID + 1e-9) * SECONDS_GRID * 1e6) / 1e6; }
@@ -68,14 +70,6 @@ export function windowSeconds(info, first, last) {
if (!fps || !count) return { start_seconds: 0, end_seconds: 0 };
const head = clamp(Math.round(finite(first)), 0, count - 1);
const tail = clamp(Math.round(finite(last)), head, count - 1);
// Above 100 fps, adjacent frames share a hundredth. Keep their exact
// boundaries instead of widening a one-frame selection onto that grid.
if (fps > 1 / SECONDS_GRID) {
return {
start_seconds: head / fps,
end_seconds: tail >= count - 1 ? 0 : (tail + 1) / fps,
};
}
return {
start_seconds: head === 0 ? 0 : Math.max(0, gridFloor(head / fps + TRIM_EPSILON)),
end_seconds: tail >= count - 1 ? 0 : gridCeil(tail / fps + TRIM_EPSILON),
+5 -6
View File
@@ -1676,15 +1676,13 @@ async function loadVideoFrame(state, serial = ++state.loadSerial, options = {})
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const image = new Image();
let adopted = false;
try {
await new Promise((resolve, reject) => {
image.onload = resolve; image.onerror = () => reject(new Error("Could not display video frame.")); image.src = objectUrl;
});
if (serial !== state.loadSerial || state.disposed || controller.signal.aborted) return;
if (serial !== state.loadSerial) return;
if (state.frameObjectUrl) URL.revokeObjectURL(state.frameObjectUrl);
state.frameObjectUrl = objectUrl; state.image = image;
adopted = true;
state.scrubPreviewTile = null; // real frame arrived; drop the ghost tile
const actualIndex = Number(response.headers.get("X-AusBoss-Frame-Index"));
const actualTime = Number(response.headers.get("X-AusBoss-Frame-Time"));
@@ -1700,8 +1698,9 @@ async function loadVideoFrame(state, serial = ++state.loadSerial, options = {})
if (Number.isFinite(actualTime)) setValue(state.node, "frame_time", actualTime);
syncTimelineRange(state);
}
} finally {
if (!adopted) URL.revokeObjectURL(objectUrl);
} catch (error) {
URL.revokeObjectURL(objectUrl);
throw error;
}
}
@@ -3158,7 +3157,7 @@ export function openTransformEditorForNode(node) {
}
export function disposeTransformNode(node) {
const state = node.__ausbossTransformState; if (!state) return; state.disposed = true; state.loadSerial += 1; liveStates.delete(state); closeEditor(state); state.settle.cancel(); state.panelAbort?.abort(); state.panelResizeObserver?.disconnect(); state.frameController?.abort(); if (state.frameObjectUrl) URL.revokeObjectURL(state.frameObjectUrl);
const state = node.__ausbossTransformState; if (!state) return; state.disposed = true; liveStates.delete(state); closeEditor(state); state.settle.cancel(); state.panelAbort?.abort(); state.panelResizeObserver?.disconnect(); state.frameController?.abort(); if (state.frameObjectUrl) URL.revokeObjectURL(state.frameObjectUrl);
if (node.__ausbossImgsSuppressed) {
const descriptor = node.__ausbossImgsDescriptor;
if (descriptor) Object.defineProperty(node, "imgs", descriptor); else delete node.imgs;
+18 -7
View File
@@ -5,8 +5,9 @@
//
// Dropping a video ONTO a Load Video 🆎 node is a different gesture with a
// different meaning: the node keeps the canvas and takes the file as its
// source. The file already contains any trim, resizing and sampling, so
// these controls reset for the imported clip. Core routes node-targeted drops through onDragDrop
// source, and an AusBoss-saved file also hands back the trim and sampling
// values its embedded workflow stored - like re-opening the clip the way it
// was loaded before. Core routes node-targeted drops through onDragDrop
// before falling back to the canvas path, which is what keeps the two
// gestures apart.
@@ -15,7 +16,7 @@ import { app } from "/scripts/app.js";
import { notifyAusbossChange, showToast } from "../shared/index.mjs";
import {
isVideoFileName,
renderedVideoValues,
loadVideoRestoreValues,
} from "../shared/load_video_restore.mjs";
import { looksLikeMp4, mp4WorkflowPayload } from "../shared/mp4_workflow.mjs";
@@ -41,10 +42,12 @@ async function uploadToInput(file) {
return subfolder ? `${subfolder}/${name}` : name;
}
function applyDropToLoadVideo(node, uploadedName) {
function applyDropToLoadVideo(node, restored, uploadedName) {
const widget = (name) => node.widgets?.find((item) => item.name === name);
// Reset before the video callback repaints the imported source.
for (const [name, value] of Object.entries(renderedVideoValues())) {
// Restored values land silently first; the video callback below repaints
// the preview once with all of them already in place, so no load-path
// callback ever sees (or wipes) a half-restored trim.
for (const [name, value] of Object.entries(restored ?? {})) {
const target = widget(name);
if (target) target.value = value;
}
@@ -67,8 +70,16 @@ function applyDropToLoadVideo(node, uploadedName) {
// surprise for a drop aimed at one node.
async function handleLoadVideoDrop(node, file) {
try {
let restored = null;
if (isMp4File(file) || /\.mov$/i.test(String(file.name || ""))) {
// mov shares the ISO box layout, so its tags parse the same way.
const bytes = new Uint8Array(await file.arrayBuffer());
if (looksLikeMp4(bytes)) {
restored = loadVideoRestoreValues(mp4WorkflowPayload(bytes).workflow);
}
}
const uploadedName = await uploadToInput(file);
applyDropToLoadVideo(node, uploadedName);
applyDropToLoadVideo(node, restored, uploadedName);
} catch (error) {
showToast({ severity: "error", summary: "Load Video \u{1F18E}", detail: `The dropped video could not be loaded: ${error?.message || error}` });
}
+9 -29
View File
@@ -438,7 +438,7 @@ def _start_reaper_locked() -> None:
now = time.monotonic()
with _SESSIONS_LOCK:
for key in [k for k, s in _SESSIONS.items() if now - s.last_used > _SESSION_IDLE_SECONDS]:
_evict_session_locked(key)
_SESSIONS.pop(key).close()
threading.Thread(target=reap, daemon=True, name="ausboss-scrub-session-reaper").start()
@@ -447,48 +447,31 @@ def close_scrub_sessions() -> None:
"""Close every open scrub session (releases file handles immediately)."""
with _SESSIONS_LOCK:
for key in list(_SESSIONS):
_evict_session_locked(key, wait=True)
_SESSIONS.pop(key).close()
def _evict_session_locked(key, *, wait=False) -> bool:
"""Called under the table lock; never close a decoder in use."""
session = _SESSIONS[key]
if not session.lock.acquire(blocking=wait):
return False
try:
_SESSIONS.pop(key).close()
return True
finally:
session.lock.release()
def _get_session(path: Path, *, acquire=False) -> "_ScrubSession":
def _get_session(path: Path) -> "_ScrubSession":
key = _file_key(path)
with _SESSIONS_LOCK:
_start_reaper_locked()
session = _SESSIONS.get(key)
if session is None:
for stale in [other for other in _SESSIONS if other[0] == key[0] and other != key]:
_evict_session_locked(stale)
for oldest in sorted(_SESSIONS, key=lambda item: _SESSIONS[item].last_used):
if len(_SESSIONS) < _SESSION_LIMIT:
break
_evict_session_locked(oldest)
_SESSIONS.pop(stale).close()
if len(_SESSIONS) >= _SESSION_LIMIT:
oldest = min(_SESSIONS, key=lambda item: _SESSIONS[item].last_used)
_SESSIONS.pop(oldest).close()
session = _ScrubSession(path)
_SESSIONS[key] = session
session.last_used = time.monotonic()
if acquire:
# Acquire before releasing the table lock: eviction cannot slip
# between looking up a session and starting its decode.
session.lock.acquire()
return session
def _session_decode(path: Path, target_index: int, requested_time: float, fps: float):
"""Decode via the persistent session. Returns None when the file lacks
usable timestamps so the caller can use the stateless fallbacks."""
session = _get_session(path, acquire=True)
try:
session = _get_session(path)
with session.lock:
stream = session.stream
if session.last_index == target_index and session.last_frame is not None:
index, time_value = _frame_position(session.last_frame, stream, fps, target_index)
@@ -528,9 +511,6 @@ def _session_decode(path: Path, target_index: int, requested_time: float, fps: f
return None
index, time_value = _frame_position(last, stream, fps, target_index)
return last.to_image().convert("RGBA"), index, time_value
finally:
session.last_used = time.monotonic()
session.lock.release()
def seek_target(metadata: dict, seek_mode: str, frame_index: int, frame_time: float) -> tuple[int, float]:
+2 -9
View File
@@ -400,14 +400,7 @@ def decode_audio_range(path: Path, start_seconds: float, end_seconds: float) ->
resampler = av.AudioResampler(format="fltp", layout=stream.layout, rate=rate)
chunks: list[np.ndarray] = []
first_time: float | None = None
raise_if_interrupted()
if start > 0 and stream.time_base:
# Audio packet boundaries need a little preroll. Seek on the
# audio stream, but translate from the video's shared clock.
target = max(0.0, start - 1.0) + stream_origin(clock)
container.seek(int(target / stream.time_base), stream=stream, backward=True)
for frame in container.decode(stream):
raise_if_interrupted()
time = stream_seconds(frame.time, clock)
if time is None:
time = 0.0
@@ -431,8 +424,8 @@ def decode_audio_range(path: Path, start_seconds: float, end_seconds: float) ->
begin = max(0, -lead)
length = max(1, round((end - start) * rate))
data = data[:, begin : begin + length]
if data.shape[1] < length:
data = np.pad(data, ((0, 0), (0, length - data.shape[1])))
if data.size == 0:
return silent_audio(end - start)
return {
"waveform": torch.from_numpy(np.ascontiguousarray(data)).unsqueeze(0),
"sample_rate": rate,
+3 -30
View File
@@ -4,8 +4,6 @@ from __future__ import annotations
import json
import math
import os
import tempfile
from fractions import Fraction
from pathlib import Path
from typing import NamedTuple
@@ -251,19 +249,10 @@ def _encode_animation(
"""
from PIL import Image
# A gif/webp frame duration is whole milliseconds, so the playback rate is
# quantized here whatever fps asked for; 1 ms is the floor players honour.
duration_ms = max(1, int(round(1000.0 / float(fps))))
frame_count = int(batch.shape[0])
if extension == "gif":
# GIF stores centiseconds. Round cumulative boundaries so rounding
# each frame cannot shorten the whole animation.
if fps > 100:
raise ValueError("GIF supports at most 100 fps; choose WebP or a video format.")
ticks = 100.0 / float(fps)
duration_ms = [
max(1, round((index + 1) * ticks) - round(index * ticks)) * 10
for index in range(frame_count)
]
else:
duration_ms = max(1, int(round(1000.0 / float(fps))))
progress = frame_progress(frame_count)
images = []
for encoded, frame in enumerate(batch, start=1):
@@ -300,22 +289,6 @@ def encode_video(
video_format: str = "mp4 h264",
) -> tuple[int, int, int]:
"""Write the encoded file; returns (width, height, frame_count)."""
# Preserve the extension for PyAV/Pillow format detection. Publish only
# after the encoder closes successfully; failed runs leave no partial clip.
path = Path(path)
fd, temporary = tempfile.mkstemp(prefix=f".{path.stem}-", suffix=path.suffix, dir=path.parent)
os.close(fd)
staging = Path(temporary)
try:
result = _encode_video(staging, frames, fps, audio, crf, metadata, video_format)
raise_if_interrupted()
os.replace(staging, path)
return result
finally:
staging.unlink(missing_ok=True)
def _encode_video(path, frames, fps, audio, crf, metadata, video_format):
if video_format not in VIDEO_FORMATS:
raise ValueError(
f"Save Video format must be one of {tuple(VIDEO_FORMATS)}, "
+13 -28
View File
@@ -244,37 +244,22 @@ class AusBossSaveVideo:
)
)
extension = VIDEO_FORMATS[format][0] if format in VIDEO_FORMATS else "mp4"
file = f"{filename}_{counter:05}_.{extension}"
output_path = Path(full_output_folder) / file
require_output_path(output_path, output_root)
# --disable-metadata is the server owner's call and beats the widget,
# as it does for Save Image and core's own Save Video.
embed = save_metadata and not metadata_disabled()
def save_reserved():
# Exclusive creation coordinates concurrent nodes and processes.
# Keep reservation and cleanup on the encoder's worker so even
# cancellation of the awaiting coroutine cannot strand a name.
next_counter = counter
while True:
file = f"{filename}_{next_counter:05}_.{extension}"
output_path = Path(full_output_folder) / file
require_output_path(output_path, output_root)
try:
with output_path.open("xb"):
pass
break
except FileExistsError:
next_counter += 1
try:
dimensions = encode_video(
output_path, frames, float(fps), audio, int(crf),
workflow_metadata(prompt, extra_pnginfo) if embed else None,
str(format),
)
return file, output_path, dimensions
except BaseException:
output_path.unlink(missing_ok=True)
raise
file, output_path, (width, height, frame_count) = await asyncio.to_thread(save_reserved)
width, height, frame_count = await asyncio.to_thread(
encode_video,
output_path,
frames,
float(fps),
audio,
int(crf),
workflow_metadata(prompt, extra_pnginfo) if embed else None,
str(format),
)
return {
"ui": {
"images": [{
-15
View File
@@ -209,18 +209,3 @@ test("loadSummary reports the snapped count", () => {
assert.equal(loadSummary(10, { start: 0, end: 3 }, 30, 1, 0, false, "8n+1"), "89 frames @ 30 fps");
assert.equal(loadSummary(10, { start: 0, end: 3 }, 30, 1, 0, false, "free"), "90 frames @ 30 fps");
});
test("FRAME picking reaches the final source frame", () => {
for (const fps of [24, 30, 60, 240]) {
const last = (2 * fps - 1) / fps;
assert.equal(singleFrameTime(2, 2, fps), last);
assert.equal(singleFrameFraction(2, 2, fps), last / 2);
}
});
test("summary counts source timestamps inside an offset window", () => {
assert.equal(loadSummary(2, {start: 0.01, end: 0.1}, 12), "1 frame");
assert.equal(loadSummary(2, {start: 0.01, end: 0.2}, 12), "2 frames @ 12 fps");
assert.equal(loadSummary(2, {start: 0.01, end: 0.2}, 12, 2), "1 frame");
});
-167
View File
@@ -1,167 +0,0 @@
"""Concurrency, cancellation and timing regressions using CPU media only."""
import asyncio
from fractions import Fraction
from pathlib import Path
import sys
import tempfile
import threading
import types
import unittest
from unittest.mock import patch
import av
import numpy as np
import torch
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from nodes import _media_helpers as media
from nodes import _video_load_helpers as load
from nodes import _video_save_helpers as save
from nodes import node_save_video as saver
class Interrupted(BaseException):
pass
class VideoRegressions(unittest.TestCase):
def test_concurrent_saves_reserve_distinct_destinations(self):
with tempfile.TemporaryDirectory() as tmp:
folders = types.SimpleNamespace(
get_output_directory=lambda: tmp,
get_save_image_path=lambda *args: (tmp, "video", 1, "", "video"),
)
barrier = threading.Barrier(2)
destinations = []
def encode(path, *args):
destinations.append(path)
barrier.wait(timeout=5)
path.write_bytes(b"complete")
return 16, 16, 1
async def run():
return await asyncio.gather(*[
saver.AusBossSaveVideo().save(12, "video", 19, frames=torch.zeros(1, 16, 16, 3))
for _ in range(2)
])
with patch.object(saver, "folder_paths", folders), patch.object(saver, "encode_video", encode):
results = asyncio.run(run())
self.assertEqual(len(set(destinations)), 2)
self.assertEqual({Path(r["result"][0]) for r in results}, set(destinations))
def test_failed_save_removes_its_reservation(self):
with tempfile.TemporaryDirectory() as tmp:
folders = types.SimpleNamespace(
get_output_directory=lambda: tmp,
get_save_image_path=lambda *args: (tmp, "video", 1, "", "video"),
)
with patch.object(saver, "folder_paths", folders), patch.object(saver, "encode_video", side_effect=Interrupted):
with self.assertRaises(Interrupted):
asyncio.run(saver.AusBossSaveVideo().save(12, "video", 19, frames=torch.zeros(1, 16, 16, 3)))
self.assertEqual(list(Path(tmp).iterdir()), [])
def test_eviction_does_not_close_a_busy_decoder(self):
class Session:
def __init__(self, path):
self.lock = threading.Lock()
self.last_used = 0
self.closed = False
def close(self):
self.closed = True
with tempfile.TemporaryDirectory() as tmp:
paths = [Path(tmp) / str(i) for i in range(4)]
for path in paths:
path.touch()
busy = Session(paths[0])
busy.lock.acquire()
sessions = {media._file_key(paths[0]): busy}
with patch.object(media, "_SESSIONS", sessions), patch.object(media, "_ScrubSession", Session), patch.object(media, "_start_reaper_locked"):
for path in paths[1:]:
media._get_session(path)
self.assertFalse(busy.closed)
busy.lock.release()
def test_interrupted_encoding_publishes_nothing(self):
calls = 0
def interrupt():
nonlocal calls
calls += 1
if calls == 4:
raise Interrupted
with tempfile.TemporaryDirectory() as tmp, patch.object(save, "raise_if_interrupted", interrupt):
path = Path(tmp) / "clip.mkv"
with self.assertRaises(Interrupted):
save.encode_video(path, torch.zeros(10, 16, 16, 3), 12, None, 19, video_format="mkv ffv1")
self.assertEqual(list(Path(tmp).iterdir()), [])
def test_gif_distributes_centisecond_rounding_error(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "clip.gif"
frames = torch.linspace(0, 1, 24).view(24, 1, 1, 1).expand(24, 16, 16, 3)
save.encode_video(path, frames, 24, None, 19, video_format="gif")
durations = []
with Image.open(path) as image:
for index in range(image.n_frames):
image.seek(index)
durations.append(image.info.get("duration", 0))
self.assertEqual(sum(durations), 1000)
self.assertTrue(all(duration > 0 and duration % 10 == 0 for duration in durations))
def test_gif_rejects_rates_above_its_timing_grid(self):
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaisesRegex(ValueError, "100 fps"):
save.encode_video(Path(tmp) / "clip.gif", torch.zeros(2, 16, 16, 3), 120, None, 19, video_format="gif")
def test_audio_tail_is_padded(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "audio.mkv"
with av.open(str(path), "w") as container:
stream = container.add_stream("pcm_s16le", rate=8000)
stream.layout = "mono"
frame = av.AudioFrame.from_ndarray(np.full((1, 4000), 1000, np.int16), format="s16", layout="mono")
frame.sample_rate = 8000
frame.pts = 0
for packet in stream.encode(frame):
container.mux(packet)
for packet in stream.encode():
container.mux(packet)
audio = load.decode_audio_range(path, 0, 2)
self.assertEqual(audio["waveform"].shape[-1], 2 * audio["sample_rate"])
self.assertEqual(torch.count_nonzero(audio["waveform"][..., 4000:]).item(), 0)
def test_audio_checks_cancellation_during_decode(self):
stream = types.SimpleNamespace(
type="audio", rate=8000, layout="mono", start_time=0, time_base=Fraction(1, 8000),
)
frame = av.AudioFrame.from_ndarray(np.zeros((1, 4000), np.int16), format="s16", layout="mono")
frame.sample_rate = 8000
frame.pts = 0
frame.time_base = Fraction(1, 8000)
container = types.SimpleNamespace(streams=[stream], decode=lambda _: iter([frame]))
with patch.object(load.av, "open") as opened, patch.object(load, "raise_if_interrupted", side_effect=[None, Interrupted]) as interrupt:
opened.return_value.__enter__.return_value = container
with self.assertRaises(Interrupted):
load.decode_audio_range(Path("clip.mkv"), 0, 2)
self.assertEqual(interrupt.call_count, 2)
def test_audio_seeks_near_a_late_window_on_the_video_clock(self):
video = types.SimpleNamespace(type="video", start_time=5000, time_base=Fraction(1, 1000))
audio = types.SimpleNamespace(type="audio", rate=8000, layout="mono", time_base=Fraction(1, 8000))
with patch.object(load.av, "open") as opened:
container = opened.return_value.__enter__.return_value
container.streams = [video, audio]
container.decode.return_value = iter([])
load.decode_audio_range(Path("clip.mkv"), 30, 32)
container.seek.assert_called_once_with(34 * 8000, stream=audio, backward=True)
if __name__ == "__main__":
unittest.main()
+3 -10
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import asyncio
import inspect
import os
import tempfile
import threading
import time
@@ -113,17 +112,13 @@ class FakeCoreVideo:
class FakeFolderPaths:
temporary = tempfile.TemporaryDirectory()
last_output = None
@staticmethod
def get_output_directory():
return FakeFolderPaths.temporary.name
return "/tmp"
@staticmethod
def get_save_image_path(*_args):
FakeFolderPaths.last_output = tempfile.mkdtemp(dir=FakeFolderPaths.temporary.name)
return FakeFolderPaths.last_output, "video", 3, "AusBoss", "AusBoss/video"
return "/tmp", "video", 3, "AusBoss", "AusBoss/video"
class SaveVideoHelperTests(unittest.TestCase):
@@ -302,7 +297,7 @@ class SaveVideoHelperTests(unittest.TestCase):
filename_prefix="AusBoss/video", crf=19,
))
# The path the encoder wrote to, verbatim, as the node's one output.
self.assertEqual(result["result"], (str(Path(FakeFolderPaths.last_output) / "video_00003_.mp4"),))
self.assertEqual(result["result"], ("/tmp/video_00003_.mp4",))
self.assertTrue(Path(result["result"][0]).is_absolute())
def test_symlinked_prefix_is_refused_before_core_creates_folders(self):
@@ -385,8 +380,6 @@ class FormatTests(unittest.TestCase):
CLIP = (8, 128, 160)
def encodable(self, spec) -> bool:
if "nvenc" in spec.video_codec and os.environ.get("CUDA_VISIBLE_DEVICES") == "":
return False
if not spec.video_codec:
return True
try:
-12
View File
@@ -285,15 +285,3 @@ test("the grip slides the kept part as far as the clip allows", () => {
assert.deepEqual(slideRange(long, free), { min: 0, max: 287 - 48 });
assert.equal(lengthFillsClip(long, free), false);
});
test("individual frames survive timestamp storage at high source rates", () => {
for (const fps of [100, 120, 240, 1000]) {
const source = { fps, frame_count: fps * 2, duration: 2 };
const info = clipInfo(source);
for (let frame = 0; frame < source.frame_count; frame += 1) {
const seconds = windowSeconds(info, frame, frame);
assert.deepEqual(backendWindow(source, seconds.start_seconds, seconds.end_seconds), {first: frame, last: frame}, `${fps} fps, frame ${frame}`);
}
}
});
-205
View File
@@ -1,205 +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 trim from "../js/load_video/trim_preview.mjs";
import * as videoMath from "../js/shared/video_preview.mjs";
import * as timecode from "../js/shared/timecode.mjs";
import * as restore from "../js/shared/load_video_restore.mjs";
function source(path) {
return readFileSync(new URL(`../${path}`, import.meta.url), "utf8")
.replace(/^import\s[\s\S]*?;\s*$/gm, "").replace(/^export /gm, "");
}
function functionSource(path, name) {
const text = source(path);
const start = text.search(new RegExp(`(?:async )?function ${name}\\(`));
assert.ok(start >= 0, name);
const rest = text.slice(start);
const next = rest.slice(1).search(/\n(?:async )?function /);
return next < 0 ? rest : rest.slice(0, next + 1);
}
class Element extends EventTarget {
constructor(tag = "div") {
super();
this.tagName = tag.toUpperCase(); this.children = []; this.style = {}; this.attrs = {};
this.duration = 10; this.readyState = 1; this.currentTime = 0; this.paused = true;
this.classList = {
values: new Set(), add(value) { this.values.add(value); }, remove(value) { this.values.delete(value); },
toggle(value, enabled) { if (enabled) this.values.add(value); else this.values.delete(value); },
};
}
append(...items) { this.children.push(...items); }
insertBefore(item) { this.children.push(item); }
setAttribute(name, value) { this.attrs[name] = value; }
getAttribute(name) { return this.attrs[name]; }
removeAttribute(name) { delete this.attrs[name]; }
load() { this.loadCalls = (this.loadCalls ?? 0) + 1; }
pause() { this.paused = true; this.pauseCalls = (this.pauseCalls ?? 0) + 1; }
getBoundingClientRect() { return {left: 0, width: 100}; }
setPointerCapture() {}
releasePointerCapture() { this.dispatchEvent(new Event("lostpointercapture")); }
}
function pointer(type, x, buttons = 1) {
const event = new Event(type);
Object.assign(event, {button: 0, buttons, clientX: x, pointerId: 1});
return event;
}
for (const single of [false, true]) {
test(`cancelled ${single ? "FRAME" : "trim"} gesture removes every listener`, () => {
const rail = new Element();
const state = {range: rail, video: {duration: 10}, start: 2, end: 8, node: {}};
const context = vm.createContext({
...trim, AbortController, state, notifyAusbossChange() {},
isSingleFrame: () => single, currentBounds: (s) => ({start: s.start, end: s.end}),
writeTrim(s, bounds) { s.start = bounds.start; s.end = bounds.end; },
writeFrameTime(s, time) { s.start = time; },
});
vm.runInContext(functionSource("js/load_video/index.js", "installTrimDrag"), context);
vm.runInContext("installTrimDrag(state)", context);
rail.dispatchEvent(pointer("pointerdown", 20));
rail.dispatchEvent(pointer("pointermove", 25));
rail.dispatchEvent(pointer("pointerup", 25, 0));
rail.dispatchEvent(pointer("pointerdown", 25));
rail.dispatchEvent(pointer("pointermove", 30));
rail.dispatchEvent(pointer("pointercancel", 30, 0));
const cancelled = state.start;
rail.dispatchEvent(pointer("pointermove", 40));
assert.equal(state.start, cancelled);
assert.equal(state.dragging, false);
});
}
for (const reason of ["superseded", "disposed", "failed", "adopted"]) {
test(`preview object URL ownership after ${reason} image decode`, async () => {
const images = [], revoked = [];
const state = {node: {}, loadSerial: 1};
const context = vm.createContext({
state, AbortController, videoParams: () => "", syncTimelineRange() {}, setValue() {},
URL: {createObjectURL: () => "blob:frame", revokeObjectURL: (url) => revoked.push(url)},
Image: class { constructor() { images.push(this); } },
api: {fetchApi: async () => ({ok: true, blob: async () => ({}), headers: {get: () => "0"}})},
});
vm.runInContext(functionSource("js/shared/transform_editor.mjs", "loadVideoFrame"), context);
const pending = vm.runInContext("loadVideoFrame(state, 1, {syncWidgets: false})", context);
await new Promise(setImmediate);
if (reason === "superseded") state.loadSerial += 1;
if (reason === "disposed") { state.disposed = true; state.frameController.abort(); }
if (reason === "failed") {
images[0].onerror();
await assert.rejects(pending, /Could not display/);
} else {
images[0].onload();
await pending;
}
assert.deepEqual(revoked, reason === "adopted" ? [] : ["blob:frame"]);
assert.equal(state.frameObjectUrl, reason === "adopted" ? "blob:frame" : undefined);
});
}
function previewContext(path) {
const extensions = [];
const context = vm.createContext({
...trim, ...videoMath, ...timecode, AbortController, Event, URLSearchParams,
document: {createElement: (tag) => new Element(tag), activeElement: null},
app: {registerExtension: (extension) => extensions.push(extension)}, api: {apiURL: (s) => s},
chainCallback(object, name, callback) {
const prior = object[name];
object[name] = function (...args) { prior?.apply(this, args); callback.apply(this, args); };
},
ensureVideoCss() {}, suppressCoreVideoPreview() {}, hideCanvasWidget() {},
keepDomWidgetWidthAuto() {}, fillNodeHeight() {}, VIDEO_MIN_WIDTH: 220, WIDGET_FRAME: 20,
makeToolButton: () => new Element("button"), mountStepOverlay: () => ({root: new Element(), hide() {}}),
stepRun: () => ({status: "idle"}), addStepConsumer: () => () => {}, watchSteps: () => () => {},
notifyAusbossChange() {}, installNameTokens() {}, keepKeyInField() {},
setTimeout() {}, queueMicrotask: (callback) => callback(), cancelAnimationFrame() {}, requestAnimationFrame() { return 1; },
});
vm.runInContext(source(path), context);
return {context, extension: extensions[0]};
}
function makeNode() {
return {
widgets: [
{name: "video", value: ""}, {name: "start_seconds", value: 0}, {name: "end_seconds", value: 0},
{name: "single_frame", value: false}, {name: "every_nth", value: 1}, {name: "max_frames", value: 0},
{name: "step_preview", value: false},
],
properties: {}, size: [380, 450], setSize(size) { this.size = size; }, setDirtyCanvas() {},
addDOMWidget(name, type, element) { const widget = {name, type, element}; this.widgets.push(widget); return widget; },
};
}
test("Save Video configuration restores LOOP off on the existing player", () => {
const {context, extension} = previewContext("js/save_video/index.js");
function Node() {}
extension.beforeRegisterNodeDef(Node, {name: "AUSBOSS_NODES_SaveVideo"});
const node = Object.assign(new Node(), makeNode());
node.onNodeCreated();
node.properties.ausboss_save_video_loop = false;
node.onConfigure();
const state = node.__ausbossSaveVideo;
assert.equal(state.video.loop, false);
assert.equal(state.loopButton.classList.values.has("active"), false);
});
test("Load Video summary follows sampling widgets and linked inputs", () => {
const {context, extension} = previewContext("js/load_video/index.js");
function Node() {}
extension.beforeRegisterNodeDef(Node, {name: "AUSBOSS_NODES_LoadVideo"});
const node = Object.assign(new Node(), makeNode());
node.onNodeCreated();
const state = node.__ausbossLoadVideo;
state.sourceFps = 24;
state.video.dispatchEvent(new Event("loadedmetadata"));
assert.match(state.durationLabel.textContent, /240 frames @ 24 fps/);
state.everyNthWidget.value = 2; state.everyNthWidget.callback?.(2);
assert.match(state.durationLabel.textContent, /120 frames @ 12 fps/);
state.maxFramesWidget.value = 30; state.maxFramesWidget.callback?.(30);
assert.match(state.durationLabel.textContent, /30 frames @ 12 fps/);
node.inputs = [{widget: {name: "every_nth"}, link: 1}]; node.onConnectionsChange?.();
assert.doesNotMatch(state.durationLabel.textContent, /frames/);
});
test("removing Load Video pauses and unloads the player", () => {
const {extension} = previewContext("js/load_video/index.js");
function Node() {}
extension.beforeRegisterNodeDef(Node, {name: "AUSBOSS_NODES_LoadVideo"});
const node = Object.assign(new Node(), makeNode()); node.onNodeCreated();
const video = node.__ausbossLoadVideo.video;
video.paused = false; video.setAttribute("src", "clip.mp4");
node.onRemoved();
assert.equal(video.paused, true);
assert.equal(video.getAttribute("src"), undefined);
assert.equal(video.loadCalls, 1);
});
test("dropping a rendered clip resets trim, resize and sampling before loading it", async () => {
const node = makeNode();
node.widgets.push({name: "custom_width", value: 640}, {name: "custom_height", value: 480});
for (const widget of node.widgets) if (widget.name !== "video") widget.value = 5;
const video = node.widgets.find((widget) => widget.name === "video");
video.callback = () => {
for (const [name, value] of Object.entries({
start_seconds: 0, end_seconds: 0, custom_width: 0, custom_height: 0,
every_nth: 1, max_frames: 0, single_frame: false,
})) {
assert.equal(node.widgets.find((widget) => widget.name === name).value, value);
}
};
const errors = [];
const context = vm.createContext({
...restore, node, notifyAusbossChange() {}, showToast: (error) => errors.push(error),
uploadToInput: async () => "trimmed.mp4", isMp4File: () => true, looksLikeMp4: () => true,
mp4WorkflowPayload: () => ({workflow: {nodes: [{type: "AUSBOSS_NODES_LoadVideo", widgets_values: ["source.mp4", 5, 8, 640, 480, 2, 10, true]}]}}),
});
vm.runInContext(functionSource("js/video_workflow_drop/index.js", "applyDropToLoadVideo"), context);
vm.runInContext(functionSource("js/video_workflow_drop/index.js", "handleLoadVideoDrop"), context);
await vm.runInContext('handleLoadVideoDrop(node, {name: "trimmed.mp4", arrayBuffer: async () => new ArrayBuffer(0)})', context);
assert.deepEqual(errors, []);
assert.equal(video.value, "trimmed.mp4");
});