Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cecffcc592 | ||
|
|
2edd8c0859 |
+8
-72
@@ -2,79 +2,14 @@
|
||||
|
||||
All notable changes to ComfyUI-AusBoss are documented here.
|
||||
|
||||
## 2.23.0 - 2026-11-09
|
||||
## Unreleased
|
||||
|
||||
- **Mask by Touch keeps your picks when the picture changes.** Your clicks
|
||||
and paint move onto the new picture, in the same places, so an edit before
|
||||
the node no longer means picking everything again. On a picture of another
|
||||
size they are scaled to fit. A click finds whatever is at that spot now;
|
||||
paint stays exactly as painted. For the old way, set **New picture**, under
|
||||
More, to **start over**.
|
||||
|
||||
- **Workflow Switches: a switch that changes a setting no longer turns its
|
||||
node off.** The node it sets keeps running. LoRA choices keep every field
|
||||
that differs between them, and the add-choice button shows as soon as a
|
||||
node is selected.
|
||||
|
||||
- **Mask by Touch clears a stopped run's message once a run works.** When
|
||||
a run stopped at the node, its message stayed on the picture after the
|
||||
next run from the Run button had worked. It now goes as soon as the new
|
||||
result comes in.
|
||||
|
||||
- **Video Crop + Rotate + Pad → Clip: the fixed_frames input sits on its own
|
||||
row again.** In workflows saved by an earlier release, newer ComfyUI drew
|
||||
fixed_frames on top of force_rate, so a link could not be dropped on it.
|
||||
Every input the pack moves into the input column now gets its own row.
|
||||
|
||||
- **Node colours stay after a save and reload.** A colour picked from the
|
||||
right-click menu now survives saving, reloading and pasting. In Nodes 2.0,
|
||||
About this node is in the right-click menu, and dragging a node's corner
|
||||
snaps to nearby lines (hold Shift to skip the snap).
|
||||
|
||||
- **Image Folder shows the right pictures when you change folders.**
|
||||
Switching folders or reloading a workflow no longer shows old or mixed-up
|
||||
results, a very large folder keeps its newest files, and thumbnails refresh
|
||||
when a picture is saved over.
|
||||
|
||||
- **Crop for Inpaint keeps the whole soft edge inside the crop.** The blend
|
||||
at the seam is now complete. Crops and blend masks can come out slightly
|
||||
different from earlier releases for the same inputs.
|
||||
|
||||
- **Compare, the preview bar and Save Image no longer show a stale picture.**
|
||||
A late reply is ignored once the picture has changed, and Save Image
|
||||
forgets its old saved file when the folder changes. Save Image also takes
|
||||
bfloat16 and one-channel images, and a blank linked caption writes no
|
||||
caption file.
|
||||
|
||||
- **Color Match leaves a picture alone when matched to itself.** When the
|
||||
reference is the same picture, or the same colours at another size, the
|
||||
picture now comes back unchanged instead of shifting colour. Other matches
|
||||
can differ from earlier releases by less than one shade step.
|
||||
|
||||
- **Krea 2 Outpaint Model Patch no longer reuses an old reference.** On a
|
||||
fresh server's first run, its reference cache could hand back a stale
|
||||
reference, so the new area came out different from a run without the
|
||||
cache. Now both match exactly. Other model patches on the same model also
|
||||
apply alongside it.
|
||||
|
||||
- **Three example workflow notes now match their graphs.** Krea 2 Rotate +
|
||||
Outpaint says its tone match is on, Qwen Image 2.1 Inpaint says its seed is
|
||||
fixed, and Qwen Image 2.1 Upscale + Restore says the redraw is at least
|
||||
about 2 MP.
|
||||
|
||||
- **The pack's own checks are stricter.** They read pyproject.toml properly
|
||||
and catch more ways a node could be hidden from the Registry's scanners.
|
||||
Nothing changes in your workflows.
|
||||
|
||||
## 2.22.0 - 2026-11-07
|
||||
|
||||
- **Node cards have a calmer, easier-to-read look.** Number boxes, choices,
|
||||
switches, fields and buttons are now one solid gray with no outlines, on a
|
||||
dark card. Labels, units and section names are lighter and easier to read,
|
||||
and every control in a card row is the same height. The bar above a result
|
||||
picture (the picture arrows, the Preview steps and Preview switches, and
|
||||
buttons such as AUTO) and the picture's frame match the cards. The colors
|
||||
live in one shared file, so the rest of the pack can follow.
|
||||
- **New node: Camera Move 🆎.** Moves the camera of your own clip. Drag the
|
||||
picture on the node to turn the camera, pull back or push in, and set keys
|
||||
on a time bar. What the old camera never saw comes out pink, ready for
|
||||
MiniMax H3 with the CrossView-Warp LoRA to paint in. The new MiniMax H3
|
||||
Camera Move example puts it all in one box between Load Video and Save
|
||||
Video.
|
||||
|
||||
- **Mask by Touch 🆎 no longer fills holes unless you ask.** Fill small
|
||||
holes, under More, now starts off, so a hole you pick stays a hole. Saved
|
||||
@@ -91,6 +26,7 @@ All notable changes to ComfyUI-AusBoss are documented here.
|
||||
the old one, the new picture now shows on the node, ready to pick from. A
|
||||
new reload button beside Undo shows it any time, without running the
|
||||
workflow.
|
||||
|
||||
- **Save Image and Save Video: the Steps switch is now called Preview steps.** It
|
||||
shows the picture or the clip's first frame while it is being made, and "Steps"
|
||||
read like a sampler's step count. It works the same, and saved workflows keep
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
<a href="https://github.com/ausboss/ComfyUI-AusBoss/releases/latest"><img src="assets/readme/release.svg" alt="Latest release"></a>
|
||||
<a href="https://www.youtube.com/@ausbossdev"><img src="https://img.shields.io/badge/YouTube-%40ausbossdev-00b4aa?style=flat-square&logo=youtube&logoColor=white" alt="YouTube @ausbossdev"></a>
|
||||
<a href="https://discord.gg/RwVxY9Fuaj"><img src="https://img.shields.io/badge/Discord-AusBoss-00b4aa?style=flat-square&logo=discord&logoColor=white" alt="Discord: AusBoss"></a>
|
||||
<a href="https://huggingface.co/ausboss"><img src="https://img.shields.io/badge/Hugging%20Face-ausboss-00b4aa?style=flat-square&logo=huggingface&logoColor=white" alt="Hugging Face: ausboss"></a>
|
||||
<a href="https://x.com/Zanzibased"><img src="https://img.shields.io/badge/X-%40Zanzibased-00b4aa?style=flat-square&logo=x&logoColor=white" alt="X @Zanzibased"></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +27,7 @@ Most of them are for image and video work. ComfyUI's core nodes already cover th
|
||||
- **[LoRA Loader](#lora-loader):** a whole LoRA stack in one node, each row with its own switch and strength.
|
||||
- **[Workflow Switches](#workflow-switches):** turn parts of a workflow off and on from one small card.
|
||||
|
||||
The **?** in a classic node's title bar opens a short card with its inputs and outputs. In Nodes 2.0, right-click the node and choose **About this node**. Every node also has a longer help page in ComfyUI's node help panel. I post the workflows I make with them on [Civitai](https://civitai.com/user/AusBoss) and [X @Zanzibased](https://x.com/Zanzibased), and videos of them on [YouTube @ausbossdev](https://www.youtube.com/@ausbossdev). Can't open Civitai? All my workflows are on [my Discord](https://discord.gg/RwVxY9Fuaj) too. If a node saves you time, a star on the repo helps other people find it.
|
||||
The **?** in a node's title bar opens a short card with its inputs and outputs, and every node has a longer help page in ComfyUI's node help panel. I post the workflows I make with them on [Civitai](https://civitai.com/user/AusBoss) and [X @Zanzibased](https://x.com/Zanzibased), and videos of them on [YouTube @ausbossdev](https://www.youtube.com/@ausbossdev). Can't open Civitai? All my workflows are on [my Discord](https://discord.gg/RwVxY9Fuaj) too. If a node saves you time, a star on the repo helps other people find it.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -169,6 +167,12 @@ Upload a sound file, or pick one from the Source list, and the node shows it as
|
||||
|
||||
It returns the `audio` you kept and its length in `seconds`. Only that part is decoded. [Help page](js/docs/AUSBOSS_NODES_LoadAudio.md).
|
||||
|
||||
### Camera Move
|
||||
|
||||
Moves the camera of your own clip. Press **Show my clip** and the node shows your clip in 3D. Drag the picture to turn the camera, scroll or pinch to pull back or push in, and click the time bar to set keys. What the old camera never saw comes out pink, and MiniMax H3 with Cseti's CrossView-Warp LoRA paints it in. Stay within about 45° left or right, 30° up and 20° down. Your keys add to any move your clip's camera already makes.
|
||||
|
||||
It needs depth from the core **MoGe Inference** node and returns the `guide` clip for H3 and a `holes` mask. [Help page](js/docs/AUSBOSS_NODES_CameraMove.md). [Open the MiniMax H3 Camera Move workflow →](example_workflows/MiniMax%20H3%20Camera%20Move%20%28AusBoss%29.json)
|
||||
|
||||
### Video Crop + Rotate + Pad → Frame
|
||||
|
||||
Picks one frame from a video, then gives you the same rotate, crop and pad controls as the image node, plus an optional megapixel resize. Scrub to the frame with the playhead on the node; the editor adds frame-by-frame stepping and playback. It returns the transformed `image`, a `mask` of the added area, a `stitcher`, the chosen `original` frame, and the output `width` and `height`. For a whole clip, use the Clip version below.
|
||||
@@ -435,7 +439,7 @@ People who download your workflow get the nodes through ComfyUI-Manager's missin
|
||||
|
||||
## Pack-wide tools
|
||||
|
||||
- **Help cards:** the **?** in a classic node's title bar opens its description, inputs and outputs. Nodes 2.0 and titleless nodes such as Run Timer have **About this node** in their right-click menu.
|
||||
- **Help cards:** the **?** in a node's title bar opens its description, inputs and outputs. Run Timer has no title bar, so it has **About this node** in its right-click menu.
|
||||
- **Recreate node 🆎** (right-click an AusBoss node) rebuilds a node saved by an older version from the current definition. It keeps the values, links, position and colors.
|
||||
- **Replace with AusBoss nodes 🆎** (canvas right-click or the command palette) finds third-party nodes this pack can stand in for, such as VideoHelperSuite's Load Video and Video Combine or KJNodes' Color Match. It previews each swap and replaces the ones you leave ticked.
|
||||
- **Snap into line:** a node, group or selection you drag lines up with the nodes and groups around it, edge to edge or middle to middle, and a teal line shows what it lines up with. Pulling a corner to resize lines up the same way. Hold Shift while you drag to skip it, or turn it off in Settings → 🆎 AusBoss → Canvas.
|
||||
|
||||
+5
-4
@@ -11,7 +11,7 @@ of taking the whole pack down, so one broken node never hides the rest.
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import tomllib
|
||||
import re
|
||||
import traceback
|
||||
|
||||
# One entry per file in nodes/, kept alphabetical. The order has no
|
||||
@@ -20,6 +20,7 @@ NODE_MODULES = [
|
||||
"node_align_image",
|
||||
"node_batch_utils",
|
||||
"node_callout",
|
||||
"node_camera_move",
|
||||
"node_color_match",
|
||||
"node_compare",
|
||||
"node_frame_interpolate",
|
||||
@@ -85,9 +86,9 @@ def _read_version():
|
||||
"""Version straight from pyproject.toml so it is never duplicated."""
|
||||
try:
|
||||
pyproject = os.path.join(os.path.dirname(__file__), "pyproject.toml")
|
||||
with open(pyproject, "rb") as f:
|
||||
version = tomllib.load(f).get("project", {}).get("version")
|
||||
return version if isinstance(version, str) and version.strip() else "unknown"
|
||||
with open(pyproject, "r", encoding="utf-8") as f:
|
||||
match = re.search(r'^version\s*=\s*"([^"]+)"', f.read(), re.MULTILINE)
|
||||
return match.group(1) if match else "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="105" height="20" role="img" aria-label="release: v2.23.0">
|
||||
<title>release: v2.23.0</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="105" height="20" role="img" aria-label="release: v2.21.2">
|
||||
<title>release: v2.21.2</title>
|
||||
<path fill="#555" d="M0 0h48v20H0z"/>
|
||||
<path fill="#00b4aa" d="M48 0h57v20H48z"/>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text x="24" y="14">release</text>
|
||||
<text x="76.5" y="14">v2.23.0</text>
|
||||
<text x="76.5" y="14">v2.21.2</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 425 B After Width: | Height: | Size: 425 B |
@@ -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"
|
||||
@@ -3482,4 +3482,4 @@
|
||||
"frontendVersion": "1.53.6"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
+14
-28
@@ -23,7 +23,6 @@ import { openInfoCard } from "../shared/settings_menu.mjs";
|
||||
|
||||
const SETTING_ID = "AusBoss.Appearance.NodeColor";
|
||||
const CUSTOM_SETTING_ID = "AusBoss.Appearance.CustomColor";
|
||||
const COLOR_OVERRIDE = "ausbossColor";
|
||||
|
||||
// Stored bare (no "#") to match the color setting's storage contract.
|
||||
const DEFAULT_CUSTOM_COLOR = "00b4aa";
|
||||
@@ -41,7 +40,6 @@ let activeCustomColor = DEFAULT_CUSTOM_COLOR;
|
||||
let activeCustomTitle = normalizeHexColor(DEFAULT_CUSTOM_COLOR);
|
||||
|
||||
function setActiveCustomColor(value) {
|
||||
if (!normalizeHexColor(value)) return;
|
||||
activeCustomColor = value;
|
||||
activeCustomTitle = normalizeHexColor(value);
|
||||
}
|
||||
@@ -58,7 +56,6 @@ function applyScheme(node, colors) {
|
||||
function repaintAll(next, previous) {
|
||||
for (const node of collectGraphNodes(app.graph)) {
|
||||
if (!isAusbossNode(node)) continue;
|
||||
if (Object.hasOwn(node.properties ?? {}, COLOR_OVERRIDE)) continue;
|
||||
if (!shouldRecolor(node, previous)) continue;
|
||||
applyScheme(node, next);
|
||||
}
|
||||
@@ -152,8 +149,8 @@ app.registerExtension({
|
||||
},
|
||||
getNodeMenuItems(node) {
|
||||
if (!isAusbossNode(node)) return [];
|
||||
// Vue nodes skip the canvas badge callbacks; title-less nodes have no bar.
|
||||
const about = isTitleless(node) || globalThis.LiteGraph?.vueNodesMode === true
|
||||
// A title-less node has no bar for the "?" badge; its card opens here.
|
||||
const about = isTitleless(node)
|
||||
? [{ content: "About this node", callback: () => openHelpCard(node, null) }]
|
||||
: [];
|
||||
return [
|
||||
@@ -165,14 +162,12 @@ app.registerExtension({
|
||||
options: SCHEME_NAMES.map((name) => ({
|
||||
content: name,
|
||||
callback: () => {
|
||||
// Keep an explicit choice, including no colors, through saves
|
||||
// and setting changes. Custom stores the pair picked today.
|
||||
// A menu pick writes the colors directly; shouldRecolor then
|
||||
// treats them as the user's choice during setting sweeps.
|
||||
const colors = schemeColors(name, activeCustomColor);
|
||||
// Custom with an unusable stored color resolves to null, which
|
||||
// would wrongly read as "Theme default" — do nothing instead.
|
||||
if (name === CUSTOM_SCHEME && !colors) return;
|
||||
node.properties ??= {};
|
||||
node.properties[COLOR_OVERRIDE] = colors ? { ...colors } : null;
|
||||
applyScheme(node, colors);
|
||||
app.graph?.setDirtyCanvas?.(true, true);
|
||||
},
|
||||
@@ -184,26 +179,16 @@ app.registerExtension({
|
||||
nodeCreated(node) {
|
||||
if (!isAusbossNode(node)) return;
|
||||
installHelpBadge(node);
|
||||
restoreAppearance(node);
|
||||
// Construction precedes saved fields; reload, paste and undo restore
|
||||
// their colors and properties before this callback runs.
|
||||
chainCallback(node, "onConfigure", function (saved) {
|
||||
restoreAppearance(this, saved);
|
||||
});
|
||||
// Colors restored from a saved workflow (and manual picks) land before
|
||||
// this hook runs — an already-colored node is left alone, unless it
|
||||
// wears a retired flagship pair: those colors were ours, so the node
|
||||
// upgrades to the active scheme instead of keeping them forever.
|
||||
if ((node.color || node.bgcolor) && !wearsLegacyScheme(node)) return;
|
||||
const colors = schemeColors(activeScheme, activeCustomColor);
|
||||
if (colors) applyScheme(node, colors);
|
||||
},
|
||||
});
|
||||
|
||||
function restoreAppearance(node, saved) {
|
||||
if (Object.hasOwn(node.properties ?? {}, COLOR_OVERRIDE)) {
|
||||
// A later pick in ComfyUI's own color menu is saved in the native fields.
|
||||
if (saved?.color || saved?.bgcolor) return;
|
||||
applyScheme(node, node.properties[COLOR_OVERRIDE]);
|
||||
return;
|
||||
}
|
||||
if ((node.color || node.bgcolor) && !wearsLegacyScheme(node)) return;
|
||||
applyScheme(node, schemeColors(activeScheme, activeCustomColor));
|
||||
}
|
||||
|
||||
function isTitleless(node) {
|
||||
return (node.title_mode ?? node.constructor?.title_mode) === (globalThis.LiteGraph?.NO_TITLE ?? 1);
|
||||
}
|
||||
@@ -219,8 +204,9 @@ function openHelpCard(node, event) {
|
||||
});
|
||||
}
|
||||
|
||||
// Classic nodes get a quiet "?" in the title bar. Vue nodes open the same
|
||||
// card from their context menu. Its text comes from DESCRIPTION and tooltips.
|
||||
// Every AusBoss node gets a quiet "?" in the title bar; clicking it opens a
|
||||
// card built from the node's own DESCRIPTION and tooltips, so the docs on
|
||||
// screen are exactly the docs in the source.
|
||||
function installHelpBadge(node) {
|
||||
// A title-less node (Run Timer) has no bar to hang the badge on; its
|
||||
// card opens from the node's context menu instead.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// The settings brought up to a Camera Move box, as one compact card above
|
||||
// the shot: what people change per clip on top (size, seed), the model files
|
||||
// folded under "Models", since they are set once. A LoRA Loader's rows keep
|
||||
// their own panel, with its strength bars; the camera keys are the shot.
|
||||
//
|
||||
// A box row shares its options with the setting inside the box, and Nodes 2.0
|
||||
// reads "hidden" from there, so a row the card takes over is hidden inside the
|
||||
// box too in that renderer (the classic canvas still shows it). Its value is
|
||||
// set from the box either way.
|
||||
|
||||
import { mountWidgetCard } from "../shared/widget_card.mjs";
|
||||
import { boxRowInputs } from "../shared/lora_box.mjs";
|
||||
import { hideWidget } from "../shared/widget_visibility.mjs";
|
||||
|
||||
const STASH = "__ausbossVisibilityStash"; // widget_visibility.mjs: a row already hidden
|
||||
const MODEL_FILE = /\.(safetensors|sft|ckpt|pt|pth|bin|gguf|onnx)$/i;
|
||||
const SUFFIX = { megapixels: "MP" };
|
||||
const ROW_TYPES = new Set(["number", "slider", "combo", "toggle", "text"]);
|
||||
const isLoraLoader = (node) => node?.type === "AUSBOSS_NODES_LoraLoader" || node?.comfyClass === "AUSBOSS_NODES_LoraLoader";
|
||||
|
||||
const isModelRow = (widget) => {
|
||||
if (widget?.type !== "combo") return false;
|
||||
if (MODEL_FILE.test(String(widget.value ?? ""))) return true;
|
||||
const values = typeof widget.options?.values === "function" ? [] : widget.options?.values;
|
||||
return Array.isArray(values) && values.some((value) => MODEL_FILE.test(String(value)));
|
||||
};
|
||||
|
||||
function labelOf(box, widget) {
|
||||
const input = (box.inputs ?? []).find((slot) => slot?.widget?.name === widget.name);
|
||||
const text = String(input?.label || widget.label || widget.name).replace(/_/g, " ");
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
// The rows a box's card shows: every setting brought up to it except `skip`
|
||||
// (the camera keys), a LoRA Loader's rows and anything with its own panel.
|
||||
export function boxCardRows(box, skip) {
|
||||
const loraRows = new Set(boxRowInputs(box, isLoraLoader).map((entry) => entry.name));
|
||||
const top = [], models = [];
|
||||
for (const widget of box.widgets ?? []) {
|
||||
if (!widget || widget === skip || widget.element || !ROW_TYPES.has(String(widget.type)) || loraRows.has(widget.name)) continue;
|
||||
if (widget[STASH] && !widget.__ausbossCameraCard) continue;
|
||||
const row = { widget: widget.name, label: labelOf(box, widget) };
|
||||
if (isModelRow(widget)) models.push({ ...row, kind: "select", group: "models" });
|
||||
else top.push(SUFFIX[innerName(box, widget)] ? { ...row, suffix: SUFFIX[innerName(box, widget)] } : row);
|
||||
}
|
||||
return models.length ? [...top, { group: "models", label: `Models · ${models.length} files` }, ...models] : top;
|
||||
}
|
||||
|
||||
// The name of the setting inside the box that a box row stands for.
|
||||
function innerName(box, widget) {
|
||||
const input = (box.subgraph?.inputs ?? []).find((slot) => slot?.name === widget.name);
|
||||
for (const id of input?.linkIds ?? []) {
|
||||
const link = box.subgraph.getLink?.(id) ?? box.subgraph.links?.get?.(id) ?? box.subgraph.links?.[id];
|
||||
const node = link && (box.subgraph.getNodeById?.(link.target_id));
|
||||
const name = node?.inputs?.[link.target_slot]?.widget?.name ?? node?.inputs?.[link.target_slot]?.name;
|
||||
if (name) return name;
|
||||
}
|
||||
return widget.name;
|
||||
}
|
||||
|
||||
// The frontend now and then makes a box's rows afresh (a new object under
|
||||
// the same name), so the card hides its rows again whenever it finds one
|
||||
// showing. Cheap: a handful of names.
|
||||
export function rehideBoxCard(box) {
|
||||
let changed = false;
|
||||
for (const name of box.__ausbossCameraCardRows ?? []) {
|
||||
const widget = (box.widgets ?? []).find((w) => w?.name === name);
|
||||
if (!widget || widget[STASH]) continue;
|
||||
hideWidget(widget);
|
||||
widget.__ausbossCameraCard = true;
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// Mount the card once per box. Settings brought up later show as plain rows.
|
||||
// `tidy` puts the box's panels back in order and says whether it moved one.
|
||||
export function mountBoxCard(box, skip, tidy) {
|
||||
if (box.__ausbossCameraCard) { rehideBoxCard(box); return false; }
|
||||
const rows = boxCardRows(box, skip);
|
||||
if (!rows.length) return false;
|
||||
box.__ausbossCameraCardRows = rows.filter((row) => row.widget).map((row) => row.widget);
|
||||
rehideBoxCard(box);
|
||||
mountWidgetCard(box, { rows, minWidth: 300, first: true });
|
||||
box.__ausbossCameraCard = true;
|
||||
const draw = box.onDrawForeground;
|
||||
box.onDrawForeground = function (...args) {
|
||||
if (rehideBoxCard(this) | Boolean(tidy?.(this))) this.setDirtyCanvas?.(true, true);
|
||||
return draw?.apply(this, args);
|
||||
};
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Camera Move: the camera maths the face shares with the node.
|
||||
//
|
||||
// The same keys, pose and easing as nodes/_camera_move_helpers.py, so the
|
||||
// picture on the node is the shot the warp makes. No DOM and no app import:
|
||||
// tests/camera_move.test.mjs runs it under node:test.
|
||||
//
|
||||
// Axes are OpenCV's: x right, y down, z into the picture. A key is
|
||||
// { t, az, el, back, px, py }: t from 0 to 1 over the clip, turn and rise in
|
||||
// degrees, back and the pan in distances to the pivot.
|
||||
|
||||
export const LIM = { az: [-90, 90], el: [-30, 60], back: [-0.6, 2], px: [-1, 1], py: [-1, 1] };
|
||||
export const K0 = { az: 0, el: 0, back: 0, px: 0, py: 0 };
|
||||
export const FIELDS = Object.keys(K0);
|
||||
// Two keys this close on the clip are the same key.
|
||||
export const SAME_KEY = 0.015;
|
||||
export const MAX_KEYS = 64;
|
||||
export const LORA_RANGE = { az: 45, up: 30, down: -20 };
|
||||
export const DEFAULT_KEYS = [{ t: 0, ...K0 }, { t: 1, ...K0, az: 30, el: 5 }];
|
||||
|
||||
export const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||
const num = (v, fallback = 0) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
|
||||
export function cleanKey(raw) {
|
||||
const key = { t: clamp(num(raw?.t), 0, 1) };
|
||||
for (const name of FIELDS) key[name] = clamp(num(raw?.[name]), ...LIM[name]);
|
||||
return key;
|
||||
}
|
||||
|
||||
// The camera a node holds: { keys, pivot }. Unreadable text gives the default move.
|
||||
export function parseCamera(text) {
|
||||
let data = null;
|
||||
if (text && typeof text === "object") data = text;
|
||||
else if (typeof text === "string" && text.trim()) {
|
||||
try { data = JSON.parse(text); } catch { data = null; }
|
||||
}
|
||||
const fallback = !data || !Array.isArray(data.keys) || !data.keys.length;
|
||||
if (fallback) data = { keys: DEFAULT_KEYS, pivot: null };
|
||||
const keys = [];
|
||||
for (const raw of data.keys.slice(0, MAX_KEYS)) {
|
||||
const key = cleanKey(raw);
|
||||
const same = keys.find((k) => Math.abs(k.t - key.t) < SAME_KEY);
|
||||
if (same) Object.assign(same, key);
|
||||
else keys.push(key);
|
||||
}
|
||||
keys.sort((a, b) => a.t - b.t);
|
||||
const p = data.pivot;
|
||||
const pivot = p && typeof p === "object" && "u" in p && "v" in p
|
||||
? { u: clamp(num(p.u, 0.5), 0, 1), v: clamp(num(p.v, 0.5), 0, 1), t: clamp(num(p.t), 0, 1) }
|
||||
: null;
|
||||
return { keys, pivot, fallback };
|
||||
}
|
||||
|
||||
const round = (v, places) => Math.round(v * 10 ** places) / 10 ** places;
|
||||
|
||||
export function cameraText({ keys, pivot }) {
|
||||
const out = {
|
||||
v: 1,
|
||||
keys: keys.map((k) => ({ t: round(k.t, 4), az: round(k.az, 2), el: round(k.el, 2), back: round(k.back, 3), px: round(k.px, 3), py: round(k.py, 3) })),
|
||||
pivot: pivot ? { u: round(pivot.u, 4), v: round(pivot.v, 4), t: round(pivot.t, 4) } : null,
|
||||
};
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
|
||||
// How fast one field moves as it passes each key: zero at the first and last
|
||||
// key (a gentle start and end) and where the field turns back or holds (it
|
||||
// never runs past a key); elsewhere it keeps its speed through the key, so a
|
||||
// move made of several keys does not slow down at each one.
|
||||
function keySlopes(ts, ys) {
|
||||
const n = ys.length, m = new Array(n).fill(0);
|
||||
for (let k = 1; k < n - 1; k++) {
|
||||
const h0 = ts[k] - ts[k - 1], h1 = ts[k + 1] - ts[k];
|
||||
if (h0 <= 0 || h1 <= 0) continue;
|
||||
const d0 = (ys[k] - ys[k - 1]) / h0, d1 = (ys[k + 1] - ys[k]) / h1;
|
||||
if (d0 * d1 > 0) m[k] = 3 * (h0 + h1) / ((2 * h1 + h0) / d0 + (h1 + 2 * h0) / d1);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// Where the camera is at moment t: through every key, gently at the ends.
|
||||
// Same maths as camera_at in nodes/_camera_move_helpers.py.
|
||||
export function camAt(keys, t) {
|
||||
if (!keys.length) return { ...K0 };
|
||||
const n = keys.length;
|
||||
const pick = (k) => { const o = {}; for (const f of FIELDS) o[f] = k[f] || 0; return o; };
|
||||
if (n === 1 || t <= keys[0].t) return pick(keys[0]);
|
||||
if (t >= keys[n - 1].t) return pick(keys[n - 1]);
|
||||
const ts = keys.map((k) => k.t);
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const a = keys[i], b = keys[i + 1];
|
||||
if (t >= a.t && t <= b.t) {
|
||||
const h = Math.max(1e-6, b.t - a.t), s = (t - a.t) / h;
|
||||
const h00 = 2 * s ** 3 - 3 * s ** 2 + 1, h10 = s ** 3 - 2 * s ** 2 + s, h01 = -2 * s ** 3 + 3 * s ** 2, h11 = s ** 3 - s ** 2;
|
||||
const o = {};
|
||||
for (const f of FIELDS) {
|
||||
const m = keySlopes(ts, keys.map((k) => k[f] || 0));
|
||||
o[f] = h00 * (a[f] || 0) + h10 * h * m[i] + h01 * (b[f] || 0) + h11 * h * m[i + 1];
|
||||
}
|
||||
return o;
|
||||
}
|
||||
}
|
||||
return pick(keys[n - 1]);
|
||||
}
|
||||
|
||||
export const keyAt = (keys, t) => keys.findIndex((k) => Math.abs(k.t - t) < SAME_KEY);
|
||||
|
||||
// Moving the camera at the time bar sets the key there, or adds one.
|
||||
export function setKeyAt(keys, t, cam) {
|
||||
const c = {};
|
||||
for (const f of FIELDS) c[f] = clamp(num(cam[f]), ...LIM[f]);
|
||||
const i = keyAt(keys, t);
|
||||
if (i >= 0) { Object.assign(keys[i], c); return keys; }
|
||||
if (keys.length >= MAX_KEYS) return keys;
|
||||
keys.push({ t: clamp(t, 0, 1), ...c });
|
||||
keys.sort((a, b) => a.t - b.t);
|
||||
return keys;
|
||||
}
|
||||
|
||||
export const pastLoraRange = (c) => Math.abs(c.az) > LORA_RANGE.az || c.el > LORA_RANGE.up || c.el < LORA_RANGE.down;
|
||||
|
||||
// R = Ry(-az) Rx(-el), row-major.
|
||||
export function rot(az, el) {
|
||||
const th = -az * Math.PI / 180, ph = -el * Math.PI / 180;
|
||||
const ct = Math.cos(th), st = Math.sin(th), cp = Math.cos(ph), sp = Math.sin(ph);
|
||||
return [ct, st * sp, st * cp, 0, cp, -sp, -st, ct * sp, ct * cp];
|
||||
}
|
||||
export const mv = (R, v) => [R[0] * v[0] + R[1] * v[1] + R[2] * v[2], R[3] * v[0] + R[4] * v[1] + R[5] * v[2], R[6] * v[0] + R[7] * v[1] + R[8] * v[2]];
|
||||
|
||||
// The moved camera's rotation R and centre C. Pan slides the camera in its own frame.
|
||||
export function pose(cam, pivot) {
|
||||
const R = rot(cam.az, cam.el), p = pivot, Rp = mv(R, p), d = Math.hypot(p[0], p[1], p[2]);
|
||||
const b = mv(R, [(cam.px || 0) * d, (cam.py || 0) * d, -(cam.back || 0) * d]);
|
||||
return { R, C: [p[0] - Rp[0] + b[0], p[1] - Rp[1] + b[1], p[2] - Rp[2] + b[2]] };
|
||||
}
|
||||
|
||||
// A world point in the moved camera: R^T (P - C).
|
||||
export function toCam(R, C, P) {
|
||||
const d = [P[0] - C[0], P[1] - C[1], P[2] - C[2]];
|
||||
return [R[0] * d[0] + R[3] * d[1] + R[6] * d[2], R[1] * d[0] + R[4] * d[1] + R[7] * d[2], R[2] * d[0] + R[5] * d[1] + R[8] * d[2]];
|
||||
}
|
||||
|
||||
// The node's depth picture: red is the high byte, green the low one, on a log
|
||||
// scale from zmin to zmax; 0 is no depth (sky), which sits at `far`.
|
||||
export function unpackDepth(rgba, count, zmin, zmax, far) {
|
||||
const z = new Float32Array(count), valid = new Uint8Array(count);
|
||||
const lmin = Math.log(zmin), lr = Math.log(zmax) - lmin;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const q = rgba[i * 4] * 256 + rgba[i * 4 + 1];
|
||||
if (q === 0) z[i] = far;
|
||||
else { z[i] = Math.exp(lmin + (q - 1) / 65534 * lr); valid[i] = 1; }
|
||||
}
|
||||
return { z, valid };
|
||||
}
|
||||
|
||||
// The 3D spot under a pixel of a frame: the middle of the nearest depth found
|
||||
// within three pixels, as the node does it. Null over sky.
|
||||
export function pivotFromPixel(frame, u, v) {
|
||||
const { W, H, z, valid, fx, fy, cx, cy } = frame;
|
||||
const x = clamp(Math.floor(u * W), 0, W - 1), y = clamp(Math.floor(v * H), 0, H - 1);
|
||||
for (let r = 0; r <= 3; r++) {
|
||||
const found = [];
|
||||
for (let yy = Math.max(0, y - r); yy <= Math.min(H - 1, y + r); yy++) {
|
||||
for (let xx = Math.max(0, x - r); xx <= Math.min(W - 1, x + r); xx++) if (valid[yy * W + xx]) found.push(z[yy * W + xx]);
|
||||
}
|
||||
if (found.length) {
|
||||
found.sort((a, b) => a - b);
|
||||
const depth = found.length % 2 ? found[found.length >> 1] : (found[found.length / 2 - 1] + found[found.length / 2]) / 2;
|
||||
return [(x + 0.5 - cx) / fx * depth, (y + 0.5 - cy) / fy * depth, depth];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Words for where the camera is.
|
||||
export function fmtAz(v) { const r = Math.round(v); return r === 0 ? "0°" : `${Math.abs(r)}° ${r > 0 ? "right" : "left"}`; }
|
||||
export function fmtEl(v) { const r = Math.round(v); return r === 0 ? "0°" : `${Math.abs(r)}° ${r > 0 ? "up" : "down"}`; }
|
||||
export function describe(c) {
|
||||
const part = (v, neg, pos) => (Math.abs(v) < 0.005 ? "" : `${Math.abs(v).toFixed(2)} ${v > 0 ? pos : neg}`);
|
||||
const pan = [part(c.px, "left", "right"), part(c.py, "up", "down")].filter(Boolean).join(", ");
|
||||
return `Turn ${fmtAz(c.az)} · Rise ${fmtEl(c.el)} · ${c.back < -0.004 ? "In" : "Back"} ${Math.abs(c.back).toFixed(2)}×${pan ? ` · Pan ${pan}` : ""}`;
|
||||
}
|
||||
@@ -0,0 +1,900 @@
|
||||
// Camera Move's face: the shot on the node, steered by grabbing it.
|
||||
//
|
||||
// Drag the picture to turn the camera round, pinch or scroll to pull back,
|
||||
// two fingers or Shift to pan, tap a spot to circle round it. Top right, as
|
||||
// in Blender: an axis ball, a magnifier, a hand, and a camera that puts you
|
||||
// back on your own shot. Under it the time bar with its keys.
|
||||
//
|
||||
// No app import: index.js hands in where the camera is kept, how to run the
|
||||
// node and how to reach a file, so the same face sits on the node or on the
|
||||
// box that holds it.
|
||||
//
|
||||
// The stage is the one part of the node that keeps the mouse wheel (it zooms
|
||||
// the camera, as in Blender); its root carries no "ausboss-" class, so the
|
||||
// pack's wheel hand-off leaves it alone. The controls under it do carry one,
|
||||
// and the wheel there zooms the graph as everywhere else.
|
||||
|
||||
import { makeScrubInput } from "../shared/scrub_input.mjs";
|
||||
import {
|
||||
K0, LIM, SAME_KEY, camAt, cameraText, clamp, describe, keyAt, mv, parseCamera, pastLoraRange,
|
||||
pivotFromPixel, pose, rot, setKeyAt, toCam, unpackDepth,
|
||||
} from "./camera_math.mjs";
|
||||
|
||||
export const MIN_WIDTH = 380; // the play row on one line, with "243 / 243" frames
|
||||
export const MIN_STAGE = 184;
|
||||
export const CONTROLS_HEIGHT = 168;
|
||||
const FPS = 24; // H3 renders at 24 frames a second; only playback speed uses it
|
||||
const MAG = 0xFFFF00FF;
|
||||
|
||||
const CSS = `
|
||||
.acm-face{position:relative;display:flex;flex-direction:column;width:100%;height:100%;box-sizing:border-box;
|
||||
--acm-teal:#4fb3a9;--acm-teal-dim:#2b605b;--acm-line:#34413f;--acm-fg:#e3e9e8;--acm-muted:#92a2a1;--acm-amber:#e2a94c;
|
||||
--acm-stage:#0c0f10;--acm-panel:#1f2629;--acm-head:#283236;color:var(--acm-fg);font:12.5px system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||||
.acm-face *{box-sizing:border-box}
|
||||
.acm-stage{position:relative;flex:1 1 auto;min-height:${MIN_STAGE}px;background:var(--acm-stage);border-radius:6px;overflow:hidden;
|
||||
touch-action:none;user-select:none;-webkit-user-select:none}
|
||||
.acm-stage>canvas.acm-pic{position:absolute;display:block;cursor:grab}
|
||||
.acm-stage>canvas.acm-pic:active{cursor:grabbing}
|
||||
.acm-pivot{position:absolute;width:18px;height:18px;margin:-9px 0 0 -9px;border:2px solid var(--acm-teal);border-radius:50%;pointer-events:none;box-shadow:0 0 0 1px rgba(0,0,0,.6)}
|
||||
.acm-pivot::after{content:"";position:absolute;left:50%;top:50%;width:3px;height:3px;margin:-1.5px;background:var(--acm-teal);border-radius:50%}
|
||||
.acm-nav{position:absolute;top:6px;right:6px;display:flex;flex-direction:column;align-items:center;gap:5px}
|
||||
.acm-nav canvas{width:64px;height:64px;cursor:grab;touch-action:none;margin-bottom:1px}
|
||||
.acm-nav button,.acm-helpbtn{width:30px;height:30px;border-radius:50%;border:1px solid rgba(146,162,161,.4);background:rgba(12,15,16,.62);
|
||||
color:var(--acm-fg);padding:0;display:grid;place-items:center;touch-action:none;box-shadow:0 1px 6px rgba(0,0,0,.45);cursor:pointer}
|
||||
.acm-nav button svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
|
||||
.acm-nav button:hover,.acm-helpbtn:hover{border-color:var(--acm-teal)}
|
||||
.acm-nav button.on,.acm-helpbtn[aria-expanded="true"]{background:var(--acm-teal-dim);border-color:var(--acm-teal)}
|
||||
.acm-nav .pan{cursor:move}.acm-nav .zoom{cursor:ns-resize}
|
||||
.acm-helpbtn{position:absolute;top:6px;left:6px;width:24px;height:24px;font:600 12px system-ui,sans-serif}
|
||||
.acm-inset{position:absolute;left:6px;bottom:6px;width:36%;border:1px solid var(--acm-line);border-radius:5px;overflow:hidden;background:var(--acm-stage);
|
||||
box-shadow:0 2px 10px rgba(0,0,0,.5);pointer-events:none}
|
||||
.acm-inset canvas{display:block;width:100%;height:auto}
|
||||
.acm-tag{position:absolute;top:4px;left:5px;font-size:10px;color:var(--acm-muted);background:rgba(12,15,16,.7);padding:1px 5px;border-radius:4px;pointer-events:none}
|
||||
.acm-empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:16px;text-align:center;color:var(--acm-muted);line-height:1.4}
|
||||
.acm-empty b{color:var(--acm-fg);font-weight:600;font-size:13px}
|
||||
.ausboss-camera-move-controls{flex:0 0 auto;display:flex;flex-direction:column;gap:6px;padding:7px 0 2px}
|
||||
.acm-readout{display:flex;justify-content:space-between;gap:8px;font-size:11.5px;color:var(--acm-fg);font-variant-numeric:tabular-nums;min-height:15px}
|
||||
.acm-readout .acm-where{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.acm-readout .acm-pink{color:var(--acm-muted);flex:0 0 auto}
|
||||
.acm-warn{color:var(--acm-amber);font-size:11.5px;line-height:15px;height:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.acm-back{display:flex;align-items:center;gap:8px}
|
||||
.acm-back>span{width:40px;color:var(--acm-muted)}
|
||||
.acm-track{position:relative;height:34px;background:var(--acm-stage);border:1px solid var(--acm-line);border-radius:6px;touch-action:none;cursor:pointer}
|
||||
.acm-track .acm-fill{position:absolute;top:15px;height:2px;left:8px;right:8px;background:var(--acm-line)}
|
||||
.acm-track .acm-inner{position:absolute;left:8px;right:8px;top:0;bottom:0}
|
||||
.acm-track .acm-key{position:absolute;top:10px;width:12px;height:12px;margin-left:-6px;transform:rotate(45deg);background:var(--acm-teal);border:1px solid #0c0f10;cursor:ew-resize}
|
||||
.acm-track .acm-key.on{background:#bfe9e4}
|
||||
.acm-track .acm-head{position:absolute;top:2px;bottom:2px;width:2px;margin-left:-1px;background:var(--acm-fg);pointer-events:none}
|
||||
.acm-track .acm-head::before{content:"";position:absolute;top:-2px;left:-4px;width:10px;height:6px;background:var(--acm-fg);border-radius:2px}
|
||||
.acm-row{display:flex;align-items:center;gap:6px}
|
||||
.acm-row>.acm-keynav,.acm-row>.acm-btn{flex:none;white-space:nowrap}
|
||||
.acm-btn{border:1px solid var(--acm-line);background:var(--acm-head);color:var(--acm-fg);font:inherit;font-size:12.5px;padding:6px 10px;border-radius:6px;cursor:pointer;line-height:1.1}
|
||||
.acm-btn:disabled{opacity:.4;cursor:default}
|
||||
.acm-btn.primary{background:var(--acm-teal-dim);border-color:var(--acm-teal-dim)}
|
||||
.acm-keynav{display:inline-flex;border:1px solid var(--acm-line);border-radius:6px;background:var(--acm-head);overflow:hidden}
|
||||
.acm-keynav button{width:32px;height:29px;border:0;background:transparent;color:var(--acm-fg);padding:0;display:grid;place-items:center;cursor:pointer}
|
||||
.acm-keynav button+button{border-left:1px solid var(--acm-line)}
|
||||
.acm-keynav button:disabled{color:var(--acm-muted);opacity:.35;cursor:default}
|
||||
.acm-keynav button:not(:disabled):hover{background:var(--acm-panel)}
|
||||
.acm-keynav svg{width:14px;height:14px}
|
||||
.acm-keynav .dia svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.6}
|
||||
.acm-keynav .dia.on svg{fill:#bfe9e4;stroke:#bfe9e4}
|
||||
.acm-keynav .arr svg{fill:currentColor}
|
||||
.acm-keynav.transport .play{width:36px;background:var(--acm-teal-dim);font-size:12px}
|
||||
.acm-keynav.transport .play:hover{background:var(--acm-teal)}
|
||||
.acm-keynav.transport .loop{color:var(--acm-muted)}
|
||||
.acm-keynav.transport .loop svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
|
||||
.acm-keynav.transport .loop[aria-pressed="true"]{color:#bfe9e4;background:var(--acm-teal-dim)}
|
||||
.acm-time{margin-left:auto;font-size:11.5px;color:var(--acm-muted);font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.acm-help{position:absolute;top:36px;left:6px;right:6px;z-index:5;background:#172023;border:1px solid var(--acm-line);border-radius:8px;
|
||||
padding:10px 12px 12px;box-shadow:0 8px 28px rgba(0,0,0,.55);max-height:calc(100% - 44px);overflow:auto}
|
||||
.acm-help-h{display:flex;justify-content:space-between;align-items:center;font-weight:600;font-size:13px;margin-bottom:6px}
|
||||
.acm-help-x{border:0;background:transparent;color:var(--acm-muted);font-size:20px;line-height:1;cursor:pointer;padding:0 2px}
|
||||
.acm-help dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:7px 12px;font-size:12.5px;line-height:1.4}
|
||||
.acm-help dt{color:var(--acm-teal);font-weight:600}.acm-help dd{margin:0}
|
||||
.acm-nav,.acm-keynav,.acm-btn,.acm-helpbtn{-webkit-touch-callout:none;-webkit-user-select:none;user-select:none}
|
||||
.acm-tip{position:fixed;z-index:10000;max-width:250px;background:#0a0d0e;color:#e3e9e8;border:1px solid #34413f;border-radius:6px;
|
||||
padding:5px 8px;font:12px/1.35 system-ui,sans-serif;pointer-events:none;box-shadow:0 4px 14px rgba(0,0,0,.5)}
|
||||
.acm-face [hidden],.acm-tip[hidden]{display:none!important}
|
||||
`;
|
||||
|
||||
function ensureCss() {
|
||||
if (document.getElementById("ausboss-camera-move-css")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "ausboss-camera-move-css";
|
||||
style.textContent = CSS;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
const el = (tag, cls, attrs = {}) => {
|
||||
const e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === "text") e.textContent = v;
|
||||
else if (k === "html") e.innerHTML = v;
|
||||
else e.setAttribute(k, v);
|
||||
}
|
||||
return e;
|
||||
};
|
||||
|
||||
const NAV_ICONS = {
|
||||
zoom: '<svg viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6"/><path d="M15 15l5 5"/></svg>',
|
||||
pan: '<svg viewBox="0 0 24 24"><path d="M7.5 12.5V6.3a1.25 1.25 0 0 1 2.5 0v5M10 11.3V4.8a1.25 1.25 0 0 1 2.5 0v6.5M12.5 11.3V5.6a1.25 1.25 0 0 1 2.5 0v6M15 11.8V8a1.25 1.25 0 0 1 2.5 0v5.5c0 3.9-2.6 6.8-6.3 6.8h-.6c-2.1 0-3.6-.9-4.8-2.6l-2.4-3.5a1.35 1.35 0 0 1 2.1-1.7l2 2.3"/></svg>',
|
||||
shot: '<svg viewBox="0 0 24 24"><rect x="2.8" y="7.3" width="12.6" height="9.4" rx="1.8"/><path d="M15.4 10.6 21 7.8v8.4l-5.6-2.8"/></svg>',
|
||||
};
|
||||
const NAV_TIPS = { zoom: "Zoom: drag up to push in, down to pull back", pan: "Pan: drag to slide the camera", shot: "Back to your own shot" };
|
||||
const GZ = 64;
|
||||
// Blender's names for the scene's directions: X to the right, Y into the picture, Z up.
|
||||
const AXES = [
|
||||
{ n: "X", v: [1, 0, 0], col: "#e05561", pos: { az: 90, el: 0 }, neg: { az: -90, el: 0 } },
|
||||
{ n: "Y", v: [0, 0, 1], col: "#86b84a", pos: null, neg: { az: 0, el: 0 } }, // nothing to see from behind the scene
|
||||
{ n: "Z", v: [0, -1, 0], col: "#4b8fdf", pos: { az: 0, el: LIM.el[1] }, neg: { az: 0, el: LIM.el[0] } },
|
||||
];
|
||||
const SUB = [[-0.25, -0.25], [0.25, -0.25], [-0.25, 0.25], [0.25, 0.25]];
|
||||
const VIEW = { az: -58, el: 28 }; // the corner map looks at the scene from the left
|
||||
|
||||
// ---------- tooltips: hover on a computer, press and hold a button on a phone ----------
|
||||
let tipEl = null, tipTimer = 0, tipFor = null, tipXY = [0, 0], tipHeld = false;
|
||||
function tip() {
|
||||
if (!tipEl || !tipEl.isConnected) {
|
||||
tipEl = el("div", "acm-tip", { role: "tooltip" });
|
||||
tipEl.hidden = true;
|
||||
document.body.append(tipEl);
|
||||
}
|
||||
return tipEl;
|
||||
}
|
||||
function placeTip(target) {
|
||||
const t = target.dataset.tip, box = tip();
|
||||
if (!t || !target.isConnected) return hideTip();
|
||||
box.textContent = t;
|
||||
box.hidden = false;
|
||||
const r = target.getBoundingClientRect(), tw = box.offsetWidth, th = box.offsetHeight, atPtr = target.dataset.tipAt === "pointer";
|
||||
if (target.closest(".acm-nav")) { // the column top right: the tip opens to its left and covers no other button
|
||||
box.style.left = `${Math.max(8, r.left - tw - 8)}px`;
|
||||
box.style.top = `${r.top + r.height / 2 - th / 2}px`;
|
||||
return;
|
||||
}
|
||||
const ax = atPtr ? tipXY[0] : r.left + r.width / 2;
|
||||
const below = atPtr ? tipXY[1] + 22 : r.bottom + 8, above = atPtr ? tipXY[1] - th - 12 : r.top - th - 8;
|
||||
box.style.left = `${clamp(ax - tw / 2, 8, innerWidth - tw - 8)}px`;
|
||||
box.style.top = `${below + th > innerHeight - 8 ? above : below}px`;
|
||||
}
|
||||
function hideTip() { clearTimeout(tipTimer); if (tipEl) tipEl.hidden = true; tipFor = null; }
|
||||
const armTip = (target, ms) => { clearTimeout(tipTimer); tipFor = target; tipTimer = setTimeout(() => placeTip(target), ms); };
|
||||
function wireTips(root) {
|
||||
const targetOf = (e) => e.target?.closest?.("[data-tip]") ?? null;
|
||||
root.addEventListener("pointermove", (e) => {
|
||||
if (e.pointerType !== "mouse") return;
|
||||
tipXY = [e.clientX, e.clientY];
|
||||
if (e.buttons) { if (tipFor) hideTip(); return; }
|
||||
const t = targetOf(e);
|
||||
if (!t) { if (tipFor) hideTip(); return; }
|
||||
if (t !== tipFor) { hideTip(); armTip(t, t.dataset.tipAt === "pointer" ? 900 : 450); }
|
||||
else if (t.dataset.tipAt === "pointer") { hideTip(); armTip(t, 900); } // over a picture the tip waits until the mouse rests
|
||||
}, true);
|
||||
root.addEventListener("pointerleave", hideTip);
|
||||
root.addEventListener("pointerdown", (e) => {
|
||||
hideTip();
|
||||
if (e.pointerType === "mouse") return;
|
||||
const t = targetOf(e);
|
||||
if (!t || t.dataset.tipAt === "pointer") return;
|
||||
const x0 = e.clientX, y0 = e.clientY;
|
||||
armTip(t, 500);
|
||||
const done = () => { removeEventListener("pointermove", move, true); removeEventListener("pointerup", up, true); removeEventListener("pointercancel", up, true); };
|
||||
const move = (m) => { if (Math.hypot(m.clientX - x0, m.clientY - y0) > 8) { clearTimeout(tipTimer); done(); } };
|
||||
const up = () => {
|
||||
if (tipEl && !tipEl.hidden) { tipHeld = true; setTimeout(() => { tipHeld = false; }, 400); setTimeout(hideTip, 1600); } else clearTimeout(tipTimer);
|
||||
done();
|
||||
};
|
||||
addEventListener("pointermove", move, true); addEventListener("pointerup", up, true); addEventListener("pointercancel", up, true);
|
||||
}, true);
|
||||
// A hold that showed a tip is not a click.
|
||||
root.addEventListener("click", (e) => { if (tipHeld) { tipHeld = false; e.preventDefault(); e.stopPropagation(); } }, true);
|
||||
root.addEventListener("contextmenu", (e) => { if (e.target.closest?.(".acm-nav, .acm-keynav, .acm-btn")) e.preventDefault(); });
|
||||
}
|
||||
|
||||
// ---------- one frame of the clip, as points ----------
|
||||
async function loadFrame(entry, info, view, blobOf) {
|
||||
const W = info.w, H = info.h;
|
||||
const picture = new Image();
|
||||
picture.decoding = "async";
|
||||
const pictureReady = new Promise((ok, fail) => { picture.onload = ok; picture.onerror = () => fail(new Error("picture")); });
|
||||
picture.src = view(entry.color);
|
||||
const depthBlob = await blobOf(entry.depth);
|
||||
const depthBitmap = await createImageBitmap(depthBlob, { colorSpaceConversion: "none", premultiplyAlpha: "none" });
|
||||
await pictureReady;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = W; canvas.height = H;
|
||||
const g = canvas.getContext("2d", { willReadFrequently: true });
|
||||
g.drawImage(picture, 0, 0, W, H);
|
||||
const rgba = new Uint32Array(g.getImageData(0, 0, W, H).data.buffer.slice(0));
|
||||
g.clearRect(0, 0, W, H);
|
||||
g.drawImage(depthBitmap, 0, 0, W, H);
|
||||
const { z, valid } = unpackDepth(g.getImageData(0, 0, W, H).data, W * H, info.zmin, info.zmax, info.far);
|
||||
// the corner map leaves out what is very far away
|
||||
const zs = [];
|
||||
for (let i = 0; i < W * H; i += 5) if (valid[i]) zs.push(z[i]);
|
||||
zs.sort((a, b) => a - b);
|
||||
const zcap = Math.min(zs[Math.floor(zs.length * 0.97)] || info.far, (zs[zs.length >> 1] || 1) * 3.5);
|
||||
return { ...entry, W, H, z, valid, rgba, zcap, picture };
|
||||
}
|
||||
|
||||
// ---------- the face ----------
|
||||
// opts: read() -> camera text, write(text), run() -> Promise<boolean>,
|
||||
// view(fileEntry) -> url, onSize() when the floor changes.
|
||||
export function mountCameraFace(opts) {
|
||||
ensureCss();
|
||||
const root = el("div", "acm-face");
|
||||
const stage = el("div", "acm-stage");
|
||||
const pic = el("canvas", "acm-pic", { "data-tip": "Drag to turn · Shift-drag to pan · wheel to zoom · click a spot to circle round it", "data-tip-at": "pointer" });
|
||||
const picCtx = pic.getContext("2d");
|
||||
const pivotMark = el("div", "acm-pivot");
|
||||
const nav = el("div", "acm-nav");
|
||||
const gizmo = el("canvas", "", { "data-tip": "Drag to turn. Click an axis to look from that side.", role: "img", "aria-label": "Axis ball: drag to turn the camera, tap an axis to look from that side" });
|
||||
gizmo.width = GZ * 2; gizmo.height = GZ * 2;
|
||||
const gctx = gizmo.getContext("2d");
|
||||
nav.append(gizmo);
|
||||
const inset = el("div", "acm-inset");
|
||||
const out = el("canvas");
|
||||
out.width = 360; out.height = 225;
|
||||
const outCtx = out.getContext("2d");
|
||||
const outImg = outCtx.createImageData(out.width, out.height);
|
||||
const outZ = new Float32Array(out.width * out.height);
|
||||
inset.append(out, el("span", "acm-tag", { text: "where the camera is" }));
|
||||
const helpBtn = el("button", "acm-helpbtn", { text: "?", "aria-label": "How to steer", "aria-expanded": "false", "data-tip": "How to steer" });
|
||||
const empty = el("div", "acm-empty");
|
||||
stage.append(pic, pivotMark, inset, nav, helpBtn, empty);
|
||||
|
||||
const controls = el("div", "ausboss-camera-move-controls");
|
||||
const readout = el("div", "acm-readout");
|
||||
const where = el("span", "acm-where");
|
||||
const pink = el("span", "acm-pink", { "data-tip": "How much of the frame the camera never saw. The LoRA paints it in." });
|
||||
readout.append(where, pink);
|
||||
// Always one line tall, empty or not, so a warning never pushes the controls down.
|
||||
const warn = el("div", "acm-warn");
|
||||
const backRow = el("div", "acm-back");
|
||||
const back = makeScrubInput({
|
||||
value: 0, min: LIM.back[0], max: LIM.back[1], step: 0.05, fineStep: 0.01, decimals: 2, unit: "×", unitWidth: 14, width: 132,
|
||||
title: "How far the camera pulls back from what it circles. Below 0 pushes in.",
|
||||
onChange: (v) => { stopPlay(); setCam({ ...camAt(keys, p), back: v }); },
|
||||
});
|
||||
backRow.append(el("span", "", { text: "Back" }), back.root);
|
||||
const track = el("div", "acm-track", { "data-tip": "Click to put a key here. Drag to move the time bar, or drag a key to change its time.", "data-tip-at": "pointer" });
|
||||
const inner = el("div", "acm-inner");
|
||||
const head = el("div", "acm-head");
|
||||
inner.append(head);
|
||||
track.append(el("div", "acm-fill"), inner);
|
||||
const row = el("div", "acm-row");
|
||||
const transport = el("span", "acm-keynav transport", { role: "group", "aria-label": "Playback" });
|
||||
const toStartBtn = el("button", "arr", { "aria-label": "Back to the start", "data-tip": "Back to the start", html: '<svg viewBox="0 0 14 14"><path d="M2.5 2.5h1.6v9H2.5zM11.5 2.5v9L5 7z"/></svg>' });
|
||||
const play = el("button", "play", { text: "▶", "aria-label": "Play", "data-tip": "Play" });
|
||||
const loop = el("button", "loop", { "aria-label": "Play on a loop", "aria-pressed": "false", "data-tip": "Play on a loop", html: '<svg viewBox="0 0 16 16"><path d="M3 9.5V7a2.5 2.5 0 0 1 2.5-2.5H12M10 2.5l2 2-2 2M13 6.5V9a2.5 2.5 0 0 1-2.5 2.5H4M6 13.5l-2-2 2-2"/></svg>' });
|
||||
transport.append(toStartBtn, play, loop);
|
||||
const keynav = el("span", "acm-keynav", { role: "group", "aria-label": "Keys" });
|
||||
const prevKey = el("button", "arr", { "aria-label": "Previous key", "data-tip": "Previous key", html: '<svg viewBox="0 0 14 14"><path d="M10 2.5v9L3.5 7z"/></svg>' });
|
||||
const toggleKey = el("button", "dia", { "aria-label": "Add a key here", "data-tip": "Add a key here", html: '<svg viewBox="0 0 16 16"><path d="M8 1.8 14.2 8 8 14.2 1.8 8z"/></svg>' });
|
||||
const nextKey = el("button", "arr", { "aria-label": "Next key", "data-tip": "Next key", html: '<svg viewBox="0 0 14 14"><path d="M4 2.5v9L10.5 7z"/></svg>' });
|
||||
keynav.append(prevKey, toggleKey, nextKey);
|
||||
const reset = el("button", "acm-btn", { text: "Start over", "data-tip": "One key again, on your own shot" });
|
||||
const time = el("span", "acm-time", { "data-tip": "Frame at the time bar" });
|
||||
row.append(transport, keynav, reset, time);
|
||||
controls.append(readout, warn, backRow, track, row);
|
||||
const help = el("div", "acm-help");
|
||||
help.hidden = true;
|
||||
root.append(stage, controls, help);
|
||||
wireTips(root);
|
||||
|
||||
// ---------- state ----------
|
||||
let keys = [], pivotSpec = null, lastText = null, p = 0, playing = false, lastT = 0, dirty = true, alive = true;
|
||||
let info = null, frames = [], shown = null, pivot = [0, 0, 2], pinkPct = 0, status = "idle", statusNote = "";
|
||||
let flashText = "", flashUntil = 0, gBalls = [], gHot = false, hovered = false, overFace = false, looping = false, needsWrite = false;
|
||||
let buf = null; // z, colour and source pixel of the moved view
|
||||
const fallbackLens = { W: 416, H: 234, fx: 380, fy: 380, cx: 208, cy: 117 };
|
||||
|
||||
function readCamera() {
|
||||
const text = opts.read();
|
||||
if (text === lastText) return;
|
||||
lastText = text;
|
||||
const parsed = parseCamera(text);
|
||||
keys = parsed.keys;
|
||||
pivotSpec = parsed.pivot;
|
||||
resolvePivot();
|
||||
dirty = true;
|
||||
}
|
||||
function writeSoon() { needsWrite = true; dirty = true; }
|
||||
function flushWrite() {
|
||||
if (!needsWrite) return;
|
||||
needsWrite = false;
|
||||
const text = cameraText({ keys, pivot: pivotSpec });
|
||||
lastText = text;
|
||||
if (text !== opts.read()) opts.write(text);
|
||||
}
|
||||
function setCam(cam) { setKeyAt(keys, p, cam); writeSoon(); }
|
||||
|
||||
function lens() { return shown ?? (info ? { ...fallbackLens, ...info.frames[0], W: info.w, H: info.h } : fallbackLens); }
|
||||
function resolvePivot() {
|
||||
if (!info) { pivot = [0, 0, 2]; return; }
|
||||
if (!pivotSpec) { pivot = info.pivot_auto?.slice() ?? [0, 0, 2]; return; }
|
||||
if (info.pivot_from && Math.abs(info.pivot_from.u - pivotSpec.u) < 1e-4 && Math.abs(info.pivot_from.v - pivotSpec.v) < 1e-4
|
||||
&& Math.abs(info.pivot_from.t - pivotSpec.t) < 1e-4) { pivot = info.pivot.slice(); return; }
|
||||
const f = nearestLoaded(pivotSpec.t);
|
||||
const found = f && pivotFromPixel(f, pivotSpec.u, pivotSpec.v);
|
||||
if (found) pivot = found;
|
||||
}
|
||||
|
||||
// ---------- frames ----------
|
||||
function nearestFrame(t) {
|
||||
let best = null;
|
||||
for (const f of frames) if (!best || Math.abs(f.t - t) < Math.abs(best.t - t)) best = f;
|
||||
return best;
|
||||
}
|
||||
function nearestLoaded(t) {
|
||||
let best = null;
|
||||
for (const f of frames) if (f.data && (!best || Math.abs(f.t - t) < Math.abs(best.t - t))) best = f;
|
||||
return best?.data ?? null;
|
||||
}
|
||||
async function loadAll(payload) {
|
||||
const order = [...frames].sort((a, b) => Math.abs(a.t - p) - Math.abs(b.t - p));
|
||||
let failed = 0, next = 0;
|
||||
// Four at a time, the frames nearest the time bar first.
|
||||
const worker = async () => {
|
||||
while (next < order.length) {
|
||||
const f = order[next++];
|
||||
if (info !== payload) return;
|
||||
try { f.data = await loadFrame(f.entry, payload, opts.view, opts.blob); } catch { failed++; }
|
||||
if (info !== payload) return;
|
||||
if (f.data && status !== "ready") { status = "ready"; resolvePivot(); }
|
||||
dirty = true;
|
||||
}
|
||||
};
|
||||
await Promise.all([worker(), worker(), worker(), worker()]);
|
||||
if (info !== payload) return;
|
||||
if (failed === frames.length) { info = null; frames = []; shown = null; status = "idle"; statusNote = "The last run's pictures are gone. Run it again to see your clip."; dirty = true; }
|
||||
}
|
||||
|
||||
// ---------- the camera's view ----------
|
||||
function fitPicture() {
|
||||
const L = lens(), sw = stage.clientWidth, sh = stage.clientHeight;
|
||||
if (!sw || !sh) return null;
|
||||
const s = Math.min(sw / L.W, sh / L.H), w = L.W * s, h = L.H * s, x = (sw - w) / 2, y = (sh - h) / 2;
|
||||
Object.assign(pic.style, { left: `${x}px`, top: `${y}px`, width: `${w}px`, height: `${h}px` });
|
||||
inset.hidden = !shown || sw < 300 || sh < MIN_STAGE;
|
||||
return { x, y, w, h };
|
||||
}
|
||||
function renderPreview(cam, fit) {
|
||||
const f = shown;
|
||||
if (!f) { pic.hidden = true; pivotMark.hidden = true; return; }
|
||||
pic.hidden = false;
|
||||
const { W, H, z, rgba, fx, fy, cx, cy } = f;
|
||||
if (!buf || buf.W !== W || buf.H !== H) {
|
||||
buf = { W, H, zbuf: new Float32Array(W * H), cbuf: new Uint32Array(W * H), ibuf: new Int32Array(W * H), fill: new Uint8Array(W * H), img: picCtx.createImageData(W, H) };
|
||||
pic.width = W; pic.height = H;
|
||||
buf.img = picCtx.createImageData(W, H);
|
||||
}
|
||||
const { zbuf, cbuf, ibuf, fill, img } = buf;
|
||||
const { R, C } = pose(cam, pivot);
|
||||
const [r0, r1, r2, r3, r4, r5, r6, r7, r8] = R, [Cx, Cy, Cz] = C;
|
||||
zbuf.fill(Infinity); ibuf.fill(-1);
|
||||
for (let y = 0, i = 0; y < H; y++) for (let x = 0; x < W; x++, i++) {
|
||||
const zz = z[i], c = rgba[i];
|
||||
for (let s = 0; s < 4; s++) {
|
||||
const X = (x + 0.5 + SUB[s][0] - cx) / fx * zz - Cx, Y = (y + 0.5 + SUB[s][1] - cy) / fy * zz - Cy, Z = zz - Cz;
|
||||
const zc = r2 * X + r5 * Y + r8 * Z;
|
||||
if (zc <= 1e-3) continue;
|
||||
const u = fx * (r0 * X + r3 * Y + r6 * Z) / zc + cx, v = fy * (r1 * X + r4 * Y + r7 * Z) / zc + cy;
|
||||
if (u < 0 || v < 0 || u >= W || v >= H) continue;
|
||||
const idx = (v | 0) * W + (u | 0);
|
||||
if (zc < zbuf[idx]) { zbuf[idx] = zc; cbuf[idx] = c; ibuf[idx] = i; }
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < W * H; i++) fill[i] = zbuf[i] < Infinity ? 1 : 0;
|
||||
// fill one-pixel cracks: a gap mostly surrounded by picture takes their average (two passes, like the node)
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
const was = fill.slice(), src = cbuf.slice();
|
||||
let any = false;
|
||||
for (let y = 1; y < H - 1; y++) for (let x = 1; x < W - 1; x++) {
|
||||
const i = y * W + x;
|
||||
if (was[i]) continue;
|
||||
let n = 0, r = 0, g = 0, b = 0;
|
||||
for (let oy = -1; oy <= 1; oy++) for (let ox = -1; ox <= 1; ox++) {
|
||||
const q = i + oy * W + ox;
|
||||
if (!was[q]) continue;
|
||||
const c = src[q]; n++; r += c & 255; g += (c >>> 8) & 255; b += (c >>> 16) & 255;
|
||||
}
|
||||
if (n >= 5) { cbuf[i] = 0xFF000000 | ((b / n) << 16) | ((g / n) << 8) | (r / n); fill[i] = 1; any = true; }
|
||||
}
|
||||
if (!any) break;
|
||||
}
|
||||
const o = new Uint32Array(img.data.buffer);
|
||||
let holes = 0;
|
||||
for (let i = 0; i < W * H; i++) { if (fill[i]) o[i] = cbuf[i]; else { o[i] = MAG; holes++; } }
|
||||
picCtx.putImageData(img, 0, 0);
|
||||
pinkPct = 100 * holes / (W * H);
|
||||
const q = toCam(R, C, pivot);
|
||||
if (fit && q[2] > 1e-3) {
|
||||
pivotMark.hidden = false;
|
||||
pivotMark.style.left = `${fit.x + fit.w * (fx * q[0] / q[2] + cx) / W}px`;
|
||||
pivotMark.style.top = `${fit.y + fit.h * (fy * q[1] / q[2] + cy) / H}px`;
|
||||
} else pivotMark.hidden = true;
|
||||
}
|
||||
|
||||
// ---------- the corner map: your scene from the side, and the camera in it ----------
|
||||
function viewer() {
|
||||
const P = pivot, d = Math.hypot(P[0], P[1], P[2]), T = [P[0] * 0.55, P[1] * 0.55, P[2] * 0.55];
|
||||
const R = rot(VIEW.az, VIEW.el), b = mv(R, [0, 0, -d * 2.3]);
|
||||
return { R, C: [T[0] + b[0], T[1] + b[1], T[2] + b[2]], f: 0.95 * out.width };
|
||||
}
|
||||
function proj(V, P) {
|
||||
const q = toCam(V.R, V.C, P);
|
||||
return q[2] <= 1e-3 ? null : [V.f * q[0] / q[2] + out.width / 2, V.f * q[1] / q[2] + out.height / 2, q[2]];
|
||||
}
|
||||
function frustum(V, cam, color, width) {
|
||||
const L = lens(), { R, C } = pose(cam, pivot), s = Math.hypot(...pivot) * 0.3;
|
||||
const corners = [[0, 0], [L.W, 0], [L.W, L.H], [0, L.H]].map(([x, y]) => {
|
||||
const l = mv(R, [(x - L.cx) / L.fx * s, (y - L.cy) / L.fy * s, s]);
|
||||
return proj(V, [C[0] + l[0], C[1] + l[1], C[2] + l[2]]);
|
||||
});
|
||||
const a = proj(V, C);
|
||||
if (!a || corners.some((c) => !c)) return;
|
||||
outCtx.strokeStyle = color; outCtx.lineWidth = width; outCtx.beginPath();
|
||||
for (const c of corners) { outCtx.moveTo(a[0], a[1]); outCtx.lineTo(c[0], c[1]); }
|
||||
outCtx.moveTo(corners[0][0], corners[0][1]);
|
||||
for (const c of [...corners.slice(1), corners[0]]) outCtx.lineTo(c[0], c[1]);
|
||||
outCtx.stroke();
|
||||
// a small roof on the top edge, so up and down never mix
|
||||
outCtx.fillStyle = color; outCtx.beginPath(); outCtx.moveTo(corners[0][0], corners[0][1]); outCtx.lineTo(corners[1][0], corners[1][1]);
|
||||
const up = mv(R, [0, -L.H / 2 / L.fy * s * 1.35, s]), ut = proj(V, [C[0] + up[0], C[1] + up[1], C[2] + up[2]]);
|
||||
if (ut) { outCtx.lineTo(ut[0], ut[1]); outCtx.closePath(); outCtx.globalAlpha = 0.6; outCtx.fill(); outCtx.globalAlpha = 1; }
|
||||
}
|
||||
function renderOutside(cam) {
|
||||
if (inset.hidden) return;
|
||||
const w = out.width, h = out.height, V = viewer(), o = new Uint32Array(outImg.data.buffer);
|
||||
o.fill(0xFF100D0C); outZ.fill(Infinity);
|
||||
const f = shown;
|
||||
if (f) {
|
||||
const { W, H, z, valid, rgba, zcap, fx, fy, cx, cy } = f, [r0, r1, r2, r3, r4, r5, r6, r7, r8] = V.R, [Cx, Cy, Cz] = V.C;
|
||||
for (let y = 0; y < H; y += 2) for (let x = 0; x < W; x += 2) {
|
||||
const i = y * W + x, zz = z[i];
|
||||
if (!valid[i] || zz > zcap) continue;
|
||||
const dx = (x + 0.5 - cx) / fx * zz - Cx, dy = (y + 0.5 - cy) / fy * zz - Cy, dz = zz - Cz, zc = r2 * dx + r5 * dy + r8 * dz;
|
||||
if (zc <= 1e-3) continue;
|
||||
const u = V.f * (r0 * dx + r3 * dy + r6 * dz) / zc + w / 2, v = V.f * (r1 * dx + r4 * dy + r7 * dz) / zc + h / 2;
|
||||
const sz = clamp(Math.round(V.f * (zz / fx * 2.2) / zc), 1, 4), x0 = (u - sz / 2) | 0, y0 = (v - sz / 2) | 0;
|
||||
for (let yy = y0; yy < y0 + sz; yy++) {
|
||||
if (yy < 0 || yy >= h) continue;
|
||||
for (let xx = x0; xx < x0 + sz; xx++) { if (xx < 0 || xx >= w) continue; const q = yy * w + xx; if (zc < outZ[q]) { outZ[q] = zc; o[q] = rgba[i]; } }
|
||||
}
|
||||
}
|
||||
}
|
||||
outCtx.putImageData(outImg, 0, 0);
|
||||
frustum(V, { ...K0 }, "rgba(200,210,210,.55)", 1.5);
|
||||
const project = (c) => proj(V, pose(c, pivot).C);
|
||||
outCtx.strokeStyle = "rgba(79,179,169,.9)"; outCtx.lineWidth = 2; outCtx.setLineDash([5, 4]); outCtx.beginPath();
|
||||
let first = true;
|
||||
for (let i = 0; i <= 48; i++) {
|
||||
const q = project(camAt(keys, i / 48));
|
||||
if (!q) { first = true; continue; }
|
||||
if (first) outCtx.moveTo(q[0], q[1]); else outCtx.lineTo(q[0], q[1]);
|
||||
first = false;
|
||||
}
|
||||
outCtx.stroke(); outCtx.setLineDash([]);
|
||||
for (const k of keys) {
|
||||
const q = project(k);
|
||||
if (!q) continue;
|
||||
outCtx.save(); outCtx.translate(q[0], q[1]); outCtx.rotate(Math.PI / 4);
|
||||
outCtx.fillStyle = Math.abs(k.t - p) < SAME_KEY ? "#bfe9e4" : "#4fb3a9"; outCtx.strokeStyle = "#0c0f10"; outCtx.lineWidth = 1.5;
|
||||
outCtx.fillRect(-5, -5, 10, 10); outCtx.strokeRect(-5, -5, 10, 10); outCtx.restore();
|
||||
}
|
||||
const pv = proj(V, pivot), cc = project(cam);
|
||||
if (pv && cc) { outCtx.strokeStyle = "rgba(79,179,169,.45)"; outCtx.lineWidth = 1; outCtx.beginPath(); outCtx.moveTo(cc[0], cc[1]); outCtx.lineTo(pv[0], pv[1]); outCtx.stroke(); }
|
||||
frustum(V, cam, "#4fb3a9", 2.5);
|
||||
}
|
||||
|
||||
// ---------- the axis ball ----------
|
||||
function drawGizmo(cam) {
|
||||
const g = gctx, c = GZ / 2, L = 21, R = rot(cam.az, cam.el);
|
||||
g.setTransform(2, 0, 0, 2, 0, 0); g.clearRect(0, 0, GZ, GZ);
|
||||
g.fillStyle = gHot ? "rgba(146,162,161,.26)" : "rgba(12,15,16,.42)"; g.beginPath(); g.arc(c, c, c - 1, 0, 7); g.fill();
|
||||
gBalls = [];
|
||||
for (const a of AXES) for (const sg of [1, -1]) {
|
||||
const w = [a.v[0] * sg, a.v[1] * sg, a.v[2] * sg]; // the direction as the camera sees it
|
||||
const q = [R[0] * w[0] + R[3] * w[1] + R[6] * w[2], R[1] * w[0] + R[4] * w[1] + R[7] * w[2], R[2] * w[0] + R[5] * w[1] + R[8] * w[2]];
|
||||
gBalls.push({ a, sg, x: c + q[0] * L, y: c + q[1] * L, z: q[2], view: sg > 0 ? a.pos : a.neg });
|
||||
}
|
||||
gBalls.sort((m, n) => n.z - m.z); // far balls first, near ones on top
|
||||
for (const b of gBalls) {
|
||||
if (b.sg > 0) { g.strokeStyle = b.a.col; g.lineWidth = 2; g.beginPath(); g.moveTo(c, c); g.lineTo(b.x, b.y); g.stroke(); }
|
||||
g.beginPath(); g.arc(b.x, b.y, b.sg > 0 ? 7 : 6, 0, 7);
|
||||
if (b.sg > 0) {
|
||||
g.fillStyle = b.a.col; g.fill();
|
||||
g.fillStyle = "#0c0f10"; g.font = "bold 9px system-ui, sans-serif"; g.textAlign = "center"; g.textBaseline = "middle"; g.fillText(b.a.n, b.x, b.y + 0.5);
|
||||
} else { g.globalAlpha = 0.35; g.fillStyle = b.a.col; g.fill(); g.globalAlpha = 1; g.strokeStyle = b.a.col; g.lineWidth = 1.2; g.stroke(); }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- gestures ----------
|
||||
const picRect = () => pic.getBoundingClientRect();
|
||||
function panBy(cam, dx, dy) { // the spot you circle stays under your finger
|
||||
const L = lens(), r = picRect(), d = Math.hypot(...pivot), zz = Math.max(0.05 * d, pivot[2] + cam.back * d);
|
||||
const du = dx * L.W / (r.width || 1), dv = dy * L.H / (r.height || 1);
|
||||
return { px: (cam.px || 0) - du * zz / (L.fx * d), py: (cam.py || 0) - dv * zz / (L.fy * d) };
|
||||
}
|
||||
function dragTo(kind, cam, dx, dy) {
|
||||
const r = picRect(), w = r.width || stage.clientWidth || 1, h = r.height || stage.clientHeight || 1;
|
||||
if (kind === "orbit") setCam({ ...cam, az: cam.az - dx * 90 / w, el: cam.el + dy * 90 / w }); // the picture follows your finger
|
||||
else if (kind === "pan") setCam({ ...cam, ...panBy(cam, dx, dy) });
|
||||
else if (kind === "zoom") setCam({ ...cam, back: cam.back + dy / h * 2.4 }); // up = in, down = back
|
||||
}
|
||||
const kindFor = (e) => (e.shiftKey ? "pan" : (e.ctrlKey || e.metaKey) ? "zoom" : "orbit");
|
||||
(() => {
|
||||
const pts = new Map();
|
||||
let g = null;
|
||||
const begin = (kind, carry) => {
|
||||
const v = [...pts.values()], cam = camAt(keys, p);
|
||||
if (v.length >= 2) {
|
||||
const [a, b] = v;
|
||||
g = { kind: "pinch", cam, d: Math.max(8, Math.hypot(a[0] - b[0], a[1] - b[1])), mx: (a[0] + b[0]) / 2, my: (a[1] + b[1]) / 2, moved: true, multi: true };
|
||||
} else if (v.length === 1) g = { kind, cam, x: v[0][0], y: v[0][1], moved: Boolean(carry), multi: Boolean(carry) };
|
||||
else g = null;
|
||||
};
|
||||
pic.addEventListener("mousedown", (e) => { if (e.button === 1) e.preventDefault(); }); // no auto-scroll on middle click
|
||||
pic.addEventListener("pointerdown", (e) => {
|
||||
if (e.button > 1) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
pic.setPointerCapture(e.pointerId); pts.set(e.pointerId, [e.clientX, e.clientY]); stopPlay();
|
||||
begin(kindFor(e));
|
||||
});
|
||||
pic.addEventListener("pointermove", (e) => {
|
||||
if (!pts.has(e.pointerId) || !g) return;
|
||||
pts.set(e.pointerId, [e.clientX, e.clientY]);
|
||||
if (g.kind === "pinch") {
|
||||
const [a, b] = [...pts.values()], d = Math.max(8, Math.hypot(a[0] - b[0], a[1] - b[1]));
|
||||
const c = { ...g.cam, back: g.cam.back - Math.log(d / g.d) * 0.9 };
|
||||
Object.assign(c, panBy(g.cam, (a[0] + b[0]) / 2 - g.mx, (a[1] + b[1]) / 2 - g.my));
|
||||
setCam(c);
|
||||
return;
|
||||
}
|
||||
const dx = e.clientX - g.x, dy = e.clientY - g.y;
|
||||
if (!g.moved && Math.hypot(dx, dy) < 5) return;
|
||||
g.moved = true;
|
||||
dragTo(g.kind, g.cam, dx, dy);
|
||||
});
|
||||
const up = (e) => {
|
||||
if (!pts.has(e.pointerId)) return;
|
||||
pts.delete(e.pointerId);
|
||||
if (pts.size === 0) {
|
||||
if (g && !g.moved && !g.multi && g.kind === "orbit" && e.type === "pointerup" && e.button === 0) pickPivot(e);
|
||||
g = null;
|
||||
return;
|
||||
}
|
||||
begin("orbit", true); // a finger left: carry on from here
|
||||
};
|
||||
pic.addEventListener("pointerup", up);
|
||||
pic.addEventListener("pointercancel", up);
|
||||
})();
|
||||
function pickPivot(e) { // tap: circle round what you tapped
|
||||
const f = shown, r = picRect();
|
||||
if (!f || !buf || !r.width) return;
|
||||
const x = ((e.clientX - r.left) / r.width * f.W) | 0, y = ((e.clientY - r.top) / r.height * f.H) | 0;
|
||||
if (x < 0 || y < 0 || x >= f.W || y >= f.H) return;
|
||||
let best = -1;
|
||||
for (let rr = 0; rr <= 3 && best < 0; rr++) for (let oy = -rr; oy <= rr && best < 0; oy++) for (let ox = -rr; ox <= rr; ox++) {
|
||||
const xx = x + ox, yy = y + oy;
|
||||
if (xx < 0 || yy < 0 || xx >= f.W || yy >= f.H) continue;
|
||||
const i = buf.ibuf[yy * f.W + xx];
|
||||
if (i >= 0) { best = i; break; }
|
||||
}
|
||||
if (best < 0 || !f.valid[best]) return; // sky: nothing to circle
|
||||
const spec = { u: ((best % f.W) + 0.5) / f.W, v: (Math.floor(best / f.W) + 0.5) / f.H, t: f.t };
|
||||
const found = pivotFromPixel(f, spec.u, spec.v);
|
||||
if (!found) return;
|
||||
pivotSpec = spec; pivot = found;
|
||||
writeSoon();
|
||||
}
|
||||
stage.addEventListener("wheel", (e) => {
|
||||
e.preventDefault(); e.stopPropagation(); stopPlay();
|
||||
const unit = e.deltaMode === 1 ? 33 : e.deltaMode === 2 ? 400 : 1, c = camAt(keys, p);
|
||||
if (e.shiftKey) { const a = (e.deltaY || e.deltaX) * unit; setCam({ ...c, ...panBy(c, 0, -a * 0.4) }); return; } // Shift + wheel pans up and down
|
||||
setCam({ ...c, back: c.back + e.deltaY * unit * (e.ctrlKey ? 0.01 : 0.0008) }); // wheel, and a trackpad pinch (Ctrl), zoom
|
||||
}, { passive: false });
|
||||
stage.addEventListener("gesturestart", (e) => e.preventDefault()); // Safari: never zoom the page instead
|
||||
stage.addEventListener("pointerdown", (e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); } });
|
||||
stage.addEventListener("pointerenter", () => { hovered = true; });
|
||||
stage.addEventListener("pointerleave", () => { hovered = false; });
|
||||
// Blender's numpad, while the pointer is over the picture.
|
||||
const onKey = (e) => {
|
||||
if (!hovered || !alive || e.altKey || e.target?.closest?.("input, textarea, select, [contenteditable]")) return;
|
||||
const c = camAt(keys, p), w = picRect().width || 300, step = 15, pan = e.ctrlKey || e.metaKey;
|
||||
const go = { Numpad4: [-1, 0], Numpad6: [1, 0], Numpad8: [0, 1], Numpad2: [0, -1] }[e.code];
|
||||
if (go) setCam(pan ? { ...c, ...panBy(c, -go[0] * w * 0.1, go[1] * w * 0.1) } : { ...c, az: c.az + go[0] * step, el: c.el + go[1] * step });
|
||||
else if (e.code === "NumpadAdd" || e.code === "NumpadSubtract") setCam({ ...c, back: c.back + (e.code === "NumpadAdd" ? -0.1 : 0.1) });
|
||||
else if (e.code === "Numpad1" && !pan) setCam({ ...c, az: 0, el: 0 }); // front
|
||||
else if (e.code === "Numpad3") setCam({ ...c, az: pan ? -90 : 90, el: 0 }); // right, Ctrl: left
|
||||
else if (e.code === "Numpad7") setCam({ ...c, az: 0, el: pan ? LIM.el[0] : LIM.el[1] }); // top, Ctrl: bottom
|
||||
else if (e.code === "Numpad0") setCam({ ...K0 }); // your own shot
|
||||
else if (e.key === "Escape" && !help.hidden) { openHelp(false); }
|
||||
else return;
|
||||
e.preventDefault(); e.stopPropagation(); stopPlay();
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
// Space plays and pauses, Home goes to the start, while the pointer is over the face.
|
||||
// Held there, Space would otherwise start a canvas pan.
|
||||
root.addEventListener("pointerenter", () => { overFace = true; });
|
||||
root.addEventListener("pointerleave", () => { overFace = false; });
|
||||
const onPlayKey = (e) => {
|
||||
if (!overFace || !alive || e.altKey || e.ctrlKey || e.metaKey || e.repeat && e.code === "Space") return;
|
||||
if (e.target?.closest?.("input, textarea, select, button, [contenteditable]")) return;
|
||||
if (e.key === "Home") toStart();
|
||||
else if (e.code === "Space") play.click();
|
||||
else return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
};
|
||||
window.addEventListener("keydown", onPlayKey, true);
|
||||
|
||||
// the axis ball: drag turns, a tap on an axis looks from that side
|
||||
(() => {
|
||||
let g = null;
|
||||
gizmo.addEventListener("pointerenter", () => { gHot = true; dirty = true; });
|
||||
gizmo.addEventListener("pointerleave", () => { if (!g) { gHot = false; dirty = true; } });
|
||||
gizmo.addEventListener("pointerdown", (e) => {
|
||||
if (e.button > 1) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
gizmo.setPointerCapture(e.pointerId); stopPlay(); gHot = true; dirty = true;
|
||||
g = { id: e.pointerId, x: e.clientX, y: e.clientY, cam: camAt(keys, p), moved: false };
|
||||
});
|
||||
gizmo.addEventListener("pointermove", (e) => {
|
||||
if (!g || e.pointerId !== g.id) return;
|
||||
const dx = e.clientX - g.x, dy = e.clientY - g.y;
|
||||
if (!g.moved && Math.hypot(dx, dy) < 4) return;
|
||||
g.moved = true;
|
||||
dragTo("orbit", g.cam, dx * 1.5, dy * 1.5);
|
||||
});
|
||||
const end = (e) => {
|
||||
if (!g || e.pointerId !== g.id) return;
|
||||
if (!g.moved && e.type === "pointerup" && (e.pointerType === "mouse" || !tipEl || tipEl.hidden)) {
|
||||
const r = gizmo.getBoundingClientRect(), x = (e.clientX - r.left) * GZ / r.width, y = (e.clientY - r.top) * GZ / r.height;
|
||||
let hit = null;
|
||||
for (const b of gBalls) if (Math.hypot(b.x - x, b.y - y) <= 9) hit = b;
|
||||
if (hit?.view) setCam({ ...camAt(keys, p), ...hit.view });
|
||||
else if (hit) flash("Nothing behind: the camera never saw the back.");
|
||||
}
|
||||
g = null;
|
||||
if (e.pointerType !== "mouse") gHot = false;
|
||||
dirty = true;
|
||||
};
|
||||
gizmo.addEventListener("pointerup", end);
|
||||
gizmo.addEventListener("pointercancel", end);
|
||||
})();
|
||||
for (const kind of ["zoom", "pan", "shot"]) {
|
||||
const b = el("button", kind, { html: NAV_ICONS[kind], "data-tip": NAV_TIPS[kind], "aria-label": NAV_TIPS[kind] });
|
||||
nav.append(b);
|
||||
if (kind === "shot") { b.onclick = (e) => { e.stopPropagation(); stopPlay(); setCam({ ...K0 }); }; continue; }
|
||||
let g = null;
|
||||
b.addEventListener("pointerdown", (e) => {
|
||||
if (e.button > 1) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
b.setPointerCapture(e.pointerId); stopPlay(); b.classList.add("on");
|
||||
g = { id: e.pointerId, x: e.clientX, y: e.clientY, cam: camAt(keys, p) };
|
||||
});
|
||||
b.addEventListener("pointermove", (e) => {
|
||||
if (!g || e.pointerId !== g.id) return;
|
||||
const m = kind === "pan" ? 1 : 1.5;
|
||||
dragTo(kind, g.cam, (e.clientX - g.x) * m, (e.clientY - g.y) * m);
|
||||
});
|
||||
const end = (e) => { if (g && e.pointerId === g.id) { g = null; b.classList.remove("on"); } };
|
||||
b.addEventListener("pointerup", end);
|
||||
b.addEventListener("pointercancel", end);
|
||||
}
|
||||
function flash(text) { flashText = text; flashUntil = performance.now() + 2600; dirty = true; setTimeout(() => { dirty = true; }, 2700); }
|
||||
|
||||
// ---------- the ? : the whole how-to, out of the way until asked for ----------
|
||||
function buildHelp() {
|
||||
help.innerHTML = "";
|
||||
const h = el("div", "acm-help-h", { text: "How to steer" });
|
||||
const x = el("button", "acm-help-x", { text: "×", "aria-label": "Close" });
|
||||
x.onclick = () => openHelp(false);
|
||||
h.append(x);
|
||||
const dl = el("dl");
|
||||
const rows = [
|
||||
["Picture", "Drag to turn the camera round. Pinch or scroll to pull back or push in; two fingers or Shift pan. Tap a spot to circle round it."],
|
||||
["Top right", "Drag the axis ball to turn, or tap an axis to look from that side. Drag the magnifier to zoom and the hand to pan. The camera puts you back on your own shot."],
|
||||
["Keys", "Click the time bar to put a key there, or drag the time bar and move the camera. ◆ adds or removes a key, ◀ ▶ jump between keys. Drag a key along the bar to change its time."],
|
||||
["Play", "⏮ goes back to the start, ▶ plays, and the loop button plays it round and round."],
|
||||
["Mouse", "Like Blender: middle-drag turns, Shift pans, Ctrl zooms, the wheel zooms. Numpad 4 6 8 2 turn, + − zoom, 1 3 7 front, right, top, 0 your own shot. Space plays, Home goes to the start."],
|
||||
["Pink", "What the camera never saw. The CrossView-Warp LoRA paints it in."],
|
||||
];
|
||||
for (const [k, v] of rows) dl.append(el("dt", "", { text: k }), el("dd", "", { text: v }));
|
||||
help.append(h, dl);
|
||||
}
|
||||
function openHelp(open) { if (open) buildHelp(); help.hidden = !open; helpBtn.setAttribute("aria-expanded", String(open)); }
|
||||
helpBtn.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||||
helpBtn.onclick = (e) => { e.stopPropagation(); openHelp(help.hidden); };
|
||||
const closeHelp = (e) => { if (!help.hidden && !help.contains(e.target) && !helpBtn.contains(e.target)) openHelp(false); };
|
||||
document.addEventListener("pointerdown", closeHelp, true);
|
||||
|
||||
// ---------- the time bar ----------
|
||||
let keyEls = [];
|
||||
function drawTrack() {
|
||||
for (const k of keyEls) k.remove();
|
||||
keyEls = [];
|
||||
for (const k of keys) {
|
||||
const d = el("div", `acm-key${Math.abs(k.t - p) < SAME_KEY ? " on" : ""}`);
|
||||
d.style.left = `${k.t * 100}%`;
|
||||
inner.insertBefore(d, head);
|
||||
keyEls.push(d);
|
||||
}
|
||||
head.style.left = `${p * 100}%`;
|
||||
const count = info?.count ?? 0;
|
||||
time.textContent = count ? `${Math.round(p * (count - 1)) + 1} / ${count}` : `${Math.round(p * 100)}%`;
|
||||
// the diamond is filled while the time bar sits on a key, hollow when it does not
|
||||
const on = keyAt(keys, p) >= 0;
|
||||
toggleKey.classList.toggle("on", on);
|
||||
const tipText = !on ? "Add a key here" : keys.length > 1 ? "Remove this key" : "The last key stays";
|
||||
toggleKey.dataset.tip = tipText; toggleKey.setAttribute("aria-label", tipText);
|
||||
toggleKey.disabled = on && keys.length < 2;
|
||||
prevKey.disabled = !keys.some((k) => k.t < p - SAME_KEY);
|
||||
nextKey.disabled = !keys.some((k) => k.t > p + SAME_KEY);
|
||||
}
|
||||
(() => {
|
||||
let drag = null;
|
||||
const tAt = (e) => { const r = inner.getBoundingClientRect(); return clamp((e.clientX - r.left) / (r.width || 1), 0, 1); };
|
||||
const set = (e) => { let t = tAt(e); for (const k of keys) if (Math.abs(k.t - t) < 0.03) t = k.t; p = t; dirty = true; };
|
||||
track.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
track.setPointerCapture(e.pointerId); stopPlay();
|
||||
const r = inner.getBoundingClientRect();
|
||||
let hit = null, best = 11; // press on a key to grab it; anywhere else moves the time bar
|
||||
for (const k of keys) { const d = Math.abs(r.left + k.t * r.width - e.clientX); if (d < best) { best = d; hit = k; } }
|
||||
drag = { key: hit, x: e.clientX, moved: false };
|
||||
if (hit) { p = hit.t; dirty = true; } else set(e);
|
||||
});
|
||||
track.addEventListener("pointermove", (e) => {
|
||||
if (!drag) return;
|
||||
if (!drag.key) { if (Math.abs(e.clientX - drag.x) >= 4) drag.moved = true; set(e); return; }
|
||||
if (!drag.moved && Math.abs(e.clientX - drag.x) < 4) return;
|
||||
drag.moved = true; // a key slides between its neighbours and never jumps over one
|
||||
const i = keys.indexOf(drag.key), lo = i > 0 ? keys[i - 1].t + 0.02 : 0, hi = i < keys.length - 1 ? keys[i + 1].t - 0.02 : 1;
|
||||
drag.key.t = clamp(tAt(e), lo, hi); p = drag.key.t;
|
||||
writeSoon();
|
||||
});
|
||||
// A click on an empty spot of the bar puts a key there; a drag only moves the time bar.
|
||||
const end = (e) => {
|
||||
if (drag && !drag.key && !drag.moved && e.type === "pointerup" && keyAt(keys, p) < 0) setCam(camAt(keys, p));
|
||||
drag = null;
|
||||
};
|
||||
track.addEventListener("pointerup", end);
|
||||
track.addEventListener("pointercancel", end);
|
||||
})();
|
||||
const playLabel = (on) => { play.textContent = on ? "❚❚" : "▶"; play.dataset.tip = on ? "Pause" : "Play"; play.setAttribute("aria-label", play.dataset.tip); };
|
||||
function stopPlay() { if (playing) { playing = false; playLabel(false); } }
|
||||
play.onclick = () => {
|
||||
if (playing) { stopPlay(); return; }
|
||||
if (p >= 0.999) p = 0;
|
||||
playing = true; lastT = performance.now(); playLabel(true);
|
||||
};
|
||||
loop.onclick = () => {
|
||||
looping = !looping;
|
||||
loop.setAttribute("aria-pressed", String(looping));
|
||||
loop.dataset.tip = looping ? "Looping: click to play once" : "Play on a loop";
|
||||
loop.setAttribute("aria-label", loop.dataset.tip);
|
||||
};
|
||||
const toStart = () => { p = 0; lastT = performance.now(); dirty = true; }; // playing carries on from the start
|
||||
toStartBtn.onclick = toStart;
|
||||
toggleKey.onclick = () => { // add a key at the time bar, or remove the one it sits on
|
||||
stopPlay();
|
||||
const i = keyAt(keys, p);
|
||||
if (i < 0) setCam(camAt(keys, p));
|
||||
else if (keys.length > 1) { keys.splice(i, 1); writeSoon(); }
|
||||
};
|
||||
const jump = (dir) => {
|
||||
stopPlay();
|
||||
const k = dir < 0 ? [...keys].reverse().find((x) => x.t < p - SAME_KEY) : keys.find((x) => x.t > p + SAME_KEY);
|
||||
if (k) { p = k.t; dirty = true; }
|
||||
};
|
||||
prevKey.onclick = () => jump(-1);
|
||||
nextKey.onclick = () => jump(1);
|
||||
reset.onclick = () => {
|
||||
stopPlay();
|
||||
keys.length = 0; keys.push({ t: 0, ...K0 });
|
||||
pivotSpec = null; resolvePivot(); p = 0;
|
||||
writeSoon();
|
||||
};
|
||||
for (const b of [play, prevKey, toggleKey, nextKey, reset]) b.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||||
|
||||
// ---------- before there is a clip to look at ----------
|
||||
function drawEmpty() {
|
||||
const show = status !== "ready";
|
||||
empty.hidden = !show;
|
||||
if (!show) return;
|
||||
const key = `${status}|${statusNote}`;
|
||||
if (empty.dataset.key === key) return;
|
||||
empty.dataset.key = key;
|
||||
empty.innerHTML = "";
|
||||
if (status === "running") {
|
||||
empty.append(el("b", "", { text: "Reading the depth…" }), el("span", "", { text: "MoGe looks at every frame once. A long clip takes a while." }));
|
||||
return;
|
||||
}
|
||||
if (status === "loading") { empty.append(el("b", "", { text: "Loading your clip…" })); return; }
|
||||
const go = el("button", "acm-btn primary", { text: "Show my clip", "data-tip": "Runs this node and the depth before it, not the whole workflow" });
|
||||
go.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||||
go.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
status = "running"; statusNote = ""; dirty = true;
|
||||
let ok = false;
|
||||
try { ok = await opts.run(); } catch { ok = false; }
|
||||
if (!ok && status === "running") { status = "idle"; statusNote = "It did not run. Pick a clip in Load Video, and check MoGe Inference is connected."; dirty = true; }
|
||||
};
|
||||
empty.append(el("b", "", { text: "Steer the camera on your clip" }), el("span", "", { text: statusNote || "Run the depth once to see your first frame here." }), go);
|
||||
}
|
||||
|
||||
// ---------- loop ----------
|
||||
function frame(now) {
|
||||
if (!alive) return;
|
||||
requestAnimationFrame(frame);
|
||||
if (!root.isConnected) return;
|
||||
readCamera();
|
||||
if (playing) {
|
||||
const dur = Math.max(1, (info?.count ?? 121) / FPS);
|
||||
p += (now - lastT) / 1000 / dur; lastT = now; dirty = true;
|
||||
if (p >= 1) { if (looping) p %= 1; else { p = 1; stopPlay(); } }
|
||||
}
|
||||
flushWrite();
|
||||
if (!dirty) return;
|
||||
if (root.offsetParent === null && !root.getClientRects().length) return;
|
||||
dirty = false;
|
||||
const cam = camAt(keys, p);
|
||||
const want = nearestLoaded(p);
|
||||
// a new run's frames take over as they load; until then the last ones stay up
|
||||
if (want && want !== shown) shown = want;
|
||||
if (!info) shown = null;
|
||||
const fit = fitPicture();
|
||||
drawEmpty();
|
||||
renderPreview(cam, fit);
|
||||
drawGizmo(cam);
|
||||
renderOutside(cam);
|
||||
drawTrack();
|
||||
where.textContent = describe(cam);
|
||||
pink.textContent = shown ? `pink ${pinkPct.toFixed(0)}%` : "";
|
||||
const over = pastLoraRange(cam), fl = performance.now() < flashUntil;
|
||||
const warnText = fl ? flashText : over ? "Past the LoRA's range: it learned up to 45° of turn, 30° up and 20° down." : "";
|
||||
if (warn.textContent !== warnText) { warn.textContent = warnText; warn.dataset.tip = warnText; }
|
||||
if (!warnText) delete warn.dataset.tip;
|
||||
back.set(cam.back);
|
||||
}
|
||||
const resize = new ResizeObserver(() => { dirty = true; });
|
||||
resize.observe(stage);
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
return {
|
||||
root,
|
||||
// What a run of the node sent back: the frames to look at.
|
||||
takeScene(payload) {
|
||||
if (!payload || !Array.isArray(payload.frames) || !payload.frames.length) return;
|
||||
info = payload;
|
||||
frames = payload.frames.map((entry) => ({ t: entry.t, entry, data: null }));
|
||||
shown = null;
|
||||
if (status !== "ready") status = "loading";
|
||||
resolvePivot();
|
||||
dirty = true;
|
||||
loadAll(payload);
|
||||
},
|
||||
runFailed(note) {
|
||||
if (status !== "running") return;
|
||||
status = "idle"; statusNote = note || "It did not run. Pick a clip in Load Video, and check MoGe Inference is connected."; dirty = true;
|
||||
},
|
||||
running() { if (status !== "ready") { status = "running"; dirty = true; } },
|
||||
refresh() { lastText = null; dirty = true; },
|
||||
get hasScene() { return Boolean(info); },
|
||||
// The least height the face needs: the picture's least, and the controls as they measure.
|
||||
floor() { return MIN_STAGE + (controls.offsetHeight || CONTROLS_HEIGHT); },
|
||||
destroy() {
|
||||
alive = false;
|
||||
resize.disconnect();
|
||||
window.removeEventListener("keydown", onKey, true);
|
||||
window.removeEventListener("keydown", onPlayKey, true);
|
||||
document.removeEventListener("pointerdown", closeHelp, true);
|
||||
hideTip();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
// Camera Move: the shot on the node, and on the box (subgraph node) that holds it.
|
||||
//
|
||||
// The face itself is face.mjs. This file mounts it, keeps it pointed at
|
||||
// wherever the camera keys are kept, runs the node alone when the face asks
|
||||
// for the clip, and hands it what a run sent back.
|
||||
//
|
||||
// Where the keys are kept:
|
||||
// - on the node, in its own `camera` setting;
|
||||
// - once `camera` is brought up to a box (a subgraph input linked to it),
|
||||
// in the box's own copy of the setting, one copy per box. The face then
|
||||
// sits on the box too, and the node inside shows that box's keys.
|
||||
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange, showToast } from "../shared/index.mjs";
|
||||
import { executionIdOf, isSwitchedOff } from "../shared/graph_ids.mjs";
|
||||
import { SUBGRAPH_INPUT_ID, boxesOf, hostBox, workflowNodes } from "../shared/lora_box.mjs";
|
||||
import { WIDGET_FRAME, ensureNodeMinHeight, fillNodeHeight } from "../shared/panel_layout.mjs";
|
||||
import { hideWidget } from "../shared/widget_visibility.mjs";
|
||||
import { CONTROLS_HEIGHT, MIN_STAGE, MIN_WIDTH, mountCameraFace } from "./face.mjs";
|
||||
import { mountBoxCard } from "./box_card.mjs";
|
||||
import { CARD_WIDGET } from "../shared/widget_card.mjs";
|
||||
|
||||
const NODE_CLASS = "AUSBOSS_NODES_CameraMove";
|
||||
const CAMERA = "camera";
|
||||
const UI_KEY = "ausboss_camera_scene";
|
||||
const FACE_WIDGET = "ausboss_camera_move";
|
||||
const OPENING_WIDTH = 460;
|
||||
const OPENING_ROOM = 90; // a new node opens this much taller than its floor, so a wide clip fills the picture
|
||||
const MIN_HEIGHT = MIN_STAGE + CONTROLS_HEIGHT + WIDGET_FRAME;
|
||||
const mounts = new Set();
|
||||
|
||||
const rootGraph = () => app.rootGraph ?? app.graph;
|
||||
const isMove = (node) => node?.type === NODE_CLASS || node?.comfyClass === NODE_CLASS;
|
||||
const same = (a, b) => a != null && b != null && String(a) === String(b);
|
||||
const linkOf = (graph, id) => (id == null || !graph ? null : graph.getLink?.(id) ?? graph.links?.get?.(id) ?? graph.links?.[id] ?? null);
|
||||
const nodeOf = (graph, id) => graph?.getNodeById?.(id) ?? (graph?.nodes ?? graph?._nodes ?? []).find((node) => same(node?.id, id)) ?? null;
|
||||
const ownCamera = (node) => node?.widgets?.find((widget) => widget?.name === CAMERA) ?? null;
|
||||
const boxCopy = (box, name) => (box?.widgets ?? []).find((widget) => widget?.name === name && typeof widget.value === "string") ?? null;
|
||||
|
||||
// The subgraph input that brings a node's camera in from its box, or null.
|
||||
function cameraInputName(node) {
|
||||
const graph = node?.graph;
|
||||
const index = (node?.inputs ?? []).findIndex((input) => input?.name === CAMERA);
|
||||
if (!graph || index < 0) return null;
|
||||
const link = linkOf(graph, node.inputs[index].link);
|
||||
if (!link || !same(link.origin_id, SUBGRAPH_INPUT_ID)) return null;
|
||||
return graph.inputs?.[link.origin_slot]?.name ?? null;
|
||||
}
|
||||
|
||||
// The first Camera Move inside a box whose camera comes up to the box: { name, inner }.
|
||||
function boxCamera(box) {
|
||||
const subgraph = box?.subgraph;
|
||||
for (const input of subgraph?.inputs ?? []) {
|
||||
for (const id of input?.linkIds ?? []) {
|
||||
const link = linkOf(subgraph, id);
|
||||
const inner = link && nodeOf(subgraph, link.target_id);
|
||||
if (isMove(inner) && inner.inputs?.[link.target_slot]?.name === CAMERA) return { name: input.name, inner };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Where a face reads and writes the keys, and which run of the node it shows.
|
||||
function sourceOf(mount) {
|
||||
const root = rootGraph();
|
||||
if (mount.box) {
|
||||
const widget = boxCopy(mount.box, mount.inputName);
|
||||
if (widget) hideWidget(widget);
|
||||
const at = executionIdOf(root, mount.box);
|
||||
return { widget, id: at ? `${at}:${mount.inner.id}` : null, holder: mount.box };
|
||||
}
|
||||
const node = mount.inner;
|
||||
const fed = cameraInputName(node);
|
||||
if (fed) {
|
||||
const box = hostBox(node.graph, boxesOf(node.graph, workflowNodes(root)), null);
|
||||
const widget = box && boxCopy(box, fed);
|
||||
const at = box && executionIdOf(root, box);
|
||||
if (widget) return { widget, id: at ? `${at}:${node.id}` : executionIdOf(root, node), holder: box };
|
||||
}
|
||||
return { widget: ownCamera(node), id: executionIdOf(root, node), holder: node };
|
||||
}
|
||||
|
||||
const viewRoute = (file) => `/view?${new URLSearchParams({ filename: file.filename, type: file.type || "temp", subfolder: file.subfolder || "" })}`;
|
||||
const viewUrl = (file) => api.apiURL(viewRoute(file));
|
||||
// The depth picture's exact bytes, through ComfyUI's own request helper.
|
||||
const viewBlob = async (file) => {
|
||||
const response = await api.fetchApi(viewRoute(file));
|
||||
if (!response.ok) throw new Error(`depth ${response.status}`);
|
||||
return response.blob();
|
||||
};
|
||||
|
||||
function locatorOf(node) {
|
||||
const graph = node?.graph;
|
||||
if (!graph || node.id == null) return null;
|
||||
const root = graph.rootGraph ?? graph;
|
||||
return graph !== root && graph.id ? `${graph.id}:${node.id}` : String(node.id);
|
||||
}
|
||||
|
||||
function restore(mount) {
|
||||
if (mount.face.hasScene) return;
|
||||
const scene = app.nodeOutputs?.[locatorOf(mount.inner)]?.[UI_KEY]?.[0];
|
||||
if (scene) mount.face.takeScene(scene);
|
||||
}
|
||||
|
||||
function mount(host, { box = null, inputName = null, inner = host } = {}) {
|
||||
const entry = { host, box, inputName, inner, face: null, widget: null };
|
||||
const face = mountCameraFace({
|
||||
read: () => String(sourceOf(entry).widget?.value ?? ""),
|
||||
write: (text) => {
|
||||
const widget = sourceOf(entry).widget;
|
||||
if (!widget) return;
|
||||
widget.value = text;
|
||||
app.canvas?.setDirty?.(true, false);
|
||||
notifyAusbossChange();
|
||||
},
|
||||
view: viewUrl,
|
||||
blob: viewBlob,
|
||||
run: async () => {
|
||||
const { id, holder } = sourceOf(entry);
|
||||
if (!id || typeof app.queuePrompt !== "function") return false;
|
||||
if (isSwitchedOff(rootGraph(), holder) || isSwitchedOff(rootGraph(), inner)) {
|
||||
showToast({ severity: "warn", summary: "Camera Move", detail: "This node is switched off (bypassed or muted), so it cannot run." });
|
||||
return false;
|
||||
}
|
||||
entry.askedAt = performance.now();
|
||||
// At the front of the queue: the clip shows up once the job running now is done.
|
||||
const queued = await app.queuePrompt(-1, 1, [id]);
|
||||
// Refused: an empty loader, a missing link, or a check that stopped it (it says why in a note).
|
||||
// Nothing was queued, so the face must not wait for a picture that never comes.
|
||||
if (queued === false) return false;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
entry.face = face;
|
||||
const widget = host.addDOMWidget(box ? `${FACE_WIDGET}:${inputName}` : FACE_WIDGET, FACE_WIDGET, face.root, { serialize: false, hideOnZoom: false });
|
||||
keepDomWidgetWidthAuto(widget);
|
||||
// Never saved with the workflow: options.serialize only keeps it out of the prompt.
|
||||
widget.serialize = false;
|
||||
// The floor follows the controls as they measure on this screen (fonts differ between phones).
|
||||
fillNodeHeight(widget, { minWidth: MIN_WIDTH, minHeight: () => face.floor() + WIDGET_FRAME, minNodeSize: [MIN_WIDTH, MIN_HEIGHT], exactMinWidth: true });
|
||||
entry.widget = widget;
|
||||
mounts.add(entry);
|
||||
restore(entry);
|
||||
holdFloor(host);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// A node or box kept shorter than the face needs (a saved size, a font that
|
||||
// runs taller on this screen) would cut the controls off at its bottom edge.
|
||||
function holdFloor(host) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (ensureNodeMinHeight(host)) host.setDirtyCanvas?.(true, true);
|
||||
}));
|
||||
}
|
||||
|
||||
function unmount(entry) {
|
||||
mounts.delete(entry);
|
||||
entry.face.destroy();
|
||||
try { entry.host.removeWidget?.(entry.widget); } catch { entry.widget?.onRemove?.(); }
|
||||
}
|
||||
|
||||
// A new node or box opens with room for the picture; a saved size wins.
|
||||
function openRoomy(host) {
|
||||
const floor = Number(host.computeSize?.()?.[1]);
|
||||
if (!Number.isFinite(floor) || host.size?.[1] > floor + 40) return;
|
||||
host.setSize?.([Math.max(host.size?.[0] ?? 0, OPENING_WIDTH), floor + OPENING_ROOM]);
|
||||
}
|
||||
|
||||
function build(node) {
|
||||
if (node.__ausbossCameraMove) return node.__ausbossCameraMove;
|
||||
hideWidget(ownCamera(node));
|
||||
node.__ausbossCameraMove = mount(node);
|
||||
return node.__ausbossCameraMove;
|
||||
}
|
||||
|
||||
// The settings card and any LoRA rows sit on top; the shot comes last and
|
||||
// takes the rest of the box, so its buttons stay on the bottom edge.
|
||||
function faceLast(box) {
|
||||
// A box makes `widgets` afresh on every read (its settings, then the panels
|
||||
// added to it), so the panels are put in order in their own list: any other
|
||||
// panel (a LoRA Loader's rows), the settings card, then the face. Nodes 2.0
|
||||
// lays them out in the order they were made, which is the same.
|
||||
const widgets = Array.isArray(box._extraWidgets) ? box._extraWidgets : box.widgets ?? [];
|
||||
const order = [widgets.find((w) => w?.name === CARD_WIDGET), box.__ausbossCameraBox?.widget].filter(Boolean);
|
||||
const want = [...widgets.filter((w) => !order.includes(w)), ...order];
|
||||
if (want.every((w, i) => widgets[i] === w)) return false;
|
||||
widgets.splice(0, widgets.length, ...want);
|
||||
box.setDirtyCanvas?.(true, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
// A box row reads its options from the widget inside. A node with a card
|
||||
// (Image Resize) hides its own rows by setting options.hidden there, and
|
||||
// Nodes 2.0 would then hide the box's row too. The box keeps every setting
|
||||
// brought up to it in view, except the camera text the face stands in for.
|
||||
const SHOWN = new WeakMap();
|
||||
function shownOptions(options) {
|
||||
if (!options || typeof options !== "object" || !options.hidden) return options;
|
||||
let view = SHOWN.get(options);
|
||||
if (!view) {
|
||||
view = new Proxy(options, { get: (target, key) => (key === "hidden" ? false : Reflect.get(target, key)) });
|
||||
SHOWN.set(options, view);
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
function showSettings(box, keep) {
|
||||
for (const widget of box.widgets ?? []) {
|
||||
if (widget === keep || widget.__ausbossCameraShown || !widget.options?.hidden) continue;
|
||||
const own = Object.getOwnPropertyDescriptor(widget, "options");
|
||||
if (!own?.get || !own.configurable || widget.__ausbossVisibilityStash) continue;
|
||||
Object.defineProperty(widget, "options", { ...own, get() { return shownOptions(own.get.call(this)); } });
|
||||
widget.__ausbossCameraShown = true;
|
||||
}
|
||||
}
|
||||
|
||||
// A box shows the face while the camera of a Camera Move inside comes up to it.
|
||||
function syncBox(box) {
|
||||
if (!box?.isSubgraphNode?.() || !box.graph) return;
|
||||
const found = boxCamera(box);
|
||||
const have = box.__ausbossCameraBox;
|
||||
if (have && (!found || have.inner !== found.inner || have.inputName !== found.name || !boxCopy(box, found.name))) {
|
||||
unmount(have);
|
||||
box.__ausbossCameraBox = null;
|
||||
box.setSize?.([box.size[0], box.computeSize?.()[1] ?? box.size[1]]);
|
||||
}
|
||||
if (box.__ausbossCameraBox) {
|
||||
if (mountBoxCard(box, boxCopy(box, box.__ausbossCameraBox.inputName), faceLast)) holdFloor(box);
|
||||
faceLast(box);
|
||||
showSettings(box, boxCopy(box, box.__ausbossCameraBox.inputName));
|
||||
return;
|
||||
}
|
||||
if (!found || !boxCopy(box, found.name)) return;
|
||||
hideWidget(boxCopy(box, found.name));
|
||||
// The card before the face: Nodes 2.0 lays a box's panels out in the order they were made.
|
||||
mountBoxCard(box, boxCopy(box, found.name), faceLast);
|
||||
box.__ausbossCameraBox = mount(box, { box, inputName: found.name, inner: found.inner });
|
||||
faceLast(box);
|
||||
showSettings(box, boxCopy(box, found.name));
|
||||
if (!box.__ausbossCameraSized) openRoomy(box);
|
||||
box.__ausbossCameraSized = true;
|
||||
box.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
function watchBox(box) {
|
||||
if (!box?.isSubgraphNode?.() || box.__ausbossCameraWatch) return;
|
||||
const abort = new AbortController();
|
||||
box.__ausbossCameraWatch = abort;
|
||||
const later = () => requestAnimationFrame(() => syncBox(box));
|
||||
for (const type of ["widget-promoted", "widget-demoted", "input-added", "removing-input"]) {
|
||||
box.subgraph?.events?.addEventListener?.(type, later, { signal: abort.signal });
|
||||
}
|
||||
chainCallback(box, "onConfigure", (info) => { if (info?.size) box.__ausbossCameraSized = true; later(); holdFloor(box); });
|
||||
chainCallback(box, "onRemoved", () => {
|
||||
abort.abort();
|
||||
if (box.__ausbossCameraBox) unmount(box.__ausbossCameraBox);
|
||||
box.__ausbossCameraBox = null;
|
||||
});
|
||||
later();
|
||||
}
|
||||
|
||||
// What a run sent back reaches every face that shows that run of the node.
|
||||
api.addEventListener("executed", ({ detail }) => {
|
||||
try {
|
||||
const scene = detail?.output?.[UI_KEY]?.[0];
|
||||
if (!scene) return;
|
||||
const ids = new Set([String(detail.node ?? ""), String(detail.display_node ?? "")]);
|
||||
for (const entry of mounts) if (ids.has(String(sourceOf(entry).id))) entry.face.takeScene(scene);
|
||||
} catch {
|
||||
// Never in the way of a run.
|
||||
}
|
||||
});
|
||||
const stopped = (note) => { for (const entry of mounts) entry.face.runFailed(note); };
|
||||
api.addEventListener("execution_error", ({ detail }) => {
|
||||
if (/OutOfMemory/i.test(String(detail?.exception_type ?? ""))) {
|
||||
stopped("The graphics card is full. Close other jobs using it, then try again.");
|
||||
return;
|
||||
}
|
||||
const why = String(detail?.exception_message ?? "").split("\n")[0].slice(0, 160);
|
||||
stopped(why ? `The run stopped: ${why}` : null);
|
||||
});
|
||||
api.addEventListener("execution_interrupted", () => stopped("The run was stopped."));
|
||||
// A run that ended without sending the clip (it never reached the node)
|
||||
// would leave a face waiting for good. Once the queue is empty, it stops.
|
||||
api.addEventListener("status", ({ detail }) => {
|
||||
if (detail?.exec_info?.queue_remaining !== 0) return;
|
||||
const now = performance.now();
|
||||
for (const entry of mounts) if (now - (entry.askedAt ?? 0) > 1500) entry.face.runFailed();
|
||||
});
|
||||
|
||||
app.registerExtension({
|
||||
name: "ausboss.camera_move",
|
||||
onNodeOutputsUpdated() {
|
||||
for (const entry of mounts) restore(entry);
|
||||
},
|
||||
nodeCreated(node) {
|
||||
if (node?.isSubgraphNode?.()) watchBox(node);
|
||||
},
|
||||
loadedGraphNode(node) {
|
||||
if (node?.isSubgraphNode?.()) watchBox(node);
|
||||
},
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== NODE_CLASS) return;
|
||||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||
build(this);
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onAdded", function () {
|
||||
if (this.__ausbossCameraSized) return;
|
||||
this.__ausbossCameraSized = true;
|
||||
openRoomy(this);
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onConfigure", function (info) {
|
||||
if (info?.size) this.__ausbossCameraSized = true;
|
||||
const entry = build(this);
|
||||
hideWidget(ownCamera(this));
|
||||
queueMicrotask(() => { entry.face.refresh(); restore(entry); });
|
||||
holdFloor(this);
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onConnectionsChange", function () {
|
||||
this.__ausbossCameraMove?.face.refresh();
|
||||
// The camera may have just come up to a box, or gone back inside.
|
||||
for (const box of boxesOf(this.graph, workflowNodes(rootGraph()))) requestAnimationFrame(() => syncBox(box));
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onRemoved", function () {
|
||||
const entry = this.__ausbossCameraMove;
|
||||
if (entry) unmount(entry);
|
||||
this.__ausbossCameraMove = null;
|
||||
});
|
||||
},
|
||||
});
|
||||
+1
-2
@@ -463,9 +463,8 @@ app.registerExtension({
|
||||
]);
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onConfigure", function () {
|
||||
const state = this.__ausbossCompare;
|
||||
queueMicrotask(() => {
|
||||
if (!state || this.__ausbossCompare !== state || state.abort.signal.aborted) return;
|
||||
const state = buildPanel(this);
|
||||
state.showingB = this.properties?.ausboss_compare_side === "B";
|
||||
syncErase(state);
|
||||
// The saved paint, if the pictures are already up.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Camera Move
|
||||
|
||||
Moves the camera of your clip. You steer it on the node's picture, the way
|
||||
you turn a model in a 3D viewer, and set keys on the time bar. What the old
|
||||
camera never saw comes out pink, and the CrossView-Warp LoRA for MiniMax H3
|
||||
paints it in.
|
||||
|
||||
## How to use it
|
||||
|
||||
1. Load your clip, scale it to the size you will render, and run it through
|
||||
**Load MoGe Model** and **MoGe Inference** (core ComfyUI nodes).
|
||||
2. Connect MoGe Inference's geometry to Camera Move.
|
||||
3. Press **Show my clip** on the node. It runs the depth once and shows your
|
||||
first frame.
|
||||
4. Steer:
|
||||
- Drag the picture to turn the camera round.
|
||||
- Pinch or scroll to pull back or push in. Two fingers, or Shift-drag, pan.
|
||||
- Tap a spot to circle round it.
|
||||
- Top right: drag the axis ball to turn, or tap an axis to look from that
|
||||
side. Drag the magnifier to zoom and the hand to pan. The camera button
|
||||
puts you back on your own shot.
|
||||
5. Keys: click the time bar to put a key there, using the camera at that
|
||||
moment. Or drag the time bar, then move the camera, and a key appears
|
||||
there. The diamond adds or removes a key; the arrows either side jump
|
||||
between keys. Drag a key along the bar to change its time.
|
||||
⏮ goes back to the start, ▶ plays, and the loop button plays it round
|
||||
and round.
|
||||
6. Run the workflow. `guide` goes to the H3 guide at frame 0.
|
||||
|
||||
The keys move the camera from where your clip's own camera was. If your
|
||||
clip's camera already moves (walks down an aisle, for example), that move
|
||||
stays, and your keys add to it. After the last key the camera holds that
|
||||
turn, but it still travels along with your clip.
|
||||
|
||||
The line under the picture turns amber when a key goes past where the LoRA
|
||||
was trained: 45° of turn, 30° up, 20° down. Past that it may leave pink.
|
||||
|
||||
### Mouse and keys (like Blender)
|
||||
|
||||
Middle-drag turns, Shift + middle-drag pans, Ctrl + middle-drag zooms, and
|
||||
the wheel zooms. On the numpad: 4 6 8 2 turn, Ctrl with them pans, + and −
|
||||
zoom, 1 is the front, 3 the right side (Ctrl: left), 7 the top (Ctrl:
|
||||
bottom), 0 your own shot. Space plays and pauses and Home goes back to the
|
||||
start, while the pointer is over the node.
|
||||
|
||||
The wheel over the picture zooms the camera, not the graph. Everywhere else
|
||||
on the node it zooms the graph as usual.
|
||||
|
||||
## Inputs and outputs
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `moge_geometry` | Depth from MoGe Inference, one per frame. |
|
||||
| `camera` | The keys, written by the node's picture. You never type here. |
|
||||
| `guide` | Your clip from the moved camera, pink where nothing was seen. |
|
||||
| `holes` | White where the guide is pink. |
|
||||
|
||||
## Technical details
|
||||
|
||||
- Each key holds a moment `t` (0 to 1 over the clip), a turn `az` and a rise
|
||||
`el` in degrees, `back` (how far the camera pulls back, in distances to the
|
||||
pivot; below 0 pushes in), and a pan `px`, `py` (same unit). The camera
|
||||
passes through each key without slowing down, and only eases at the first
|
||||
and last key. Each value follows a monotone cubic through the keys
|
||||
(Fritsch-Butland slopes), so it never runs past a key: a value that turns
|
||||
back or holds at a key stops there.
|
||||
- The camera turns round the pivot: a spot you tap (saved as a place on one
|
||||
frame, `pivot: {u, v, t}`) or, by default, the nearest fifth of what sits in
|
||||
the middle of the first frame, never further than 3 m.
|
||||
- `R = Ry(-az) · Rx(-el)`, `C = pivot − R·pivot + R·[px·d, py·d, −back·d]`
|
||||
with `d = |pivot|`, in OpenCV axes (x right, y down, z forward).
|
||||
- MoGe's depth scale and lens wobble from frame to frame, which shook the
|
||||
moved picture. Every frame is scaled so its median depth follows a
|
||||
smoothed line (15 frames wide) through all of them, and every frame uses
|
||||
the clip's median lens.
|
||||
- Each pixel is drawn as 4 points, nearest wins, and gaps mostly surrounded
|
||||
by picture (5 of 8 neighbours, two passes) are filled. Everything else
|
||||
stays magenta (1, 0, 1).
|
||||
- The node is an output node so its picture can run it on its own. Each run
|
||||
writes up to 61 small frames (416 px on the long side) with their depth to
|
||||
ComfyUI's temp folder; the depth is a PNG with a log scale in red (high
|
||||
byte) and green (low byte), 0 for no depth.
|
||||
@@ -110,7 +110,7 @@ parts back.
|
||||
|
||||
## Technical details
|
||||
|
||||
- Starting values: Context `1.2`, Blend `16` px, Multiple `32`; Grow, Blur,
|
||||
- Starting values: Context `1.2`, Blend `16` px, Multiple `8`; Grow, Blur,
|
||||
Extra context, the Target rows and the Extend rows start at `0`.
|
||||
- With no Target size set, the crop is still rounded up to **Multiple**, so
|
||||
with **Stay in picture** on it can still reach a few pixels (at most
|
||||
|
||||
@@ -160,9 +160,6 @@ or an area you painted.
|
||||
everything except what you picked.
|
||||
- **More → If nothing** (`if_nothing`): **stop the run** or **empty mask**,
|
||||
see below.
|
||||
- **More → New picture** (`new_picture`): **keep picks** or **start over**,
|
||||
for when a different picture comes in. See "Which picture the picks belong
|
||||
to" below.
|
||||
- **More → Free VRAM** (`free_vram`): takes SAM 3 out of the graphics card's
|
||||
memory right after the search, so the next model has the room. Turn it on
|
||||
in a workflow that loads a big model after this node.
|
||||
@@ -212,17 +209,11 @@ nothing instead.
|
||||
|
||||
## Which picture the picks belong to
|
||||
|
||||
Picks belong to the picture they were made on. When a different picture
|
||||
comes in, **New picture** (under More) decides what happens to them:
|
||||
|
||||
- **Keep picks** (the default): your clicks and paint move onto the new
|
||||
picture, in the same places. On a picture of another size they are
|
||||
scaled to fit it. A click finds whatever is at that spot now; paint stays
|
||||
exactly as painted. Press **Clear** to start over.
|
||||
- **Start over**: the node shows the new picture with nothing picked. If the
|
||||
workflow runs before you pick again, the run stops and says the picks
|
||||
were made on another picture. The new picture shows on the node then,
|
||||
ready to pick from.
|
||||
Picks belong to the picture they were made on. Load another picture and the
|
||||
node shows it with nothing picked, and says the old picks are not used.
|
||||
If the workflow runs before the node has shown the new picture, the run
|
||||
stops and says the picks were made on another picture. The new picture
|
||||
shows on the node then, ready to pick from.
|
||||
|
||||
- **The reload button** (the round arrow beside Undo) shows the picture
|
||||
coming in now. Use it after you change the picture before this node, for
|
||||
@@ -333,10 +324,7 @@ no browser:
|
||||
used.
|
||||
- **`paint`**: exactly the stroke's own area. SAM 3 is not asked.
|
||||
- **`key`** names the picture the picks were made on: its size and a
|
||||
fingerprint of every pixel. Picks made on another picture are moved onto
|
||||
the one that came in, scaled from the size in their key to its size, and
|
||||
the face is sent the moved touches. With `new_picture` set to `start
|
||||
over` they are ignored instead.
|
||||
fingerprint of every pixel. Picks made on another picture are ignored.
|
||||
Leave `key` out when you write `touches` by hand.
|
||||
|
||||
### How an area is tidied
|
||||
|
||||
@@ -31,8 +31,8 @@ names from here on; how it measures may still improve.
|
||||
moved a boat keeps the boat where it drew it, so the result lines up
|
||||
closely, not pixel for pixel.
|
||||
- **Leaves alone:** a picture it can't measure, or a zoom bigger than
|
||||
**Max zoom** after allowing for padded reframing. That picture only
|
||||
comes out resized, and the report says why.
|
||||
**Max zoom**. That picture only comes out resized, and the report says
|
||||
why.
|
||||
|
||||
## Controls
|
||||
|
||||
@@ -50,10 +50,8 @@ names from here on; how it measures may still improve.
|
||||
with nothing in it. `edge` (the default) stretches the nearest pixels
|
||||
into it, `source` fills it from the original, `gray` fills it flat gray
|
||||
for inpainting. With a big enough margin there is no strip.
|
||||
- **Max zoom**: the zoom limit in percent (20 by default). For a padded
|
||||
picture, zooming in to fill the padding gets an extra allowance; the
|
||||
setting limits zoom beyond that. Zooming out still uses the stated limit.
|
||||
Anything beyond those limits is treated as a change you meant and left alone.
|
||||
- **Max zoom**: the biggest zoom it will undo, in percent (20 by default).
|
||||
Anything bigger is treated as a change you meant and left alone.
|
||||
|
||||
## Outputs
|
||||
|
||||
@@ -123,10 +121,6 @@ new cloud, are left out. It trusts the fit when at least 10 blocks, and at
|
||||
least a quarter of them, agree. With a stitcher, the blocks tile the
|
||||
picture's area only.
|
||||
|
||||
For padded reframing, the extra zoom-in allowance on each axis is the
|
||||
canvas size divided by the source picture's size, minus one. It is added
|
||||
to **Max zoom**; zooming out gets no extra allowance.
|
||||
|
||||
It runs on the CPU in a fraction of a second per 1 MP frame. No new
|
||||
dependencies, and no model.
|
||||
|
||||
|
||||
@@ -67,11 +67,9 @@ are in the model's own picture.
|
||||
Tone match 0 the new area is bit-identical to what the model painted, and
|
||||
the whole stitch is the 2.4.0 blend in, pixel for pixel.
|
||||
- With the classic seam, feeding the crop back unchanged reproduces the
|
||||
original image exactly only when there is no resizing in either direction,
|
||||
with Tone match at 0 and `fix_edge_halo` off. Resizing can lose detail even
|
||||
when the crop comes back unchanged; the halo fix rewrites the feathered
|
||||
band on purpose. Blend in does not promise this: it keeps the model's
|
||||
version of the thin strip at your picture's edge.
|
||||
original image exactly (with `fix_edge_halo` off; the fix rewrites the
|
||||
feathered band on purpose). Blend in does not promise this: it keeps the
|
||||
model's version of the thin strip at your picture's edge.
|
||||
- A stitcher built from one image broadcasts over any number of inpainted
|
||||
frames. A stitcher built from **more** frames than came back is trimmed to
|
||||
the leading ones with a console note - video models keep 8n+1 (LTX) or
|
||||
|
||||
+13
-57
@@ -26,7 +26,6 @@ import {
|
||||
childOf,
|
||||
countLine,
|
||||
foldersAddress,
|
||||
folderContext,
|
||||
isPicked,
|
||||
listAddress,
|
||||
nameAt,
|
||||
@@ -275,7 +274,6 @@ async function refresh(state) {
|
||||
}
|
||||
|
||||
function scheduleRefresh(state, delay = 160) {
|
||||
++state.token;
|
||||
clearTimeout(state.timer);
|
||||
state.timer = setTimeout(() => refresh(state), delay);
|
||||
}
|
||||
@@ -344,7 +342,6 @@ async function upload(file, subfolder, overwrite) {
|
||||
|
||||
// Loose pictures go into the folder the node shows.
|
||||
async function addFiles(state, files) {
|
||||
if (!state.alive) return false;
|
||||
const now = settings(state.node);
|
||||
const images = [...(files ?? [])].filter(isPicture);
|
||||
if (!images.length) return false;
|
||||
@@ -355,13 +352,10 @@ async function addFiles(state, files) {
|
||||
const added = [];
|
||||
let failed = 0;
|
||||
for (const file of images) {
|
||||
if (!state.alive) return true;
|
||||
const name = await upload(file, now.folder, false);
|
||||
if (!state.alive) return true;
|
||||
if (name) added.push(name); else failed += 1;
|
||||
}
|
||||
await refresh(state);
|
||||
if (!state.alive) return true;
|
||||
// A hand-made pick takes the new pictures in: they were just put there to be used.
|
||||
const picked = parsePicked(settings(state.node).pictures);
|
||||
if (picked !== null && added.length) setPicked(state, [...picked, ...added.filter((name) => state.names.includes(name))]);
|
||||
@@ -394,7 +388,6 @@ function closeAsk(answer = false) {
|
||||
}
|
||||
|
||||
function askBig(state, name, pictures) {
|
||||
if (!state.alive) return Promise.resolve(false);
|
||||
const bytes = pictures.reduce((sum, item) => sum + (Number(item.file?.size) || 0), 0);
|
||||
if (pictures.length <= BIG_COUNT && bytes <= BIG_BYTES) return Promise.resolve(true);
|
||||
closeAsk(false);
|
||||
@@ -411,7 +404,7 @@ function askBig(state, name, pictures) {
|
||||
foot.append(cancel, go);
|
||||
root.append(foot);
|
||||
const abort = new AbortController();
|
||||
openAsk = { root, abort, done, state };
|
||||
openAsk = { root, abort, done };
|
||||
cancel.addEventListener("click", () => closeAsk(false));
|
||||
go.addEventListener("click", () => closeAsk(true));
|
||||
document.body.append(root);
|
||||
@@ -427,17 +420,7 @@ function askBig(state, name, pictures) {
|
||||
// input/<its name>, folders inside it too, and the node moves there. Copying
|
||||
// the same folder again writes over its copies instead of piling up doubles.
|
||||
async function copyFolder(state, name, pictures) {
|
||||
if (!state.alive || state.copyLocked) return;
|
||||
state.copyLocked = true;
|
||||
try {
|
||||
if (!(await askBig(state, name, pictures)) || !state.alive) return;
|
||||
await copyPictures(state, name, pictures);
|
||||
} finally {
|
||||
state.copyLocked = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPictures(state, name, pictures) {
|
||||
if (!(await askBig(state, name, pictures)) || !state.alive) return;
|
||||
const target = folderName(name);
|
||||
const queue = pictures.slice(0, COPY_LIMIT);
|
||||
state.copying = { done: 0, total: queue.length, name, target };
|
||||
@@ -448,9 +431,7 @@ async function copyPictures(state, name, pictures) {
|
||||
for (;;) {
|
||||
const next = queue.shift();
|
||||
if (!next || !state.alive) return;
|
||||
const added = await upload(next.file, next.folder ? `${target}/${next.folder}` : target, true);
|
||||
if (!state.alive) return;
|
||||
if (added) copied += 1;
|
||||
if (await upload(next.file, next.folder ? `${target}/${next.folder}` : target, true)) copied += 1;
|
||||
state.copying.done += 1;
|
||||
paint(state);
|
||||
cover(state);
|
||||
@@ -463,7 +444,6 @@ async function copyPictures(state, name, pictures) {
|
||||
if (!state.alive) return;
|
||||
const moved = write(state.node, "source", "input") | write(state.node, "folder", target);
|
||||
if (moved) notifyAusbossChange(); else await refresh(state);
|
||||
if (!state.alive) return;
|
||||
paint(state);
|
||||
showToast({ detail: `Copied ${copied} picture${copied === 1 ? "" : "s"} from "${name}" into your ComfyUI input folder (input/${target}).` });
|
||||
if (copied < total) showToast({ severity: "warn", detail: `${total - copied} could not be copied.` });
|
||||
@@ -526,7 +506,7 @@ function addMenu(state, anchor) {
|
||||
closeMenu();
|
||||
const root = el("div", "ausboss-if-menu");
|
||||
const abort = new AbortController();
|
||||
openMenu = { root, abort, state };
|
||||
openMenu = { root, abort };
|
||||
const row = (text, title, run) => {
|
||||
const button = el("button", "ausboss-if-browse-row", text);
|
||||
button.type = "button"; button.title = title;
|
||||
@@ -565,25 +545,19 @@ async function browse(state, anchor) {
|
||||
foot.append(cancel, use);
|
||||
root.append(path, list, foot);
|
||||
const abort = new AbortController();
|
||||
openBrowse = { root, abort, state };
|
||||
openBrowse = { root, abort };
|
||||
let at = now.folder;
|
||||
let source = now.source;
|
||||
let navigation = 0;
|
||||
|
||||
const show = async (folder) => {
|
||||
const token = ++navigation;
|
||||
use.disabled = true;
|
||||
let data = null;
|
||||
try {
|
||||
const response = await api.fetchApi(foldersAddress({ source, folder }));
|
||||
const response = await api.fetchApi(foldersAddress({ source: now.source, folder }));
|
||||
if (response.ok) data = await response.json();
|
||||
} catch { /* shown as an empty list below */ }
|
||||
if (openBrowse?.root !== root || token !== navigation || !state.alive) return;
|
||||
if (openBrowse?.root !== root) return;
|
||||
if (!data && folder) return show(""); // a folder that is gone: start at the top
|
||||
at = data?.folder ?? "";
|
||||
source = data?.source ?? source;
|
||||
use.disabled = false;
|
||||
path.replaceChildren(document.createTextNode(`${source} / `), el("b", "", at || "(the folder itself)"));
|
||||
path.replaceChildren(document.createTextNode(`${now.source} / `), el("b", "", at || "(the folder itself)"));
|
||||
list.replaceChildren();
|
||||
if (at) {
|
||||
const up = el("button", "ausboss-if-browse-row up", "↑ up");
|
||||
@@ -608,9 +582,7 @@ async function browse(state, anchor) {
|
||||
};
|
||||
|
||||
use.addEventListener("click", () => {
|
||||
if (use.disabled || !state.alive) return;
|
||||
const moved = write(state.node, "source", source) | write(state.node, "folder", at);
|
||||
if (moved) notifyAusbossChange();
|
||||
if (write(state.node, "folder", at)) notifyAusbossChange();
|
||||
closeBrowse();
|
||||
});
|
||||
cancel.addEventListener("click", closeBrowse);
|
||||
@@ -734,12 +706,6 @@ function buildPanel(node) {
|
||||
const now = settings(node);
|
||||
const key = `${now.source}|${now.folder}|${now.subfolders}|${now.sort}`;
|
||||
if (key === state.key) { paint(state); return; }
|
||||
const context = folderContext(now);
|
||||
if (context !== state.context) {
|
||||
state.done.clear();
|
||||
state.loadedLine = "";
|
||||
state.context = context;
|
||||
}
|
||||
// Another folder holds other names: a pick made in the old one means nothing there.
|
||||
const moved = state.key && (now.source !== state.shown.source || now.folder !== state.shown.folder || now.subfolders !== state.shown.subfolders);
|
||||
state.key = key;
|
||||
@@ -759,9 +725,7 @@ function buildPanel(node) {
|
||||
const now = settings(node);
|
||||
if (now.run !== "one per run" || isLinked(node, "position")) return;
|
||||
const count = pickedNames(parsePicked(now.pictures), state.names).length;
|
||||
// The folder can change on disk or arrive through a link. Only the
|
||||
// backend knows where it ends; a cached gallery must not cap "next".
|
||||
const next = nextPosition(now.position, now.afterRun === "next" ? null : count, now.afterRun, now.atTheEnd);
|
||||
const next = nextPosition(now.position, count, now.afterRun, now.atTheEnd);
|
||||
if (next !== now.position) { position.value = next; position.callback?.(next); }
|
||||
});
|
||||
}
|
||||
@@ -784,7 +748,6 @@ function buildPanel(node) {
|
||||
});
|
||||
|
||||
const now = settings(node);
|
||||
state.context = folderContext(now);
|
||||
state.key = `${now.source}|${now.folder}|${now.subfolders}|${now.sort}`;
|
||||
rebuild(state);
|
||||
scheduleRefresh(state, 60);
|
||||
@@ -810,18 +773,12 @@ app.registerExtension({
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onConfigure", function (info) {
|
||||
const state = buildPanel(this);
|
||||
++state.token;
|
||||
clearTimeout(state.timer);
|
||||
state.done.clear();
|
||||
state.loadedLine = "";
|
||||
// A node that comes with a size keeps it, the smallest one too.
|
||||
if (info?.size) state.sized = true;
|
||||
// A loaded workflow brings its own folder and its own pick.
|
||||
state.restoring = true;
|
||||
queueMicrotask(() => {
|
||||
if (!state.alive) return;
|
||||
const now = settings(this);
|
||||
state.context = folderContext(now);
|
||||
state.key = `${now.source}|${now.folder}|${now.subfolders}|${now.sort}`;
|
||||
state.restoring = false;
|
||||
scheduleRefresh(state, 30);
|
||||
@@ -832,7 +789,6 @@ app.registerExtension({
|
||||
const state = this.__ausbossImageFolder;
|
||||
const note = output?.ausboss_image_folder?.[0];
|
||||
if (!state?.alive || !note) return;
|
||||
if (!note.context || folderContext(note.context) !== folderContext(settings(this))) return;
|
||||
for (const name of note.names ?? []) state.done.add(name);
|
||||
const loaded = note.loaded ?? [];
|
||||
state.loadedLine = loaded.length === 1
|
||||
@@ -849,9 +805,9 @@ app.registerExtension({
|
||||
state.abort.abort();
|
||||
state.choosing?.abort();
|
||||
clearTimeout(state.dropTimer);
|
||||
if (openBrowse?.state === state) closeBrowse();
|
||||
if (openMenu?.state === state) closeMenu();
|
||||
if (openAsk?.state === state) closeAsk(false);
|
||||
if (openBrowse) closeBrowse();
|
||||
if (openMenu) closeMenu();
|
||||
if (openAsk?.root && state.grid.isConnected === false) closeAsk(false);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
+25
-40
@@ -19,8 +19,7 @@ import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { isForeignRun } from "../shared/prompt_scope.mjs";
|
||||
import { executionIdOf, nodeByExecutionId } from "../shared/graph_ids.mjs";
|
||||
import { FONT_UI, RADIUS_CONTROL, RADIUS_XS, THEME as T } from "../shared/theme.mjs";
|
||||
import { chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
|
||||
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
|
||||
import { ensureNodeMinHeight, fillNodeHeight, growToNodeFloor } from "../shared/panel_layout.mjs";
|
||||
import { suppressCoreImagePreview } from "../shared/core_preview.mjs";
|
||||
import { autoMaskValues } from "../shared/mask_auto.mjs";
|
||||
@@ -131,29 +130,28 @@ function ensureCss() {
|
||||
style.textContent = `
|
||||
.ausboss-input-preview{box-sizing:border-box;display:flex;flex-direction:column;gap:4px;width:100%;height:100%;padding:0 6px 6px;pointer-events:none;overflow:hidden;}
|
||||
.ausboss-input-preview-bar{box-sizing:border-box;flex:none;display:flex;align-items:center;gap:${BAR_GAP}px;height:${BAR_HEIGHT}px;padding:0 ${BAR_PADDING}px;pointer-events:none;}
|
||||
.ausboss-input-preview-stage{position:relative;flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;width:100%;overflow:hidden;border:1px solid ${T.rule};border-radius:${RADIUS_CONTROL}px;background:${T.well};}
|
||||
.ausboss-input-preview-stage{position:relative;flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;width:100%;overflow:hidden;border:1px solid rgba(0,180,170,.27);border-radius:6px;background:rgba(0,0,0,.28);}
|
||||
[data-node-id] .ausboss-input-preview-stage{box-sizing:border-box;min-height:${STAGE_HEIGHT}px;}
|
||||
.ausboss-input-preview.preview-off .ausboss-input-preview-stage{display:none;}
|
||||
.ausboss-input-preview.preview-off .ausboss-stepper{display:none;}
|
||||
.ausboss-input-preview-stage img,.ausboss-input-preview-stage video{display:none;position:absolute;inset:0;margin:auto;max-width:100%;max-height:100%;object-fit:contain;}
|
||||
.ausboss-input-preview-stage.show-image img{display:block;}
|
||||
.ausboss-input-preview-stage.show-video video{display:block;}
|
||||
.ausboss-input-preview-hint{display:none;max-width:86%;color:${T.faint};font:11px/1.35 ${FONT_UI};text-align:center;}
|
||||
.ausboss-input-preview-hint{display:none;max-width:86%;color:#78908e;font:11px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:center;}
|
||||
.ausboss-input-preview-stage:not(.show-image):not(.show-video) .ausboss-input-preview-hint{display:block;}
|
||||
.ausboss-input-preview-tools{display:flex;gap:4px;pointer-events:auto;}
|
||||
.ausboss-input-preview-tool{box-sizing:border-box;height:${BAR_HEIGHT}px;min-width:24px;padding:0 6px;border:1px solid transparent;border-radius:${RADIUS_XS}px;background:${T.fill};color:${T.label};font:600 9.5px/18px ${FONT_UI};letter-spacing:.02em;cursor:pointer;}
|
||||
.ausboss-input-preview-tool:hover{color:${T.ink};background:${T.fillHover};}
|
||||
.ausboss-input-preview-tool.active{color:${T.brandInk};background:${T.brandSoft};}
|
||||
.ausboss-input-preview-tool:focus-visible{outline:2px solid ${T.brand};outline-offset:1px;}
|
||||
.ausboss-input-preview-tool{box-sizing:border-box;height:${BAR_HEIGHT}px;min-width:24px;padding:0 6px;border:1px solid rgba(0,180,170,.52);border-radius:4px;background:rgba(0,0,0,.7);color:#c8dddd;font:700 9px/18px "Segoe UI",sans-serif;cursor:pointer;}
|
||||
.ausboss-input-preview-tool:hover{border-color:${BRAND};color:#fff;background:rgba(0,79,75,.78);}
|
||||
.ausboss-input-preview-tool.active{border-color:${BRAND};color:${BRAND};}
|
||||
.ausboss-input-preview:not(.preview-off) .ausboss-panel-switch.steps + .ausboss-panel-switch{margin-left:0;}
|
||||
.ausboss-input-preview.preview-off .ausboss-panel-switch.steps{display:none;}
|
||||
.ausboss-input-preview-tool.ausboss-steps-stop{visibility:hidden;}
|
||||
.ausboss-input-preview-tool.ausboss-steps-stop.live{visibility:visible;pointer-events:auto;}
|
||||
.ausboss-input-preview-readout{box-sizing:border-box;height:${BAR_HEIGHT}px;padding:0 7px;border:1px solid transparent;border-radius:999px;background:${T.brandSoft};color:${T.brandInk};font:600 9.5px/18px ${FONT_UI};font-variant-numeric:tabular-nums;white-space:nowrap;pointer-events:none;}
|
||||
.ausboss-input-preview-readout{box-sizing:border-box;height:${BAR_HEIGHT}px;padding:0 7px;border:1px solid rgba(0,180,170,.5);border-radius:10px;background:rgba(0,180,170,.14);color:${BRAND};font:700 9.5px/18px "Segoe UI",sans-serif;white-space:nowrap;pointer-events:none;}
|
||||
.ausboss-input-preview-readout:empty{display:none;}
|
||||
.ausboss-input-preview-readout.warn{background:${T.warnSoft};color:${T.warnInk};}
|
||||
.ausboss-input-preview-note{display:none;position:absolute;left:6px;right:6px;bottom:6px;z-index:6;padding:6px 8px;border:1px solid ${T.warn};border-radius:${RADIUS_CONTROL}px;background:${T.warnSoft};color:${T.warnInk};font:600 11px/1.35 ${FONT_UI};text-align:center;pointer-events:none;}
|
||||
.ausboss-input-preview-toast{position:absolute;left:50%;bottom:6px;z-index:5;max-width:88%;padding:3px 7px;border-radius:${RADIUS_XS}px;background:${T.fill};color:${T.inkSoft};font:10px/1.3 ${FONT_UI};text-align:center;transform:translateX(-50%);pointer-events:none;}
|
||||
.ausboss-input-preview-readout.warn{border-color:#c98a2b;background:rgba(201,138,43,.16);color:#ffd9a0;}
|
||||
.ausboss-input-preview-note{display:none;position:absolute;left:6px;right:6px;bottom:6px;z-index:6;padding:6px 8px;border:1px solid #c98a2b;border-radius:6px;background:rgba(20,10,0,.86);color:#ffd9a0;font:600 11px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:center;pointer-events:none;}
|
||||
.ausboss-input-preview-toast{position:absolute;left:50%;bottom:6px;z-index:5;max-width:88%;padding:3px 7px;border-radius:4px;background:rgba(0,0,0,.78);color:#b8d3d1;font:10px/1.3 "Segoe UI",sans-serif;text-align:center;transform:translateX(-50%);pointer-events:none;}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -446,7 +444,7 @@ function syncPreviewMode(state, resize) {
|
||||
}
|
||||
if (!enabled) {
|
||||
// Nothing to show and nothing to fetch while it is off.
|
||||
clearMedia(state);
|
||||
state.img.removeAttribute("src");
|
||||
}
|
||||
if (resize) {
|
||||
const node = state.node;
|
||||
@@ -519,16 +517,14 @@ function clearMedia(state) {
|
||||
state.video.removeAttribute("src");
|
||||
}
|
||||
|
||||
// Each source has one hook, with subscribers that can leave in any order.
|
||||
const sourceWatchers = new WeakMap();
|
||||
// Restore the watched widget's callback if our hook is still the tail of the
|
||||
// chain; if someone chained after us the hook simply goes inert via
|
||||
// state.alive. We never create object URLs, so there is nothing to revoke.
|
||||
function unwatchSource(state) {
|
||||
const watched = state.watched;
|
||||
state.watched = null;
|
||||
if (!watched) return;
|
||||
watched.subscribers.delete(state);
|
||||
if (!watched.subscribers.size && watched.widget.callback === watched.hook) {
|
||||
if (watched && watched.widget.callback === watched.hook) {
|
||||
watched.widget.callback = watched.prior;
|
||||
sourceWatchers.delete(watched.widget);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,21 +533,14 @@ function rewatchSource(state, source) {
|
||||
if (state.watched?.widget === widget) return;
|
||||
unwatchSource(state);
|
||||
if (!widget) return;
|
||||
let watched = sourceWatchers.get(widget);
|
||||
if (!watched) {
|
||||
const prior = widget.callback;
|
||||
const subscribers = new Set();
|
||||
const hook = function (...args) {
|
||||
const result = prior?.apply(this, args);
|
||||
for (const subscriber of subscribers) if (subscriber.alive) scheduleRefresh(subscriber);
|
||||
return result;
|
||||
};
|
||||
watched = { widget, prior, hook, subscribers };
|
||||
sourceWatchers.set(widget, watched);
|
||||
widget.callback = hook;
|
||||
}
|
||||
watched.subscribers.add(state);
|
||||
state.watched = watched;
|
||||
const prior = widget.callback;
|
||||
const hook = function (...args) {
|
||||
const result = prior?.apply(this, args);
|
||||
if (state.alive) scheduleRefresh(state);
|
||||
return result;
|
||||
};
|
||||
widget.callback = hook;
|
||||
state.watched = { widget, prior, hook };
|
||||
}
|
||||
|
||||
// The node's stored output as a /view URL, for Nodes 2.0, where node.imgs
|
||||
@@ -595,10 +584,6 @@ function stepPicture(state, step) {
|
||||
|
||||
function refresh(state) {
|
||||
if (!state.alive) return;
|
||||
if (!previewEnabled(state)) {
|
||||
clearMedia(state);
|
||||
return;
|
||||
}
|
||||
const source = upstreamNode(state.node, state.inputName);
|
||||
rewatchSource(state, source);
|
||||
const count = resultCount(state);
|
||||
@@ -840,9 +825,9 @@ app.registerExtension({
|
||||
if (state) scheduleRefresh(state);
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onConfigure", function () {
|
||||
const state = this.__ausbossInputPreview;
|
||||
queueMicrotask(() => {
|
||||
if (!state?.alive || this.__ausbossInputPreview !== state) return;
|
||||
const state = buildPanel(this, config);
|
||||
if (!state) return;
|
||||
// onNodeCreated already built the panel before this node's saved
|
||||
// properties existed, so the advanced toggle has to be re-applied
|
||||
// here or a workflow always reopens in the simple state.
|
||||
|
||||
@@ -288,9 +288,6 @@ function syncSizes() {
|
||||
const grabbed = together.grabbed;
|
||||
if (!grabbed) return;
|
||||
const was = together.before.get(grabbed);
|
||||
// A handle click is not a resize. Once a drag starts, keep syncing even
|
||||
// when it returns to its starting size so the other nodes follow it back.
|
||||
if (!together.sides && grabbed.size[0] === was.size[0] && grabbed.size[1] === was.size[1]) return;
|
||||
together.sides ??= sharedSides(
|
||||
[...together.before.values()].map(({ pos, size }) => ({ x: pos[0], y: pos[1], w: size[0], h: size[1] })),
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from "../shared/index.mjs";
|
||||
import { WIDGET_FRAME, fillNodeHeight, holdVueNodeMinWidth } from "../shared/panel_layout.mjs";
|
||||
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
|
||||
import { liftSlot, markSocketless } from "../shared/widget_card_math.mjs";
|
||||
import { markSocketless } from "../shared/widget_card_math.mjs";
|
||||
import { makeScrubInput } from "../shared/scrub_input.mjs";
|
||||
import {
|
||||
closeSettingsMenu,
|
||||
@@ -1014,7 +1014,8 @@ function installResolutionNode(node) {
|
||||
const liftSockets = () => {
|
||||
for (const input of node.inputs ?? []) {
|
||||
if (["width", "height", "latent", "batch_size"].includes(input.widget?.name)) {
|
||||
liftSlot(input);
|
||||
delete input.widget;
|
||||
input._widget = undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -146,7 +146,7 @@ async function openBrowse(state, anchor) {
|
||||
}
|
||||
path = listing.path;
|
||||
const use = el("button", "use", path ? `Use “${path}”` : "Use the output folder");
|
||||
use.addEventListener("click", () => { state.savedPath = null; setValue(state.node, "output_dir", path); state.refresh(); notifyAusbossChange(); closeBrowse(); });
|
||||
use.addEventListener("click", () => { setValue(state.node, "output_dir", path); state.refresh(); notifyAusbossChange(); closeBrowse(); });
|
||||
popup.append(use);
|
||||
if (path) {
|
||||
const up = el("button", "", "↑ up one level");
|
||||
@@ -370,8 +370,7 @@ function buildCard(node) {
|
||||
chainCallback(node, "onExecuted", (output) => {
|
||||
if (isForeignRun()) return;
|
||||
const saved = output?.ausboss_saved_path?.[0];
|
||||
state.savedPath = typeof saved === "string" && saved ? saved : null;
|
||||
state.refresh();
|
||||
if (typeof saved === "string" && saved) { state.savedPath = saved; state.refresh(); }
|
||||
});
|
||||
chainCallback(node, "onRemoved", () => { state.alive = false; closeBrowse(); });
|
||||
state.refresh();
|
||||
|
||||
@@ -10,15 +10,11 @@
|
||||
|
||||
// The wheel belongs to the panel while it can still scroll that way (a long
|
||||
// text box, a list of rows), or while a text field has the keyboard.
|
||||
export function canScrollFurther(element, delta, axis = "y") {
|
||||
if (!element || !delta) return false;
|
||||
const horizontal = axis === "x";
|
||||
const size = horizontal ? element.scrollWidth : element.scrollHeight;
|
||||
const visible = horizontal ? element.clientWidth : element.clientHeight;
|
||||
const start = horizontal ? element.scrollLeft : element.scrollTop;
|
||||
const room = size - visible;
|
||||
export function canScrollFurther(element, deltaY) {
|
||||
if (!element || !deltaY) return false;
|
||||
const room = element.scrollHeight - element.clientHeight;
|
||||
if (room <= 1) return false;
|
||||
return delta > 0 ? start + visible < size - 1 : start > 0;
|
||||
return deltaY > 0 ? element.scrollTop + element.clientHeight < element.scrollHeight - 1 : element.scrollTop > 0;
|
||||
}
|
||||
|
||||
// Does the wheel belong to something inside the panel, between the target
|
||||
@@ -29,11 +25,8 @@ export function panelKeepsWheel(target, event, { root, host, styleOf, activeElem
|
||||
for (let element = target; element && element !== root && element !== host; element = element.parentElement) {
|
||||
const tag = String(element.tagName ?? "").toUpperCase();
|
||||
if ((tag === "TEXTAREA" || tag === "INPUT") && element === activeElement) return true;
|
||||
const style = styleOf(element);
|
||||
const overflow = style?.overflowY;
|
||||
const overflow = styleOf(element)?.overflowY;
|
||||
if ((overflow === "auto" || overflow === "scroll") && canScrollFurther(element, event.deltaY)) return true;
|
||||
const overflowX = style?.overflowX;
|
||||
if ((overflowX === "auto" || overflowX === "scroll") && canScrollFurther(element, event.deltaX, "x")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -628,7 +628,7 @@ function installPointer(er, signal) {
|
||||
}
|
||||
|
||||
async function save(er) {
|
||||
if (er.signal.aborted || er.saving || !er.refs) return;
|
||||
if (er.saving || !er.refs) return;
|
||||
er.saving = true;
|
||||
syncTools(er);
|
||||
try {
|
||||
@@ -644,7 +644,6 @@ async function save(er) {
|
||||
})),
|
||||
});
|
||||
const answer = await response.json().catch(() => null);
|
||||
if (er.signal.aborted) return;
|
||||
if (!response.ok) {
|
||||
// No words from the route: this ComfyUI started before the update.
|
||||
const missing = (response.status === 404 || response.status === 405) && !answer?.error;
|
||||
@@ -653,11 +652,10 @@ async function save(er) {
|
||||
showToast({ severity: "success", summary: "Image Compare", detail: savedMessage(answer?.files) });
|
||||
er.onSaved?.(answer?.files ?? []);
|
||||
} catch (error) {
|
||||
if (er.signal.aborted) return;
|
||||
showToast({ severity: "error", summary: "Image Compare", detail: String(error?.message ?? error), life: 8000 });
|
||||
} finally {
|
||||
er.saving = false;
|
||||
if (!er.signal.aborted) syncTools(er);
|
||||
syncTools(er);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,24 +663,20 @@ async function save(er) {
|
||||
// to B as it came. The panel swaps in the pictures; Save and Copy then use
|
||||
// the lined-up B, and the paint stays where it is.
|
||||
async function align(er) {
|
||||
if (er.signal.aborted || er.aligning || !er.refs) return;
|
||||
if (er.aligning || !er.refs) return;
|
||||
if (er.refs.aligned) {
|
||||
er.onAligned?.(er.refs.original);
|
||||
return;
|
||||
}
|
||||
er.aligning = true;
|
||||
const refs = er.refs;
|
||||
const picturesVersion = er.picturesVersion;
|
||||
const current = () => !er.signal.aborted && er.refs === refs && er.picturesVersion === picturesVersion;
|
||||
syncTools(er);
|
||||
try {
|
||||
const response = await api.fetchApi(ALIGN_ROUTE, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pair: refs.pair }),
|
||||
body: JSON.stringify({ pair: er.refs.pair }),
|
||||
});
|
||||
const answer = await response.json().catch(() => null);
|
||||
if (!current()) return;
|
||||
if (!response.ok) {
|
||||
// No words from the route: this ComfyUI started before the update.
|
||||
const missing = (response.status === 404 || response.status === 405) && !answer?.error;
|
||||
@@ -690,16 +684,15 @@ async function align(er) {
|
||||
}
|
||||
if (answer?.aligned && answer.b?.filename && typeof answer.pair === "string") {
|
||||
showToast({ severity: "success", summary: "Image Compare", detail: answer.message, life: 4000 });
|
||||
er.onAligned?.({ ...refs, b: answer.b, pair: answer.pair, aligned: true, original: refs });
|
||||
er.onAligned?.({ ...er.refs, b: answer.b, pair: answer.pair, aligned: true, original: er.refs });
|
||||
} else {
|
||||
showToast({ severity: "info", summary: "Image Compare", detail: answer?.message || "B was left as it is.", life: 6000 });
|
||||
}
|
||||
} catch (error) {
|
||||
if (!current()) return;
|
||||
showToast({ severity: "error", summary: "Image Compare", detail: String(error?.message ?? error), life: 8000 });
|
||||
} finally {
|
||||
er.aligning = false;
|
||||
if (!er.signal.aborted) syncTools(er);
|
||||
syncTools(er);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,7 +764,7 @@ function copy(er) {
|
||||
// pictures change.
|
||||
export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved, onAligned }) {
|
||||
const er = {
|
||||
node, root, stage, imageA, imageB, signal, onSaved, onAligned,
|
||||
node, root, stage, imageA, imageB, onSaved, onAligned,
|
||||
canvas: document.createElement("canvas"),
|
||||
mask: document.createElement("canvas"),
|
||||
live: document.createElement("canvas"),
|
||||
@@ -779,7 +772,7 @@ export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved,
|
||||
dab: document.createElement("canvas"),
|
||||
dabKey: "",
|
||||
maskScale: 1,
|
||||
refs: null, picturesVersion: 0, width: 0, height: 0, fit: null,
|
||||
refs: null, width: 0, height: 0, fit: null,
|
||||
strokes: [], history: [],
|
||||
active: false, stroke: null, adjust: null, pointer: null, back: false, flipKey: false,
|
||||
saving: false, copying: false, frame: 0, keys: null,
|
||||
@@ -992,7 +985,6 @@ export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved,
|
||||
// Both pictures are loaded (or, with null, on their way). The paint is
|
||||
// read from the node, and kept only while the picture keeps its size.
|
||||
setPictures(refs) {
|
||||
er.picturesVersion += 1;
|
||||
const ready = Boolean(refs && imageA.naturalWidth > 0 && imageB.naturalWidth > 0);
|
||||
er.refs = ready ? refs : null;
|
||||
finishStroke(er, null, true);
|
||||
|
||||
@@ -93,10 +93,8 @@ export function nameAt(picked, names, position, atTheEnd) {
|
||||
// the first, so Picture always names a real picture. A Picture already past
|
||||
// the end (saved by an older version) steps on from the picture it loaded.
|
||||
// "random" jumps to any picked picture (`roll` is 0..1, for tests).
|
||||
// With an unknown count, "next" keeps advancing and the backend handles the end.
|
||||
export function nextPosition(position, count, afterRun, atTheEnd = "stop the run", roll = Math.random()) {
|
||||
const place = Math.max(1, Math.round(Number(position) || 1));
|
||||
if (count == null) return afterRun === "next" ? place + 1 : place;
|
||||
if (count < 1) return place;
|
||||
if (afterRun === "random") return Math.min(count, Math.floor(roll * count) + 1);
|
||||
if (afterRun !== "next") return place;
|
||||
@@ -126,12 +124,6 @@ export function childOf(folder, name) {
|
||||
return [...String(folder ?? "").split("/").filter(Boolean), name].join("/");
|
||||
}
|
||||
|
||||
// A relative name belongs to one folder, even when another has the same name.
|
||||
export function folderContext({ source, folder, subfolders }) {
|
||||
const relative = String(folder ?? "").trim().replaceAll("\\", "/").split("/").filter(part => part && part !== ".").join("/");
|
||||
return JSON.stringify([source, relative, Boolean(subfolders)]);
|
||||
}
|
||||
|
||||
// A query string. Every value is typed by a person or saved in a workflow.
|
||||
export function query(params) {
|
||||
return Object.entries(params)
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import { app } from "../../../scripts/app.js";
|
||||
// Must match `version` in pyproject.toml — scripts/release_preflight.py
|
||||
// enforces it. The backend serves its copy at /ausboss/pack_version; a
|
||||
// mismatch means the browser cached JavaScript from an older install.
|
||||
export const AUSBOSS_JS_VERSION = "2.23.0";
|
||||
export const AUSBOSS_JS_VERSION = "2.21.2";
|
||||
|
||||
export const BRAND = "#00b4aa"; // AusBoss teal — keep node accents consistent.
|
||||
export const BRAND_DARK = "#007f78";
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
// Shared controls for compact node panels. Only elements explicitly given one
|
||||
// of these classes are styled; the owning panel decides where they sit.
|
||||
import { FONT_UI, RADIUS_XS, THEME as T } from "./theme.mjs";
|
||||
import { BRAND } from "./index.mjs";
|
||||
|
||||
export const PANEL_LABELS = Object.freeze({
|
||||
load: "Load", sources: { input: "Inputs", output: "Outputs" },
|
||||
embed: "Embed workflow", off: "Off", on: "On",
|
||||
});
|
||||
|
||||
const controlFont = FONT_UI;
|
||||
export const CAPTION_TYPE = `font:600 11px/1.25 ${controlFont};color:${T.label};`;
|
||||
export const HINT_TYPE = `font:400 10px/1.25 ${controlFont};color:${T.faint};`;
|
||||
const controlFont = 'system-ui, sans-serif';
|
||||
export const CAPTION_TYPE = `font:600 11px/1.25 ${controlFont};color:#b2c6c3;`;
|
||||
export const HINT_TYPE = `font:400 10px/1.25 ${controlFont};color:#95aba8;`;
|
||||
|
||||
export function ensurePanelControls() {
|
||||
if (document.getElementById("ausboss-panel-controls")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "ausboss-panel-controls";
|
||||
style.textContent = `
|
||||
.ausboss-compact-action{box-sizing:border-box;flex:none;height:22px;min-width:24px;padding:0 7px;border:1px solid transparent;border-radius:${RADIUS_XS}px;background:${T.fill};color:${T.inkSoft};font:600 11px/1 ${controlFont};white-space:nowrap;cursor:pointer;pointer-events:auto}
|
||||
.ausboss-compact-action:hover:enabled{color:${T.ink};background:${T.fillHover}}
|
||||
.ausboss-compact-action.active{color:${T.brandInk};background:${T.brandSoft}}
|
||||
.ausboss-compact-action{box-sizing:border-box;flex:none;height:22px;min-width:24px;padding:0 7px;border:1px solid #365957;border-radius:4px;background:#111b1c;color:#bdd8d5;font:600 11px/1 ${controlFont};white-space:nowrap;cursor:pointer;pointer-events:auto}
|
||||
.ausboss-compact-action:hover:enabled,.ausboss-compact-action.active{color:#dbfffb;border-color:${BRAND};background:#173332}
|
||||
.ausboss-compact-action:disabled{opacity:.5;cursor:default}
|
||||
.ausboss-panel-switch{display:inline-flex;align-items:center;gap:6px;align-self:flex-end;flex:none;box-sizing:border-box;min-height:20px;margin-left:auto;padding:0 2px;background:transparent;border:0;color:${T.label};font:600 11px/1 ${controlFont};cursor:pointer;user-select:none;pointer-events:auto}
|
||||
.ausboss-panel-switch i{position:relative;width:24px;height:13px;border-radius:7px;background:${T.switchOff}}
|
||||
.ausboss-panel-switch i::after{content:"";position:absolute;top:2px;left:2px;width:9px;height:9px;border-radius:50%;background:${T.inkSoft}}
|
||||
.ausboss-panel-switch[aria-checked=true] i{background:${T.brand}}
|
||||
.ausboss-panel-switch[aria-checked=true] i::after{left:13px;background:${T.inkHover}}
|
||||
.ausboss-compact-action:focus-visible,.ausboss-panel-switch:focus-visible{outline:2px solid ${T.brand};outline-offset:1px}
|
||||
.ausboss-panel-switch{display:inline-flex;align-items:center;gap:6px;align-self:flex-end;flex:none;box-sizing:border-box;min-height:20px;margin-left:auto;padding:0 2px;background:transparent;border:0;color:#b2c6c3;font:600 11px/1 ${controlFont};cursor:pointer;user-select:none;pointer-events:auto}
|
||||
.ausboss-panel-switch i{position:relative;width:24px;height:13px;border-radius:7px;background:#3a4445}
|
||||
.ausboss-panel-switch i::after{content:"";position:absolute;top:2px;left:2px;width:9px;height:9px;border-radius:50%;background:#b6c4c3}
|
||||
.ausboss-panel-switch[aria-checked=true] i{background:${BRAND}}
|
||||
.ausboss-panel-switch[aria-checked=true] i::after{left:13px;background:#fff}
|
||||
.ausboss-compact-action:focus-visible,.ausboss-panel-switch:focus-visible{outline:2px solid ${BRAND};outline-offset:1px}
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// steps through its pairs with it, and the preview panel of Save Image and
|
||||
// the other preview nodes through their results. It hides itself while
|
||||
// there is one picture or none, so a single picture looks as it always did.
|
||||
import { FONT_UI, RADIUS_CONTROL, RADIUS_XS, THEME as T } from "./theme.mjs";
|
||||
import { BRAND } from "./index.mjs";
|
||||
import { stepperLabel } from "./picture_steps.mjs";
|
||||
|
||||
const CSS_ID = "ausboss-picture-stepper-v1";
|
||||
@@ -13,12 +13,12 @@ function ensureCss() {
|
||||
const style = document.createElement("style");
|
||||
style.id = CSS_ID;
|
||||
style.textContent = `
|
||||
.ausboss-stepper{box-sizing:border-box;flex:none;display:flex;align-items:stretch;height:var(--ausboss-stepper-height,24px);padding:1px;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};pointer-events:auto;user-select:none;}
|
||||
.ausboss-stepper{box-sizing:border-box;flex:none;display:flex;align-items:stretch;height:var(--ausboss-stepper-height,24px);padding:1px;border:1px solid #2a3437;border-radius:6px;background:#0f1516;pointer-events:auto;user-select:none;}
|
||||
.ausboss-stepper[hidden]{display:none;}
|
||||
.ausboss-stepper button{box-sizing:border-box;width:20px;margin:0;padding:0;border:none;border-radius:${RADIUS_XS}px;background:transparent;color:${T.label};font:600 13px/1 ${FONT_UI};cursor:pointer;}
|
||||
.ausboss-stepper button:hover{color:${T.ink};background:${T.fillHover};}
|
||||
.ausboss-stepper button:focus-visible{outline:1px solid ${T.brand};outline-offset:-1px;}
|
||||
.ausboss-stepper span{min-width:34px;padding:0 3px;color:${T.ink};font:600 10px/1 ${FONT_UI};font-variant-numeric:tabular-nums;text-align:center;white-space:nowrap;align-self:center;}
|
||||
.ausboss-stepper button{box-sizing:border-box;width:20px;margin:0;padding:0;border:none;border-radius:4px;background:transparent;color:#8ba3a1;font:700 13px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;}
|
||||
.ausboss-stepper button:hover{color:#fff;background:rgba(0,79,75,.78);}
|
||||
.ausboss-stepper button:focus-visible{outline:1px solid ${BRAND};outline-offset:-1px;}
|
||||
.ausboss-stepper span{min-width:34px;padding:0 3px;color:#d8eeee;font:700 10px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-variant-numeric:tabular-nums;text-align:center;white-space:nowrap;align-self:center;}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
@@ -25,8 +25,10 @@
|
||||
// Shift's fine step and typed values are never snapped.
|
||||
|
||||
import { keepKeyInField } from "./canvas_passthrough.mjs";
|
||||
// theme.mjs has no imports, so this module's math stays testable in node:test.
|
||||
import { FONT_UI, RADIUS_CONTROL, THEME as T } from "./theme.mjs";
|
||||
|
||||
// Keep in sync with BRAND in shared/index.mjs - importing it would pull
|
||||
// /scripts/app.js into node:test, and this module's math must stay testable.
|
||||
const BRAND = "#00b4aa";
|
||||
|
||||
// Horizontal pixels of drag per step; small enough to feel light, large
|
||||
// enough that a shaky click cannot change the value. The dead zone keeps a
|
||||
@@ -179,19 +181,19 @@ function ensureScrubCss() {
|
||||
// for one) carry blanket `section input` rules, and these must outrank
|
||||
// them wherever the control is mounted.
|
||||
style.textContent = `
|
||||
.ausboss-scrub{display:flex;width:72px;height:24px;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;
|
||||
background:${T.fill};overflow:hidden;flex:none;color:${T.ink};font:500 12px ${FONT_UI};font-variant-numeric:tabular-nums}
|
||||
.ausboss-scrub:focus-within{border-color:${T.brand}}
|
||||
.ausboss-scrub{display:flex;width:72px;height:24px;border:1px solid #3a4047;border-radius:5px;
|
||||
background:#23272c;overflow:hidden;flex:none;color:#d7dde2;font:12px system-ui}
|
||||
.ausboss-scrub:focus-within{border-color:${BRAND}}
|
||||
.ausboss-scrub>.ausboss-scrub-input{flex:1 1 auto;min-width:0;width:100%;height:100%;border:none;
|
||||
padding:0;margin:0;background:transparent;color:inherit;text-align:center;cursor:ew-resize;
|
||||
user-select:none;font:inherit;outline:none;border-radius:0}
|
||||
.ausboss-scrub>.ausboss-scrub-input:focus{cursor:text;user-select:text}
|
||||
.ausboss-scrub>.ausboss-scrub-unit{flex:none;align-self:center;padding:0 3px 0 1px;color:${T.faint};font:600 10px ${FONT_UI};letter-spacing:.04em;text-transform:uppercase;text-align:center;user-select:none;pointer-events:none}
|
||||
.ausboss-scrub>.ausboss-scrub-unit{flex:none;align-self:center;padding:0 3px 0 1px;color:#5f7674;font-size:9.5px;letter-spacing:.06em;text-transform:uppercase;text-align:center;user-select:none;pointer-events:none}
|
||||
.ausboss-scrub>.ausboss-scrub-step{flex:none;width:14px;display:flex;flex-direction:column;
|
||||
border-left:1px solid transparent;background:${T.fillHover}}
|
||||
border-left:1px solid #3a4047}
|
||||
.ausboss-scrub>.ausboss-scrub-step>button{flex:1 1 0;min-height:0;border:none;background:transparent;
|
||||
color:${T.label};cursor:pointer;padding:0;margin:0;display:grid;place-items:center;border-radius:0}
|
||||
.ausboss-scrub>.ausboss-scrub-step>button:hover{color:${T.brand};background:rgba(255,255,255,.05)}
|
||||
color:#9ba2aa;cursor:pointer;padding:0;margin:0;display:grid;place-items:center;border-radius:0}
|
||||
.ausboss-scrub>.ausboss-scrub-step>button:hover{color:${BRAND};background:rgba(255,255,255,.05)}
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// chosen one: the mode row of Image Compare A/B, and its off | on choices.
|
||||
// The choices share one width, so the marker only has to move. With `fit`
|
||||
// each choice is as wide as its own name, and the marker is measured.
|
||||
import { FONT_UI, RADIUS_CONTROL, RADIUS_XS, THEME as T } from "./theme.mjs";
|
||||
import { BRAND } from "./index.mjs";
|
||||
|
||||
const CSS_ID = "ausboss-slide-switch-v1";
|
||||
|
||||
@@ -11,17 +11,17 @@ function ensureCss() {
|
||||
const style = document.createElement("style");
|
||||
style.id = CSS_ID;
|
||||
style.textContent = `
|
||||
.ausboss-slide{position:relative;box-sizing:border-box;flex:none;display:grid;grid-auto-flow:column;grid-auto-columns:1fr;height:24px;padding:2px;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};}
|
||||
.ausboss-slide{position:relative;box-sizing:border-box;flex:none;display:grid;grid-auto-flow:column;grid-auto-columns:1fr;height:24px;padding:2px;border:1px solid #2a3437;border-radius:6px;background:#0f1516;}
|
||||
.ausboss-slide.fit{grid-auto-columns:max-content;}
|
||||
.ausboss-slide.fit button{padding:0 7px;}
|
||||
.ausboss-slide-mark{position:absolute;top:2px;bottom:2px;left:2px;border-radius:${RADIUS_XS}px;background:${T.brand};transition:transform .16s ease,width .16s ease;pointer-events:none;}
|
||||
.ausboss-slide-mark{position:absolute;top:2px;bottom:2px;left:2px;border-radius:4px;background:${BRAND};box-shadow:0 0 7px rgba(0,180,170,.3);transition:transform .16s ease,width .16s ease;pointer-events:none;}
|
||||
.ausboss-slide.none .ausboss-slide-mark{display:none;}
|
||||
.ausboss-slide button{position:relative;box-sizing:border-box;min-width:0;height:100%;margin:0;padding:0 9px;border:none;border-radius:${RADIUS_XS}px;background:transparent;color:${T.label};font:600 11px/1 ${FONT_UI};white-space:nowrap;cursor:pointer;transition:color .16s ease;}
|
||||
.ausboss-slide button:hover{color:${T.inkHover};}
|
||||
.ausboss-slide button.on,.ausboss-slide button.on:hover{color:${T.onBrand};}
|
||||
.ausboss-slide.off-left button.on:first-of-type,.ausboss-slide.off-left button.on:first-of-type:hover{color:${T.ink};}
|
||||
.ausboss-slide.off-left.is-off .ausboss-slide-mark{background:${T.switchOff};}
|
||||
.ausboss-slide button:focus-visible{outline:1px solid ${T.brand};outline-offset:1px;}
|
||||
.ausboss-slide button{position:relative;box-sizing:border-box;min-width:0;height:100%;margin:0;padding:0 9px;border:none;border-radius:4px;background:transparent;color:#8ba3a1;font:700 10px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.02em;white-space:nowrap;cursor:pointer;transition:color .16s ease;}
|
||||
.ausboss-slide button:hover{color:#fff;}
|
||||
.ausboss-slide button.on,.ausboss-slide button.on:hover{color:#04201d;}
|
||||
.ausboss-slide.off-left button.on:first-of-type,.ausboss-slide.off-left button.on:first-of-type:hover{color:#d8eeee;}
|
||||
.ausboss-slide.off-left.is-off .ausboss-slide-mark{background:#33423f;box-shadow:none;}
|
||||
.ausboss-slide button:focus-visible{outline:1px solid ${BRAND};outline-offset:1px;}
|
||||
@media (prefers-reduced-motion: reduce){.ausboss-slide-mark,.ausboss-slide button{transition:none;}}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { nodeByExecutionId } from "./graph_ids.mjs";
|
||||
import { FONT_UI, RADIUS_CONTROL, THEME as T } from "./theme.mjs";
|
||||
import { BRAND } from "./index.mjs";
|
||||
import { outputRecordQuery } from "./input_preview.mjs";
|
||||
import { isForeignRun } from "./prompt_scope.mjs";
|
||||
import {
|
||||
@@ -213,15 +213,15 @@ export function ensureStepCss() {
|
||||
.ausboss-steps.on{display:block;}
|
||||
.ausboss-steps-showing>img,.ausboss-steps-showing>video,.ausboss-steps-showing>.ausboss-input-preview-hint,.ausboss-steps-showing>.ausboss-video-status{visibility:hidden;}
|
||||
.ausboss-steps img,.ausboss-steps.on img{display:block;position:absolute;inset:0;width:100%;height:100%;max-width:none;max-height:none;margin:0;object-fit:contain;visibility:visible;}
|
||||
.ausboss-steps-line{position:absolute;left:0;top:0;height:3px;width:0;background:${T.brand};transition:width .15s linear;}
|
||||
.ausboss-steps-chip{position:absolute;left:6px;right:6px;bottom:6px;box-sizing:border-box;padding:4px 7px;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:rgba(21,24,26,.88);color:${T.inkSoft};font:600 10.5px/1.35 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.ausboss-steps-line{position:absolute;left:0;top:0;height:3px;width:0;background:${BRAND};box-shadow:0 0 6px rgba(0,180,170,.5);transition:width .15s linear;}
|
||||
.ausboss-steps-chip{position:absolute;left:6px;right:6px;bottom:6px;box-sizing:border-box;padding:4px 7px;border:1px solid rgba(255,255,255,.08);border-radius:5px;background:rgba(8,12,13,.84);color:#d8ecea;font:600 10.5px/1.35 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.ausboss-steps.stopped img{filter:brightness(.55) saturate(.7);}
|
||||
.ausboss-steps.stopped .ausboss-steps-line{display:none;}
|
||||
.ausboss-steps.stopped .ausboss-steps-chip{border-color:${T.danger};color:${T.dangerInk};}
|
||||
.ausboss-steps-hint{position:absolute;left:50%;top:50%;max-width:86%;transform:translate(-50%,-50%);color:${T.faint};font:11px/1.35 ${FONT_UI};text-align:center;}
|
||||
.ausboss-steps-stop{border-color:transparent!important;background:${T.dangerSoft}!important;color:${T.dangerInk}!important;}
|
||||
.ausboss-steps-stop:hover{background:rgba(120,38,33,.95)!important;color:${T.inkHover}!important;}
|
||||
.ausboss-steps-stop::before{content:"";display:inline-block;width:6px;height:6px;margin-right:5px;border-radius:1px;background:${T.danger};vertical-align:1px;}
|
||||
.ausboss-steps.stopped .ausboss-steps-chip{border-color:rgba(224,96,90,.55);color:#ffd0cc;}
|
||||
.ausboss-steps-hint{position:absolute;left:50%;top:50%;max-width:86%;transform:translate(-50%,-50%);color:#78908e;font:11px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:center;}
|
||||
.ausboss-steps-stop{border-color:rgba(224,96,90,.8)!important;background:rgba(58,28,27,.9)!important;color:#ffd9d6!important;}
|
||||
.ausboss-steps-stop:hover{background:rgba(120,38,33,.95)!important;color:#fff!important;}
|
||||
.ausboss-steps-stop::before{content:"";display:inline-block;width:6px;height:6px;margin-right:5px;border-radius:1px;background:#e0605a;vertical-align:1px;}
|
||||
.ausboss-video-tool.ausboss-steps-stop{display:none;}
|
||||
.ausboss-video-tool.ausboss-steps-stop.live{display:inline-block;}
|
||||
.ausboss-video-stage.is-still .ausboss-video-tools.ausboss-steps-tools{display:flex;}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// The pack's node-face palette, in one place. Cards, number boxes and
|
||||
// switches read their colours, font and sizes from here instead of
|
||||
// carrying their own literals.
|
||||
//
|
||||
// The look: solid gray controls with no outlines on a near-black card,
|
||||
// neutral grays, and one teal accent for whatever is on or picked.
|
||||
//
|
||||
// No imports on purpose: scrub_input.mjs and the node:test suites load
|
||||
// this file without the ComfyUI app.
|
||||
|
||||
export const THEME = Object.freeze({
|
||||
brand: "#00b4aa", // the one accent: picked segment, switch on, focus ring
|
||||
onBrand: "#04201d", // text on a brand fill
|
||||
brandSoft: "rgba(0,180,170,.16)", // chip fill
|
||||
brandSoftHover: "rgba(0,180,170,.26)",
|
||||
brandInk: "#45d6cb", // chip text on brandSoft
|
||||
brandGlow: "#6fe0d6", // the big text look
|
||||
|
||||
card: "#101214", // widget card ground
|
||||
fill: "#2d3134", // every control: solid, no border
|
||||
fillHover: "#383c40", // control hover, the chevron column
|
||||
well: "#0f1112", // picture stage ground
|
||||
rule: "#2b2f32", // hairline after a section label
|
||||
switchOff: "#62676d", // the mark of a switch sitting on off
|
||||
|
||||
ink: "#f6f7f8", // values: numbers, picked options, typed text
|
||||
inkSoft: "#e0e3e5", // default card text
|
||||
inkHover: "#ffffff",
|
||||
label: "#b4b9bd", // row labels, unselected segments, chevrons
|
||||
faint: "#979da2", // units, section labels, placeholders, muted rows
|
||||
|
||||
danger: "#e0605a", // stop: the dot on the Stop button
|
||||
dangerInk: "#ffd9d6",
|
||||
dangerSoft: "#3a1c1b",
|
||||
|
||||
warn: "#e0a040",
|
||||
warnInk: "#ffdca8",
|
||||
warnSoft: "#3a2a10",
|
||||
});
|
||||
|
||||
export const FONT_UI = `system-ui,-apple-system,"Segoe UI",Roboto,sans-serif`;
|
||||
|
||||
// Every control in a card row is as tall as the row.
|
||||
export const CONTROL_HEIGHT = 26;
|
||||
export const RADIUS_XS = 4;
|
||||
export const RADIUS_CONTROL = 6;
|
||||
export const RADIUS_CARD = 10;
|
||||
|
||||
// The select's chevron, drawn in `label`.
|
||||
export const SELECT_CHEVRON = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23b4b9bd' stroke-width='1.5'/%3E%3C/svg%3E")`;
|
||||
@@ -268,15 +268,6 @@ export function encodeTouches(key, items) {
|
||||
return JSON.stringify({ v: 1, key: String(key ?? ""), items: clean });
|
||||
}
|
||||
|
||||
// The picks the node moved onto a new picture (New picture: keep picks), as
|
||||
// the text to write: only while the widget still holds the picks they were
|
||||
// moved from (`current`). Anything picked since is kept, and then this is null.
|
||||
export function movedTouches(note, current) {
|
||||
const moved = note?.moved;
|
||||
if (!moved || typeof moved.touches !== "string" || typeof moved.from !== "string") return null;
|
||||
return stampOf(String(current ?? "")) === moved.from ? moved.touches : null;
|
||||
}
|
||||
|
||||
// Whether two runs of touches pick the same things. How they are written
|
||||
// decides, so a touch fresh from a click and the same one read back from the
|
||||
// widget compare equal.
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
loaderSignature,
|
||||
maskOn,
|
||||
maskShare,
|
||||
movedTouches,
|
||||
nameShown,
|
||||
nextGroup,
|
||||
numberChip,
|
||||
@@ -1534,9 +1533,6 @@ function followOwnLoader(face, now = false) {
|
||||
}
|
||||
|
||||
const OTHER_PICTURE = "This is another picture. The picks made on the last one are not used.";
|
||||
// With New picture on keep picks, the node moves them onto it instead.
|
||||
const PICKS_CARRY = "This is another picture. Your picks carry over once the node looks at it or the workflow runs.";
|
||||
const otherPictureNote = (face) => (widgetByName(face.node, "new_picture")?.value === "start over" ? OTHER_PICTURE : PICKS_CARRY);
|
||||
|
||||
function dropPicture(face) {
|
||||
dropPending(face);
|
||||
@@ -1604,7 +1600,7 @@ function syncSource(face) {
|
||||
});
|
||||
readItems(face);
|
||||
face.itemNotes = null;
|
||||
if (face.other) setNote(face, otherPictureNote(face), { life: 6000 });
|
||||
if (face.other) setNote(face, OTHER_PICTURE, { life: 6000 });
|
||||
resolve(face);
|
||||
syncState(face);
|
||||
draw(face);
|
||||
@@ -1650,7 +1646,6 @@ function syncState(face) {
|
||||
|
||||
function setNote(face, text, { life = 0 } = {}) {
|
||||
clearTimeout(face.noteTimer);
|
||||
face.stopNote = false;
|
||||
face.noteEl.textContent = text;
|
||||
face.noteEl.classList.toggle("on", Boolean(text));
|
||||
if (text && life) face.noteTimer = setTimeout(() => setNote(face, ""), life);
|
||||
@@ -1709,15 +1704,7 @@ function adopt(face, entry) {
|
||||
|
||||
function show(face, entry, note, answered, { restored = false, own = false, interim = false, areas = null } = {}) {
|
||||
const before = face.pic;
|
||||
// A run that stopped left its message up. A result since says it is over.
|
||||
if (face.stopNote && !interim && !restored) setNote(face, "");
|
||||
adopt(face, entry);
|
||||
// Picks made on another picture that the node moved onto this one (New
|
||||
// picture: keep picks) come back moved. They are taken only while the
|
||||
// widget still holds the picks they were moved from: anything picked
|
||||
// since stays.
|
||||
const moved = movedTouches(note, face.touches?.value);
|
||||
if (moved !== null) writeTouches(face, moved);
|
||||
readItems(face);
|
||||
let items = face.items;
|
||||
let mine = Boolean(answered) && note.stamp === face.stamp;
|
||||
@@ -1798,7 +1785,7 @@ function show(face, entry, note, answered, { restored = false, own = false, inte
|
||||
readItems(face);
|
||||
face.itemNotes = notes;
|
||||
}
|
||||
if (face.other) setNote(face, otherPictureNote(face), { life: 6000 });
|
||||
if (face.other) setNote(face, OTHER_PICTURE, { life: 6000 });
|
||||
if (face.shownPieces !== entry.piecesTag) {
|
||||
face.shownPieces = entry.piecesTag;
|
||||
face.hover = -1;
|
||||
@@ -3097,8 +3084,6 @@ export function mountTouchStage(node, host) {
|
||||
face.stopped = (message) => {
|
||||
settle(face);
|
||||
setNote(face, String(message ?? ""));
|
||||
// It stays up until something else is said or the next result comes in.
|
||||
face.stopNote = true;
|
||||
};
|
||||
// A setting on the node changed: the edge follows it.
|
||||
face.settingsChanged = () => {
|
||||
|
||||
+52
-46
@@ -30,18 +30,17 @@
|
||||
// Pure decisions (which rows show, how tall the card is, how a number
|
||||
// scrubs, where a socket sits) live in widget_card_math.mjs for node:test;
|
||||
// this file is the DOM.
|
||||
import { chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "./index.mjs";
|
||||
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "./index.mjs";
|
||||
import { keepKeyInField } from "./canvas_passthrough.mjs";
|
||||
import { createMediaPicker } from "./media_picker.mjs";
|
||||
import { ensureNodeMinHeight, fillNodeHeight, holdNodeMinWidth, nodeHeightAfterCardChange, pinVuePanelHeight } from "./panel_layout.mjs";
|
||||
import { makeScrubInput, quantizeScrubValue, stepDecimals } from "./scrub_input.mjs";
|
||||
import { CONTROL_HEIGHT, FONT_UI, RADIUS_CARD, RADIUS_CONTROL, RADIUS_XS, SELECT_CHEVRON, THEME as T } from "./theme.mjs";
|
||||
import { limitsLabel, narrowedRange } from "./wired_limits.mjs";
|
||||
import { hideWidget } from "./widget_visibility.mjs";
|
||||
import {
|
||||
CARD_PADDING, GROUP_HEIGHT, ROW_GAP, ROW_HEIGHT, SECTION_HEIGHT, SLOT_OFFSET, UNIT_SLOT_WIDTH,
|
||||
cardChangeRestore, cardHeight, choiceLabel, commitWidgetValue, comboValues, rowHeight, rowInLook, rowKind, rowMuted, rowTops, scrubSteps, socketWidgetY, visibleRows,
|
||||
liftSocket, widgetTooltip,
|
||||
widgetTooltip,
|
||||
} from "./widget_card_math.mjs";
|
||||
|
||||
export * from "./widget_card_math.mjs";
|
||||
@@ -56,7 +55,7 @@ const WRAPPER_INSET = 16;
|
||||
// widget's layout y (DomWidgets: margin 10 per side).
|
||||
const WRAPPER_TOP = 10;
|
||||
|
||||
const FONT = FONT_UI;
|
||||
const FONT = `-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif`;
|
||||
// A boolean pill's state labels must fit half a control; longer text is a
|
||||
// tooltip, and the pill says on / off.
|
||||
const SWITCH_LABEL_MAX = 16;
|
||||
@@ -79,80 +78,76 @@ export function ensureCardCss() {
|
||||
const style = document.createElement("style");
|
||||
style.id = CSS_ID;
|
||||
style.textContent = `
|
||||
.ausboss-card{position:relative;box-sizing:border-box;width:100%;height:100%;display:flex;flex-direction:column;gap:${ROW_GAP}px;padding:${CARD_PADDING}px 8px;overflow:hidden;border:1px solid transparent;border-radius:${RADIUS_CARD}px;background:${T.card};font:12px/1.3 ${FONT};color:${T.inkSoft};pointer-events:none}
|
||||
.ausboss-card{position:relative;box-sizing:border-box;width:100%;height:100%;display:flex;flex-direction:column;gap:${ROW_GAP}px;padding:${CARD_PADDING}px 8px;overflow:hidden;border:1px solid rgba(0,180,170,.22);border-radius:8px;background:rgba(0,0,0,.28);font:12px/1.3 ${FONT};color:#c8dddd;pointer-events:none}
|
||||
.ausboss-card-row{display:grid;grid-template-columns:minmax(56px,30%) 1fr;align-items:center;gap:8px;height:${ROW_HEIGHT}px;flex:none;pointer-events:auto}
|
||||
.ausboss-card-row.hidden{display:none}
|
||||
.ausboss-card-row.linked{opacity:.5}
|
||||
.ausboss-card-row.linked .ausboss-card-control{pointer-events:none}
|
||||
.ausboss-card-row.muted .ausboss-card-control{opacity:.35;pointer-events:none}
|
||||
.ausboss-card-row.muted .ausboss-card-label{color:${T.faint}}
|
||||
.ausboss-card-range{margin-left:6px;color:${T.faint};font-size:10px;font-weight:600;letter-spacing:.02em;white-space:nowrap}
|
||||
.ausboss-card-row.muted .ausboss-card-label{color:#566b69}
|
||||
.ausboss-card-range{margin-left:6px;color:#5f7674;font-size:10px;font-weight:600;letter-spacing:.02em;white-space:nowrap}
|
||||
.ausboss-card.has-corner .ausboss-card-control{margin-right:var(--ausboss-card-corner,0px)}
|
||||
.ausboss-card-corner{position:absolute;top:${CARD_PADDING}px;right:8px;height:${ROW_HEIGHT}px;display:flex;align-items:center;justify-content:flex-end;gap:5px;pointer-events:auto}
|
||||
.ausboss-card-gear{width:24px;height:24px;flex:none;display:grid;place-items:center;padding:0;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};color:${T.label};cursor:pointer}
|
||||
.ausboss-card-gear:hover{background:${T.fillHover};color:${T.inkHover}}
|
||||
.ausboss-card-chip{height:20px;flex:none;padding:0 9px;border:none;border-radius:999px;background:${T.brandSoft};color:${T.brandInk};font:600 10px/1 ${FONT};letter-spacing:.02em;font-variant-numeric:tabular-nums;white-space:nowrap;cursor:pointer}
|
||||
.ausboss-card-chip:hover{background:${T.brandSoftHover};color:${T.inkHover}}
|
||||
.ausboss-card-gear{width:24px;height:24px;flex:none;display:grid;place-items:center;padding:0;border:1px solid #2a3437;border-radius:6px;background:#0f1516;color:#8ba3a1;cursor:pointer}
|
||||
.ausboss-card-gear:hover{border-color:${BRAND};color:${BRAND}}
|
||||
.ausboss-card-chip{height:20px;flex:none;padding:0 8px;border:1px solid rgba(0,180,170,.5);border-radius:10px;background:rgba(0,180,170,.14);color:${BRAND};font:600 10px/1 ${FONT};letter-spacing:.02em;white-space:nowrap;cursor:pointer}
|
||||
.ausboss-card-chip:hover{background:rgba(0,180,170,.24);color:#fff}
|
||||
.ausboss-card-chip[hidden]{display:none}
|
||||
.ausboss-card-row.area{display:block}
|
||||
.ausboss-card-row.panel{display:block}
|
||||
.ausboss-card-row.area.grow{flex:1 1 auto;height:auto}
|
||||
.ausboss-card-label{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:${T.label};font-size:11px;user-select:none}
|
||||
.ausboss-card-label{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#8ba3a1;font-size:11px;user-select:none}
|
||||
.ausboss-card-control{display:flex;align-items:center;gap:6px;min-width:0;height:100%}
|
||||
.ausboss-card-field{display:flex;align-items:center;gap:6px;flex:1 1 0;min-width:0;height:100%}
|
||||
.ausboss-card-field.linked{opacity:.5;pointer-events:none}
|
||||
.ausboss-card .ausboss-scrub{height:${CONTROL_HEIGHT}px}
|
||||
.ausboss-card-field>.ausboss-scrub{flex:1 1 0;width:auto;min-width:0}
|
||||
.ausboss-card-field>.ausboss-scrub .ausboss-scrub-input{width:100%}
|
||||
.ausboss-card-sep{flex:none;color:${T.faint};font-size:11px}
|
||||
.ausboss-card-suffix{flex:none;color:${T.faint};font:600 10px ${FONT};letter-spacing:.1em;text-transform:uppercase;user-select:none}
|
||||
.ausboss-card-section{height:${SECTION_HEIGHT}px;flex:none;display:flex;align-items:flex-end;gap:8px;color:${T.faint};font:600 10px/1 ${FONT};letter-spacing:.1em;text-transform:uppercase;user-select:none}
|
||||
.ausboss-card-section::after{content:"";flex:1;height:1px;margin-bottom:4px;background:${T.rule}}
|
||||
.ausboss-card-sep{flex:none;color:#5f7674;font-size:11px}
|
||||
.ausboss-card-suffix{flex:none;color:#5f7674;font-size:10px;letter-spacing:.06em;text-transform:uppercase;user-select:none}
|
||||
.ausboss-card-section{height:${SECTION_HEIGHT}px;flex:none;display:flex;align-items:flex-end;color:#5f7674;font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;user-select:none}
|
||||
.ausboss-card-section.hidden{display:none}
|
||||
.ausboss-card-group{height:${GROUP_HEIGHT}px;flex:none;display:flex;align-items:center;gap:6px;padding:0 4px;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:transparent;color:${T.faint};font:600 10px/1 ${FONT};letter-spacing:.1em;text-transform:uppercase;cursor:pointer;text-align:left;pointer-events:auto}
|
||||
.ausboss-card-group:hover{color:${T.inkHover}}
|
||||
.ausboss-card-group{height:${GROUP_HEIGHT}px;flex:none;display:flex;align-items:center;gap:6px;padding:0 4px;border:1px solid transparent;border-radius:6px;background:transparent;color:#78908e;font:600 10.5px/1 ${FONT};letter-spacing:.06em;text-transform:uppercase;cursor:pointer;text-align:left;pointer-events:auto}
|
||||
.ausboss-card-group:hover{color:#fff;border-color:#2a3437}
|
||||
.ausboss-card-group .glyph{display:inline-block;width:10px;transition:transform .12s}
|
||||
.ausboss-card-group.open .glyph{transform:rotate(90deg)}
|
||||
.ausboss-card-seg{display:grid;grid-auto-flow:column;grid-auto-columns:1fr;width:100%;height:${ROW_HEIGHT}px;padding:2px;box-sizing:border-box;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill}}
|
||||
.ausboss-card-seg button{min-width:0;border:none;border-radius:${RADIUS_XS}px;padding:0 2px;background:transparent;color:${T.label};font:600 11px/1 ${FONT};cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.ausboss-card-seg button:hover{color:${T.inkHover}}
|
||||
.ausboss-card-seg button.on{background:${T.brand};color:${T.onBrand}}
|
||||
.ausboss-card-bool button.on:first-child{background:${T.switchOff};color:${T.ink}}
|
||||
.ausboss-card-select{width:100%;height:${ROW_HEIGHT}px;padding:0 24px 0 8px;box-sizing:border-box;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill} ${SELECT_CHEVRON} no-repeat right 9px center;color:${T.ink};font:500 12px ${FONT};appearance:none;-webkit-appearance:none;cursor:pointer;overflow:hidden;text-overflow:ellipsis}
|
||||
.ausboss-card-select:hover{background-color:${T.fillHover}}
|
||||
.ausboss-card-select:focus-visible{outline:2px solid ${T.brand};outline-offset:1px}
|
||||
.ausboss-card-text{width:100%;height:${ROW_HEIGHT}px;padding:0 8px;box-sizing:border-box;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};color:${T.ink};font:12px ${FONT};min-width:0}
|
||||
.ausboss-card-text:focus{outline:none;border-color:${T.brand}}
|
||||
.ausboss-card-text::placeholder{color:${T.faint}}
|
||||
.ausboss-card-area{display:block;width:100%;height:100%;padding:5px 8px;box-sizing:border-box;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};color:${T.ink};font:12px/1.4 ${FONT};resize:none;overflow:auto;min-width:0}
|
||||
.ausboss-card-area:focus{outline:none;border-color:${T.brand}}
|
||||
.ausboss-card-area::placeholder{color:${T.faint}}
|
||||
.ausboss-card.look-big .ausboss-card-row.area .ausboss-card-field{position:relative;display:block;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};cursor:text}
|
||||
.ausboss-card.look-big .ausboss-card-row.area .ausboss-card-field:focus-within{border-color:${T.brand}}
|
||||
.ausboss-card.look-big .ausboss-card-area{position:absolute;left:0;right:0;top:0;bottom:0;margin:auto 0;max-height:100%;padding:${BIG_PAD_Y}px ${BIG_PAD_X}px;border:none;border-radius:5px;background:transparent;color:${T.brandGlow};text-shadow:0 0 .35em rgba(0,180,170,.55);font:600 ${BIG_FONT}px/${BIG_LINE}px ${FONT};text-align:center;scrollbar-width:thin;scrollbar-color:${T.fillHover} transparent}
|
||||
.ausboss-card.look-big .ausboss-card-area::placeholder{color:${T.faint};text-shadow:none}
|
||||
.ausboss-card-seg{display:grid;grid-auto-flow:column;grid-auto-columns:1fr;width:100%;height:${ROW_HEIGHT}px;padding:2px;box-sizing:border-box;border:1px solid #2a3437;border-radius:6px;background:#0f1516}
|
||||
.ausboss-card-seg button{min-width:0;border:none;border-radius:4px;padding:0 2px;background:transparent;color:#8ba3a1;font:600 10.5px/1 ${FONT};cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.ausboss-card-seg button:hover{color:#fff}
|
||||
.ausboss-card-seg button.on{background:${BRAND};color:#04201d}
|
||||
.ausboss-card-select{width:100%;height:${ROW_HEIGHT}px;padding:0 24px 0 8px;box-sizing:border-box;border:1px solid #2a3437;border-radius:6px;background:#0b0f10 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%238ba3a1' stroke-width='1.5'/%3E%3C/svg%3E") no-repeat right 8px center;color:#d8ecea;font:600 11.5px ${FONT};appearance:none;-webkit-appearance:none;cursor:pointer;overflow:hidden;text-overflow:ellipsis}
|
||||
.ausboss-card-select:hover{border-color:${BRAND}}
|
||||
.ausboss-card-select:focus-visible{outline:2px solid ${BRAND};outline-offset:1px}
|
||||
.ausboss-card-text{width:100%;height:${ROW_HEIGHT}px;padding:0 8px;box-sizing:border-box;border:1px solid #2a3437;border-radius:6px;background:#0b0f10;color:#d8ecea;font:12px ${FONT};min-width:0}
|
||||
.ausboss-card-text:focus{outline:none;border-color:${BRAND}}
|
||||
.ausboss-card-text::placeholder{color:#4f6462}
|
||||
.ausboss-card-area{display:block;width:100%;height:100%;padding:5px 8px;box-sizing:border-box;border:1px solid #2a3437;border-radius:6px;background:#0b0f10;color:#d8ecea;font:12px/1.4 ${FONT};resize:none;overflow:auto;min-width:0}
|
||||
.ausboss-card-area:focus{outline:none;border-color:${BRAND}}
|
||||
.ausboss-card-area::placeholder{color:#4f6462}
|
||||
.ausboss-card.look-big .ausboss-card-row.area .ausboss-card-field{position:relative;display:block;border:1px solid #2a3437;border-radius:6px;background:#0b0f10;cursor:text}
|
||||
.ausboss-card.look-big .ausboss-card-row.area .ausboss-card-field:focus-within{border-color:${BRAND}}
|
||||
.ausboss-card.look-big .ausboss-card-area{position:absolute;left:0;right:0;top:0;bottom:0;margin:auto 0;max-height:100%;padding:${BIG_PAD_Y}px ${BIG_PAD_X}px;border:none;border-radius:5px;background:transparent;color:#6fe0d6;text-shadow:0 0 .35em rgba(0,180,170,.55);font:600 ${BIG_FONT}px/${BIG_LINE}px ${FONT};text-align:center;scrollbar-width:thin;scrollbar-color:#2f3d3c transparent}
|
||||
.ausboss-card.look-big .ausboss-card-area::placeholder{color:#4f6462;text-shadow:none}
|
||||
.ausboss-card.look-big .ausboss-card-row:not(.area){display:block}
|
||||
.ausboss-card.look-big .ausboss-card-row:not(.area) .ausboss-card-label{display:none}
|
||||
.ausboss-card.look-big .ausboss-card-row:not(.area) .ausboss-card-control{margin:0}
|
||||
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub{height:100%;justify-content:center;align-items:center;gap:4px;border-color:transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill}}
|
||||
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub:focus-within{border-color:${T.brand}}
|
||||
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-input{order:1;flex:0 0 auto;width:auto;min-width:2ch;max-width:60%;field-sizing:content;color:${T.brandGlow};text-shadow:0 0 .35em rgba(0,180,170,.55);font:600 ${BIG_NUMBER_FONT}px ${FONT}}
|
||||
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub{height:100%;justify-content:center;align-items:center;gap:4px;border-color:#2a3437;border-radius:6px;background:#0b0f10}
|
||||
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub:focus-within{border-color:${BRAND}}
|
||||
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-input{order:1;flex:0 0 auto;width:auto;min-width:2ch;max-width:60%;field-sizing:content;color:#6fe0d6;text-shadow:0 0 .35em rgba(0,180,170,.55);font:600 ${BIG_NUMBER_FONT}px ${FONT}}
|
||||
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-unit{order:2}
|
||||
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-unit:empty{display:none}
|
||||
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-step{display:contents}
|
||||
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-step>button{flex:none;width:30px;height:30px;border-radius:15px;color:${T.label}}
|
||||
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-step>button{flex:none;width:30px;height:30px;border-radius:15px;color:#6f8886}
|
||||
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-step>button:first-child{order:3}
|
||||
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-step>button:last-child{order:0}
|
||||
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-step>button>svg{transform:rotate(90deg) scale(1.6)}
|
||||
.ausboss-card.look-big .ausboss-card-corner{top:${CARD_PADDING + 4}px;right:12px;height:22px}
|
||||
.ausboss-card.look-big .ausboss-card-gear{width:22px;height:22px;border-color:transparent;background:transparent}
|
||||
.ausboss-card.look-big .ausboss-card-gear:hover{background:${T.fillHover}}
|
||||
.ausboss-card-swatch{flex:none;width:${ROW_HEIGHT}px;height:${ROW_HEIGHT}px;padding:0;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};cursor:pointer}
|
||||
.ausboss-card.look-big .ausboss-card-gear:hover{border-color:${BRAND}}
|
||||
.ausboss-card-swatch{flex:none;width:${ROW_HEIGHT}px;height:${ROW_HEIGHT}px;padding:0;border:1px solid #2a3437;border-radius:6px;background:#0b0f10;cursor:pointer}
|
||||
.ausboss-card-swatch::-webkit-color-swatch-wrapper{padding:3px}
|
||||
.ausboss-card-swatch::-webkit-color-swatch{border:none;border-radius:3px}
|
||||
.ausboss-card-btn{flex:none;height:${ROW_HEIGHT}px;padding:0 10px;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};color:${T.inkSoft};font:600 11px/1 ${FONT};cursor:pointer;white-space:nowrap}
|
||||
.ausboss-card-btn:hover{background:${T.fillHover};color:${T.inkHover}}
|
||||
.ausboss-card-btn:focus-visible{outline:2px solid ${T.brand};outline-offset:1px}
|
||||
.ausboss-card-btn{flex:none;height:${ROW_HEIGHT}px;padding:0 10px;border:1px solid #2a3437;border-radius:6px;background:#16201f;color:#d8ecea;font:600 11px/1 ${FONT};cursor:pointer;white-space:nowrap}
|
||||
.ausboss-card-btn:hover{border-color:${BRAND};color:#fff}
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
@@ -174,6 +169,17 @@ function widgetSlot(node, name) {
|
||||
return node.inputs?.find((input) => input?.widget?.name === name || input?.name === name) ?? null;
|
||||
}
|
||||
|
||||
// A `top: true` row's widgets keep their input slots but the slots stop
|
||||
// being widget inputs: the frontend then measures and draws them in the
|
||||
// node's slot column, and a link into one is resolved by input name.
|
||||
export function liftSocket(node, name) {
|
||||
const slot = node.inputs?.find((input) => input?.widget?.name === name);
|
||||
if (!slot) return false;
|
||||
delete slot.widget;
|
||||
slot._widget = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
function widgetLinked(node, name) {
|
||||
const slot = widgetSlot(node, name);
|
||||
return Boolean(slot && slot.link !== null && slot.link !== undefined);
|
||||
|
||||
@@ -218,23 +218,3 @@ export function widgetTooltip(node, name, widget = null) {
|
||||
const text = Array.isArray(spec) ? spec[1]?.tooltip : undefined;
|
||||
return typeof text === "string" ? text : "";
|
||||
}
|
||||
|
||||
// A `top: true` row's widgets keep their input slots but the slots stop
|
||||
// being widget inputs: the frontend then measures and draws them in the
|
||||
// node's slot column, and a link into one is resolved by input name.
|
||||
// Newer frontends also stamp a widget's socket with the position of its
|
||||
// row (`slot.pos`) and keep honouring it once the widget is gone, so every
|
||||
// lifted socket would sit on the first slot's row. The stamp goes too.
|
||||
export function liftSocket(node, name) {
|
||||
const slot = node.inputs?.find((input) => input?.widget?.name === name);
|
||||
if (!slot) return false;
|
||||
liftSlot(slot);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The same lift for a slot already in hand (a loop over `node.inputs`).
|
||||
export function liftSlot(slot) {
|
||||
delete slot.widget;
|
||||
slot._widget = undefined;
|
||||
delete slot.pos;
|
||||
}
|
||||
|
||||
@@ -62,8 +62,7 @@ const CHOICES = {
|
||||
};
|
||||
|
||||
export function cleanTitle(title) {
|
||||
const text = ["string", "number", "boolean"].includes(typeof title) ? String(title) : "";
|
||||
return text.replace(/\s+/g, " ").trim().slice(0, TITLE_MAX);
|
||||
return String(title ?? "").replace(/\s+/g, " ").trim().slice(0, TITLE_MAX);
|
||||
}
|
||||
|
||||
// Node and group ids as a workflow file stores them: numbers, or text for a
|
||||
@@ -136,7 +135,7 @@ export function normalizeSwitches(raw) {
|
||||
const used = new Set();
|
||||
for (const item of Array.isArray(raw) ? raw : []) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
let id = typeof item.id === "number" || typeof item.id === "string" ? Number(item.id) : NaN;
|
||||
let id = Number(item.id);
|
||||
if (!Number.isInteger(id) || id < 1 || used.has(id)) {
|
||||
id = 1;
|
||||
while (used.has(id)) id += 1;
|
||||
@@ -358,7 +357,7 @@ export function stackRows(value) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(rows)) return null;
|
||||
if (!Array.isArray(rows) || !rows.length) return null;
|
||||
const isRow = (row) => row && typeof row === "object" && typeof row.name === "string" && typeof row.enabled === "boolean";
|
||||
return rows.every(isRow) ? rows : null;
|
||||
}
|
||||
@@ -485,12 +484,7 @@ export function choicePairs(entry, switches) {
|
||||
for (const other of switches ?? []) {
|
||||
if (other === entry || !other.choice) continue;
|
||||
for (const pair of valuePairs({ on: entry.on, off: other.on })) {
|
||||
const previous = found.get(pairKey(pair));
|
||||
if (!previous) found.set(pairKey(pair), { ...pair, choice: true });
|
||||
else if (pair.row !== undefined) {
|
||||
previous.on = { ...previous.on, ...pair.on };
|
||||
previous.off = { ...pair.off, ...previous.off };
|
||||
}
|
||||
if (!found.has(pairKey(pair))) found.set(pairKey(pair), { ...pair, choice: true });
|
||||
}
|
||||
}
|
||||
return [...found.values()];
|
||||
@@ -545,9 +539,8 @@ export function stackRowOwners(boards, nodeId, widget, rowName) {
|
||||
const owners = [];
|
||||
if (!rowName) return owners;
|
||||
for (const { node, settings } of boards ?? []) {
|
||||
const { switches } = normalizeSettings(settings);
|
||||
for (const entry of switches) {
|
||||
const moves = (entry.choice ? choicePairs(entry, switches) : valuePairs(entry)).some(
|
||||
for (const entry of normalizeSettings(settings).switches) {
|
||||
const moves = valuePairs(entry).some(
|
||||
(pair) => pair.row !== undefined && String(pair.node) === String(nodeId) && pair.widget === widget && sameFile(pair.row, rowName),
|
||||
);
|
||||
if (moves) owners.push({ node, id: entry.id, title: entry.title });
|
||||
@@ -562,9 +555,8 @@ export function stackGroupOwners(boards, nodeId, widget, group) {
|
||||
const owners = [];
|
||||
if (!group) return owners;
|
||||
for (const { node, settings } of boards ?? []) {
|
||||
const { switches } = normalizeSettings(settings);
|
||||
for (const entry of switches) {
|
||||
const moves = (entry.choice ? choicePairs(entry, switches) : valuePairs(entry)).some(
|
||||
for (const entry of normalizeSettings(settings).switches) {
|
||||
const moves = valuePairs(entry).some(
|
||||
(pair) => pair.group === group && String(pair.node) === String(nodeId) && pair.widget === widget,
|
||||
);
|
||||
if (moves) owners.push({ node, id: entry.id, title: entry.title });
|
||||
@@ -650,9 +642,7 @@ export function readGroups(groups, placed, { counts = () => true } = {}) {
|
||||
group,
|
||||
rect,
|
||||
title: String(group.title ?? ""),
|
||||
// Group colors come from a hex picker; a CSS background must never
|
||||
// receive URL functions or references from a saved workflow.
|
||||
color: typeof group.color === "string" && /^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.test(group.color.trim()) ? group.color.trim() : null,
|
||||
color: typeof group.color === "string" && group.color ? group.color : null,
|
||||
members,
|
||||
...partState(members, counts),
|
||||
};
|
||||
@@ -678,7 +668,6 @@ export function readSwitches(switches, placed, groupRows, { counts = () => true
|
||||
if (id !== undefined && id !== null && !groupById.has(String(id))) groupById.set(String(id), row);
|
||||
}
|
||||
return (switches ?? []).map((entry) => {
|
||||
const sides = new Set([...Object.keys(entry.on ?? {}), ...Object.keys(entry.off ?? {})]);
|
||||
const members = [];
|
||||
const offMembers = [];
|
||||
const seen = new Set();
|
||||
@@ -686,7 +675,6 @@ export function readSwitches(switches, placed, groupRows, { counts = () => true
|
||||
let gone = 0;
|
||||
const takeNodes = (ids, into) => {
|
||||
for (const id of ids ?? []) {
|
||||
if (sides.has(String(id))) continue;
|
||||
const hit = nodeById.get(String(id));
|
||||
if (!hit) {
|
||||
gone += 1;
|
||||
@@ -707,7 +695,7 @@ export function readSwitches(switches, placed, groupRows, { counts = () => true
|
||||
}
|
||||
rects.push(row.rect);
|
||||
for (const node of row.members) {
|
||||
if (seen.has(node) || sides.has(String(node.id))) continue;
|
||||
if (seen.has(node)) continue;
|
||||
seen.add(node);
|
||||
into.push(node);
|
||||
}
|
||||
@@ -720,6 +708,7 @@ export function readSwitches(switches, placed, groupRows, { counts = () => true
|
||||
// The nodes whose settings it changes: listed, framed and selected with
|
||||
// the rest, but never turned off by it.
|
||||
const valueNodes = [];
|
||||
const sides = new Set([...Object.keys(entry.on ?? {}), ...Object.keys(entry.off ?? {})]);
|
||||
for (const id of sides) {
|
||||
const hit = nodeById.get(id);
|
||||
if (!hit) {
|
||||
|
||||
+4
-21
@@ -37,8 +37,7 @@ const OVERHANG_PX = 6;
|
||||
const state = {
|
||||
enabled: true,
|
||||
before: null, // Map item -> { pos, size, rect } taken when the button went down
|
||||
corner: null, // the corner a resize pulls, such as "SE"
|
||||
resizingNode: null, // the node whose Vue corner handle took the press
|
||||
corner: null, // the corner a classic resize pulls, such as "SE"
|
||||
drag: null, // the running move, or { idle: true } for a gesture left alone
|
||||
guides: [],
|
||||
frame: 0,
|
||||
@@ -200,8 +199,8 @@ function stepMove(canvas, drag, event) {
|
||||
canvas.setDirty?.(true, true);
|
||||
}
|
||||
|
||||
// The frontend has just sized the node from its corner, so pull the moving
|
||||
// edges onto any line within reach.
|
||||
// Classic look: LiteGraph has just sized the node from its corner, so pull
|
||||
// the moving edges onto any line within reach.
|
||||
function stepNodeResize(canvas, node, event) {
|
||||
// LiteGraph forgets the corner once the pointer leaves the node, so use the
|
||||
// one it had when the button went down.
|
||||
@@ -239,7 +238,6 @@ function step(event) {
|
||||
const canvas = app.canvas;
|
||||
if (!state.before || !canvas?.graph || !canvas.canvas || !canvas.ds) return;
|
||||
if (canvas.resizing_node) return stepNodeResize(canvas, canvas.resizing_node, event);
|
||||
if (state.resizingNode) return stepNodeResize(canvas, state.resizingNode, event);
|
||||
if (canvas.resizingGroup) return stepGroupResize(canvas, canvas.resizingGroup, event);
|
||||
if (!state.drag) state.drag = startMove(canvas, event);
|
||||
if (state.drag && !state.drag.idle) stepMove(canvas, state.drag, event);
|
||||
@@ -252,20 +250,6 @@ function onPointerDown(event) {
|
||||
if (OTHER_SNAPPERS.some(settingOn)) return;
|
||||
if (app.canvas?.graph && app.canvas.canvas && app.canvas.ds) state.before = takeBefore(app.canvas);
|
||||
state.corner = cornerOf(app.canvas);
|
||||
if (vueNodes() && state.before) {
|
||||
const handle = event.target?.closest?.("[data-corner]");
|
||||
const id = handle?.closest?.("[data-node-id]")?.dataset?.nodeId;
|
||||
const corner = handle?.dataset?.corner;
|
||||
if (id != null && ["SE", "SW", "NE", "NW"].includes(corner)) {
|
||||
for (const [item, was] of state.before) {
|
||||
if (was.kind === "node" && String(item.id) === id) {
|
||||
state.resizingNode = item;
|
||||
state.corner = corner;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cornerOf(canvas) {
|
||||
@@ -275,7 +259,7 @@ function cornerOf(canvas) {
|
||||
|
||||
// Behind LiteGraph's own pointerdown, which names the corner a resize pulls.
|
||||
function afterPointerDown() {
|
||||
if (state.before && !state.resizingNode) state.corner = cornerOf(app.canvas) ?? state.corner;
|
||||
if (state.before) state.corner = cornerOf(app.canvas) ?? state.corner;
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
@@ -297,7 +281,6 @@ function onPointerMove(event) {
|
||||
function end() {
|
||||
state.before = null;
|
||||
state.corner = null;
|
||||
state.resizingNode = null;
|
||||
state.drag = null;
|
||||
state.pending = null;
|
||||
setGuides([]);
|
||||
|
||||
@@ -14,7 +14,6 @@ import { castChips } from "../shared/h3_cast_chips.mjs";
|
||||
import { clipSetsDuration } from "../shared/h3_duration.mjs";
|
||||
import { momentStrip } from "../shared/moment_strip.mjs";
|
||||
import { mountWidgetCard } from "../shared/widget_card.mjs";
|
||||
import { FONT_UI, THEME } from "../shared/theme.mjs";
|
||||
import { SOCKETLESS_CHOICE, SOCKETLESS_TEXT, commitWidgetValue, markSocketless, resetUnknownValues } from "../shared/widget_card_math.mjs";
|
||||
import { mediaViewQuery } from "../shared/media_list.mjs";
|
||||
import { gearIconSvg, openSettingsMenu } from "../shared/settings_menu.mjs";
|
||||
@@ -60,7 +59,7 @@ function seamCorner(node, card) {
|
||||
const note = document.createElement("div");
|
||||
note.append("not used", document.createElement("br"), "by blend in");
|
||||
note.style.cssText = "position:absolute;top:33px;right:2px;text-align:right;white-space:nowrap;"
|
||||
+ `color:${THEME.faint};font:9px/11px ${FONT_UI};`
|
||||
+ "color:#6f8886;font:9px/11px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"
|
||||
+ "pointer-events:none";
|
||||
tools.append(chip, gear, note);
|
||||
return {
|
||||
@@ -243,8 +242,6 @@ const CARDS = {
|
||||
{ widget: "fill_holes", label: "Fill small holes", group: "advanced" },
|
||||
{ widget: "invert", label: "Invert", group: "advanced" },
|
||||
{ widget: "if_nothing", label: "If nothing", kind: "segment", group: "advanced" },
|
||||
// What a different picture coming in does to the picks.
|
||||
{ widget: "new_picture", label: "New picture", kind: "segment", group: "advanced" },
|
||||
{ widget: "free_vram", label: "Free VRAM", group: "advanced" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -604,7 +604,7 @@ function newSwitch(state) {
|
||||
// has choices the nodes they hold, so the next one takes no selecting.
|
||||
function choiceSnapshots(state, nodes) {
|
||||
const graph = state.node.graph;
|
||||
const held = choiceNodeIds(settingsOf(state.node)).map((id) => graph?.getNodeById?.(id)).filter(Boolean);
|
||||
const held = choiceNodeIds(settingsOf(state.node)).map((id) => graph?.getNodeById?.(Number(id))).filter(Boolean);
|
||||
return [...new Set([...nodes, ...held])]
|
||||
.map((node) => ({ id: node.id, values: snapshotNode(node) }))
|
||||
.filter((item) => Object.keys(item.values).length);
|
||||
@@ -1156,7 +1156,7 @@ function refresh(state) {
|
||||
// The selection only shows in the edit view (the + Switch bar).
|
||||
const pick = edit ? picked(state) : { nodes: [], groups: [], count: 0 };
|
||||
const signature = [
|
||||
rowsSignature(rows, settings, rowKey), groups.length, edit, settingsOpen(node), pick.count, pick.nodes.length,
|
||||
rowsSignature(rows, settings, rowKey), groups.length, edit, settingsOpen(node), pick.count,
|
||||
].join("|");
|
||||
if (signature === state.signature) return;
|
||||
state.signature = signature;
|
||||
@@ -1166,7 +1166,7 @@ function refresh(state) {
|
||||
// Called on every canvas redraw: read the graph again at most every
|
||||
// RECHECK_MS, and always once after the last redraw.
|
||||
function scheduleCheck(state) {
|
||||
if (state.disposed || !state.node.graph || state.timer !== null) return;
|
||||
if (state.disposed || state.timer !== null) return;
|
||||
const shown = app.canvas?.getCurrentGraph?.() ?? app.canvas?.graph;
|
||||
if (state.node.graph && shown && state.node.graph !== shown) return;
|
||||
const wait = Math.max(0, state.checkedAt + RECHECK_MS - performance.now());
|
||||
@@ -1254,6 +1254,7 @@ function buildPanel(node) {
|
||||
trimNodeBottom(node);
|
||||
state.widget = widget;
|
||||
|
||||
live.add(state);
|
||||
ensureRedrawHook();
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -1591,5 +1591,25 @@
|
||||
"AUSBOSS_NODES_WorkflowNoteTile": {
|
||||
"display_name": "工作流说明卡片 🆎",
|
||||
"description": "点击打开完整说明的小按钮。点击铅笔编辑,拖动角落调整大小。旧版节点包会将它显示为缺失节点,请更新 ComfyUI-AusBoss。"
|
||||
},
|
||||
"AUSBOSS_NODES_CameraMove": {
|
||||
"display_name": "镜头移动 🆎",
|
||||
"description": "移动视频的镜头。在节点画面上拖动来转动镜头,捏合或滚轮拉远推近,并在时间轴上设置关键帧。原镜头从未拍到的地方显示为粉色,交给 CrossView-Warp LoRA 补画。需要 MoGe Inference 的深度。",
|
||||
"inputs": {
|
||||
"moge_geometry": {
|
||||
"name": "MoGe 几何"
|
||||
},
|
||||
"camera": {
|
||||
"name": "镜头关键帧"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "引导"
|
||||
},
|
||||
"1": {
|
||||
"name": "空洞"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Camera Move: the camera keys, the camera's pose and the depth warp.
|
||||
|
||||
The keys are what the node's face writes: a list of moments on the clip
|
||||
(t from 0 to 1) and where the camera is at each one. The camera passes
|
||||
through every key without slowing down, and starts and ends gently. The face draws its picture with the
|
||||
same maths, so what you steer on the node is what the warp makes.
|
||||
|
||||
Axes are OpenCV's: x right, y down, z into the picture. Each frame's points
|
||||
stay where the original camera saw them; only the camera moves.
|
||||
|
||||
Technical details
|
||||
-----------------
|
||||
A key holds az (turn, degrees, right is positive), el (rise, degrees, up is
|
||||
positive), back (pull back, in distances to the pivot; negative pushes in),
|
||||
px and py (slide right and down, in distances to the pivot).
|
||||
|
||||
R = Ry(-az) . Rx(-el)
|
||||
C = pivot - R.pivot + R.[px.d, py.d, -back.d] d = |pivot|
|
||||
|
||||
A point P lands in the new camera at R^T (P - C). The pivot is the spot the
|
||||
camera circles: picked by a tap on the face, else what sits in the middle
|
||||
of the first frame.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
|
||||
LIMITS = {"az": (-90.0, 90.0), "el": (-30.0, 60.0), "back": (-0.6, 2.0), "px": (-1.0, 1.0), "py": (-1.0, 1.0)}
|
||||
FIELDS = tuple(LIMITS)
|
||||
HOME = {name: 0.0 for name in FIELDS}
|
||||
# Where the LoRA was trained to work well. Past this the face warns.
|
||||
LORA_RANGE = {"az": 45.0, "el_up": 30.0, "el_down": -20.0}
|
||||
# Two keys within this much of the clip are the same key.
|
||||
SAME_KEY = 0.015
|
||||
MAX_KEYS = 64
|
||||
MAGENTA = (1.0, 0.0, 1.0)
|
||||
# The nearest thing in the middle of the first frame, never further than this
|
||||
# (in the depth model's metres): the move stays a move, not a flight.
|
||||
PIVOT_CAP = 3.0
|
||||
SCENE_LONG_SIDE = 416
|
||||
# Every second frame of a 5-second clip, so the face plays smoothly (about 190 KB a frame).
|
||||
SCENE_FRAMES = 61
|
||||
|
||||
DEFAULT_CAMERA = {
|
||||
"v": 1,
|
||||
"keys": [{"t": 0.0, **HOME}, {"t": 1.0, **HOME, "az": 30.0, "el": 5.0}],
|
||||
"pivot": None,
|
||||
}
|
||||
|
||||
|
||||
def default_camera_text() -> str:
|
||||
return json.dumps(DEFAULT_CAMERA, separators=(",", ":"))
|
||||
|
||||
|
||||
def _number(value, fallback=0.0) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
return number if math.isfinite(number) else fallback
|
||||
|
||||
|
||||
def _clamp(value, low, high):
|
||||
return max(low, min(high, value))
|
||||
|
||||
|
||||
def clean_key(raw) -> dict:
|
||||
"""One key with every field present, inside its limits."""
|
||||
raw = raw if isinstance(raw, dict) else {}
|
||||
key = {"t": _clamp(_number(raw.get("t")), 0.0, 1.0)}
|
||||
for name, (low, high) in LIMITS.items():
|
||||
key[name] = _clamp(_number(raw.get(name)), low, high)
|
||||
return key
|
||||
|
||||
|
||||
def parse_camera(text) -> dict:
|
||||
"""The camera the face wrote. Anything unreadable falls back to the default move.
|
||||
|
||||
Returns {"keys": [...], "pivot": None | {"u", "v", "t"}, "fallback": bool}.
|
||||
Keys come back sorted by time, with keys at the same moment merged (the
|
||||
later one wins), and never empty.
|
||||
"""
|
||||
data = None
|
||||
if isinstance(text, dict):
|
||||
data = text
|
||||
elif isinstance(text, str) and text.strip():
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except (ValueError, RecursionError):
|
||||
data = None
|
||||
fallback = not isinstance(data, dict) or not isinstance(data.get("keys"), list) or not data["keys"]
|
||||
if fallback:
|
||||
data = DEFAULT_CAMERA
|
||||
keys = []
|
||||
for raw in data["keys"][:MAX_KEYS]:
|
||||
key = clean_key(raw)
|
||||
same = next((k for k in keys if abs(k["t"] - key["t"]) < SAME_KEY), None)
|
||||
if same is not None:
|
||||
same.update(key)
|
||||
else:
|
||||
keys.append(key)
|
||||
keys.sort(key=lambda k: k["t"])
|
||||
pivot = data.get("pivot")
|
||||
if isinstance(pivot, dict) and all(name in pivot for name in ("u", "v")):
|
||||
pivot = {
|
||||
"u": _clamp(_number(pivot.get("u"), 0.5), 0.0, 1.0),
|
||||
"v": _clamp(_number(pivot.get("v"), 0.5), 0.0, 1.0),
|
||||
"t": _clamp(_number(pivot.get("t"), 0.0), 0.0, 1.0),
|
||||
}
|
||||
else:
|
||||
pivot = None
|
||||
return {"keys": keys, "pivot": pivot, "fallback": fallback}
|
||||
|
||||
|
||||
def _key_slopes(ts, ys):
|
||||
"""How fast one field moves as it passes each key.
|
||||
|
||||
Zero at the first and last key, so the move starts and ends gently. Zero
|
||||
at a key where the field turns back or holds, so it never runs past a
|
||||
key. Elsewhere it keeps its speed through the key (Fritsch-Butland), so
|
||||
a long move made of several keys does not slow down at each one.
|
||||
"""
|
||||
n = len(ys)
|
||||
slopes = [0.0] * n
|
||||
for k in range(1, n - 1):
|
||||
h0, h1 = ts[k] - ts[k - 1], ts[k + 1] - ts[k]
|
||||
if h0 <= 0 or h1 <= 0:
|
||||
continue
|
||||
d0, d1 = (ys[k] - ys[k - 1]) / h0, (ys[k + 1] - ys[k]) / h1
|
||||
if d0 * d1 > 0:
|
||||
slopes[k] = 3.0 * (h0 + h1) / ((2.0 * h1 + h0) / d0 + (h1 + 2.0 * h0) / d1)
|
||||
return slopes
|
||||
|
||||
|
||||
def camera_at(keys, t: float) -> dict:
|
||||
"""Where the camera is at moment t (0..1): through every key, gently at the ends."""
|
||||
if not keys:
|
||||
return dict(HOME)
|
||||
first, last = keys[0], keys[-1]
|
||||
if len(keys) == 1 or t <= first["t"]:
|
||||
return {name: first[name] for name in FIELDS}
|
||||
if t >= last["t"]:
|
||||
return {name: last[name] for name in FIELDS}
|
||||
ts = [k["t"] for k in keys]
|
||||
for i in range(len(keys) - 1):
|
||||
a, b = keys[i], keys[i + 1]
|
||||
if a["t"] <= t <= b["t"]:
|
||||
h = max(1e-6, b["t"] - a["t"])
|
||||
s = (t - a["t"]) / h
|
||||
h00, h10, h01, h11 = 2 * s**3 - 3 * s**2 + 1, s**3 - 2 * s**2 + s, -2 * s**3 + 3 * s**2, s**3 - s**2
|
||||
out = {}
|
||||
for name in FIELDS:
|
||||
m = _key_slopes(ts, [k[name] for k in keys])
|
||||
out[name] = h00 * a[name] + h10 * h * m[i] + h01 * b[name] + h11 * h * m[i + 1]
|
||||
return out
|
||||
return {name: last[name] for name in FIELDS}
|
||||
|
||||
|
||||
def frame_moments(count: int) -> list[float]:
|
||||
"""The moment (0..1) of each frame. One frame sits at the start."""
|
||||
if count <= 1:
|
||||
return [0.0] * max(0, count)
|
||||
return [i / (count - 1) for i in range(count)]
|
||||
|
||||
|
||||
def rotation(az: float, el: float) -> list[list[float]]:
|
||||
"""R = Ry(-az) . Rx(-el): az > 0 swings the camera right, el > 0 lifts it."""
|
||||
th, ph = math.radians(-az), math.radians(-el)
|
||||
ct, st, cp, sp = math.cos(th), math.sin(th), math.cos(ph), math.sin(ph)
|
||||
return [[ct, st * sp, st * cp], [0.0, cp, -sp], [-st, ct * sp, ct * cp]]
|
||||
|
||||
|
||||
def _mv(R, v):
|
||||
return [sum(R[r][c] * v[c] for c in range(3)) for r in range(3)]
|
||||
|
||||
|
||||
def pose(cam: dict, pivot) -> tuple[list[list[float]], list[float]]:
|
||||
"""The moved camera's rotation R and centre C, for a key and a pivot point."""
|
||||
R = rotation(cam["az"], cam["el"])
|
||||
d = math.sqrt(sum(x * x for x in pivot))
|
||||
Rp = _mv(R, pivot)
|
||||
b = _mv(R, [cam.get("px", 0.0) * d, cam.get("py", 0.0) * d, -cam["back"] * d])
|
||||
return R, [pivot[i] - Rp[i] + b[i] for i in range(3)]
|
||||
|
||||
|
||||
def past_lora_range(cam: dict) -> bool:
|
||||
return abs(cam["az"]) > LORA_RANGE["az"] or cam["el"] > LORA_RANGE["el_up"] or cam["el"] < LORA_RANGE["el_down"]
|
||||
|
||||
|
||||
def range_report(keys) -> str:
|
||||
"""One plain line when a key goes past where the LoRA was trained, else empty."""
|
||||
over = [k for k in keys if past_lora_range(k)]
|
||||
if not over:
|
||||
return ""
|
||||
when = ", ".join(f"{k['t']:.2f}" for k in over[:4])
|
||||
return (
|
||||
f"Camera Move: {len(over)} key(s) past the LoRA's range (at {when} of the clip). "
|
||||
"It learned up to 45 degrees of turn, 30 up and 20 down; more may leave pink."
|
||||
)
|
||||
|
||||
|
||||
def scene_frame_indices(count: int, most: int = SCENE_FRAMES) -> list[int]:
|
||||
"""Which frames the face gets to look at: evenly spread, first and last included."""
|
||||
if count <= 0:
|
||||
return []
|
||||
if count <= most:
|
||||
return list(range(count))
|
||||
return sorted({round(i * (count - 1) / (most - 1)) for i in range(most)})
|
||||
|
||||
|
||||
def scene_size(width: int, height: int, long_side: int = SCENE_LONG_SIDE) -> tuple[int, int]:
|
||||
scale = min(1.0, long_side / max(width, height))
|
||||
return max(1, round(width * scale)), max(1, round(height * scale))
|
||||
|
||||
|
||||
def pack_depth(z, valid, zmin: float, zmax: float):
|
||||
"""Depth as two bytes a pixel (red high, green low) on a log scale; 0 is no depth.
|
||||
|
||||
z and valid are numpy arrays. Returns an (H, W, 3) uint8 array.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
lo, hi = math.log(max(zmin, 1e-6)), math.log(max(zmax, zmin * 1.0001, 1e-6))
|
||||
q = np.zeros(z.shape, dtype=np.uint32)
|
||||
if valid.any():
|
||||
zz = np.clip(z[valid], math.exp(lo), math.exp(hi))
|
||||
q[valid] = 1 + np.round((np.log(zz) - lo) / max(1e-9, hi - lo) * 65534).astype(np.uint32)
|
||||
out = np.zeros(z.shape + (3,), dtype=np.uint8)
|
||||
out[..., 0] = (q >> 8) & 255
|
||||
out[..., 1] = q & 255
|
||||
return out
|
||||
|
||||
|
||||
def unpack_depth(rgb, zmin: float, zmax: float, far: float):
|
||||
"""The face's reading of pack_depth, for tests."""
|
||||
import numpy as np
|
||||
|
||||
q = rgb[..., 0].astype(np.uint32) * 256 + rgb[..., 1].astype(np.uint32)
|
||||
lo, hi = math.log(zmin), math.log(zmax)
|
||||
z = np.exp(lo + (q.astype(np.float64) - 1) / 65534 * (hi - lo))
|
||||
return np.where(q == 0, far, z), q > 0
|
||||
|
||||
|
||||
# -- the warp (torch) ---------------------------------------------------------
|
||||
|
||||
|
||||
def pixel_intrinsics(K, width: int, height: int):
|
||||
"""MoGe's lens as pixels; it may come normalised to the picture size."""
|
||||
K = K.clone().float()
|
||||
if K[0, 2] < 4:
|
||||
K[0, :] *= width
|
||||
K[1, :] *= height
|
||||
return K
|
||||
|
||||
|
||||
def steady_depth(z, valid, image, radius: int = 4, colour_tol: float = 0.05):
|
||||
"""Calm the depth of things that hold still.
|
||||
|
||||
The depth model looks at every frame afresh, so a wall that never moves
|
||||
still gets a slightly different depth each frame, and the moved camera
|
||||
shakes. A spot whose colour stays the same a few frames either side has
|
||||
its depth averaged over those frames. Anything that changes in that
|
||||
window, such as a person moving, keeps its own depth.
|
||||
"""
|
||||
import torch
|
||||
|
||||
count = z.shape[0]
|
||||
if count < 2 or radius < 1:
|
||||
return z
|
||||
logz = torch.where(valid, z.clamp(min=1e-6).log(), torch.zeros_like(z))
|
||||
out = z.clone()
|
||||
inv = 1.0 / (2.0 * colour_tol * colour_tol)
|
||||
for i in range(count):
|
||||
lo, hi = max(0, i - radius), min(count, i + radius + 1)
|
||||
change = ((image[lo:hi] - image[i]) ** 2).sum(-1).amax(0)
|
||||
held = valid[lo:hi].all(0)
|
||||
still = torch.exp(-change * inv) * held
|
||||
mean = logz[lo:hi].sum(0) / (hi - lo)
|
||||
calm = (still * mean + (1 - still) * logz[i]).exp()
|
||||
out[i] = torch.where(valid[i] & held, calm, z[i])
|
||||
return out
|
||||
|
||||
|
||||
def prepare_geometry(geometry: dict, device=None):
|
||||
"""Points, colours and lens from a MOGE_GEOMETRY, with every frame held to one depth scale.
|
||||
|
||||
The depth model's scale and lens wobble from frame to frame, which shakes
|
||||
the moved picture. Each frame is scaled so its median depth follows a
|
||||
smoothed line through all of them, every frame gets the clip's median
|
||||
lens, and the depth of whatever holds still is calmed (steady_depth).
|
||||
"""
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
points = geometry.get("points")
|
||||
intrinsics = geometry.get("intrinsics")
|
||||
if points is None or intrinsics is None:
|
||||
raise ValueError(
|
||||
"Camera Move needs depth from MoGe Inference with its camera lens. "
|
||||
"Connect MoGe Inference's geometry, not a panorama."
|
||||
)
|
||||
points = points.float()
|
||||
count, height, width, _ = points.shape
|
||||
image = geometry["image"][..., :3].float()
|
||||
if image.shape[1:3] != (height, width):
|
||||
image = F.interpolate(image.movedim(-1, 1), size=(height, width), mode="bilinear", align_corners=False).movedim(1, -1)
|
||||
z = points[..., 2]
|
||||
valid = torch.isfinite(points).all(-1) & (z > 0)
|
||||
mask = geometry.get("mask")
|
||||
if mask is not None:
|
||||
valid &= mask.bool().to(valid.device)
|
||||
z = torch.where(valid, z, torch.zeros_like(z))
|
||||
meds = torch.stack([z[i][valid[i]].median() if valid[i].any() else torch.tensor(1.0) for i in range(count)])
|
||||
win = min(15, count) | 1
|
||||
padded = F.pad(meds.log()[None, None], (win // 2, win // 2), mode="replicate")
|
||||
smooth = F.avg_pool1d(padded, win, 1)[0, 0]
|
||||
z = z * (smooth - meds.log()).exp()[:, None, None]
|
||||
if device is not None:
|
||||
z = steady_depth(z.to(device), valid.to(device), image.to(device)).to(z.device)
|
||||
else:
|
||||
z = steady_depth(z, valid, image)
|
||||
# The depth model guesses the lens again for every frame and the guess
|
||||
# wobbles a few percent, which shakes the warp. One lens for the clip.
|
||||
K = torch.stack([pixel_intrinsics(intrinsics[i], width, height) for i in range(count)]).median(dim=0).values
|
||||
Ks = K[None].expand(count, 3, 3).clone()
|
||||
v0 = valid[0]
|
||||
far = float(torch.quantile(z[0][v0][::7], 0.98)) * 2.5 if v0.any() else float(meds.median()) * 10.0
|
||||
return {"z": z, "valid": valid, "image": image, "K": Ks, "far": far, "count": count, "width": width, "height": height}
|
||||
|
||||
|
||||
def unproject(K, x: float, y: float, depth: float) -> list[float]:
|
||||
fx, fy, cx, cy = float(K[0, 0]), float(K[1, 1]), float(K[0, 2]), float(K[1, 2])
|
||||
return [(x - cx) / fx * depth, (y - cy) / fy * depth, depth]
|
||||
|
||||
|
||||
def auto_pivot(scene) -> list[float]:
|
||||
"""What sits in the middle of the first frame: the nearest fifth of the centre box."""
|
||||
import torch
|
||||
|
||||
z0, v0, H, W = scene["z"][0], scene["valid"][0], scene["height"], scene["width"]
|
||||
box = (slice(int(H * 0.35), int(H * 0.65)), slice(int(W * 0.4), int(W * 0.6)))
|
||||
vb = v0[box]
|
||||
if vb.any():
|
||||
depth = float(torch.quantile(z0[box][vb].float(), 0.2))
|
||||
elif v0.any():
|
||||
depth = float(z0[v0].median())
|
||||
else:
|
||||
depth = 1.0
|
||||
return [0.0, 0.0, min(depth, PIVOT_CAP)]
|
||||
|
||||
|
||||
def resolve_pivot(scene, pivot) -> list[float]:
|
||||
"""The 3D spot the camera circles: the tapped one, else the automatic one."""
|
||||
if not pivot:
|
||||
return auto_pivot(scene)
|
||||
count, H, W = scene["count"], scene["height"], scene["width"]
|
||||
i = min(count - 1, max(0, round(pivot["t"] * (count - 1))))
|
||||
x = min(W - 1, max(0, int(pivot["u"] * W)))
|
||||
y = min(H - 1, max(0, int(pivot["v"] * H)))
|
||||
z, valid = scene["z"][i], scene["valid"][i]
|
||||
for r in range(0, 4):
|
||||
y0, y1, x0, x1 = max(0, y - r), min(H, y + r + 1), max(0, x - r), min(W, x + r + 1)
|
||||
patch = valid[y0:y1, x0:x1]
|
||||
if patch.any():
|
||||
depth = float(z[y0:y1, x0:x1][patch].median())
|
||||
return unproject(scene["K"][i], x + 0.5, y + 0.5, depth)
|
||||
return auto_pivot(scene)
|
||||
|
||||
|
||||
def _fill_cracks(img, filled, passes=2):
|
||||
"""Fill one-pixel gaps that are mostly surrounded by picture."""
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
k = torch.ones(1, 1, 3, 3, device=img.device)
|
||||
for _ in range(passes):
|
||||
f = filled.float()[None, None]
|
||||
cnt = F.conv2d(f, k, padding=1)[0, 0]
|
||||
s = F.conv2d((img * filled.float())[:, None], k, padding=1)[:, 0]
|
||||
take = (~filled) & (cnt >= 5)
|
||||
if not take.any():
|
||||
break
|
||||
img = torch.where(take[None], s / cnt.clamp(min=1), img)
|
||||
filled = filled | take
|
||||
return img, filled
|
||||
|
||||
|
||||
def warp_frame(scene, i: int, cam: dict, pivot, device):
|
||||
"""One frame seen from the moved camera. Returns (colour (H,W,3), hole (H,W) bool) on the CPU."""
|
||||
import torch
|
||||
|
||||
H, W = scene["height"], scene["width"]
|
||||
K = scene["K"][i].to(device)
|
||||
Kinv = torch.linalg.inv(K)
|
||||
valid = scene["valid"][i].to(device)
|
||||
zi = torch.where(valid, scene["z"][i].to(device), torch.full((H, W), scene["far"], device=device))
|
||||
col = scene["image"][i].to(device)
|
||||
sub = torch.tensor([[-0.25, -0.25], [0.25, -0.25], [-0.25, 0.25], [0.25, 0.25]], device=device)
|
||||
vv, uu = torch.meshgrid(torch.arange(H, device=device, dtype=torch.float32), torch.arange(W, device=device, dtype=torch.float32), indexing="ij")
|
||||
u = (uu[None] + 0.5 + sub[:, 0, None, None]).reshape(-1)
|
||||
v = (vv[None] + 0.5 + sub[:, 1, None, None]).reshape(-1)
|
||||
zz = zi[None].expand(4, H, W).reshape(-1)
|
||||
cc = col[None].expand(4, H, W, 3).reshape(-1, 3)
|
||||
P = (torch.stack([u, v, torch.ones_like(u)], -1) @ Kinv.T) * zz[:, None]
|
||||
R_list, C_list = pose(cam, pivot)
|
||||
R = torch.tensor(R_list, dtype=torch.float32, device=device)
|
||||
C = torch.tensor(C_list, dtype=torch.float32, device=device)
|
||||
Pc = (P - C) @ R # R^T (P - C), row vectors
|
||||
zc = Pc[:, 2]
|
||||
ok = zc > 1e-3
|
||||
uv = Pc[:, :2] / zc.clamp(min=1e-3)[:, None]
|
||||
up = K[0, 0] * uv[:, 0] + K[0, 2]
|
||||
vp = K[1, 1] * uv[:, 1] + K[1, 2]
|
||||
ui, vi = up.floor().long(), vp.floor().long()
|
||||
ok &= (ui >= 0) & (ui < W) & (vi >= 0) & (vi < H)
|
||||
lin, zc, cc = vi[ok] * W + ui[ok], zc[ok], cc[ok]
|
||||
zbuf = torch.full((H * W,), float("inf"), device=device)
|
||||
zbuf.scatter_reduce_(0, lin, zc, reduce="amin", include_self=True)
|
||||
win = zc <= zbuf[lin] * (1 + 1e-5)
|
||||
magenta = torch.tensor(MAGENTA, device=device).view(3, 1, 1)
|
||||
frame = magenta.expand(3, H, W).clone()
|
||||
frame.view(3, -1)[:, lin[win]] = cc[win].T
|
||||
filled = torch.isfinite(zbuf).view(H, W)
|
||||
frame, filled = _fill_cracks(frame, filled)
|
||||
frame = torch.where(filled[None], frame, magenta)
|
||||
return frame.movedim(0, -1).clamp(0, 1).cpu(), (~filled).cpu()
|
||||
+17
-31
@@ -208,7 +208,7 @@ def _weighted_mean_cov(
|
||||
def _matrix_sqrt(matrix: torch.Tensor, inverse: bool = False) -> torch.Tensor:
|
||||
"""Symmetric PSD square root (or inverse square root) via eigh."""
|
||||
values, vectors = torch.linalg.eigh(matrix.double())
|
||||
values = values.clamp_min(1e-10 if inverse else 0.0)
|
||||
values = values.clamp_min(1e-10)
|
||||
roots = values.rsqrt() if inverse else values.sqrt()
|
||||
return (vectors @ torch.diag(roots) @ vectors.T).to(matrix.dtype)
|
||||
|
||||
@@ -229,36 +229,18 @@ def _mkl_matched(
|
||||
weights = None
|
||||
if mask is not None:
|
||||
weights = mask[index if mask.shape[0] > 1 else 0].reshape(-1)
|
||||
img_mean, img_cov = _weighted_mean_cov(
|
||||
pixels.double(), weights.double() if weights is not None else None
|
||||
)
|
||||
img_mean, img_cov = _weighted_mean_cov(pixels, weights)
|
||||
ref_pixels = reference[index if reference.shape[0] > 1 else 0].reshape(-1, 3)
|
||||
ref_mean, ref_cov = _weighted_mean_cov(ref_pixels.double(), None)
|
||||
# Regularize both covariances in the same units. Their product has
|
||||
# squared units, so its square root must not use that absolute floor.
|
||||
regularizer = torch.eye(3, dtype=img_cov.dtype, device=img_cov.device) * 1e-10
|
||||
img_cov = img_cov + regularizer
|
||||
ref_cov = ref_cov + regularizer
|
||||
ref_mean, ref_cov = _weighted_mean_cov(ref_pixels, None)
|
||||
img_root = _matrix_sqrt(img_cov)
|
||||
img_root_inv = _matrix_sqrt(img_cov, inverse=True)
|
||||
# SVD gives the middle square root without squaring the condition
|
||||
# number of nearly singular covariances in an eigendecomposition.
|
||||
_, singular, right = torch.linalg.svd(_matrix_sqrt(ref_cov) @ img_root)
|
||||
middle = right.T @ torch.diag(singular) @ right
|
||||
middle = _matrix_sqrt(img_root @ ref_cov @ img_root)
|
||||
mapping = img_root_inv @ middle @ img_root_inv
|
||||
matched = (pixels - img_mean) @ mapping.T + ref_mean
|
||||
frames.append(matched.to(rgb.dtype).reshape(rgb.shape[1], rgb.shape[2], 3))
|
||||
frames.append(matched.reshape(rgb.shape[1], rgb.shape[2], 3))
|
||||
return torch.stack(frames, dim=0).clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _interpolate(values: torch.Tensor, positions: torch.Tensor, samples: torch.Tensor) -> torch.Tensor:
|
||||
"""Linear interpolation with constant endpoints, including a singleton."""
|
||||
high = torch.searchsorted(positions, values.contiguous()).clamp_max(positions.numel() - 1)
|
||||
low = (high - 1).clamp_min(0)
|
||||
fraction = ((values - positions[low]) / (positions[high] - positions[low]).clamp_min(1e-20)).clamp(0, 1)
|
||||
return samples[low] + fraction * (samples[high] - samples[low])
|
||||
|
||||
|
||||
def _histogram_matched(
|
||||
rgb: torch.Tensor, reference: torch.Tensor, mask: torch.Tensor | None
|
||||
) -> torch.Tensor:
|
||||
@@ -281,14 +263,18 @@ def _histogram_matched(
|
||||
for channel in range(3):
|
||||
values = frame[..., channel]
|
||||
pool = values[selected] if selected is not None else values.reshape(-1)
|
||||
source_values, source_counts = torch.unique(pool, sorted=True, return_counts=True)
|
||||
ref_values, ref_counts = torch.unique(ref[..., channel], sorted=True, return_counts=True)
|
||||
source_quantiles = source_counts.cumsum(0).float() / pool.numel()
|
||||
ref_quantiles = ref_counts.cumsum(0).float() / ref[..., channel].numel()
|
||||
# Equal values share their cumulative probability, independent
|
||||
# of how many pixels were used to represent the distribution.
|
||||
mapping = _interpolate(source_quantiles, ref_quantiles, ref_values)
|
||||
mapped = _interpolate(values.reshape(-1), source_values, mapping)
|
||||
sorted_pool, _ = pool.reshape(-1).sort()
|
||||
sorted_ref, _ = ref[..., channel].reshape(-1).sort()
|
||||
# Rank every pixel against the measured pool, then read the same
|
||||
# quantile off the reference.
|
||||
ranks = torch.searchsorted(sorted_pool, values.reshape(-1).contiguous())
|
||||
ranks = ranks.clamp_max(sorted_pool.numel() - 1)
|
||||
quantiles = ranks.float() / max(1, sorted_pool.numel() - 1)
|
||||
positions = quantiles * (sorted_ref.numel() - 1)
|
||||
low = positions.floor().long()
|
||||
high = positions.ceil().long()
|
||||
fraction = positions - low.float()
|
||||
mapped = sorted_ref[low] * (1.0 - fraction) + sorted_ref[high] * fraction
|
||||
channels.append(mapped.reshape(values.shape))
|
||||
frames.append(torch.stack(channels, dim=-1))
|
||||
return torch.stack(frames, dim=0).clamp(0.0, 1.0)
|
||||
|
||||
@@ -36,7 +36,7 @@ import torch
|
||||
from PIL import Image
|
||||
|
||||
from ._brush_helpers import brush_core, brush_sigma, stroke_cover # noqa: F401 - the brush's numbers are read from here too
|
||||
from ._image_save_helpers import IMAGE_SAVE_LOCK, next_free_counter, require_output_path, strip_image_extension
|
||||
from ._image_save_helpers import next_free_counter, require_output_path, strip_image_extension
|
||||
from ._psd_helpers import PSD_MAX_SIDE, write_psd
|
||||
from ._realign_helpers import crop_if_shift, measure, warp_to_source
|
||||
|
||||
@@ -72,6 +72,7 @@ ALIGN_FAILED = (
|
||||
"or have too little detail to measure."
|
||||
)
|
||||
|
||||
_save_lock = threading.Lock()
|
||||
_pairs: OrderedDict[str, tuple[Path, Path, str]] = OrderedDict()
|
||||
_pairs_lock = threading.Lock()
|
||||
|
||||
@@ -360,7 +361,7 @@ def save_mix(*, picture_a, picture_b, output_dir, top="A", strokes=None, layers=
|
||||
mixed = mix_pictures(upper, under, gone)
|
||||
|
||||
folder = Path(output_dir)
|
||||
with IMAGE_SAVE_LOCK:
|
||||
with _save_lock:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
counter = next_free_counter((entry.name for entry in folder.iterdir()), name)
|
||||
while True:
|
||||
|
||||
@@ -10,7 +10,6 @@ as a path below that root, and anything that leaves it is refused.
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import heapq
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -126,44 +125,45 @@ def list_folders(root, folder, source: str = "input") -> list[str]:
|
||||
def list_pictures(root, folder, subfolders: bool = False, sort: str = "name", source: str = "input",
|
||||
limit: int = MAX_PICTURES) -> list[dict]:
|
||||
"""Every picture in the folder: {"name": path below the folder, "path",
|
||||
"mtime", "mtime_ns", "size"}, in the chosen order. Hidden files, links
|
||||
that lead out of the root and anything that is not a picture are left out."""
|
||||
"mtime", "size"}, in the chosen order. Hidden files, links that lead out
|
||||
of the root and anything that is not a picture are left out."""
|
||||
base = safe_folder(root, folder, source)
|
||||
top = Path(root).resolve()
|
||||
found: list[dict] = []
|
||||
if subfolders:
|
||||
walker = os.walk(base, followlinks=False)
|
||||
else:
|
||||
walker = [(str(base), [], sorted(os.listdir(base)))]
|
||||
|
||||
def candidates():
|
||||
for directory, folders, files in walker:
|
||||
folders[:] = sorted(name for name in folders if not name.startswith("."))
|
||||
for name in files:
|
||||
if name.startswith(".") or not name.lower().endswith(IMAGE_EXTENSIONS):
|
||||
for directory, folders, files in walker:
|
||||
folders[:] = sorted(name for name in folders if not name.startswith("."))
|
||||
for name in files:
|
||||
if name.startswith(".") or not name.lower().endswith(IMAGE_EXTENSIONS):
|
||||
continue
|
||||
path = Path(directory) / name
|
||||
try:
|
||||
real = path.resolve()
|
||||
if top not in real.parents or not real.is_file():
|
||||
continue
|
||||
path = Path(directory) / name
|
||||
try:
|
||||
real = path.resolve()
|
||||
if top not in real.parents or not real.is_file():
|
||||
continue
|
||||
stat = real.stat()
|
||||
except (OSError, RuntimeError):
|
||||
continue
|
||||
yield {
|
||||
"name": path.relative_to(base).as_posix(),
|
||||
"path": real,
|
||||
"mtime": float(stat.st_mtime),
|
||||
"mtime_ns": int(stat.st_mtime_ns),
|
||||
"size": int(stat.st_size),
|
||||
}
|
||||
|
||||
stat = real.stat()
|
||||
except OSError:
|
||||
continue
|
||||
found.append({
|
||||
"name": path.relative_to(base).as_posix(),
|
||||
"path": real,
|
||||
"mtime": float(stat.st_mtime),
|
||||
"size": int(stat.st_size),
|
||||
})
|
||||
if len(found) >= limit:
|
||||
break
|
||||
if len(found) >= limit:
|
||||
break
|
||||
if sort == "newest first":
|
||||
order = lambda item: (-item["mtime_ns"], natural_key(item["name"]))
|
||||
found.sort(key=lambda item: (-item["mtime"], natural_key(item["name"])))
|
||||
elif sort == "oldest first":
|
||||
order = lambda item: (item["mtime_ns"], natural_key(item["name"]))
|
||||
found.sort(key=lambda item: (item["mtime"], natural_key(item["name"])))
|
||||
else:
|
||||
order = lambda item: natural_key(item["name"])
|
||||
return heapq.nsmallest(limit, candidates(), key=order)
|
||||
found.sort(key=lambda item: natural_key(item["name"]))
|
||||
return found
|
||||
|
||||
|
||||
def parse_picked(text) -> list[str] | None:
|
||||
@@ -216,7 +216,7 @@ def output_name(name: str) -> str:
|
||||
|
||||
|
||||
def _has_see_through(frame: Image.Image) -> bool:
|
||||
return frame.mode in ("RGBA", "LA", "PA") or "transparency" in frame.info
|
||||
return frame.mode in ("RGBA", "LA", "PA") or (frame.mode == "P" and "transparency" in frame.info)
|
||||
|
||||
|
||||
def load_picture(path) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
|
||||
@@ -10,7 +10,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
|
||||
@@ -33,8 +32,6 @@ FORMAT_EXTENSIONS = {"png": "png", "webp lossless": "webp", "jxl lossless": "jxl
|
||||
|
||||
COUNTER_WIDTH = 5
|
||||
BATCH_WIDTH = 3
|
||||
# Save Image and Compare choose counters and write under this same lock.
|
||||
IMAGE_SAVE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def metadata_disabled() -> bool:
|
||||
@@ -265,9 +262,7 @@ def sidecar_path(image_path: Path) -> Path:
|
||||
|
||||
|
||||
def frame_to_pil(frame: torch.Tensor) -> Image.Image:
|
||||
array = np.clip(frame.detach().cpu().float().numpy() * 255.0, 0, 255).astype(np.uint8)
|
||||
if array.ndim == 3 and array.shape[-1] == 1:
|
||||
array = array[..., 0]
|
||||
array = np.clip(frame.detach().cpu().numpy() * 255.0, 0, 255).astype(np.uint8)
|
||||
return Image.fromarray(array)
|
||||
|
||||
|
||||
@@ -338,7 +333,6 @@ __all__ = [
|
||||
"FORMAT_EXTENSIONS",
|
||||
"IMAGE_EXTENSIONS",
|
||||
"IMAGE_FORMATS",
|
||||
"IMAGE_SAVE_LOCK",
|
||||
"OUTPUT_DIR_RULE",
|
||||
"counter_pattern",
|
||||
"encode_image",
|
||||
|
||||
@@ -434,15 +434,6 @@ def build_crop(
|
||||
blur_sigma = max(0.0, float(mask_blur))
|
||||
if blur_sigma > 0.0:
|
||||
mask = blur_mask(mask, blur_sigma).clamp(0.0, 1.0)
|
||||
# New space must stay fully paintable, even after shrinking or softening.
|
||||
if ext_l:
|
||||
mask[:, :, :ext_l] = 1.0
|
||||
if ext_r:
|
||||
mask[:, :, -ext_r:] = 1.0
|
||||
if ext_u:
|
||||
mask[:, :ext_u, :] = 1.0
|
||||
if ext_d:
|
||||
mask[:, -ext_d:, :] = 1.0
|
||||
height, width = image.shape[1], image.shape[2]
|
||||
multiple = max(1, int(output_multiple))
|
||||
blend_px = max(0, int(blend_pixels))
|
||||
@@ -458,7 +449,6 @@ def build_crop(
|
||||
)
|
||||
|
||||
bbox = mask_bbox(mask)
|
||||
keep_box = bbox
|
||||
if bbox is None:
|
||||
rect = (0, 0, width, height)
|
||||
else:
|
||||
@@ -470,17 +460,6 @@ def build_crop(
|
||||
rect[2] + 2 * context_px,
|
||||
rect[3] + 2 * context_px,
|
||||
)
|
||||
if blend_px:
|
||||
# Feathering dilates by blend_px, then blurs over another
|
||||
# ceil(3 * sigma) pixels. Keep all of that support in the crop.
|
||||
reach = blend_px + math.ceil(3.0 * (blend_px / 3.0))
|
||||
bx, by, bw, bh = bbox
|
||||
x0, y0 = max(0, bx - reach), max(0, by - reach)
|
||||
x1, y1 = min(width, bx + bw + reach), min(height, by + bh + reach)
|
||||
keep_box = (x0, y0, x1 - x0, y1 - y0)
|
||||
x0, y0 = min(rect[0], x0), min(rect[1], y0)
|
||||
x1, y1 = max(rect[0] + rect[2], x1), max(rect[1] + rect[3], y1)
|
||||
rect = (x0, y0, x1 - x0, y1 - y0)
|
||||
if keep_inside:
|
||||
rect = clamp_rect_to_bounds(rect, width, height)
|
||||
rect = fit_rect(rect, width, height)
|
||||
@@ -501,7 +480,7 @@ def build_crop(
|
||||
target_h = max(1, round(rect[3] * target_w / rect[2]))
|
||||
target_w = round_up_to_multiple(target_w, multiple)
|
||||
target_h = round_up_to_multiple(target_h, multiple)
|
||||
keep_box = keep_box if keep_box is not None else (0, 0, width, height)
|
||||
keep_box = bbox if bbox is not None else (0, 0, width, height)
|
||||
rect = match_rect_shape(rect, target_w, target_h, width, height, keep_inside, keep_box)
|
||||
else:
|
||||
# Native sizing: the crop itself must satisfy the sampler multiple.
|
||||
@@ -1077,11 +1056,7 @@ def stitch_blend_mask(stitcher: dict, frames: int = 1, seam: str = SEAM_CLASSIC)
|
||||
# One depth map serves every frame, so the weight broadcasts.
|
||||
weight = blend_in_weight(plan["depth"], plan["tone"])[:, oy : oy + oh, ox : ox + ow]
|
||||
return weight.expand(frames, -1, -1).contiguous()
|
||||
# Older stitchers may have a feather wider than the actual paste window.
|
||||
cx, cy, cw, ch = stitcher["crop_to_canvas"]
|
||||
mask = torch.zeros_like(blend)
|
||||
mask[:, cy : cy + ch, cx : cx + cw] = blend[:, cy : cy + ch, cx : cx + cw]
|
||||
mask = mask[:, oy : oy + oh, ox : ox + ow]
|
||||
mask = blend[:, oy : oy + oh, ox : ox + ow]
|
||||
if mask.shape[0] == 1 and frames > 1:
|
||||
mask = mask.expand(frames, -1, -1)
|
||||
return mask.contiguous()
|
||||
|
||||
+5
-28
@@ -14,8 +14,6 @@ tested against.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from einops import rearrange
|
||||
@@ -93,13 +91,6 @@ def _attn_kv(
|
||||
k = rearrange(k, "B L (H D) -> B H L D", H=attn.kvheads)
|
||||
v = rearrange(v, "B L (H D) -> B H L D", H=attn.kvheads)
|
||||
q, k = attn.qknorm(q, k)
|
||||
patches = transformer_options.get("patches", {})
|
||||
extra_options = transformer_options.copy()
|
||||
if "block_index" in transformer_options:
|
||||
for callback in patches.get("attn1_patch", ()):
|
||||
changes = callback(q, k, v, pe=freqs, attn_mask=mask, extra_options=extra_options)
|
||||
q, k, v = changes.get("q", q), changes.get("k", k), changes.get("v", v)
|
||||
freqs, mask = changes.get("pe", freqs), changes.get("attn_mask", mask)
|
||||
if freqs is not None:
|
||||
q, k = apply_rope(q, k, freqs)
|
||||
if kv_capture is not None:
|
||||
@@ -120,9 +111,6 @@ def _attn_kv(
|
||||
skip_reshape=True,
|
||||
transformer_options=transformer_options,
|
||||
)
|
||||
if "block_index" in transformer_options:
|
||||
for callback in patches.get("attn1_output_patch", ()):
|
||||
out = callback(out, extra_options)
|
||||
return attn.wo(out * F.sigmoid(gate))
|
||||
|
||||
|
||||
@@ -165,10 +153,7 @@ def _precompute_ref_kv(
|
||||
freqs = dit.pe_embedder(refpos)
|
||||
|
||||
ref_kv = []
|
||||
transformer_options = {**transformer_options, "total_blocks": len(dit.blocks),
|
||||
"block_type": "single", "img_slice": [0, h.shape[1]]}
|
||||
for index, block in enumerate(dit.blocks):
|
||||
transformer_options["block_index"] = index
|
||||
for block in dit.blocks:
|
||||
cap = []
|
||||
h = _block_kv_forward(
|
||||
block,
|
||||
@@ -219,10 +204,7 @@ def _forward_with_cached_refs(
|
||||
|
||||
freqs = dit.pe_embedder(pos)
|
||||
|
||||
transformer_options = {**transformer_options, "total_blocks": len(dit.blocks),
|
||||
"block_type": "single", "img_slice": [txtlen, combined.shape[1]]}
|
||||
for index, (block, kv) in enumerate(zip(dit.blocks, ref_kv)):
|
||||
transformer_options["block_index"] = index
|
||||
for block, kv in zip(dit.blocks, ref_kv):
|
||||
combined = _block_kv_forward(
|
||||
block,
|
||||
combined,
|
||||
@@ -250,13 +232,8 @@ def _forward_with_cached_refs(
|
||||
|
||||
|
||||
def _ref_fingerprint(ref_latents, bs: int, bbox_norm: list[float] | None):
|
||||
key = [bs, tuple(bbox_norm) if bbox_norm is not None else None]
|
||||
key = [bs, tuple(round(v, 6) for v in bbox_norm) if bbox_norm is not None else None]
|
||||
for r in ref_latents:
|
||||
try:
|
||||
version = r._version
|
||||
except RuntimeError:
|
||||
# Inference tensors have no mutation counter. Hash their bytes
|
||||
# rather than treating equal summary statistics as equal pixels.
|
||||
version = hashlib.sha256(r.detach().contiguous().view(torch.uint8).cpu().numpy().tobytes()).digest()
|
||||
key.append((id(r), version, tuple(r.shape), tuple(r.stride()), r.dtype, r.device))
|
||||
rf = r.float()
|
||||
key.append((tuple(r.shape), float(rf.sum()), float(rf.square().sum())))
|
||||
return tuple(key)
|
||||
|
||||
@@ -123,15 +123,6 @@ OTHER_PICTURE = (
|
||||
"coming into this node has changed since, so none of them are used. "
|
||||
"Click a thing on the node's picture, or paint over it, then run again."
|
||||
)
|
||||
MOVED_NOTHING = (
|
||||
"Mask by Touch: the picks made on the last picture found nothing on "
|
||||
"this one. Click a thing on the node's picture, or paint over it, then "
|
||||
"run again."
|
||||
)
|
||||
# What happens to the picks when another picture comes in: they are used on
|
||||
# it, in the same places, or the run stops until something is picked anew.
|
||||
NEW_PICTURE = ("keep picks", "start over")
|
||||
KEEP_PICKS, START_OVER = NEW_PICTURE
|
||||
NOT_TOUCHES = (
|
||||
"Mask by Touch could not read what was picked. Press Clear on the node "
|
||||
"and pick again."
|
||||
@@ -192,53 +183,6 @@ def touch_stamp(text) -> str:
|
||||
return f"{value:08x}"
|
||||
|
||||
|
||||
# A picture key starts with the picture's size: "<width>x<height>-".
|
||||
_KEY_SIZE = re.compile(r"(\d{1,9})x(\d{1,9})-")
|
||||
|
||||
|
||||
def _number(value) -> bool:
|
||||
return not isinstance(value, bool) and isinstance(value, (int, float)) and math.isfinite(value)
|
||||
|
||||
|
||||
def move_touches(text, key: str) -> str:
|
||||
"""Touches made on another picture, moved onto the picture `key` names.
|
||||
|
||||
Every touch keeps its place: the same pixels on a picture of the same
|
||||
size, the same spot scaled on one of another size, a brush's width
|
||||
scaled with it. Everything else in a touch (its word, its chip, how soft
|
||||
it was) stays as written. The face writes this text back as it is, so
|
||||
the node and the face go on from the same touches. Whole pixels round
|
||||
half up, as the face rounds.
|
||||
"""
|
||||
data = json.loads(str(text))
|
||||
old = _KEY_SIZE.match(str(data.get("key") or ""))
|
||||
new = _KEY_SIZE.match(str(key))
|
||||
across, down = 1.0, 1.0
|
||||
if old and new and int(old.group(1)) > 0 and int(old.group(2)) > 0:
|
||||
across = int(new.group(1)) / int(old.group(1))
|
||||
down = int(new.group(2)) / int(old.group(2))
|
||||
if (across, down) != (1.0, 1.0):
|
||||
def at(x, y):
|
||||
return math.floor(x * across + 0.5), math.floor(y * down + 0.5)
|
||||
|
||||
for entry in data.get("items", []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if _number(entry.get("x")) and _number(entry.get("y")):
|
||||
entry["x"], entry["y"] = at(entry["x"], entry["y"])
|
||||
if _number(entry.get("r")):
|
||||
entry["r"] = max(1, math.floor(entry["r"] * math.sqrt(across * down) + 0.5))
|
||||
if isinstance(entry.get("pts"), list):
|
||||
entry["pts"] = [
|
||||
[*at(point[0], point[1]), *point[2:]]
|
||||
if isinstance(point, list) and len(point) >= 2 and _number(point[0]) and _number(point[1])
|
||||
else point
|
||||
for point in entry["pts"]
|
||||
]
|
||||
data["key"] = str(key)
|
||||
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _whole(value) -> int:
|
||||
"""A coordinate as a whole pixel. Text, a missing value or a number that
|
||||
is not finite is an error."""
|
||||
@@ -1190,13 +1134,10 @@ __all__ = [
|
||||
"HOLE_SHARE",
|
||||
"ITEMS_MAX",
|
||||
"Item",
|
||||
"KEEP_PICKS",
|
||||
"MATCHES_MAX",
|
||||
"Memory",
|
||||
"MOVED_NOTHING",
|
||||
"NEEDS_SAM3",
|
||||
"NEWER_TOUCHES",
|
||||
"NEW_PICTURE",
|
||||
"NOTHING_PICKED",
|
||||
"NOT_A_PICTURE",
|
||||
"NOT_SAM3",
|
||||
@@ -1208,7 +1149,6 @@ __all__ = [
|
||||
"POINTS_MAX",
|
||||
"PieceSieve",
|
||||
"SMALL_SIDE",
|
||||
"START_OVER",
|
||||
"TEXT_MAX",
|
||||
"TOO_MUCH",
|
||||
"Touches",
|
||||
@@ -1228,7 +1168,6 @@ __all__ = [
|
||||
"keep_touched",
|
||||
"match_points",
|
||||
"match_under",
|
||||
"move_touches",
|
||||
"pack_planes",
|
||||
"parse_touches",
|
||||
"picture_key",
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Camera Move 🆎: move the camera of a clip, steered with keys on the node."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ._camera_move_helpers import (
|
||||
camera_at,
|
||||
default_camera_text,
|
||||
frame_moments,
|
||||
pack_depth,
|
||||
parse_camera,
|
||||
prepare_geometry,
|
||||
range_report,
|
||||
resolve_pivot,
|
||||
auto_pivot,
|
||||
scene_frame_indices,
|
||||
scene_size,
|
||||
warp_frame,
|
||||
)
|
||||
|
||||
UI_KEY = "ausboss_camera_scene"
|
||||
|
||||
|
||||
def _device():
|
||||
try:
|
||||
import comfy.model_management as mm
|
||||
|
||||
return mm.get_torch_device()
|
||||
except Exception:
|
||||
import torch
|
||||
|
||||
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
|
||||
|
||||
class AusBossCameraMove:
|
||||
CATEGORY = "🆎 AusBoss/Video"
|
||||
DESCRIPTION = (
|
||||
"Moves the camera of your clip. Steer it on the node: drag the picture "
|
||||
"to turn, pinch or scroll to pull back, and set keys on the time bar. "
|
||||
"Places the old camera never saw come out pink, ready for the "
|
||||
"CrossView-Warp LoRA to paint in. Needs depth from MoGe Inference."
|
||||
)
|
||||
SEARCH_ALIASES = [
|
||||
"camera move",
|
||||
"move camera",
|
||||
"orbit camera",
|
||||
"camera orbit video",
|
||||
"crossview warp",
|
||||
"novel view",
|
||||
"dolly",
|
||||
"camera keyframes",
|
||||
"ausboss",
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"moge_geometry": (
|
||||
"MOGE_GEOMETRY",
|
||||
{"tooltip": "Depth of your clip from MoGe Inference. Every frame needs it."},
|
||||
),
|
||||
"camera": (
|
||||
"STRING",
|
||||
{
|
||||
"default": default_camera_text(),
|
||||
"multiline": False,
|
||||
"tooltip": "The camera keys. Set them on the node's picture; you never type here.",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("guide", "holes")
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"Your clip seen from the moved camera. Pink is what the old camera never saw.",
|
||||
"White where the guide is pink: the parts to paint in.",
|
||||
)
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "run"
|
||||
|
||||
def run(self, moge_geometry, camera):
|
||||
import torch
|
||||
|
||||
parsed = parse_camera(camera)
|
||||
if parsed["fallback"]:
|
||||
print("[AusBoss] Camera Move: could not read the camera keys, using the default move.")
|
||||
report = range_report(parsed["keys"])
|
||||
if report:
|
||||
print("[AusBoss] " + report)
|
||||
device = _device()
|
||||
scene = prepare_geometry(moge_geometry, device)
|
||||
pivot = resolve_pivot(scene, parsed["pivot"])
|
||||
try:
|
||||
import comfy.utils
|
||||
|
||||
bar = comfy.utils.ProgressBar(scene["count"])
|
||||
except Exception:
|
||||
bar = None
|
||||
frames, holes = [], []
|
||||
with torch.no_grad():
|
||||
for i, t in enumerate(frame_moments(scene["count"])):
|
||||
frame, hole = warp_frame(scene, i, camera_at(parsed["keys"], t), pivot, device)
|
||||
frames.append(frame)
|
||||
holes.append(hole)
|
||||
if bar is not None:
|
||||
bar.update(1)
|
||||
guide = torch.stack(frames)
|
||||
mask = torch.stack(holes).float()
|
||||
info = self._scene(scene, pivot)
|
||||
info["pivot_from"] = parsed["pivot"]
|
||||
return {"ui": {UI_KEY: [info]}, "result": (guide, mask)}
|
||||
|
||||
# -- what the node's face looks at ----------------------------------------
|
||||
|
||||
def _scene(self, scene, pivot):
|
||||
"""A few frames, small, with their depth, written to ComfyUI's temp folder."""
|
||||
import folder_paths
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
W, H, count = scene["width"], scene["height"], scene["count"]
|
||||
w, h = scene_size(W, H)
|
||||
ys = np.clip(((np.arange(h) + 0.5) * H / h).astype(int), 0, H - 1)
|
||||
xs = np.clip(((np.arange(w) + 0.5) * W / w).astype(int), 0, W - 1)
|
||||
picks = scene_frame_indices(count)
|
||||
small = []
|
||||
for i in picks:
|
||||
z = scene["z"][i].cpu().numpy()[ys][:, xs]
|
||||
valid = scene["valid"][i].cpu().numpy()[ys][:, xs]
|
||||
small.append((i, z, valid))
|
||||
every = np.concatenate([z[v] for _, z, v in small if v.any()]) if any(v.any() for _, _, v in small) else np.array([1.0])
|
||||
zmin, zmax = float(every.min()), float(max(every.max(), every.min() * 1.001))
|
||||
folder, name, counter, subfolder, _ = folder_paths.get_save_image_path(
|
||||
"ausboss_camera_move", folder_paths.get_temp_directory(), w, h
|
||||
)
|
||||
frames = []
|
||||
for j, (i, z, valid) in enumerate(small):
|
||||
colour = (scene["image"][i].cpu().numpy().clip(0, 1) * 255).astype(np.uint8)
|
||||
stem = f"{name}_{counter:05}_{j:02}"
|
||||
Image.fromarray(colour).resize((w, h), Image.LANCZOS).save(Path(folder) / f"{stem}_c.jpg", quality=88)
|
||||
Image.fromarray(pack_depth(z, valid, zmin, zmax)).save(Path(folder) / f"{stem}_d.png", compress_level=1)
|
||||
K = scene["K"][i]
|
||||
frames.append({
|
||||
"i": i,
|
||||
"t": i / (count - 1) if count > 1 else 0.0,
|
||||
"color": {"filename": f"{stem}_c.jpg", "subfolder": subfolder, "type": "temp"},
|
||||
"depth": {"filename": f"{stem}_d.png", "subfolder": subfolder, "type": "temp"},
|
||||
"fx": float(K[0, 0]) * w / W,
|
||||
"fy": float(K[1, 1]) * h / H,
|
||||
"cx": float(K[0, 2]) * w / W,
|
||||
"cy": float(K[1, 2]) * h / H,
|
||||
})
|
||||
return {
|
||||
"w": w,
|
||||
"h": h,
|
||||
"count": count,
|
||||
"zmin": zmin,
|
||||
"zmax": zmax,
|
||||
"far": scene["far"],
|
||||
"pivot": [float(x) for x in pivot],
|
||||
"pivot_auto": [float(x) for x in auto_pivot(scene)],
|
||||
"frames": frames,
|
||||
}
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_CameraMove": AusBossCameraMove}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_CameraMove": "Camera Move 🆎"}
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from ._execution_helpers import ignore_extra_inputs
|
||||
from ._route_errors import safe_route_error
|
||||
from ._image_folder_helpers import (
|
||||
AFTER_RUN,
|
||||
AT_THE_END,
|
||||
@@ -87,7 +86,7 @@ def _register_routes() -> None:
|
||||
return str(request.rel_url.query.get(name, "")).strip()
|
||||
|
||||
def refused(error: Exception):
|
||||
return web.json_response({"error": safe_route_error(error, "This folder cannot be read.")}, status=400)
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
|
||||
@routes.get("/ausboss/image_folder/folders")
|
||||
async def folders(request):
|
||||
@@ -96,7 +95,7 @@ def _register_routes() -> None:
|
||||
source, folder = _settle(source, query(request, "folder"))
|
||||
shown = clean_folder(folder)
|
||||
names = list_folders(_root(source), shown, source)
|
||||
except (ValueError, RuntimeError, OSError) as error:
|
||||
except (ValueError, RuntimeError) as error:
|
||||
return refused(error)
|
||||
return web.json_response({"source": source, "folder": shown, "folders": names})
|
||||
|
||||
@@ -111,13 +110,13 @@ def _register_routes() -> None:
|
||||
listed = list_pictures(
|
||||
_root(source), shown, query(request, "subfolders") == "1", query(request, "sort") or "name", source
|
||||
)
|
||||
except (ValueError, RuntimeError, OSError) as error:
|
||||
except (ValueError, RuntimeError) as error:
|
||||
return refused(error)
|
||||
return web.json_response({
|
||||
"source": source,
|
||||
"folder": shown,
|
||||
"full": len(listed) >= MAX_PICTURES,
|
||||
"pictures": [{"name": item["name"], "v": f"{item['mtime_ns']}-{item['size']}"} for item in listed],
|
||||
"pictures": [{"name": item["name"], "v": int(item["mtime"])} for item in listed],
|
||||
})
|
||||
|
||||
@routes.get("/ausboss/image_folder/thumb")
|
||||
@@ -274,7 +273,6 @@ class AusBossImageFolder:
|
||||
|
||||
def load(self, source, folder, subfolders, sort, pictures, run, position, after_run, at_the_end):
|
||||
# after_run is the editor's: it moves Picture on after a run is queued. A run loads what Picture says.
|
||||
source, folder = _settle(source, folder)
|
||||
chosen = _picked(source, folder, subfolders, sort, pictures)
|
||||
count = len(chosen)
|
||||
images, masks, names, numbers = [], [], [], []
|
||||
@@ -285,10 +283,7 @@ class AusBossImageFolder:
|
||||
masks.append(mask)
|
||||
names.append(output_name(item["name"]))
|
||||
numbers.append(place + 1)
|
||||
note = [{
|
||||
"count": count, "loaded": numbers, "names": [chosen[n - 1]["name"] for n in numbers],
|
||||
"context": {"source": source, "folder": clean_folder(folder), "subfolders": bool(subfolders)},
|
||||
}]
|
||||
note = [{"count": count, "loaded": numbers[:64], "names": [chosen[n - 1]["name"] for n in numbers[:64]]}]
|
||||
return {"ui": {UI_KEY: note}, "result": (images, masks, names, numbers, count)}
|
||||
|
||||
|
||||
|
||||
@@ -93,7 +93,6 @@ class AusBossKrea2OutpaintModelPatch:
|
||||
|
||||
def patch(self, model, stitcher, kv_cache=True, placement="source rectangle"):
|
||||
import comfy.conds
|
||||
from comfy.patcher_extension import CallbacksMP
|
||||
|
||||
from . import _krea2_core as core
|
||||
|
||||
@@ -133,13 +132,6 @@ class AusBossKrea2OutpaintModelPatch:
|
||||
|
||||
state = {"last_sigma": None, "caches": {}}
|
||||
|
||||
def clear_cache(*_):
|
||||
state["caches"].clear()
|
||||
state["last_sigma"] = None
|
||||
|
||||
patched.add_callback(CallbacksMP.ON_PRE_RUN, clear_cache)
|
||||
patched.add_callback(CallbacksMP.ON_CLEANUP, clear_cache)
|
||||
|
||||
def forward(
|
||||
x,
|
||||
timesteps,
|
||||
@@ -179,11 +171,8 @@ class AusBossKrea2OutpaintModelPatch:
|
||||
ref_kv = None
|
||||
key = None
|
||||
if kv_cache:
|
||||
key = (tuple(x.shape), x.dtype, x.device,
|
||||
core._ref_fingerprint(ref_latents, batch, bbox_norm))
|
||||
cached = state["caches"].get(key)
|
||||
if cached is not None:
|
||||
ref_kv = cached[1]
|
||||
key = core._ref_fingerprint(ref_latents, batch, bbox_norm)
|
||||
ref_kv = state["caches"].get(key)
|
||||
if ref_kv is None:
|
||||
ref_kv = core._precompute_ref_kv(
|
||||
dit,
|
||||
@@ -194,9 +183,7 @@ class AusBossKrea2OutpaintModelPatch:
|
||||
bbox_norm=bbox_norm,
|
||||
)
|
||||
if kv_cache:
|
||||
# Keep the references alive while their identities are
|
||||
# keys, so a later tensor cannot reuse a released id.
|
||||
state["caches"][key] = (tuple(ref_latents), ref_kv)
|
||||
state["caches"][key] = ref_kv
|
||||
return core._forward_with_cached_refs(
|
||||
dit, x, timesteps, context, ref_kv, transformer_options
|
||||
)
|
||||
|
||||
@@ -28,16 +28,12 @@ from ._fine_edge_helpers import (
|
||||
)
|
||||
from ._mask_by_name_helpers import IF_NOTHING, cut_out
|
||||
from ._mask_by_touch_helpers import (
|
||||
KEEP_PICKS,
|
||||
MOVED_NOTHING,
|
||||
NEEDS_SAM3,
|
||||
NEW_PICTURE,
|
||||
NOTHING_PICKED,
|
||||
NOT_A_PICTURE,
|
||||
NOT_SAM3,
|
||||
NO_TEXT_READER,
|
||||
OTHER_PICTURE,
|
||||
START_OVER,
|
||||
WORDS_FIRST,
|
||||
WORDS_MORE,
|
||||
Memory,
|
||||
@@ -51,7 +47,6 @@ from ._mask_by_touch_helpers import (
|
||||
finish_mask,
|
||||
match_points,
|
||||
match_under,
|
||||
move_touches,
|
||||
pack_planes,
|
||||
parse_touches,
|
||||
picture_key,
|
||||
@@ -791,18 +786,6 @@ class AusBossMaskByTouch:
|
||||
),
|
||||
},
|
||||
),
|
||||
"new_picture": (
|
||||
list(NEW_PICTURE),
|
||||
{
|
||||
"default": KEEP_PICKS,
|
||||
"tooltip": (
|
||||
"When a different picture comes in. Keep picks uses your "
|
||||
"clicks and paint on it, in the same places (scaled if the "
|
||||
"size changed). Start over drops them, and the run stops "
|
||||
"until you pick again."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
# ComfyUI hands the node its own id here, so a look can tell this
|
||||
# node's face what it has found. Not a widget and not a socket.
|
||||
@@ -1122,10 +1105,10 @@ class AusBossMaskByTouch:
|
||||
entry = self._keep(("picture", key), self._write(picture, "picture", "jpg", format="JPEG", quality=90))
|
||||
return entry
|
||||
|
||||
def _show_picture(self, session, image, key: str, unique_id, moved=None) -> bool:
|
||||
def _show_picture(self, session, image, key: str, unique_id) -> bool:
|
||||
"""Send the face the picture that came in, with nothing picked on it,
|
||||
for a run that stops at this node, and the picks moved onto it if
|
||||
they were. A failure never changes why the run stops."""
|
||||
for a run that stops at this node. A failure never changes why the
|
||||
run stops."""
|
||||
if unique_id is None or _server() is None:
|
||||
return False
|
||||
try:
|
||||
@@ -1140,8 +1123,6 @@ class AusBossMaskByTouch:
|
||||
"pieces": self._pieces_note(_MEMORY.get_pieces((session.token, session.reader, key))),
|
||||
"fine": None, "areas": None,
|
||||
}
|
||||
if moved:
|
||||
note["moved"] = moved
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
return _tell(unique_id, note, True, stopped=True)
|
||||
@@ -1185,13 +1166,12 @@ class AusBossMaskByTouch:
|
||||
|
||||
def pick(
|
||||
self, image, model, clip, touches, grow, soften, smooth, fill_holes, if_nothing,
|
||||
free_vram=False, invert=False, ask="", fine_edges=FINE_OFF, new_picture=KEEP_PICKS, unique_id=None,
|
||||
free_vram=False, invert=False, ask="", fine_edges=FINE_OFF, unique_id=None,
|
||||
):
|
||||
session = _Session(model, clip)
|
||||
try:
|
||||
return self._run(
|
||||
session, image, touches, grow, soften, smooth, fill_holes, if_nothing, invert, ask, unique_id, fine_edges,
|
||||
new_picture,
|
||||
session, image, touches, grow, soften, smooth, fill_holes, if_nothing, invert, ask, unique_id, fine_edges
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
@@ -1205,7 +1185,7 @@ class AusBossMaskByTouch:
|
||||
|
||||
def _run(
|
||||
self, session, image, touches, grow, soften, smooth, fill_holes, if_nothing, invert, ask, unique_id,
|
||||
fine_edges=FINE_OFF, new_picture=KEEP_PICKS,
|
||||
fine_edges=FINE_OFF,
|
||||
):
|
||||
if not isinstance(image, torch.Tensor) or image.ndim != 4 or 0 in image.shape:
|
||||
raise ValueError(NOT_A_PICTURE)
|
||||
@@ -1215,15 +1195,6 @@ class AusBossMaskByTouch:
|
||||
picked = parse_touches(touches)
|
||||
# Touches made on another picture are not this picture's.
|
||||
elsewhere = picked.key not in (None, key) and bool(picked.items)
|
||||
moved = None
|
||||
if elsewhere and str(new_picture) != START_OVER:
|
||||
# Unless the node starts over, they are moved onto this one: the
|
||||
# same places, scaled to its size. The face is sent the moved
|
||||
# touches, and the stamp of the ones they were moved from.
|
||||
moved = {"from": touch_stamp(touches), "touches": move_touches(touches, key)}
|
||||
touches = moved["touches"]
|
||||
picked = parse_touches(touches)
|
||||
elsewhere = False
|
||||
items = () if elsewhere else picked.items
|
||||
strokes, selections, painted, item_notes = {}, [], [], []
|
||||
# Each picked thing's own area on the first picture, for the face.
|
||||
@@ -1293,10 +1264,10 @@ class AusBossMaskByTouch:
|
||||
# The run stops here, but the face is shown the picture that
|
||||
# came in first: a picture that changed upstream is there to
|
||||
# pick from at once, without unplugging it.
|
||||
self._show_picture(session, image, key, unique_id, moved)
|
||||
self._show_picture(session, image, key, unique_id)
|
||||
# Picks the face may still show, made before the picture
|
||||
# changed: say so, not that nothing is picked.
|
||||
raise RuntimeError(OTHER_PICTURE if elsewhere else MOVED_NOTHING if moved else NOTHING_PICKED)
|
||||
raise RuntimeError(OTHER_PICTURE if elsewhere else NOTHING_PICKED)
|
||||
# A look for pieces (scope) returns no mask anyone uses: no fine edge then.
|
||||
fine = wants_fine_edges(fine_edges) and asked != "scope" and bool(raw.any())
|
||||
some = raw.flatten(1).any(dim=1)[:, None, None]
|
||||
@@ -1362,7 +1333,7 @@ class AusBossMaskByTouch:
|
||||
areas_note = self._areas_note(key, stamp, things, small_width, small_height)
|
||||
|
||||
def note(pieces) -> dict:
|
||||
told = {
|
||||
return {
|
||||
"key": key,
|
||||
"width": width,
|
||||
"height": height,
|
||||
@@ -1376,9 +1347,6 @@ class AusBossMaskByTouch:
|
||||
"fine": fine_note,
|
||||
"areas": areas_note,
|
||||
}
|
||||
if moved:
|
||||
told["moved"] = moved
|
||||
return told
|
||||
|
||||
pieces_key = (session.token, session.reader, key)
|
||||
pieces = _MEMORY.get_pieces(pieces_key)
|
||||
|
||||
+45
-49
@@ -10,7 +10,6 @@ from ._image_save_helpers import (
|
||||
EXISTING_POLICIES,
|
||||
FORMAT_EXTENSIONS,
|
||||
IMAGE_FORMATS,
|
||||
IMAGE_SAVE_LOCK,
|
||||
encode_image,
|
||||
existing_action,
|
||||
fill_name_tokens,
|
||||
@@ -320,7 +319,8 @@ class AusBossSaveImage:
|
||||
embed = save_metadata and not metadata_disabled()
|
||||
metadata = workflow_metadata(prompt, extra_pnginfo) if embed else None
|
||||
# A linked caption wins over the legacy text box; blank means no sidecar.
|
||||
caption_value = str(caption_text) if caption_text is not None else str(caption or "")
|
||||
linked_caption = str(caption_text or "") if caption_text is not None else ""
|
||||
caption_value = linked_caption if linked_caption.strip() else str(caption or "")
|
||||
output_root = Path(folder_paths.get_output_directory())
|
||||
root = resolve_output_root(output_dir, output_root)
|
||||
count = int(images.shape[0])
|
||||
@@ -343,55 +343,51 @@ class AusBossSaveImage:
|
||||
else:
|
||||
exact = sanitize_exact_name(exact_name)
|
||||
|
||||
with IMAGE_SAVE_LOCK:
|
||||
planned: list[tuple[Path, str]] = [] # (path, collision policy)
|
||||
if exact:
|
||||
base = strip_image_extension(exact)
|
||||
for name in plan_exact_names(base, extension, count):
|
||||
planned.append((root / name, on_existing))
|
||||
else:
|
||||
subfolder, base = split_prefix(filename_prefix)
|
||||
folder = root / subfolder if subfolder else root
|
||||
require_output_path(folder, output_root)
|
||||
stem = name_stem(
|
||||
base,
|
||||
{"date": bool(name_date), "time": bool(name_time), "size": bool(name_size)},
|
||||
now,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
counter = bool(name_counter)
|
||||
existing = [entry.name for entry in folder.iterdir()] if folder.is_dir() else []
|
||||
names = plan_local_names(
|
||||
stem, extension, count, counter=counter, batch=bool(name_batch),
|
||||
next_counter=next_free_counter(existing, stem),
|
||||
)
|
||||
# The shared lock keeps this counter free until writing finishes.
|
||||
# Without it, the legacy policy decides whether to overwrite.
|
||||
policy = "overwrite" if counter else on_existing
|
||||
planned.extend((folder / name, policy) for name in names)
|
||||
planned: list[tuple[Path, str]] = [] # (path, collision policy)
|
||||
if exact:
|
||||
base = strip_image_extension(exact)
|
||||
for name in plan_exact_names(base, extension, count):
|
||||
planned.append((root / name, on_existing))
|
||||
else:
|
||||
subfolder, base = split_prefix(filename_prefix)
|
||||
folder = root / subfolder if subfolder else root
|
||||
require_output_path(folder, output_root)
|
||||
stem = name_stem(
|
||||
base,
|
||||
{"date": bool(name_date), "time": bool(name_time), "size": bool(name_size)},
|
||||
now,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
counter = bool(name_counter)
|
||||
existing = [entry.name for entry in folder.iterdir()] if folder.is_dir() else []
|
||||
names = plan_local_names(
|
||||
stem, extension, count, counter=counter, batch=bool(name_batch),
|
||||
next_counter=next_free_counter(existing, stem),
|
||||
)
|
||||
# The counter is collision-safe by construction; without it the
|
||||
# legacy policy decides, and its default deliberately overwrites.
|
||||
policy = "overwrite" if counter else on_existing
|
||||
planned.extend((folder / name, policy) for name in names)
|
||||
|
||||
# Validate the whole batch before any write, including caption targets,
|
||||
# then settle every collision: an "error" on a later frame must stop
|
||||
# the run before the first frame lands, not after.
|
||||
for path, _policy in planned:
|
||||
require_output_path(path, output_root)
|
||||
if caption_value.strip():
|
||||
require_output_path(sidecar_path(path), output_root)
|
||||
actions = [
|
||||
existing_action(path.exists() or (bool(caption_value.strip()) and sidecar_path(path).exists()), policy)
|
||||
for path, policy in planned
|
||||
]
|
||||
# Validate the whole batch before any write, including caption targets,
|
||||
# then settle every collision: an "error" on a later frame must stop
|
||||
# the run before the first frame lands, not after.
|
||||
for path, _policy in planned:
|
||||
require_output_path(path, output_root)
|
||||
if caption_value.strip():
|
||||
require_output_path(sidecar_path(path), output_root)
|
||||
actions = [existing_action(path.exists(), policy) for path, policy in planned]
|
||||
|
||||
saved: list[Path] = []
|
||||
for (path, _policy), action, frame in zip(planned, actions, images):
|
||||
if action == "skip":
|
||||
continue
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
encode_image(path, frame, format, metadata)
|
||||
if caption_value.strip():
|
||||
sidecar_path(path).write_text(caption_value, encoding="utf-8")
|
||||
saved.append(path)
|
||||
saved: list[Path] = []
|
||||
for (path, _policy), action, frame in zip(planned, actions, images):
|
||||
if action == "skip":
|
||||
continue
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
encode_image(path, frame, format, metadata)
|
||||
if caption_value.strip():
|
||||
sidecar_path(path).write_text(caption_value, encoding="utf-8")
|
||||
saved.append(path)
|
||||
|
||||
# Every destination was confined to the output folder above, so each
|
||||
# saved file previews from there and is shown relative to it. The
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ name = "ausboss-nodes"
|
||||
# Manager/registry search matches against this text, so it names the actual
|
||||
# nodes instead of describing the collection abstractly.
|
||||
description = "ComfyUI-AusBoss (AusBoss nodes): image and video nodes with compact controls: Image and Video Crop + Rotate + Pad (frame or clip), Load Image + Pad, Image Folder, Image Resize, Align Image, Image Size, Color Match, Image Compare A/B, Realign to Source (experimental), Save Image (PNG, WebP/JXL, captions), Load Video, Load Audio, Save Video, Select Frame, Frame Interpolate, Mask Refine, Mask by Name and Mask by Touch (type a name or click a thing, get its mask), LaMa Inpaint, Crop For Inpaint, Stitch Inpaint, Tiled Upscale and Tiled Upscale Stitch, LoRA Loader, Krea 2 Encode and Outpaint Model Patch, Latent Size, Layout Boxes, Seed, Select Every Nth, Split Batch, Merge Batches, Text, H3 Prompt Writer, Integer, Float, Math Expression, Show Text, Free Memory, Workflow Note, Workflow Note Tile, Callout, Callout Tile, Workflow Switches, Run Timer, and Step Preview (stop bad runs). Example workflows for Qwen Image 2.1, Krea 2, Klein 9B and LTX 2.3."
|
||||
version = "2.23.0"
|
||||
version = "2.21.2"
|
||||
# comfy-cli serializes this to JSON verbatim, so `file` publishes the literal
|
||||
# string {"file": "LICENSE"} to the registry listing. `text` puts a readable
|
||||
# name there instead. Both forms are supported; the LICENSE file is unchanged.
|
||||
|
||||
@@ -28,7 +28,6 @@ import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
# The Registry shows the notes in a small box: a long release is cut to its
|
||||
@@ -102,10 +101,10 @@ def notes_for(version: str, text: str) -> str:
|
||||
|
||||
def pyproject_version() -> str:
|
||||
raw = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
||||
version = tomllib.loads(raw).get("project", {}).get("version")
|
||||
if not isinstance(version, str) or not version.strip():
|
||||
raise ValueError("pyproject.toml needs a nonempty project.version string")
|
||||
return version
|
||||
found = re.search(r'^version\s*=\s*"([^"]+)"', raw, re.MULTILINE)
|
||||
if not found:
|
||||
raise ValueError('pyproject.toml has no version = "..." line')
|
||||
return found.group(1)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
+13
-26
@@ -53,21 +53,12 @@ def example_nodes(data: dict):
|
||||
yield f" in subgraph '{graph.get('name') or graph.get('id')}'", node
|
||||
|
||||
|
||||
def _widget(spec) -> bool:
|
||||
if not isinstance(spec, (list, tuple)) or not spec:
|
||||
return False
|
||||
options = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
|
||||
return not options.get("forceInput") and (isinstance(spec[0], list) or spec[0] in ("INT", "FLOAT", "BOOLEAN", "STRING", "COMBO"))
|
||||
|
||||
|
||||
def given_names(node: dict, info: dict) -> set[str]:
|
||||
"""Connected sockets and usable serialized widget values."""
|
||||
names = {entry.get("name") for entry in node.get("inputs") or []
|
||||
if isinstance(entry, dict) and entry.get("link") is not None}
|
||||
for saved in (node.get("widgets_values"), node.get("widgets_values_named")):
|
||||
if isinstance(saved, dict):
|
||||
names.update(name for name, value in saved.items()
|
||||
if value is not None and _widget(required_of(info, name)))
|
||||
def given_names(node: dict) -> set[str]:
|
||||
"""The inputs a saved node has a socket or a named value for."""
|
||||
names = {entry.get("name") for entry in node.get("inputs") or [] if isinstance(entry, dict)}
|
||||
named = node.get("widgets_values_named")
|
||||
if isinstance(named, dict):
|
||||
names.update(named)
|
||||
return {name for name in names if name}
|
||||
|
||||
|
||||
@@ -91,29 +82,25 @@ def example_problems(directory: Path, offered: dict) -> list[str]:
|
||||
continue
|
||||
if not isinstance(data, dict) or not isinstance(data.get("nodes"), list):
|
||||
continue
|
||||
subgraphs = {graph.get("id") for graph in (data.get("definitions") or {}).get("subgraphs") or []}
|
||||
for where, node in example_nodes(data):
|
||||
kind = str(node.get("type"))
|
||||
if kind in FRONTEND_ONLY or kind in subgraphs:
|
||||
continue
|
||||
if SUBGRAPH.fullmatch(kind):
|
||||
problems.append(f"{path.name}: unresolved subgraph '{kind}' (#{node.get('id')}{where})")
|
||||
if kind in FRONTEND_ONLY or SUBGRAPH.match(kind):
|
||||
continue
|
||||
info = offered.get(kind)
|
||||
if info is None:
|
||||
problems.append(f"{path.name}: this ComfyUI has no node '{kind}' (#{node.get('id')}{where})")
|
||||
continue
|
||||
required = set((info.get("input") or {}).get("required") or {})
|
||||
# Older positional saves cannot identify widgets by name, but
|
||||
# required sockets still need a connection.
|
||||
positional = not isinstance(node.get("widgets_values_named"), dict) and isinstance(node.get("widgets_values"), list) and bool(node["widgets_values"])
|
||||
given = given_names(node, info)
|
||||
# A saved node without named values (an older save) cannot be
|
||||
# checked by name; its sockets alone would raise false alarms.
|
||||
if not isinstance(node.get("widgets_values_named"), dict) and node.get("widgets_values"):
|
||||
continue
|
||||
given = given_names(node)
|
||||
# An input that grows (values.a, values.b, ...) is saved under
|
||||
# its parts' names, and may have none of them filled.
|
||||
roots = given | {name.split(".", 1)[0] for name in given}
|
||||
for name in sorted(required - roots):
|
||||
spec = required_of(info, name)
|
||||
if _grows(spec) or (positional and _widget(spec)):
|
||||
if _grows(required_of(info, name)):
|
||||
continue
|
||||
problems.append(f"{path.name}: '{kind}' (#{node.get('id')}{where}) now requires '{name}', and the example has no value for it")
|
||||
return problems
|
||||
|
||||
@@ -63,23 +63,6 @@ def _parent_map(tree: ast.AST) -> dict[int, ast.AST]:
|
||||
return parents
|
||||
|
||||
|
||||
def _bound_names(node: ast.AST) -> set[str]:
|
||||
"""Bindings stored as strings rather than ast.Name nodes."""
|
||||
if isinstance(node, ast.Import):
|
||||
return {alias.asname or alias.name.split(".")[0] for alias in node.names}
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
return {alias.asname or alias.name for alias in node.names}
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
return {node.name}
|
||||
if isinstance(node, ast.arg):
|
||||
return {node.arg}
|
||||
if isinstance(node, (ast.ExceptHandler, ast.MatchAs, ast.MatchStar)):
|
||||
return {node.name} if node.name else set()
|
||||
if isinstance(node, ast.MatchMapping):
|
||||
return {node.rest} if node.rest else set()
|
||||
return set()
|
||||
|
||||
|
||||
def _reference_problem(name: str, ref: ast.Name, parents: dict[int, ast.AST]) -> str:
|
||||
"""Why this mention of a mapping outside its own assignment is refused.
|
||||
|
||||
@@ -205,13 +188,6 @@ def mapping_problems(source: str, label: str) -> list[str]:
|
||||
f"{label}:{ref.lineno}: {_reference_problem(name, ref, parents)}"
|
||||
" - a scanner reads only the literal"
|
||||
)
|
||||
for node in ast.walk(tree):
|
||||
bindings = _bound_names(node)
|
||||
if name in bindings or "*" in bindings:
|
||||
problems.append(
|
||||
f"{label}:{node.lineno}: {name} may be rebound here "
|
||||
"- a scanner reads only the literal"
|
||||
)
|
||||
|
||||
# The two mappings describe the same nodes, so they carry the same keys.
|
||||
if len(literals) == 2:
|
||||
@@ -272,45 +248,20 @@ def module_list(source: str, name: str = "NODE_MODULES") -> set[str] | None:
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError:
|
||||
return None
|
||||
declarations = [node for node in tree.body if (
|
||||
for node in tree.body:
|
||||
if (
|
||||
isinstance(node, ast.Assign)
|
||||
and len(node.targets) == 1
|
||||
and isinstance(node.targets[0], ast.Name)
|
||||
and node.targets[0].id == name
|
||||
)]
|
||||
if len(declarations) != 1:
|
||||
return None
|
||||
declaration = declarations[0]
|
||||
try:
|
||||
value = ast.literal_eval(declaration.value)
|
||||
except (ValueError, SyntaxError):
|
||||
return None
|
||||
if not isinstance(value, (list, tuple, set)) or not all(isinstance(item, str) for item in value):
|
||||
return None
|
||||
parents = _parent_map(tree)
|
||||
for node in ast.walk(tree):
|
||||
bindings = _bound_names(node)
|
||||
if name in bindings or "*" in bindings:
|
||||
return None
|
||||
if isinstance(node, ast.Name) and node.id == name and node is not declaration.targets[0]:
|
||||
parent = parents.get(id(node))
|
||||
# The loader only iterates the list. Passing or aliasing it could
|
||||
# change it somewhere this check cannot follow.
|
||||
if not isinstance(parent, ast.For) or parent.iter is not node:
|
||||
):
|
||||
try:
|
||||
value = ast.literal_eval(node.value)
|
||||
except (ValueError, SyntaxError):
|
||||
return None
|
||||
return set(value)
|
||||
|
||||
|
||||
def registered_class(tree: ast.Module, key: str) -> ast.ClassDef | None:
|
||||
"""Resolve a literal registration to its module-level class declaration."""
|
||||
for node in tree.body:
|
||||
if not (isinstance(node, ast.Assign) and isinstance(node.value, ast.Dict)
|
||||
and any(isinstance(t, ast.Name) and t.id == "NODE_CLASS_MAPPINGS" for t in node.targets)):
|
||||
continue
|
||||
for ident, value in zip(node.value.keys, node.value.values):
|
||||
if isinstance(ident, ast.Constant) and ident.value == key and isinstance(value, ast.Name):
|
||||
classes = [item for item in tree.body if isinstance(item, ast.ClassDef) and item.name == value.id]
|
||||
return classes[0] if len(classes) == 1 else None
|
||||
if not isinstance(value, (list, tuple, set)):
|
||||
return None
|
||||
return {item for item in value if isinstance(item, str)}
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -33,11 +33,9 @@ Checks:
|
||||
Exit code 0 = ready to release, 1 = problems printed below.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
|
||||
from changelog_notes import notes_for
|
||||
from registry_contract import git_paths
|
||||
@@ -58,27 +56,20 @@ except OSError as exc:
|
||||
if raw.startswith(b"\xef\xbb\xbf"):
|
||||
errors.append("pyproject.toml starts with a UTF-8 BOM; save it without one")
|
||||
|
||||
try:
|
||||
project = tomllib.loads(raw.decode("utf-8")).get("project", {})
|
||||
except (ValueError, UnicodeError) as exc:
|
||||
errors.append(f"could not parse pyproject.toml: {exc}")
|
||||
project = {}
|
||||
version = project.get("version")
|
||||
if isinstance(version, str) and version.strip():
|
||||
pyproject_version = version
|
||||
match = re.search(rb'^version\s*=\s*"([^"]+)"', raw, re.MULTILINE)
|
||||
if match:
|
||||
pyproject_version = match.group(1).decode("ascii", "replace")
|
||||
else:
|
||||
errors.append("pyproject.toml needs a nonempty project.version string")
|
||||
errors.append('pyproject.toml has no version = "..." line')
|
||||
|
||||
# The Registry refuses an upload whose description is over 1000 bytes
|
||||
# ("description is too long"), and it says so only after the version is
|
||||
# on main.
|
||||
DESCRIPTION_LIMIT = 1000
|
||||
description = project.get("description")
|
||||
if not isinstance(description, str) or not description.strip():
|
||||
errors.append("pyproject.toml needs a nonempty project.description string")
|
||||
elif len(description.encode("utf-8")) > DESCRIPTION_LIMIT:
|
||||
match = re.search(rb'^description\s*=\s*"([^"]*)"', raw, re.MULTILINE)
|
||||
if match and len(match.group(1)) > DESCRIPTION_LIMIT:
|
||||
errors.append(
|
||||
f"the pyproject description is {len(description.encode('utf-8'))} bytes; "
|
||||
f"the pyproject description is {len(match.group(1))} bytes; "
|
||||
f"the Registry takes {DESCRIPTION_LIMIT} at most"
|
||||
)
|
||||
|
||||
@@ -295,7 +286,6 @@ NETWORK_PATTERNS = {
|
||||
".js": (".connect(",),
|
||||
".mjs": (".connect(",),
|
||||
}
|
||||
NETWORK_IMPORTS = {"requests", "httpx", "socket", "urllib.request", "http.client", "aiohttp.ClientSession"}
|
||||
shipped_source = [ROOT / "__init__.py", *sorted((ROOT / "nodes").rglob("*.py"))]
|
||||
shipped_source += sorted(p for p in (ROOT / "js").rglob("*") if p.suffix in (".js", ".mjs"))
|
||||
for path in shipped_source:
|
||||
@@ -306,21 +296,6 @@ for path in shipped_source:
|
||||
except OSError as exc:
|
||||
errors.append(f"could not read {path.relative_to(ROOT)}: {exc}")
|
||||
continue
|
||||
if path.suffix == ".py":
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError as exc:
|
||||
errors.append(f"{path.relative_to(ROOT)} cannot be parsed: {exc}")
|
||||
continue
|
||||
imports = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
imports.extend(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and not node.level:
|
||||
imports.extend(f"{node.module}.{alias.name}" for alias in node.names)
|
||||
for imported in imports:
|
||||
if any(imported == name or imported.startswith(name + ".") for name in NETWORK_IMPORTS):
|
||||
errors.append(f"{path.relative_to(ROOT)} imports {imported}; shipped code makes no network requests")
|
||||
for pattern in NETWORK_PATTERNS[path.suffix]:
|
||||
if pattern in text:
|
||||
errors.append(
|
||||
@@ -340,7 +315,7 @@ for path in shipped_source:
|
||||
relative = path.relative_to(ROOT).as_posix()
|
||||
if relative in FETCH_ALLOWED:
|
||||
continue
|
||||
if re.search(r"(?<![\w$])fetch\s*(?:\?\.\s*)?\(", path.read_text(encoding="utf-8")):
|
||||
if re.search(r"(?<![\w.])fetch\(", path.read_text(encoding="utf-8")):
|
||||
errors.append(
|
||||
f"{relative} calls fetch(); only {sorted(FETCH_ALLOWED)} may. "
|
||||
"Use api.fetchApi for the pack's own routes."
|
||||
|
||||
@@ -43,7 +43,6 @@ from registry_contract import (
|
||||
duplicate_key_problems,
|
||||
mapping_problems,
|
||||
module_list,
|
||||
registered_class,
|
||||
scanned_python_files,
|
||||
)
|
||||
|
||||
@@ -53,6 +52,7 @@ PUBLIC_NODE_IDS = {
|
||||
"AUSBOSS_NODES_AlignImage",
|
||||
"AUSBOSS_NODES_Callout",
|
||||
"AUSBOSS_NODES_CalloutTile",
|
||||
"AUSBOSS_NODES_CameraMove",
|
||||
"AUSBOSS_NODES_ColorMatch",
|
||||
"AUSBOSS_NODES_Compare",
|
||||
"AUSBOSS_NODES_CropForInpaint",
|
||||
@@ -211,12 +211,9 @@ for path in node_files:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if path.stem in {"node_image_crop_rotate_pad", "node_video_crop_rotate_pad"}:
|
||||
tree = ast.parse(text)
|
||||
key = {"node_image_crop_rotate_pad": "AUSBOSS_NODES_ImageCropRotatePad",
|
||||
"node_video_crop_rotate_pad": "AUSBOSS_NODES_VideoCropRotatePad"}[path.stem]
|
||||
cls = registered_class(tree, key)
|
||||
return_types = None
|
||||
return_names = None
|
||||
for node in cls.body if cls is not None else []:
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
for target in node.targets:
|
||||
@@ -261,13 +258,13 @@ for unlisted_id in sorted(mapping_keys - RELEASED_NODE_IDS):
|
||||
# import ever registers, and Manager offered the pack for workflows using
|
||||
# them. Fixtures live as .py.txt for this reason - real source, not a module.
|
||||
for path in python_files:
|
||||
if path in node_files:
|
||||
if path.parent == ROOT / "nodes":
|
||||
continue
|
||||
stray = class_mapping_keys(path.read_text(encoding="utf-8"))
|
||||
for key in sorted(stray):
|
||||
errors.append(
|
||||
f"{path.relative_to(ROOT)}: declares mapping key {key} outside "
|
||||
"a registered node module, where registry scanners will advertise it as a node"
|
||||
"nodes/, where registry scanners will advertise it as a node"
|
||||
)
|
||||
|
||||
# --- 6. public display names carry the pack suffix ---------------------------
|
||||
|
||||
+14
-153
@@ -2,14 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from functools import lru_cache
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
from registry_contract import class_mapping_keys, registered_class
|
||||
|
||||
UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
|
||||
# Below this saved zoom the frontend stops drawing widget text (about 0.57 on
|
||||
@@ -23,93 +18,6 @@ PREFIX_SLOT = {"AUSBOSS_NODES_SaveImage": 0, "AUSBOSS_NODES_SaveVideo": 1}
|
||||
# loaders (its CLIP toggle covers the model-only case).
|
||||
CORE_LORA_LOADERS = {"LoraLoader", "LoraLoaderModelOnly"}
|
||||
|
||||
# Serialized widget slots for the core/frontend nodes used by the examples.
|
||||
# Keep this independent of each saved object's named-copy insertion order.
|
||||
CORE_WIDGETS = {
|
||||
"UNETLoader": "unet_name weight_dtype",
|
||||
"CLIPLoader": "clip_name type device",
|
||||
"DualCLIPLoader": "clip_name1 clip_name2 type device",
|
||||
"VAELoader": "vae_name",
|
||||
"CheckpointLoaderSimple": "ckpt_name",
|
||||
"MarkdownNote": "text",
|
||||
"Note": "text",
|
||||
"ImageScaleToMaxDimension": "upscale_method largest_size",
|
||||
"TextGenerate": "prompt max_length sampling_mode sampling_mode.temperature sampling_mode.top_k sampling_mode.top_p sampling_mode.min_p sampling_mode.repetition_penalty sampling_mode.seed sampling_mode.presence_penalty thinking use_default_template mtp",
|
||||
"StringConcatenate": "string_a string_b delimiter",
|
||||
"StringFormat": "f_string",
|
||||
"CLIPTextEncode": "text",
|
||||
"KSampler": "seed control_after_generate steps cfg sampler_name scheduler denoise",
|
||||
"LoadImage": "image upload",
|
||||
"EmptyImage": "width height batch_size color",
|
||||
"ImageCompositeMasked": "x y resize_source",
|
||||
"GrowMask": "expand tapered_corners",
|
||||
"LTXVPreprocess": "img_compression",
|
||||
"LTXVConditioning": "frame_rate",
|
||||
"EmptyLTXVLatentVideo": "width height length batch_size",
|
||||
"LTXVAddGuide": "frame_idx strength",
|
||||
"VAEDecodeTiled": "tile_size overlap temporal_size temporal_overlap",
|
||||
"TextEncodeQwenImage21": "prompt negative_prompt resolution",
|
||||
"QwenImage21Cache": "device dtype",
|
||||
"SAM3_Detect": "threshold refine_iterations individual_masks",
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def pack_widget_names() -> dict[str, list[str]]:
|
||||
"""Read the pack's widget order without importing any node or library."""
|
||||
result = {}
|
||||
for path in sorted((Path(__file__).resolve().parent.parent / "nodes").glob("node_*.py")):
|
||||
source = path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
for key in class_mapping_keys(source):
|
||||
cls = registered_class(tree, key)
|
||||
if cls is None:
|
||||
continue
|
||||
names = []
|
||||
upload = False
|
||||
for fn in cls.body:
|
||||
if not isinstance(fn, ast.FunctionDef) or fn.name != "INPUT_TYPES":
|
||||
continue
|
||||
returned = [n.value for n in ast.walk(fn) if isinstance(n, ast.Return)]
|
||||
if len(returned) != 1 or not isinstance(returned[0], ast.Dict):
|
||||
continue
|
||||
groups = {k.value: v for k, v in zip(returned[0].keys, returned[0].values) if isinstance(k, ast.Constant)}
|
||||
for group in ("required", "optional"):
|
||||
inputs = groups.get(group)
|
||||
if not isinstance(inputs, ast.Dict):
|
||||
continue
|
||||
for name, spec in zip(inputs.keys, inputs.values):
|
||||
if not isinstance(name, ast.Constant) or not isinstance(spec, ast.Tuple) or not spec.elts:
|
||||
continue
|
||||
kind = spec.elts[0]
|
||||
options = spec.elts[1] if len(spec.elts) > 1 else None
|
||||
flags = {k.value: v.value for k, v in zip(options.keys, options.values)
|
||||
if isinstance(k, ast.Constant) and isinstance(v, ast.Constant)} if isinstance(options, ast.Dict) else {}
|
||||
primitive = isinstance(kind, ast.Constant) and kind.value in ("INT", "FLOAT", "BOOLEAN", "STRING")
|
||||
combo = isinstance(kind, (ast.List, ast.Call)) or isinstance(kind, ast.Name) and kind.id != "WILDCARD"
|
||||
if flags.get("forceInput") or not (primitive or combo):
|
||||
continue
|
||||
names.append(name.value)
|
||||
if flags.get("control_after_generate"):
|
||||
names.append("control_after_generate")
|
||||
if flags.get("image_upload") or flags.get("video_upload") or flags.get("audio_upload"):
|
||||
upload = True
|
||||
if upload:
|
||||
names.append("upload")
|
||||
result[key] = names
|
||||
return result
|
||||
|
||||
|
||||
def serialized_widget_names(node: dict) -> list[str] | None:
|
||||
kind = node.get("type")
|
||||
if kind in CORE_WIDGETS:
|
||||
return CORE_WIDGETS[kind].split()
|
||||
if kind in pack_widget_names():
|
||||
return pack_widget_names()[kind]
|
||||
if isinstance(kind, str) and UUID.fullmatch(kind):
|
||||
return [entry["widget"]["name"] for entry in node.get("inputs", []) if isinstance(entry.get("widget"), dict)]
|
||||
return None
|
||||
|
||||
|
||||
def widget_value_list(node: dict) -> list:
|
||||
values = node.get("widgets_values")
|
||||
@@ -143,37 +51,19 @@ def named_copy_problems(node: dict) -> list[str]:
|
||||
values = node.get("widgets_values")
|
||||
if not isinstance(named, dict):
|
||||
return []
|
||||
placeholders = {key for key, value in named.items() if key.startswith("ausboss_") and value in ("", None)}
|
||||
keys = set(named) - placeholders
|
||||
if isinstance(values, dict):
|
||||
# Upload buttons and old panels can have an empty named value even
|
||||
# though the frontend does not serialize them into the primary copy.
|
||||
if "upload" not in values:
|
||||
keys.discard("upload")
|
||||
pairs = [(key, named[key], values[key]) for key in named if key in values]
|
||||
unmatched = keys ^ set(values)
|
||||
elif isinstance(values, list):
|
||||
slots = serialized_widget_names(node)
|
||||
if slots is None:
|
||||
return [f"node {node['id']} has no known serialized widget slot mapping"]
|
||||
last = max((slots.index(key) for key in keys if key in slots), default=-1) + 1
|
||||
unmatched = keys - set(slots)
|
||||
unmatched.update(key for index, key in enumerate(slots[:last]) if key not in keys or index >= len(values))
|
||||
pairs = [(key, named[key], values[index]) for index, key in enumerate(slots)
|
||||
if key in keys and index < len(values)]
|
||||
trailing = values[last:]
|
||||
if len(trailing) > len(placeholders) or any(value not in ("", None) for value in trailing):
|
||||
unmatched.add("extra positional values")
|
||||
# Panel widgets the frontend saves as "" have no positional twin.
|
||||
keys = [key for key in named if not (key.startswith("ausboss_") and named[key] in ("", None))]
|
||||
pairs = [(key, named[key], value) for key, value in zip(keys, values)]
|
||||
else:
|
||||
return [f"node {node['id']} has named widgets without serialized values"]
|
||||
return []
|
||||
stale = [key for key, saved, loaded in pairs if saved != loaded]
|
||||
problems = [f"node {node['id']} has stale named widget values: {', '.join(stale)}"] if stale else []
|
||||
if unmatched:
|
||||
problems.append(f"node {node['id']} has unmatched widget values: {', '.join(sorted(unmatched))}")
|
||||
return problems
|
||||
return [f"node {node['id']} has stale named widget values: {', '.join(stale)}"] if stale else []
|
||||
|
||||
|
||||
def workflow_problems(data: dict, *, _subgraph: bool = False) -> list[str]:
|
||||
def workflow_problems(data: dict) -> list[str]:
|
||||
problems = []
|
||||
nodes = data.get("nodes", [])
|
||||
if not isinstance(nodes, list) or not nodes:
|
||||
@@ -181,21 +71,7 @@ def workflow_problems(data: dict, *, _subgraph: bool = False) -> list[str]:
|
||||
by_id = {n["id"]: n for n in nodes}
|
||||
if len(by_id) != len(nodes):
|
||||
problems.append("duplicate node ids")
|
||||
if _subgraph:
|
||||
# Boundary nodes are stored separately, with their sockets on the
|
||||
# graph itself. Treat them like nodes for the same link checks.
|
||||
by_id[data["inputNode"]["id"]] = {
|
||||
"id": data["inputNode"]["id"],
|
||||
"outputs": [{"links": entry.get("linkIds", [])} for entry in data.get("inputs", [])],
|
||||
}
|
||||
by_id[data["outputNode"]["id"]] = {
|
||||
"id": data["outputNode"]["id"],
|
||||
"inputs": [{"boundary_links": entry.get("linkIds", [])} for entry in data.get("outputs", [])],
|
||||
}
|
||||
links = data.get("links", [])
|
||||
if _subgraph:
|
||||
fields = ("id", "origin_id", "origin_slot", "target_id", "target_slot", "type")
|
||||
links = [[link[key] for key in fields] if isinstance(link, dict) and all(key in link for key in fields) else link for link in links]
|
||||
by_link = {link[0]: link for link in links if isinstance(link, list) and len(link) == 6}
|
||||
if len(by_link) != len(links):
|
||||
problems.append("duplicate or malformed links (expected six fields)")
|
||||
@@ -212,21 +88,20 @@ def workflow_problems(data: dict, *, _subgraph: bool = False) -> list[str]:
|
||||
problems.append(f"link {ident} is missing from its source output")
|
||||
if not isinstance(target_slot, int) or not 0 <= target_slot < len(inputs):
|
||||
problems.append(f"link {ident} has an invalid target slot")
|
||||
elif ident not in inputs[target_slot].get("boundary_links", [inputs[target_slot].get("link")]):
|
||||
elif inputs[target_slot].get("link") != ident:
|
||||
problems.append(f"link {ident} is missing from its target input")
|
||||
for n in by_id.values():
|
||||
for n in nodes:
|
||||
for slot, inp in enumerate(n.get("inputs", [])):
|
||||
for ident in inp.get("boundary_links", [inp.get("link")]):
|
||||
if ident is not None and (ident not in by_link or by_link[ident][3:5] != [n["id"], slot]):
|
||||
problems.append(f"node {n['id']} input {slot} has a stale link")
|
||||
ident = inp.get("link")
|
||||
if ident is not None and (ident not in by_link or by_link[ident][3:5] != [n["id"], slot]):
|
||||
problems.append(f"node {n['id']} input {slot} has a stale link")
|
||||
for slot, output in enumerate(n.get("outputs", [])):
|
||||
for ident in output.get("links") or []:
|
||||
if ident not in by_link or by_link[ident][1:3] != [n["id"], slot]:
|
||||
problems.append(f"node {n['id']} output {slot} has a stale link")
|
||||
counters = data.get("state", {}) if _subgraph else data
|
||||
if counters.get("lastNodeId" if _subgraph else "last_node_id", -1) < max(by_id):
|
||||
if data.get("last_node_id", -1) < max(by_id):
|
||||
problems.append("last_node_id is behind the graph")
|
||||
if by_link and counters.get("lastLinkId" if _subgraph else "last_link_id", -1) < max(by_link):
|
||||
if by_link and data.get("last_link_id", -1) < max(by_link):
|
||||
problems.append("last_link_id is behind the graph")
|
||||
|
||||
ident = data.get("id")
|
||||
@@ -242,19 +117,8 @@ def workflow_problems(data: dict, *, _subgraph: bool = False) -> list[str]:
|
||||
selected = selected_files(n)
|
||||
for model in (n.get("properties") or {}).get("models") or []:
|
||||
name = model.get("name") if isinstance(model, dict) else None
|
||||
if not isinstance(model, dict) or any(not isinstance(model.get(key), str) or not model[key].strip() for key in ("name", "url", "directory")):
|
||||
problems.append(f"node {n['id']} download info needs nonempty name, url and directory strings")
|
||||
continue
|
||||
if name and name not in selected:
|
||||
problems.append(f"node {n['id']} carries download info for {name}, which it does not select")
|
||||
try:
|
||||
url = urlsplit(model["url"])
|
||||
valid_url = url.scheme in ("http", "https") and bool(url.netloc)
|
||||
filename = unquote(url.path.rsplit("/", 1)[-1])
|
||||
except ValueError:
|
||||
valid_url, filename = False, ""
|
||||
if not valid_url or filename != name:
|
||||
problems.append(f"node {n['id']} download URL must end with the selected filename {name}")
|
||||
if n["type"] in PREFIX_SLOT:
|
||||
saved = n.get("widgets_values")
|
||||
prefix = saved.get("filename_prefix") if isinstance(saved, dict) else (
|
||||
@@ -266,7 +130,7 @@ def workflow_problems(data: dict, *, _subgraph: bool = False) -> list[str]:
|
||||
if not groups:
|
||||
problems.append("no stage groups")
|
||||
notes = [n for n in nodes if n["type"] in {"AUSBOSS_NODES_WorkflowNote", "AUSBOSS_NODES_WorkflowNoteTile"}]
|
||||
if not notes and not _subgraph:
|
||||
if not notes:
|
||||
problems.append("no Workflow Note with setup instructions")
|
||||
for n in notes:
|
||||
try:
|
||||
@@ -308,9 +172,6 @@ def workflow_problems(data: dict, *, _subgraph: bool = False) -> list[str]:
|
||||
for other, ox0, oy0, ox1, oy1 in rectangles[i + 1:]:
|
||||
if min(x1, ox1) > max(x0, ox0) + 1 and min(y1, oy1) > max(y0, oy0) + 1:
|
||||
problems.append(f"nodes {ident} and {other} overlap (including title bars)")
|
||||
for graph in (data.get("definitions") or {}).get("subgraphs") or []:
|
||||
label = graph.get("name") or graph.get("id")
|
||||
problems.extend(f"subgraph {label}: {issue}" for issue in workflow_problems(graph, _subgraph=True))
|
||||
return problems
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Camera Move's face maths must match the node's (nodes/_camera_move_helpers.py).
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
DEFAULT_KEYS, K0, LIM, camAt, cameraText, keyAt, parseCamera, pastLoraRange, pivotFromPixel, pose, setKeyAt, toCam, unpackDepth,
|
||||
} from "../js/camera_move/camera_math.mjs";
|
||||
|
||||
const close = (a, b, eps = 1e-9) => assert.ok(Math.abs(a - b) < eps, `${a} != ${b}`);
|
||||
|
||||
test("pose matches the node's numbers", () => {
|
||||
// From nodes/_camera_move_helpers.pose with the same key and pivot.
|
||||
const { R, C } = pose({ az: 33, el: -12, back: 0.4, px: 0.2, py: -0.1 }, [0.3, -0.2, 2.0]);
|
||||
const R_py = [0.838670567945424, -0.11323682265532711, -0.532737365365924, 0, 0.9781476007338057, -0.20791169081775934,
|
||||
0.5446390350150271, 0.17436941582062362, 0.8203436038418747];
|
||||
R.forEach((v, i) => close(v, R_py[i]));
|
||||
[1.8881747404128695, 0.3816804134905334, -0.250127706778845].forEach((v, i) => close(C[i], v));
|
||||
});
|
||||
|
||||
test("easing between keys matches the node's", () => {
|
||||
const { keys } = parseCamera({ keys: [{ t: 0 }, { t: 0.4, az: 20, el: 10, back: 0.5 }, { t: 1, az: -10, px: 0.3 }] });
|
||||
const c = camAt(keys, 0.3);
|
||||
close(c.az, 16.875); close(c.el, 8.4375); close(c.back, 0.421875);
|
||||
});
|
||||
|
||||
test("the camera keeps moving through a key, the same as the node", () => {
|
||||
const { keys } = parseCamera({ keys: [{ t: 0 }, { t: 0.3, az: 12 }, { t: 0.6, az: 25, el: 5 }, { t: 1, az: 40, el: 5, back: 0.4 }] });
|
||||
// From nodes/_camera_move_helpers.camera_at with the same keys.
|
||||
close(camAt(keys, 0.45).az, 18.547068965517244); close(camAt(keys, 0.8).az, 34.51724137931035);
|
||||
close(camAt(keys, 0.8).back, 0.2);
|
||||
const steady = parseCamera({ keys: [{ t: 0 }, { t: 0.5, az: 20 }, { t: 1, az: 40 }] }).keys;
|
||||
assert.ok((camAt(steady, 0.51).az - camAt(steady, 0.49).az) / 0.02 > 35);
|
||||
});
|
||||
|
||||
test("the pivot stays where it was on screen", () => {
|
||||
const pivot = [0.3, -0.2, 2], d = Math.hypot(...pivot);
|
||||
const cam = { az: 40, el: 15, back: 0.5, px: 0, py: 0 };
|
||||
const { R, C } = pose(cam, pivot);
|
||||
const q = toCam(R, C, pivot);
|
||||
close(q[0], 0.3); close(q[1], -0.2); close(q[2], 2 + 0.5 * d);
|
||||
});
|
||||
|
||||
test("unreadable camera text falls back to the default move", () => {
|
||||
for (const text of ["", "nope", "[]", '{"keys":[]}', null]) {
|
||||
const parsed = parseCamera(text);
|
||||
assert.equal(parsed.fallback, true);
|
||||
assert.equal(parsed.keys.length, DEFAULT_KEYS.length);
|
||||
}
|
||||
});
|
||||
|
||||
test("camera text round-trips, clamped and sorted", () => {
|
||||
const parsed = parseCamera({ keys: [{ t: 0.9, az: 300 }, { t: 0.1, el: -90, back: 5 }], pivot: { u: 0.25, v: 2, t: 0.5 } });
|
||||
assert.deepEqual(parsed.keys.map((k) => k.t), [0.1, 0.9]);
|
||||
assert.equal(parsed.keys[1].az, LIM.az[1]);
|
||||
assert.equal(parsed.keys[0].el, LIM.el[0]);
|
||||
assert.deepEqual(parsed.pivot, { u: 0.25, v: 1, t: 0.5 });
|
||||
const again = parseCamera(cameraText(parsed));
|
||||
assert.deepEqual(again.keys, parsed.keys);
|
||||
assert.deepEqual(again.pivot, parsed.pivot);
|
||||
});
|
||||
|
||||
test("moving the camera at the time bar sets or adds a key", () => {
|
||||
const keys = [{ t: 0, ...K0 }, { t: 1, ...K0 }];
|
||||
setKeyAt(keys, 0.5, { az: 10 });
|
||||
assert.equal(keys.length, 3);
|
||||
assert.equal(keys[1].t, 0.5);
|
||||
setKeyAt(keys, 0.505, { az: 99 });
|
||||
assert.equal(keys.length, 3);
|
||||
assert.equal(keys[1].az, 90);
|
||||
assert.equal(keyAt(keys, 0.51), 1);
|
||||
assert.equal(keyAt(keys, 0.6), -1);
|
||||
});
|
||||
|
||||
test("the LoRA's range", () => {
|
||||
assert.equal(pastLoraRange({ az: 45, el: 30 }), false);
|
||||
assert.equal(pastLoraRange({ az: 46, el: 0 }), true);
|
||||
assert.equal(pastLoraRange({ az: 0, el: -21 }), true);
|
||||
});
|
||||
|
||||
test("the depth picture reads back as the node wrote it", () => {
|
||||
// q = 1 is zmin, q = 65535 is zmax, 0 is sky.
|
||||
const rgba = new Uint8ClampedArray([0, 1, 0, 255, 255, 255, 0, 255, 0, 0, 0, 255]);
|
||||
const { z, valid } = unpackDepth(rgba, 3, 0.5, 40, 99);
|
||||
close(z[0], 0.5, 1e-6); close(z[1], 40, 1e-4); assert.equal(z[2], 99);
|
||||
assert.deepEqual([...valid], [1, 1, 0]);
|
||||
});
|
||||
|
||||
test("a tapped pixel becomes a point in front of the camera", () => {
|
||||
const W = 4, H = 2, z = new Float32Array([2, 2, 2, 2, 2, 2, 2, 2]), valid = new Uint8Array([0, 0, 0, 0, 0, 1, 0, 0]);
|
||||
const frame = { W, H, z, valid, fx: 2, fy: 2, cx: 2, cy: 1 };
|
||||
const p = pivotFromPixel(frame, 0.1, 0.9);
|
||||
assert.ok(p);
|
||||
close(p[2], 2);
|
||||
assert.equal(pivotFromPixel({ ...frame, valid: new Uint8Array(8) }, 0.5, 0.5), null);
|
||||
});
|
||||
@@ -1,243 +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 appearance from "../js/shared/appearance.mjs";
|
||||
import * as help from "../js/shared/help_badge.mjs";
|
||||
import * as line from "../js/shared/line_up.mjs";
|
||||
import * as snap from "../js/shared/snap_lines.mjs";
|
||||
import { boundsOf } from "../js/shared/outline.mjs";
|
||||
|
||||
// Run the real extension entry with a small page and graph in place of ComfyUI.
|
||||
function page(name, nodes, { vue = false, hooks = [] } = {}) {
|
||||
const graph = { _nodes: nodes, _groups: [], setDirtyCanvas() {} };
|
||||
const canvas = {
|
||||
graph, selectedItems: new Set(nodes), setDirty() {},
|
||||
canvas: { getBoundingClientRect: () => ({ left: 0, top: 0 }) },
|
||||
ds: { scale: 1, offset: [0, 0] },
|
||||
};
|
||||
const app = { graph, canvas, registerExtension(extension) { this.extension = extension; } };
|
||||
const frames = new Map();
|
||||
let frameId = 0;
|
||||
const cards = [];
|
||||
const context = vm.createContext({
|
||||
...appearance, ...help, ...line, ...snap, boundsOf,
|
||||
outlineOf: boundsOf, nodeElements: () => new Map(),
|
||||
app, LiteGraph: { vueNodesMode: vue },
|
||||
window: { addEventListener() {}, removeEventListener() {}, innerWidth: 1000 },
|
||||
document: { querySelector: () => null },
|
||||
requestAnimationFrame(fn) { frames.set(++frameId, fn); return frameId; },
|
||||
cancelAnimationFrame(id) { frames.delete(id); },
|
||||
chainCallback(object, key, fn) {
|
||||
const prior = object[key];
|
||||
object[key] = function (...args) {
|
||||
const result = prior?.apply(this, args);
|
||||
fn.apply(this, args);
|
||||
return result;
|
||||
};
|
||||
},
|
||||
chainHandler(object, key, fn) {
|
||||
const prior = object[key];
|
||||
object[key] = function (...args) {
|
||||
const result = prior?.apply(this, args);
|
||||
const handled = fn.apply(this, args);
|
||||
return result || handled;
|
||||
};
|
||||
},
|
||||
isAusbossNode: (node) => node.comfyClass?.startsWith("AUSBOSS_NODES_"),
|
||||
openInfoCard: (card) => cards.push(card),
|
||||
BRAND: "#00b4aa", notifyAusbossChange() {}, showToast() {},
|
||||
});
|
||||
const source = readFileSync(new URL(`../js/${name}/index.js`, import.meta.url), "utf8")
|
||||
.replace(/^import[\s\S]*?;\s*/gm, "");
|
||||
vm.runInContext(`${source}\nglobalThis.hooks = {${hooks.join(",")}};`, context);
|
||||
return {
|
||||
app, extension: app.extension, hooks: context.hooks, cards,
|
||||
flush() { for (const [id, fn] of frames) { frames.delete(id); fn(); } },
|
||||
};
|
||||
}
|
||||
|
||||
function node(id, x = 0, y = 0, w = 200, h = 100) {
|
||||
return {
|
||||
id, pos: [x, y], size: [w, h], properties: {}, flags: {},
|
||||
comfyClass: "AUSBOSS_NODES_Seed", title: "Seed", constructor: {},
|
||||
get boundingRect() { return [this.pos[0], this.pos[1] - 30, this.size[0], this.size[1] + 30]; },
|
||||
computeSize: () => [100, 50],
|
||||
setSize(size) { this.size = size; },
|
||||
};
|
||||
}
|
||||
|
||||
function pointer(node, corner = "SE", extra = {}) {
|
||||
const face = { dataset: { nodeId: String(node.id) } };
|
||||
const handle = { dataset: { corner }, closest: () => face };
|
||||
return {
|
||||
button: 0, buttons: 1, clientX: 0, clientY: 0,
|
||||
target: { closest: () => handle }, ...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function pick(page, node, name) {
|
||||
page.extension.getNodeMenuItems(node).find((item) => item.content === "AusBoss color")
|
||||
.submenu.options.find((item) => item.content === name).callback();
|
||||
}
|
||||
|
||||
function setting(page, suffix, value) {
|
||||
page.extension.settings.find((item) => item.id.endsWith(suffix)).onChange(value);
|
||||
}
|
||||
|
||||
function restore(page, saved) {
|
||||
const copy = node(99);
|
||||
page.extension.nodeCreated(copy); // construction precedes saved field restoration
|
||||
Object.assign(copy, JSON.parse(JSON.stringify(saved)));
|
||||
copy.onConfigure?.(saved);
|
||||
return copy;
|
||||
}
|
||||
|
||||
test("pressing and releasing a resize handle leaves the other selected nodes alone", () => {
|
||||
for (const vue of [false, true]) {
|
||||
const a = node(1), b = node(2, 0, 300, 400);
|
||||
const p = page("line_up", [a, b], { vue, hooks: ["onTogetherDown", "onTogetherMove", "onTogetherUp"] });
|
||||
p.hooks.onTogetherDown(pointer(a));
|
||||
if (!vue) p.app.canvas.resizing_node = a;
|
||||
p.hooks.onTogetherMove(pointer(a));
|
||||
p.flush();
|
||||
p.hooks.onTogetherUp();
|
||||
assert.equal(b.size[0], 400);
|
||||
}
|
||||
});
|
||||
|
||||
test("resize together still propagates a drag and its return to the starting size", () => {
|
||||
const a = node(1), b = node(2, 0, 300, 400);
|
||||
const p = page("line_up", [a, b], { hooks: ["onTogetherDown", "onTogetherMove", "onTogetherUp"] });
|
||||
p.hooks.onTogetherDown(pointer(a));
|
||||
a.size[0] = 240;
|
||||
p.hooks.onTogetherMove(pointer(a));
|
||||
p.flush();
|
||||
assert.equal(b.size[0], 240);
|
||||
a.size[0] = 200;
|
||||
p.hooks.onTogetherUp();
|
||||
assert.equal(b.size[0], 200);
|
||||
});
|
||||
|
||||
test("per-node Theme default survives serialization, reload and paste", () => {
|
||||
const a = node(1);
|
||||
const p = page("appearance", [a]);
|
||||
p.extension.nodeCreated(a);
|
||||
pick(p, a, "Theme default");
|
||||
const saved = JSON.parse(JSON.stringify({ properties: a.properties, color: a.color, bgcolor: a.bgcolor }));
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const copy = restore(p, saved);
|
||||
assert.equal(copy.color, undefined);
|
||||
assert.equal(copy.bgcolor, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("explicit color choices keep following the node instead of the global scheme", () => {
|
||||
for (const name of ["AusBoss", "Theme default", "Custom"]) {
|
||||
const a = node(1);
|
||||
const p = page("appearance", [a]);
|
||||
p.extension.nodeCreated(a);
|
||||
pick(p, a, name);
|
||||
const chosen = { color: a.color, bgcolor: a.bgcolor };
|
||||
setting(p, "NodeColor", "Slate");
|
||||
assert.deepEqual({ color: a.color, bgcolor: a.bgcolor }, chosen, name);
|
||||
const copy = restore(p, { properties: a.properties, ...chosen });
|
||||
assert.deepEqual({ color: copy.color, bgcolor: copy.bgcolor }, chosen, name);
|
||||
}
|
||||
});
|
||||
|
||||
for (const corner of ["SE", "NE", "SW", "NW"]) {
|
||||
test(`Nodes 2.0 ${corner} resize snaps only the moving edges`, () => {
|
||||
const a = node(1, 0, 0, 280, 100);
|
||||
const west = corner.includes("W"), north = corner.includes("N");
|
||||
const b = node(2, west ? -20 : 300, north ? -200 : 400, 100, 100);
|
||||
const p = page("snap_lines", [a, b], { vue: true, hooks: ["onPointerDown", "onPointerMove", "end"] });
|
||||
p.hooks.onPointerDown(pointer(a, corner));
|
||||
if (west) { a.pos[0] = -16; a.size[0] = 296; }
|
||||
else a.size[0] = 296;
|
||||
if (north) { a.pos[1] = -66; a.size[1] = 166; }
|
||||
p.hooks.onPointerMove(pointer(a, corner));
|
||||
p.flush();
|
||||
assert.equal(a.size[0], 300);
|
||||
assert.equal(a.pos[0], west ? -20 : 0);
|
||||
assert.equal(a.pos[0] + a.size[0], west ? 280 : 300);
|
||||
if (north) {
|
||||
assert.equal(a.pos[1], -70);
|
||||
assert.equal(a.pos[1] + a.size[1], 100);
|
||||
}
|
||||
p.hooks.end();
|
||||
a.size[0] = 296;
|
||||
p.hooks.onPointerMove(pointer(a, corner));
|
||||
p.flush();
|
||||
assert.equal(a.size[0], 296, "release clears the captured handle");
|
||||
});
|
||||
}
|
||||
|
||||
test("Nodes 2.0 resize lets Shift bypass snapping", () => {
|
||||
const a = node(1, 0, 0, 280), b = node(2, 300, 400, 100);
|
||||
const p = page("snap_lines", [a, b], { vue: true, hooks: ["onPointerDown", "onPointerMove"] });
|
||||
p.hooks.onPointerDown(pointer(a));
|
||||
a.size[0] = 296;
|
||||
p.hooks.onPointerMove(pointer(a, "SE", { shiftKey: true }));
|
||||
p.flush();
|
||||
assert.equal(a.size[0], 296);
|
||||
});
|
||||
|
||||
test("titled Nodes 2.0 nodes offer an About card in the context menu", () => {
|
||||
const a = node(1);
|
||||
const p = page("appearance", [a], { vue: true });
|
||||
p.extension.nodeCreated(a);
|
||||
const about = p.extension.getNodeMenuItems(a).find((item) => item.content === "About this node");
|
||||
assert.ok(about);
|
||||
about.callback();
|
||||
assert.equal(p.cards[0].title, "Seed");
|
||||
});
|
||||
|
||||
test("an invalid Custom edit preserves the last valid color for later sweeps", () => {
|
||||
const a = node(1);
|
||||
const p = page("appearance", [a]);
|
||||
p.extension.nodeCreated(a);
|
||||
setting(p, "NodeColor", "Custom");
|
||||
const before = a.color;
|
||||
setting(p, "CustomColor", "bad");
|
||||
assert.equal(a.color, before);
|
||||
setting(p, "CustomColor", "ff0000");
|
||||
assert.equal(a.color, "#ff0000");
|
||||
setting(p, "NodeColor", "Slate");
|
||||
assert.equal(a.color, appearance.schemeColors("Slate").title);
|
||||
});
|
||||
|
||||
test("saved legacy colors migrate after configuration, including Theme default", () => {
|
||||
for (const name of ["AusBoss", "Slate", "Theme default"]) {
|
||||
const p = page("appearance", []);
|
||||
setting(p, "NodeColor", name);
|
||||
const old = appearance.LEGACY_SCHEME_PAIRS[0];
|
||||
const copy = restore(p, { color: old.title, bgcolor: old.body, properties: {} });
|
||||
assert.equal(copy.color, appearance.schemeColors(name)?.title);
|
||||
assert.equal(copy.bgcolor, appearance.schemeColors(name)?.body);
|
||||
}
|
||||
});
|
||||
|
||||
test("restoration preserves manual colors and chains the existing configure callback", () => {
|
||||
const a = node(1);
|
||||
const p = page("appearance", [a]);
|
||||
let calls = 0;
|
||||
a.onConfigure = function () { assert.equal(this, a); calls++; return 42; };
|
||||
p.extension.nodeCreated(a);
|
||||
Object.assign(a, { color: "#123456", bgcolor: "#654321" });
|
||||
assert.equal(a.onConfigure({}), 42);
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(a.color, "#123456");
|
||||
assert.equal(a.bgcolor, "#654321");
|
||||
});
|
||||
|
||||
test("a later manual color edit wins over an earlier menu choice on reload", () => {
|
||||
const a = node(1);
|
||||
const p = page("appearance", [a]);
|
||||
p.extension.nodeCreated(a);
|
||||
pick(p, a, "Theme default");
|
||||
const copy = restore(p, { properties: a.properties, color: "#123456", bgcolor: "#654321" });
|
||||
assert.equal(copy.color, "#123456");
|
||||
assert.equal(copy.bgcolor, "#654321");
|
||||
});
|
||||
@@ -45,30 +45,6 @@ test("middle button and Ctrl+Shift drag go to the graph; ordinary clicks do not"
|
||||
assert.equal(graphDragStarts({ button: 0, buttons: 1, ctrlKey: true }, on), false);
|
||||
});
|
||||
|
||||
test("horizontal wheel stays in a scrollable code block until its edge", () => {
|
||||
const root = { parentElement: null };
|
||||
const code = {
|
||||
tagName: "PRE", parentElement: root,
|
||||
scrollWidth: 1000, clientWidth: 200, scrollLeft: 0,
|
||||
scrollHeight: 50, clientHeight: 50, scrollTop: 0,
|
||||
};
|
||||
const context = { root, styleOf: () => ({ overflowX: "auto", overflowY: "auto" }) };
|
||||
const keeps = (deltaX, deltaY = 0) => panelKeepsWheel(code, { deltaX, deltaY }, context);
|
||||
assert.equal(keeps(80), true);
|
||||
assert.equal(keeps(-80), false);
|
||||
code.scrollLeft = 400;
|
||||
assert.equal(keeps(-80), true);
|
||||
assert.equal(keeps(80, 80), true, "diagonal scroll with horizontal room");
|
||||
code.scrollLeft = 800;
|
||||
assert.equal(keeps(80), false);
|
||||
assert.equal(keeps(-80), true);
|
||||
code.scrollWidth = 200;
|
||||
assert.equal(keeps(80), false);
|
||||
code.scrollWidth = 1000;
|
||||
context.styleOf = () => ({ overflowX: "hidden" });
|
||||
assert.equal(keeps(-80), false, "hidden overflow cannot keep the wheel");
|
||||
});
|
||||
|
||||
test("only the pack's own panels count, in both node renderers", () => {
|
||||
const chain = (host, ...classes) => {
|
||||
let parent = host;
|
||||
@@ -132,6 +108,7 @@ test("only the listed parts of the pack listen for the wheel", () => {
|
||||
const js = join(dirname(fileURLToPath(import.meta.url)), "..", "js");
|
||||
const listed = [
|
||||
"callout/index.js wrap", // the text box while a note is being edited
|
||||
"camera_move/face.mjs stage", // the shot: the wheel pulls the camera back or in
|
||||
"canvas_passthrough/index.js window", // hands the wheel to the graph
|
||||
"image_folder/index.js root", // the copy question, a window over the page
|
||||
"image_folder/index.js root", // the folder list, a window over the page
|
||||
|
||||
Vendored
+18
-2
@@ -40,6 +40,23 @@
|
||||
"optional": [],
|
||||
"outputs": []
|
||||
},
|
||||
"AUSBOSS_NODES_CameraMove": {
|
||||
"required": [
|
||||
"moge_geometry",
|
||||
"camera"
|
||||
],
|
||||
"optional": [],
|
||||
"outputs": [
|
||||
[
|
||||
"IMAGE",
|
||||
"guide"
|
||||
],
|
||||
[
|
||||
"MASK",
|
||||
"holes"
|
||||
]
|
||||
]
|
||||
},
|
||||
"AUSBOSS_NODES_ColorMatch": {
|
||||
"required": [
|
||||
"image",
|
||||
@@ -687,8 +704,7 @@
|
||||
"free_vram",
|
||||
"invert",
|
||||
"ask",
|
||||
"fine_edges",
|
||||
"new_picture"
|
||||
"fine_edges"
|
||||
],
|
||||
"outputs": [
|
||||
[
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { getEventListeners, setMaxListeners } from 'node:events';
|
||||
import test from 'node:test';
|
||||
import * as helpers from '../js/shared/image_folder.mjs';
|
||||
import * as scope from '../js/shared/prompt_scope.mjs';
|
||||
import * as math from '../js/shared/widget_card_math.mjs';
|
||||
import * as layout from '../js/shared/panel_layout.mjs';
|
||||
class Element extends EventTarget {
|
||||
constructor(tag = 'div') {
|
||||
super(); setMaxListeners(0, this); this.tagName = tag.toUpperCase(); this.children = []; this.style = {}; this.dataset = {}; this.className = ''; this.textContent = ''; this.parentElement = null; this.isConnected = true;
|
||||
this.classList = {
|
||||
contains: name => this.className.split(' ').includes(name),
|
||||
add: name => { if (!this.classList.contains(name)) this.className += ` ${name}`; },
|
||||
remove: name => { this.className = this.className.split(' ').filter(x => x !== name).join(' '); },
|
||||
toggle: (name, on) => on ? this.classList.add(name) : this.classList.remove(name)
|
||||
};
|
||||
}
|
||||
append(...items) { for (const item of items) { this.children.push(item); item.parentElement = this; } }
|
||||
appendChild(item) { this.append(item); return item; }
|
||||
replaceChildren(...items) { this.children = []; this.append(...items); }
|
||||
remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter(x => x !== this); this.isConnected = false; }
|
||||
contains(target) { return this === target || this.children.some(x => x.contains(target)); }
|
||||
matches(query) { return query.startsWith('.') ? this.classList.contains(query.slice(1)) : this.tagName.toLowerCase() === query; }
|
||||
closest(query) { return this.matches(query) ? this : this.parentElement?.closest(query) ?? null; }
|
||||
querySelector(query) { for (const child of this.children) { if (child.matches(query)) return child; const found = child.querySelector(query); if (found) return found; } return null; }
|
||||
get firstChild() { return this.children[0]; }
|
||||
get lastChild() { return this.children.at(-1); }
|
||||
getBoundingClientRect() { return {left: 20, top: 20, bottom: 200, width: 340}; }
|
||||
click() { this.dispatchEvent(new Event('click')); }
|
||||
}
|
||||
|
||||
function harness() {
|
||||
const timers = new Map(); let timerId = 0;
|
||||
const document = { head: new Element('head'), body: new Element('body'), createElement: tag => new Element(tag), createTextNode: text => Object.assign(new Element('text'), {textContent: text}), getElementById: () => null };
|
||||
const window = Object.assign(new EventTarget(), {innerWidth: 1000, innerHeight: 800});
|
||||
const messages = [];
|
||||
const app = {canvas: {}, registerExtension(extension) { this.extension = extension; }, extensionManager: {toast: {add(message) { messages.push(message); }}}};
|
||||
const pending = [];
|
||||
const api = {apiURL: x => x, fetchApi: (url, options) => new Promise((resolve, reject) => pending.push({url, options, resolve, reject}))};
|
||||
class FormData { constructor() { this.fields = {}; } append(key, value) { this.fields[key] = value; } }
|
||||
const context = vm.createContext({console, document, window, app, api, AbortController: class extends AbortController { constructor() { super(); setMaxListeners(0, this.signal); } }, performance, FormData, queueMicrotask, ...helpers, ...scope, commitWidgetValue: math.commitWidgetValue, fillNodeHeight: layout.fillNodeHeight,
|
||||
setTimeout: (fn, delay) => { const id = ++timerId; timers.set(id, {fn, delay}); return id; }, clearTimeout: id => timers.delete(id)});
|
||||
function strip(source) { return source.replace(/^import[\s\S]*?;\n/gm, '').replace(/^export /gm, ''); }
|
||||
vm.runInContext(strip(fs.readFileSync(new URL('../js/shared/index.mjs', import.meta.url), 'utf8')), context);
|
||||
vm.runInContext(strip(fs.readFileSync(new URL('../js/image_folder/index.js', import.meta.url), 'utf8')) + '\nglobalThis.review = {buildPanel, refresh, write, copyFolder, addFiles, browse, closeBrowse, addMenu, closeMenu, askBig, closeAsk, paint, scheduleRefresh};', context);
|
||||
const r = context.review;
|
||||
class FolderNode {
|
||||
constructor(values = {}) {
|
||||
this.widgets = Object.entries({source:'input', folder:'', subfolders:false, sort:'name', pictures:'', run:'all in one run', position:1, after_run:'next', at_the_end:'stop the run', ...values}).map(([name,value]) => ({name,value}));
|
||||
this.inputs = []; this.size = [340,400]; this.onNodeCreated();
|
||||
}
|
||||
addDOMWidget(name, type, element, options) { const w = {name,type,element,options,node:this}; this.widgets.push(w); return w; }
|
||||
computeSize() { return [320,300]; }
|
||||
setSize(size) { this.size = size; }
|
||||
}
|
||||
app.extension.beforeRegisterNodeDef(FolderNode, {name:'AUSBOSS_NODES_ImageFolder'});
|
||||
const value = (node, name) => node.widgets.find(w => w.name === name).value;
|
||||
const flush = async () => { for (let i=0;i<10;i++) await Promise.resolve(); };
|
||||
const ok = data => ({ok:true, json:async () => data});
|
||||
const newNode = values => { const n = new FolderNode(values); timers.clear(); return n; };
|
||||
const image = name => ({file:{name,size:1,type:'image/png'},folder:''});
|
||||
|
||||
return {r, newNode, pending, messages, document, window, timers, value, flush, ok, image};
|
||||
}
|
||||
|
||||
for (const change of ['edit', 'configure']) {
|
||||
test(`a pending listing cannot overwrite a later ${change}`, async () => {
|
||||
const {r, newNode, pending, value, ok, flush} = harness();
|
||||
const n = newNode({folder: 'a//'}), s = n.__ausbossImageFolder;
|
||||
const task = r.refresh(s), old = pending.shift();
|
||||
if (change === 'edit') r.write(n, 'folder', 'b');
|
||||
else {
|
||||
n.widgets.find(w => w.name === 'folder').value = 'b';
|
||||
n.onConfigure({size: [320, 400]});
|
||||
await flush();
|
||||
}
|
||||
old.resolve(ok({source: 'input', folder: 'a', pictures: [{name: 'old.png'}]}));
|
||||
await task;
|
||||
assert.equal(value(n, 'folder'), 'b');
|
||||
assert.equal(s.list.length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('folder copies lock before confirmation and release after completion', async () => {
|
||||
const {r, newNode, pending, value, ok, flush, image} = harness();
|
||||
const n = newNode(), s = n.__ausbossImageFolder;
|
||||
const first = r.copyFolder(s, 'first', [image('a.png')]);
|
||||
const second = r.copyFolder(s, 'second', [image('b.png')]);
|
||||
await flush();
|
||||
assert.equal(pending.length, 1);
|
||||
pending.shift().resolve(ok({name: 'a.png'}));
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(value(n, 'folder'), 'first');
|
||||
const third = r.copyFolder(s, 'third', [image('c.png')]);
|
||||
await flush();
|
||||
assert.equal(pending.length, 1);
|
||||
pending.shift().resolve(ok({name: 'c.png'}));
|
||||
await third;
|
||||
assert.equal(value(n, 'folder'), 'third');
|
||||
});
|
||||
|
||||
test('confirmation also holds the folder-copy lock', async () => {
|
||||
const {r, newNode, pending, ok, flush, image} = harness();
|
||||
const s = newNode().__ausbossImageFolder;
|
||||
const first = r.copyFolder(s, 'big', Array.from({length: 301}, () => image('a.png')));
|
||||
const second = r.copyFolder(s, 'second', [image('b.png')]);
|
||||
await flush();
|
||||
assert.equal(pending.length, 0);
|
||||
r.closeAsk(false);
|
||||
await Promise.all([first, second]);
|
||||
const next = r.copyFolder(s, 'next', [image('c.png')]);
|
||||
await flush();
|
||||
pending.shift().resolve(ok({name: 'c.png'}));
|
||||
await next;
|
||||
});
|
||||
|
||||
for (const at_the_end of ['stop the run', 'start over']) {
|
||||
test(`queue advancement ignores missing and stale listings with ${at_the_end}`, () => {
|
||||
const {newNode, value} = harness();
|
||||
const n = newNode({run: 'one per run', at_the_end}), s = n.__ausbossImageFolder;
|
||||
const position = n.widgets.find(w => w.name === 'position');
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
assert.equal(value(n, 'position'), i);
|
||||
position.afterQueued({isPartialExecution: false});
|
||||
}
|
||||
s.names = ['a.png', 'b.png'];
|
||||
n.inputs.push({name: 'folder', link: 42});
|
||||
for (let i = 4; i <= 8; i++) {
|
||||
assert.equal(value(n, 'position'), i);
|
||||
position.afterQueued({isPartialExecution: false});
|
||||
}
|
||||
position.afterQueued({isPartialExecution: true});
|
||||
assert.equal(value(n, 'position'), 9);
|
||||
n.inputs.push({name: 'position', link: 43});
|
||||
position.afterQueued({isPartialExecution: false});
|
||||
assert.equal(value(n, 'position'), 9);
|
||||
});
|
||||
}
|
||||
|
||||
test('removing a node stops loose uploads and completion work', async () => {
|
||||
const {r, newNode, pending, messages, ok} = harness();
|
||||
const n = newNode(), s = n.__ausbossImageFolder;
|
||||
const task = r.addFiles(s, [{name: 'a.png'}, {name: 'b.png'}]);
|
||||
const first = pending.shift();
|
||||
n.onRemoved();
|
||||
first.resolve(ok({name: 'a.png'}));
|
||||
// Let a broken implementation finish so a failure does not leave a pending test.
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
const extra = pending.splice(0);
|
||||
for (const request of extra) request.resolve(ok({name: 'b.png'}));
|
||||
await task;
|
||||
assert.equal(extra.length, 0);
|
||||
assert.equal(messages.length, 0);
|
||||
});
|
||||
|
||||
test('Browse accepts only the newest navigation response', async () => {
|
||||
const {r, newNode, pending, document, value, ok, flush} = harness();
|
||||
const n = newNode({folder: 'album'});
|
||||
const task = r.browse(n.__ausbossImageFolder);
|
||||
pending.shift().resolve(ok({source: 'input', folder: 'album', folders: ['a', 'b']}));
|
||||
await task;
|
||||
const root = document.body.querySelector('.ausboss-if-browse');
|
||||
const list = root.querySelector('.ausboss-if-browse-list');
|
||||
list.children.find(x => x.textContent === 'a').click();
|
||||
list.children.find(x => x.textContent === 'b').click();
|
||||
const a = pending.shift(), b = pending.shift();
|
||||
b.resolve(ok({source: 'input', folder: 'album/b', folders: []}));
|
||||
await flush();
|
||||
a.resolve(ok({source: 'input', folder: 'album/a', folders: []}));
|
||||
await flush();
|
||||
root.querySelector('.use').click();
|
||||
assert.equal(value(n, 'folder'), 'album/b');
|
||||
});
|
||||
|
||||
test('Browse keeps the returned source through navigation and selection', async () => {
|
||||
const {r, newNode, pending, document, value, ok, flush} = harness();
|
||||
const n = newNode({folder: '/server/output/album'});
|
||||
const task = r.browse(n.__ausbossImageFolder);
|
||||
pending.shift().resolve(ok({source: 'output', folder: 'album', folders: ['child']}));
|
||||
await task;
|
||||
const root = document.body.querySelector('.ausboss-if-browse');
|
||||
root.querySelector('.ausboss-if-browse-list').children.find(x => x.textContent === 'child').click();
|
||||
const request = pending.shift();
|
||||
assert.match(request.url, /source=output/);
|
||||
request.resolve(ok({source: 'output', folder: 'album/child', folders: []}));
|
||||
await flush();
|
||||
root.querySelector('.use').click();
|
||||
assert.equal(value(n, 'source'), 'output');
|
||||
assert.equal(value(n, 'folder'), 'album/child');
|
||||
});
|
||||
|
||||
for (const kind of ['menu', 'browse', 'ask']) {
|
||||
test(`removal closes only the owner's ${kind}, even with a connected grid`, async () => {
|
||||
const {r, newNode, pending, document, window, ok, image} = harness();
|
||||
const a = newNode(), b = newNode(), s = b.__ausbossImageFolder;
|
||||
let task;
|
||||
if (kind === 'menu') r.addMenu(s, s.add);
|
||||
if (kind === 'browse') {
|
||||
task = r.browse(s);
|
||||
pending.shift().resolve(ok({source: 'input', folder: '', folders: []}));
|
||||
await task;
|
||||
}
|
||||
if (kind === 'ask') task = r.askBig(s, 'big', Array.from({length: 301}, () => image('a.png')));
|
||||
const selector = `.ausboss-if-${kind}`;
|
||||
const listenerCount = getEventListeners(window, 'keydown').length;
|
||||
assert.ok(listenerCount > 0);
|
||||
a.onRemoved();
|
||||
assert.ok(document.body.querySelector(selector));
|
||||
assert.equal(getEventListeners(window, 'keydown').length, listenerCount);
|
||||
assert.equal(s.grid.isConnected, true);
|
||||
b.onRemoved();
|
||||
assert.equal(document.body.querySelector(selector), null);
|
||||
assert.equal(getEventListeners(window, 'keydown').length, 0);
|
||||
if (kind === 'ask') assert.equal(await task, false);
|
||||
window.dispatchEvent(new Event('keydown'));
|
||||
});
|
||||
}
|
||||
|
||||
test('completion marks reset for another folder and reject old results', async () => {
|
||||
const {r, newNode, pending, ok} = harness();
|
||||
const n = newNode({folder: 'album-a'}), s = n.__ausbossImageFolder;
|
||||
const note = {count: 1, loaded: [1], names: ['same.png'], context: {source: 'input', folder: 'album-a', subfolders: false}};
|
||||
n.onExecuted({ausboss_image_folder: [note]});
|
||||
assert.equal(s.done.has('same.png'), true);
|
||||
r.write(n, 'folder', 'album-b');
|
||||
assert.equal(s.done.size, 0);
|
||||
assert.equal(s.loadedLine, '');
|
||||
n.onExecuted({ausboss_image_folder: [note]});
|
||||
assert.equal(s.done.size, 0);
|
||||
const task = r.refresh(s);
|
||||
pending.shift().resolve(ok({source: 'input', folder: 'album-b', pictures: [{name: 'same.png'}]}));
|
||||
await task;
|
||||
assert.equal(s.cells.get('same.png').classList.contains('done'), false);
|
||||
n.onExecuted({ausboss_image_folder: [{...note, context: {...note.context, folder: 'album-b'}}]});
|
||||
assert.equal(s.done.has('same.png'), true);
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
|
||||
|
||||
test("the unchanged-crop guarantee excludes resizing", () => {
|
||||
const help = read("js/docs/AUSBOSS_NODES_StitchInpaint.md");
|
||||
const guarantee = help.match(/- With the classic seam, feeding the crop[\s\S]*?(?=\n- )/)[0];
|
||||
assert.match(guarantee, /no resizing|never resized/);
|
||||
});
|
||||
|
||||
test("Max zoom explains the extra allowance for padded reframing", () => {
|
||||
const help = read("js/docs/AUSBOSS_NODES_RealignToSource.md");
|
||||
const control = help.match(/- \*\*Max zoom\*\*:[\s\S]*?(?=\n\n)/)[0];
|
||||
assert.match(control, /padded[\s\S]*extra allowance/);
|
||||
assert.match(control, /Zooming out[\s\S]*stated limit/);
|
||||
});
|
||||
|
||||
test("the technical starting Multiple agrees with the node default", () => {
|
||||
const help = read("js/docs/AUSBOSS_NODES_CropForInpaint.md");
|
||||
const source = read("nodes/node_inpaint_crop_stitch.py");
|
||||
const defaultMultiple = source.match(/"output_multiple"[\s\S]*?"default": (\d+)/)[1];
|
||||
const documented = help.match(/Starting values:[\s\S]*?Multiple `(\d+)`/)[1];
|
||||
assert.equal(documented, defaultMultiple);
|
||||
});
|
||||
@@ -28,6 +28,8 @@ const JS_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "js");
|
||||
// A panel whose DOM is built by a shared module, so a second view can reuse
|
||||
// it, is audited where it is built.
|
||||
const SHARED_PANELS = { mask_by_touch: join("shared", "touch_stage.mjs") };
|
||||
// A panel whose face lives in its own module beside index.js is audited with it.
|
||||
const PANEL_PARTS = { camera_move: [join("camera_move", "face.mjs")] };
|
||||
|
||||
function domWidgetEntries() {
|
||||
const entries = [];
|
||||
@@ -38,7 +40,8 @@ function domWidgetEntries() {
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const source = readFileSync(path, "utf-8");
|
||||
const source = [path, ...(PANEL_PARTS[name] ?? []).map((part) => join(JS_ROOT, part))]
|
||||
.map((file) => readFileSync(file, "utf-8")).join("\n");
|
||||
if (source.includes("addDOMWidget")) entries.push({ name, source });
|
||||
}
|
||||
return entries;
|
||||
@@ -110,6 +113,7 @@ test("a panel that should follow the node's height never declares computeSize",
|
||||
// is this a stage that should grow, or a fixed row?
|
||||
const mustGrow = new Set([
|
||||
"callout",
|
||||
"camera_move",
|
||||
"resolution",
|
||||
"compare",
|
||||
"image_crop_rotate_pad",
|
||||
@@ -397,6 +401,7 @@ test("every panel root class carries border-box and an overflow clip", () => {
|
||||
show_text: ".ausboss-show-text{",
|
||||
step_preview: ".ausboss-steppv{",
|
||||
callout: ".ausboss-callout{",
|
||||
camera_move: ".acm-face{",
|
||||
workflow_note: ".ausboss-note{",
|
||||
seed: ".ausboss-seed{",
|
||||
run_timer: ".ausboss-timer{",
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
import * as preview from "../js/shared/input_preview.mjs";
|
||||
import { saveRequest, savedMessage } from "../js/shared/compare_erase.mjs";
|
||||
|
||||
const read = (file) => readFileSync(new URL(`../${file}`, import.meta.url), "utf8");
|
||||
const COMPARE = "js/compare/index.js";
|
||||
const ERASE = "js/shared/compare_erase_stage.mjs";
|
||||
const PREVIEW = "js/input_preview/index.js";
|
||||
const SAVER = "js/save_image/index.js";
|
||||
|
||||
function part(file, start, end) {
|
||||
const source = read(file);
|
||||
const first = source.indexOf(start);
|
||||
const last = source.indexOf(end, first + start.length);
|
||||
assert.ok(first >= 0 && last > first, `Missing function boundary in ${file}`);
|
||||
return source.slice(first, last);
|
||||
}
|
||||
|
||||
function chainCallback(target, name, callback) {
|
||||
const prior = target[name];
|
||||
target[name] = function (...args) {
|
||||
const result = prior?.apply(this, args);
|
||||
callback.apply(this, args);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
function pendingRequest() {
|
||||
let resolve;
|
||||
const pending = new Promise((done) => { resolve = done; });
|
||||
return {
|
||||
api: { fetchApi: () => pending },
|
||||
reply: (answer, ok = true) => resolve({ ok, status: ok ? 200 : 400, json: async () => answer }),
|
||||
};
|
||||
}
|
||||
|
||||
function eraseContext(request) {
|
||||
const toasts = [];
|
||||
const context = vm.createContext({
|
||||
api: request.api, ALIGN_ROUTE: "/align", SAVE_ROUTE: "/save",
|
||||
LAYERS_PROPERTY: "layers", NAME_PROPERTY: "name", saveRequest, savedMessage,
|
||||
syncTools() {}, topOf: () => "A", showToast: (message) => toasts.push(message),
|
||||
});
|
||||
vm.runInContext(part(ERASE, "async function save(er)", "// The mix as a PNG"), context);
|
||||
return { context, toasts };
|
||||
}
|
||||
|
||||
const pair = (index) => ({ a: { filename: `a${index}.png` }, b: { filename: `b${index}.png` }, pair: `pair${index}` });
|
||||
const aligned = { aligned: true, b: { filename: "aligned-b1.png" }, pair: "aligned1" };
|
||||
|
||||
test("Align ignores a reply after changing pairs or removing its panel", async () => {
|
||||
for (const change of ["pair", "removed", "reload"]) {
|
||||
const request = pendingRequest();
|
||||
const { context, toasts } = eraseContext(request);
|
||||
const abort = new AbortController();
|
||||
const received = [];
|
||||
const er = { refs: pair(1), picturesVersion: 1, signal: abort.signal, onAligned: (refs) => received.push(refs) };
|
||||
const pending = context.align(er);
|
||||
if (change === "pair") er.refs = pair(2);
|
||||
if (change === "removed") abort.abort();
|
||||
if (change === "reload") er.picturesVersion += 1;
|
||||
request.reply(aligned);
|
||||
await pending;
|
||||
assert.equal(received.length, 0, change);
|
||||
assert.equal(toasts.length, 0, change);
|
||||
assert.equal(er.aligning, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("Align keeps the requested A and original pair on a current reply", async () => {
|
||||
const request = pendingRequest();
|
||||
const { context, toasts } = eraseContext(request);
|
||||
const original = pair(1);
|
||||
let received;
|
||||
const er = { refs: original, picturesVersion: 1, signal: new AbortController().signal, onAligned: (refs) => { received = refs; } };
|
||||
const pending = context.align(er);
|
||||
request.reply(aligned);
|
||||
await pending;
|
||||
assert.equal(received.a, original.a);
|
||||
assert.equal(received.b, aligned.b);
|
||||
assert.equal(received.original, original);
|
||||
assert.equal(received.pair, aligned.pair);
|
||||
assert.equal(toasts[0].severity, "success");
|
||||
});
|
||||
|
||||
test("Save ignores UI callbacks after removal, for successful and failed replies", async () => {
|
||||
for (const ok of [true, false]) {
|
||||
const request = pendingRequest();
|
||||
const { context, toasts } = eraseContext(request);
|
||||
const abort = new AbortController();
|
||||
let callbacks = 0;
|
||||
const er = {
|
||||
refs: pair(1), node: { properties: {} }, strokes: [], signal: abort.signal,
|
||||
onSaved() { callbacks += 1; throw new Error("Removed panel"); },
|
||||
};
|
||||
const pending = context.save(er);
|
||||
abort.abort();
|
||||
request.reply({ files: [{ filename: "mix.png" }], error: "save failed" }, ok);
|
||||
await pending;
|
||||
assert.equal(callbacks, 0);
|
||||
assert.equal(toasts.length, 0);
|
||||
assert.equal(er.saving, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("Save still reports success and delivers files while its panel is alive", async () => {
|
||||
const request = pendingRequest();
|
||||
const { context, toasts } = eraseContext(request);
|
||||
const files = [{ filename: "mix.png" }];
|
||||
let received;
|
||||
const er = { refs: pair(1), node: {}, strokes: [], signal: new AbortController().signal, onSaved: (value) => { received = value; } };
|
||||
const pending = context.save(er);
|
||||
request.reply({ files });
|
||||
await pending;
|
||||
assert.equal(received, files);
|
||||
assert.deepEqual(toasts.map((toast) => toast.severity), ["success"]);
|
||||
});
|
||||
|
||||
function previewContext(extra = {}) {
|
||||
const context = vm.createContext({
|
||||
...preview, BRAND: "#00b4aa", chainCallback,
|
||||
api: { addEventListener() {}, apiURL: (url) => url },
|
||||
app: { registerExtension(extension) { this.extension = extension; } },
|
||||
setTimeout, clearTimeout, queueMicrotask, stepRun: () => ({}), ...extra,
|
||||
});
|
||||
const source = read(PREVIEW).replace(/^import\s[\s\S]*?from\s+"[^"]+";\s*/gm, "");
|
||||
vm.runInContext(source, context);
|
||||
return context;
|
||||
}
|
||||
|
||||
test("shared source watchers unsubscribe in either order without retaining callbacks", () => {
|
||||
const refreshed = [];
|
||||
const context = previewContext();
|
||||
context.scheduleRefresh = (state) => refreshed.push(state);
|
||||
let calls = 0;
|
||||
const receiver = {};
|
||||
const original = function (value) { assert.equal(this, receiver); calls += 1; return value; };
|
||||
const widget = { name: "image", callback: original };
|
||||
const source = { widgets: [widget] };
|
||||
for (const order of [[0, 1], [1, 0]]) {
|
||||
for (let cycle = 0; cycle < 100; cycle += 1) {
|
||||
const states = [{ alive: true }, { alive: true }];
|
||||
states.forEach((state) => context.rewatchSource(state, source));
|
||||
refreshed.length = 0;
|
||||
assert.equal(widget.callback.call(receiver, 17), 17);
|
||||
assert.deepEqual(refreshed, states);
|
||||
context.unwatchSource(states[order[0]]);
|
||||
refreshed.length = 0;
|
||||
widget.callback.call(receiver, 18);
|
||||
assert.deepEqual(refreshed, [states[order[1]]]);
|
||||
context.unwatchSource(states[order[1]]);
|
||||
assert.equal(widget.callback, original);
|
||||
}
|
||||
}
|
||||
assert.equal(calls, 400);
|
||||
});
|
||||
|
||||
function media() {
|
||||
return {
|
||||
attributes: new Map(), assigned: [], pauses: 0,
|
||||
getAttribute(name) { return this.attributes.get(name) ?? null; },
|
||||
removeAttribute(name) { this.attributes.delete(name); },
|
||||
set src(url) { this.attributes.set("src", url); this.assigned.push(url); },
|
||||
pause() { this.pauses += 1; }, load() {},
|
||||
};
|
||||
}
|
||||
const classes = () => ({ remove() {}, toggle() {} });
|
||||
|
||||
test("disabled previews clear images and videos and refresh never fetches them", () => {
|
||||
const context = previewContext({
|
||||
keepIndex: () => 0, growToNodeFloor() {},
|
||||
app: { nodeOutputs: { 7: { images: [{ filename: "saved.png", type: "output" }] } }, registerExtension() {} },
|
||||
});
|
||||
const graph = { getNodeById() { return null; } };
|
||||
graph.rootGraph = graph;
|
||||
const state = {
|
||||
alive: true, node: { id: 7, graph, inputs: [], imgs: [] }, inputName: "images", index: 0,
|
||||
previewWidget: { value: false }, root: { classList: classes() }, stage: { classList: classes() },
|
||||
img: media(), video: media(), stepper: { root: { hidden: true }, set() {} }, hint: {},
|
||||
};
|
||||
state.img.src = "old.png";
|
||||
state.video.src = "old.mp4";
|
||||
context.syncPreviewMode(state, false);
|
||||
assert.equal(state.img.getAttribute("src"), null);
|
||||
assert.equal(state.video.getAttribute("src"), null);
|
||||
context.refresh(state);
|
||||
assert.equal(state.img.getAttribute("src"), null);
|
||||
assert.equal(state.video.getAttribute("src"), null);
|
||||
assert.equal(state.img.assigned.length, 1);
|
||||
assert.equal(state.video.assigned.length, 1);
|
||||
assert.ok(state.video.pauses > 0);
|
||||
state.previewWidget.value = true;
|
||||
context.refresh(state);
|
||||
assert.match(state.img.getAttribute("src"), /saved\.png/);
|
||||
});
|
||||
|
||||
test("deferred configuration cannot recreate removed or replaced panels", () => {
|
||||
for (const kind of ["compare", "preview"]) {
|
||||
for (const replace of [false, true]) {
|
||||
const queued = [];
|
||||
let rebuilt = 0;
|
||||
const context = vm.createContext({
|
||||
app: { registerExtension(extension) { this.extension = extension; } },
|
||||
chainCallback, queueMicrotask: (fn) => queued.push(fn), clearTimeout,
|
||||
hideInputsInDef() {}, livePanels: new Set(), unwatchSource() {}, clearMedia() {},
|
||||
buildPanel() { rebuilt += 1; return { eraser: {}, alive: true }; },
|
||||
syncErase() {}, syncAdvanced() {}, scheduleRefresh() {}, getMode: () => "slide", updateModeButtons() {},
|
||||
});
|
||||
const file = kind === "compare" ? COMPARE : PREVIEW;
|
||||
vm.runInContext('const NODE_NAME="Compare"; const REST_SETTING="test"; const NODE_CONFIG={Preview:{}}; const INPUT_SIDE=1;'
|
||||
+ read(file).slice(read(file).indexOf("app.registerExtension({")), context);
|
||||
function Node() {}
|
||||
context.app.extension.beforeRegisterNodeDef(Node, { name: kind === "compare" ? "Compare" : "Preview" });
|
||||
const node = new Node();
|
||||
const key = kind === "compare" ? "__ausbossCompare" : "__ausbossInputPreview";
|
||||
const abort = new AbortController();
|
||||
const state = { abort, alive: true };
|
||||
node[key] = state;
|
||||
node.onConfigure();
|
||||
node.onRemoved();
|
||||
const replacement = replace ? { abort: new AbortController(), alive: true } : null;
|
||||
node[key] = replacement;
|
||||
queued.forEach((fn) => fn());
|
||||
assert.equal(rebuilt, 0, kind);
|
||||
assert.equal(node[key], replacement);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Browse invalidates the old saved path when changing destination", async () => {
|
||||
const elements = [];
|
||||
function el(tag, className = "", textContent = "") {
|
||||
return {
|
||||
tag, className, textContent, style: {}, children: [], events: {},
|
||||
append(...items) { this.children.push(...items); },
|
||||
addEventListener(name, fn) { this.events[name] = fn; }, remove() {}, contains() { return false; },
|
||||
};
|
||||
}
|
||||
const state = { node: {}, savedPath: "old/shot_00001.png", refresh() {} };
|
||||
let folder = "old";
|
||||
const context = vm.createContext({
|
||||
el, document: { body: { append: (element) => elements.push(element) } },
|
||||
window: { innerWidth: 1000, addEventListener() {} }, AbortController,
|
||||
value: () => folder, setValue: (_node, _name, next) => { folder = next; }, notifyAusbossChange() {},
|
||||
api: { fetchApi: async () => ({ ok: true, json: async () => ({ path: "new", folders: [] }) }) },
|
||||
});
|
||||
vm.runInContext(part(SAVER, "let openPopup = null;", "// ---------- the card"), context);
|
||||
await context.openBrowse(state, { getBoundingClientRect: () => ({ left: 0, bottom: 40 }) });
|
||||
elements[0].children.find((child) => child.className === "use").events.click();
|
||||
assert.equal(folder, "new");
|
||||
assert.equal(state.savedPath, null);
|
||||
});
|
||||
|
||||
test("an execution with no saved file clears the old path", () => {
|
||||
const state = { savedPath: "old.png", refreshes: 0, refresh() { this.refreshes += 1; } };
|
||||
const node = {};
|
||||
const context = vm.createContext({ node, state, chainCallback, isForeignRun: () => false });
|
||||
vm.runInContext(part(SAVER, 'chainCallback(node, "onExecuted",', 'chainCallback(node, "onRemoved",'), context);
|
||||
node.onExecuted({ ausboss_saved_path: [] });
|
||||
assert.equal(state.savedPath, null);
|
||||
assert.equal(state.refreshes, 1);
|
||||
node.onExecuted({ ausboss_saved_path: ["new.png"] });
|
||||
assert.equal(state.savedPath, "new.png");
|
||||
node.onExecuted({});
|
||||
assert.equal(state.savedPath, null);
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Offline tests for Camera Move: keys, pose, depth packing and the warp."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
|
||||
del sys.modules["nodes"]
|
||||
|
||||
from nodes._camera_move_helpers import (
|
||||
DEFAULT_CAMERA,
|
||||
LIMITS,
|
||||
camera_at,
|
||||
default_camera_text,
|
||||
frame_moments,
|
||||
pack_depth,
|
||||
parse_camera,
|
||||
past_lora_range,
|
||||
pose,
|
||||
range_report,
|
||||
rotation,
|
||||
scene_frame_indices,
|
||||
scene_size,
|
||||
unpack_depth,
|
||||
)
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
import torch
|
||||
except ImportError: # pragma: no cover - the warp tests need ComfyUI's Python
|
||||
np = torch = None
|
||||
|
||||
|
||||
def _apply(R, v):
|
||||
return [sum(R[r][c] * v[c] for c in range(3)) for r in range(3)]
|
||||
|
||||
|
||||
def _apply_t(R, v):
|
||||
return [sum(R[c][r] * v[c] for c in range(3)) for r in range(3)]
|
||||
|
||||
|
||||
class ParseCameraTests(unittest.TestCase):
|
||||
def test_default_text_reads_back_as_the_default(self):
|
||||
parsed = parse_camera(default_camera_text())
|
||||
self.assertFalse(parsed["fallback"])
|
||||
self.assertEqual([k["t"] for k in parsed["keys"]], [0.0, 1.0])
|
||||
self.assertEqual(parsed["keys"][1]["az"], DEFAULT_CAMERA["keys"][1]["az"])
|
||||
self.assertIsNone(parsed["pivot"])
|
||||
|
||||
def test_unreadable_text_falls_back_to_the_default_move(self):
|
||||
for text in ("", "not json", "[]", '{"keys": []}', '{"keys": "x"}', None, 12):
|
||||
parsed = parse_camera(text)
|
||||
self.assertTrue(parsed["fallback"], text)
|
||||
self.assertEqual(len(parsed["keys"]), 2)
|
||||
|
||||
def test_keys_are_clamped_sorted_and_merged(self):
|
||||
text = json.dumps({"keys": [
|
||||
{"t": 0.9, "az": 500, "el": -99, "back": 9},
|
||||
{"t": 0.1, "az": "12", "px": -5},
|
||||
{"t": 0.905, "az": 20},
|
||||
{"t": 7, "el": float("nan")},
|
||||
]})
|
||||
keys = parse_camera(text)["keys"]
|
||||
self.assertEqual([round(k["t"], 3) for k in keys], [0.1, 0.905, 1.0])
|
||||
self.assertEqual(keys[0]["az"], 12.0)
|
||||
self.assertEqual(keys[0]["px"], LIMITS["px"][0])
|
||||
# The later key at the same moment wins.
|
||||
self.assertEqual(keys[1]["az"], 20.0)
|
||||
self.assertEqual(keys[2]["el"], 0.0)
|
||||
for key in keys:
|
||||
for name, (low, high) in LIMITS.items():
|
||||
self.assertTrue(low <= key[name] <= high)
|
||||
|
||||
def test_pivot_is_kept_inside_the_picture(self):
|
||||
parsed = parse_camera({"keys": [{"t": 0}], "pivot": {"u": 1.4, "v": -1, "t": 0.5}})
|
||||
self.assertEqual(parsed["pivot"], {"u": 1.0, "v": 0.0, "t": 0.5})
|
||||
self.assertIsNone(parse_camera({"keys": [{"t": 0}], "pivot": {"u": 0.2}})["pivot"])
|
||||
|
||||
|
||||
class CameraAtTests(unittest.TestCase):
|
||||
keys = [{"t": 0.0, "az": 0, "el": 0, "back": 0, "px": 0, "py": 0},
|
||||
{"t": 0.5, "az": 40, "el": 10, "back": 1, "px": 0, "py": 0},
|
||||
{"t": 1.0, "az": 40, "el": 10, "back": 1, "px": 0.5, "py": 0}]
|
||||
|
||||
def test_holds_before_the_first_and_after_the_last_key(self):
|
||||
self.assertEqual(camera_at(self.keys, -1)["az"], 0)
|
||||
self.assertEqual(camera_at(self.keys, 2)["px"], 0.5)
|
||||
|
||||
def test_eases_between_keys(self):
|
||||
self.assertAlmostEqual(camera_at(self.keys, 0.25)["az"], 20.0)
|
||||
self.assertAlmostEqual(camera_at(self.keys, 0.125)["az"], 40 * (0.25 ** 2 * 2.5))
|
||||
self.assertEqual(camera_at(self.keys, 0.5)["az"], 40)
|
||||
|
||||
def test_one_key_holds_still(self):
|
||||
self.assertEqual(camera_at(self.keys[1:2], 0.0)["back"], 1)
|
||||
|
||||
def test_keeps_moving_through_a_key(self):
|
||||
keys = parse_camera({"keys": [{"t": 0}, {"t": 0.5, "az": 20}, {"t": 1, "az": 40}]})["keys"]
|
||||
speed = (camera_at(keys, 0.51)["az"] - camera_at(keys, 0.49)["az"]) / 0.02
|
||||
self.assertGreater(speed, 35) # the steady speed is 40 a clip; easing at each key gave about 0
|
||||
self.assertAlmostEqual(camera_at(keys, 0.5)["az"], 20)
|
||||
|
||||
def test_never_runs_past_a_key(self):
|
||||
keys = parse_camera({"keys": [{"t": 0}, {"t": 0.3, "az": 30}, {"t": 0.5, "az": 30}, {"t": 1, "az": -20}]})["keys"]
|
||||
values = [camera_at(keys, i / 200)["az"] for i in range(201)]
|
||||
self.assertLessEqual(max(values), 30 + 1e-9)
|
||||
self.assertGreaterEqual(min(values), -20 - 1e-9)
|
||||
self.assertTrue(all(abs(camera_at(keys, t)["az"] - 30) < 1e-9 for t in (0.3, 0.4, 0.5))) # two equal keys hold still
|
||||
|
||||
def test_matches_the_face(self):
|
||||
keys = parse_camera({"keys": [{"t": 0}, {"t": 0.3, "az": 12}, {"t": 0.6, "az": 25, "el": 5},
|
||||
{"t": 1, "az": 40, "el": 5, "back": 0.4}]})["keys"]
|
||||
self.assertAlmostEqual(camera_at(keys, 0.45)["az"], 18.547068965517244) # tests/camera_move.test.mjs
|
||||
self.assertAlmostEqual(camera_at(keys, 0.8)["az"], 34.51724137931035)
|
||||
|
||||
def test_frame_moments_run_from_start_to_end(self):
|
||||
self.assertEqual(frame_moments(1), [0.0])
|
||||
self.assertEqual(frame_moments(3), [0.0, 0.5, 1.0])
|
||||
|
||||
|
||||
class PoseTests(unittest.TestCase):
|
||||
pivot = [0.3, -0.2, 2.0]
|
||||
|
||||
def test_rotation_is_a_rotation(self):
|
||||
R = rotation(33, -17)
|
||||
for r in range(3):
|
||||
for c in range(3):
|
||||
dot = sum(R[k][r] * R[k][c] for k in range(3))
|
||||
self.assertAlmostEqual(dot, 1.0 if r == c else 0.0)
|
||||
|
||||
def test_turning_right_moves_the_camera_right(self):
|
||||
_, C = pose({"az": 30, "el": 0, "back": 0, "px": 0, "py": 0}, [0, 0, 2])
|
||||
self.assertGreater(C[0], 0)
|
||||
_, C = pose({"az": 0, "el": 20, "back": 0, "px": 0, "py": 0}, [0, 0, 2])
|
||||
self.assertLess(C[1], 0) # y is down: rising is negative y
|
||||
|
||||
def test_home_is_the_original_camera(self):
|
||||
R, C = pose({"az": 0, "el": 0, "back": 0, "px": 0, "py": 0}, self.pivot)
|
||||
self.assertEqual([[round(x, 9) for x in row] for row in R], [[1, 0, 0], [0, 1, 0], [0, 0, 1]])
|
||||
for x in C:
|
||||
self.assertAlmostEqual(x, 0.0)
|
||||
|
||||
def test_the_pivot_stays_where_it_was_on_screen(self):
|
||||
d = math.sqrt(sum(x * x for x in self.pivot))
|
||||
for cam in ({"az": 40, "el": 15, "back": 0.5}, {"az": -70, "el": -25, "back": -0.4}):
|
||||
cam = {**cam, "px": 0, "py": 0}
|
||||
R, C = pose(cam, self.pivot)
|
||||
seen = _apply_t(R, [self.pivot[i] - C[i] for i in range(3)])
|
||||
# Where the original camera saw it, pushed back along the view by back * d.
|
||||
self.assertAlmostEqual(seen[0], self.pivot[0])
|
||||
self.assertAlmostEqual(seen[1], self.pivot[1])
|
||||
self.assertAlmostEqual(seen[2], self.pivot[2] + cam["back"] * d)
|
||||
|
||||
def test_pan_slides_the_camera_in_its_own_frame(self):
|
||||
d = math.sqrt(sum(x * x for x in self.pivot))
|
||||
R, C0 = pose({"az": 25, "el": 5, "back": 0, "px": 0, "py": 0}, self.pivot)
|
||||
_, C1 = pose({"az": 25, "el": 5, "back": 0, "px": 0.5, "py": 0}, self.pivot)
|
||||
moved = _apply_t(R, [C1[i] - C0[i] for i in range(3)])
|
||||
self.assertAlmostEqual(moved[0], 0.5 * d)
|
||||
self.assertAlmostEqual(moved[1], 0.0)
|
||||
self.assertAlmostEqual(moved[2], 0.0)
|
||||
|
||||
def test_lora_range(self):
|
||||
self.assertFalse(past_lora_range({"az": 45, "el": 30}))
|
||||
self.assertTrue(past_lora_range({"az": -46, "el": 0}))
|
||||
self.assertTrue(past_lora_range({"az": 0, "el": -21}))
|
||||
self.assertEqual(range_report(CameraAtTests.keys), "")
|
||||
report = range_report([{"t": 0.5, "az": 60, "el": 0}])
|
||||
self.assertIn("past the LoRA's range", report)
|
||||
report.encode("ascii")
|
||||
|
||||
|
||||
class SceneTests(unittest.TestCase):
|
||||
def test_frames_for_the_face(self):
|
||||
self.assertEqual(scene_frame_indices(0), [])
|
||||
self.assertEqual(scene_frame_indices(5), [0, 1, 2, 3, 4])
|
||||
picks = scene_frame_indices(121)
|
||||
self.assertEqual((picks[0], picks[-1], len(picks)), (0, 120, 61))
|
||||
self.assertEqual(picks[:3], [0, 2, 4])
|
||||
|
||||
def test_scene_size_keeps_the_shape(self):
|
||||
self.assertEqual(scene_size(832, 480), (416, 240))
|
||||
self.assertEqual(scene_size(300, 200), (300, 200))
|
||||
|
||||
@unittest.skipIf(np is None, "needs numpy")
|
||||
def test_depth_packing_round_trips(self):
|
||||
rng = np.random.default_rng(1)
|
||||
z = rng.uniform(0.4, 60.0, (24, 32))
|
||||
valid = rng.uniform(size=z.shape) > 0.2
|
||||
packed = pack_depth(z, valid, float(z[valid].min()), float(z[valid].max()))
|
||||
self.assertEqual(packed.dtype, np.uint8)
|
||||
back, ok = unpack_depth(packed, float(z[valid].min()), float(z[valid].max()), 999.0)
|
||||
self.assertTrue((ok == valid).all())
|
||||
self.assertLess(float(np.abs(back[valid] / z[valid] - 1).max()), 1e-4)
|
||||
self.assertTrue((back[~valid] == 999.0).all())
|
||||
|
||||
|
||||
def _wall_and_box(frames=3, H=48, W=64):
|
||||
"""A wall 4 m away with a box 2 m away in the middle, as MoGe would hand it over."""
|
||||
f = 60.0
|
||||
K = torch.tensor([[f, 0, W / 2], [0, f, H / 2], [0, 0, 1.0]])
|
||||
vv, uu = torch.meshgrid(torch.arange(H) + 0.5, torch.arange(W) + 0.5, indexing="ij")
|
||||
z = torch.full((H, W), 4.0)
|
||||
z[H // 3: 2 * H // 3, W // 3: 2 * W // 3] = 2.0
|
||||
pts = torch.stack([(uu - W / 2) / f * z, (vv - H / 2) / f * z, z], -1)
|
||||
image = torch.zeros(H, W, 3)
|
||||
image[..., 0] = uu / W
|
||||
image[..., 1] = vv / H
|
||||
image[..., 2] = 0.2
|
||||
return {
|
||||
"points": pts[None].expand(frames, H, W, 3).clone(),
|
||||
"intrinsics": K[None].expand(frames, 3, 3).clone(),
|
||||
"image": image[None].expand(frames, H, W, 3).clone(),
|
||||
"mask": torch.ones(frames, H, W, dtype=torch.bool),
|
||||
}
|
||||
|
||||
|
||||
@unittest.skipIf(torch is None, "needs torch")
|
||||
class WarpTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from nodes._camera_move_helpers import auto_pivot, prepare_geometry, resolve_pivot, warp_frame
|
||||
|
||||
self.prepare, self.auto, self.resolve, self.warp = prepare_geometry, auto_pivot, resolve_pivot, warp_frame
|
||||
self.scene = prepare_geometry(_wall_and_box())
|
||||
|
||||
def test_home_gives_back_the_clip(self):
|
||||
home = {"az": 0, "el": 0, "back": 0, "px": 0, "py": 0}
|
||||
frame, hole = self.warp(self.scene, 0, home, self.auto(self.scene), torch.device("cpu"))
|
||||
self.assertEqual(int(hole.sum()), 0)
|
||||
self.assertLess(float((frame - self.scene["image"][0]).abs().max()), 1e-4)
|
||||
|
||||
def test_a_turn_opens_pink_holes_beside_the_box(self):
|
||||
cam = {"az": 25, "el": 0, "back": 0, "px": 0, "py": 0}
|
||||
frame, hole = self.warp(self.scene, 0, cam, self.auto(self.scene), torch.device("cpu"))
|
||||
self.assertGreater(int(hole.sum()), 10)
|
||||
pink = frame[hole]
|
||||
self.assertTrue(torch.allclose(pink, torch.tensor([1.0, 0.0, 1.0]).expand_as(pink)))
|
||||
|
||||
def test_auto_pivot_is_the_box(self):
|
||||
self.assertAlmostEqual(self.auto(self.scene)[2], 2.0, places=4)
|
||||
|
||||
def test_a_tapped_pivot_is_the_spot_tapped(self):
|
||||
on_wall = self.resolve(self.scene, {"u": 0.05, "v": 0.05, "t": 0.0})
|
||||
self.assertAlmostEqual(on_wall[2], 4.0, places=4)
|
||||
self.assertLess(on_wall[0], 0)
|
||||
self.assertLess(on_wall[1], 0)
|
||||
|
||||
def test_one_steady_lens_and_depth_for_the_clip(self):
|
||||
geometry = _wall_and_box(frames=12)
|
||||
wobble = torch.tensor([1.0, 1.06, 0.95, 1.04, 0.97, 1.05, 0.94, 1.03, 0.96, 1.05, 0.95, 1.0])
|
||||
geometry["points"] = geometry["points"] * wobble[:, None, None, None]
|
||||
geometry["intrinsics"] = geometry["intrinsics"].clone()
|
||||
geometry["intrinsics"][:, 0, 0] *= wobble
|
||||
scene = self.prepare(geometry)
|
||||
self.assertTrue(torch.equal(scene["K"][0], scene["K"][7]))
|
||||
meds = torch.stack([scene["z"][i][scene["valid"][i]].median() for i in range(12)])
|
||||
jumps = (meds[1:] / meds[:-1] - 1).abs()
|
||||
self.assertLess(float(jumps.max()), 0.02) # the clip wobbles up to 11% frame to frame
|
||||
|
||||
def test_calms_still_depth_and_leaves_a_mover_alone(self):
|
||||
from nodes._camera_move_helpers import steady_depth
|
||||
|
||||
frames, H, W = 9, 8, 8
|
||||
z = torch.full((frames, H, W), 4.0)
|
||||
z[:, :, :4] *= torch.tensor([1.0, 1.08, 0.93, 1.05, 0.95, 1.07, 0.94, 1.04, 1.0])[:, None, None] # wall flicker
|
||||
image = torch.full((frames, H, W, 3), 0.5)
|
||||
image[4, :, 6:] = torch.tensor([0.9, 0.2, 0.1]) # someone passes on the right in frame 4
|
||||
z[4, :, 6:] = 2.0
|
||||
out = steady_depth(z, torch.ones(frames, H, W, dtype=torch.bool), image)
|
||||
self.assertLess(float((out[4, :, :4] / out[3, :, :4] - 1).abs().max()), 0.03) # was 12% between frames
|
||||
self.assertTrue(torch.allclose(out[4, :, 6:], torch.tensor(2.0)))
|
||||
|
||||
def test_needs_the_lens(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.prepare({"image": torch.zeros(1, 4, 4, 3), "depth": torch.ones(1, 4, 4)})
|
||||
|
||||
|
||||
@unittest.skipIf(torch is None, "needs torch")
|
||||
class NodeTests(unittest.TestCase):
|
||||
def test_run_returns_guide_holes_and_the_scene(self):
|
||||
temp = tempfile.mkdtemp()
|
||||
stub = types.ModuleType("folder_paths")
|
||||
stub.get_temp_directory = lambda: temp
|
||||
|
||||
def get_save_image_path(prefix, folder, w, h):
|
||||
return folder, prefix, 1, "", prefix
|
||||
|
||||
stub.get_save_image_path = get_save_image_path
|
||||
sys.modules["folder_paths"] = stub
|
||||
import nodes.node_camera_move as node_module
|
||||
from nodes.node_camera_move import UI_KEY, AusBossCameraMove
|
||||
|
||||
node_module._device = lambda: torch.device("cpu")
|
||||
|
||||
out = AusBossCameraMove().run(_wall_and_box(frames=4), default_camera_text())
|
||||
guide, holes = out["result"]
|
||||
self.assertEqual(tuple(guide.shape), (4, 48, 64, 3))
|
||||
self.assertEqual(tuple(holes.shape), (4, 48, 64))
|
||||
self.assertEqual(float(holes[0].sum()), 0.0) # the first key is your own shot
|
||||
self.assertGreater(float(holes[-1].sum()), 0.0)
|
||||
info = out["ui"][UI_KEY][0]
|
||||
self.assertEqual((info["w"], info["h"], info["count"]), (64, 48, 4))
|
||||
self.assertEqual(len(info["frames"]), 4)
|
||||
for frame in info["frames"]:
|
||||
self.assertTrue((Path(temp) / frame["color"]["filename"]).is_file())
|
||||
self.assertTrue((Path(temp) / frame["depth"]["filename"]).is_file())
|
||||
json.dumps(info)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,240 +0,0 @@
|
||||
"""Offline checks must reject source and saved graphs that cannot work."""
|
||||
|
||||
import ast
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
import changelog_notes
|
||||
import compat_boot
|
||||
from registry_contract import mapping_problems, module_list
|
||||
from workflow_contract import named_copy_problems, workflow_problems
|
||||
|
||||
|
||||
class SourceTests(unittest.TestCase):
|
||||
def test_module_list_cannot_change_after_its_declaration(self):
|
||||
declaration = 'NODE_MODULES = ["node_a"]\n'
|
||||
self.assertEqual(module_list(declaration + 'for name in NODE_MODULES:\n pass\n'), {"node_a"})
|
||||
for extra in (
|
||||
'NODE_MODULES.clear()\n', 'NODE_MODULES = []\n',
|
||||
'NODE_MODULES[0] = "node_b"\n', 'NODE_MODULES += ["node_b"]\n',
|
||||
'alias = NODE_MODULES\n', 'change(NODE_MODULES)\n',
|
||||
'from other import NODE_MODULES\n', 'del NODE_MODULES\n',
|
||||
):
|
||||
with self.subTest(extra=extra):
|
||||
self.assertIsNone(module_list(declaration + extra))
|
||||
self.assertIsNone(module_list('NODE_MODULES = ["node_a", 7]\n'))
|
||||
|
||||
def test_reserved_mapping_names_cannot_be_rebound(self):
|
||||
valid = ('class Node: pass\nNODE_CLASS_MAPPINGS = {"A": Node}\n'
|
||||
'NODE_DISPLAY_NAME_MAPPINGS = {"A": "A"}\n')
|
||||
for binding in (
|
||||
'from other import NODE_CLASS_MAPPINGS',
|
||||
'from other import value as NODE_CLASS_MAPPINGS',
|
||||
'import other as NODE_CLASS_MAPPINGS',
|
||||
'def NODE_CLASS_MAPPINGS(): pass',
|
||||
'class NODE_CLASS_MAPPINGS: pass',
|
||||
'try:\n pass\nexcept Exception as NODE_CLASS_MAPPINGS:\n pass',
|
||||
'match value:\n case {"key": NODE_CLASS_MAPPINGS}: pass',
|
||||
):
|
||||
with self.subTest(binding=binding):
|
||||
self.assertTrue(mapping_problems(valid + binding + '\n', "sample.py"))
|
||||
|
||||
|
||||
class WorkflowTests(unittest.TestCase):
|
||||
def klein(self):
|
||||
return json.loads((ROOT / "example_workflows" / "Klein 9B Outpaint (AusBoss).json").read_text())
|
||||
|
||||
def test_subgraph_links_widgets_and_local_counters_are_checked(self):
|
||||
data = self.klein()
|
||||
graph = data["definitions"]["subgraphs"][0]
|
||||
graph["links"][0]["target_id"] = 999999
|
||||
self.assertIn("missing node", " ".join(workflow_problems(data)))
|
||||
data = self.klein()
|
||||
graph = data["definitions"]["subgraphs"][0]
|
||||
graph["nodes"][0]["widgets_values_named"]["upscale_method"] = "wrong"
|
||||
self.assertIn("stale named", " ".join(workflow_problems(data)))
|
||||
for counter in ("lastNodeId", "lastLinkId"):
|
||||
data = self.klein()
|
||||
data["definitions"]["subgraphs"][0]["state"][counter] = 0
|
||||
self.assertIn("behind the graph", " ".join(workflow_problems(data)))
|
||||
|
||||
def test_subgraph_boundary_links_are_checked_in_both_directions(self):
|
||||
data = self.klein()
|
||||
self.assertEqual(workflow_problems(data), [])
|
||||
graph = data["definitions"]["subgraphs"][0]
|
||||
graph["inputs"][0]["linkIds"] = []
|
||||
self.assertIn("source output", " ".join(workflow_problems(data)))
|
||||
data = self.klein()
|
||||
graph = data["definitions"]["subgraphs"][0]
|
||||
graph["outputs"][0]["linkIds"] = [999999]
|
||||
self.assertIn("stale link", " ".join(workflow_problems(data)))
|
||||
|
||||
def test_named_values_follow_widget_slots_instead_of_object_order(self):
|
||||
node = {"id": 1, "type": "AUSBOSS_NODES_Resolution",
|
||||
"widgets_values": [640, 480, "16ch", 1],
|
||||
"widgets_values_named": {"height": 640, "width": 480, "latent": "16ch", "batch_size": 1}}
|
||||
self.assertTrue(named_copy_problems(node))
|
||||
node["widgets_values_named"].update(height=480, width=640)
|
||||
self.assertEqual(named_copy_problems(node), [])
|
||||
node["widgets_values"].append("unmatched")
|
||||
self.assertTrue(named_copy_problems(node))
|
||||
node["widgets_values"] = [640, 480, "16ch"]
|
||||
self.assertTrue(named_copy_problems(node))
|
||||
node["widgets_values"] = [640, 480, "16ch", 1]
|
||||
del node["widgets_values_named"]["width"]
|
||||
self.assertTrue(named_copy_problems(node))
|
||||
|
||||
def test_frontend_seed_control_has_its_own_slot(self):
|
||||
node = {"id": 1, "type": "AUSBOSS_NODES_Seed", "widgets_values": [7, "fixed", ""],
|
||||
"widgets_values_named": {"control_after_generate": "fixed", "seed": 7, "ausboss_seed_panel": ""}}
|
||||
self.assertEqual(named_copy_problems(node), [])
|
||||
node["widgets_values"] = [7]
|
||||
self.assertTrue(named_copy_problems(node))
|
||||
|
||||
def test_dict_values_report_unmatched_real_entries(self):
|
||||
node = {"id": 1, "widgets_values": {"width": 640}, "widgets_values_named": {"width": 640, "height": 480}}
|
||||
self.assertTrue(named_copy_problems(node))
|
||||
node["widgets_values_named"] = {}
|
||||
self.assertTrue(named_copy_problems(node))
|
||||
|
||||
def test_download_metadata_must_name_the_selected_url_file(self):
|
||||
data = self.klein()
|
||||
node = next(n for n in data["nodes"] if n.get("properties", {}).get("models"))
|
||||
model = node["properties"]["models"][0]
|
||||
original = copy.deepcopy(model)
|
||||
for field in ("name", "url", "directory"):
|
||||
for value in (None, "", 7):
|
||||
model.clear()
|
||||
model.update(original)
|
||||
model[field] = value
|
||||
self.assertTrue(workflow_problems(data), (field, value))
|
||||
model.clear()
|
||||
model.update(original, url="https://example.invalid/wrong.safetensors")
|
||||
self.assertIn("URL", " ".join(workflow_problems(data)))
|
||||
model["url"] = "https://example.invalid/" + original["name"].replace("-", "%2D") + "?download=1"
|
||||
self.assertEqual(workflow_problems(data), [])
|
||||
|
||||
|
||||
class CompatibilityTests(unittest.TestCase):
|
||||
def problems(self, data, offered):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
Path(folder, "sample.json").write_text(json.dumps(data))
|
||||
return compat_boot.example_problems(Path(folder), offered)
|
||||
|
||||
def test_only_defined_subgraph_types_are_skipped(self):
|
||||
kind = "12345678-1234-4234-8234-123456789abc"
|
||||
data = {"nodes": [{"id": 1, "type": kind}]}
|
||||
self.assertTrue(self.problems(data, {}))
|
||||
data["definitions"] = {"subgraphs": [{"id": kind, "nodes": [{"id": 2, "type": "Missing"}]}]}
|
||||
self.assertIn("Missing", " ".join(self.problems(data, {})))
|
||||
self.assertEqual(self.problems(data, {"Missing": {}}), [])
|
||||
|
||||
def test_required_socket_needs_a_link_even_in_older_saves(self):
|
||||
offered = {"NeedsImage": {"input": {"required": {"image": ["IMAGE", {"forceInput": True}]}}}}
|
||||
node = {"id": 1, "type": "NeedsImage", "inputs": [{"name": "image", "link": None}],
|
||||
"widgets_values_named": {"image": None}}
|
||||
self.assertTrue(self.problems({"nodes": [node]}, offered))
|
||||
node["inputs"][0]["link"] = 1
|
||||
self.assertEqual(self.problems({"nodes": [node]}, offered), [])
|
||||
node["inputs"][0]["link"] = None
|
||||
del node["widgets_values_named"]
|
||||
node["widgets_values"] = ["an unrelated legacy value"]
|
||||
self.assertTrue(self.problems({"nodes": [node]}, offered))
|
||||
|
||||
def test_serialized_false_and_zero_are_usable_widget_values(self):
|
||||
offered = {"Options": {"input": {"required": {"count": ["INT", {}], "enabled": ["BOOLEAN", {}]}}}}
|
||||
node = {"id": 1, "type": "Options", "widgets_values_named": {"count": 0, "enabled": False}}
|
||||
self.assertEqual(self.problems({"nodes": [node]}, offered), [])
|
||||
|
||||
def test_newer_combo_format_counts_as_a_widget(self):
|
||||
offered = {"Cache": {"input": {"required": {"device": ["COMBO", {"options": ["auto", "cpu"]}]}}}}
|
||||
node = {"id": 1, "type": "Cache", "widgets_values": ["auto"], "widgets_values_named": {"device": "auto"}}
|
||||
self.assertEqual(self.problems({"nodes": [node]}, offered), [])
|
||||
|
||||
|
||||
class CommandTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
for folder in ("scripts", "nodes", "js", "example_workflows", "locales", "assets"):
|
||||
shutil.copytree(ROOT / folder, self.root / folder, ignore=shutil.ignore_patterns("__pycache__"))
|
||||
for name in ("__init__.py", "pyproject.toml", "README.md", "LICENSE", "CHANGELOG.md", ".comfyignore", "ausboss_presets_example.json"):
|
||||
shutil.copy2(ROOT / name, self.root / name)
|
||||
|
||||
def run_check(self, script):
|
||||
return subprocess.run([sys.executable, str(self.root / "scripts" / script)],
|
||||
capture_output=True, text=True, env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
|
||||
|
||||
def test_network_imports_and_qualified_fetch_are_rejected_without_execution(self):
|
||||
path = self.root / "nodes" / "_network_probe.py"
|
||||
for source in ('from requests import get\n', 'from httpx import Client\n',
|
||||
'import requests as r\n', 'from urllib import request\n',
|
||||
'from http import client\n', 'from aiohttp import ClientSession\n'):
|
||||
path.write_text(source)
|
||||
result = self.run_check("release_preflight.py")
|
||||
self.assertNotEqual(result.returncode, 0, source)
|
||||
self.assertIn("network", result.stdout)
|
||||
path.unlink()
|
||||
path = self.root / "js" / "shared" / "network_probe.mjs"
|
||||
for call in ('fetch ("https://example.invalid")', 'globalThis.fetch("https://example.invalid")',
|
||||
'window . fetch ("https://example.invalid")', 'self.fetch\n("https://example.invalid")'):
|
||||
path.write_text("export function lookup() { return " + call + "; }\n")
|
||||
result = self.run_check("release_preflight.py")
|
||||
self.assertNotEqual(result.returncode, 0, call)
|
||||
self.assertIn("calls fetch", result.stdout)
|
||||
|
||||
def test_unrelated_class_cannot_supply_registered_transform_outputs(self):
|
||||
path = self.root / "nodes" / "node_image_crop_rotate_pad.py"
|
||||
source = path.read_text().replace('RETURN_TYPES = ("IMAGE", "MASK",', 'RETURN_TYPES = ("MASK", "IMAGE",', 1)
|
||||
source = source.replace('RETURN_NAMES = ("image", "mask",', 'RETURN_NAMES = ("mask", "image",', 1)
|
||||
source += '\nclass ExtraMetadata:\n RETURN_TYPES = ("IMAGE", "MASK", "AUSBOSS_STITCHER", "IMAGE")\n RETURN_NAMES = ("image", "mask", "stitcher", "original")\n'
|
||||
path.write_text(source)
|
||||
result = self.run_check("validate_nodes.py")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("first outputs", result.stdout)
|
||||
|
||||
def test_helper_cannot_advertise_an_unregistered_node(self):
|
||||
(self.root / "nodes" / "_extra_helpers.py").write_text(
|
||||
'class Extra: pass\nNODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_Extra": Extra}\n'
|
||||
'NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_Extra": "Extra"}\n')
|
||||
result = self.run_check("validate_nodes.py")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("_extra_helpers.py", result.stdout)
|
||||
|
||||
def test_toml_description_limit_uses_decoded_utf8_bytes(self):
|
||||
path = self.root / "pyproject.toml"
|
||||
lines = path.read_text().splitlines()
|
||||
path.write_text("\n".join('description = "\\\"' + 'x' * 1001 + '"' if line.startswith("description =") else line for line in lines) + "\n")
|
||||
result = self.run_check("release_preflight.py")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("1002 bytes", result.stdout)
|
||||
|
||||
def test_literal_version_uses_project_table_in_all_readers(self):
|
||||
path = self.root / "pyproject.toml"
|
||||
source = path.read_text()
|
||||
version = changelog_notes.pyproject_version()
|
||||
path.write_text(source.replace('version = "' + version + '"', "version = '" + version + "'"))
|
||||
result = self.run_check("release_preflight.py")
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
with patch.object(changelog_notes, "ROOT", self.root):
|
||||
self.assertEqual(changelog_notes.pyproject_version(), version)
|
||||
tree = ast.parse((ROOT / "__init__.py").read_text())
|
||||
body = [n for n in tree.body if isinstance(n, (ast.Import, ast.ImportFrom)) or isinstance(n, ast.FunctionDef) and n.name == "_read_version"]
|
||||
scope = {"__file__": str(self.root / "__init__.py")}
|
||||
exec(compile(ast.Module(body=body, type_ignores=[]), "version_reader", "exec"), scope)
|
||||
self.assertEqual(scope["_read_version"](), version)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -47,28 +47,6 @@ class LabConversionTests(unittest.TestCase):
|
||||
|
||||
|
||||
class MatchColorsTests(unittest.TestCase):
|
||||
def test_mkl_preserves_tiny_variations_in_its_own_reference(self):
|
||||
import itertools
|
||||
|
||||
image = torch.tensor(list(itertools.product([0.4999, 0.5001], repeat=3)))
|
||||
image = image.reshape(1, 2, 4, 3)
|
||||
out = match_colors(image, image.clone(), 1.0, method="mkl")
|
||||
self.assertTrue(torch.allclose(out, image, atol=1e-6, rtol=0))
|
||||
|
||||
def test_mkl_preserves_a_nearly_singular_self_reference(self):
|
||||
ramp = torch.linspace(0.2, 0.8, 64).reshape(1, 8, 8, 1)
|
||||
image = ramp.expand(-1, -1, -1, 3) + rand_image(1, 8, 8, 10) * 1e-4
|
||||
out = match_colors(image, image.clone(), 1.0, method="mkl")
|
||||
self.assertTrue(torch.allclose(out, image, atol=1e-6, rtol=0))
|
||||
|
||||
def test_histogram_preserves_equal_distributions_at_different_sizes(self):
|
||||
source = torch.tensor([0., 0., 0.5, 0.5, 1., 1.]).reshape(1, 1, 6, 1)
|
||||
reference = torch.tensor([0., 0.5, 1.]).reshape(1, 1, 3, 1)
|
||||
source = source.expand(-1, -1, -1, 3)
|
||||
reference = reference.expand(-1, -1, -1, 3)
|
||||
for image, ref in ((source, reference), (reference, source)):
|
||||
self.assertTrue(torch.equal(match_colors(image, ref, 1., method="histogram"), image))
|
||||
|
||||
def test_identity_reference_is_nearly_unchanged(self):
|
||||
image = rand_image(1, 48, 48, seed=2)
|
||||
out = match_colors(image, image, 1.0)
|
||||
|
||||
@@ -53,7 +53,7 @@ class MiddlewareTests(unittest.TestCase):
|
||||
def test_header_is_set_on_pack_modules_only(self):
|
||||
try:
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
except ImportError:
|
||||
self.skipTest("aiohttp is not installed")
|
||||
|
||||
@@ -61,17 +61,20 @@ class MiddlewareTests(unittest.TestCase):
|
||||
return web.Response(text="export const a = 1;", content_type="text/javascript")
|
||||
|
||||
async def run():
|
||||
middleware = make_revalidate_middleware("ausboss-nodes")
|
||||
seen = {}
|
||||
for path in (
|
||||
"/extensions/ausboss-nodes/shared/index.mjs",
|
||||
"/extensions/ausboss-nodes/seed/index.js",
|
||||
"/extensions/other-pack/shared/index.mjs",
|
||||
):
|
||||
request = make_mocked_request("GET", path)
|
||||
response = await middleware(request, modules_get)
|
||||
seen[path] = response.headers.get("Cache-Control")
|
||||
return seen
|
||||
app = web.Application(middlewares=[make_revalidate_middleware("ausboss-nodes")])
|
||||
app.router.add_get("/extensions/ausboss-nodes/shared/index.mjs", modules_get)
|
||||
app.router.add_get("/extensions/ausboss-nodes/seed/index.js", modules_get)
|
||||
app.router.add_get("/extensions/other-pack/shared/index.mjs", modules_get)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
seen = {}
|
||||
for path in (
|
||||
"/extensions/ausboss-nodes/shared/index.mjs",
|
||||
"/extensions/ausboss-nodes/seed/index.js",
|
||||
"/extensions/other-pack/shared/index.mjs",
|
||||
):
|
||||
response = await client.get(path)
|
||||
seen[path] = response.headers.get("Cache-Control")
|
||||
return seen
|
||||
|
||||
seen = asyncio.run(run())
|
||||
self.assertEqual(seen["/extensions/ausboss-nodes/shared/index.mjs"], "no-cache")
|
||||
@@ -166,10 +169,10 @@ class VersionedServingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
try:
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
except ImportError:
|
||||
self.skipTest("aiohttp is not installed")
|
||||
self.web, self.make_request = web, make_mocked_request
|
||||
self.web, self.TestClient, self.TestServer = web, TestClient, TestServer
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.folder.cleanup)
|
||||
base = Path(self.folder.name)
|
||||
@@ -190,13 +193,14 @@ class VersionedServingTests(unittest.TestCase):
|
||||
|
||||
async def run():
|
||||
root = self.js if js_root == "default" else js_root
|
||||
middleware = make_revalidate_middleware("ausboss-nodes", root, token)
|
||||
out = {}
|
||||
for path in paths:
|
||||
request = self.make_request("GET", path, headers=headers)
|
||||
response = await middleware(request, handled)
|
||||
out[path] = (response.status, response.text or "", response.headers)
|
||||
return out
|
||||
app = web.Application(middlewares=[make_revalidate_middleware("ausboss-nodes", root, token)])
|
||||
app.router.add_get("/extensions/{tail:.*}", handled)
|
||||
async with self.TestClient(self.TestServer(app)) as client:
|
||||
out = {}
|
||||
for path in paths:
|
||||
response = await client.get(path, headers=headers)
|
||||
out[path] = (response.status, await response.text(), response.headers)
|
||||
return out
|
||||
|
||||
return asyncio.run(run())
|
||||
|
||||
|
||||
+1
-103
@@ -5,15 +5,11 @@ root is a temp folder, so these run without ComfyUI."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
@@ -26,7 +22,6 @@ from nodes import node_image_folder # noqa: E402
|
||||
from nodes._image_folder_helpers import ( # noqa: E402
|
||||
AT_THE_END,
|
||||
BAD_PICKS,
|
||||
MAX_PICTURES,
|
||||
NO_MASK_SIZE,
|
||||
NONE_LEFT,
|
||||
NONE_PICKED,
|
||||
@@ -83,28 +78,6 @@ class FolderCase(unittest.TestCase):
|
||||
|
||||
|
||||
class ListingTests(FolderCase):
|
||||
def test_the_limit_is_applied_in_the_requested_order(self):
|
||||
base = self.root / "ordered"
|
||||
for name, stamp in [("a10.png", 100), ("a2.png", 200), ("z.png", 300)]:
|
||||
picture(base / name)
|
||||
os.utime(base / name, (stamp, stamp))
|
||||
self.assertEqual(self.names("ordered", sort="name", limit=2), ["a2.png", "a10.png"])
|
||||
self.assertEqual(self.names("ordered", sort="newest first", limit=2), ["z.png", "a2.png"])
|
||||
self.assertEqual(self.names("ordered", sort="oldest first", limit=2), ["a10.png", "a2.png"])
|
||||
|
||||
def test_the_production_limit_keeps_the_newest_file(self):
|
||||
base = self.root / "many"
|
||||
base.mkdir()
|
||||
for number in range(MAX_PICTURES):
|
||||
path = base / f"a{number:04d}.png"
|
||||
path.touch()
|
||||
os.utime(path, (100, 100))
|
||||
picture(base / "z-newest.png")
|
||||
os.utime(base / "z-newest.png", (200, 200))
|
||||
names = self.names("many", sort="newest first")
|
||||
self.assertEqual(len(names), MAX_PICTURES)
|
||||
self.assertEqual(names[0], "z-newest.png")
|
||||
|
||||
def test_a_folder_lists_its_pictures_in_natural_order(self):
|
||||
self.assertEqual(self.names("people"), ["Anna.jpg", "photo_2.png", "photo_10.png"])
|
||||
self.assertEqual(natural_key("photo_2") < natural_key("photo_10"), True)
|
||||
@@ -211,18 +184,6 @@ class PickingTests(FolderCase):
|
||||
|
||||
|
||||
class LoadingTests(FolderCase):
|
||||
def test_rgb_and_grayscale_transparency_produce_masks_and_dark_thumbnails(self):
|
||||
for mode, color, transparency in [("RGB", (255, 0, 0), (255, 0, 0)), ("L", 0, 0)]:
|
||||
with self.subTest(mode=mode):
|
||||
path = self.root / f"transparent-{mode}.png"
|
||||
Image.new(mode, (3, 2), color).save(path, transparency=transparency)
|
||||
_image, mask = load_picture(path)
|
||||
self.assertEqual(tuple(mask.shape), (1, 2, 3))
|
||||
self.assertEqual(float(mask.sum()), 6.0)
|
||||
with Image.open(BytesIO(thumbnail(path))) as small:
|
||||
pixel = small.getpixel((0, 0))
|
||||
self.assertTrue(all(abs(a - b) <= 3 for a, b in zip(pixel, (27, 35, 38))))
|
||||
|
||||
def test_a_picture_loads_as_image_and_the_no_mask_stand_in(self):
|
||||
image, mask = load_picture(self.root / "people" / "photo_2.png")
|
||||
self.assertEqual(tuple(image.shape), (1, 30, 40, 3))
|
||||
@@ -268,18 +229,6 @@ class LoadingTests(FolderCase):
|
||||
|
||||
|
||||
class NodeTests(FolderCase):
|
||||
def test_runs_above_64_report_every_loaded_picture(self):
|
||||
for number in range(65):
|
||||
picture(self.root / "many" / f"{number}.png", size=(2, 2))
|
||||
out = self.run_node(folder="many")
|
||||
note = out["ui"][node_image_folder.UI_KEY][0]
|
||||
self.assertEqual(note["loaded"], out["result"][3])
|
||||
self.assertEqual(len(note["names"]), 65)
|
||||
|
||||
def test_completion_records_identify_the_folder_used_by_the_run(self):
|
||||
note = self.run_node()["ui"][node_image_folder.UI_KEY][0]
|
||||
self.assertEqual(note.get("context"), {"source": "input", "folder": "people", "subfolders": False})
|
||||
|
||||
def test_all_in_one_run_gives_a_list_of_everything(self):
|
||||
out = self.run_node()
|
||||
images, masks, names, numbers, count = out["result"]
|
||||
@@ -297,10 +246,7 @@ class NodeTests(FolderCase):
|
||||
out = self.run_node(run=RUNS[1], position=2)
|
||||
_images, _masks, names, numbers, count = out["result"]
|
||||
self.assertEqual((names, numbers, count), (["photo_2"], [2], 3))
|
||||
self.assertEqual(out["ui"]["ausboss_image_folder"][0], {
|
||||
"count": 3, "loaded": [2], "names": ["photo_2.png"],
|
||||
"context": {"source": "input", "folder": "people", "subfolders": False},
|
||||
})
|
||||
self.assertEqual(out["ui"]["ausboss_image_folder"][0], {"count": 3, "loaded": [2], "names": ["photo_2.png"]})
|
||||
|
||||
def test_one_per_run_stops_after_the_last_or_starts_over(self):
|
||||
with self.assertRaises(RuntimeError) as stopped:
|
||||
@@ -349,53 +295,5 @@ class NodeTests(FolderCase):
|
||||
text.encode("ascii")
|
||||
|
||||
|
||||
class RouteTests(FolderCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.handlers = {}
|
||||
handlers = self.handlers
|
||||
|
||||
class Routes:
|
||||
def get(self, address):
|
||||
def register(fn):
|
||||
handlers[address] = fn
|
||||
return fn
|
||||
return register
|
||||
|
||||
server = SimpleNamespace(PromptServer=SimpleNamespace(instance=SimpleNamespace(routes=Routes())))
|
||||
with patch.dict(sys.modules, {"server": server}):
|
||||
node_image_folder._register_routes()
|
||||
|
||||
def request(self, route, **query):
|
||||
request = SimpleNamespace(rel_url=SimpleNamespace(query=query))
|
||||
return asyncio.run(self.handlers[f"/ausboss/image_folder/{route}"](request))
|
||||
|
||||
def test_route_errors_hide_server_paths(self):
|
||||
(self.root / "loop").symlink_to("loop", target_is_directory=True)
|
||||
for route, query in [("list", {"folder": "loop"}), ("folders", {"folder": "loop"}),
|
||||
("thumb", {"name": "loop/picture.png"})]:
|
||||
with self.subTest(route=route):
|
||||
response = self.request(route, **query)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertNotIn(str(self.root), json.loads(response.text)["error"])
|
||||
|
||||
def test_thumbnail_versions_change_with_subsecond_rewrites_and_size(self):
|
||||
path = self.root / "top.png"
|
||||
os.utime(path, ns=(1_700_000_000_100_000_000,) * 2)
|
||||
def version():
|
||||
response = self.request("list")
|
||||
self.assertEqual(response.status, 200)
|
||||
return json.loads(response.text)["pictures"][0]["v"]
|
||||
first = version()
|
||||
picture(path, color=(0, 0, 255))
|
||||
os.utime(path, ns=(1_700_000_000_800_000_000,) * 2)
|
||||
second = version()
|
||||
self.assertNotEqual(first, second)
|
||||
self.assertIsInstance(second, str)
|
||||
picture(path, size=(80, 90))
|
||||
os.utime(path, ns=(1_700_000_000_800_000_000,) * 2)
|
||||
self.assertNotEqual(second, version())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -2,9 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -124,17 +122,6 @@ class NamingTests(unittest.TestCase):
|
||||
|
||||
|
||||
class EncodeTests(unittest.TestCase):
|
||||
def test_bfloat16_and_single_channel_frames_roundtrip(self):
|
||||
for dtype, channels in ((torch.bfloat16, 3), (torch.float32, 1), (torch.bfloat16, 1)):
|
||||
for format_name, extension in (("png", "png"), ("webp lossless", "webp")):
|
||||
with self.subTest(dtype=dtype, channels=channels, format=format_name), tempfile.TemporaryDirectory() as tmp:
|
||||
frame = torch.full((2, 2, channels), 0.5, dtype=dtype, device="cpu")
|
||||
path = Path(tmp) / f"frame.{extension}"
|
||||
encode_image(path, frame, format_name, None)
|
||||
with Image.open(path) as image:
|
||||
self.assertEqual(image.size, (2, 2))
|
||||
self.assertEqual(image.convert("RGB").getpixel((0, 0)), (127, 127, 127))
|
||||
|
||||
def test_png_roundtrips_and_embeds_metadata(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "one.png"
|
||||
@@ -186,71 +173,6 @@ class NameTokenTests(unittest.TestCase):
|
||||
|
||||
|
||||
class SaveImageNodeTests(unittest.TestCase):
|
||||
def test_counter_save_and_compare_save_keep_both_images(self):
|
||||
from nodes import _compare_helpers as compare
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
picture_a, picture_b = root / "a.png", root / "b.png"
|
||||
Image.new("RGB", (16, 8), "green").save(picture_a)
|
||||
Image.new("RGB", (16, 8), "blue").save(picture_b)
|
||||
entered, proceed, comparing = threading.Event(), threading.Event(), threading.Event()
|
||||
encode = node_save_image.encode_image
|
||||
|
||||
def paused_encode(*args):
|
||||
entered.set()
|
||||
if not proceed.wait(5):
|
||||
raise TimeoutError("Timed out waiting to finish encoding.")
|
||||
return encode(*args)
|
||||
|
||||
def save_compare():
|
||||
comparing.set()
|
||||
return compare.save_mix(picture_a=picture_a, picture_b=picture_b, output_dir=root, name="shot")
|
||||
|
||||
with patch.object(node_save_image, "encode_image", paused_encode), ThreadPoolExecutor(max_workers=2) as pool:
|
||||
first = pool.submit(self.run_node, root, filename_prefix="shot")
|
||||
try:
|
||||
self.assertTrue(entered.wait(5))
|
||||
second = pool.submit(save_compare)
|
||||
self.assertTrue(comparing.wait(5))
|
||||
try:
|
||||
second.result(timeout=0.2)
|
||||
except TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
proceed.set()
|
||||
saved = Path(first.result(timeout=5)["result"][0])
|
||||
mixed = root / second.result(timeout=5)["files"][0]["filename"]
|
||||
self.assertNotEqual(saved, mixed)
|
||||
with Image.open(saved) as image:
|
||||
self.assertEqual(image.getpixel((0, 0)), (0, 0, 0))
|
||||
with Image.open(mixed) as image:
|
||||
self.assertEqual(image.getpixel((0, 0)), (0, 128, 0))
|
||||
|
||||
def test_caption_only_collision_obeys_each_policy(self):
|
||||
for naming in ({"exact_name": "photo"}, {"filename_prefix": "photo", "name_counter": False}):
|
||||
for policy in ("error", "skip", "overwrite"):
|
||||
with self.subTest(**naming, policy=policy), tempfile.TemporaryDirectory() as tmp:
|
||||
caption = Path(tmp) / "photo.txt"
|
||||
caption.write_text("original", encoding="utf-8")
|
||||
if policy == "error":
|
||||
with self.assertRaisesRegex(ValueError, "already exists"):
|
||||
self.run_node(tmp, caption_text="replacement", on_existing=policy, **naming)
|
||||
else:
|
||||
result = self.run_node(tmp, caption_text="replacement", on_existing=policy, **naming)
|
||||
self.assertEqual(bool(result["result"][0]), policy == "overwrite")
|
||||
self.assertEqual(caption.read_text(encoding="utf-8"), "replacement" if policy == "overwrite" else "original")
|
||||
self.assertEqual((Path(tmp) / "photo.png").exists(), policy == "overwrite")
|
||||
|
||||
def test_later_caption_collision_stops_batch_before_any_write(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
caption = Path(tmp) / "shot_002.txt"
|
||||
caption.write_text("original", encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "already exists"):
|
||||
self.run_node(tmp, images=gradient_batch(2, 8, 16), exact_name="shot", caption_text="new", on_existing="error")
|
||||
self.assertEqual([p.name for p in Path(tmp).iterdir()], ["shot_002.txt"])
|
||||
self.assertEqual(caption.read_text(encoding="utf-8"), "original")
|
||||
|
||||
def test_name_tokens_in_the_prefix_and_in_an_exact_name(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = self.run_node(tmp, filename_prefix="sets/%width%x%height%/img_%year%", name_counter=False)
|
||||
@@ -493,12 +415,8 @@ class SaveImageLocalModeTests(SaveImageNodeTests):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self.run_node(tmp, exact_name="photo", caption="old", caption_text="new caption")
|
||||
self.assertEqual((Path(tmp) / "photo.txt").read_text(encoding="utf-8"), "new caption")
|
||||
for index, caption in enumerate(("", " ", "\n")):
|
||||
name = f"blank{index}"
|
||||
self.run_node(tmp, exact_name=name, caption="old", caption_text=caption)
|
||||
self.assertFalse((Path(tmp) / f"{name}.txt").exists())
|
||||
self.run_node(tmp, exact_name="legacy", caption="old", caption_text=None)
|
||||
self.assertEqual((Path(tmp) / "legacy.txt").read_text(encoding="utf-8"), "old")
|
||||
self.run_node(tmp, exact_name="photo2", caption="old", caption_text="")
|
||||
self.assertEqual((Path(tmp) / "photo2.txt").read_text(encoding="utf-8"), "old")
|
||||
|
||||
def test_prefix_validation_rejects_escapes(self):
|
||||
node = node_save_image.AusBossSaveImage
|
||||
|
||||
@@ -198,9 +198,9 @@ class CropContractTests(unittest.TestCase):
|
||||
image = rand_image(1, 40, 40, seed=1)
|
||||
mask = box_mask(40, 40, 10, 20, 10, 20)
|
||||
_, sampling, stitcher = build_crop(image, mask, 2.0, 16, 1)
|
||||
# The feather needs the full picture; only the paste mask is softened.
|
||||
self.assertEqual(stitcher["crop_to_canvas"], (0, 0, 40, 40))
|
||||
self.assertTrue(torch.equal(sampling, mask))
|
||||
# context 2.0 on a 10x10 bbox with multiple 1 -> rect (5, 5, 20, 20)
|
||||
self.assertEqual(stitcher["crop_to_canvas"], (5, 5, 20, 20))
|
||||
self.assertTrue(torch.equal(sampling, mask[:, 5:25, 5:25]))
|
||||
values = torch.unique(sampling)
|
||||
self.assertTrue(all(v in (0.0, 1.0) for v in values.tolist()))
|
||||
# The blend mask is feathered: it must contain intermediate values.
|
||||
@@ -262,20 +262,6 @@ class CropContractTests(unittest.TestCase):
|
||||
|
||||
|
||||
class StitchExactnessTests(unittest.TestCase):
|
||||
def test_the_crop_contains_the_whole_feather(self):
|
||||
image = torch.zeros((1, 128, 128, 3))
|
||||
mask = box_mask(128, 128, 54, 74, 54, 74)
|
||||
expected = stitch_blend_from_mask(mask, 16)
|
||||
for options in ({}, {"target_width": 32, "target_height": 64}):
|
||||
with self.subTest(options=options):
|
||||
cropped, sampling, stitcher = build_crop(
|
||||
image, mask, 1.2, 16, 32, keep_inside=True, **options
|
||||
)
|
||||
out = apply_stitch(stitcher, torch.ones_like(cropped))
|
||||
torch.testing.assert_close(out[..., 0], expected, atol=1e-6, rtol=0)
|
||||
torch.testing.assert_close(stitch_blend_mask(stitcher), expected, atol=1e-6, rtol=0)
|
||||
self.assertTrue(all(v in (0.0, 1.0) for v in sampling.unique().tolist()))
|
||||
|
||||
def test_identity_round_trip_is_bit_exact(self):
|
||||
image = rand_image(1, 64, 96, seed=10)
|
||||
mask = box_mask(64, 96, 24, 40, 40, 56)
|
||||
@@ -767,22 +753,6 @@ class RescaleAlgorithmTests(unittest.TestCase):
|
||||
|
||||
|
||||
class OutpaintExtendTests(unittest.TestCase):
|
||||
def test_mask_shaping_cannot_erase_the_extension_bands(self):
|
||||
image = torch.zeros((1, 32, 32, 3))
|
||||
for side in ("left", "right", "up", "down"):
|
||||
for blur in (0.0, 2.0):
|
||||
with self.subTest(side=side, blur=blur):
|
||||
cropped, sampling, stitcher = build_crop(
|
||||
image, torch.zeros((1, 32, 32)), 1.0, 0, 1,
|
||||
mask_grow=-8, mask_blur=blur, **{f"extend_{side}": 8},
|
||||
)
|
||||
self.assertTrue(torch.equal(sampling, torch.ones_like(sampling)))
|
||||
out = apply_stitch(stitcher, torch.ones_like(cropped))
|
||||
expected = torch.ones_like(stitcher["blend"])
|
||||
x, y = (8 if side == "left" else 0), (8 if side == "up" else 0)
|
||||
expected[:, y : y + 32, x : x + 32] = 0.0
|
||||
self.assertTrue(torch.equal(out[..., 0], expected))
|
||||
|
||||
def test_extension_grows_the_stitched_output(self):
|
||||
image = rand_image(1, 32, 32, seed=36)
|
||||
empty = torch.zeros((1, 32, 32), dtype=torch.float32)
|
||||
@@ -878,20 +848,6 @@ class BlendMaskOutputTests(unittest.TestCase):
|
||||
self.image = rand_image(1, 64, 96, seed=50)
|
||||
self.mask = box_mask(64, 96, 24, 40, 40, 56)
|
||||
|
||||
def test_a_partial_paste_window_limits_the_reported_mask(self):
|
||||
# A previously built stitcher can carry a feather wider than its crop.
|
||||
for original in ((0, 0, 96, 64), (8, 4, 80, 56)):
|
||||
with self.subTest(original=original):
|
||||
stitcher = build_canvas_stitcher(
|
||||
torch.zeros_like(self.image), torch.ones_like(self.mask)
|
||||
)
|
||||
stitcher["crop_to_canvas"] = (24, 16, 32, 32)
|
||||
stitcher["canvas_to_original"] = original
|
||||
out = apply_stitch(stitcher, torch.ones((3, 32, 32, 3)))
|
||||
reported = stitch_blend_mask(stitcher, frames=3)
|
||||
self.assertTrue(torch.equal(reported, out[..., 0]))
|
||||
self.assertTrue(torch.equal(stitcher["blend"], torch.ones_like(self.mask)))
|
||||
|
||||
def test_matches_the_blend_over_the_original_window(self):
|
||||
_c, _s, stitcher = build_crop(self.image, self.mask, 1.5, 8, 8)
|
||||
out_mask = stitch_blend_mask(stitcher)
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
"""CPU probes for reference attention, without importing model runtime code."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import torch
|
||||
from einops import rearrange
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
tree = ast.parse((ROOT / "nodes/_krea2_core.py").read_text())
|
||||
core = types.ModuleType("nodes._krea2_core")
|
||||
core.__dict__.update(torch=torch, F=torch.nn.functional, rearrange=rearrange, hashlib=hashlib)
|
||||
exec(compile(ast.Module(body=[n for n in tree.body if isinstance(n, ast.FunctionDef)], type_ignores=[]),
|
||||
"reference helpers", "exec"), core.__dict__)
|
||||
|
||||
|
||||
class CacheTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
class StubModel:
|
||||
def __init__(self):
|
||||
self.model = types.SimpleNamespace(extra_conds=lambda **kw: {}, extra_conds_shapes=lambda **kw: {})
|
||||
self.dit = types.SimpleNamespace(forward=lambda *a, **kw: "original")
|
||||
self.patches, self.callbacks = {}, {}
|
||||
|
||||
def clone(self): return StubModel()
|
||||
def get_model_object(self, name): return self.dit
|
||||
def add_object_patch(self, name, value): self.patches[name] = value
|
||||
def add_callback(self, name, value): self.callbacks[name] = value
|
||||
|
||||
comfy = types.ModuleType("comfy")
|
||||
comfy.conds = types.ModuleType("comfy.conds")
|
||||
comfy.patcher_extension = types.ModuleType("comfy.patcher_extension")
|
||||
comfy.patcher_extension.CallbacksMP = types.SimpleNamespace(ON_PRE_RUN="pre", ON_CLEANUP="cleanup")
|
||||
self.modules = patch.dict(sys.modules, {"comfy": comfy, "comfy.conds": comfy.conds,
|
||||
"comfy.patcher_extension": comfy.patcher_extension, "nodes._krea2_core": core})
|
||||
self.modules.start()
|
||||
from nodes.node_krea2_model_patch import AusBossKrea2OutpaintModelPatch
|
||||
import nodes
|
||||
self.package = patch.object(nodes, "_krea2_core", core, create=True)
|
||||
self.package.start()
|
||||
self.calls = []
|
||||
|
||||
def precompute(dit, x, t, refs, opts, **kw):
|
||||
result = object()
|
||||
self.calls.append(result)
|
||||
return result
|
||||
|
||||
self.precompute = patch.object(core, "_precompute_ref_kv", precompute)
|
||||
self.compute = patch.object(core, "_forward_with_cached_refs", lambda dit, x, t, c, kv, opts: kv)
|
||||
self.precompute.start()
|
||||
self.compute.start()
|
||||
self.model = AusBossKrea2OutpaintModelPatch().patch(StubModel(), None)[0]
|
||||
self.ref = torch.arange(64, dtype=torch.float32).reshape(1, 16, 2, 2)
|
||||
|
||||
def tearDown(self):
|
||||
self.compute.stop()
|
||||
self.precompute.stop()
|
||||
self.package.stop()
|
||||
self.modules.stop()
|
||||
|
||||
def run_forward(self, size=4, ref=None):
|
||||
return self.model.patches["diffusion_model.forward"](
|
||||
torch.zeros(1, 16, size, size), torch.ones(1), None,
|
||||
ref_latents=[self.ref if ref is None else ref])
|
||||
|
||||
def test_same_reference_and_canvas_reuse_the_cache(self):
|
||||
self.assertIs(self.run_forward(), self.run_forward())
|
||||
self.assertEqual(len(self.calls), 1)
|
||||
|
||||
def test_canvas_size_changes_invalidate_the_cache_at_the_same_sigma(self):
|
||||
self.assertIsNot(self.run_forward(), self.run_forward(size=8))
|
||||
|
||||
def test_reordered_reference_pixels_do_not_collide(self):
|
||||
self.assertIsNot(self.run_forward(), self.run_forward(ref=self.ref.flip(-1)))
|
||||
|
||||
def test_in_place_reference_changes_invalidate_the_cache(self):
|
||||
before = self.run_forward()
|
||||
self.ref.copy_(self.ref.flip(-1))
|
||||
self.assertIsNot(before, self.run_forward())
|
||||
|
||||
def test_inference_reference_changes_invalidate_the_cache(self):
|
||||
with torch.inference_mode():
|
||||
ref = self.ref.clone()
|
||||
before = self.run_forward(ref=ref)
|
||||
ref.copy_(ref.flip(-1))
|
||||
self.assertIsNot(before, self.run_forward(ref=ref))
|
||||
|
||||
def test_sampler_boundaries_clear_the_cache_even_at_the_same_sigma(self):
|
||||
for boundary in ("pre", "cleanup"):
|
||||
before = self.run_forward()
|
||||
self.assertIn(boundary, self.model.callbacks)
|
||||
self.model.callbacks[boundary](self.model)
|
||||
self.assertIsNot(before, self.run_forward())
|
||||
|
||||
|
||||
class AttentionTests(unittest.TestCase):
|
||||
def test_attention_patches_run_in_order_and_keep_their_metadata(self):
|
||||
seen = []
|
||||
options = {"block_index": 2, "block_type": "single", "total_blocks": 3, "img_slice": [1, 2]}
|
||||
|
||||
def input_patch(q, k, v, pe, attn_mask, extra_options):
|
||||
seen.append(("input", extra_options.copy()))
|
||||
return {"q": q + 2, "v": v + 3, "pe": "rotary", "attn_mask": "mask"}
|
||||
|
||||
def rope(q, k, freqs):
|
||||
self.assertEqual(freqs, "rotary")
|
||||
return q + 4, k
|
||||
|
||||
def attention(q, k, v, heads, **kwargs):
|
||||
seen.append(("attention", kwargs["mask"]))
|
||||
self.assertTrue(torch.equal(q, torch.full_like(q, 7)))
|
||||
self.assertEqual(k.shape[2], 3)
|
||||
self.assertTrue(torch.equal(v[:, :, :2], torch.full_like(v[:, :, :2], 4)))
|
||||
return torch.zeros(1, 2, 3)
|
||||
|
||||
def output_patch(out, extra_options):
|
||||
seen.append(("output", extra_options.copy()))
|
||||
return out + 10
|
||||
|
||||
options["patches"] = {"attn1_patch": [input_patch], "attn1_output_patch": [output_patch]}
|
||||
attn = types.SimpleNamespace(wq=lambda x: x, wk=lambda x: x, wv=lambda x: x,
|
||||
gate=torch.zeros_like, qknorm=lambda q, k: (q, k), heads=1, kvheads=1, wo=lambda x: x)
|
||||
with patch.dict(core.__dict__, optimized_attention_masked=attention, apply_rope=rope):
|
||||
out = core._attn_kv(attn, torch.ones(1, 2, 3), None,
|
||||
kv_cache=(torch.zeros(1, 1, 1, 3), torch.zeros(1, 1, 1, 3)), transformer_options=options)
|
||||
self.assertEqual([item[0] for item in seen], ["input", "attention", "output"])
|
||||
self.assertEqual(seen[0][1], options)
|
||||
self.assertEqual(seen[2][1], options)
|
||||
self.assertTrue(torch.equal(out, torch.full_like(out, 5)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -26,7 +26,6 @@ if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
|
||||
from nodes import node_mask_by_touch as node_module # noqa: E402
|
||||
from nodes._mask_by_name_helpers import IF_NOTHING # noqa: E402
|
||||
from nodes._mask_by_touch_helpers import ( # noqa: E402
|
||||
MOVED_NOTHING,
|
||||
NEEDS_SAM3,
|
||||
NOTHING_PICKED,
|
||||
NOT_A_PICTURE,
|
||||
@@ -36,8 +35,6 @@ from nodes._mask_by_touch_helpers import ( # noqa: E402
|
||||
OTHER_PICTURE,
|
||||
WORDS_FIRST,
|
||||
WORDS_MORE,
|
||||
move_touches,
|
||||
parse_touches,
|
||||
picture_key,
|
||||
scope_points,
|
||||
stroke_area,
|
||||
@@ -536,67 +533,17 @@ class NothingPickedTests(TouchCase):
|
||||
self.assertIsNone(note["selection"])
|
||||
|
||||
def test_picks_made_on_another_picture_are_not_this_pictures(self):
|
||||
# With New picture on start over: the node's way before keep picks.
|
||||
text = touches(tap(75, 34), name("face", 60, 45), key="160x120-0000000000000000")
|
||||
with self.assertRaises(RuntimeError) as stopped:
|
||||
self.run_node(text, new_picture="start over")
|
||||
self.run_node(text)
|
||||
self.assertEqual(str(stopped.exception), OTHER_PICTURE, "the picks are there, on another picture")
|
||||
out = self.run_node(text, if_nothing="empty mask", new_picture="start over")
|
||||
out = self.run_node(text, if_nothing="empty mask")
|
||||
self.assertEqual(float(out["result"][0].sum()), 0.0, "empty mask still lets the run go on")
|
||||
note = self.note(self.run_node(text, ask="look", new_picture="start over"))
|
||||
self.assertNotIn("moved", note)
|
||||
note = self.note(self.run_node(text, ask="look"))
|
||||
self.assertEqual((note["items"], note["key"], note["stamp"]), ([], picture_key(self.picture), touch_stamp(text)))
|
||||
self.assertEqual(FakeSam.calls, [])
|
||||
self.assertEqual(len(self.note(self.run_node(touches(tap(75, 34), key=picture_key(self.picture))))["items"]), 1)
|
||||
|
||||
def test_picks_made_on_another_picture_move_onto_this_one(self):
|
||||
# Issue 301: a new picture keeps the picks, in the same places.
|
||||
here = picture_key(self.picture)
|
||||
made = [tap(75, 34), paint([[10, 100], [40, 100]], r=6)]
|
||||
other = touches(*made, key="160x120-0000000000000000")
|
||||
out = self.run_node(other)
|
||||
same = self.run_node(touches(*made, key=here))
|
||||
self.assertTrue(torch.equal(out["result"][0], same["result"][0]), "the same mask as picks made on this picture")
|
||||
note = self.note(out)
|
||||
moved = note["moved"]
|
||||
self.assertEqual(moved["from"], touch_stamp(other), "the face knows which picks were moved")
|
||||
self.assertEqual(note["stamp"], touch_stamp(moved["touches"]), "the answer is for the moved picks")
|
||||
self.assertEqual(parse_touches(moved["touches"]), parse_touches(touches(*made, key=here)))
|
||||
self.assertNotIn("moved", self.note(same), "picks made here are not moved")
|
||||
|
||||
def test_picks_from_a_picture_of_another_size_are_scaled_to_this_one(self):
|
||||
# Made on a picture twice the size: every place and the brush halve.
|
||||
big = touches(paint([[20, 200], [80, 200]], r=12), key="320x240-0000000000000000")
|
||||
out = self.run_node(big)
|
||||
small = touches(paint([[10, 100], [40, 100]], r=6), key=picture_key(self.picture))
|
||||
self.assertTrue(torch.equal(out["result"][0], self.run_node(small)["result"][0]))
|
||||
|
||||
def test_moved_picks_that_find_nothing_say_so(self):
|
||||
text = touches(tap(*WALL), key="160x120-0000000000000000")
|
||||
with self.assertRaises(RuntimeError) as stopped:
|
||||
self.run_node(text)
|
||||
self.assertEqual(str(stopped.exception), MOVED_NOTHING)
|
||||
|
||||
def test_moving_touches_keeps_everything_but_the_places(self):
|
||||
text = json.dumps({"v": 1, "key": "320x240-00", "items": [
|
||||
{"t": "tap", "op": 1, "x": 101, "y": 51, "g": 1},
|
||||
{"t": "name", "op": 0, "w": "mug", "x": 10, "y": 10, "q": 1},
|
||||
{"t": "paint", "op": 1, "r": 9, "pts": [[3, 5], [7, 9]], "s": 40, "g": 2},
|
||||
{"t": "all", "op": 1, "w": "hand"},
|
||||
{"t": "subject", "op": 1},
|
||||
]})
|
||||
moved = json.loads(move_touches(text, "160x120-11"))
|
||||
self.assertEqual(moved["key"], "160x120-11")
|
||||
self.assertEqual(moved["items"], [
|
||||
{"t": "tap", "op": 1, "x": 51, "y": 26, "g": 1}, # halves round up, as the face rounds
|
||||
{"t": "name", "op": 0, "w": "mug", "x": 5, "y": 5, "q": 1},
|
||||
{"t": "paint", "op": 1, "r": 5, "pts": [[2, 3], [4, 5]], "s": 40, "g": 2},
|
||||
{"t": "all", "op": 1, "w": "hand"},
|
||||
{"t": "subject", "op": 1},
|
||||
])
|
||||
same_size = json.loads(move_touches(text, "320x240-22"))
|
||||
self.assertEqual((same_size["key"], same_size["items"]), ("320x240-22", json.loads(text)["items"]), "the same size only takes the new key")
|
||||
|
||||
def test_text_that_is_not_touches_stops_before_any_search(self):
|
||||
for ask in ("", "look"):
|
||||
with self.assertRaises(ValueError) as stopped:
|
||||
@@ -1835,7 +1782,7 @@ class TellTests(TouchCase):
|
||||
for text, message in ((other, OTHER_PICTURE), ("", NOTHING_PICKED)):
|
||||
self.sent.clear()
|
||||
with self.assertRaises(RuntimeError) as stopped:
|
||||
self.run_node(text, unique_id=7, new_picture="start over")
|
||||
self.run_node(text, unique_id=7)
|
||||
self.assertEqual(str(stopped.exception), message, "the run still stops, for the same reason")
|
||||
[(event, data, sid)] = self.sent
|
||||
self.assertEqual((event, data["node"], data["end"], data["stopped"], sid), (node_module.LOOK_EVENT, "7", True, True, "the page that asked"))
|
||||
@@ -2116,9 +2063,7 @@ class ContractTests(unittest.TestCase):
|
||||
def test_inputs_and_outputs_are_the_contracts(self):
|
||||
inputs = self.cls.INPUT_TYPES()
|
||||
self.assertEqual(list(inputs["required"]), ["image", "model", "clip", "touches", "grow", "soften", "smooth", "fill_holes", "if_nothing"])
|
||||
self.assertEqual(list(inputs["optional"]), ["free_vram", "invert", "ask", "fine_edges", "new_picture"])
|
||||
# Picks carry over to a new picture unless the node starts over.
|
||||
self.assertEqual((inputs["optional"]["new_picture"][0], inputs["optional"]["new_picture"][1]["default"]), (["keep picks", "start over"], "keep picks"))
|
||||
self.assertEqual(list(inputs["optional"]), ["free_vram", "invert", "ask", "fine_edges"])
|
||||
# Off until the user picks a model file: an API prompt without it runs as before.
|
||||
self.assertEqual((optional_first := inputs["optional"]["fine_edges"])[1]["default"], "off")
|
||||
self.assertEqual(optional_first[0][0], "off", "off is always offered, and comes first")
|
||||
|
||||
+11
-20
@@ -1,17 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
import av
|
||||
import numpy as np
|
||||
from aiohttp import FormData, StreamReader, web
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
from aiohttp import FormData, web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
@@ -45,29 +43,22 @@ class UploadTests(unittest.IsolatedAsyncioTestCase):
|
||||
return web.json_response(await stream_video_upload(request, self.root, {'.mp4', '.mov'}))
|
||||
except Exception as exc:
|
||||
return web.json_response({'error': str(exc)}, status=400)
|
||||
self.handler = handler
|
||||
app = web.Application(client_max_size=1024)
|
||||
app.router.add_post('/upload', handler)
|
||||
self.client = TestClient(TestServer(app))
|
||||
await self.client.start_server()
|
||||
self.addAsyncCleanup(self.client.close)
|
||||
|
||||
async def upload(self, body, filename='clip.mp4'):
|
||||
form = FormData()
|
||||
form.add_field('image', io.BytesIO(body), filename=filename, content_type='video/mp4')
|
||||
payload = form()
|
||||
stream = StreamReader(Mock(_reading_paused=False), limit=1024)
|
||||
|
||||
class Writer:
|
||||
async def write(self, chunk):
|
||||
stream.feed_data(chunk)
|
||||
|
||||
await payload.write(Writer())
|
||||
stream.feed_eof()
|
||||
request = make_mocked_request('POST', '/upload', headers=payload.headers,
|
||||
payload=stream, client_max_size=1024)
|
||||
return await self.handler(request)
|
||||
return await self.client.post('/upload', data=form)
|
||||
|
||||
async def test_stream_exceeds_buffered_body_limit_and_retains_bytes(self):
|
||||
body = self.body + b'\0' * (2 * 1024 * 1024)
|
||||
response = await self.upload(body)
|
||||
self.assertEqual(response.status, 200, response.text)
|
||||
result = json.loads(response.text)
|
||||
self.assertEqual(response.status, 200, await response.text())
|
||||
result = await response.json()
|
||||
self.assertEqual((self.root / result['name']).read_bytes(), body)
|
||||
|
||||
async def test_collision_and_symlink_never_overwrite(self):
|
||||
@@ -75,7 +66,7 @@ class UploadTests(unittest.IsolatedAsyncioTestCase):
|
||||
(self.root / 'clip.mp4').symlink_to(self.root / 'kept.txt')
|
||||
response = await self.upload(self.body)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(json.loads(response.text)['name'], 'clip_1.mp4')
|
||||
self.assertEqual((await response.json())['name'], 'clip_1.mp4')
|
||||
self.assertEqual((self.root / 'kept.txt').read_text(), 'keep')
|
||||
|
||||
async def test_invalid_media_is_removed(self):
|
||||
|
||||
@@ -6,8 +6,6 @@ outpaint stitches a clip. People who update the pack must get the same
|
||||
picture. Each case below builds its stitcher the way its node does, stitches a
|
||||
fixed patch at color_match 0, 0.5 and 1 through the node, and compares the
|
||||
image and blend_mask with the output the 2.3.0 code gave.
|
||||
The legacy inpaint crop keeps its original geometry here; its expected
|
||||
mask is limited to the paste window. Crop sizing is tested separately.
|
||||
|
||||
Blend in with Tone match at 0 is pinned the same way, to the 2.4.0 code:
|
||||
the published Qwen Image 2.1 Rotate + Outpaint workflow stitches that way.
|
||||
@@ -124,12 +122,7 @@ def crop_for_inpaint():
|
||||
mask = torch.zeros((1, 64, 80))
|
||||
mask[:, 20:44, 30:58] = 1.0
|
||||
cropped, _sampling, stitcher = AusBossCropForInpaint().crop(image, mask, 1.5, 16, 8)
|
||||
# Freeze the released crop window so this checks the seam independently
|
||||
# of the corrected crop sizing, without regenerating release fixtures.
|
||||
stitcher["crop_to_canvas"] = (20, 12, 48, 40)
|
||||
stitcher["sampler_size"] = (48, 40)
|
||||
cx, cy, cw, ch = stitcher["crop_to_canvas"]
|
||||
cropped = image[:, cy : cy + ch, cx : cx + cw]
|
||||
return stitcher, model_output(cropped, stitcher["blend"][:, cy : cy + ch, cx : cx + cw], 13)
|
||||
|
||||
|
||||
@@ -208,13 +201,7 @@ class ClassicSeamPinTests(unittest.TestCase):
|
||||
stitcher, patch_image = build()
|
||||
image, blend_mask = stitch(stitcher, patch_image, strength)
|
||||
assert_released_image(image, reference_image(name, strength))
|
||||
expected_mask = reference_mask(name)
|
||||
if name == "crop for inpaint":
|
||||
cx, cy, cw, ch = stitcher["crop_to_canvas"]
|
||||
pasted = torch.zeros_like(expected_mask)
|
||||
pasted[:, cy : cy + ch, cx : cx + cw] = expected_mask[:, cy : cy + ch, cx : cx + cw]
|
||||
expected_mask = pasted
|
||||
assert_released_image(blend_mask, expected_mask)
|
||||
assert_released_image(blend_mask, reference_mask(name))
|
||||
|
||||
def test_the_mask_references_are_the_pinned_masks(self):
|
||||
# Reads the files only, so every machine gives the same answer.
|
||||
|
||||
@@ -35,7 +35,6 @@ class WorkflowContractTests(unittest.TestCase):
|
||||
|
||||
def test_named_widget_copy_must_match_the_loaded_values(self):
|
||||
graph = good_graph()
|
||||
graph["nodes"][0]["type"] = "AUSBOSS_NODES_Seed"
|
||||
graph["nodes"][0]["widgets_values"] = [7, "fixed", ""]
|
||||
graph["nodes"][0]["widgets_values_named"] = {"seed": 7, "control_after_generate": "fixed", "ausboss_seed_panel": ""}
|
||||
self.assertEqual(workflow_problems(graph), [])
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { THEME } from "../js/shared/theme.mjs";
|
||||
|
||||
// The card, the number box, the switch and the picture bar take every colour
|
||||
// from theme.mjs, so the palette is changed in one place.
|
||||
const ON_THEME = [
|
||||
"js/shared/widget_card.mjs", "js/shared/scrub_input.mjs", "js/shared/slide_switch.mjs",
|
||||
"js/input_preview/index.js", "js/shared/panel_controls.mjs", "js/shared/picture_stepper.mjs",
|
||||
"js/shared/step_preview.mjs",
|
||||
];
|
||||
|
||||
test("the card, number box, switch and picture bar carry no colour of their own", () => {
|
||||
for (const file of ON_THEME) {
|
||||
const source = readFileSync(new URL(`../${file}`, import.meta.url), "utf8");
|
||||
const hex = source.match(/#[0-9a-fA-F]{3,8}\b/g) ?? [];
|
||||
assert.deepEqual(hex, [], `${file} has colour literals; add them to js/shared/theme.mjs`);
|
||||
}
|
||||
});
|
||||
|
||||
const luminance = (hex) => {
|
||||
const channels = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
|
||||
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
||||
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
|
||||
};
|
||||
const contrast = (a, b) => {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
|
||||
test("text a person reads holds 4.5:1 on the card and on a control", () => {
|
||||
const pairs = [
|
||||
["ink", "fill"], ["inkSoft", "card"], ["label", "card"], ["label", "fill"],
|
||||
["faint", "card"], ["faint", "fill"], ["onBrand", "brand"], ["ink", "switchOff"], ["warnInk", "warnSoft"],
|
||||
["dangerInk", "dangerSoft"], ["faint", "well"], ["inkSoft", "fill"],
|
||||
];
|
||||
for (const [text, ground] of pairs) {
|
||||
const ratio = contrast(THEME[text], THEME[ground]);
|
||||
assert.ok(ratio >= 4.5, `${text} on ${ground} is ${ratio.toFixed(2)}:1`);
|
||||
}
|
||||
});
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
growMask,
|
||||
loaderSignature,
|
||||
maskShare,
|
||||
movedTouches,
|
||||
nameShown,
|
||||
packPlanes,
|
||||
paintCover,
|
||||
@@ -1048,17 +1047,3 @@ test("while the node still looks at a picture it says so, a lit thing is taken a
|
||||
assert.match(stage.slice(stage.indexOf("function syncReadout"), stage.indexOf("function removeChip")), /syncLooking\(face\)/);
|
||||
assert.match(stage.slice(stage.indexOf("function syncState"), stage.indexOf("function setNote")), /syncLooking\(face\)/);
|
||||
});
|
||||
|
||||
test("picks the node moved onto a new picture are taken only while nothing was picked since", () => {
|
||||
// Issue 301: New picture on keep picks. The node moves the picks and sends
|
||||
// the stamp of the ones it moved them from.
|
||||
const before = encodeTouches("320x240-aa", [{ t: "tap", op: 1, x: 100, y: 50 }]);
|
||||
const after = encodeTouches("160x120-bb", [{ t: "tap", op: 1, x: 50, y: 25 }]);
|
||||
const note = { key: "160x120-bb", moved: { from: stampOf(before), touches: after } };
|
||||
assert.equal(movedTouches(note, before), after, "the widget still holds what was moved: take the moved picks");
|
||||
const since = encodeTouches("320x240-aa", [{ t: "tap", op: 1, x: 100, y: 50 }, { t: "tap", op: 1, x: 9, y: 9 }]);
|
||||
assert.equal(movedTouches(note, since), null, "a pick made since is kept");
|
||||
assert.equal(movedTouches({ key: "160x120-bb" }, before), null, "nothing was moved");
|
||||
assert.equal(movedTouches({ moved: { from: 7, touches: after } }, before), null, "a note that is not this shape moves nothing");
|
||||
assert.equal(movedTouches(null, before), null);
|
||||
});
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
// A stopped run's message on Mask by Touch goes once the next result comes in.
|
||||
//
|
||||
// When a run stops at Mask by Touch (nothing picked, picks made on another
|
||||
// picture), its message goes on the node's face and stays there, so the
|
||||
// person can read it. It used to stay after the next run from the Run button
|
||||
// had worked, until something else on the face was said. The face's own
|
||||
// runs cleared it; a workflow run's result did not.
|
||||
//
|
||||
// touch_stage.mjs imports ComfyUI's api, so it cannot be loaded here. This
|
||||
// file runs its setNote for real and holds the stop and the result to the
|
||||
// lines that clear the message. The live proof is a stopped run followed by
|
||||
// a good one on a real canvas.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const SOURCE = readFileSync(new URL("../js/shared/touch_stage.mjs", import.meta.url), "utf-8");
|
||||
|
||||
function functionSource(name) {
|
||||
const start = SOURCE.indexOf(`function ${name}(`);
|
||||
assert.ok(start >= 0, `touch_stage.mjs has no function ${name}`);
|
||||
const end = SOURCE.indexOf("\n}\n", start);
|
||||
return SOURCE.slice(start, end + 2);
|
||||
}
|
||||
|
||||
// The face's note, with setNote as touch_stage.mjs has it.
|
||||
function noteFace() {
|
||||
const setNote = new Function(`${functionSource("setNote")}\nreturn setNote;`)();
|
||||
const classes = new Set();
|
||||
const face = {
|
||||
noteTimer: 0,
|
||||
noteEl: { textContent: "", classList: { toggle: (name, on) => (on ? classes.add(name) : classes.delete(name)) } },
|
||||
};
|
||||
return { face, setNote, shown: () => (classes.has("on") ? face.noteEl.textContent : "") };
|
||||
}
|
||||
|
||||
test("a stopped run's message stays up until the next result, then goes", () => {
|
||||
const { face, setNote, shown } = noteFace();
|
||||
const stopped = SOURCE.match(/face\.stopped = \(message\) => \{([\s\S]*?)\n {2}\};/)?.[1];
|
||||
assert.ok(stopped, "touch_stage.mjs has no face.stopped");
|
||||
assert.match(stopped, /setNote\(face, String\(message \?\? ""\)\);\s*(?:\/\/[^\n]*\n\s*)*face\.stopNote = true;/,
|
||||
"a stop marks its message as a stop message, after it is set");
|
||||
// What face.stopped does to the note.
|
||||
setNote(face, "Mask by Touch: nothing is picked.");
|
||||
face.stopNote = true;
|
||||
assert.equal(shown(), "Mask by Touch: nothing is picked.");
|
||||
|
||||
// The next result clears it, as the head of show() does.
|
||||
const head = functionSource("show").split("\n").slice(0, 6).join("\n");
|
||||
assert.match(head, /if \(face\.stopNote && !interim && !restored\) setNote\(face, ""\);/,
|
||||
"show() clears a stop message when a result comes in");
|
||||
if (face.stopNote) setNote(face, "");
|
||||
assert.equal(shown(), "");
|
||||
});
|
||||
|
||||
test("any other message ends the stop message's hold", () => {
|
||||
const { face, setNote } = noteFace();
|
||||
setNote(face, "Mask by Touch: nothing is picked.");
|
||||
face.stopNote = true;
|
||||
setNote(face, "Nothing found there.", { life: 1 });
|
||||
clearTimeout(face.noteTimer);
|
||||
assert.equal(face.stopNote, false, "a note said since is not cleared by the next result");
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
CARD_PADDING, GROUP_HEIGHT, ROW_GAP, ROW_HEIGHT, SECTION_HEIGHT, SLOT_OFFSET, SOCKETLESS_CHOICE, SOCKETLESS_TEXT,
|
||||
cardHeight, choiceLabel, commitWidgetValue, holdsUnknownValue, liftSlot, liftSocket, markSocketless, resetUnknownValues, rowHeight, rowInLook, rowKind, rowMuted, rowTops,
|
||||
cardHeight, choiceLabel, commitWidgetValue, holdsUnknownValue, markSocketless, resetUnknownValues, rowHeight, rowInLook, rowKind, rowMuted, rowTops,
|
||||
scrubSteps, segmentFits, socketWidgetY, visibleRows, widgetTooltip,
|
||||
} from "../js/shared/widget_card_math.mjs";
|
||||
|
||||
@@ -418,20 +418,3 @@ test("a choice's words come from the row: a map, or a function for lists only th
|
||||
// The pill or the list is chosen from the words a person reads, so two long file names still make a short pill.
|
||||
assert.equal(rowKind({ widget: "fine_edges", labels: row.labels }, { type: "combo", options: { values: ["off", "vitmatte-small-composition-1k/model.safetensors"] } }), "segment");
|
||||
});
|
||||
|
||||
test("a lifted socket loses the row position the frontend stamped on it", () => {
|
||||
// Newer frontends set `slot.pos` to the widget's row and keep using it
|
||||
// after the widget is gone, which drew fixed_frames on top of force_rate.
|
||||
const stamped = () => ({ name: "fixed_frames", widget: { name: "fixed_frames" }, _widget: {}, pos: [10, 10], link: null });
|
||||
const node = { inputs: [{ name: "force_rate" }, stamped()] };
|
||||
assert.equal(liftSocket(node, "fixed_frames"), true);
|
||||
const slot = node.inputs[1];
|
||||
assert.equal(slot.widget, undefined);
|
||||
assert.equal(slot._widget, undefined);
|
||||
assert.equal("pos" in slot, false);
|
||||
assert.equal(liftSocket(node, "fixed_frames"), false);
|
||||
const loose = stamped();
|
||||
liftSlot(loose);
|
||||
assert.equal("pos" in loose, false);
|
||||
assert.equal(loose.widget, undefined);
|
||||
});
|
||||
|
||||
@@ -400,7 +400,7 @@ test("a switch changes only the settings that differ between its on and its off"
|
||||
assert.deepEqual(valuePairs({}), []);
|
||||
// Text that only looks like a stack on one side is switched whole.
|
||||
assert.deepEqual(valuePairs({ on: { 5: { text: "[1, 2]" } }, off: { 5: { text: "plain" } } }), [{ node: "5", widget: "text", on: "[1, 2]", off: "plain" }]);
|
||||
assert.deepEqual(stackRows("[]"), []);
|
||||
assert.equal(stackRows("[]"), null);
|
||||
assert.equal(stackRows('[{"name":"a"}]'), null);
|
||||
assert.equal(stackRows("not json ["), null);
|
||||
});
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import * as logic from "../js/shared/workflow_switches.mjs";
|
||||
import { cardHeight } from "../js/shared/widget_card_math.mjs";
|
||||
|
||||
class Element extends EventTarget {
|
||||
constructor(tag = "div") {
|
||||
super();
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.className = "";
|
||||
this.style = { setProperty(key, value) { this[key] = value; } };
|
||||
}
|
||||
get classList() {
|
||||
const classes = () => this.className.split(/\s+/).filter(Boolean);
|
||||
return {
|
||||
contains: (name) => classes().includes(name),
|
||||
add: (...names) => { this.className = [...new Set([...classes(), ...names])].join(" "); },
|
||||
remove: (...names) => { this.className = classes().filter((name) => !names.includes(name)).join(" "); },
|
||||
toggle: (name, force) => {
|
||||
const on = force ?? !classes().includes(name);
|
||||
this.className = [...classes().filter((value) => value !== name), ...(on ? [name] : [])].join(" ");
|
||||
return on;
|
||||
},
|
||||
};
|
||||
}
|
||||
append(...nodes) { for (const node of nodes) this.insertBefore(node, null); }
|
||||
insertBefore(node, cursor) {
|
||||
node.remove();
|
||||
node.parentElement = this;
|
||||
this.children.splice(cursor ? this.children.indexOf(cursor) : this.children.length, 0, node);
|
||||
}
|
||||
remove() {
|
||||
if (!this.parentElement) return;
|
||||
const siblings = this.parentElement.children;
|
||||
siblings.splice(siblings.indexOf(this), 1);
|
||||
this.parentElement = null;
|
||||
}
|
||||
replaceChildren(...nodes) {
|
||||
for (const node of [...this.children]) node.remove();
|
||||
this.append(...nodes);
|
||||
}
|
||||
get firstElementChild() { return this.children[0] ?? null; }
|
||||
get nextElementSibling() {
|
||||
const siblings = this.parentElement?.children ?? [];
|
||||
return siblings[siblings.indexOf(this) + 1] ?? null;
|
||||
}
|
||||
setAttribute(name, value) { this[name] = value; }
|
||||
querySelector(selector) {
|
||||
for (const child of this.children) {
|
||||
if (selector.startsWith(".") && child.classList.contains(selector.slice(1))) return child;
|
||||
const found = child.querySelector(selector);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
contains(target) { return this === target || this.children.some((child) => child.contains(target)); }
|
||||
getContext() { return { measureText: (text) => ({ width: String(text).length * 6 }) }; }
|
||||
}
|
||||
|
||||
function page() {
|
||||
class Node {
|
||||
constructor() {
|
||||
this.type = "AUSBOSS_NODES_WorkflowSwitches";
|
||||
this.properties = {};
|
||||
this.widgets = [];
|
||||
this.size = [320, 100];
|
||||
this.pos = [0, 0];
|
||||
}
|
||||
addDOMWidget(name, type, element, options) {
|
||||
const widget = { name, type, element, options };
|
||||
this.widgets.push(widget);
|
||||
return widget;
|
||||
}
|
||||
computeSize() { return [320, 100]; }
|
||||
setSize(size) { this.size = size; }
|
||||
}
|
||||
class Group {}
|
||||
const timers = new Map();
|
||||
const microtasks = [];
|
||||
let nextTimer = 0;
|
||||
const app = {
|
||||
canvas: { graph: { nodes: [], groups: [] } },
|
||||
registerExtension(extension) { this.extension = extension; },
|
||||
};
|
||||
const document = {
|
||||
head: new Element(), body: new Element(), activeElement: null,
|
||||
createElement: (tag) => new Element(tag), createElementNS: (_ns, tag) => new Element(tag),
|
||||
getElementById: () => null,
|
||||
};
|
||||
const context = vm.createContext({
|
||||
...logic, app, document, performance, console, cardHeight,
|
||||
LiteGraph: { LGraphNode: Node, LGraphGroup: Group }, BRAND: "#00b4aa",
|
||||
chainCallback(proto, name, callback) {
|
||||
const previous = proto[name];
|
||||
proto[name] = function (...args) { const result = previous?.apply(this, args); callback.apply(this, args); return result; };
|
||||
},
|
||||
keepDomWidgetWidthAuto() {}, holdNodeMinWidth() {}, holdVueNodeMinWidth() {}, pinVuePanelHeight() {},
|
||||
ensureCardCss() {}, keepKeyInField() {}, notifyAusbossChange() {}, showToast() {}, commitWidgetValue() {}, onPaste() {},
|
||||
queueMicrotask: (callback) => microtasks.push(callback),
|
||||
setTimeout: (callback) => { const id = ++nextTimer; timers.set(id, callback); return id; },
|
||||
clearTimeout: (id) => timers.delete(id),
|
||||
});
|
||||
const source = readFileSync(new URL("../js/workflow_switches/index.js", import.meta.url), "utf8");
|
||||
vm.runInContext(source.replace(/^import\s[\s\S]*?from\s+"[^"]+";\n/gm, "")
|
||||
+ "\nglobalThis.hooks = { live, refresh, scheduleCheck, choiceSnapshots };", context);
|
||||
app.extension.beforeRegisterNodeDef(Node, { name: "AUSBOSS_NODES_WorkflowSwitches" });
|
||||
const flush = () => { for (const callback of microtasks.splice(0)) callback(); };
|
||||
const makeCard = () => {
|
||||
const node = new Node();
|
||||
node.onNodeCreated();
|
||||
node.graph = app.canvas.graph;
|
||||
node.graph.nodes.push(node);
|
||||
node.onAdded();
|
||||
flush();
|
||||
return node;
|
||||
};
|
||||
return { app, Node, Group, timers, flush, makeCard, ...context.hooks };
|
||||
}
|
||||
|
||||
test("copy serialization clones do not retain live states or schedule redraw timers", () => {
|
||||
const p = page();
|
||||
const real = p.makeCard();
|
||||
const clones = Array.from({ length: 5 }, () => {
|
||||
const clone = new p.Node();
|
||||
clone.onNodeCreated();
|
||||
clone.onConfigure();
|
||||
return clone;
|
||||
});
|
||||
p.flush();
|
||||
assert.equal(p.live.size, 1);
|
||||
real.onRemoved();
|
||||
p.app.canvas.onDrawForeground();
|
||||
for (const clone of clones) p.scheduleCheck(clone.__ausbossWorkflowSwitches);
|
||||
assert.equal(p.live.size, 0);
|
||||
assert.equal(p.timers.size, 0);
|
||||
real.onAdded();
|
||||
p.flush();
|
||||
assert.equal(p.live.size, 1, "undoing removal registers the card again");
|
||||
});
|
||||
|
||||
test("selection-free choice capture preserves text node ids", () => {
|
||||
const p = page();
|
||||
const card = p.makeCard();
|
||||
const target = { id: "node-value", widgets: [{ name: "value", value: 20 }] };
|
||||
card.properties.ausboss_workflow_switches = { switches: [{ id: 1, choice: true, on: { "node-value": { value: 8 } } }] };
|
||||
card.graph.getNodeById = (id) => id === target.id ? target : null;
|
||||
const snapshots = p.choiceSnapshots(card.__ausbossWorkflowSwitches, []);
|
||||
assert.equal(snapshots.length, 1);
|
||||
assert.equal(snapshots[0].id, "node-value");
|
||||
assert.equal(snapshots[0].values.value, 20);
|
||||
});
|
||||
|
||||
test("selecting a node after one group reveals the choice button without another graph change", () => {
|
||||
const p = page();
|
||||
const card = p.makeCard();
|
||||
card.properties.ausboss_workflow_switches.groups = "none";
|
||||
const group = new p.Group();
|
||||
group.id = 9;
|
||||
p.app.canvas.selectedItems = new Set([group]);
|
||||
p.refresh(card.__ausbossWorkflowSwitches);
|
||||
assert.equal(card.__ausbossWorkflowSwitches.choose.classList.contains("hidden"), true);
|
||||
const target = new p.Node();
|
||||
target.type = "Integer";
|
||||
target.id = 4;
|
||||
target.widgets = [{ name: "value", value: 8 }];
|
||||
p.app.canvas.selectedItems = new Set([target]);
|
||||
p.refresh(card.__ausbossWorkflowSwitches);
|
||||
assert.equal(card.__ausbossWorkflowSwitches.choose.classList.contains("hidden"), false);
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
choicePairs, choosePlan, normalizeSettings, pairValue, placeNodes, readGroups, readSwitches,
|
||||
releaseStackGroup, releaseStackRow, stackGroupOwners, stackRowOwners, stackRows, switchPlan, valuePlan,
|
||||
} from "../js/shared/workflow_switches.mjs";
|
||||
|
||||
const stack = (...rows) => JSON.stringify(rows);
|
||||
const lora = (name, enabled = true, strength = 1, extra = {}) => ({ name, enabled, strength, ...extra });
|
||||
const target = (id, name, value) => ({ id, mode: 0, pos: [0, 30], size: [100, 100], widgets: [{ name, value }] });
|
||||
const choices = (...values) => normalizeSettings({ switches: values.map((value, index) => ({
|
||||
id: index + 1, title: String(index + 1), choice: true, on: { 5: { loras: value } },
|
||||
})) });
|
||||
|
||||
test("taking over a row leaves an empty saved stack safe and preserves unrelated live rows", () => {
|
||||
const a = lora("a");
|
||||
const b = lora("b");
|
||||
const saved = { switches: [{ id: 1, on: { 5: { loras: stack(a, b), steps: 8 } }, off: { 5: { loras: stack(b), steps: 20 } } }] };
|
||||
const settings = releaseStackRow(saved, 1, 5, "loras", "b");
|
||||
const current = stack(a, b, lora("unrelated"));
|
||||
const live = target(5, "loras", current);
|
||||
live.widgets.push({ name: "steps", value: 8 });
|
||||
const rows = readSwitches(settings.switches, placeNodes([live]), []);
|
||||
for (const [pair, side] of valuePlan(rows, rows[0], false, settings)) {
|
||||
pair.control.value = pairValue(pair, side, pair.control.value);
|
||||
}
|
||||
assert.equal(live.widgets[0].value, current);
|
||||
assert.equal(live.widgets[1].value, 20);
|
||||
assert.deepEqual(stackRows("[]"), []);
|
||||
});
|
||||
|
||||
test("settings targets remain running when either side includes their group", () => {
|
||||
for (const side of ["groups", "groups_off"]) {
|
||||
const live = target(5, "steps", 8);
|
||||
const neighbor = target(6, "steps", 12);
|
||||
const settings = normalizeSettings({ switches: [{ id: 1, [side]: [2], on: { 5: { steps: 8 } }, off: { 5: { steps: 20 } } }] });
|
||||
const placed = placeNodes([live, neighbor]);
|
||||
const groups = readGroups([{ id: 2, boundingRect: [0, 0, 300, 300] }], placed);
|
||||
const rows = readSwitches(settings.switches, placed, groups);
|
||||
for (const on of [false, true]) {
|
||||
for (const [node, mode] of switchPlan(rows, rows[0], on, settings)) node.mode = mode;
|
||||
for (const [pair, valueSide] of valuePlan(rows, rows[0], on, settings)) pair.control.value = pairValue(pair, valueSide, pair.control.value);
|
||||
assert.equal(live.mode, 0, side);
|
||||
}
|
||||
assert.deepEqual(rows[0].valueNodes, [live]);
|
||||
assert.equal(rows[0].members.includes(live), false);
|
||||
assert.equal(rows[0].offMembers.includes(live), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("three LoRA choices retain every differing field regardless of comparison order", () => {
|
||||
const saves = [stack(lora("a", true, 1)), stack(lora("a", false, 1)), stack(lora("a", true, 2))];
|
||||
for (const order of [saves, [saves[0], saves[2], saves[1]]]) {
|
||||
const settings = choices(...order);
|
||||
const live = target(5, "loras", saves[2]);
|
||||
const rows = readSwitches(settings.switches, placeNodes([live]), []);
|
||||
assert.equal(rows[0].state, "off");
|
||||
const plan = choosePlan(rows, rows[0], settings);
|
||||
for (const [pair, side] of plan.values) pair.control.value = pairValue(pair, side, pair.control.value);
|
||||
assert.deepEqual(stackRows(live.widgets[0].value), [lora("a", true, 1, { strength_clip: 1 })]);
|
||||
assert.equal(readSwitches(settings.switches, placeNodes([live]), [])[0].state, "on");
|
||||
}
|
||||
});
|
||||
|
||||
test("ordinary JSON with object ids and titles falls back without coercion errors", () => {
|
||||
const malformed = JSON.parse('{"switches":[{"id":{"toString":null},"title":{"toString":null}},{"id":[2],"title":["oops"]},{"id":"4","title":"Valid"}]}');
|
||||
const normalized = normalizeSettings(malformed);
|
||||
assert.deepEqual(normalized.switches.map(({ id, title }) => [id, title]), [[1, "Switch 1"], [2, "Switch 2"], [4, "Valid"]]);
|
||||
});
|
||||
|
||||
test("group chips accept hex colors and reject CSS that can load resources", () => {
|
||||
const colorOf = (color) => readGroups([{ color, boundingRect: [0, 0, 100, 100] }], [])[0].color;
|
||||
for (const color of ["#335", "#abcd", "#123456", "#12345678"]) assert.equal(colorOf(color), color);
|
||||
for (const color of ["url(https://example.invalid/pixel)", "image-set(url(//example.invalid/pixel) 1x)", "var(--external)", "#335; background:url(x)", "#12345", {}, null]) {
|
||||
assert.equal(colorOf(color), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("LoRA choices expose row locks and taking over releases every choice without changing other rows", () => {
|
||||
const settings = choices(
|
||||
stack(lora("a", true), lora("b", true)),
|
||||
stack(lora("a", false), lora("b", false)),
|
||||
stack(lora("a", true, 2), lora("b", false)),
|
||||
);
|
||||
const boards = [{ node: "card", settings }];
|
||||
const owners = stackRowOwners(boards, 5, "loras", "a");
|
||||
assert.deepEqual(owners.map(({ id }) => id), [1, 2, 3]);
|
||||
let released = settings;
|
||||
for (const { id } of owners) released = releaseStackRow(released, id, 5, "loras", "a");
|
||||
assert.deepEqual(stackRowOwners([{ node: "card", settings: released }], 5, "loras", "a"), []);
|
||||
assert.ok(choicePairs(released.switches[0], released.switches).some((pair) => pair.row === "b"));
|
||||
assert.equal(settings.switches[0].on[5].loras, stack(lora("a", true), lora("b", true)));
|
||||
});
|
||||
|
||||
test("LoRA group choices expose locks and taking over keeps row choices intact", () => {
|
||||
const settings = choices(
|
||||
stack(lora("a", true, 1, { group: "Look" })),
|
||||
stack(lora("a", false, 1, { group: "Look", group_off: true })),
|
||||
);
|
||||
const owners = stackGroupOwners([{ node: "card", settings }], 5, "loras", "Look");
|
||||
assert.deepEqual(owners.map(({ id }) => id), [1, 2]);
|
||||
let released = settings;
|
||||
for (const { id } of owners) released = releaseStackGroup(released, id, 5, "loras", "Look");
|
||||
assert.deepEqual(stackGroupOwners([{ node: "card", settings: released }], 5, "loras", "Look"), []);
|
||||
assert.equal(stackRowOwners([{ node: "card", settings: released }], 5, "loras", "a").length, 2);
|
||||
});
|
||||
Reference in New Issue
Block a user