Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04690e9689 |
@@ -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, so Krea 2 knows where to paint. The plain crop goes along too."
|
||||
"text": "The painted area, grown a little, is filled with flat gray. Krea 2 never sees what you are replacing."
|
||||
},
|
||||
"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. Painting starts from the plain crop, inside the noise mask: the painted area, widened by 24 px."
|
||||
"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."
|
||||
},
|
||||
"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. Tone match is fully on, so the new area is adjusted to your picture's tone."
|
||||
"Blend in fades the model's picture into yours just inside its edge, with no tone shift. The new area stays as painted."
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"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."
|
||||
"text": "Blend in fades the model's picture into yours just inside its edge, with no tone shift. The new area stays as painted."
|
||||
},
|
||||
"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? 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\"}]}"
|
||||
"{\"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\"}]}"
|
||||
],
|
||||
"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? 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\"}]}"
|
||||
"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\"}]}"
|
||||
},
|
||||
"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- 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\"}]}"
|
||||
"{\"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\"}]}"
|
||||
],
|
||||
"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- 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\"}]}"
|
||||
"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\"}]}"
|
||||
},
|
||||
"color": "#14424d",
|
||||
"bgcolor": "#161f21"
|
||||
|
||||
+38
-17
@@ -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)
|
||||
? singleFrameTime(state.video.duration, state.startWidget.value, state.sourceFps, state.sourceFrameCount)
|
||||
: currentBounds(state).start;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ 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}`);
|
||||
@@ -133,14 +134,15 @@ 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) {
|
||||
const next = roundHundredth(value);
|
||||
function writeWidget(widget, value, exact = false) {
|
||||
const next = exact ? value : roundHundredth(value);
|
||||
if (Math.abs(Number(widget.value || 0) - next) < 0.0001) return;
|
||||
widget.value = next;
|
||||
widget.callback?.(next);
|
||||
@@ -166,10 +168,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);
|
||||
const time = singleFrameTime(duration, timeValue, state.sourceFps, state.sourceFrameCount);
|
||||
state.writing = true;
|
||||
try {
|
||||
writeWidget(state.startWidget, time);
|
||||
writeWidget(state.startWidget, time, true);
|
||||
} finally {
|
||||
state.writing = false;
|
||||
}
|
||||
@@ -182,8 +184,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);
|
||||
const percent = singleFrameFraction(duration, time) * 100;
|
||||
const time = singleFrameTime(duration, state.startWidget.value, state.sourceFps, state.sourceFrameCount);
|
||||
const percent = singleFrameFraction(duration, time, state.sourceFps, state.sourceFrameCount) * 100;
|
||||
state.selection.style.width = "0%";
|
||||
state.startHandle.style.left = `${percent}%`;
|
||||
if (document.activeElement !== state.startInput) state.startInput.value = formatTimecode(time);
|
||||
@@ -258,6 +260,9 @@ 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.
|
||||
@@ -275,15 +280,15 @@ function installTrimDrag(state) {
|
||||
const finish = () => {
|
||||
if (!state.dragging) return;
|
||||
state.dragging = false;
|
||||
range.removeEventListener("pointermove", move);
|
||||
gesture.abort();
|
||||
try { range.releasePointerCapture(event.pointerId); } catch (_error) { /* already released */ }
|
||||
// One notification per gesture: the frame changed on pointerdown.
|
||||
notifyAusbossChange();
|
||||
};
|
||||
range.addEventListener("pointermove", move);
|
||||
range.addEventListener("pointerup", finish, { once: true });
|
||||
range.addEventListener("pointercancel", finish, { once: true });
|
||||
range.addEventListener("lostpointercapture", finish, { once: true });
|
||||
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 });
|
||||
return;
|
||||
}
|
||||
state.dragging = true;
|
||||
@@ -327,7 +332,7 @@ function installTrimDrag(state) {
|
||||
const finish = (finishEvent) => {
|
||||
if (!state.dragging) return;
|
||||
state.dragging = false;
|
||||
range.removeEventListener("pointermove", move);
|
||||
gesture.abort();
|
||||
try { range.releasePointerCapture(event.pointerId); } catch (_error) { /* already released */ }
|
||||
if (!moved && initial.zone === "jump") {
|
||||
const next = infoAt(finishEvent?.clientX ?? downX);
|
||||
@@ -342,10 +347,10 @@ function installTrimDrag(state) {
|
||||
// One notification per gesture, on release — never per pointermove.
|
||||
if (moved || initial.zone === "jump") notifyAusbossChange();
|
||||
};
|
||||
range.addEventListener("pointermove", move);
|
||||
range.addEventListener("pointerup", finish, { once: true });
|
||||
range.addEventListener("pointercancel", finish, { once: true });
|
||||
range.addEventListener("lostpointercapture", finish, { once: true });
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -627,6 +632,15 @@ 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));
|
||||
@@ -668,7 +682,14 @@ 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);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -72,17 +72,22 @@ 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) {
|
||||
export function singleFrameTime(durationValue, timeValue, fpsValue = 0, countValue = 0) {
|
||||
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) {
|
||||
export function singleFrameFraction(durationValue, timeValue, fpsValue = 0, countValue = 0) {
|
||||
const duration = Math.max(0, finiteNumber(durationValue, 0));
|
||||
if (duration <= 0) return 0;
|
||||
return clamp(singleFrameTime(duration, timeValue) / duration, 0, 1);
|
||||
return clamp(singleFrameTime(duration, timeValue, fpsValue, countValue) / duration, 0, 1);
|
||||
}
|
||||
|
||||
// Leaving single-frame mode, the picked frame is IN. An OUT at or before it
|
||||
@@ -107,7 +112,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):
|
||||
// ceil(window x fps) frames in the trim, one kept in every_nth, capped by
|
||||
// source timestamps inside 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.
|
||||
@@ -139,7 +144,10 @@ 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)));
|
||||
let frames = Math.ceil(Math.ceil((end - start) * fps) / nth);
|
||||
// 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);
|
||||
if (cap > 0) frames = Math.min(frames, cap);
|
||||
frames = snapFrameCount(frames, frameSnap);
|
||||
if (frames <= 0) return "";
|
||||
|
||||
@@ -191,6 +191,7 @@ function buildPreview(node) {
|
||||
video.loop = enabled;
|
||||
loopButton.classList.toggle("active", enabled);
|
||||
};
|
||||
state.updateLoopButton = updateLoopButton;
|
||||
updateLoopButton();
|
||||
|
||||
loopButton.addEventListener("click", (event) => {
|
||||
@@ -254,7 +255,7 @@ app.registerExtension({
|
||||
queueMicrotask(() => {
|
||||
const state = buildPreview(this);
|
||||
suppressCoreVideoPreview(this);
|
||||
if (state) syncSteps(state);
|
||||
if (state) { state.updateLoopButton?.(); syncSteps(state); }
|
||||
});
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onExecuted", function (message) {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// 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.
|
||||
// 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.
|
||||
|
||||
export const LOAD_VIDEO_CLASS = "AUSBOSS_NODES_LoadVideo";
|
||||
|
||||
@@ -41,6 +38,13 @@ 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);
|
||||
@@ -65,8 +69,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). "video" is never
|
||||
// restored: the dropped file itself is the source now. A save from before
|
||||
// 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
|
||||
// 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) {
|
||||
|
||||
@@ -54,12 +54,10 @@ export function frameWindow(info, startSeconds, endSeconds) {
|
||||
return { first, last };
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
export const SECONDS_GRID = 0.01;
|
||||
|
||||
function gridFloor(value) { return Math.round(Math.floor(value / SECONDS_GRID + 1e-9) * SECONDS_GRID * 1e6) / 1e6; }
|
||||
@@ -70,6 +68,14 @@ 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),
|
||||
|
||||
@@ -1676,13 +1676,15 @@ 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) return;
|
||||
if (serial !== state.loadSerial || state.disposed || controller.signal.aborted) 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"));
|
||||
@@ -1698,9 +1700,8 @@ async function loadVideoFrame(state, serial = ++state.loadSerial, options = {})
|
||||
if (Number.isFinite(actualTime)) setValue(state.node, "frame_time", actualTime);
|
||||
syncTimelineRange(state);
|
||||
}
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
throw error;
|
||||
} finally {
|
||||
if (!adopted) URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3157,7 +3158,7 @@ export function openTransformEditorForNode(node) {
|
||||
}
|
||||
|
||||
export function disposeTransformNode(node) {
|
||||
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);
|
||||
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);
|
||||
if (node.__ausbossImgsSuppressed) {
|
||||
const descriptor = node.__ausbossImgsDescriptor;
|
||||
if (descriptor) Object.defineProperty(node, "imgs", descriptor); else delete node.imgs;
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
//
|
||||
// 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, 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
|
||||
// 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
|
||||
// before falling back to the canvas path, which is what keeps the two
|
||||
// gestures apart.
|
||||
|
||||
@@ -16,7 +15,7 @@ import { app } from "/scripts/app.js";
|
||||
import { notifyAusbossChange, showToast } from "../shared/index.mjs";
|
||||
import {
|
||||
isVideoFileName,
|
||||
loadVideoRestoreValues,
|
||||
renderedVideoValues,
|
||||
} from "../shared/load_video_restore.mjs";
|
||||
import { looksLikeMp4, mp4WorkflowPayload } from "../shared/mp4_workflow.mjs";
|
||||
|
||||
@@ -42,12 +41,10 @@ async function uploadToInput(file) {
|
||||
return subfolder ? `${subfolder}/${name}` : name;
|
||||
}
|
||||
|
||||
function applyDropToLoadVideo(node, restored, uploadedName) {
|
||||
function applyDropToLoadVideo(node, uploadedName) {
|
||||
const widget = (name) => node.widgets?.find((item) => item.name === name);
|
||||
// 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 ?? {})) {
|
||||
// Reset before the video callback repaints the imported source.
|
||||
for (const [name, value] of Object.entries(renderedVideoValues())) {
|
||||
const target = widget(name);
|
||||
if (target) target.value = value;
|
||||
}
|
||||
@@ -70,16 +67,8 @@ function applyDropToLoadVideo(node, restored, 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, restored, uploadedName);
|
||||
applyDropToLoadVideo(node, uploadedName);
|
||||
} catch (error) {
|
||||
showToast({ severity: "error", summary: "Load Video \u{1F18E}", detail: `The dropped video could not be loaded: ${error?.message || error}` });
|
||||
}
|
||||
|
||||
+29
-9
@@ -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]:
|
||||
_SESSIONS.pop(key).close()
|
||||
_evict_session_locked(key)
|
||||
|
||||
threading.Thread(target=reap, daemon=True, name="ausboss-scrub-session-reaper").start()
|
||||
|
||||
@@ -447,31 +447,48 @@ def close_scrub_sessions() -> None:
|
||||
"""Close every open scrub session (releases file handles immediately)."""
|
||||
with _SESSIONS_LOCK:
|
||||
for key in list(_SESSIONS):
|
||||
_SESSIONS.pop(key).close()
|
||||
_evict_session_locked(key, wait=True)
|
||||
|
||||
|
||||
def _get_session(path: Path) -> "_ScrubSession":
|
||||
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":
|
||||
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]:
|
||||
_SESSIONS.pop(stale).close()
|
||||
if len(_SESSIONS) >= _SESSION_LIMIT:
|
||||
oldest = min(_SESSIONS, key=lambda item: _SESSIONS[item].last_used)
|
||||
_SESSIONS.pop(oldest).close()
|
||||
_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)
|
||||
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)
|
||||
with session.lock:
|
||||
session = _get_session(path, acquire=True)
|
||||
try:
|
||||
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)
|
||||
@@ -511,6 +528,9 @@ 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]:
|
||||
|
||||
@@ -400,7 +400,14 @@ 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
|
||||
@@ -424,8 +431,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.size == 0:
|
||||
return silent_audio(end - start)
|
||||
if data.shape[1] < length:
|
||||
data = np.pad(data, ((0, 0), (0, length - data.shape[1])))
|
||||
return {
|
||||
"waveform": torch.from_numpy(np.ascontiguousarray(data)).unsqueeze(0),
|
||||
"sample_rate": rate,
|
||||
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
@@ -249,10 +251,19 @@ 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):
|
||||
@@ -289,6 +300,22 @@ 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)}, "
|
||||
|
||||
+28
-13
@@ -244,22 +244,37 @@ 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()
|
||||
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),
|
||||
)
|
||||
|
||||
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)
|
||||
return {
|
||||
"ui": {
|
||||
"images": [{
|
||||
|
||||
@@ -209,3 +209,18 @@ 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");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""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()
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
@@ -112,13 +113,17 @@ class FakeCoreVideo:
|
||||
|
||||
|
||||
class FakeFolderPaths:
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
last_output = None
|
||||
|
||||
@staticmethod
|
||||
def get_output_directory():
|
||||
return "/tmp"
|
||||
return FakeFolderPaths.temporary.name
|
||||
|
||||
@staticmethod
|
||||
def get_save_image_path(*_args):
|
||||
return "/tmp", "video", 3, "AusBoss", "AusBoss/video"
|
||||
FakeFolderPaths.last_output = tempfile.mkdtemp(dir=FakeFolderPaths.temporary.name)
|
||||
return FakeFolderPaths.last_output, "video", 3, "AusBoss", "AusBoss/video"
|
||||
|
||||
|
||||
class SaveVideoHelperTests(unittest.TestCase):
|
||||
@@ -297,7 +302,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"], ("/tmp/video_00003_.mp4",))
|
||||
self.assertEqual(result["result"], (str(Path(FakeFolderPaths.last_output) / "video_00003_.mp4"),))
|
||||
self.assertTrue(Path(result["result"][0]).is_absolute())
|
||||
|
||||
def test_symlinked_prefix_is_refused_before_core_creates_folders(self):
|
||||
@@ -380,6 +385,8 @@ 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:
|
||||
|
||||
@@ -285,3 +285,15 @@ 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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user