Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
729084ca78 |
@@ -7,10 +7,6 @@ on:
|
||||
description: Full commit SHA from ComfyUI-AusBoss-Model-Downloader main
|
||||
required: true
|
||||
type: string
|
||||
changelog:
|
||||
description: Release note for this version, shown in the Registry's Updates section
|
||||
required: true
|
||||
type: string
|
||||
|
||||
# The existing AusBoss publisher credential stays in this repository. This
|
||||
# manual job publishes only the separately maintained Model Downloader addon.
|
||||
@@ -66,16 +62,12 @@ jobs:
|
||||
id: publish
|
||||
env:
|
||||
REGISTRY_ACCESS_TOKEN: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
COMFY_NODE_CHANGELOG: ${{ inputs.changelog }}
|
||||
COMFY_NODE_CHANGELOG: 'Initial release: model downloads from Civitai and Hugging Face, installed-file checks, saved model lists, and AusBoss Workflow Note integration.'
|
||||
run: |
|
||||
if [ -z "$REGISTRY_ACCESS_TOKEN" ]; then
|
||||
echo '::error::REGISTRY_ACCESS_TOKEN is not configured.'
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${COMFY_NODE_CHANGELOG// /}" ]; then
|
||||
echo '::error::The changelog input is empty; the Registry shows it as the release note.'
|
||||
exit 1
|
||||
fi
|
||||
comfy --skip-prompt --no-enable-telemetry node publish --token "$REGISTRY_ACCESS_TOKEN"
|
||||
- name: Report Registry review status
|
||||
if: always() && steps.publish.outcome != 'skipped'
|
||||
|
||||
-171
@@ -4,177 +4,6 @@ All notable changes to ComfyUI-AusBoss are documented here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **New node: Edit Lock.** Keeps every part of your picture that an edit
|
||||
did not change exactly where it was, with the colors it had. Edit models
|
||||
redraw the whole picture, so untouched parts often come back a little
|
||||
zoomed, shifted or tinted. Put Edit Lock between the model and the
|
||||
sampler and give it the picture you are editing. It needs no mask. It
|
||||
steps aside when the edit changes the whole picture, and lets rain,
|
||||
snow and haze through while holding the picture in place. Tested on
|
||||
MiniMax-H3-Image.
|
||||
|
||||
- **New node: Edit Lock Restore.** Puts your picture's own pixels back in
|
||||
every part the edit did not change. Add it after the VAE Decode with
|
||||
Edit Lock's `edit_lock` output. It also gives a mask of what the edit
|
||||
changed and a one-line report.
|
||||
|
||||
- **Save Image shows every picture of a run at once.** When a run saves
|
||||
several pictures, they now show side by side in the node, the way the
|
||||
core Save Image does. The arrows still go to each picture by itself, and
|
||||
round again to all of them. Select Frame, Mask Refine, Mask by Name and
|
||||
LaMa Inpaint show a batch the same way.
|
||||
|
||||
- **Save Image can wait for a click before it saves.** Switch **Auto save**
|
||||
off and a run only shows its pictures on the node. **SAVE ALL** over them
|
||||
keeps every one; step to one picture with the arrows and **SAVE** keeps
|
||||
just that one. Good for making a batch and keeping the best. Auto save is
|
||||
on unless you switch it off, so nothing changes for workflows you have.
|
||||
|
||||
- **Mask by Touch: type a name to take it out of the mask.** The name field
|
||||
under the picture now has a sign in front. Click it to switch between **+**
|
||||
(add by name) and **−** (take away by name), then type "face" or "hair" and
|
||||
press Enter. Typing "-face" takes it away whatever the sign says. Before,
|
||||
a typed name was only taken away while Erase was chosen, which nothing on
|
||||
the node said.
|
||||
|
||||
- **Mask by Touch: Ctrl-click only takes away what you point at.** With a
|
||||
sweater picked, a Ctrl-click on the face offered the whole person, and the
|
||||
sweater went with it. Now a spot where nothing is picked says so and
|
||||
changes nothing.
|
||||
|
||||
- **Mask by Touch works on big pictures.** A look at a 3840 pixel picture took
|
||||
twice as long as at 2048, and at 8192 pixels it ran a 32 GB graphics card
|
||||
out of memory. The node now asks SAM 3 about a copy of at most 2048 pixels
|
||||
and brings the answer back to full size. Pictures up to 2048 pixels come
|
||||
out exactly as before. On bigger ones a mask's edge can sit a pixel or two
|
||||
differently.
|
||||
|
||||
- **Mask by Touch no longer fills the queue.** With Image Crop + Rotate + Pad
|
||||
or Load Image + Pad wired in, every change to the loader (each step of its
|
||||
megapixels, for one) queued one more look at a picture that was already
|
||||
replaced. Now only the newest look waits. The button that pauses the
|
||||
looking also says **Pause** and **Look on**.
|
||||
|
||||
- **Two errors from outside a workflow now say what to do.** When a MiniMax H3
|
||||
run stops with `KeyError: 'latent'` because the Muse MiniMax Director pack is
|
||||
installed, a message names that pack and says to turn it off. When an older
|
||||
ComfyUI cannot read a `w6a8` model file, a message says to update ComfyUI.
|
||||
ComfyUI's own error report stays as it is.
|
||||
|
||||
- **ComfyUI starts when the pack is installed twice.** A Manager install
|
||||
(`custom_nodes/ausboss-nodes`) beside a git clone
|
||||
(`custom_nodes/ComfyUI-AusBoss`) stopped ComfyUI while it started. Now one
|
||||
copy loads and the other steps aside, and the console says which folder to
|
||||
delete. A copy from 2.23.0 or older is the one that loads when it sits
|
||||
beside a newer one, so delete the older folder.
|
||||
|
||||
## 2.23.0 - 2026-11-09
|
||||
|
||||
- **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.
|
||||
|
||||
- **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
|
||||
workflows keep their setting.
|
||||
|
||||
- **H3 Prompt Writer: a swap no longer makes the new person talk to noise.** With
|
||||
**Keep the clip's sound** on and no words typed, the writer told H3 the new
|
||||
person speaks as in the video whenever the clip's sound was loud, so a train
|
||||
or a beat made their mouth move. It now does that only when it hears a real
|
||||
voice: half a second or more of clear speech.
|
||||
|
||||
- **Mask by Touch shows a changed picture without unplugging it.** When the
|
||||
picture coming in changes and the run stops because the picks were made on
|
||||
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
|
||||
their setting.
|
||||
|
||||
## 2.21.2 - 2026-11-07
|
||||
|
||||
- **H3 Prompt Writer reads a plain swap sentence.** "Replace the man in the red
|
||||
shirt with @1", "Swap the woman for @1" and similar phrasings now name only the
|
||||
person who goes, instead of putting the whole sentence into the prompt.
|
||||
- **A swapped-in person is there from the first frame.** Swaps with a picture
|
||||
always ask for the replacement throughout, with no transition or morph.
|
||||
- **A text model on H3 Prompt Writer's `clip` input writes the `@1 looks like`
|
||||
line in a swap.** It describes the person's hair and face from the picture
|
||||
when no line is typed. Without a model on `clip`, nothing changes.
|
||||
- **Workflow Note tiles start compact in the classic canvas again.** A leftover
|
||||
image preview filed under the node's id made the classic canvas grow a new
|
||||
tile to hold the picture; notes now stand that preview down.
|
||||
- **Colored link buttons and pop-out notes keep their tile layout when shared.**
|
||||
Callout Tile and Workflow Note Tile save as distinct node types, so an older
|
||||
install can report what is missing. Tiles have larger editors and visible
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -20,7 +18,6 @@ Most of them are for image and video work. ComfyUI's core nodes already cover th
|
||||
- **[Mask by Touch](#mask-by-touch):** click a thing on the picture and get its mask. Hover shows what a click would take, and **Fine edges** follows hair and fur.
|
||||
- **[Mask by Name](#mask-by-name):** type "the red jacket" and get its mask and a cut-out.
|
||||
- **[Image Crop + Rotate + Pad](#image-crop--rotate--pad):** turn, crop and pad a picture by dragging handles on the node, for outpainting.
|
||||
- **[Edit Lock](#edit-lock):** an edit changes what you asked for, and the rest of the picture stays exactly where it was. No mask needed.
|
||||
- **[Stitch Inpaint](#stitch-inpaint):** after an inpaint or outpaint, everything you didn't ask to change is your original pixels again.
|
||||
- **[Video Crop + Rotate + Pad → Clip](#video-crop--rotate--pad--clip):** trim a clip on a timeline, and pad or crop it to a new shape.
|
||||
- **[Tiled Upscale](#tiled-upscale):** upscale a picture in tiles to the megapixels you set.
|
||||
@@ -30,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
|
||||
|
||||
@@ -43,8 +40,6 @@ git clone https://github.com/ausboss/ComfyUI-AusBoss.git
|
||||
|
||||
Restart ComfyUI and search the node library for **AusBoss**.
|
||||
|
||||
Install it one way, not both. Manager puts the pack in `custom_nodes/ausboss-nodes` and a clone in `custom_nodes/ComfyUI-AusBoss`. They are the same pack, so if both folders are there, delete one. The console says which copy it skipped.
|
||||
|
||||
After an update, through Manager or `git pull`, restart ComfyUI and hard-refresh the browser with **Ctrl+Shift+R**. Otherwise the browser keeps the old JavaScript, and the pack warns you about it. Manager can install a version or two behind while the Comfy Registry reviews a new release. To get the newest one, pick it in Manager's version list, or update by hand: delete the pack's folder in `ComfyUI/custom_nodes` and clone the repository as shown above.
|
||||
|
||||
The pack uses the Pillow, NumPy, Torch and PyAV that come with ComfyUI. Each example lists the models it needs on its Workflow Note. LaMa Inpaint also needs [`big-lama.pt`](https://github.com/Sanster/models/releases/download/add_big_lama/big-lama.pt) in `ComfyUI/models/lama/`. The pack never downloads weights by itself.
|
||||
@@ -140,18 +135,6 @@ The same watercolor edit without and with Realign to Source: 61 px off before, 0
|
||||
|
||||
Qwen drew this watercolor 5.6% taller, and Realign to Source lined it back up with the original.
|
||||
|
||||
### Edit Lock
|
||||
|
||||
Keeps every part of your picture that an edit did not change exactly where it was, with the colors it had. An edit model redraws the whole picture, so the parts you did not ask about often come back a little zoomed in, shifted or tinted, and the picture jumps when you flip between before and after. Put Edit Lock between the model and the sampler and give it the picture you are editing. It needs no mask: it sees what the edit changes while the picture is being made, and holds on to everything else.
|
||||
|
||||
When the edit changes the whole picture (a new style, new light), there is nothing to keep, so the node steps aside and the edit runs as usual. A soft effect over the whole picture, such as rain or falling snow, is let through as well: the node then only holds the picture in place. **Lock** (`lock`) set to `off` does nothing at all, `always` locks even a whole-picture edit, and an optional `mask` says where the edit may happen. **Tolerance** (`tolerance`) sets how big a change must be to count as part of the edit.
|
||||
|
||||
On MiniMax-H3-Image, 4 megapixel edits moved the picture by up to 14 px without the lock (measured at 1 megapixel) and by 0.2 px or less with it (0.08 px or less on 35 of the 36 locked runs), on 19 local edits at two seeds each. After five edits in a row on one picture the plain chain had drifted 1.65 px and turned visibly pink; the locked chain sat at 0.04 px with no tint.
|
||||
|
||||
### Edit Lock Restore
|
||||
|
||||
Puts your picture's own pixels back in every part the edit did not change. Edit Lock keeps those parts in place, but they still pass through the VAE, which softens fine detail a little. Add this right after the VAE Decode and wire in Edit Lock's `edit_lock` output. **Feather** (`feather`) sets how softly the original fades into the edit. It also returns `edit_mask`, white where the edit changed the picture, and a one-line `report` of how much was kept.
|
||||
|
||||
### Image Compare A/B
|
||||
|
||||
Compares two pictures with a slider, or flips between them whole. Nothing covers the picture, and its size shows underneath. The panel grows with the node, and images A and B pass through, so you can put it anywhere on an image wire. **Erase** mixes the two by hand: rub parts of one picture away with a brush so the other shows through, then save the mix as a PNG under a name you type, with a layered PSD for Photoshop or GIMP if you want it.
|
||||
@@ -216,7 +199,7 @@ It returns the new frames and their fps. It doesn't touch audio, so carry the so
|
||||
|
||||
Saves a frame batch or a core `VIDEO` as MP4 (H.264 or H.265), WebM (VP9 or AV1), ProRes MOV, lossless FFV1 MKV, GIF or WebP. Which encoders you get depends on your FFmpeg and PyAV build, and H.264 and H.265 come in CPU and NVENC versions. The videos carry color tags, and optionally audio and the workflow.
|
||||
|
||||
**Ping-pong** plays forward and then back without repeating the turnaround frames. A wired `VIDEO` brings its own frame rate; otherwise wire fps from Load Video or Frame Interpolate. The player on the node shows the saved file, and **Preview steps** shows the clip's first frame while it is being made, with a **STOP** button. Drop a saved video back on the canvas to load the workflow inside it, for the formats that can hold one. The [help page](js/docs/AUSBOSS_NODES_SaveVideo.md) has the format and audio details.
|
||||
**Ping-pong** plays forward and then back without repeating the turnaround frames. A wired `VIDEO` brings its own frame rate; otherwise wire fps from Load Video or Frame Interpolate. The player on the node shows the saved file, and **STEPS** shows the clip's first frame while it is being made, with a **STOP** button. Drop a saved video back on the canvas to load the workflow inside it, for the formats that can hold one. The [help page](js/docs/AUSBOSS_NODES_SaveVideo.md) has the format and audio details.
|
||||
|
||||
## Mask and inpaint nodes
|
||||
|
||||
@@ -450,7 +433,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.
|
||||
|
||||
+11
-39
@@ -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
|
||||
@@ -22,7 +22,6 @@ NODE_MODULES = [
|
||||
"node_callout",
|
||||
"node_color_match",
|
||||
"node_compare",
|
||||
"node_edit_lock",
|
||||
"node_frame_interpolate",
|
||||
"node_free_memory",
|
||||
"node_h3_prompt_writer",
|
||||
@@ -64,19 +63,7 @@ NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
_failed_modules = []
|
||||
|
||||
# Two copies of the pack (a Manager install beside a git clone) add every
|
||||
# route twice and ComfyUI stops while starting. Only one copy loads.
|
||||
_other_copy = None
|
||||
try:
|
||||
from .nodes._single_copy import other_copy_that_loads
|
||||
|
||||
_other_copy = other_copy_that_loads(os.path.dirname(__file__))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
for _module_name in NODE_MODULES:
|
||||
if _other_copy:
|
||||
break
|
||||
try:
|
||||
_module = importlib.import_module(f".nodes.{_module_name}", __name__)
|
||||
NODE_CLASS_MAPPINGS.update(getattr(_module, "NODE_CLASS_MAPPINGS", {}))
|
||||
@@ -89,7 +76,7 @@ for _module_name in NODE_MODULES:
|
||||
|
||||
# Folder ComfyUI serves to the browser; every .js file in it is loaded as a
|
||||
# frontend extension. Shared modules use .mjs so they are import-only.
|
||||
WEB_DIRECTORY = None if _other_copy else "./js"
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
@@ -98,9 +85,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"
|
||||
|
||||
@@ -125,26 +112,11 @@ def _print_banner():
|
||||
print(bar)
|
||||
|
||||
|
||||
def _print_second_copy():
|
||||
# ASCII only, as in the banner.
|
||||
red = "\033[0;31m"
|
||||
reset = "\033[0m"
|
||||
own = os.path.basename(os.path.dirname(os.path.realpath(__file__)))
|
||||
other = os.path.basename(_other_copy["folder"])
|
||||
print(f"{red}AusBoss: this pack is installed twice.{reset}")
|
||||
print(f" Using: custom_nodes/{other} (v{_other_copy['version']})")
|
||||
print(f" Skipping: custom_nodes/{own} (v{_read_version()})")
|
||||
print(" Delete one of the two folders, then restart ComfyUI.")
|
||||
try:
|
||||
from .nodes._frontend_cache import register_frontend_cache_guard
|
||||
|
||||
register_frontend_cache_guard()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
if _other_copy:
|
||||
_print_second_copy()
|
||||
else:
|
||||
try:
|
||||
from .nodes._frontend_cache import register_frontend_cache_guard
|
||||
|
||||
register_frontend_cache_guard()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
_print_banner()
|
||||
_print_banner()
|
||||
|
||||
@@ -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.1">
|
||||
<title>release: v2.21.1</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.1</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"
|
||||
|
||||
@@ -439,7 +439,7 @@
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
true,
|
||||
"stop the run",
|
||||
false,
|
||||
false,
|
||||
@@ -451,7 +451,7 @@
|
||||
"grow": 0,
|
||||
"soften": 0,
|
||||
"smooth": 0,
|
||||
"fill_holes": false,
|
||||
"fill_holes": true,
|
||||
"if_nothing": "stop the run",
|
||||
"free_vram": false,
|
||||
"invert": false,
|
||||
|
||||
@@ -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.
|
||||
|
||||
+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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
# Edit Lock
|
||||
|
||||
Keeps every part of your picture that an edit did not change exactly where
|
||||
it was, with the colors it had.
|
||||
|
||||
An edit model redraws the whole picture, also the parts you did not ask it
|
||||
to change. Those parts often come back a little zoomed in, shifted or
|
||||
tinted. Flip between the original and the edit and the whole picture
|
||||
jumps. Edit Lock stops that: only the edit is new.
|
||||
|
||||
It needs no mask. It sees what the edit changes while the picture is being
|
||||
made, and holds on to everything else.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Put **Edit Lock 🆎** between the model and the sampler: the model (after
|
||||
any LoRA loader) goes in, and its `model` output goes to the sampler or
|
||||
guider.
|
||||
2. Wire the VAE into `vae` and the picture you are editing into `image`.
|
||||
Use the same picture the edit model gets, at the size of the result.
|
||||
3. After the VAE Decode, add **Edit Lock Restore 🆎**. Wire the decoded
|
||||
picture into `image` and Edit Lock's `edit_lock` output into
|
||||
`edit_lock`. This puts your own pixels back in the untouched parts.
|
||||
4. Wire Restore's `report` into **Show Text 🆎** to read how much was kept.
|
||||
|
||||
Run your edit as usual. Prompt, steps and seed stay as they were.
|
||||
|
||||
## What it does with each kind of edit
|
||||
|
||||
- **A local edit** (add glasses, repaint the car, change the sign): the
|
||||
edit happens, and the rest of the picture stays put.
|
||||
- **An edit of the whole picture** (a new style, new light, fog):
|
||||
there is nothing to keep, so Edit Lock steps aside and the edit runs as
|
||||
if the node were not there. The report says so.
|
||||
- **A soft effect over the whole picture** (rain, falling snow, haze):
|
||||
it touches everything a little, so there is no untouched part to put
|
||||
back. Edit Lock lets the effect through and only holds the picture in
|
||||
place, so it does not come back zoomed or shifted. The report says so.
|
||||
- **A soft effect and a clear change in one edit** (rain and an umbrella):
|
||||
the node may lock on the umbrella and hold the rain back everywhere
|
||||
else. If the effect is missing outside the changed part, set **Lock** to
|
||||
`off`.
|
||||
- **Something in between** (a new hair color on a close-up, a new
|
||||
background behind a person): it may lock or step aside. The report says
|
||||
which. These edits often change the light on the face too. `auto` steps
|
||||
aside for most of them, so the new light comes with the new hair.
|
||||
`always` keeps the old light on everything it keeps, which can look
|
||||
wrong on a face. A mask settles it: paint the hair and the face, and
|
||||
the rest is locked.
|
||||
- **The model ignores your picture and draws a new one:** that reads as a
|
||||
whole-picture edit, so `auto` steps aside. `always` holds the model to
|
||||
your picture.
|
||||
|
||||
## Controls
|
||||
|
||||
- **model**: the edit model. Patch last, after any LoRAs.
|
||||
- **vae**: the same VAE the workflow decodes with.
|
||||
- **image**: the picture you are editing, at the size of the result. If
|
||||
the sizes differ by more than a few percent the lock does nothing, and
|
||||
the report says so.
|
||||
- **Lock** (`lock`): `auto` (the default) locks the untouched parts and
|
||||
steps aside when the whole picture changes. `always` locks even then.
|
||||
`off` does nothing, which makes a quick before and after.
|
||||
- **Tolerance** (`tolerance`): how big a change must be to count as part of
|
||||
the edit. 1 is the default. Raise it if small unwanted changes get
|
||||
through. Lower it if the lock holds back part of your edit.
|
||||
- **mask** (optional): white marks where the edit may happen; everything
|
||||
else is locked. With a mask the node does not look for the change
|
||||
itself. A mask from **Mask by Touch 🆎** or **Mask by Name 🆎** works.
|
||||
|
||||
## Outputs
|
||||
|
||||
- **model**: the model with the lock on.
|
||||
- **edit_lock**: what the lock kept, for Edit Lock Restore 🆎.
|
||||
|
||||
## Good to know
|
||||
|
||||
- The lock belongs to one picture. For an edit with two pictures, give it
|
||||
the one the result should line up with.
|
||||
- It works on single pictures, not video.
|
||||
- The node watches the first steps of a run and locks for the rest, so
|
||||
the model finishes the edit on top of your real picture. Shadows and
|
||||
reflections the edit adds next to the changed part still come through.
|
||||
- If your edit is missing and the report says nearly all of the picture
|
||||
was kept, set **Lock** to `off`.
|
||||
- Tested on MiniMax-H3-Image. The lock itself is not tied to one model, but
|
||||
the line between "local edit" and "whole picture" was measured there.
|
||||
|
||||
## Technical details
|
||||
|
||||
At every step an edit model predicts the finished picture. Edit Lock
|
||||
compares that prediction with your picture in latent space.
|
||||
|
||||
**Watching.** The first prediction of a run is a blur, and rain, snow or
|
||||
the light a new hair color throws on a face only show a few steps in. So
|
||||
the node watches until the noise is down to 0.8 of where it began: step 4
|
||||
of a 6-step MiniMax-H3-Image run. A run that never gets that low decides
|
||||
at its last step. While it watches, the model draws its own picture and
|
||||
only one thing is corrected. The model often draws the whole picture about
|
||||
one percent larger, or a little shifted. The node measures that zoom and
|
||||
shift at every step, on the calmer half of the picture, and moves the
|
||||
prediction back so it sits where your picture does.
|
||||
|
||||
**Deciding.** Change is measured against the quiet part of the picture:
|
||||
the tenth where the prediction differs least. An even tint over the whole
|
||||
picture is taken out first, so a color cast is not read as a change. A
|
||||
spot counts as changed when it differs by 3 times the quiet level, or by 2
|
||||
times while it touches a spot that does. So a faint change beside a clear
|
||||
one belongs to the edit, and a faint change alone is redraw noise.
|
||||
**Tolerance** multiplies both numbers.
|
||||
|
||||
**Locking.** From the decision on, wherever the picture did not change,
|
||||
the original latent is put back before the sampler takes its next step. A
|
||||
part once read as changed stays changed for the rest of the run. The last
|
||||
step ends on the original there, so what was kept is the original itself,
|
||||
not a redrawn copy.
|
||||
|
||||
**Stepping aside.** `auto` does not lock in three cases:
|
||||
|
||||
- At the first step the whole picture is already changing (a new style,
|
||||
new light). The node reads the calmest fifth of a smoothed copy, where a
|
||||
slightly larger redraw nearly vanishes and a new style does not. On
|
||||
MiniMax-H3-Image, local edits at 1, 4 and 16 megapixels read 0.22 or
|
||||
less there and whole-picture edits 0.27 or more; the line sits at 0.243.
|
||||
The model then runs untouched.
|
||||
- At the decision no place has changed clearly: the strongest spot of the
|
||||
picture is under 4 times the quiet level. That is light rain or fog.
|
||||
Local edits read 4.7 or more there, glasses and makeup included.
|
||||
- At the decision the lock would hand a tenth of the picture or more to
|
||||
the edit, yet the strongest hundredth of the picture is under 4 times
|
||||
the quiet level. That is heavier rain or snow, and a lock would let it
|
||||
through in patches. Local edits that wide read 4.4 or more; rain and
|
||||
snow read 3.6 or less.
|
||||
|
||||
In the last two cases the node keeps undoing the zoom and shift to the end
|
||||
of the run, and puts nothing back. An edit close to one of these lines (a
|
||||
hair color change on a close-up, a new background, smoke) can fall on
|
||||
either side. The report says which, and **Lock** overrides it.
|
||||
|
||||
This is the target the TailSteer paper trains a model toward (the source
|
||||
where nothing changed, the model's own edit elsewhere), applied while
|
||||
sampling. It needs no LoRA and no training.
|
||||
@@ -1,48 +0,0 @@
|
||||
# Edit Lock Restore
|
||||
|
||||
Puts your picture's own pixels back in every part the edit did not change.
|
||||
|
||||
**Edit Lock 🆎** keeps the untouched parts in place while the picture is
|
||||
made. They still pass through the VAE, which softens fine detail a little.
|
||||
This node swaps your original pixels in for those parts, so they match
|
||||
your picture pixel for pixel. Only the edit is new.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Add it right after the VAE Decode, before any upscaler.
|
||||
2. Wire the decoded picture into `image`.
|
||||
3. Wire the `edit_lock` output of Edit Lock 🆎 into `edit_lock`.
|
||||
4. Wire `report` into **Show Text 🆎** to read what was kept.
|
||||
|
||||
## Controls
|
||||
|
||||
- **image**: the edited picture, straight from the VAE Decode.
|
||||
- **edit_lock**: from Edit Lock 🆎. It carries your original picture and
|
||||
the map of what the edit changed, so there is nothing else to wire.
|
||||
- **Feather** (`feather`): how softly your original fades into the edit,
|
||||
in pixels. 16 is the default. The fade lies outside the edit, so the
|
||||
edit itself is never mixed with the old picture.
|
||||
|
||||
## Outputs
|
||||
|
||||
- **image**: the edit, with your original pixels in the untouched parts.
|
||||
- **edit_mask**: white where the edit changed the picture. Use it to see
|
||||
what the lock decided, or for a later step that should only touch the
|
||||
edit.
|
||||
- **report**: one line, such as "Edit Lock kept 82% of your picture as it
|
||||
was and left 18% to the edit".
|
||||
|
||||
## When nothing was locked
|
||||
|
||||
The picture passes through unchanged, the mask is all white, and the
|
||||
report says why: the edit changed the whole picture, the lock was off, or
|
||||
the picture and the result were different sizes.
|
||||
|
||||
## Technical details
|
||||
|
||||
The map of what changed is made while sampling, on the latent grid. Here
|
||||
it is enlarged to the picture's size, widened by the feather and blurred
|
||||
by the same amount, and used to blend the decoded picture with your
|
||||
original. If the decoded picture is not the size of your original (after
|
||||
an upscale), the original is resized to fit first, which costs it some
|
||||
sharpness. That is why this node goes before an upscaler.
|
||||
@@ -19,8 +19,8 @@ What is lit in teal is what the mask will be. When it is right, press Run
|
||||
for the whole workflow.
|
||||
|
||||
The first time the pointer rests on a picture, the node looks at it. A
|
||||
button under the picture shows how far along it is, **❚❚ Pause · looking
|
||||
40/104**, filling up as it goes. The first things light up after a few seconds, and
|
||||
small button under the picture shows how far along it is, **❚❚ 40/104**,
|
||||
filling up as it goes. The first things light up after a few seconds, and
|
||||
it has seen the whole picture after about 20.
|
||||
|
||||
You do not have to wait for that. Whatever is lit under the pointer can be
|
||||
@@ -28,7 +28,7 @@ picked: a click takes it at once, also while the node is still looking.
|
||||
|
||||
Click that button to pause the looking when what you need is already lit:
|
||||
what is lit stays, and you go on picking and painting. It turns into
|
||||
**▶ Look on**; click it to look on from where it stopped. A click on a spot where
|
||||
**▶**; click it to look on from where it stopped. A click on a spot where
|
||||
nothing is lit yet looks on as well.
|
||||
|
||||
A click on a spot where nothing is lit yet is not lost: a dot marks it, the
|
||||
@@ -50,9 +50,6 @@ at that spot.
|
||||
A stroke across two things, like both shoes, takes both.
|
||||
- Did the thing not light up at all? Type its name into the field under the
|
||||
picture, like "screen" or "earring", and press Enter.
|
||||
- Is something in the mask that should not be, like the face or the hair of
|
||||
a person you picked? Click the **+** in front of that field so it shows
|
||||
**−**, type "face" and press Enter. Or hold Ctrl and click the face.
|
||||
- When nothing fits, switch to **Paint** and paint the area yourself.
|
||||
|
||||
## The whole subject, or the background
|
||||
@@ -62,10 +59,6 @@ click: the person, the animal, the product. **Background** picks everything
|
||||
else. They are picks like any other: click more things to add them, or
|
||||
Ctrl-click a thing to take it away again.
|
||||
|
||||
Ctrl-click takes away the thing under the pointer, where it is in the mask.
|
||||
On a spot where nothing is picked it changes nothing, and the picture says
|
||||
so.
|
||||
|
||||
Both need ComfyUI's background-removal model, see "The model for Fine
|
||||
edges" below.
|
||||
|
||||
@@ -122,16 +115,11 @@ or an area you painted.
|
||||
it.
|
||||
- **all 3** on a chip takes every thing of that name instead of the one you
|
||||
clicked: all five birds, both shoes.
|
||||
- The field at the end picks every thing of the name you type. The sign in
|
||||
front of it says what happens to the name: **+** adds it, **−** takes it
|
||||
away. Click the sign to change it. A name typed with a minus in front,
|
||||
like "-face", is taken away whatever the sign shows. With Erase chosen the
|
||||
sign starts on **−**.
|
||||
- The field at the end, **+ type a name**, picks every thing of the name
|
||||
you type. With Erase chosen it takes them away.
|
||||
|
||||
## The tools on the node
|
||||
|
||||
- **Reload** (the round arrow) shows the picture coming in now: see
|
||||
**Which picture the picks belong to**, below.
|
||||
- **Undo** takes back the last change, or a Clear. Ctrl+Z works as well: it
|
||||
is ComfyUI's own undo, and every change counts.
|
||||
- **Clear** picks nothing.
|
||||
@@ -164,15 +152,11 @@ or an area you painted.
|
||||
- **More → Smooth** (`smooth`): rounds off a ragged outline by this many
|
||||
pixels.
|
||||
- **More → Fill small holes** (`fill_holes`): closes pinholes inside the
|
||||
picked area. Larger openings stay open, like a mug's handle. Off by
|
||||
default, so a hole you pick stays a hole.
|
||||
picked area. Larger openings stay open, like a mug's handle.
|
||||
- **More → Invert** (`invert`): turns the mask round. On, the mask is
|
||||
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.
|
||||
@@ -222,21 +206,10 @@ 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.
|
||||
|
||||
- **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
|
||||
example with an edit. It runs only this node and what comes before it.
|
||||
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.
|
||||
|
||||
- With a Load Image wired straight in, or through Reroute nodes, the
|
||||
picture shows as soon as you choose a file.
|
||||
@@ -283,15 +256,6 @@ then.
|
||||
|
||||
### How SAM 3 is asked
|
||||
|
||||
- A picture whose long side is over 2048 pixels is asked about on a copy
|
||||
with its long side at 2048. SAM 3 reads every picture at 1008 pixels
|
||||
anyway, and returns each answer at the size it was given, on the graphics
|
||||
card: at 8192 pixels one word with many matches needed over 27 GB. The
|
||||
answer is brought back to the picture's own size, so the mask, the cut-out
|
||||
and your touches stay in pixels of the picture. Paint is never scaled.
|
||||
Measured on a 3840 pixel picture, five words: the new masks share 95 to
|
||||
99.6 % of their area with the old ones, the edge sits a median 0 to 2
|
||||
pixels differently, and a look takes 31 seconds in place of 53.
|
||||
- The search is ComfyUI's own SAM 3: SAM3 Detect, asked about points, or
|
||||
about a word that CLIP Text Encode has read. Both use ComfyUI's own
|
||||
settings (threshold 0.5, two edge passes). The node needs ComfyUI 0.20 or
|
||||
@@ -352,10 +316,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.
|
||||
|
||||
|
||||
@@ -50,19 +50,9 @@ file's name. A caption on the `caption_text` input writes the paired `.txt`.
|
||||
shorter; on again, the node returns to its height with the last saved
|
||||
image. It is display only: the file is saved either way and the queue
|
||||
still lists it. When a run saves more than one image (a batch, or a list
|
||||
such as a folder of images), they all show side by side. The arrows
|
||||
beside the switch (‹ all 5 ›) go on to each one by itself (‹ 2 / 5 ›) and
|
||||
back round to all of them. A run of more than 48 shows one at a time.
|
||||
- **Auto save** (`autosave`, on by default): on, every run saves its
|
||||
pictures. Off, a run only shows them here and saves nothing. A **SAVE**
|
||||
button then sits over the picture: **SAVE ALL** keeps every picture of
|
||||
the run, and with the arrows on one picture, **SAVE** keeps that one.
|
||||
Use it to make a batch, look, and keep only the good ones. The pictures
|
||||
are saved under the name and folder set above, in the format picked,
|
||||
with the workflow inside if **Embed workflow** is on. They wait in
|
||||
ComfyUI's temp folder, which is emptied when ComfyUI restarts, so save
|
||||
what you want before that.
|
||||
- **Preview steps** (`step_preview`, off by default): the switch left of
|
||||
such as a folder of images), arrows beside the switch (‹ 2 / 5 ›) step
|
||||
through them.
|
||||
- **Steps** (`step_preview`, off by default): the switch left of
|
||||
**preview** shows the picture while it is being made, one step at a time,
|
||||
where the saved image goes, with a **STOP** button on the bar. Stop a run
|
||||
that is going wrong after the first steps instead of waiting for the end;
|
||||
|
||||
@@ -9,7 +9,7 @@ duration, shows a tall video whole, and has a **LOOP** control. `gif` and
|
||||
`webp` are shown as pictures with just the file name, since a video player
|
||||
cannot play either.
|
||||
|
||||
**Preview steps** above the player (`step_preview`, off by default) shows the clip's first frame in
|
||||
**Steps** above the player (`step_preview`, off by default) shows the clip's first frame in
|
||||
the player while it is being made, one step at a time, with a **STOP**
|
||||
button on the player. Stop a clip that is going wrong after the first steps
|
||||
instead of waiting for the end; a stopped run saves nothing. The saved
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,7 +24,6 @@ help panel.
|
||||
| Get a mask by typing what to find | Mask by Name (`AUSBOSS_NODES_MaskByName`) |
|
||||
| Get a mask by clicking the thing | Mask by Touch (`AUSBOSS_NODES_MaskByTouch`) |
|
||||
| Grow, shrink, fill or smooth a mask | Mask Refine (`AUSBOSS_NODES_RefineMask`) |
|
||||
| Keep the untouched parts of a picture exactly as they were while an edit model edits it | Edit Lock (`AUSBOSS_NODES_EditLock`) then Edit Lock Restore (`AUSBOSS_NODES_EditLockRestore`) |
|
||||
| Line an edited picture back up with the original | Realign to Source (`AUSBOSS_NODES_RealignToSource`, experimental) |
|
||||
| Match one picture's colors to another | Color Match (`AUSBOSS_NODES_ColorMatch`) |
|
||||
| Upscale big pictures in tiles | Tiled Upscale (`AUSBOSS_NODES_TiledUpscale`) then Tiled Upscale Stitch (`AUSBOSS_NODES_TiledUpscaleStitch`) |
|
||||
|
||||
+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);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
+36
-240
@@ -19,14 +19,13 @@ 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";
|
||||
import { hideInputsInDef, hideWidget, setWidgetVisible } from "../shared/widget_visibility.mjs";
|
||||
import { makePictureStepper } from "../shared/picture_stepper.mjs";
|
||||
import { ALL, allLabel, gridShape, heldPlaces, keepIndex, stepIndex, stepWithAll } from "../shared/picture_steps.mjs";
|
||||
import { keepIndex, stepIndex } from "../shared/picture_steps.mjs";
|
||||
import { addStepConsumer, mountStepOverlay, showsStepsOn, stepRun, stopStepRun, watchSteps } from "../shared/step_preview.mjs";
|
||||
import {
|
||||
describeNodePreview,
|
||||
@@ -55,11 +54,6 @@ const BAR_PADDING = 2;
|
||||
const PANEL_HEIGHT = STAGE_HEIGHT + BAR_HEIGHT + 4 + 16;
|
||||
const OFF_HEIGHT = BAR_HEIGHT + 16;
|
||||
const INPUT_SIDE = 1; // LiteGraph.INPUT
|
||||
// A run that left this many pictures or fewer opens with all of them side
|
||||
// by side. More than that and each would be a speck, so the arrows go
|
||||
// through them one at a time, as before.
|
||||
const GRID_MAX = 48;
|
||||
const GRID_GAP = 4;
|
||||
// The node's height from before its preview was switched off, so switching
|
||||
// it back on returns the node to it (saved with the workflow).
|
||||
const HEIGHT_PROPERTY = "ausboss_preview_height";
|
||||
@@ -119,17 +113,7 @@ const NODE_CONFIG = {
|
||||
},
|
||||
AUSBOSS_NODES_SelectFrame: { inputName: "frames", noun: "frames" },
|
||||
// Its result is the file it just saved; the card sits above the panel.
|
||||
// SAVE shows only for a run made with Auto save off, which holds its
|
||||
// pictures until this button keeps them.
|
||||
AUSBOSS_NODES_SaveImage: {
|
||||
inputName: "images",
|
||||
noun: "an image",
|
||||
switchTitle: "Show the saved image here. The file is saved either way.",
|
||||
steps: true,
|
||||
tools: [
|
||||
{ label: "SAVE", title: "Save what is showing: every picture, or the one the arrows picked.", action: keepHeld, held: true },
|
||||
],
|
||||
},
|
||||
AUSBOSS_NODES_SaveImage: { inputName: "images", noun: "an image", switchTitle: "Show the saved image here. The file is saved either way.", steps: true },
|
||||
};
|
||||
|
||||
// The picture sits out of flow, centred in the stage, so it can never set
|
||||
@@ -146,35 +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-grid{display:none;position:absolute;inset:0;box-sizing:border-box;gap:${GRID_GAP}px;padding:${GRID_GAP}px;justify-content:center;align-content:center;}
|
||||
.ausboss-input-preview-stage.show-grid .ausboss-input-preview-grid{display:grid;}
|
||||
.ausboss-input-preview-stage .ausboss-input-preview-grid img{display:block;position:static;margin:0;width:100%;height:100%;min-width:0;min-height:0;max-width:none;max-height:none;object-fit:contain;}
|
||||
.ausboss-input-preview-hint{display:none;max-width:86%;color:${T.faint};font:11px/1.35 ${FONT_UI};text-align:center;}
|
||||
.ausboss-input-preview-stage:not(.show-image):not(.show-video):not(.show-grid) .ausboss-input-preview-hint{display:block;}
|
||||
.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[hidden]{display:none;}
|
||||
.ausboss-input-preview-tool.keep{color:${T.brandInk};background:${T.brandSoft};}
|
||||
.ausboss-input-preview-stage .ausboss-input-preview-grid img.kept{outline:2px solid ${T.brand};outline-offset:-2px;}
|
||||
.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);
|
||||
}
|
||||
@@ -357,7 +334,7 @@ function installStepsSwitch(state, signal) {
|
||||
if (!widget) return;
|
||||
state.stepsWidget = widget;
|
||||
hideWidget(widget);
|
||||
const button = panelSwitch("Preview steps", "Show the picture here while it is being made, one step at a time, with a Stop button. Off by default.");
|
||||
const button = panelSwitch("Steps", "Show the picture here while it is being made, one step at a time, with a Stop button. Off by default.");
|
||||
button.classList.add("steps");
|
||||
button.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
|
||||
button.addEventListener("click", (event) => {
|
||||
@@ -467,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;
|
||||
@@ -509,14 +486,13 @@ function buildTools(state, tools, signal) {
|
||||
tool.action(state);
|
||||
}, { signal });
|
||||
state.toolButtons[tool.label] = button;
|
||||
if (tool.held) { button.hidden = true; state.keepButton = button; }
|
||||
bar.append(button);
|
||||
}
|
||||
return bar;
|
||||
}
|
||||
|
||||
function showHint(state, text) {
|
||||
state.stage.classList.remove("show-image", "show-video", "show-grid");
|
||||
state.stage.classList.remove("show-image", "show-video");
|
||||
state.hint.textContent = text;
|
||||
state.placeholder = null;
|
||||
releaseSteps(state);
|
||||
@@ -531,7 +507,6 @@ function showPlaceholder(state, connected) {
|
||||
function showMedia(state, kind) {
|
||||
state.stage.classList.toggle("show-image", kind === "image");
|
||||
state.stage.classList.toggle("show-video", kind === "video");
|
||||
state.stage.classList.toggle("show-grid", kind === "grid");
|
||||
state.placeholder = null;
|
||||
releaseSteps(state);
|
||||
}
|
||||
@@ -540,65 +515,16 @@ function clearMedia(state) {
|
||||
state.img.removeAttribute("src");
|
||||
state.video.pause?.();
|
||||
state.video.removeAttribute("src");
|
||||
clearGrid(state);
|
||||
}
|
||||
|
||||
function clearGrid(state) {
|
||||
if (!state.gridKey) return;
|
||||
state.gridKey = "";
|
||||
state.grid?.replaceChildren();
|
||||
}
|
||||
|
||||
// Every picture of the run at once, each as big as the stage allows.
|
||||
function layoutGrid(state) {
|
||||
const cells = [...(state.grid?.children ?? [])];
|
||||
if (!cells.length) return;
|
||||
const loaded = cells.find((cell) => cell.naturalWidth > 0 && cell.naturalHeight > 0);
|
||||
const aspect = loaded ? loaded.naturalWidth / loaded.naturalHeight : 1;
|
||||
const { columns, rows } = gridShape(cells.length, state.stage.clientWidth, state.stage.clientHeight, aspect);
|
||||
// Tracks the size of one picture, so the pictures sit together in the
|
||||
// middle of the stage with no bands between the rows.
|
||||
const wide = (state.stage.clientWidth - GRID_GAP * (columns + 1)) / columns;
|
||||
const tall = (state.stage.clientHeight - GRID_GAP * (rows + 1)) / rows;
|
||||
if (!(wide > 0) || !(tall > 0)) return;
|
||||
const width = Math.floor(Math.min(wide, tall * aspect));
|
||||
const height = Math.floor(width / aspect);
|
||||
state.grid.style.gridTemplateColumns = `repeat(${columns}, ${width}px)`;
|
||||
state.grid.style.gridTemplateRows = `repeat(${rows}, ${height}px)`;
|
||||
}
|
||||
|
||||
function showGrid(state, count) {
|
||||
const urls = Array.from({ length: count }, (_, index) => storedResultUrl(state.node, index) ?? "");
|
||||
const key = urls.join("\n");
|
||||
if (state.gridKey !== key) {
|
||||
state.gridKey = key;
|
||||
state.grid.replaceChildren(...urls.map((url, index) => {
|
||||
const cell = document.createElement("img");
|
||||
cell.alt = `Picture ${index + 1} of ${count}`;
|
||||
cell.decoding = "async";
|
||||
// The first picture to load tells the grid the shape to lay out for.
|
||||
cell.addEventListener("load", () => layoutGrid(state), { once: true });
|
||||
cell.src = url;
|
||||
return cell;
|
||||
}));
|
||||
}
|
||||
state.img.removeAttribute("src");
|
||||
state.video.pause?.();
|
||||
state.video.removeAttribute("src");
|
||||
layoutGrid(state);
|
||||
showMedia(state, "grid");
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,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
|
||||
@@ -639,9 +558,8 @@ function storedResultUrl(node, index = null) {
|
||||
}
|
||||
|
||||
// Several pictures from one run (a list, or a batch Save Image saved one by
|
||||
// one): they all show side by side, and the arrows in the bar go on to each
|
||||
// one by itself and back round to all of them. One picture keeps the panel
|
||||
// as it was, with no arrows.
|
||||
// one): the arrows in the bar pick which one shows. One picture keeps the
|
||||
// panel as it was, with no arrows.
|
||||
function resultCount(state) {
|
||||
if (state.stale || state.resultGone) return 0;
|
||||
return outputRecordCount(app.nodeOutputs?.[outputLocatorId(state.node)]);
|
||||
@@ -657,125 +575,21 @@ function previewMinWidth(state) {
|
||||
return Math.max(PANEL_MIN_WIDTH, Math.ceil(state.node.size[0] - state.bar.clientWidth + needed));
|
||||
}
|
||||
|
||||
// --- pictures held until Save (Save Image with Auto save off) ---------------
|
||||
|
||||
// The pictures this node is holding, in the order it shows them. Empty when
|
||||
// the last run saved its pictures itself.
|
||||
function heldPictures(state) {
|
||||
if (state.stale || state.resultGone) return [];
|
||||
return heldPlaces(app.nodeOutputs?.[outputLocatorId(state.node)]);
|
||||
}
|
||||
|
||||
// Which of the shown pictures are kept, by their place on the node.
|
||||
function keptOf(state, held) {
|
||||
const key = held.map((picture) => `${picture.token}_${picture.index}`).join(" ");
|
||||
if (state.kept?.key !== key) state.kept = { key, indexes: new Set() };
|
||||
return state.kept.indexes;
|
||||
}
|
||||
|
||||
// A click keeps every picture, or only the one the arrows picked. With the
|
||||
// preview off there is no picture to pick, so it keeps them all.
|
||||
function keepsAll(state, count) {
|
||||
return count < 2 || showsAll(state, count) || !previewEnabled(state);
|
||||
}
|
||||
|
||||
// The button says what a click will do: SAVE ALL over the grid, SAVE over
|
||||
// one picture, SAVED once there is nothing left to keep there.
|
||||
function syncHeld(state, count = resultCount(state)) {
|
||||
const button = state.keepButton;
|
||||
if (!button) return;
|
||||
const held = heldPictures(state);
|
||||
button.hidden = !held.length || count < 1;
|
||||
if (button.hidden) return;
|
||||
const kept = keptOf(state, held);
|
||||
const all = keepsAll(state, count);
|
||||
const done = all ? kept.size >= held.length : kept.has(state.index);
|
||||
button.textContent = state.keeping ? "SAVING" : done ? "SAVED" : all && count > 1 ? "SAVE ALL" : "SAVE";
|
||||
button.classList.toggle("keep", !done);
|
||||
button.disabled = Boolean(state.keeping);
|
||||
[...(state.grid?.children ?? [])].forEach((cell, index) => cell.classList.toggle("kept", kept.has(index)));
|
||||
}
|
||||
|
||||
async function keepHeld(state) {
|
||||
const held = heldPictures(state);
|
||||
if (!held.length || state.keeping) return;
|
||||
const count = resultCount(state);
|
||||
const wanted = keepsAll(state, count) ? held.map((_, place) => place) : [state.index];
|
||||
// One request for each run, in the order the pictures show.
|
||||
const runs = new Map();
|
||||
for (const place of wanted) {
|
||||
const picture = held[place];
|
||||
if (!picture) continue;
|
||||
if (!runs.has(picture.token)) runs.set(picture.token, []);
|
||||
runs.get(picture.token).push(picture.index);
|
||||
}
|
||||
state.keeping = true;
|
||||
syncHeld(state);
|
||||
const kept = keptOf(state, held);
|
||||
const saved = [];
|
||||
let skipped = 0;
|
||||
try {
|
||||
for (const [token, picks] of runs) {
|
||||
const response = await api.fetchApi("/ausboss/save_image/keep", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, picks }),
|
||||
});
|
||||
const answer = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(answer.error || "Could not save.");
|
||||
if (!state.alive) return;
|
||||
const done = new Set(answer.kept ?? []);
|
||||
held.forEach((picture, place) => {
|
||||
if (picture.token === token && done.has(picture.index)) kept.add(place);
|
||||
});
|
||||
saved.push(...(answer.saved ?? []));
|
||||
skipped += answer.skipped?.length ?? 0;
|
||||
}
|
||||
toast(state, saved.length === 1
|
||||
? `Saved ${saved[0].path}`
|
||||
: saved.length
|
||||
? `Saved ${saved.length} pictures`
|
||||
: skipped ? "Not saved: the file already exists" : "Already saved");
|
||||
} catch (error) {
|
||||
if (state.alive) toast(state, error?.message || "Could not save.");
|
||||
} finally {
|
||||
state.keeping = false;
|
||||
if (state.alive) syncHeld(state);
|
||||
}
|
||||
}
|
||||
|
||||
function showsAll(state, count) {
|
||||
return count > 1 && count <= GRID_MAX && state.all !== false;
|
||||
}
|
||||
|
||||
function stepPicture(state, step) {
|
||||
const count = resultCount(state);
|
||||
if (count < 2) return;
|
||||
if (count > GRID_MAX) {
|
||||
state.index = stepIndex(state.index, count, step);
|
||||
} else {
|
||||
const at = stepWithAll(showsAll(state, count) ? ALL : state.index, count, step);
|
||||
state.all = at === ALL;
|
||||
if (!state.all) state.index = at;
|
||||
}
|
||||
state.index = stepIndex(state.index, count, step);
|
||||
refresh(state);
|
||||
}
|
||||
|
||||
function refresh(state) {
|
||||
if (!state.alive) return;
|
||||
if (!previewEnabled(state)) {
|
||||
clearMedia(state);
|
||||
syncHeld(state);
|
||||
return;
|
||||
}
|
||||
const source = upstreamNode(state.node, state.inputName);
|
||||
rewatchSource(state, source);
|
||||
const count = resultCount(state);
|
||||
const hidden = state.stepper.root.hidden;
|
||||
state.index = keepIndex(state.index, count);
|
||||
const all = showsAll(state, count);
|
||||
state.stepper.set(state.index, count, all ? allLabel(count) : undefined);
|
||||
syncHeld(state, count);
|
||||
state.stepper.set(state.index, count);
|
||||
if (state.stepper.root.hidden !== hidden) growToNodeFloor(state.widget);
|
||||
if (state.stale) {
|
||||
// The node ran while its preview was off, so the picture it still has
|
||||
@@ -787,13 +601,6 @@ function refresh(state) {
|
||||
// A node that just stopped the run has no result for this picture: the
|
||||
// one it still holds is from an earlier run (Mask by Name's last find,
|
||||
// tinted), so show what feeds it instead.
|
||||
if (all) {
|
||||
showGrid(state, count);
|
||||
// The grid's cells are new: mark the ones already kept.
|
||||
syncHeld(state, count);
|
||||
return;
|
||||
}
|
||||
clearGrid(state);
|
||||
const described = state.resultGone
|
||||
? describeSourcePreview(source)
|
||||
: count > 1
|
||||
@@ -879,9 +686,7 @@ function buildPanel(node, config) {
|
||||
const toastEl = document.createElement("div");
|
||||
toastEl.className = "ausboss-input-preview-toast";
|
||||
toastEl.style.display = "none";
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "ausboss-input-preview-grid";
|
||||
stage.append(img, video, grid, hint, toastEl);
|
||||
stage.append(img, video, hint, toastEl);
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "ausboss-input-preview-bar";
|
||||
root.append(bar, stage);
|
||||
@@ -915,10 +720,7 @@ function buildPanel(node, config) {
|
||||
noun: config.noun,
|
||||
switchTitle: config.switchTitle,
|
||||
advanced: config.advanced,
|
||||
root, bar, stage, img, video, grid, hint, widget, abort,
|
||||
// Several pictures open side by side; the arrows pick one (false).
|
||||
all: true,
|
||||
gridKey: "",
|
||||
root, bar, stage, img, video, hint, widget, abort,
|
||||
toast: toastEl,
|
||||
toastTimer: 0,
|
||||
toolButtons: null,
|
||||
@@ -952,12 +754,6 @@ function buildPanel(node, config) {
|
||||
installPreviewSwitch(state, abort.signal);
|
||||
if (config.steps) installStepsSwitch(state, abort.signal);
|
||||
|
||||
// The grid is laid out for the stage's shape, so a resized node lays it out again.
|
||||
if (typeof ResizeObserver === "function") {
|
||||
const watcher = new ResizeObserver(() => { if (state.alive && state.gridKey) layoutGrid(state); });
|
||||
watcher.observe(stage);
|
||||
abort.signal.addEventListener("abort", () => watcher.disconnect(), { once: true });
|
||||
}
|
||||
img.addEventListener("load", () => showMedia(state, "image"));
|
||||
img.addEventListener("error", () => {
|
||||
if (img.getAttribute("src")) showPlaceholder(state, true);
|
||||
@@ -1029,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] })),
|
||||
);
|
||||
|
||||
@@ -47,24 +47,6 @@ async function stopLook(node) {
|
||||
return Boolean(running || waiting.length);
|
||||
}
|
||||
|
||||
// Take this node's own runs that still wait out of the queue: the looks and
|
||||
// touches its picture asked for (they carry an `ask`), never a run of the
|
||||
// workflow. Says how many went. The one that is running is left alone.
|
||||
async function dropWaiting(node) {
|
||||
const id = executionIdOf(app.rootGraph ?? app.graph, node);
|
||||
if (!id) return 0;
|
||||
const queue = await (await api.fetchApi("/queue")).json();
|
||||
const ours = (entry) => {
|
||||
const step = entry?.[2]?.[id];
|
||||
return step?.class_type === NODE_CLASS && String(step.inputs?.ask ?? "") !== "";
|
||||
};
|
||||
const waiting = (queue?.queue_pending ?? []).filter(ours).map((entry) => entry[1]);
|
||||
if (waiting.length) {
|
||||
await api.fetchApi("/queue", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: waiting }) });
|
||||
}
|
||||
return waiting.length;
|
||||
}
|
||||
|
||||
// The picture goes above the settings: the stage widget sits ahead of the
|
||||
// node's card, whichever of the two was built first.
|
||||
function stageFirst(node) {
|
||||
@@ -110,7 +92,6 @@ function build(node) {
|
||||
const face = mountTouchStage(node, {
|
||||
lookOnButton: () => lookOnButton,
|
||||
stopLook: () => stopLook(node).catch(() => false),
|
||||
dropWaiting: () => dropWaiting(node).catch(() => 0),
|
||||
// A workflow is being opened: nothing is run on the person's behalf.
|
||||
loading: () => Boolean(app.configuringGraph),
|
||||
// Bypassed or muted, or inside a box that is: nothing is run for it.
|
||||
@@ -187,18 +168,13 @@ api.addEventListener("execution_error", ({ detail }) => {
|
||||
api.addEventListener("execution_interrupted", () => settleAll());
|
||||
// A look is one long, quiet run. The node sends what it has found every
|
||||
// second or two, so things light up while it is still looking.
|
||||
// A run that stops at the node (nothing picked on this picture) sends the
|
||||
// picture that came in first, so a picture that changed upstream shows at
|
||||
// once. It is shown like a kept note: it starts no run of its own.
|
||||
api.addEventListener(LOOK_EVENT, ({ detail }) => {
|
||||
try {
|
||||
if (!faces.size || !detail?.note) return;
|
||||
const id = String(detail.node ?? "");
|
||||
const root = app.rootGraph ?? app.graph;
|
||||
for (const face of faces) {
|
||||
if (!face.alive || executionIdOf(root, face.node) !== id) continue;
|
||||
if (detail.stopped) face.takeNote(detail.note, { restored: true });
|
||||
else if (face.inFlight) face.takeNote(detail.note, { interim: true });
|
||||
if (face.alive && face.inFlight && executionIdOf(root, face.node) === id) face.takeNote(detail.note, { interim: true });
|
||||
}
|
||||
} catch {
|
||||
// Never in the way of a run.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,7 +26,6 @@ import { nodeByExecutionId } from "../shared/graph_ids.mjs";
|
||||
import {
|
||||
collapseRepeatedErrors,
|
||||
emptySourceMessage,
|
||||
explainRunError,
|
||||
findEmptySources,
|
||||
nodeLacksSource,
|
||||
} from "../shared/run_check.mjs";
|
||||
@@ -175,20 +174,10 @@ function collapseAnswers() {
|
||||
api.queuePrompt = collapsing;
|
||||
}
|
||||
|
||||
// A run that stops on an error the pack knows the cause of gets one toast
|
||||
// that says what to do. ComfyUI's own error report is left as it is.
|
||||
function explainKnownErrors() {
|
||||
api.addEventListener("execution_error", ({ detail }) => {
|
||||
const known = explainRunError(detail);
|
||||
if (known) showToast({ severity: "warn", summary: known.summary, detail: known.detail, life: 30000 });
|
||||
});
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ausboss.run_check",
|
||||
setup() {
|
||||
guardRuns();
|
||||
collapseAnswers();
|
||||
explainKnownErrors();
|
||||
},
|
||||
});
|
||||
|
||||
+4
-26
@@ -29,11 +29,11 @@ const NODE_MIN_WIDTH = 340;
|
||||
const NAME_TITLE = "The local filename; subfolders are allowed. The tags below are appended to it. Tokens work: %date:yyyy-MM-dd%, %width%, %KSampler.seed%.";
|
||||
// Captions, fields, the two-line preview, chips, pills, the embed row and
|
||||
// the gaps between them; the frontend's wrapper insets ride on top.
|
||||
const CARD_HEIGHT = 320;
|
||||
const CARD_HEIGHT = 288;
|
||||
const WRAPPER_INSET = 16;
|
||||
const HIDDEN = [
|
||||
"filename_prefix", "format", "save_metadata", "exact_name", "on_existing",
|
||||
"output_dir", "caption", "name_counter", "name_date", "name_time", "name_size", "name_batch", "autosave",
|
||||
"output_dir", "caption", "name_counter", "name_date", "name_time", "name_size", "name_batch",
|
||||
];
|
||||
const FONT = `-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif`;
|
||||
const MONO = `ui-monospace,SFMono-Regular,Menlo,Consolas,monospace`;
|
||||
@@ -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");
|
||||
@@ -285,22 +285,6 @@ function buildCard(node) {
|
||||
embedRow.append(el("span", "ausboss-save-embed-label", PANEL_LABELS.embed), embedSeg);
|
||||
root.append(embedRow);
|
||||
|
||||
// Auto save: off shows the pictures without saving them; the Save button
|
||||
// over the picture (js/input_preview) then keeps the ones that are wanted.
|
||||
const autoRow = el("div", "ausboss-save-row");
|
||||
const autoSeg = el("div", "ausboss-card-seg ausboss-card-bool");
|
||||
autoSeg.setAttribute("role", "radiogroup");
|
||||
const autoButtons = new Map();
|
||||
const autoTitle = "On: every run saves its pictures. Off: the pictures only show here, and the Save button over them keeps the ones you want.";
|
||||
for (const [on, text] of [[false, PANEL_LABELS.off], [true, PANEL_LABELS.on]]) {
|
||||
const button = el("button", "", text);
|
||||
button.type = "button"; button.title = autoTitle;
|
||||
button.addEventListener("click", () => { if (setValue(node, "autosave", on)) { state.savedPath = null; notifyAusbossChange(); } state.refresh(); });
|
||||
autoSeg.append(button); autoButtons.set(on, button);
|
||||
}
|
||||
autoRow.append(el("span", "ausboss-save-embed-label", "Auto save"), autoSeg);
|
||||
root.append(autoRow);
|
||||
|
||||
root.addEventListener("pointerdown", (event) => {
|
||||
if (event.target.closest("button,input,select")) event.stopPropagation();
|
||||
});
|
||||
@@ -346,11 +330,6 @@ function buildCard(node) {
|
||||
button.classList.toggle("on", on === embed);
|
||||
button.setAttribute("aria-checked", String(on === embed));
|
||||
}
|
||||
const auto = values.autosave !== false;
|
||||
for (const [on, button] of autoButtons) {
|
||||
button.classList.toggle("on", on === auto);
|
||||
button.setAttribute("aria-checked", String(on === auto));
|
||||
}
|
||||
};
|
||||
|
||||
const domWidget = node.addDOMWidget(WIDGET_NAME, WIDGET_NAME, root, {
|
||||
@@ -391,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();
|
||||
|
||||
@@ -39,7 +39,7 @@ function getLoopEnabled(node) {
|
||||
return !!node.properties.ausboss_save_video_loop;
|
||||
}
|
||||
|
||||
// The Preview steps switch: the hidden `step_preview` widget (the card hides it).
|
||||
// The STEPS button: the hidden `step_preview` widget (the card hides it).
|
||||
// On, the player shows each step of the clip's first frame while it is
|
||||
// being made, and a STOP button joins the tools for the run.
|
||||
function stepsOn(state) {
|
||||
@@ -131,7 +131,7 @@ function buildPreview(node) {
|
||||
loopButton.classList.add("ausboss-steps-loop");
|
||||
const stopButton = makeToolButton("STOP", "Stop this run now. The next run in the queue still starts.");
|
||||
stopButton.classList.add("ausboss-steps-stop");
|
||||
const stepsButton = panelSwitch("Preview steps", "Show the clip's first frame here while it is being made, one step at a time, with a Stop button. Off by default.");
|
||||
const stepsButton = panelSwitch("Steps", "Show the clip's first frame here while it is being made, one step at a time, with a Stop button. Off by default.");
|
||||
tools.classList.add("ausboss-steps-tools");
|
||||
tools.append(stopButton, loopButton);
|
||||
stage.append(video, image, status, tools);
|
||||
|
||||
@@ -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.1";
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -52,10 +52,9 @@ export function makePictureStepper({ noun = "picture", height = 24, onStep, sign
|
||||
root.append(arrow("‹", -1, `Previous ${noun}`), label, arrow("›", 1, `Next ${noun}`));
|
||||
return {
|
||||
root,
|
||||
// `text` replaces the "2 / 5" for a stop that is not one picture.
|
||||
set(index, count, text) {
|
||||
set(index, count) {
|
||||
root.hidden = !(count > 1);
|
||||
label.textContent = text ?? stepperLabel(index, count);
|
||||
label.textContent = stepperLabel(index, count);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,53 +19,3 @@ export function stepIndex(index, count, step) {
|
||||
export function stepperLabel(index, count) {
|
||||
return count > 1 ? `${keepIndex(index, count) + 1} / ${count}` : "";
|
||||
}
|
||||
|
||||
// The preview panel has one more stop, before picture 1: every picture at
|
||||
// once. ALL stands for it where an index would be.
|
||||
export const ALL = -1;
|
||||
|
||||
// One step back or on through "all, 1, 2 ... count", round at both ends.
|
||||
export function stepWithAll(index, count, step) {
|
||||
if (!(count > 0)) return ALL;
|
||||
const stops = count + 1;
|
||||
const at = index === ALL ? 0 : keepIndex(index, count) + 1;
|
||||
const next = (((at + Math.sign(Number(step) || 0)) % stops) + stops) % stops;
|
||||
return next === 0 ? ALL : next - 1;
|
||||
}
|
||||
|
||||
export function allLabel(count) {
|
||||
return count > 1 ? `all ${count}` : "";
|
||||
}
|
||||
|
||||
// How to lay `count` pictures out in a box so each one draws as big as it
|
||||
// can: the columns and rows that leave a picture of this shape (`aspect`,
|
||||
// width over height) the most room.
|
||||
export function gridShape(count, width, height, aspect = 1) {
|
||||
const total = Math.max(1, Math.trunc(Number(count) || 0));
|
||||
const shape = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
|
||||
if (!(width > 0) || !(height > 0)) {
|
||||
const columns = Math.ceil(Math.sqrt(total));
|
||||
return { columns, rows: Math.ceil(total / columns) };
|
||||
}
|
||||
let best = { columns: 1, rows: total, size: 0 };
|
||||
for (let columns = 1; columns <= total; columns += 1) {
|
||||
const rows = Math.ceil(total / columns);
|
||||
const size = Math.min(width / columns, (height / rows) * shape);
|
||||
if (size > best.size) best = { columns, rows, size };
|
||||
}
|
||||
return { columns: best.columns, rows: best.rows };
|
||||
}
|
||||
|
||||
// The held pictures in a Save Image result, in the order they show: each
|
||||
// one's run token and its place in that run. A list run saves once for each
|
||||
// item, so one result can hold several tokens. Empty when the result holds
|
||||
// none, or when a picture in it is not one of the held ones.
|
||||
export function heldPlaces(record) {
|
||||
const tokens = new Set(Array.isArray(record?.ausboss_held) ? record.ausboss_held : []);
|
||||
if (!tokens.size) return [];
|
||||
const held = (Array.isArray(record.images) ? record.images : []).map((image) => {
|
||||
const match = /^([0-9a-f]{16})_(\d{3})\./.exec(String(image?.filename ?? ""));
|
||||
return match && tokens.has(match[1]) ? { token: match[1], index: Number(match[2]) } : null;
|
||||
});
|
||||
return held.length && held.every(Boolean) ? held : [];
|
||||
}
|
||||
|
||||
@@ -160,51 +160,3 @@ export function collapseRepeatedErrors(nodeErrors) {
|
||||
}
|
||||
return nodeErrors;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ known run errors
|
||||
//
|
||||
// Some errors that stop a run come from outside the workflow and say nothing
|
||||
// a person can act on ("KeyError: 'latent'"). Each entry here names one such
|
||||
// error by what is in ComfyUI's own report, and says in plain words what to
|
||||
// do. An entry is added only after the cause was found for real.
|
||||
const KNOWN_RUN_ERRORS = Object.freeze([
|
||||
{
|
||||
// Muse MiniMax Director replaces part of H3 for every workflow, and that
|
||||
// part reads a picture from every guide. ComfyUI's own H3 nodes also make
|
||||
// guides that hold sound alone, which is how a clip keeps its sound.
|
||||
matches: ({ type, message, traceback }) =>
|
||||
type === "KeyError" && message.includes("'latent'") && traceback.includes("muse_minimax_hybrid_patch"),
|
||||
summary: "Another node pack stopped this run",
|
||||
detail:
|
||||
"This error comes from the Muse MiniMax Director pack, not from this workflow. Turn that pack off in "
|
||||
+ "Manager, restart ComfyUI and run again.",
|
||||
},
|
||||
{
|
||||
// The w6a8 H3 files load from ComfyUI 0.38.0 on; older versions stop in
|
||||
// the model loader on the name of the format.
|
||||
matches: ({ message }) => message.includes("w6a8_int8"),
|
||||
summary: "This model needs a newer ComfyUI",
|
||||
detail:
|
||||
"Your ComfyUI is too old to read this model file. Update ComfyUI to 0.38 or newer and run again.",
|
||||
},
|
||||
]);
|
||||
|
||||
/**
|
||||
* Plain advice for a run error the pack knows the cause of, from the
|
||||
* `execution_error` event's detail. `null` for every other error.
|
||||
*/
|
||||
export function explainRunError(detail) {
|
||||
const report = {
|
||||
type: String(detail?.exception_type ?? "").split(".").pop(),
|
||||
message: String(detail?.exception_message ?? ""),
|
||||
traceback: [].concat(detail?.traceback ?? []).join("\n"),
|
||||
};
|
||||
for (const known of KNOWN_RUN_ERRORS) {
|
||||
try {
|
||||
if (known.matches(report)) return { summary: known.summary, detail: known.detail };
|
||||
} catch (_error) {
|
||||
// A report in a shape this entry did not expect is simply not a match.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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")`;
|
||||
@@ -74,25 +74,6 @@ export function loaderSignature(widgets) {
|
||||
return JSON.stringify(settings);
|
||||
}
|
||||
|
||||
// What the picture says when a run stopped at the node. ComfyUI's own text
|
||||
// for a full graphics card is ten lines of numbers; here it is one plain
|
||||
// line. Anything else is said as it came.
|
||||
export const OUT_OF_MEMORY = "The graphics card ran out of memory, so the node could not look. Free some memory on it, then click again.";
|
||||
export function stopMessage(message) {
|
||||
const text = String(message ?? "");
|
||||
return /out of memory|OutOfMemory/i.test(text) ? OUT_OF_MEMORY : text;
|
||||
}
|
||||
|
||||
// A name as it is typed under the picture: "-face" (or the minus sign a
|
||||
// phone types) takes the name away, "+face" adds it; without a sign it does
|
||||
// what `takes` says. Returns { word, add }, the word cleaned.
|
||||
export function typedName(text, takes = false) {
|
||||
const typed = String(text ?? "").trim();
|
||||
const sign = /^[-\u2212\u2013+]/.exec(typed)?.[0] ?? "";
|
||||
const word = cleanWord(sign ? typed.slice(1) : typed);
|
||||
return { word, add: sign ? sign === "+" : !takes };
|
||||
}
|
||||
|
||||
// A word as the node sends it to SAM 3: commas, colons and brackets are the
|
||||
// text reader's own syntax and go; spaces are collapsed.
|
||||
export function cleanWord(word) {
|
||||
@@ -287,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.
|
||||
@@ -1063,46 +1035,19 @@ export function strokePieces(planes, list, sizes, pixels, stroke, ranks, wholes
|
||||
// picked area, and as untouched while almost none of it is.
|
||||
const NEARLY = 0.03;
|
||||
|
||||
// Which pieces hold a whole pick: nearly all of one picked area (`areas`,
|
||||
// on/off masks, one per chip) lies inside the piece. A person holds the
|
||||
// sweater picked on them; hair that a picked jacket only laps over does not.
|
||||
export const HOLDS = 0.9;
|
||||
export function piecesHolding(planes, count, areas) {
|
||||
const holds = new Uint8Array(count);
|
||||
for (const area of areas ?? []) {
|
||||
if (!area) continue;
|
||||
let size = 0;
|
||||
for (let index = 0; index < area.length; index += 1) if (area[index]) size += 1;
|
||||
if (!size) continue;
|
||||
const inside = piecesInside(planes, count, area);
|
||||
for (let piece = 0; piece < count; piece += 1) if (inside[piece] >= HOLDS * size) holds[piece] = 1;
|
||||
}
|
||||
return holds;
|
||||
}
|
||||
|
||||
// Which piece a pointer at pixel `index` lights up: the first piece there,
|
||||
// in the order they are offered (`ranks`; without it, smallest first), that
|
||||
// would still change something. Adding, that is a piece not yet (nearly)
|
||||
// all picked, so clicking the same spot again goes on from the glasses to
|
||||
// the face to the person. Taking away, it is a piece that has some of the
|
||||
// picked area in it. -1 when there is none.
|
||||
//
|
||||
// Taking away from a spot that is not picked itself (`here` false) never
|
||||
// means a thing that holds a whole pick somewhere else (`holds`, from
|
||||
// piecesHolding): with a sweater picked, pointing at the face does not
|
||||
// offer the person, which would take the sweater along.
|
||||
export function pieceAt(planes, index, { sizes, inside, adding = true, ranks = null, here = true, holds = null } = {}) {
|
||||
export function pieceAt(planes, index, { sizes, inside, adding = true, ranks = null } = {}) {
|
||||
for (const piece of candidatesAt(planes, index, ranks)) {
|
||||
const size = sizes?.[piece] ?? 0;
|
||||
if (!size) continue;
|
||||
const picked = inside?.[piece] ?? 0;
|
||||
const slack = Math.max(4, size * NEARLY);
|
||||
if (adding) {
|
||||
if (size - picked > slack) return piece;
|
||||
continue;
|
||||
}
|
||||
if (picked <= slack || (!here && holds?.[piece])) continue;
|
||||
return piece;
|
||||
if (adding ? size - picked > slack : picked > slack) return piece;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
+30
-142
@@ -63,7 +63,6 @@ import {
|
||||
loaderSignature,
|
||||
maskOn,
|
||||
maskShare,
|
||||
movedTouches,
|
||||
nameShown,
|
||||
nextGroup,
|
||||
numberChip,
|
||||
@@ -77,7 +76,6 @@ import {
|
||||
pieceRanks,
|
||||
pieceWholes,
|
||||
pieceSizes,
|
||||
piecesHolding,
|
||||
piecesInside,
|
||||
prefixStamps,
|
||||
previewPixels,
|
||||
@@ -86,11 +84,9 @@ import {
|
||||
sharedPixels,
|
||||
shrinkMask,
|
||||
stampOf,
|
||||
stopMessage,
|
||||
strokePieces,
|
||||
thinStroke,
|
||||
touchFits,
|
||||
typedName,
|
||||
} from "./touch_math.mjs";
|
||||
|
||||
const CSS_ID = "ausboss-touch-css";
|
||||
@@ -99,9 +95,8 @@ export const NOTE_KEY = "ausboss_mask_by_touch";
|
||||
const TOOLS_HEIGHT = 24;
|
||||
const BAR_HEIGHT = 20;
|
||||
const STAGE_MIN = 150;
|
||||
// Wide enough for the tools over the picture (Reload, Undo, Clear and the eye
|
||||
// included) and the bar under it: the arrows, a pick's name, Subject and Background.
|
||||
export const PANEL_MIN_WIDTH = 428;
|
||||
// Wide enough for the bar under the picture: the arrows, a pick's name, Subject and Background.
|
||||
export const PANEL_MIN_WIDTH = 400;
|
||||
// What the panel's element needs at its smallest: tools, stage and bar, two
|
||||
// gaps and the padding under them.
|
||||
const CHIPS_HEIGHT = 22;
|
||||
@@ -109,9 +104,9 @@ const CHIPS_HEIGHT = 22;
|
||||
const CHIP_ROWS = 3;
|
||||
// A node this wide has room for the names in front of the brush's two
|
||||
// numbers. A narrower one shows the numbers alone.
|
||||
const LABELS_FROM = 470;
|
||||
const LABELS_FROM = 440;
|
||||
// The width a new node opens with: the names fit.
|
||||
export const OPENING_WIDTH = 490;
|
||||
export const OPENING_WIDTH = 460;
|
||||
const PANEL_NEEDS = TOOLS_HEIGHT + STAGE_MIN + BAR_HEIGHT + CHIPS_HEIGHT + 12 + 6;
|
||||
// Saved with the workflow: the brush size once it was set by hand, the snap
|
||||
// switch, and how the picked area is shown.
|
||||
@@ -139,8 +134,6 @@ const EYE_TITLES = {
|
||||
// The eye beside Undo and Clear, drawn here: an almond and its pupil.
|
||||
const EYE_ICON = '<svg width="16" height="16" viewBox="-8 -8 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true">'
|
||||
+ '<path d="M-6.6 0Q0 -6 6.6 0Q0 6 -6.6 0Z"/><circle r="2" fill="currentColor" stroke="none"/></svg>';
|
||||
const RELOAD_ICON = '<svg width="14" height="14" viewBox="-8 -8 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
|
||||
+ '<path d="M5.6 -2.4A6 6 0 1 0 5.2 3"/><path d="M6 -6.2V-2.2H2"/></svg>';
|
||||
// The brush size, across the picture, in picture pixels.
|
||||
const BRUSH_SIZE = [4, 1000];
|
||||
// A brush mark counts as touching an area within this many pixels of the
|
||||
@@ -193,17 +186,9 @@ function ensureCss() {
|
||||
.ausboss-touch-chip>button:hover{opacity:1;background:rgba(255,255,255,.14);}
|
||||
.ausboss-touch-chip>button.more{padding:0 6px;background:rgba(0,0,0,.35);opacity:.95;}
|
||||
.ausboss-touch-chip.open>button.more{background:rgba(0,0,0,.2);}
|
||||
.ausboss-touch-namebox{box-sizing:border-box;flex:none;display:inline-flex;align-items:stretch;height:20px;border:1px dashed rgba(0,180,170,.5);border-radius:10px;overflow:hidden;}
|
||||
.ausboss-touch-namebox:focus-within{border-style:solid;border-color:${BRAND};background:rgba(0,0,0,.4);}
|
||||
.ausboss-touch-namebox.away{border-color:rgba(${LIT_AWAY.join(",")},.6);}
|
||||
.ausboss-touch-namebox.away:focus-within{border-color:rgb(${LIT_AWAY.join(",")});}
|
||||
.ausboss-touch-sign{box-sizing:border-box;flex:none;width:22px;padding:0;border:0;border-right:1px solid rgba(0,180,170,.35);background:rgba(0,180,170,.16);color:#dffcf9;font:700 13px/18px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;}
|
||||
.ausboss-touch-sign:hover{background:rgba(0,180,170,.34);color:#fff;}
|
||||
.ausboss-touch-sign:disabled{opacity:.4;cursor:default;}
|
||||
.ausboss-touch-namebox.away .ausboss-touch-sign{border-right-color:rgba(${LIT_AWAY.join(",")},.4);background:rgba(${LIT_AWAY.join(",")},.24);color:#ffe9e2;}
|
||||
.ausboss-touch-namebox.away .ausboss-touch-sign:hover{background:rgba(${LIT_AWAY.join(",")},.42);}
|
||||
.ausboss-touch-name{box-sizing:border-box;flex:none;width:118px;height:18px;padding:0 8px 0 6px;border:0;background:transparent;color:#dffcf9;font:600 10px/18px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;outline:none;}
|
||||
.ausboss-touch-name{box-sizing:border-box;flex:none;width:104px;height:20px;padding:0 9px;border:1px dashed rgba(0,180,170,.5);border-radius:10px;background:transparent;color:#dffcf9;font:600 10px/18px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;outline:none;}
|
||||
.ausboss-touch-name::placeholder{color:#78908e;font-weight:500;}
|
||||
.ausboss-touch-name:focus{border-style:solid;border-color:${BRAND};background:rgba(0,0,0,.4);}
|
||||
.ausboss-touch-gap{flex:1 1 auto;min-width:0;}
|
||||
.ausboss-touch-stage{position:relative;flex:1 1 auto;min-height:0;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-touch-stage{box-sizing:border-box;min-height:${STAGE_MIN}px;}
|
||||
@@ -235,12 +220,9 @@ function ensureCss() {
|
||||
.ausboss-touch.fining .ausboss-touch-fining{display:flex;}
|
||||
.ausboss-touch-fining::before{content:"";flex:none;width:7px;height:7px;border-radius:50%;background:${BRAND};animation:ausboss-touch-pulse 1s ease-in-out infinite;}
|
||||
@keyframes ausboss-touch-pulse{0%,100%{opacity:.25;}50%{opacity:1;}}
|
||||
.ausboss-touch-btn.lookpill{display:inline-flex;align-items:center;gap:5px;height:${BAR_HEIGHT}px;padding:0 9px 0 0;overflow:hidden;border:1px solid rgba(0,180,170,.8);border-radius:10px;letter-spacing:0;text-transform:none;font:600 10px/18px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#dffcf9;background:linear-gradient(90deg,rgba(0,180,170,.38) var(--done,0%),rgba(0,180,170,.08) var(--done,0%));}
|
||||
.ausboss-touch-btn.lookpill{display:inline-flex;align-items:center;gap:5px;height:${BAR_HEIGHT}px;padding:0 9px 0 8px;border:1px solid rgba(0,180,170,.6);border-radius:10px;letter-spacing:0;text-transform:none;font:600 10px/18px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#dffcf9;background:linear-gradient(90deg,rgba(0,180,170,.38) var(--done,0%),rgba(0,180,170,.08) var(--done,0%));}
|
||||
.ausboss-touch-btn.lookpill:hover{border-color:${BRAND};color:#fff;background:linear-gradient(90deg,rgba(0,180,170,.5) var(--done,0%),rgba(0,180,170,.16) var(--done,0%));}
|
||||
.ausboss-touch-btn.lookpill .key{flex:none;display:inline-flex;align-items:center;gap:4px;align-self:stretch;padding:0 7px 0 8px;background:${BRAND};color:#04201e;font-weight:700;}
|
||||
.ausboss-touch-btn.lookpill:hover .key{background:#35d6cb;}
|
||||
.ausboss-touch-btn.lookpill.paused .key{background:#9fb4b2;}
|
||||
.ausboss-touch-btn.lookpill .icon{flex:none;font-size:8px;letter-spacing:-1px;}
|
||||
.ausboss-touch-btn.lookpill .icon{flex:none;font-size:8px;letter-spacing:-1px;opacity:.9;}
|
||||
.ausboss-touch-btn.lookpill.paused{border-color:rgba(120,144,142,.6);color:#c8dddd;background:linear-gradient(90deg,rgba(120,144,142,.3) var(--done,0%),rgba(120,144,142,.06) var(--done,0%));}
|
||||
.ausboss-touch-btn.lookpill.paused .icon{letter-spacing:0;}
|
||||
.ausboss-touch-btn.lookpill .count{opacity:.8;}
|
||||
@@ -259,7 +241,7 @@ function ensureCss() {
|
||||
.ausboss-touch.narrow .ausboss-touch-tools{gap:3px;}
|
||||
.ausboss-touch.narrow .ausboss-touch-field.size .ausboss-scrub{width:56px !important;}
|
||||
.ausboss-touch.narrow .ausboss-touch-field.soft .ausboss-scrub{width:48px !important;}
|
||||
.ausboss-touch-btn.eye,.ausboss-touch-btn.reload{display:inline-flex;align-items:center;justify-content:center;width:26px;min-width:26px;padding:0;}
|
||||
.ausboss-touch-btn.eye{display:inline-flex;align-items:center;justify-content:center;width:26px;min-width:26px;padding:0;}
|
||||
.ausboss-touch-btn.eye.on{border-color:${BRAND};background:${BRAND};color:#04201f;}
|
||||
.ausboss-touch-btn.arrow{min-width:20px;width:20px;padding:0;font-size:14px;line-height:16px;}
|
||||
.ausboss-touch-swatch{box-sizing:border-box;flex:none;width:${BAR_HEIGHT}px;height:${BAR_HEIGHT}px;border-radius:4px;background:#0b1113;border:1px solid rgba(0,180,170,.3);pointer-events:auto;}
|
||||
@@ -541,7 +523,6 @@ function resolve(face) {
|
||||
face.raw = raw;
|
||||
face.exact = exact;
|
||||
face.chipAreas = null;
|
||||
face.holds = null;
|
||||
face.inside = pic.pieces ? piecesInside(pic.pieces.planes, pic.pieces.list.length, raw) : null;
|
||||
face.hoverMask = null;
|
||||
}
|
||||
@@ -712,11 +693,6 @@ function requestRun(face, ask) {
|
||||
return false;
|
||||
}
|
||||
const run = { ask, stamp: face.stamp, epoch: face.sourceEpoch, queued: false };
|
||||
// Runs of this node that are at a picture it no longer has. The one that
|
||||
// is running ends by itself once the next job waits; the ones that still
|
||||
// wait in the queue would each run for nothing, one after the other, so
|
||||
// they are taken out before this one is sent.
|
||||
const stale = face.flying.filter((other) => other.queued && other.epoch !== run.epoch);
|
||||
face.flying.push(run);
|
||||
face.inFlight = true;
|
||||
face.askedAt = performance.now();
|
||||
@@ -727,15 +703,7 @@ function requestRun(face, ask) {
|
||||
face.flying = face.flying.filter((other) => other !== run);
|
||||
if (!face.flying.length) settle(face);
|
||||
};
|
||||
const cleared = stale.length && face.host.dropWaiting
|
||||
? Promise.resolve(face.host.dropWaiting()).then((gone) => {
|
||||
// What was taken out never answers: the newest of the stale ones,
|
||||
// because a run of this node goes to the front of the queue.
|
||||
const out = new Set(stale.slice(Math.max(0, stale.length - (Number(gone) || 0))));
|
||||
if (out.size) face.flying = face.flying.filter((other) => !out.has(other));
|
||||
}, () => {})
|
||||
: Promise.resolve();
|
||||
cleared.then(() => face.host.run(ask === "scope" ? scopeAsk(face) : ask)).then((queued) => {
|
||||
Promise.resolve(face.host.run(ask === "scope" ? scopeAsk(face) : ask)).then((queued) => {
|
||||
if (queued === false) {
|
||||
failed();
|
||||
return;
|
||||
@@ -774,7 +742,7 @@ function settings(face) {
|
||||
grow: Math.round(number("grow")),
|
||||
soften: Math.max(0, number("soften")),
|
||||
smooth: Math.max(0, Math.round(number("smooth"))),
|
||||
fill: widgetByName(face.node, "fill_holes")?.value === true,
|
||||
fill: widgetByName(face.node, "fill_holes")?.value !== false,
|
||||
invert: widgetByName(face.node, "invert")?.value === true,
|
||||
fine: fineChoice(widgetByName(face.node, "fine_edges")?.value),
|
||||
};
|
||||
@@ -1286,23 +1254,11 @@ function takeAll(face, index) {
|
||||
refreshHover(face);
|
||||
}
|
||||
|
||||
// Whether a typed name is taken away: what the sign in front of the field
|
||||
// was set to, or else what the tool does right now (Erase, or Ctrl held).
|
||||
function nameTakes(face) {
|
||||
return face.nameAway ?? face.away;
|
||||
}
|
||||
|
||||
// A typed name, as every thing of that name. "-face" takes it away and
|
||||
// "+face" adds it, whatever the sign says.
|
||||
function addName(face, text) {
|
||||
const typed = typedName(text, nameTakes(face));
|
||||
if (!typed.word || !face.pic || face.held) return false;
|
||||
const word = cleanWord(text);
|
||||
if (!word || !face.pic || face.held) return false;
|
||||
flushPending(face);
|
||||
if (!typed.add && !face.items.some((item) => item.op === 1)) {
|
||||
setNote(face, "Nothing is picked yet. Pick something first, then take a name away from it.", { life: 3200 });
|
||||
return false;
|
||||
}
|
||||
addItem(face, { t: "all", op: typed.add ? 1 : 0, w: typed.word });
|
||||
addItem(face, { t: "all", op: face.away ? 0 : 1, w: word });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1447,25 +1403,14 @@ function syncTools(face) {
|
||||
const waiting = live && face.pending.length > 0;
|
||||
face.undo.disabled = !live || (!some && !face.past.length && !waiting);
|
||||
face.clear.disabled = !some && !waiting;
|
||||
face.reload.disabled = !live || face.inFlight;
|
||||
if (face.lookButton) {
|
||||
const offer = live && face.snap && !face.held && !face.inFlight && !face.pic.scoped && Boolean(face.host.lookOnButton?.());
|
||||
face.lookButton.style.display = offer ? "" : "none";
|
||||
}
|
||||
if (face.nameField) {
|
||||
const off = !live || face.held > 0;
|
||||
const takes = nameTakes(face);
|
||||
face.nameField.disabled = off;
|
||||
face.nameSign.disabled = off;
|
||||
face.nameBox.classList.toggle("away", takes);
|
||||
face.nameSign.textContent = takes ? "\u2212" : "+";
|
||||
face.nameSign.title = takes
|
||||
? "A name you type is taken away from the mask. Click to add by name instead."
|
||||
: "A name you type is added to the mask. Click to take away by name instead.";
|
||||
face.nameField.placeholder = takes ? "take away by name" : "add by name";
|
||||
face.nameField.title = takes
|
||||
? "Type what to take out of the mask, like \"face\" or \"hair\", and press Enter. Every thing of that name is taken away."
|
||||
: "Type what to pick, like \"screen\" or \"earring\", and press Enter. Every thing of that name is taken. Start with a minus, like \"-face\", to take it away instead.";
|
||||
face.nameField.disabled = !live || face.held > 0;
|
||||
// A typed name is added, or taken away while the tool takes away.
|
||||
face.nameField.placeholder = face.away ? "\u2212 type a name" : "+ type a name";
|
||||
}
|
||||
if (face.newButton) face.newButton.disabled = !live || face.held > 0 || Boolean(face.fresh);
|
||||
// The switch shows what a press does right now: with Ctrl held, Paint
|
||||
@@ -1584,9 +1529,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);
|
||||
@@ -1654,7 +1596,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);
|
||||
@@ -1700,7 +1642,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);
|
||||
@@ -1759,15 +1700,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;
|
||||
@@ -1848,7 +1781,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;
|
||||
@@ -1999,31 +1932,12 @@ function pointOf(face, event) {
|
||||
return [clamp(x, 0, face.pic.width - 1), clamp(y, 0, face.pic.height - 1)];
|
||||
}
|
||||
|
||||
// The pieces that hold a whole pick, worked out once per change to the
|
||||
// picks and only when something is being taken away.
|
||||
function holdsOf(face) {
|
||||
const pic = face.pic;
|
||||
if (!pic?.pieces || !face.raw) return null;
|
||||
if (!face.holds || face.holds.length !== pic.pieces.list.length) {
|
||||
const areas = chipGroups(face.items).filter((chip) => !chip.away).map((chip) => chipArea(face, chip));
|
||||
face.holds = piecesHolding(pic.pieces.planes, pic.pieces.list.length, areas);
|
||||
}
|
||||
return face.holds;
|
||||
}
|
||||
|
||||
function pieceUnder(face, point, adding) {
|
||||
const pic = face.pic;
|
||||
if (!pic?.pieces || !point) return -1;
|
||||
const x = clamp(Math.floor(point[0] * pic.scale), 0, pic.pw - 1);
|
||||
const y = clamp(Math.floor(point[1] * pic.scale), 0, pic.ph - 1);
|
||||
const at = y * pic.pw + x;
|
||||
if (adding) return pieceAt(pic.pieces.planes, at, { sizes: pic.pieces.sizes, inside: face.inside, ranks: pic.pieces.ranks });
|
||||
// Taking away from a spot that is not picked: never a thing that holds a
|
||||
// whole pick somewhere else (the person, for the sweater picked on them).
|
||||
return pieceAt(pic.pieces.planes, at, {
|
||||
sizes: pic.pieces.sizes, inside: face.inside, adding, ranks: pic.pieces.ranks,
|
||||
here: face.raw?.[at] >= 0.5, holds: holdsOf(face),
|
||||
});
|
||||
return pieceAt(pic.pieces.planes, y * pic.pw + x, { sizes: pic.pieces.sizes, inside: face.inside, adding, ranks: pic.pieces.ranks });
|
||||
}
|
||||
|
||||
// The pixel of the small copy under a point of the picture.
|
||||
@@ -2393,8 +2307,7 @@ function syncLooking(face) {
|
||||
pill.classList.toggle("paused", paused);
|
||||
pill.style.setProperty("--done", `${total ? Math.round((done / total) * 100) : 0}%`);
|
||||
face.lookIcon.textContent = paused ? "\u25b6" : "\u275a\u275a";
|
||||
face.lookVerb.textContent = paused ? "Look on" : "Pause";
|
||||
face.lookWord.textContent = paused ? "paused" : "looking";
|
||||
face.lookWord.textContent = paused ? "Paused" : "Looking";
|
||||
face.lookCount.textContent = total ? `${done}/${total}` : "\u2026";
|
||||
const far = total ? ` ${done} of ${total}` : "";
|
||||
pill.title = paused
|
||||
@@ -2787,8 +2700,6 @@ function setSoft(face, soft) {
|
||||
|
||||
// Pick takes whole things; Paint and Erase are the brush.
|
||||
function setTool(face, tool) {
|
||||
// The sign in front of the name field follows the tool again.
|
||||
face.nameAway = null;
|
||||
if (!TOOLS.includes(tool)) return;
|
||||
face.tool = tool;
|
||||
face.snap = tool === "pick";
|
||||
@@ -2845,21 +2756,16 @@ export function mountTouchStage(node, host) {
|
||||
const bar = el("div", "ausboss-touch-bar");
|
||||
const chips = el("div", "ausboss-touch-chips");
|
||||
const chipList = el("span", "ausboss-touch-chiplist");
|
||||
// A name typed here picks every thing of that name, or takes it away:
|
||||
// the sign in front says which, and a press on it changes it.
|
||||
const nameBox = el("span", "ausboss-touch-namebox");
|
||||
const nameSign = el("button", "ausboss-touch-sign", "+");
|
||||
nameSign.type = "button";
|
||||
const nameField = el("input", "ausboss-touch-name");
|
||||
nameField.type = "text";
|
||||
nameField.placeholder = "add by name";
|
||||
nameField.placeholder = "+ type a name";
|
||||
nameField.maxLength = 60;
|
||||
nameField.spellcheck = false;
|
||||
nameBox.append(nameSign, nameField);
|
||||
nameField.title = "Type what to pick, like \"screen\" or \"earring\", and press Enter. Every thing of that name is taken.";
|
||||
const newButton = el("button", "ausboss-touch-new", "+ new");
|
||||
newButton.type = "button";
|
||||
newButton.title = "Start a new area: what you paint next gets a chip of its own, also where it touches another area.";
|
||||
chips.append(chipList, newButton, nameBox);
|
||||
chips.append(chipList, newButton, nameField);
|
||||
root.append(tools, stage, bar, chips);
|
||||
|
||||
const frame = el("div", "ausboss-touch-frame");
|
||||
@@ -2904,7 +2810,7 @@ export function mountTouchStage(node, host) {
|
||||
|
||||
const face = node.__ausbossTouch = {
|
||||
node, host, root, stage, frame, img, tint, lit, ink, path, dots, ringBack, ring, coreRing, dab, brushLabel, tag, hint, noteEl, abort,
|
||||
chips, chipList, newButton, nameField, nameBox, nameSign, nameAway: null, chipsShown: null,
|
||||
chips, chipList, newButton, nameField, chipsShown: null,
|
||||
flying: [],
|
||||
pendingTimer: 0,
|
||||
alive: true,
|
||||
@@ -2971,9 +2877,9 @@ export function mountTouchStage(node, host) {
|
||||
};
|
||||
readProperties(face);
|
||||
|
||||
const showPicture = () => { if (!face.inFlight) { face.retries = 0; if (face.pic) face.pic.paused = false; requestRun(face, "scope"); } };
|
||||
face.start = button("ausboss-touch-start", "Show picture",
|
||||
"Runs only this node, so the picture shows here and its pieces light up under the pointer.", showPicture, signal);
|
||||
"Runs only this node, so the picture shows here and its pieces light up under the pointer.",
|
||||
() => { if (!face.inFlight) { face.retries = 0; if (face.pic) face.pic.paused = false; requestRun(face, "scope"); } }, signal);
|
||||
wake.append(face.start, hint);
|
||||
|
||||
// Tools: what a press does, the brush, undo and clear; on the right, how
|
||||
@@ -3070,13 +2976,7 @@ export function mountTouchStage(node, host) {
|
||||
if (event.shiftKey) face.setView(face.view === "cut" ? "mask" : face.view === "mask" ? "cut" : face.result === "cut" ? "mask" : "cut");
|
||||
else face.setView(face.view === "tint" ? face.result : "tint");
|
||||
}, { signal });
|
||||
// The picture coming in changed (an edit made upstream): this shows the
|
||||
// new one, the same way Show picture does.
|
||||
face.reload = button("ausboss-touch-btn reload", "",
|
||||
"Reload the picture: runs only this node, so a picture that changed upstream shows here.", showPicture, signal);
|
||||
face.reload.innerHTML = RELOAD_ICON;
|
||||
face.reload.setAttribute("aria-label", "Reload the picture");
|
||||
tools.append(face.toolSwitch.root, face.sizeField, face.softField, el("div", "ausboss-touch-gap"), face.reload, face.undo, face.clear, face.eye);
|
||||
tools.append(face.toolSwitch.root, face.sizeField, face.softField, el("div", "ausboss-touch-gap"), face.undo, face.clear, face.eye);
|
||||
newButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -3108,15 +3008,10 @@ export function mountTouchStage(node, host) {
|
||||
if (face.pic?.paused && !face.inFlight) resumeLook(face);
|
||||
else pauseLook(face);
|
||||
}, signal);
|
||||
// The press it takes comes first, as a button of its own look: "Pause"
|
||||
// while the node looks, "Look on" once it is paused.
|
||||
face.lookIcon = el("span", "icon");
|
||||
face.lookVerb = el("span", "verb");
|
||||
face.lookWord = el("span", "word");
|
||||
face.lookCount = el("span", "count");
|
||||
const lookKey = el("span", "key");
|
||||
lookKey.append(face.lookIcon, face.lookVerb);
|
||||
face.lookPill.append(lookKey, face.lookWord, face.lookCount);
|
||||
face.lookPill.append(face.lookIcon, face.lookWord, face.lookCount);
|
||||
face.lookPill.style.display = "none";
|
||||
// First in the line under the picture: what the node is doing with the
|
||||
// picture, then what is picked; the buttons that pick come after.
|
||||
@@ -3126,11 +3021,6 @@ export function mountTouchStage(node, host) {
|
||||
for (const type of ["keydown", "keyup", "keypress", "pointerdown", "mousedown", "dblclick"]) {
|
||||
chips.addEventListener(type, (event) => event.stopPropagation(), { signal });
|
||||
}
|
||||
nameSign.addEventListener("click", () => {
|
||||
face.nameAway = !nameTakes(face);
|
||||
syncTools(face);
|
||||
nameField.focus();
|
||||
}, { signal });
|
||||
nameField.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
@@ -3183,9 +3073,7 @@ export function mountTouchStage(node, host) {
|
||||
// The run stopped at this node with a message for the person.
|
||||
face.stopped = (message) => {
|
||||
settle(face);
|
||||
setNote(face, stopMessage(message));
|
||||
// It stays up until something else is said or the next result comes in.
|
||||
face.stopNote = true;
|
||||
setNote(face, String(message ?? ""));
|
||||
};
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -166,14 +166,6 @@ export function noteIsEmpty(note) {
|
||||
);
|
||||
}
|
||||
|
||||
// A note shows its tile face when it is in tile layout and has anything to
|
||||
// show. The node is then just a button: the note itself opens in a dialog,
|
||||
// so there is no card inside the node whose height needs fitting.
|
||||
export function noteShowsTileFace(note) {
|
||||
if (!note || typeof note !== "object") return false;
|
||||
return note.layout === "tile" && !noteIsEmpty(note);
|
||||
}
|
||||
|
||||
// "ComfyUI/models/loras" - the folder hint under each group of rows.
|
||||
export function folderPath(dir) {
|
||||
if (!dir) return "ComfyUI/models";
|
||||
|
||||
@@ -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 {
|
||||
@@ -121,15 +120,6 @@ const CARDS = {
|
||||
{ widget: "interpolation", label: "Filter", kind: "select" },
|
||||
],
|
||||
},
|
||||
AUSBOSS_NODES_EditLock: {
|
||||
minWidth: 300,
|
||||
rows: [
|
||||
{ widget: "lock", label: "Lock", kind: "segment",
|
||||
titles: { auto: "Lock the untouched parts; step aside when the whole picture changes", always: "Lock even when the whole picture changes", off: "Do nothing" } },
|
||||
{ widget: "tolerance", label: "Tolerance", decimals: 2, suffix: "×" },
|
||||
],
|
||||
},
|
||||
AUSBOSS_NODES_EditLockRestore: { minWidth: 300, rows: [{ widget: "feather", label: "Feather", suffix: "px" }] },
|
||||
AUSBOSS_NODES_ColorMatch: {
|
||||
minWidth: 300,
|
||||
rows: [
|
||||
@@ -252,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" },
|
||||
],
|
||||
},
|
||||
@@ -428,7 +416,7 @@ const CARDS = {
|
||||
],
|
||||
},
|
||||
AUSBOSS_NODES_SaveVideo: {
|
||||
// step_preview is the Preview steps switch on the player (js/save_video).
|
||||
// step_preview is the STEPS button on the player (js/save_video).
|
||||
minWidth: 320, first: true, hide: ["step_preview"],
|
||||
rows: [
|
||||
{ widget: "filename_prefix", label: "Prefix", placeholder: "video" },
|
||||
|
||||
@@ -21,7 +21,6 @@ import { copyToClipboard } from "../shared/clipboard.mjs";
|
||||
import { confirmDiscard } from "../shared/discard_prompt.mjs";
|
||||
import { WIDGET_FRAME, fillNodeHeight, holdNodeMinHeight } from "../shared/panel_layout.mjs";
|
||||
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
|
||||
import { suppressCoreImagePreview } from "../shared/core_preview.mjs";
|
||||
import {
|
||||
COMMON_FOLDERS,
|
||||
INPUT_FOLDER,
|
||||
@@ -35,7 +34,6 @@ import {
|
||||
matchInstalled,
|
||||
noteFit,
|
||||
noteIsEmpty,
|
||||
noteShowsTileFace,
|
||||
normalizeNote,
|
||||
packsFromGraph,
|
||||
parseMarkdown,
|
||||
@@ -710,13 +708,13 @@ function naturalHeight(state, width) {
|
||||
// as the node's floor. It never shrinks the node: a taller note keeps its
|
||||
// empty space until someone drags it shorter.
|
||||
function fitNote(state) {
|
||||
if (!classicCanvas()) return;
|
||||
// A tile face is a button, not a card: fitting it would measure the note
|
||||
// behind the button and grow a new tile straight back to full height.
|
||||
if (noteShowsTileFace(state.note)) {
|
||||
// A tile opens its content in a dialog. Keep its chosen canvas size,
|
||||
// rather than growing it to a full note's measured height.
|
||||
if (state.note.layout === "tile") {
|
||||
state.fitHeight = undefined;
|
||||
return;
|
||||
}
|
||||
if (!classicCanvas()) return;
|
||||
const { node, root, domWidget } = state;
|
||||
const width = node.size[0] - WIDGET_FRAME;
|
||||
if (!(width > 0)) return;
|
||||
@@ -1121,11 +1119,6 @@ function buildPanel(node) {
|
||||
const widget = node.widgets?.find((item) => item.name === "note");
|
||||
if (!widget) return null;
|
||||
hideWidget(widget);
|
||||
// A note never has output images of its own. The frontend files previews
|
||||
// by node id, so one left over from another node can still land here, and
|
||||
// in the classic canvas its widget asks for the picture's full height:
|
||||
// the node grows to hold it and a tile stops starting as a tile.
|
||||
suppressCoreImagePreview(node);
|
||||
|
||||
const root = el("div", "ausboss-note");
|
||||
const card = el("div", "ausboss-note-card");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -172,64 +172,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"AUSBOSS_NODES_EditLock": {
|
||||
"display_name": "编辑锁定 🆎",
|
||||
"description": "让图片里编辑没有改动的部分保持在原位,颜色也不变。编辑模型会把整张图重画一遍,没改的部分常常略微放大、偏移或偏色。把这个节点放在模型和采样器之间,并接上你要编辑的图片。不需要遮罩:它在出图过程中看出编辑改了哪里。如果编辑改变的是整张图(新风格、新光线、新天气),它会自动让开。解码后再接上编辑锁定还原 🆎,未改动的部分就能换回你原图的像素。",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "模型"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae"
|
||||
},
|
||||
"image": {
|
||||
"name": "图像"
|
||||
},
|
||||
"lock": {
|
||||
"name": "锁定"
|
||||
},
|
||||
"tolerance": {
|
||||
"name": "容差"
|
||||
},
|
||||
"mask": {
|
||||
"name": "遮罩"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "模型"
|
||||
},
|
||||
"1": {
|
||||
"name": "编辑锁"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AUSBOSS_NODES_EditLockRestore": {
|
||||
"display_name": "编辑锁定还原 🆎",
|
||||
"description": "把编辑没有改动的部分换回你原图自己的像素。放在 VAE 解码之后,并接上编辑锁定 🆎 的 edit_lock 输出。未改动的部分与原图逐像素一致,只有编辑的部分是新的。如果锁定让开了,图片原样通过。",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "图像"
|
||||
},
|
||||
"edit_lock": {
|
||||
"name": "编辑锁"
|
||||
},
|
||||
"feather": {
|
||||
"name": "羽化"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "图像"
|
||||
},
|
||||
"1": {
|
||||
"name": "编辑遮罩"
|
||||
},
|
||||
"2": {
|
||||
"name": "报告"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AUSBOSS_NODES_Float": {
|
||||
"display_name": "浮点数 🆎",
|
||||
"description": "一个独立的 FLOAT 值。在多个节点之间共用同一个强度、缩放或 CFG,只需在一处修改。",
|
||||
@@ -1110,9 +1052,6 @@
|
||||
},
|
||||
"step_preview": {
|
||||
"name": "步骤预览"
|
||||
},
|
||||
"autosave": {
|
||||
"name": "自动保存"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
|
||||
+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:
|
||||
|
||||
@@ -1,455 +0,0 @@
|
||||
"""Edit Lock: keep what an edit did not change. Pure torch, no ComfyUI imports.
|
||||
|
||||
At every sampling step an edit model predicts the finished picture. Where
|
||||
that prediction sits close to the original, the edit is not happening
|
||||
there, so the original is put back and the model carries on from the real
|
||||
picture. This is the target TailSteer trains a model toward
|
||||
(original where nothing changed, the model's edit elsewhere), applied while
|
||||
sampling instead of through training.
|
||||
|
||||
What counts as changed is measured against the quiet part of the picture:
|
||||
the tenth of it where the prediction differs least, which a local edit
|
||||
leaves near the level of the model's own redraw noise. The node watches the
|
||||
first steps, where it only undoes the model's zoom and shift, and decides
|
||||
once the picture has formed. An edit of the whole picture (a style, the
|
||||
light) has no quiet part: the first look checks for that and the lock steps
|
||||
aside. A soft change over the whole picture (rain, snow, haze) has no clear
|
||||
peak: the decision checks for that, and the picture is only held in place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
LOCK_MODES = ("auto", "always", "off")
|
||||
|
||||
# A spot counts as changed when it differs from the original by STRONG times
|
||||
# the quiet level, or by WEAK times while it touches a spot that does. So a
|
||||
# faint change next to a clear one belongs to the edit, and a faint change
|
||||
# alone is redraw noise. Set against what 14 plain MiniMax-H3-Image edits
|
||||
# changed in the end: this frees about 80% of it at the first look and 3%
|
||||
# of what stayed.
|
||||
WEAK = 2.0
|
||||
STRONG = 3.0
|
||||
# The model often draws the whole picture a little larger or shifted. That
|
||||
# is measured and discounted before anything is compared, up to these
|
||||
# limits; a bigger move is treated as part of the edit.
|
||||
# The node watches the start of a run and decides once the noise is down to
|
||||
# this share of where it began. The first prediction is a blur: rain, snow,
|
||||
# smoke and the light a new hair color throws on a face only show a few
|
||||
# steps in. While it watches it only undoes the zoom and shift.
|
||||
DECIDE_AT = 0.8
|
||||
# A local edit has a peak: somewhere the picture clearly changes. Rain, snow
|
||||
# and haze change the whole picture a little and no part clearly. A lock
|
||||
# would hold them back, or let them through in patches where it hands a wide
|
||||
# part of the picture to the edit. So two things are read at the decision,
|
||||
# in units of the quiet level: the strongest spot, and the strongest
|
||||
# hundredth when the lock would free SOFT_WIDE of the picture or more. On
|
||||
# recorded MiniMax-H3-Image runs the strongest spot read 4.7 or more for
|
||||
# every local edit, glasses and makeup included, and 3.7 or less for light
|
||||
# rain, fog and snow; with a wide part freed, the strongest hundredth read
|
||||
# 4.4 or more for local edits and 3.6 or less for rain and snow.
|
||||
SOFT_WIDE = 0.10
|
||||
SOFT_PEAK = 4.0
|
||||
# Kept this much or more: the report adds what to do if the edit is missing.
|
||||
# A soft effect over the whole picture (rain, snow, smoke) looks like a small
|
||||
# edit at the first look, and under the lock the model then drops it.
|
||||
NEARLY_ALL = 0.95
|
||||
MAX_ZOOM = 0.05
|
||||
MAX_SHIFT = 4.0
|
||||
# The whole picture is changing when even its calmest fifth sits this far
|
||||
# from the original at the first look, in units of the original's own
|
||||
# spread. Measured on MiniMax-H3-Image: 28 local edits at 1, 4 and 16 MP came
|
||||
# in at 0.22 or less (zoomed ones included), 12 whole-picture edits (styles,
|
||||
# light, weather) at 0.27 or more.
|
||||
WHOLE_PICTURE = 0.243
|
||||
# Blur and growth of the change map, as a share of the short side.
|
||||
REACH = 1.0 / 54.0
|
||||
# A result this much larger or smaller than the picture is another picture.
|
||||
SIZE_SLACK = 0.04
|
||||
|
||||
|
||||
def box_blur(x: torch.Tensor, radius: int) -> torch.Tensor:
|
||||
if radius <= 0:
|
||||
return x
|
||||
size = 2 * radius + 1
|
||||
return F.avg_pool2d(F.pad(x, (radius,) * 4, mode="replicate"), size, stride=1)
|
||||
|
||||
|
||||
def reach(height: int, width: int) -> int:
|
||||
return max(1, round(min(height, width) * REACH))
|
||||
|
||||
|
||||
def _moved(source: torch.Tensor, move: torch.Tensor) -> torch.Tensor:
|
||||
"""The original sampled at (zoom_x * x + shift_x, zoom_y * y + shift_y), x and y in cells from the centre.
|
||||
|
||||
move = [zoom_x - 1, zoom_y - 1, shift_x, shift_y].
|
||||
"""
|
||||
height, width = source.shape[-2:]
|
||||
theta = torch.zeros(source.shape[0], 2, 3, device=source.device, dtype=source.dtype)
|
||||
theta[:, 0, 0] = 1.0 + move[0]
|
||||
theta[:, 1, 1] = 1.0 + move[1]
|
||||
theta[:, 0, 2] = 2.0 * move[2] / width
|
||||
theta[:, 1, 2] = 2.0 * move[3] / height
|
||||
grid = F.affine_grid(theta, list(source.shape), align_corners=False)
|
||||
return F.grid_sample(source, grid, mode="bilinear", padding_mode="border", align_corners=False)
|
||||
|
||||
|
||||
def measure_redraw(pred: torch.Tensor, source: torch.Tensor) -> torch.Tensor:
|
||||
"""How much larger and how far shifted the prediction is drawn, as the move that lays the original over it.
|
||||
|
||||
pred and source are [1, C, H, W]. Fitted on the calmer half of the
|
||||
picture, so the edit itself does not pull the answer, from a coarse grid
|
||||
down to the full one. Returns [zoom_x - 1, zoom_y - 1, shift_x, shift_y];
|
||||
zeros when nothing can be measured.
|
||||
"""
|
||||
pred, source = pred.float(), source.float()
|
||||
move = torch.zeros(4, device=pred.device, dtype=torch.float32)
|
||||
limit = torch.tensor([MAX_ZOOM, MAX_ZOOM, MAX_SHIFT, MAX_SHIFT], device=pred.device)
|
||||
for level in (4, 2, 1):
|
||||
if min(pred.shape[-2:]) // level < 16:
|
||||
continue
|
||||
a = box_blur(F.avg_pool2d(pred, level) if level > 1 else pred, 1)
|
||||
b = box_blur(F.avg_pool2d(source, level) if level > 1 else source, 1)
|
||||
height, width = a.shape[-2:]
|
||||
ys = (torch.arange(height, device=a.device, dtype=torch.float32) + 0.5 - height / 2)[:, None].expand(height, width)
|
||||
xs = (torch.arange(width, device=a.device, dtype=torch.float32) + 0.5 - width / 2)[None, :].expand(height, width)
|
||||
here = move.clone()
|
||||
here[2:] = here[2:] / level
|
||||
for _ in range(6):
|
||||
laid = _moved(b, here)
|
||||
rest = a - laid
|
||||
rest = rest - rest.flatten(2).median(dim=2).values[:, :, None, None]
|
||||
wide = F.pad(laid, (1, 1, 1, 1), mode="replicate")
|
||||
gx = (wide[..., 1:-1, 2:] - wide[..., 1:-1, :-2]) / 2.0
|
||||
gy = (wide[..., 2:, 1:-1] - wide[..., :-2, 1:-1]) / 2.0
|
||||
size = rest.abs().mean(dim=1, keepdim=True)
|
||||
calm = (size <= size.flatten().median()).float()
|
||||
calm[..., :2, :] = 0.0
|
||||
calm[..., -2:, :] = 0.0
|
||||
calm[..., :, :2] = 0.0
|
||||
calm[..., :, -2:] = 0.0
|
||||
jac = (torch.stack([gx * xs, gy * ys, gx, gy], dim=-1) * calm[..., None]).reshape(-1, 4)
|
||||
normal = jac.T @ jac + 1e-6 * torch.eye(4, device=a.device)
|
||||
try:
|
||||
change = torch.linalg.solve(normal, jac.T @ (rest * calm).reshape(-1))
|
||||
except RuntimeError:
|
||||
return torch.zeros(4, device=pred.device, dtype=torch.float32)
|
||||
if not bool(torch.isfinite(change).all()):
|
||||
return torch.zeros(4, device=pred.device, dtype=torch.float32)
|
||||
here = here + change
|
||||
if float(change.abs().max()) < 1e-4:
|
||||
break
|
||||
move = here.clone()
|
||||
move[2:] = move[2:] * level
|
||||
move = torch.maximum(torch.minimum(move, limit), -limit)
|
||||
return move
|
||||
|
||||
|
||||
def redraws(pred: torch.Tensor, source: torch.Tensor) -> list[torch.Tensor]:
|
||||
"""measure_redraw for each picture of a batch."""
|
||||
return [measure_redraw(pred[index:index + 1], source[:1]) for index in range(pred.shape[0])]
|
||||
|
||||
|
||||
def difference(pred: torch.Tensor, source: torch.Tensor, moves: list[torch.Tensor] | None = None) -> torch.Tensor:
|
||||
"""How far each spot of the prediction is from the original, [B, 1, H, W].
|
||||
|
||||
Two things are not a change. A redraw of the whole picture slightly
|
||||
larger or shifted: the original is laid over the prediction first. And
|
||||
an even tint: each channel's middle difference is taken out. The result
|
||||
is in units of the original's own spread, which keeps the numbers alike
|
||||
between models.
|
||||
"""
|
||||
pred = pred.float()
|
||||
source = source.float()
|
||||
moves = moves if moves is not None else redraws(pred, source)
|
||||
out = []
|
||||
for index in range(pred.shape[0]):
|
||||
delta = pred[index:index + 1] - _moved(source[:1], moves[index])
|
||||
delta = delta - delta.flatten(2).median(dim=2).values[:, :, None, None]
|
||||
out.append(delta.abs().mean(dim=1, keepdim=True))
|
||||
magnitude = torch.cat(out) / source.std().clamp_min(1e-6)
|
||||
return box_blur(magnitude, reach(*magnitude.shape[-2:]))
|
||||
|
||||
|
||||
def moved_back(pred: torch.Tensor, moves: list[torch.Tensor]) -> torch.Tensor:
|
||||
"""The prediction with its zoom and shift undone, so it sits where the original does.
|
||||
|
||||
Resampling softens a latent, so a move under a tenth of a cell at the
|
||||
corner is left alone, and the rest is resampled bicubic: on recorded
|
||||
runs that kept 85% of the fine detail where bilinear kept 65%.
|
||||
"""
|
||||
out = []
|
||||
for index in range(pred.shape[0]):
|
||||
one = pred[index:index + 1]
|
||||
move = moves[index]
|
||||
height, width = one.shape[-2:]
|
||||
corner = (move[0].abs() * width / 2 + move[2].abs()).maximum(move[1].abs() * height / 2 + move[3].abs())
|
||||
if float(corner) < 0.1:
|
||||
out.append(one)
|
||||
continue
|
||||
theta = torch.zeros(1, 2, 3, device=one.device, dtype=torch.float32)
|
||||
theta[:, 0, 0] = 1.0 / (1.0 + move[0])
|
||||
theta[:, 1, 1] = 1.0 / (1.0 + move[1])
|
||||
theta[:, 0, 2] = -2.0 * move[2] / (1.0 + move[0]) / width
|
||||
theta[:, 1, 2] = -2.0 * move[3] / (1.0 + move[1]) / height
|
||||
grid = F.affine_grid(theta, list(one.shape), align_corners=False)
|
||||
back = F.grid_sample(one.float(), grid, mode="bicubic", padding_mode="border", align_corners=False)
|
||||
out.append(back.to(one.dtype))
|
||||
return torch.cat(out)
|
||||
|
||||
|
||||
def broad_change(pred: torch.Tensor, source: torch.Tensor) -> torch.Tensor:
|
||||
"""How far the calmest fifth of each picture sits from the original, [B].
|
||||
|
||||
Read on a smoothed copy, where a redraw one percent larger nearly
|
||||
vanishes and a new style, new light or new weather does not. An even
|
||||
tint is taken out first: the model adds one by itself, a stronger one
|
||||
on large pictures, and that is no edit.
|
||||
"""
|
||||
delta = (pred - source).float()
|
||||
delta = delta - delta.flatten(2).median(dim=2).values[:, :, None, None]
|
||||
delta = box_blur(delta, max(1, round(min(delta.shape[-2:]) / 30.0)))
|
||||
level = (delta * delta).mean(dim=1).sqrt().flatten(1)
|
||||
stride = max(1, level.shape[1] // 200000)
|
||||
spread = source.float().std().clamp_min(1e-6)
|
||||
return torch.quantile(level[:, ::stride], 0.2, dim=1) / spread
|
||||
|
||||
|
||||
def quiet_level(magnitude: torch.Tensor) -> torch.Tensor:
|
||||
"""The tenth of each picture that differs least, [B]."""
|
||||
flat = magnitude.flatten(1)
|
||||
stride = max(1, flat.shape[1] // 200000)
|
||||
return torch.quantile(flat[:, ::stride], 0.1, dim=1)
|
||||
|
||||
|
||||
def peaks(magnitude: torch.Tensor, quiet: torch.Tensor) -> tuple[float, float]:
|
||||
"""How clear the clearest change is, in units of the quiet level: (strongest spot, strongest hundredth)."""
|
||||
ratio = (magnitude / quiet.clamp_min(1e-6)[:, None, None, None]).flatten(1)
|
||||
stride = max(1, ratio.shape[1] // 200000)
|
||||
return float(ratio.max(dim=1).values.min()), float(torch.quantile(ratio[:, ::stride], 0.99, dim=1).min())
|
||||
|
||||
|
||||
def change_core(magnitude: torch.Tensor, quiet: torch.Tensor, tolerance: float = 1.0,
|
||||
held: torch.Tensor | None = None) -> torch.Tensor:
|
||||
"""1 where the edit changed the picture, 0 where it did not, hard-edged, [B, 1, H, W].
|
||||
|
||||
`held` is what earlier steps called changed: it stays changed, and
|
||||
faint change that touches it joins it.
|
||||
"""
|
||||
ratio = magnitude / quiet.clamp_min(1e-6)[:, None, None, None]
|
||||
weak = (ratio > WEAK * tolerance).float()
|
||||
core = (ratio > STRONG * tolerance).float()
|
||||
if held is not None:
|
||||
weak = torch.maximum(weak, held)
|
||||
core = torch.maximum(core, held)
|
||||
for _ in range(sum(core.shape[-2:])):
|
||||
grown = F.max_pool2d(core, 3, stride=1, padding=1) * weak
|
||||
if torch.equal(grown, core):
|
||||
break
|
||||
core = grown
|
||||
return core
|
||||
|
||||
|
||||
def soften(core: torch.Tensor) -> torch.Tensor:
|
||||
"""The hard change map with a margin and a soft edge, so the edit has room to blend in."""
|
||||
grow = reach(*core.shape[-2:])
|
||||
return box_blur(F.max_pool2d(core, 2 * grow + 1, stride=1, padding=grow), grow)
|
||||
|
||||
|
||||
def fit_latent(latent: torch.Tensor, height: int, width: int) -> torch.Tensor | None:
|
||||
"""The original's latent on the result's grid, or None when they are different pictures."""
|
||||
h, w = latent.shape[-2:]
|
||||
if (h, w) == (height, width):
|
||||
return latent
|
||||
if abs(h / height - 1.0) > SIZE_SLACK or abs(w / width - 1.0) > SIZE_SLACK:
|
||||
return None
|
||||
return F.interpolate(latent, size=(height, width), mode="bilinear", align_corners=False)
|
||||
|
||||
|
||||
class EditLock:
|
||||
"""One lock: the original, the settings, and what the last run kept."""
|
||||
|
||||
def __init__(self, mode: str = "auto", tolerance: float = 1.0):
|
||||
self.mode = mode
|
||||
self.tolerance = tolerance
|
||||
self.source: torch.Tensor | None = None # IMAGE, [1, H, W, 3]
|
||||
self.latent: torch.Tensor | None = None # [1, C, H, W] or [1, C, 1, H, W], model space
|
||||
self.user_mask: torch.Tensor | None = None # MASK, white = the edit may happen
|
||||
self.status = "waiting"
|
||||
self.mask: torch.Tensor | None = None # [B, 1, h, w], 1 = changed, soft-edged
|
||||
self.core: torch.Tensor | None = None # the same before its margin, hard-edged
|
||||
self.quiet: torch.Tensor | None = None # [B], the quiet level at the decision
|
||||
self.broad: float | None = None # the first look's whole-picture reading
|
||||
self.wide: float | None = None # share of the picture the lock would free, at the decision
|
||||
self.top: float | None = None # the strongest spot then, in units of the quiet level
|
||||
self.peak: float | None = None # and the strongest hundredth
|
||||
self._sigma: float | None = None
|
||||
self._first = True
|
||||
self._first_sigma = 1.0
|
||||
self._decided = False
|
||||
|
||||
def _begin(self) -> None:
|
||||
self.status = "waiting"
|
||||
self.mask = None
|
||||
self.core = None
|
||||
self.quiet = None
|
||||
self.broad = None
|
||||
self.wide = None
|
||||
self.top = None
|
||||
self.peak = None
|
||||
self._first = True
|
||||
self._decided = False
|
||||
|
||||
def _decides_now(self, args: dict, sigma: float) -> bool:
|
||||
"""True once the noise is down far enough, or this is the run's last step."""
|
||||
steps = (args.get("model_options") or {}).get("transformer_options", {}).get("sample_sigmas")
|
||||
if steps is None or len(steps) < 2:
|
||||
return True # the sampler does not say where the run ends: decide at once
|
||||
if sigma <= float(steps[-2]) * 1.0001:
|
||||
return True
|
||||
return sigma <= DECIDE_AT * self._first_sigma
|
||||
|
||||
def step(self, args: dict) -> torch.Tensor:
|
||||
"""ComfyUI's post-CFG hook: the prediction comes in, the locked prediction goes out."""
|
||||
pred = args["denoised"]
|
||||
sigma = float(args["sigma"].flatten()[0])
|
||||
if self._sigma is None or sigma >= self._sigma:
|
||||
self._begin() # the noise level went up: a new run
|
||||
self._sigma = sigma
|
||||
if self.status in ("whole picture", "size", "video", "vae") or self.latent is None:
|
||||
return pred
|
||||
video = pred.ndim == 5
|
||||
if video and pred.shape[2] != 1:
|
||||
self.status = "video"
|
||||
return pred
|
||||
flat = pred[:, :, 0] if video else pred
|
||||
latent = self.latent[:, :, 0] if self.latent.ndim == 5 else self.latent
|
||||
if latent.shape[1] != flat.shape[1]:
|
||||
self.status = "vae"
|
||||
return pred
|
||||
source = fit_latent(latent.to(device=flat.device, dtype=torch.float32), *flat.shape[-2:])
|
||||
if source is None:
|
||||
self.status = "size"
|
||||
return pred
|
||||
if self.status == "soft":
|
||||
# nothing is put back, the picture is only held where the original sits
|
||||
back = moved_back(flat, redraws(flat, source))
|
||||
return back[:, :, None] if video else back
|
||||
if self.user_mask is not None:
|
||||
mask = self._given_mask(flat)
|
||||
else:
|
||||
if self._first:
|
||||
self._first_sigma = sigma
|
||||
self.broad = float(broad_change(flat, source).max())
|
||||
if self.mode == "auto" and self.broad > WHOLE_PICTURE:
|
||||
self.status = "whole picture"
|
||||
return pred
|
||||
moves = redraws(flat, source)
|
||||
magnitude = difference(flat, source, moves)
|
||||
if not self._decided and not self._decides_now(args, sigma):
|
||||
# still watching: the model draws its own picture, only its zoom and shift are undone
|
||||
self._first = False
|
||||
self.status = "watching"
|
||||
if self.broad > WHOLE_PICTURE:
|
||||
return pred # too much is changing to trust the measured move
|
||||
back = moved_back(flat, moves)
|
||||
return back[:, :, None] if video else back
|
||||
if not self._decided:
|
||||
self._decided = True
|
||||
# the yardstick is set once, here: later steps are read against it
|
||||
self.quiet = quiet_level(magnitude)
|
||||
self.top, self.peak = peaks(magnitude, self.quiet)
|
||||
self.wide = float(change_core(magnitude, self.quiet, self.tolerance).flatten(1).mean(dim=1).max())
|
||||
thin = self.top < SOFT_PEAK or (self.wide >= SOFT_WIDE and self.peak < SOFT_PEAK)
|
||||
if self.mode == "auto" and not self._first and thin:
|
||||
# a soft change over the whole picture: a lock would hold it back or let it through in patches
|
||||
self.status = "soft"
|
||||
back = moved_back(flat, moves)
|
||||
return back[:, :, None] if video else back
|
||||
held = self.core if self.core is not None and self.core.shape[0] == flat.shape[0] else None
|
||||
self.core = change_core(magnitude, self.quiet.to(magnitude.device), self.tolerance,
|
||||
held.to(magnitude.device) if held is not None else None).detach()
|
||||
mask = soften(self.core)
|
||||
self._first = False
|
||||
self.mask = mask.detach()
|
||||
self.status = "locked"
|
||||
keep = (1.0 - mask).to(flat.dtype)
|
||||
locked = flat * (1.0 - keep) + source.to(flat.dtype) * keep
|
||||
return locked[:, :, None] if video else locked
|
||||
|
||||
def _given_mask(self, flat: torch.Tensor) -> torch.Tensor:
|
||||
mask = self.user_mask
|
||||
if mask.ndim == 2:
|
||||
mask = mask[None]
|
||||
mask = mask[:1, None].to(device=flat.device, dtype=torch.float32)
|
||||
mask = F.interpolate(mask, size=flat.shape[-2:], mode="bilinear", align_corners=False).clamp(0.0, 1.0)
|
||||
return soften(mask).expand(flat.shape[0], -1, -1, -1)
|
||||
|
||||
def kept_share(self) -> float | None:
|
||||
if self.status != "locked" or self.mask is None:
|
||||
return None
|
||||
return float((self.mask < 0.5).float().mean())
|
||||
|
||||
def pixel_mask(self, height: int, width: int) -> torch.Tensor | None:
|
||||
"""The change mask at the picture's size, [B, H, W], or None when nothing was locked."""
|
||||
if self.status != "locked" or self.mask is None:
|
||||
return None
|
||||
mask = F.interpolate(self.mask.float().cpu(), size=(height, width), mode="bilinear", align_corners=False)
|
||||
return mask[:, 0].clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def restore_pixels(result: torch.Tensor, source: torch.Tensor, mask: torch.Tensor,
|
||||
feather: int) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""The original's own pixels where the mask is black. result [B, H, W, C], source [1, h, w, 3], mask [b, H, W].
|
||||
|
||||
The fade lies outside the edit: the mask is widened by the feather first.
|
||||
"""
|
||||
frames, height, width = int(result.shape[0]), int(result.shape[1]), int(result.shape[2])
|
||||
edit = mask[:, None].float()
|
||||
if edit.shape[0] not in (1, frames):
|
||||
edit = edit[:1]
|
||||
if feather > 0:
|
||||
size = 2 * feather + 1
|
||||
edit = F.max_pool2d(edit, size, stride=1, padding=feather)
|
||||
edit = box_blur(edit, feather)
|
||||
original = source[..., :3].float()
|
||||
if tuple(original.shape[1:3]) != (height, width):
|
||||
original = F.interpolate(original.movedim(-1, 1), size=(height, width), mode="bicubic",
|
||||
align_corners=False, antialias=True).movedim(1, -1).clamp(0.0, 1.0)
|
||||
weight = edit.movedim(1, -1).to(result.device, result.dtype)
|
||||
out = result.clone()
|
||||
out[..., :3] = result[..., :3] * weight + original.to(result.device, result.dtype) * (1.0 - weight)
|
||||
edit_mask = edit[:, 0].expand(frames, -1, -1).clone()
|
||||
return out, edit_mask
|
||||
|
||||
|
||||
def describe(lock: EditLock) -> str:
|
||||
if lock.status == "locked":
|
||||
kept = lock.kept_share() or 0.0
|
||||
how = "your mask" if lock.user_mask is not None else "what the edit changed"
|
||||
text = (f"Edit Lock kept {kept * 100:.0f}% of your picture as it was and left "
|
||||
f"{(1.0 - kept) * 100:.0f}% to the edit ({how}).")
|
||||
if kept >= NEARLY_ALL and lock.user_mask is None:
|
||||
# rain, snow or smoke over the whole picture reads as a small edit and is held back
|
||||
text += (" If your edit is missing, it is one that spreads thinly over the whole picture "
|
||||
"(rain, snow, smoke): set lock to off.")
|
||||
return text
|
||||
if lock.status == "whole picture":
|
||||
return ("Edit Lock stepped aside: the edit changes the whole picture, so there was nothing to keep. "
|
||||
"Set lock to always to lock anyway.")
|
||||
if lock.status == "soft":
|
||||
return ("Edit Lock stepped aside: the edit spreads thinly over the whole picture (rain, snow, haze), "
|
||||
"so no part of it stayed as it was. It only held the picture in place. "
|
||||
"Set lock to always to lock anyway.")
|
||||
if lock.status == "off":
|
||||
return "Edit Lock is off."
|
||||
if lock.status == "size":
|
||||
return ("Edit Lock did nothing: the picture and the result are different sizes. "
|
||||
"Give it the picture at the size of the result.")
|
||||
if lock.status == "video":
|
||||
return "Edit Lock did nothing: it works on single pictures, not video."
|
||||
if lock.status == "vae":
|
||||
return "Edit Lock did nothing: this VAE does not belong to this model."
|
||||
return "Edit Lock has not run yet: plug its model output into the sampler."
|
||||
@@ -102,33 +102,6 @@ ONE_GOES = "the original person"
|
||||
APPEARANCE_LINE = re.compile(r'^\s*@([1-4])\s+looks like\s+([^"\n]+?)\s*$', re.I)
|
||||
|
||||
|
||||
# Hair and face only, no clothes, expression or background. When the line names clothes that differ from what the
|
||||
# person in the clip wears, H3 tends to open on the picture's own backdrop before cutting to the clip.
|
||||
DESCRIBE_PERSON = (
|
||||
"Describe only the hair and face of the person in this picture, in one short line for a video prompt: hair colour "
|
||||
"and style, and glasses, a beard or other face details you can see. No clothes, no expression, no background. "
|
||||
"Start with 'a man' or 'a woman'."
|
||||
)
|
||||
|
||||
|
||||
def add_looks(text, jobs, describe):
|
||||
"""A swap's script with an @N looks like line for each new person who has none, written by describe(slot), a
|
||||
small picture-reading model shown that person's picture. Without such a line H3 kept the original person for the
|
||||
first seconds (2026-11-07), so a connected model writes it when the user didn't."""
|
||||
typed = {int(match.group(1)) for match in map(APPEARANCE_LINE.match, text.splitlines()) if match}
|
||||
for slot in sorted(jobs):
|
||||
if jobs[slot] not in (PERSON, SHEET) or slot in typed:
|
||||
continue
|
||||
words = next((line for line in str(describe(slot) or "").splitlines() if line.strip()), "")
|
||||
words = re.sub(r'["\u201c\u201d]', "", words).strip(" .,;:")
|
||||
words = re.sub(r"^(?:the\s+)?(?:person|subject)\s+(?:in\s+the\s+picture\s+)?(?:is|looks\s+like)\s+", "", words, flags=re.I)
|
||||
# "and no visible beard": naming what isn't there can put it in the picture.
|
||||
words = re.sub(r",?\s*(?:and\s+|with\s+)*(?:no|without)\s+(?:visible\s+)?[^,.;]*", "", words, flags=re.I).strip(" .,;:")
|
||||
if words:
|
||||
text = f"{text.rstrip()}\n@{slot} looks like {words[:1].lower() + words[1:]}"
|
||||
return text
|
||||
|
||||
|
||||
def swap_appearances(text, jobs, video):
|
||||
"""An optional separate @1 looks like line describes a replacement, not who goes.
|
||||
|
||||
@@ -170,21 +143,6 @@ REPLACES = re.compile(
|
||||
r"|swaps?\s+(?:in\s+)?for|goes\s+in\s+for|stands?\s+in\s+for)\s+",
|
||||
re.I,
|
||||
)
|
||||
# A plain sentence: "Replace the man in red with @1", "swap the guy for @1", "make the dancer look like @1",
|
||||
# "swap @1 in for the singer". The words caught are who goes.
|
||||
ASKS = re.compile(
|
||||
r"^\s*(?:please\s+)?(?:replace|swap(?:\s+out)?|switch(?:\s+out)?|change|turn|make)\s+(?!@\d)(.+?)\s+"
|
||||
r"(?:with|for|to|into|by|look\s+like|become)\s+@\d\b"
|
||||
r"|^\s*(?:please\s+)?(?:swap|put|place|drop|switch)\s+@\d\s+(?:in\s+)?(?:with|for|over|on|onto|in\s+place\s+of)\s+(.+)",
|
||||
re.I,
|
||||
)
|
||||
# "the cashier is now @1", "the man on the left becomes @1".
|
||||
BECOMES = re.compile(
|
||||
r"^\s*(.+?)\s+(?:is\s+now|becomes|turns\s+into|is\s+replaced\s+(?:by|with)|gets\s+replaced\s+(?:by|with)"
|
||||
r"|is\s+swapped\s+(?:for|with|to)|gets\s+swapped\s+(?:for|with)|->|\u2192)\s*@\d\b",
|
||||
re.I,
|
||||
)
|
||||
IN_THE_CLIP = re.compile(r"\s+(?:in|from|of)\s+the\s+(?:clip|video|original(?:\s+(?:clip|video))?)\b", re.I)
|
||||
# "Elon is @1," names the new person, not the one who goes.
|
||||
# "the man on the left, Dario is" ends where the next person's name starts.
|
||||
NAMES_NEXT = re.compile(r",?\s*(?:[A-Z][\w'.-]*\s+)+(?:is|as|plays)\s*$")
|
||||
@@ -231,16 +189,9 @@ REVOICE_KEPT = (
|
||||
|
||||
def who_goes_in(text):
|
||||
"""Who a typed swap line says goes, without the rest of the line: "Elon is @1, he replaces the man at the
|
||||
counter" is the man at the counter, and so is "Replace the man at the counter with @1."."""
|
||||
counter" is the man at the counter."""
|
||||
replaces = REPLACES.search(text)
|
||||
plain = None if replaces else next((match for match in (ASKS.search(text), BECOMES.search(text)) if match), None)
|
||||
if replaces:
|
||||
text = text[replaces.end() :]
|
||||
elif plain:
|
||||
text = next(group for group in plain.groups() if group)
|
||||
else:
|
||||
text = NAMES_THE_NEW.sub("", text)
|
||||
text = IN_THE_CLIP.sub("", text)
|
||||
text = text[replaces.end() :] if replaces else NAMES_THE_NEW.sub("", text)
|
||||
text = re.split(r"[.;!?]", text.strip(" .;!?"), maxsplit=1)[0]
|
||||
text = NAMES_NEXT.sub("", text)
|
||||
text = SAYS_WHO.sub("", TOKEN.sub("", text).strip(" .,;")).strip(" .,;")
|
||||
|
||||
@@ -340,16 +340,14 @@ def write_prompt(script, seconds=15.0, camera="", look="", sound="", language="E
|
||||
cast.clip_sound = True
|
||||
# With no words typed, the clip's own speech is still heard, so the new person says it as the video does.
|
||||
cast.typed_lines = any(moment.speech for moment in scene.moments)
|
||||
# Only a voice makes them speak: a train or a beat is loud in the voice band too, but has no clear pitch,
|
||||
# and the line made the new person mouth along to it (subway clip, 2026-11-07: 0.0 s of clear voice).
|
||||
cast.speaks_as_video = heard_a_voice(speech) and not cast.typed_lines
|
||||
cast.speaks_as_video = bool(speech) and not cast.typed_lines
|
||||
time_from_sound(scene.moments, speech)
|
||||
scene.moments = [moment for moment in scene.moments if not (moment.voice or moment.other)]
|
||||
if not scene.moments:
|
||||
scene.moments.append(Moment(ACTION_GOES_ON if video == KEEP else ""))
|
||||
if voice and not any(moment.speech for moment in scene.moments):
|
||||
raise ValueError(VOICE_NO_WORDS)
|
||||
if ask is not None and video != SWAP: # A swap keeps the clip's own action; its model only writes the looks.
|
||||
if ask is not None:
|
||||
add_moments(scene, seconds, ask, jobs)
|
||||
add_sound(scene, ask, jobs)
|
||||
audio += soundtrack and video == KEEP
|
||||
@@ -440,14 +438,6 @@ LONGEST_LINE = 12 # Stretches of speech one line can take.
|
||||
GAP_WEIGHT, INNER_GAP_WEIGHT, NOISE_WEIGHT, PITCH_WEIGHT = 1.0, 1.0, 1.5, 6.0
|
||||
|
||||
|
||||
SPEAKING_VOICE = 0.5 # Seconds of clear voice before the clip counts as someone talking.
|
||||
|
||||
|
||||
def heard_a_voice(speech):
|
||||
"""Whether find_speech heard someone talk: enough clear voice, not just loud sound in the voice band."""
|
||||
return sum(stretch[3] for stretch in speech or ()) >= SPEAKING_VOICE
|
||||
|
||||
|
||||
def find_speech(samples, rate):
|
||||
"""[(start, end, pitch, voiced), ...]: the stretches of a mono track loud
|
||||
enough in the voice band to be words, in seconds, with each stretch's
|
||||
|
||||
@@ -727,16 +727,11 @@ def before_the_action(number, text, cast, wearers):
|
||||
for _, who, new in cast.swaps:
|
||||
picture = next((p for p in cast.swappers if subject_label(p.subject) == new), None)
|
||||
appearance = cast.appearances.get(picture.slot) if picture else None
|
||||
if picture:
|
||||
# Every swap gets the firm wording. Without it H3 kept the original person for the first seconds,
|
||||
# or for the whole clip at Fast preview; with it the new
|
||||
# person is there from the first frame. An @1 looks like line only adds its own words.
|
||||
looks = (f"has the defined appearance from {picture_label(picture.picture)}: {appearance}" if appearance
|
||||
else f"has the face, hair and clothes of the person in {picture_label(picture.picture)}")
|
||||
if appearance:
|
||||
notes.append(
|
||||
f"{new} occupies the starting position of {who} and follows the same route, movements, timing and pace. "
|
||||
f"{new} {looks}. "
|
||||
f"The replacement stays present throughout, from the first frame, ending at the original performance's final position. "
|
||||
f"{new} has the defined appearance from {picture_label(picture.picture)}: {appearance}. "
|
||||
f"The replacement stays present throughout, ending at the original performance's final position. "
|
||||
f"Do not show {who} in that role at any time; there is no transition or morph into the replacement."
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
"""Pictures Save Image holds until they are kept.
|
||||
|
||||
With Auto save off, a run writes its pictures to ComfyUI's temp folder and
|
||||
shows them on the node. A click on the node's Save button keeps the ones
|
||||
that are wanted: they are copied into the output folder under the names the
|
||||
node would have given them. Everything here works from names this module
|
||||
made itself; the only thing a caller sends is the run's token and which
|
||||
pictures to keep.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
|
||||
from ._image_save_helpers import (
|
||||
IMAGE_SAVE_LOCK,
|
||||
existing_action,
|
||||
name_stem,
|
||||
next_free_counter,
|
||||
plan_exact_names,
|
||||
plan_local_names,
|
||||
require_output_path,
|
||||
resolve_output_root,
|
||||
sidecar_path,
|
||||
split_prefix,
|
||||
strip_image_extension,
|
||||
)
|
||||
|
||||
HELD_FOLDER = "ausboss_held"
|
||||
_TOKEN = re.compile(r"^[0-9a-f]{16}$")
|
||||
GONE = "These pictures are no longer held. Run the workflow again."
|
||||
|
||||
|
||||
def plan_destinations(settings: dict, count: int, now: datetime, root: Path, output_root: Path, picks=None):
|
||||
"""Where each picture goes, as (path, what to do if it exists).
|
||||
|
||||
Call it with IMAGE_SAVE_LOCK held: the counter it reads stays free only
|
||||
until the lock is let go. `picks` keeps some of a run's pictures; an
|
||||
exact name then keeps the number it would have had in the whole run.
|
||||
"""
|
||||
wanted = list(range(count)) if picks is None else list(picks)
|
||||
extension = settings["extension"]
|
||||
exact = settings.get("exact") or ""
|
||||
if exact:
|
||||
names = plan_exact_names(strip_image_extension(exact), extension, count)
|
||||
return [(root / names[index], settings["on_existing"]) for index in wanted]
|
||||
subfolder, base = split_prefix(settings["filename_prefix"])
|
||||
folder = root / subfolder if subfolder else root
|
||||
require_output_path(folder, output_root)
|
||||
stem = name_stem(
|
||||
base,
|
||||
{"date": bool(settings["name_date"]), "time": bool(settings["name_time"]), "size": bool(settings["name_size"])},
|
||||
now,
|
||||
int(settings["width"]),
|
||||
int(settings["height"]),
|
||||
)
|
||||
counter = bool(settings["name_counter"])
|
||||
existing = [entry.name for entry in folder.iterdir()] if folder.is_dir() else []
|
||||
names = plan_local_names(
|
||||
stem, extension, len(wanted), counter=counter, batch=bool(settings["name_batch"]),
|
||||
next_counter=next_free_counter(existing, stem),
|
||||
)
|
||||
# With a counter the name is free by construction. Without one, the
|
||||
# legacy policy decides whether to overwrite.
|
||||
policy = "overwrite" if counter else settings["on_existing"]
|
||||
return [(folder / name, policy) for name in names]
|
||||
|
||||
|
||||
def settle_destinations(planned, has_caption: bool, output_root: Path) -> list[str]:
|
||||
"""Check every destination, then say 'write' or 'skip' for each.
|
||||
|
||||
The whole batch is settled before any write: an "error" on a later
|
||||
picture stops the save before the first one lands.
|
||||
"""
|
||||
for path, _policy in planned:
|
||||
require_output_path(path, output_root)
|
||||
if has_caption:
|
||||
require_output_path(sidecar_path(path), output_root)
|
||||
return [
|
||||
existing_action(path.exists() or (has_caption and sidecar_path(path).exists()), policy)
|
||||
for path, policy in planned
|
||||
]
|
||||
|
||||
|
||||
def _held_name(token: str, index: int, extension: str) -> str:
|
||||
return f"{token}_{index:03d}.{extension}"
|
||||
|
||||
|
||||
def hold_pictures(temp_root, frames, settings: dict, encode) -> tuple[str, list[dict]]:
|
||||
"""Write a run's pictures to the temp folder and remember how to name them.
|
||||
|
||||
`encode(path, frame)` writes one picture in the run's format. Returns
|
||||
the run's token and the entries the node shows them from.
|
||||
"""
|
||||
folder = Path(temp_root) / HELD_FOLDER
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
token = secrets.token_hex(8)
|
||||
previews = []
|
||||
for index, frame in enumerate(frames):
|
||||
name = _held_name(token, index, settings["extension"])
|
||||
encode(folder / name, frame)
|
||||
previews.append({"filename": name, "subfolder": HELD_FOLDER, "type": "temp"})
|
||||
manifest = dict(settings, count=len(previews), kept={})
|
||||
(folder / f"{token}.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
return token, previews
|
||||
|
||||
|
||||
def keep_pictures(temp_root, output_root, token, picks=None, now: datetime | None = None) -> dict:
|
||||
"""Copy held pictures into the output folder. Returns what was saved.
|
||||
|
||||
`picks` lists which pictures of the run to keep, counted from 0; None
|
||||
keeps them all. A picture kept before is not saved a second time.
|
||||
"""
|
||||
if not isinstance(token, str) or not _TOKEN.match(token):
|
||||
raise ValueError("Save Image: that is not a run it is holding.")
|
||||
folder = Path(temp_root) / HELD_FOLDER
|
||||
manifest_path = folder / f"{token}.json"
|
||||
if not manifest_path.is_file():
|
||||
raise LookupError(GONE)
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
count = int(manifest["count"])
|
||||
kept = {int(index): str(path) for index, path in dict(manifest.get("kept") or {}).items()}
|
||||
|
||||
if picks is None:
|
||||
wanted = list(range(count))
|
||||
else:
|
||||
wanted = []
|
||||
for pick in picks:
|
||||
if isinstance(pick, bool) or not isinstance(pick, int) or not 0 <= pick < count:
|
||||
raise ValueError("Save Image: that picture is not in this run.")
|
||||
if pick not in wanted:
|
||||
wanted.append(pick)
|
||||
todo = [index for index in wanted if index not in kept]
|
||||
|
||||
output_root = Path(output_root)
|
||||
caption = str(manifest.get("caption") or "")
|
||||
has_caption = bool(caption.strip())
|
||||
skipped: list[int] = []
|
||||
saved: list[int] = []
|
||||
with IMAGE_SAVE_LOCK:
|
||||
root = resolve_output_root(manifest.get("output_dir", ""), output_root)
|
||||
planned = plan_destinations(manifest, count, now or datetime.now(), root, output_root, picks=todo)
|
||||
actions = settle_destinations(planned, has_caption, output_root)
|
||||
resolved_root = output_root.resolve()
|
||||
for (path, _policy), action, index in zip(planned, actions, todo):
|
||||
if action == "skip":
|
||||
skipped.append(index)
|
||||
continue
|
||||
source = folder / _held_name(token, index, manifest["extension"])
|
||||
if not source.is_file():
|
||||
raise LookupError(GONE)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, path)
|
||||
if has_caption:
|
||||
sidecar_path(path).write_text(caption, encoding="utf-8")
|
||||
kept[index] = path.resolve().relative_to(resolved_root).as_posix()
|
||||
saved.append(index)
|
||||
manifest["kept"] = {str(index): path for index, path in kept.items()}
|
||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
return {
|
||||
"count": count,
|
||||
"saved": [{"index": index, "path": kept[index]} for index in saved],
|
||||
"kept": sorted(kept),
|
||||
"skipped": skipped,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -41,16 +41,6 @@ RADIUS_MAX = 4096.0
|
||||
# The face's own picture: the long side is at most this.
|
||||
SMALL_SIDE = 1024
|
||||
|
||||
# SAM 3 is asked about a copy of a big picture: its long side is at most
|
||||
# this. ComfyUI's SAM 3 reads every picture at 1008 pixels and returns each
|
||||
# answer at the size of the picture it was given, on the graphics card. At
|
||||
# 8192 pixels one word with sixteen matches needed over 27 GB there, and a
|
||||
# look at 3840 took twice as long as at 2048, all for outlines the model
|
||||
# never saw that finely. Twice what the model reads keeps its second pass
|
||||
# (a close-up of each thing it found) as sharp as before on pictures up to
|
||||
# this size, which come out exactly as they did.
|
||||
WORK_SIDE = 2048
|
||||
|
||||
# How a find stroke is asked (find_points, choose_found). Measured on 27
|
||||
# strokes over three pictures, each drawn 13 slightly different ways: four
|
||||
# points alone returned the stroked thing in 87 % of 351 tries, two points
|
||||
@@ -133,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."
|
||||
@@ -202,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."""
|
||||
@@ -396,73 +330,6 @@ def read_ask(ask, height: int, width: int) -> tuple:
|
||||
return "look", None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The copy of a big picture that SAM 3 is asked about
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WorkView:
|
||||
"""One picture as SAM 3 is asked about it.
|
||||
|
||||
A picture whose long side is over WORK_SIDE is asked about on a smaller
|
||||
copy (`frame`, averaged down, so nothing shimmers), and what SAM 3
|
||||
answers there is brought back to the picture's own size with `full`.
|
||||
A smaller picture is its own copy: `same` is true and nothing changes.
|
||||
|
||||
Touches and pieces keep their places in pixels of the picture. `point`
|
||||
gives a place on the copy, `full_point` a place on the picture that
|
||||
`point` leads back to the same pixel of the copy.
|
||||
"""
|
||||
|
||||
def __init__(self, frame: torch.Tensor):
|
||||
self.height, self.width = int(frame.shape[1]), int(frame.shape[2])
|
||||
long_side = max(self.height, self.width)
|
||||
self.same = long_side <= WORK_SIDE
|
||||
if self.same:
|
||||
self.frame, self.work_height, self.work_width = frame, self.height, self.width
|
||||
return
|
||||
scale = WORK_SIDE / long_side
|
||||
self.work_height = max(1, int(math.floor(self.height * scale + 0.5)))
|
||||
self.work_width = max(1, int(math.floor(self.width * scale + 0.5)))
|
||||
small = F.interpolate(
|
||||
frame.detach().float().cpu().movedim(-1, 1), size=(self.work_height, self.work_width), mode="area",
|
||||
)
|
||||
self.frame = small.movedim(1, -1).clamp(0.0, 1.0).contiguous()
|
||||
|
||||
def point(self, point) -> tuple:
|
||||
x = min(self.work_width - 1, max(0, int(point[0]) * self.work_width // self.width))
|
||||
y = min(self.work_height - 1, max(0, int(point[1]) * self.work_height // self.height))
|
||||
return (x, y)
|
||||
|
||||
def full_point(self, point) -> tuple:
|
||||
x = min(self.width - 1, -(-int(point[0]) * self.width // self.work_width))
|
||||
y = min(self.height - 1, -(-int(point[1]) * self.height // self.work_height))
|
||||
return (x, y)
|
||||
|
||||
def points(self, points) -> tuple:
|
||||
return tuple(self.point(point) for point in points)
|
||||
|
||||
def length(self, value: float) -> float:
|
||||
"""A length of the picture (a brush's radius) on the copy."""
|
||||
return float(value) * self.work_width / self.width
|
||||
|
||||
def full_box(self, box) -> list:
|
||||
if self.same:
|
||||
return list(box)
|
||||
x0, y0, x1, y1 = (int(value) for value in box)
|
||||
return [
|
||||
x0 * self.width // self.work_width, y0 * self.height // self.work_height,
|
||||
min(self.width, -(-x1 * self.width // self.work_width)), min(self.height, -(-y1 * self.height // self.work_height)),
|
||||
]
|
||||
|
||||
def full(self, mask: torch.Tensor) -> torch.Tensor:
|
||||
"""An answer from the copy at the picture's own size (bool)."""
|
||||
if self.same or tuple(mask.shape) == (self.height, self.width):
|
||||
return mask
|
||||
soft = F.interpolate(mask[None, None].float(), size=(self.height, self.width), mode="bilinear", align_corners=False)
|
||||
return soft[0, 0] >= 0.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strokes
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1267,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",
|
||||
@@ -1285,7 +1149,6 @@ __all__ = [
|
||||
"POINTS_MAX",
|
||||
"PieceSieve",
|
||||
"SMALL_SIDE",
|
||||
"START_OVER",
|
||||
"TEXT_MAX",
|
||||
"TOO_MUCH",
|
||||
"Touches",
|
||||
@@ -1305,7 +1168,6 @@ __all__ = [
|
||||
"keep_touched",
|
||||
"match_points",
|
||||
"match_under",
|
||||
"move_touches",
|
||||
"pack_planes",
|
||||
"parse_touches",
|
||||
"picture_key",
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
"""Only one copy of the pack loads.
|
||||
|
||||
A Manager install lands in ``custom_nodes/ausboss-nodes`` and a git clone in
|
||||
``custom_nodes/ComfyUI-AusBoss``. With both in place every route is added
|
||||
twice and ComfyUI stops while starting. So each copy looks for the others
|
||||
and all of them agree on the one that loads; the rest step aside and say
|
||||
which folder to delete.
|
||||
|
||||
A copy from before this file existed cannot step aside, so it always loads
|
||||
and a newer copy yields to it. Among copies that can, the newest version
|
||||
loads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tomllib
|
||||
|
||||
PACK_NAME = "ausboss-nodes"
|
||||
# A copy that has this file knows how to step aside.
|
||||
_GUARD_FILE = os.path.join("nodes", "_single_copy.py")
|
||||
|
||||
|
||||
def _read_pack(folder: str) -> dict | None:
|
||||
"""Name the pack copy in ``folder``, or None when it is another pack."""
|
||||
try:
|
||||
with open(os.path.join(folder, "pyproject.toml"), "rb") as f:
|
||||
project = tomllib.load(f).get("project", {})
|
||||
except Exception:
|
||||
return None
|
||||
if project.get("name") != PACK_NAME:
|
||||
return None
|
||||
if not os.path.isfile(os.path.join(folder, "__init__.py")):
|
||||
return None
|
||||
version = project.get("version")
|
||||
return {
|
||||
"folder": folder,
|
||||
"version": version if isinstance(version, str) else "unknown",
|
||||
"guarded": os.path.isfile(os.path.join(folder, _GUARD_FILE)),
|
||||
}
|
||||
|
||||
|
||||
def _version_key(version: str) -> tuple:
|
||||
parts = []
|
||||
for part in version.split("."):
|
||||
digits = "".join(ch for ch in part if ch.isdigit())
|
||||
parts.append(int(digits) if digits else -1)
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def find_copies(own_folder: str, roots: list[str]) -> list[dict]:
|
||||
"""Every copy of the pack below ``roots``, this one included."""
|
||||
own = os.path.realpath(own_folder)
|
||||
seen = {own}
|
||||
copies = [_read_pack(own) or {"folder": own, "version": "unknown", "guarded": True}]
|
||||
for root in roots:
|
||||
try:
|
||||
names = sorted(os.listdir(root))
|
||||
except OSError:
|
||||
continue
|
||||
for name in names:
|
||||
# ComfyUI skips these too.
|
||||
if name.endswith(".disabled") or name.startswith("."):
|
||||
continue
|
||||
folder = os.path.realpath(os.path.join(root, name))
|
||||
if folder in seen or not os.path.isdir(folder):
|
||||
continue
|
||||
seen.add(folder)
|
||||
pack = _read_pack(folder)
|
||||
if pack:
|
||||
copies.append(pack)
|
||||
return copies
|
||||
|
||||
|
||||
def pick_copy(copies: list[dict]) -> dict:
|
||||
"""The one copy that loads. Every copy reaches the same answer."""
|
||||
unguarded = [c for c in copies if not c["guarded"]]
|
||||
pool = unguarded or copies
|
||||
return max(pool, key=lambda c: (_version_key(c["version"]), c["folder"]))
|
||||
|
||||
|
||||
def other_copy_that_loads(own_folder: str, roots: list[str] | None = None) -> dict | None:
|
||||
"""The copy this one steps aside for, or None when this one loads."""
|
||||
if roots is None:
|
||||
# Outside ComfyUI nothing is served, so there is nothing to clash.
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
roots = list(folder_paths.get_folder_paths("custom_nodes"))
|
||||
except Exception:
|
||||
return None
|
||||
roots = list(dict.fromkeys(os.path.realpath(r) for r in roots))
|
||||
copies = find_copies(own_folder, roots)
|
||||
if len(copies) < 2:
|
||||
return None
|
||||
chosen = pick_copy(copies)
|
||||
return None if chosen["folder"] == os.path.realpath(own_folder) else chosen
|
||||
@@ -1,213 +0,0 @@
|
||||
"""Edit Lock 🆎 and Edit Lock Restore 🆎: an edit leaves the rest of the picture alone.
|
||||
|
||||
An edit model redraws the whole picture, so the parts nobody asked to change
|
||||
come back a little moved and a little tinted. Edit Lock watches the model's
|
||||
own prediction at every step, reads where it differs from the original, and
|
||||
puts the original back everywhere else, so the model finishes the edit on
|
||||
top of the real picture. Edit Lock Restore then swaps the original's own
|
||||
pixels in for those parts after the decode. The deciding and blending live
|
||||
in _edit_lock_helpers.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
from ._edit_lock_helpers import (
|
||||
LOCK_MODES,
|
||||
EditLock,
|
||||
describe,
|
||||
restore_pixels,
|
||||
)
|
||||
|
||||
|
||||
class AusBossEditLock:
|
||||
CATEGORY = "🆎 AusBoss/Image"
|
||||
DESCRIPTION = (
|
||||
"Keeps every part of your picture that an edit did not change "
|
||||
"exactly where it was, with the colors it had. Edit models redraw "
|
||||
"the whole picture, so untouched parts often come back slightly "
|
||||
"zoomed, shifted or tinted. Put this between the model and the "
|
||||
"sampler and give it the picture you are editing. It needs no "
|
||||
"mask: it sees what the edit changes while the picture is made. "
|
||||
"When the edit changes the whole picture (a new style, new light, "
|
||||
"new weather) it steps aside. Add Edit Lock Restore 🆎 after the "
|
||||
"decode to get your own pixels back in the untouched parts."
|
||||
)
|
||||
SEARCH_ALIASES = [
|
||||
"edit lock",
|
||||
"pixel drift",
|
||||
"pixel shift",
|
||||
"color shift",
|
||||
"consistency",
|
||||
"keep unchanged",
|
||||
"preserve original",
|
||||
"tail steer",
|
||||
"no drift edit",
|
||||
"ausboss",
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model": (
|
||||
"MODEL",
|
||||
{"tooltip": "The edit model. Patch last, after any LoRAs."},
|
||||
),
|
||||
"vae": (
|
||||
"VAE",
|
||||
{"tooltip": "The same VAE the workflow decodes with."},
|
||||
),
|
||||
"image": (
|
||||
"IMAGE",
|
||||
{
|
||||
"tooltip": (
|
||||
"The picture you are editing, at the size of the "
|
||||
"result. Use the same picture the edit model gets."
|
||||
),
|
||||
},
|
||||
),
|
||||
"lock": (
|
||||
list(LOCK_MODES),
|
||||
{
|
||||
"default": LOCK_MODES[0],
|
||||
"tooltip": (
|
||||
"auto locks the untouched parts and steps aside "
|
||||
"when the whole picture changes. always locks "
|
||||
"even then. off does nothing."
|
||||
),
|
||||
},
|
||||
),
|
||||
"tolerance": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 1.0,
|
||||
"min": 0.25,
|
||||
"max": 4.0,
|
||||
"step": 0.05,
|
||||
"tooltip": (
|
||||
"How big a change must be to count as part of the "
|
||||
"edit. Raise it if the lock lets small unwanted "
|
||||
"changes through; lower it if it holds back part "
|
||||
"of your edit."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"mask": (
|
||||
"MASK",
|
||||
{
|
||||
"tooltip": (
|
||||
"Optional. White marks where the edit may happen; "
|
||||
"everything else is locked. With a mask the node "
|
||||
"does not look for the change itself."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "AUSBOSS_EDIT_LOCK")
|
||||
RETURN_NAMES = ("model", "edit_lock")
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"The model with the lock on. Plug it into the sampler.",
|
||||
"What the lock kept. Plug it into Edit Lock Restore 🆎 after the decode.",
|
||||
)
|
||||
FUNCTION = "patch"
|
||||
|
||||
def patch(self, model, vae, image, lock, tolerance, mask=None):
|
||||
if image.ndim != 4:
|
||||
raise ValueError("Edit Lock: image must be an IMAGE batch.")
|
||||
state = EditLock(mode=str(lock), tolerance=float(tolerance))
|
||||
source = image[:1, ..., :3]
|
||||
state.source = source.detach().float().cpu()
|
||||
if mask is not None:
|
||||
state.user_mask = mask.detach().float().cpu()
|
||||
if state.mode == "off":
|
||||
state.status = "off"
|
||||
return (model, state)
|
||||
latent = vae.encode(source)
|
||||
patched = model.clone()
|
||||
state.latent = patched.model.process_latent_in(latent).detach().float().cpu()
|
||||
patched.set_model_sampler_post_cfg_function(state.step)
|
||||
return (patched, state)
|
||||
|
||||
|
||||
class AusBossEditLockRestore:
|
||||
CATEGORY = "🆎 AusBoss/Image"
|
||||
DESCRIPTION = (
|
||||
"Puts your picture's own pixels back in every part the edit did not "
|
||||
"change. Use it right after the VAE decode, with the edit_lock "
|
||||
"output of Edit Lock 🆎. The untouched parts then match your "
|
||||
"original pixel for pixel; only the edit is new. When the lock "
|
||||
"stepped aside the picture passes through unchanged."
|
||||
)
|
||||
SEARCH_ALIASES = [
|
||||
"edit lock restore",
|
||||
"restore original pixels",
|
||||
"paste back",
|
||||
"keep unchanged",
|
||||
"ausboss",
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": (
|
||||
"IMAGE",
|
||||
{"tooltip": "The edited picture, straight from the VAE decode."},
|
||||
),
|
||||
"edit_lock": (
|
||||
"AUSBOSS_EDIT_LOCK",
|
||||
{"tooltip": "From Edit Lock 🆎."},
|
||||
),
|
||||
"feather": (
|
||||
"INT",
|
||||
{
|
||||
"default": 16,
|
||||
"min": 0,
|
||||
"max": 256,
|
||||
"step": 1,
|
||||
"tooltip": (
|
||||
"How softly your original fades into the edit, in "
|
||||
"pixels. The fade stays outside the edit."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "STRING")
|
||||
RETURN_NAMES = ("image", "edit_mask", "report")
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"The edit with your original pixels in the untouched parts.",
|
||||
"White where the edit changed the picture.",
|
||||
"How much of the picture was kept. Plug it into Show Text 🆎 to read it.",
|
||||
)
|
||||
FUNCTION = "restore"
|
||||
|
||||
def restore(self, image, edit_lock, feather):
|
||||
if image.ndim != 4:
|
||||
raise ValueError("Edit Lock Restore: image must be an IMAGE batch.")
|
||||
if not isinstance(edit_lock, EditLock):
|
||||
raise ValueError("Edit Lock Restore: edit_lock needs the output of Edit Lock.")
|
||||
height, width = int(image.shape[1]), int(image.shape[2])
|
||||
mask = edit_lock.pixel_mask(height, width)
|
||||
if mask is None or edit_lock.source is None:
|
||||
return (image, torch.ones((int(image.shape[0]), height, width)), describe(edit_lock))
|
||||
result, edit_mask = restore_pixels(image, edit_lock.source, mask, int(feather))
|
||||
return (result, edit_mask, describe(edit_lock))
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"AUSBOSS_NODES_EditLock": AusBossEditLock,
|
||||
"AUSBOSS_NODES_EditLockRestore": AusBossEditLockRestore,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"AUSBOSS_NODES_EditLock": "Edit Lock 🆎",
|
||||
"AUSBOSS_NODES_EditLockRestore": "Edit Lock Restore 🆎",
|
||||
}
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from ._h3_cast_helpers import DESCRIBE_PERSON, add_looks, EDIT, JOBS, KEEP, KEEPS_ITS_LENGTH, PERSON, PLACE, START, SWAP, THING, VIDEO_JOBS
|
||||
from ._h3_cast_helpers import EDIT, JOBS, KEEP, KEEPS_ITS_LENGTH, PERSON, PLACE, START, SWAP, THING, VIDEO_JOBS
|
||||
from ._h3_prompt_helpers import find_beats, find_speech, split_sections, write_prompt
|
||||
from ._h3_script_helpers import CAMERAS, LOOKS, script_report
|
||||
from ._route_errors import safe_route_error
|
||||
@@ -177,8 +177,7 @@ class AusBossH3PromptWriter:
|
||||
{
|
||||
"tooltip": (
|
||||
"Optional: a small text model such as Qwen3-VL 8B. It adds "
|
||||
"moments after a one-line idea and matches the sound to the place. "
|
||||
"In a swap it writes what the new person looks like, from their picture."
|
||||
"moments after a one-line idea and matches the sound to the place."
|
||||
),
|
||||
},
|
||||
),
|
||||
@@ -336,9 +335,6 @@ class AusBossH3PromptWriter:
|
||||
kinds = {1: job_1, 2: job_2, 3: job_3, 4: job_4}
|
||||
jobs = {slot: kinds[slot] for slot in given}
|
||||
video_job = "" if video is None else video_job
|
||||
if clip is not None and video_job == SWAP:
|
||||
# The model on clip writes each new person's @N looks like line from their picture, when none is typed.
|
||||
script = add_looks(script, jobs, lambda slot: model_asker(clip, given[slot])(DESCRIBE_PERSON, 60))
|
||||
frames = clip_frames(seconds)
|
||||
if video_job in KEEPS_ITS_LENGTH:
|
||||
frames = video_frames(video.shape[0])
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
+44
-137
@@ -28,21 +28,16 @@ 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,
|
||||
PieceSieve,
|
||||
WorkView,
|
||||
apply_item,
|
||||
box_of,
|
||||
choose_found,
|
||||
@@ -52,7 +47,6 @@ from ._mask_by_touch_helpers import (
|
||||
finish_mask,
|
||||
match_points,
|
||||
match_under,
|
||||
move_touches,
|
||||
pack_planes,
|
||||
parse_touches,
|
||||
picture_key,
|
||||
@@ -127,8 +121,6 @@ QUICK = True
|
||||
|
||||
# Shared by every Mask by Touch node: what SAM 3 answered, per picture.
|
||||
_MEMORY = Memory()
|
||||
# How many of those smaller copies a node keeps: this picture's and the last one's.
|
||||
VIEWS_KEPT = 2
|
||||
# What the text reader made of each word, per text reader. A word reads the
|
||||
# same for every picture, so it is read once.
|
||||
_WORDS = OrderedDict()
|
||||
@@ -220,19 +212,19 @@ def _server():
|
||||
return None
|
||||
|
||||
|
||||
def _tell(node_id, note: dict, end: bool, stopped: bool = False) -> bool:
|
||||
def _tell(node_id, note: dict, end: bool) -> bool:
|
||||
"""Tell the node's face what a look has found, over ComfyUI's own channel
|
||||
to its page. It goes to the page that asked for the run. True when it
|
||||
was sent; a failure never stops the look. `stopped`: the run stops at
|
||||
this node, and the face is only shown the picture that came in."""
|
||||
was sent; a failure never stops the look."""
|
||||
server = _server()
|
||||
if server is None or node_id is None:
|
||||
return False
|
||||
event = {"node": str(node_id), "note": note, "end": bool(end)}
|
||||
if stopped:
|
||||
event["stopped"] = True
|
||||
try:
|
||||
server.send_sync(LOOK_EVENT, event, getattr(server, "client_id", None))
|
||||
server.send_sync(
|
||||
LOOK_EVENT,
|
||||
{"node": str(node_id), "note": note, "end": bool(end)},
|
||||
getattr(server, "client_id", None),
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
return True
|
||||
@@ -728,10 +720,10 @@ class AusBossMaskByTouch:
|
||||
"fill_holes": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"default": True,
|
||||
"tooltip": (
|
||||
"Closes pinholes inside the picked area. Larger openings "
|
||||
"stay open, like a mug's handle. Off keeps every hole you pick."
|
||||
"stay open, like a mug's handle."
|
||||
),
|
||||
},
|
||||
),
|
||||
@@ -794,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.
|
||||
@@ -839,44 +819,25 @@ class AusBossMaskByTouch:
|
||||
# What Fine edges is set to in the run at hand: the subject is asked
|
||||
# of that model.
|
||||
self._edge_choice = FINE_OFF
|
||||
# The smaller copies of big pictures SAM 3 is asked about, newest last.
|
||||
self._views = OrderedDict()
|
||||
|
||||
# -- what SAM 3 finds ---------------------------------------------------
|
||||
|
||||
def _view(self, frame, frame_key: str) -> WorkView:
|
||||
"""The picture as SAM 3 is asked about it: itself, or a smaller copy
|
||||
of a big one, kept for the next run on the same picture."""
|
||||
held = self._views.get(frame_key)
|
||||
if held is not None and (held.height, held.width) == (int(frame.shape[1]), int(frame.shape[2])):
|
||||
self._views.move_to_end(frame_key)
|
||||
return held
|
||||
view = WorkView(frame)
|
||||
if not view.same:
|
||||
self._views[frame_key] = view
|
||||
while len(self._views) > VIEWS_KEPT:
|
||||
self._views.popitem(last=False)
|
||||
return view
|
||||
|
||||
def _tap(self, session: _Session, view: WorkView, frame_key: str, point):
|
||||
"""The thing at one point, without its stray specks, at the size SAM 3
|
||||
was asked at (`view`). None for nothing. `point` is a pixel of the
|
||||
picture.
|
||||
def _tap(self, session: _Session, frame, frame_key: str, point):
|
||||
"""The thing at one point, without its stray specks. None for nothing.
|
||||
|
||||
Hovering's pieces and taps share this, and what it returned is
|
||||
remembered, so a tap on a piece that was shown gives that piece."""
|
||||
key = (session.token, frame_key, ("tap", int(point[0]), int(point[1])))
|
||||
found, answer = _MEMORY.get(key)
|
||||
if not found:
|
||||
spot = view.point(point)
|
||||
answer = drop_specks(session.ask(view.frame, [spot]), spot)
|
||||
answer = drop_specks(session.ask(frame, [point]), point)
|
||||
answer = answer if bool(answer.any()) else None
|
||||
_MEMORY.put(key, answer)
|
||||
return answer
|
||||
|
||||
def _named(self, session: _Session, view: WorkView, frame_key: str, word: str) -> list:
|
||||
def _named(self, session: _Session, frame, frame_key: str, word: str) -> list:
|
||||
"""Everything in one picture that SAM 3 calls by a word, tidied:
|
||||
[(bool at the size SAM 3 was asked at, how sure), ...].
|
||||
[(H x W bool, how sure), ...].
|
||||
|
||||
Hovering's named pieces and `name` touches share this, and what it
|
||||
returned is remembered, so a touch on a named piece that was shown
|
||||
@@ -884,7 +845,7 @@ class AusBossMaskByTouch:
|
||||
key = (session.token, session.reader, frame_key, ("word", word))
|
||||
found, matches = _MEMORY.get_matches(key)
|
||||
if not found:
|
||||
matches = tidy_matches(*session.ask_word(view.frame, word))
|
||||
matches = tidy_matches(*session.ask_word(frame, word))
|
||||
_MEMORY.put_matches(key, matches)
|
||||
return matches
|
||||
|
||||
@@ -895,9 +856,6 @@ class AusBossMaskByTouch:
|
||||
The area of a `paint` touch made with a soft brush is float, 0..1.
|
||||
"""
|
||||
height, width = int(frame.shape[1]), int(frame.shape[2])
|
||||
# What SAM 3 is asked comes back at the size it was asked at, and is
|
||||
# brought to the picture's own size here.
|
||||
view = self._view(frame, frame_key)
|
||||
if item.kind in ("subject", "background"):
|
||||
# The picture's main subject, as ComfyUI's background-removal
|
||||
# model sees it, or everything but it.
|
||||
@@ -906,25 +864,25 @@ class AusBossMaskByTouch:
|
||||
return (area, "thing", ()) if bool(area.any()) else (None, "none", ())
|
||||
if item.kind == "all":
|
||||
# Every thing called by that word, joined.
|
||||
matches = self._named(session, view, frame_key, item.word) if item.word else []
|
||||
matches = self._named(session, frame, frame_key, item.word) if item.word else []
|
||||
if not matches:
|
||||
return None, "none", ()
|
||||
area = torch.zeros(view.work_height, view.work_width, dtype=torch.bool)
|
||||
area = torch.zeros(height, width, dtype=torch.bool)
|
||||
for mask, _ in matches:
|
||||
area |= mask
|
||||
return view.full(area), "thing", tuple(range(len(matches)))
|
||||
return area, "thing", tuple(range(len(matches)))
|
||||
if item.kind in ("tap", "name"):
|
||||
if not (0 <= item.x < width and 0 <= item.y < height):
|
||||
return None, "none", ()
|
||||
if item.kind == "tap":
|
||||
answer = self._tap(session, view, frame_key, (item.x, item.y))
|
||||
return (view.full(answer), "thing", ()) if answer is not None else (None, "none", ())
|
||||
answer = self._tap(session, frame, frame_key, (item.x, item.y))
|
||||
return (answer, "thing", ()) if answer is not None else (None, "none", ())
|
||||
if not item.word:
|
||||
return None, "none", ()
|
||||
# Of the things called by that word, the smallest at the point.
|
||||
matches = self._named(session, view, frame_key, item.word)
|
||||
index = match_under(matches, view.point((item.x, item.y)))
|
||||
return (None, "none", ()) if index is None else (view.full(matches[index][0]), "thing", (index,))
|
||||
matches = self._named(session, frame, frame_key, item.word)
|
||||
index = match_under(matches, (item.x, item.y))
|
||||
return (None, "none", ()) if index is None else (matches[index][0], "thing", (index,))
|
||||
if item.kind not in ("find", "paint"):
|
||||
return None, "none", ()
|
||||
if item.kind == "paint" and item.soft > 0:
|
||||
@@ -943,18 +901,14 @@ class AusBossMaskByTouch:
|
||||
found, answer = _MEMORY.get(key)
|
||||
if not found:
|
||||
many, pair = find_points(item.pts, height, width)
|
||||
# The stroke as it lies on the copy SAM 3 is asked about.
|
||||
under = stroke if view.same else stroke_area(
|
||||
view.points(item.pts), max(1.0, view.length(item.r)), view.work_height, view.work_width,
|
||||
)
|
||||
answer = choose_found(
|
||||
session.ask(view.frame, view.points(many)) if many else None,
|
||||
session.ask(view.frame, view.points(pair)) if pair else None,
|
||||
under,
|
||||
session.ask(frame, many) if many else None,
|
||||
session.ask(frame, pair) if pair else None,
|
||||
stroke,
|
||||
)
|
||||
_MEMORY.put(key, answer)
|
||||
# Over empty space the paint is kept as it is.
|
||||
return (view.full(answer), "thing", ()) if answer is not None else (stroke, "paint", ())
|
||||
return (answer, "thing", ()) if answer is not None else (stroke, "paint", ())
|
||||
|
||||
def _look(self, session: _Session, frame, key: str, pieces: dict, pointer=None, tell=None) -> dict:
|
||||
"""Find a picture's pieces for hovering.
|
||||
@@ -972,7 +926,6 @@ class AusBossMaskByTouch:
|
||||
piece.
|
||||
"""
|
||||
height, width = int(frame.shape[1]), int(frame.shape[2])
|
||||
view = self._view(frame, key)
|
||||
queue = _scope_queue(width, height)
|
||||
sieve = pieces["sieve"]
|
||||
fresh = {} # (name or None, point) -> (area, is it tidy and remembered)
|
||||
@@ -997,7 +950,7 @@ class AusBossMaskByTouch:
|
||||
if pointer is not None:
|
||||
since = time.perf_counter()
|
||||
known = _MEMORY.has((session.token, key, ("tap", pointer[0], pointer[1])))
|
||||
answer = self._tap(session, view, key, pointer)
|
||||
answer = self._tap(session, frame, key, pointer)
|
||||
answered(known, since)
|
||||
if answer is not None and sieve.offer(pointer, answer, pointed=True):
|
||||
fresh[(None, pointer)] = (answer, True)
|
||||
@@ -1023,10 +976,8 @@ class AusBossMaskByTouch:
|
||||
break
|
||||
ahead.append(later)
|
||||
session.read_ahead(ahead, stop=_jobs_waiting)
|
||||
matches = self._named(session, view, key, what)
|
||||
for (area, sure), spot in zip(matches, match_points(matches)):
|
||||
# The match's own point, as a pixel of the picture.
|
||||
point = None if spot is None else view.full_point(spot)
|
||||
matches = self._named(session, frame, key, what)
|
||||
for (area, sure), point in zip(matches, match_points(matches)):
|
||||
if point is not None and sieve.offer(point, area, name=what, sure=sure):
|
||||
fresh[(what, point)] = (area, True)
|
||||
pieces["words"] += 1
|
||||
@@ -1034,7 +985,7 @@ class AusBossMaskByTouch:
|
||||
memory_key = (session.token, key, ("tap", what[0], what[1]))
|
||||
known, answer = _MEMORY.get(memory_key)
|
||||
if not known:
|
||||
answer = session.ask(view.frame, [view.point(what)])
|
||||
answer = session.ask(frame, [what])
|
||||
if not bool(answer.any()):
|
||||
_MEMORY.put(memory_key, None)
|
||||
answer = None
|
||||
@@ -1057,7 +1008,6 @@ class AusBossMaskByTouch:
|
||||
first, and the small copies packed into planes. `fresh` holds the
|
||||
areas found since the last time, and is emptied."""
|
||||
height, width = int(frame.shape[1]), int(frame.shape[2])
|
||||
view = self._view(frame, key)
|
||||
small_width, small_height, _ = small_size(width, height)
|
||||
sieve = pieces["sieve"]
|
||||
before = {ident: slot for slot, ident in enumerate(pieces["ids"])}
|
||||
@@ -1069,19 +1019,18 @@ class AusBossMaskByTouch:
|
||||
else:
|
||||
# A piece kept by a look that was stopped half way is asked
|
||||
# for again: its answer was not held on to.
|
||||
answer, tidy = fresh.get(ident) or (self._piece(session, view, key, name, point), True)
|
||||
answer, tidy = fresh.get(ident) or (self._piece(session, frame, key, name, point), True)
|
||||
if answer is None:
|
||||
continue
|
||||
if not tidy:
|
||||
# What a tap at this point gives: the same answer, tidied
|
||||
# the same way, remembered so the tap asks nothing.
|
||||
answer = drop_specks(answer, view.point(point))
|
||||
answer = drop_specks(answer, point)
|
||||
_MEMORY.put((session.token, key, ("tap", point[0], point[1])), answer)
|
||||
sure = sieve.sure_of(name, point)
|
||||
entry = {
|
||||
"x": point[0], "y": point[1],
|
||||
"share": round(float(answer.sum()) / (view.work_height * view.work_width), 5),
|
||||
"box": view.full_box(box_of(answer)),
|
||||
"share": round(float(answer.sum()) / (height * width), 5), "box": box_of(answer),
|
||||
"kind": "point" if name is None else "name", "name": name,
|
||||
"sure": None if sure is None else round(sure, 3),
|
||||
}
|
||||
@@ -1098,13 +1047,13 @@ class AusBossMaskByTouch:
|
||||
pieces["list"] = [row[4] for row in rows]
|
||||
pieces["planes"] = pack_planes(masks)
|
||||
|
||||
def _piece(self, session: _Session, view: WorkView, key: str, name, point):
|
||||
def _piece(self, session: _Session, frame, key: str, name, point):
|
||||
"""The area of a piece from what is remembered: a point's answer, or
|
||||
the match of a word that the piece's own point stands for."""
|
||||
if name is None:
|
||||
return self._tap(session, view, key, point)
|
||||
matches = self._named(session, view, key, name)
|
||||
index = match_under(matches, view.point(point))
|
||||
return self._tap(session, frame, key, point)
|
||||
matches = self._named(session, frame, key, name)
|
||||
index = match_under(matches, point)
|
||||
return None if index is None else matches[index][0]
|
||||
|
||||
# -- files for the node's face -------------------------------------------
|
||||
@@ -1156,30 +1105,6 @@ 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:
|
||||
"""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."""
|
||||
if unique_id is None or _server() is None:
|
||||
return False
|
||||
try:
|
||||
height, width = int(image.shape[1]), int(image.shape[2])
|
||||
small_width, small_height, scale = small_size(width, height)
|
||||
picture = self._picture_file(image, key, small_width, small_height)
|
||||
if picture is None:
|
||||
return False
|
||||
note = {
|
||||
"key": key, "width": width, "height": height, "scale": scale, "stamp": "",
|
||||
"picture": picture, "selection": None, "items": [], "share": 0.0,
|
||||
"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)
|
||||
|
||||
def _pieces_note(self, pieces):
|
||||
if pieces is None:
|
||||
return None
|
||||
@@ -1219,13 +1144,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()
|
||||
@@ -1239,7 +1163,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)
|
||||
@@ -1249,15 +1173,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.
|
||||
@@ -1324,13 +1239,9 @@ class AusBossMaskByTouch:
|
||||
raw = torch.stack(selections)
|
||||
soft = raw.dtype != torch.bool
|
||||
if not bool(raw.any()) and not asked and str(if_nothing) != IF_NOTHING[1]:
|
||||
# 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)
|
||||
# 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]
|
||||
@@ -1396,7 +1307,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,
|
||||
@@ -1410,18 +1321,14 @@ 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)
|
||||
if asked == "scope" and (pieces is None or pointer is not None or pieces["next"] < pieces["total"]):
|
||||
if pieces is None:
|
||||
words = len(WORDS_FIRST) + len(WORDS_MORE)
|
||||
view = self._view(image[:1], key)
|
||||
pieces = {
|
||||
"sieve": PieceSieve(view.work_height, view.work_width), "next": 0, "total": len(_scope_queue(width, height)),
|
||||
"sieve": PieceSieve(height, width), "next": 0, "total": len(_scope_queue(width, height)),
|
||||
"words": 0, "words_total": words, "number": 0, "ids": [], "list": [], "planes": [],
|
||||
}
|
||||
# Remembered from the start: a look that is stopped half way
|
||||
|
||||
+52
-90
@@ -6,20 +6,25 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from ._execution_helpers import ignore_extra_inputs
|
||||
from ._image_hold_helpers import hold_pictures, keep_pictures, plan_destinations, settle_destinations
|
||||
from ._image_save_helpers import (
|
||||
EXISTING_POLICIES,
|
||||
FORMAT_EXTENSIONS,
|
||||
IMAGE_FORMATS,
|
||||
IMAGE_SAVE_LOCK,
|
||||
encode_image,
|
||||
existing_action,
|
||||
fill_name_tokens,
|
||||
metadata_disabled,
|
||||
name_stem,
|
||||
next_free_counter,
|
||||
plan_exact_names,
|
||||
plan_local_names,
|
||||
resolve_output_root,
|
||||
require_output_path,
|
||||
sanitize_exact_name,
|
||||
sidecar_path,
|
||||
split_prefix,
|
||||
strip_image_extension,
|
||||
)
|
||||
from ._route_errors import safe_route_error
|
||||
from ._step_preview_helpers import STEP_PREVIEW_DEFAULT, register_step_preview_handler
|
||||
from ._video_save_helpers import workflow_metadata
|
||||
|
||||
@@ -62,33 +67,6 @@ def _register_folder_route() -> None:
|
||||
shown = "" if target == root else target.relative_to(root).as_posix()
|
||||
return web.json_response({"path": shown, "folders": folders})
|
||||
|
||||
@PromptServer.instance.routes.post("/ausboss/save_image/keep")
|
||||
async def keep_held(request):
|
||||
"""Save pictures a run is holding (Auto save off). The body names the
|
||||
run by the token the node was given and, optionally, which of its
|
||||
pictures to keep. Names and folders come from what the run stored,
|
||||
never from the request."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "bad request"}, status=400)
|
||||
if not isinstance(body, dict):
|
||||
return web.json_response({"error": "bad request"}, status=400)
|
||||
picks = body.get("picks")
|
||||
if picks is not None and not isinstance(picks, list):
|
||||
return web.json_response({"error": "bad request"}, status=400)
|
||||
try:
|
||||
answer = keep_pictures(
|
||||
folder_paths.get_temp_directory(), folder_paths.get_output_directory(), body.get("token"), picks,
|
||||
)
|
||||
except LookupError as exc:
|
||||
return web.json_response({"error": safe_route_error(exc, "Those pictures are gone.")}, status=404)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": safe_route_error(exc, "Could not save.")}, status=400)
|
||||
except Exception as exc:
|
||||
return web.json_response({"error": safe_route_error(exc, "Could not save.")}, status=500)
|
||||
return web.json_response(answer)
|
||||
|
||||
|
||||
class AusBossSaveImage:
|
||||
CATEGORY = "🆎 AusBoss/Image"
|
||||
@@ -298,17 +276,6 @@ class AusBossSaveImage:
|
||||
),
|
||||
},
|
||||
),
|
||||
"autosave": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": (
|
||||
"On: every run saves its pictures. Off: the "
|
||||
"pictures only show on the node, and its Save "
|
||||
"button keeps the ones you want."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
|
||||
}
|
||||
@@ -342,7 +309,6 @@ class AusBossSaveImage:
|
||||
name_batch=False,
|
||||
preview=True,
|
||||
step_preview=STEP_PREVIEW_DEFAULT,
|
||||
autosave=True,
|
||||
prompt=None,
|
||||
extra_pnginfo=None,
|
||||
):
|
||||
@@ -353,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])
|
||||
@@ -376,56 +343,51 @@ class AusBossSaveImage:
|
||||
else:
|
||||
exact = sanitize_exact_name(exact_name)
|
||||
|
||||
settings = {
|
||||
"format": format,
|
||||
"extension": extension,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"exact": exact,
|
||||
"filename_prefix": filename_prefix,
|
||||
"on_existing": on_existing,
|
||||
"output_dir": str(output_dir or ""),
|
||||
"name_counter": bool(name_counter),
|
||||
"name_date": bool(name_date),
|
||||
"name_time": bool(name_time),
|
||||
"name_size": bool(name_size),
|
||||
"name_batch": bool(name_batch),
|
||||
"caption": caption_value,
|
||||
}
|
||||
has_caption = bool(caption_value.strip())
|
||||
|
||||
if not autosave:
|
||||
# Nothing is saved yet. The pictures wait in the temp folder, in
|
||||
# their final format, and the node's Save button keeps the ones
|
||||
# that are wanted (the keep route above). A bad name or folder
|
||||
# still stops the run here, not at the click.
|
||||
with IMAGE_SAVE_LOCK:
|
||||
settle_destinations(plan_destinations(settings, count, now, root, output_root), has_caption, output_root)
|
||||
token, previews = hold_pictures(
|
||||
folder_paths.get_temp_directory(), images, settings,
|
||||
lambda path, frame: encode_image(path, frame, format, metadata),
|
||||
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,
|
||||
)
|
||||
return {
|
||||
"ui": {"images": previews, "ausboss_saved_path": [], "ausboss_held": [token]},
|
||||
"result": ("", images),
|
||||
}
|
||||
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)
|
||||
|
||||
with IMAGE_SAVE_LOCK:
|
||||
# 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.
|
||||
planned = plan_destinations(settings, count, now, root, output_root)
|
||||
actions = settle_destinations(planned, has_caption, output_root)
|
||||
# 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 has_caption:
|
||||
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.1"
|
||||
# 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
|
||||
|
||||
@@ -94,10 +94,7 @@ for (const type of types) {
|
||||
const seen = await evalJs(`(async () => {
|
||||
const { app } = window;
|
||||
app.canvas.linkConnector?.reset?.();
|
||||
// clear() resets the counter, but frontend preview caches can outlive
|
||||
// the graph. Reserve fresh ids before clearing so probes stay isolated.
|
||||
const nextNodeId = app.graph.last_node_id + 50;
|
||||
app.graph.clear(); app.graph.last_node_id = nextNodeId;
|
||||
app.graph.clear(); app.graph.last_node_id += 50;
|
||||
let node;
|
||||
try { node = window.LiteGraph.createNode(${JSON.stringify(type)}); } catch (error) { return { error: "createNode threw: " + (error?.message ?? error) }; }
|
||||
if (!node) return { error: "createNode gave nothing" };
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -56,8 +55,6 @@ PUBLIC_NODE_IDS = {
|
||||
"AUSBOSS_NODES_ColorMatch",
|
||||
"AUSBOSS_NODES_Compare",
|
||||
"AUSBOSS_NODES_CropForInpaint",
|
||||
"AUSBOSS_NODES_EditLock",
|
||||
"AUSBOSS_NODES_EditLockRestore",
|
||||
"AUSBOSS_NODES_Float",
|
||||
"AUSBOSS_NODES_FrameInterpolate",
|
||||
"AUSBOSS_NODES_FreeMemory",
|
||||
@@ -213,12 +210,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:
|
||||
@@ -263,13 +257,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
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
test("face smoke gives each probe a fresh node id after clearing the graph", () => {
|
||||
const source = readFileSync(new URL("../scripts/dev/face_smoke.mjs", import.meta.url), "utf8");
|
||||
// Run the browser setup used by every probe. ComfyUI clears the node-id
|
||||
// counter with the graph, but output/preview caches can retain those ids.
|
||||
const setup = source.match(/const seen = await evalJs\(`\(async \(\) => \{([\s\S]*?)\n let node;/)?.[1];
|
||||
assert.ok(setup, "the browser probe setup must be exercised");
|
||||
const previews = new Map();
|
||||
const graph = { last_node_id: 0, clear() { this.last_node_id = 0; } };
|
||||
let resets = 0;
|
||||
const context = vm.createContext({ window: { app: {
|
||||
graph, canvas: { linkConnector: { reset() { resets++; } } },
|
||||
} } });
|
||||
const ids = [];
|
||||
for (let probe = 0; probe < 48; probe++) {
|
||||
vm.runInContext(`(() => { ${setup} })()`, context);
|
||||
const id = ++graph.last_node_id;
|
||||
assert.equal(previews.has(id), false, "a later node must not inherit an earlier node's preview");
|
||||
previews.set(id, `preview-${probe}`);
|
||||
ids.push(id);
|
||||
}
|
||||
assert.equal(new Set(ids).size, 48);
|
||||
assert.equal(resets, 48);
|
||||
});
|
||||
Vendored
+2
-48
@@ -116,50 +116,6 @@
|
||||
]
|
||||
]
|
||||
},
|
||||
"AUSBOSS_NODES_EditLock": {
|
||||
"required": [
|
||||
"model",
|
||||
"vae",
|
||||
"image",
|
||||
"lock",
|
||||
"tolerance"
|
||||
],
|
||||
"optional": [
|
||||
"mask"
|
||||
],
|
||||
"outputs": [
|
||||
[
|
||||
"MODEL",
|
||||
"model"
|
||||
],
|
||||
[
|
||||
"AUSBOSS_EDIT_LOCK",
|
||||
"edit_lock"
|
||||
]
|
||||
]
|
||||
},
|
||||
"AUSBOSS_NODES_EditLockRestore": {
|
||||
"required": [
|
||||
"image",
|
||||
"edit_lock",
|
||||
"feather"
|
||||
],
|
||||
"optional": [],
|
||||
"outputs": [
|
||||
[
|
||||
"IMAGE",
|
||||
"image"
|
||||
],
|
||||
[
|
||||
"MASK",
|
||||
"edit_mask"
|
||||
],
|
||||
[
|
||||
"STRING",
|
||||
"report"
|
||||
]
|
||||
]
|
||||
},
|
||||
"AUSBOSS_NODES_Float": {
|
||||
"required": [
|
||||
"value"
|
||||
@@ -731,8 +687,7 @@
|
||||
"free_vram",
|
||||
"invert",
|
||||
"ask",
|
||||
"fine_edges",
|
||||
"new_picture"
|
||||
"fine_edges"
|
||||
],
|
||||
"outputs": [
|
||||
[
|
||||
@@ -886,8 +841,7 @@
|
||||
"name_size",
|
||||
"name_batch",
|
||||
"preview",
|
||||
"step_preview",
|
||||
"autosave"
|
||||
"step_preview"
|
||||
],
|
||||
"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);
|
||||
});
|
||||
@@ -26,51 +26,3 @@ test("the label counts from one and is empty for a single picture", () => {
|
||||
assert.equal(stepperLabel(0, 1), "");
|
||||
assert.equal(stepperLabel(0, 0), "");
|
||||
});
|
||||
|
||||
test("all the pictures are one more stop before the first", async () => {
|
||||
const { ALL, allLabel, stepWithAll } = await import("../js/shared/picture_steps.mjs");
|
||||
assert.equal(stepWithAll(ALL, 3, 1), 0);
|
||||
assert.equal(stepWithAll(0, 3, 1), 1);
|
||||
assert.equal(stepWithAll(2, 3, 1), ALL);
|
||||
assert.equal(stepWithAll(ALL, 3, -1), 2);
|
||||
assert.equal(stepWithAll(0, 3, -1), ALL);
|
||||
assert.equal(stepWithAll(1, 3, 0), 1);
|
||||
assert.equal(stepWithAll(0, 0, 1), ALL);
|
||||
assert.equal(allLabel(5), "all 5");
|
||||
assert.equal(allLabel(1), "");
|
||||
});
|
||||
|
||||
test("the grid gives each picture the most room its shape allows", async () => {
|
||||
const { gridShape } = await import("../js/shared/picture_steps.mjs");
|
||||
// Five portraits in a tall stage: two across beats one row or one column.
|
||||
assert.deepEqual(gridShape(5, 440, 1100, 3 / 4), { columns: 2, rows: 3 });
|
||||
// The same five in a wide stage go in one row.
|
||||
assert.deepEqual(gridShape(5, 1600, 440, 3 / 4), { columns: 5, rows: 1 });
|
||||
// Wide pictures in a tall stage stack.
|
||||
assert.deepEqual(gridShape(3, 400, 900, 16 / 9), { columns: 1, rows: 3 });
|
||||
assert.deepEqual(gridShape(1, 400, 400, 1), { columns: 1, rows: 1 });
|
||||
// A stage that has no size yet still gets a whole grid.
|
||||
assert.deepEqual(gridShape(5, 0, 0, 1), { columns: 3, rows: 2 });
|
||||
assert.deepEqual(gridShape(4, 400, 400, Number.NaN), { columns: 2, rows: 2 });
|
||||
});
|
||||
|
||||
test("held pictures map to their run and their place in it", async () => {
|
||||
const { heldPlaces } = await import("../js/shared/picture_steps.mjs");
|
||||
const a = "0123456789abcdef";
|
||||
const b = "fedcba9876543210";
|
||||
const name = (token, index) => ({ filename: `${token}_00${index}.png`, subfolder: "ausboss_held", type: "temp" });
|
||||
// One run of three pictures.
|
||||
assert.deepEqual(
|
||||
heldPlaces({ images: [name(a, 0), name(a, 1), name(a, 2)], ausboss_held: [a] }),
|
||||
[{ token: a, index: 0 }, { token: a, index: 1 }, { token: a, index: 2 }],
|
||||
);
|
||||
// A list run: one picture from each of two runs.
|
||||
assert.deepEqual(
|
||||
heldPlaces({ images: [name(a, 0), name(b, 0)], ausboss_held: [a, b] }),
|
||||
[{ token: a, index: 0 }, { token: b, index: 0 }],
|
||||
);
|
||||
// A saved run holds nothing, and a picture from another run is not trusted.
|
||||
assert.deepEqual(heldPlaces({ images: [{ filename: "shot_00001.png" }] }), []);
|
||||
assert.deepEqual(heldPlaces({ images: [name(a, 0), name(b, 0)], ausboss_held: [a] }), []);
|
||||
assert.deepEqual(heldPlaces(undefined), []);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import test from "node:test";
|
||||
import {
|
||||
collapseRepeatedErrors,
|
||||
emptySourceMessage,
|
||||
explainRunError,
|
||||
findEmptySources,
|
||||
nodeLacksSource,
|
||||
nodesThatRun,
|
||||
@@ -200,40 +199,3 @@ test("an empty Load Audio asks for a sound file", () => {
|
||||
prompt[1].inputs.audio = "song.mp3";
|
||||
assert.deepEqual(findEmptySources(prompt, outputs), []);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ known run errors
|
||||
|
||||
// The report a user posted: ComfyUI 0.44.0, H3 keeping the clip's sound, with
|
||||
// Muse MiniMax Director installed.
|
||||
const MUSE_REPORT = {
|
||||
node_type: "SamplerCustomAdvanced",
|
||||
exception_type: "KeyError",
|
||||
exception_message: "'latent'",
|
||||
traceback: [
|
||||
' File "G:\\ComfyUI\\ComfyUI\\comfy\\samplers.py", line 953, in encode_model_conds\n out = model_function(**params)\n',
|
||||
' File "G:\\ComfyUI\\ComfyUI\\custom_nodes\\muse-minimax-director-v1-2\\muse_minimax_hybrid_patch.py", line 30, in extra_conds_with_hybrid\n visual_latents.extend(item["latent"] for item in keyframes)\n',
|
||||
],
|
||||
};
|
||||
|
||||
test("the Muse MiniMax Director error is explained and names the pack", () => {
|
||||
const known = explainRunError(MUSE_REPORT);
|
||||
assert.match(known.detail, /Muse MiniMax Director/);
|
||||
assert.match(known.detail, /Turn that pack off/);
|
||||
});
|
||||
|
||||
test("the same KeyError from anywhere else is left alone", () => {
|
||||
assert.equal(explainRunError({ ...MUSE_REPORT, traceback: [' File "comfy/samplers.py", line 953\n'] }), null);
|
||||
assert.equal(explainRunError({ ...MUSE_REPORT, exception_message: "'samples'" }), null);
|
||||
});
|
||||
|
||||
test("an old ComfyUI loading a w6a8 model is told to update", () => {
|
||||
const known = explainRunError({ exception_type: "KeyError", exception_message: "'w6a8_int8'", traceback: [] });
|
||||
assert.match(known.detail, /0\.38/);
|
||||
});
|
||||
|
||||
test("an unknown error, or no report at all, gives nothing", () => {
|
||||
assert.equal(explainRunError({ exception_type: "RuntimeError", exception_message: "CUDA out of memory", traceback: [] }), null);
|
||||
assert.equal(explainRunError(null), null);
|
||||
assert.equal(explainRunError({}), null);
|
||||
assert.equal(explainRunError({ exception_type: "KeyError", exception_message: "'latent'", traceback: "muse_minimax_hybrid_patch" }) !== null, true);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,373 +0,0 @@
|
||||
"""Edit Lock: a local edit keeps the rest of the picture, a whole-picture edit is left alone."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
|
||||
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._edit_lock_helpers import ( # noqa: E402
|
||||
WHOLE_PICTURE,
|
||||
EditLock,
|
||||
box_blur,
|
||||
broad_change,
|
||||
change_core,
|
||||
describe,
|
||||
difference,
|
||||
measure_redraw,
|
||||
quiet_level,
|
||||
restore_pixels,
|
||||
soften,
|
||||
)
|
||||
from nodes.node_edit_lock import ( # noqa: E402
|
||||
NODE_CLASS_MAPPINGS,
|
||||
NODE_DISPLAY_NAME_MAPPINGS,
|
||||
AusBossEditLock,
|
||||
AusBossEditLockRestore,
|
||||
)
|
||||
|
||||
H, W = 96, 80
|
||||
BOX = (slice(30, 60), slice(20, 50)) # where the planted edit sits
|
||||
|
||||
|
||||
def latents(seed: int = 0, video: bool = True):
|
||||
gen = torch.Generator().manual_seed(seed)
|
||||
# a real latent is smooth from cell to cell, so these are too
|
||||
source, other = (box_blur(torch.randn(1, 24, H, W, generator=gen), 4) for _ in range(2))
|
||||
source, other = source / source.std(), other / other.std()
|
||||
redraw = 0.15 * torch.randn(1, 24, H, W, generator=gen) # the model's own redraw noise
|
||||
if video:
|
||||
return source[:, :, None], other[:, :, None], redraw[:, :, None]
|
||||
return source, other, redraw
|
||||
|
||||
|
||||
def local_prediction(source, other, redraw, tint: float = 0.0):
|
||||
pred = source + redraw + tint
|
||||
pred[..., BOX[0], BOX[1]] = other[..., BOX[0], BOX[1]]
|
||||
return pred
|
||||
|
||||
|
||||
def lock_for(source, mode="auto", tolerance=1.0):
|
||||
lock = EditLock(mode=mode, tolerance=tolerance)
|
||||
lock.latent = source.clone()
|
||||
lock.source = torch.zeros(1, H * 16, W * 16, 3)
|
||||
return lock
|
||||
|
||||
|
||||
def run(lock, pred, sigma=1.0, steps=None):
|
||||
args = {"denoised": pred, "sigma": torch.tensor([sigma])}
|
||||
if steps is not None:
|
||||
# the sampler's own list of noise levels, the way ComfyUI hands it to the hook
|
||||
args["model_options"] = {"transformer_options": {"sample_sigmas": torch.tensor(steps)}}
|
||||
return lock.step(args)
|
||||
|
||||
|
||||
SIX = [1.0, 0.97, 0.89, 0.75, 0.53, 0.23, 0.0]
|
||||
|
||||
|
||||
class EditLockTests(unittest.TestCase):
|
||||
def test_local_edit_keeps_the_rest(self):
|
||||
for video in (True, False):
|
||||
source, other, redraw = latents(video=video)
|
||||
pred = local_prediction(source, other, redraw, tint=0.2)
|
||||
lock = lock_for(source)
|
||||
out = run(lock, pred)
|
||||
self.assertEqual(lock.status, "locked")
|
||||
self.assertEqual(out.shape, pred.shape)
|
||||
# far from the edit the original is back exactly, tint and redraw noise gone
|
||||
self.assertTrue(torch.equal(out[..., :12, :], source[..., :12, :].to(out.dtype)))
|
||||
self.assertTrue(torch.equal(out[..., 80:, :], source[..., 80:, :].to(out.dtype)))
|
||||
# inside the edit the model's prediction is untouched
|
||||
inner = (slice(36, 54), slice(26, 44))
|
||||
self.assertTrue(torch.equal(out[..., inner[0], inner[1]], pred[..., inner[0], inner[1]]))
|
||||
kept = lock.kept_share()
|
||||
self.assertGreater(kept, 0.7)
|
||||
self.assertLess(kept, 0.92)
|
||||
self.assertIn("kept", describe(lock))
|
||||
|
||||
def test_report_says_what_to_do_when_nearly_all_was_kept(self):
|
||||
source, other, redraw = latents(video=False)
|
||||
lock = lock_for(source)
|
||||
run(lock, local_prediction(source, other, redraw))
|
||||
self.assertNotIn("set lock to off", describe(lock))
|
||||
# a faint change everywhere (rain) and one small clear spot: nearly all of it is kept
|
||||
pred = source + redraw
|
||||
pred[..., 40:46, 30:36] = other[..., 40:46, 30:36]
|
||||
lock = lock_for(source)
|
||||
run(lock, pred)
|
||||
self.assertGreater(lock.kept_share(), 0.95)
|
||||
self.assertIn("set lock to off", describe(lock))
|
||||
|
||||
def test_it_watches_first_and_locks_when_the_noise_is_down(self):
|
||||
source, other, redraw = latents(video=False)
|
||||
lock = lock_for(source)
|
||||
# the model's own redraw noise fades from step to step, as it does in a real run
|
||||
for sigma, fade in ((1.0, 1.0), (0.97, 0.8), (0.89, 0.7)):
|
||||
pred = local_prediction(source, other, redraw * fade)
|
||||
out = run(lock, pred, sigma, SIX)
|
||||
self.assertEqual(lock.status, "watching")
|
||||
self.assertIsNone(lock.pixel_mask(H * 16, W * 16))
|
||||
# nothing is put back yet, and with no zoom to undo the prediction passes as it is
|
||||
self.assertTrue(torch.equal(out, pred))
|
||||
pred = local_prediction(source, other, redraw * 0.65)
|
||||
out = run(lock, pred, 0.75, SIX)
|
||||
self.assertEqual(lock.status, "locked")
|
||||
self.assertGreater(lock.top, 4.0)
|
||||
self.assertTrue(torch.equal(out[..., :12, :], source[..., :12, :]))
|
||||
inner = (slice(36, 54), slice(26, 44))
|
||||
self.assertTrue(torch.equal(out[..., inner[0], inner[1]], pred[..., inner[0], inner[1]]))
|
||||
|
||||
def test_a_faint_change_everywhere_is_let_through(self):
|
||||
source, _, redraw = latents(video=False)
|
||||
rain = 0.25 * box_blur(torch.randn(1, 24, H, W, generator=torch.Generator().manual_seed(5)), 1)
|
||||
for mode, status in (("auto", "soft"), ("always", "locked")):
|
||||
lock = lock_for(source, mode=mode)
|
||||
# nothing shows at the first look; then the rain comes in everywhere, and no spot changes clearly
|
||||
for sigma, fade, wet in ((1.0, 1.0, 0.0), (0.97, 0.9, 0.5), (0.89, 0.8, 1.0)):
|
||||
run(lock, source + redraw * fade + rain * wet, sigma, SIX)
|
||||
self.assertEqual(lock.status, "watching")
|
||||
pred = source + redraw * 0.7 + rain * 1.5
|
||||
out = run(lock, pred, 0.75, SIX)
|
||||
self.assertEqual(lock.status, status)
|
||||
self.assertLess(lock.top, 4.0)
|
||||
if mode == "auto":
|
||||
self.assertLess(float((out - pred).abs().max()), 1e-4)
|
||||
self.assertIn("stepped aside", describe(lock))
|
||||
|
||||
def test_a_wide_faint_change_with_no_peak_is_not_locked_in_patches(self):
|
||||
source, _, redraw = latents(video=False)
|
||||
gen = torch.Generator().manual_seed(9)
|
||||
snow = torch.zeros(1, 24, H, W)
|
||||
snow[..., :, :34] = 0.25 * torch.randn(1, 24, H, 34, generator=gen) # faint, over two fifths of the picture
|
||||
theta = torch.tensor([[[1 / 1.015, 0.0, 0.0], [0.0, 1 / 1.015, 0.0]]])
|
||||
grid = torch.nn.functional.affine_grid(theta, list(source.shape), align_corners=False)
|
||||
zoomed = torch.nn.functional.grid_sample(source, grid, mode="bilinear", padding_mode="border", align_corners=False)
|
||||
for mode, status in (("auto", "soft"), ("always", "locked")):
|
||||
lock = lock_for(source, mode=mode)
|
||||
for sigma, fade in ((1.0, 1.0), (0.97, 0.8), (0.89, 0.7)):
|
||||
run(lock, source + redraw * fade, sigma, SIX)
|
||||
pred = source + redraw * 0.65 + snow
|
||||
out = run(lock, pred, 0.75, SIX)
|
||||
self.assertEqual(lock.status, status)
|
||||
self.assertGreater(lock.wide, 0.10)
|
||||
self.assertLess(lock.peak, 4.0)
|
||||
if mode == "always":
|
||||
self.assertLess(lock.kept_share(), 0.8)
|
||||
continue
|
||||
self.assertIsNone(lock.pixel_mask(H * 16, W * 16))
|
||||
self.assertLess(float((out - pred).abs().max()), 1e-4)
|
||||
self.assertIn("held the picture in place", describe(lock))
|
||||
# for the rest of the run only the zoom is undone
|
||||
out = run(lock, zoomed, 0.53, SIX)
|
||||
self.assertEqual(lock.status, "soft")
|
||||
smooth = lambda x: box_blur(x, 2)[..., 6:-6, 6:-6]
|
||||
self.assertLess(float((smooth(out) - smooth(source)).abs().mean()),
|
||||
float((smooth(zoomed) - smooth(source)).abs().mean()) * 0.35)
|
||||
|
||||
def test_the_last_step_decides_when_the_noise_never_gets_that_low(self):
|
||||
source, other, redraw = latents(video=False)
|
||||
lock = lock_for(source)
|
||||
two = [1.0, 0.92, 0.0]
|
||||
run(lock, local_prediction(source, other, redraw), 1.0, two)
|
||||
self.assertEqual(lock.status, "watching")
|
||||
run(lock, local_prediction(source, other, redraw * 0.8), 0.92, two)
|
||||
self.assertEqual(lock.status, "locked")
|
||||
# a one-step run decides at once
|
||||
lock = lock_for(source)
|
||||
run(lock, local_prediction(source, other, redraw), 1.0, [1.0, 0.0])
|
||||
self.assertEqual(lock.status, "locked")
|
||||
|
||||
def test_the_zoom_is_undone_while_it_watches(self):
|
||||
source, other, redraw = latents(video=False)
|
||||
theta = torch.tensor([[[1 / 1.015, 0.0, 0.0], [0.0, 1 / 1.015, 0.0]]])
|
||||
grid = torch.nn.functional.affine_grid(theta, list(source.shape), align_corners=False)
|
||||
zoomed = torch.nn.functional.grid_sample(source, grid, mode="bilinear", padding_mode="border", align_corners=False)
|
||||
lock = lock_for(source)
|
||||
out = run(lock, zoomed, 1.0, SIX)
|
||||
self.assertEqual(lock.status, "watching")
|
||||
# read on smoothed copies: this is about where things sit, and resampling costs fine detail either way
|
||||
smooth = lambda x: box_blur(x, 2)[..., 6:-6, 6:-6]
|
||||
off_before = float((smooth(zoomed) - smooth(source)).abs().mean())
|
||||
off_after = float((smooth(out) - smooth(source)).abs().mean())
|
||||
self.assertLess(off_after, off_before * 0.35)
|
||||
|
||||
def test_whole_picture_edit_is_left_alone(self):
|
||||
source, other, _ = latents()
|
||||
pred = 0.3 * source + other # a new picture over the old one
|
||||
lock = lock_for(source)
|
||||
out = run(lock, pred)
|
||||
self.assertEqual(lock.status, "whole picture")
|
||||
self.assertTrue(torch.equal(out, pred))
|
||||
self.assertIsNone(lock.pixel_mask(H * 16, W * 16))
|
||||
# and it stays aside for the rest of the run
|
||||
self.assertTrue(torch.equal(run(lock, pred, 0.8), pred))
|
||||
self.assertIn("stepped aside", describe(lock))
|
||||
|
||||
def test_a_slightly_larger_redraw_is_measured_and_is_not_an_edit(self):
|
||||
source, other, redraw = latents(video=False)
|
||||
# drawn 1.5% wider, 1% taller and half a cell to the right
|
||||
theta = torch.tensor([[[1 / 1.015, 0.0, -1.0 / W], [0.0, 1 / 1.01, 0.0]]])
|
||||
grid = torch.nn.functional.affine_grid(theta, list(source.shape), align_corners=False)
|
||||
zoomed = torch.nn.functional.grid_sample(source, grid, mode="bilinear", padding_mode="border", align_corners=False)
|
||||
move = measure_redraw(local_prediction(zoomed, other, redraw), source)
|
||||
self.assertAlmostEqual(float(move[0]), 1 / 1.015 - 1, delta=0.002)
|
||||
self.assertAlmostEqual(float(move[1]), 1 / 1.01 - 1, delta=0.002)
|
||||
self.assertAlmostEqual(float(move[2]), -0.5, delta=0.15)
|
||||
self.assertAlmostEqual(float(move[3]), 0.0, delta=0.15)
|
||||
self.assertLess(float(measure_redraw(source + redraw, source).abs().max()), 0.02)
|
||||
self.assertLess(float(broad_change(zoomed + redraw, source)), WHOLE_PICTURE / 2)
|
||||
lock = lock_for(source)
|
||||
out = run(lock, local_prediction(zoomed, other, redraw))
|
||||
self.assertEqual(lock.status, "locked")
|
||||
# the zoom did not read as an edit: only the planted box is free, and the rest is the original, unzoomed
|
||||
self.assertGreater(lock.kept_share(), 0.7)
|
||||
self.assertTrue(torch.equal(out[..., :12, :], source[..., :12, :]))
|
||||
# an even tint is not one either; new light across the picture is
|
||||
self.assertLess(float(broad_change(source + redraw + 0.6, source)), WHOLE_PICTURE / 2)
|
||||
self.assertGreater(float(broad_change(source + redraw + 0.6 * other, source)), WHOLE_PICTURE)
|
||||
|
||||
def test_always_locks_a_whole_picture_edit(self):
|
||||
source, other, _ = latents()
|
||||
lock = lock_for(source, mode="always")
|
||||
run(lock, 0.3 * source + other)
|
||||
self.assertEqual(lock.status, "locked")
|
||||
|
||||
def test_changed_stays_changed_and_a_new_run_starts_clean(self):
|
||||
source, other, redraw = latents()
|
||||
lock = lock_for(source)
|
||||
run(lock, local_prediction(source, other, redraw), 1.0)
|
||||
first = lock.mask.clone()
|
||||
run(lock, source + redraw, 0.8) # a later step that agrees with the original everywhere
|
||||
self.assertTrue(torch.all(lock.mask >= first))
|
||||
run(lock, source + redraw, 1.0) # the noise level went up: a new run
|
||||
self.assertLess(float(lock.mask.max()), 0.5)
|
||||
|
||||
def test_given_mask_is_used_as_is(self):
|
||||
source, other, redraw = latents()
|
||||
lock = lock_for(source)
|
||||
mask = torch.zeros(1, H * 16, W * 16)
|
||||
mask[:, : H * 8] = 1.0 # the top half may change
|
||||
lock.user_mask = mask
|
||||
pred = 0.3 * source + other # would count as a whole-picture edit
|
||||
out = run(lock, pred)
|
||||
self.assertEqual(lock.status, "locked")
|
||||
self.assertTrue(torch.equal(out[..., :40, :], pred[..., :40, :]))
|
||||
self.assertTrue(torch.equal(out[..., 56:, :], source[..., 56:, :]))
|
||||
self.assertIn("your mask", describe(lock))
|
||||
|
||||
def test_other_sizes_video_and_wrong_vae_do_nothing(self):
|
||||
source, other, redraw = latents()
|
||||
pred = local_prediction(source, other, redraw)
|
||||
lock = lock_for(source[..., :48, :40].clone())
|
||||
self.assertTrue(torch.equal(run(lock, pred), pred))
|
||||
self.assertEqual(lock.status, "size")
|
||||
lock = lock_for(source)
|
||||
clip = pred.expand(-1, -1, 5, -1, -1)
|
||||
self.assertTrue(torch.equal(run(lock, clip), clip))
|
||||
self.assertEqual(lock.status, "video")
|
||||
lock = lock_for(source[:, :16].clone())
|
||||
self.assertTrue(torch.equal(run(lock, pred), pred))
|
||||
self.assertEqual(lock.status, "vae")
|
||||
for status in ("size", "video", "vae"):
|
||||
lock.status = status
|
||||
self.assertIn("did nothing", describe(lock))
|
||||
|
||||
def test_a_grid_one_cell_off_still_locks(self):
|
||||
source, other, redraw = latents(video=False)
|
||||
lock = lock_for(torch.nn.functional.interpolate(source, size=(H + 2, W + 1), mode="bilinear"))
|
||||
run(lock, local_prediction(source, other, redraw))
|
||||
self.assertEqual(lock.status, "locked")
|
||||
self.assertGreater(lock.kept_share(), 0.7)
|
||||
|
||||
def test_tolerance_moves_the_line(self):
|
||||
source, other, redraw = latents(video=False)
|
||||
pred = source + redraw
|
||||
pred[..., BOX[0], BOX[1]] += 0.45 * other[..., BOX[0], BOX[1]] # a faint change
|
||||
magnitude = difference(pred, source)
|
||||
quiet = quiet_level(magnitude)
|
||||
loose = change_core(magnitude, quiet, 0.5)
|
||||
strict = change_core(magnitude, quiet, 2.0)
|
||||
self.assertGreater(float(loose[..., 40:50, 30:40].mean()), 0.9)
|
||||
self.assertEqual(float(strict.max()), 0.0)
|
||||
|
||||
def test_faint_change_counts_only_beside_a_clear_one(self):
|
||||
quiet = torch.tensor([1.0])
|
||||
magnitude = torch.ones(1, 1, 40, 40)
|
||||
magnitude[..., 5:15, 5:15] = 2.2 # faint, alone: redraw noise
|
||||
magnitude[..., 22:34, 22:34] = 2.2 # faint ...
|
||||
magnitude[..., 26:30, 26:30] = 4.0 # ... around a clear change
|
||||
core = change_core(magnitude, quiet)
|
||||
self.assertEqual(float(core[..., 5:15, 5:15].max()), 0.0)
|
||||
self.assertEqual(float(core[..., 22:34, 22:34].min()), 1.0)
|
||||
self.assertEqual(float(core.sum()), 144.0)
|
||||
# what an earlier step held stays, and faint change that touches it joins
|
||||
held = torch.zeros_like(core)
|
||||
held[..., 8:10, 8:10] = 1.0
|
||||
again = change_core(torch.ones(1, 1, 40, 40), quiet, held=held)
|
||||
self.assertEqual(float(again.sum()), 4.0)
|
||||
joined = change_core(magnitude, quiet, held=held)
|
||||
self.assertEqual(float(joined[..., 5:15, 5:15].min()), 1.0)
|
||||
soft = soften(core)
|
||||
self.assertTrue(torch.all(soft[..., 22:34, 22:34] == 1.0))
|
||||
self.assertGreater(float(soft[0, 0, 21, 28]), 0.5)
|
||||
self.assertEqual(float(soft[0, 0, 2, 2]), 0.0)
|
||||
|
||||
def test_restore_gives_the_original_pixels_back(self):
|
||||
gen = torch.Generator().manual_seed(1)
|
||||
original = torch.rand(1, 128, 160, 3, generator=gen)
|
||||
result = torch.rand(2, 128, 160, 3, generator=gen)
|
||||
mask = torch.zeros(1, 128, 160)
|
||||
mask[:, 40:80, 60:100] = 1.0
|
||||
out, edit = restore_pixels(result, original, mask, 8)
|
||||
self.assertEqual(out.shape, result.shape)
|
||||
self.assertEqual(edit.shape, (2, 128, 160))
|
||||
self.assertTrue(torch.equal(out[:, :16], original[:, :16].expand(2, -1, -1, -1)))
|
||||
self.assertTrue(torch.equal(out[:, 40:80, 60:100], result[:, 40:80, 60:100]))
|
||||
# the fade lies outside the edit
|
||||
self.assertTrue(torch.all(edit[:, 40:80, 60:100] == 1.0))
|
||||
self.assertGreater(float(edit[0, 36, 80]), 0.0)
|
||||
# another size: the original is resized to the result
|
||||
small = torch.rand(1, 64, 80, 3, generator=gen)
|
||||
out, _ = restore_pixels(result, small, mask, 0)
|
||||
self.assertEqual(out.shape, result.shape)
|
||||
|
||||
def test_restore_node_passes_through_when_nothing_was_locked(self):
|
||||
lock = EditLock()
|
||||
lock.status = "whole picture"
|
||||
image = torch.rand(1, 32, 48, 3)
|
||||
out, mask, report = AusBossEditLockRestore().restore(image, lock, 16)
|
||||
self.assertTrue(torch.equal(out, image))
|
||||
self.assertEqual(mask.shape, (1, 32, 48))
|
||||
self.assertTrue(torch.all(mask == 1.0))
|
||||
self.assertIn("stepped aside", report)
|
||||
|
||||
def test_restore_node_after_a_locked_run(self):
|
||||
source, other, redraw = latents()
|
||||
lock = lock_for(source)
|
||||
lock.source = torch.rand(1, H * 4, W * 4, 3)
|
||||
run(lock, local_prediction(source, other, redraw))
|
||||
image = torch.rand(1, H * 4, W * 4, 3)
|
||||
out, mask, report = AusBossEditLockRestore().restore(image, lock, 4)
|
||||
self.assertTrue(torch.equal(out[:, :24], lock.source[:, :24]))
|
||||
self.assertTrue(torch.equal(out[:, 150:210, 110:170], image[:, 150:210, 110:170]))
|
||||
self.assertGreater(float(mask[0, 180, 140]), 0.99)
|
||||
self.assertIn("kept", report)
|
||||
|
||||
def test_mappings(self):
|
||||
keys = {"AUSBOSS_NODES_EditLock", "AUSBOSS_NODES_EditLockRestore"}
|
||||
self.assertEqual(set(NODE_CLASS_MAPPINGS), keys)
|
||||
self.assertEqual(set(NODE_DISPLAY_NAME_MAPPINGS), keys)
|
||||
self.assertIs(NODE_CLASS_MAPPINGS["AUSBOSS_NODES_EditLock"], AusBossEditLock)
|
||||
for name in NODE_DISPLAY_NAME_MAPPINGS.values():
|
||||
self.assertTrue(name.endswith(" 🆎"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -733,63 +733,6 @@ class VideoJobTests(unittest.TestCase):
|
||||
self.assertIn("<Video 1>: partially_preserved", parts["retention_analysis"])
|
||||
self.assertIn("the background of <Picture 1> is not transferred", parts["retention_analysis"])
|
||||
|
||||
def test_a_connected_model_writes_the_new_persons_looks_in_a_swap(self):
|
||||
from nodes._h3_cast_helpers import add_looks
|
||||
asked = []
|
||||
|
||||
def describe(slot):
|
||||
asked.append(slot)
|
||||
return '"A man with curly brown hair, wearing glasses."\nExtra line.'
|
||||
|
||||
script = add_looks("Replace the guy with @1.", {1: "person"}, describe)
|
||||
self.assertEqual(script, "Replace the guy with @1.\n@1 looks like a man with curly brown hair, wearing glasses")
|
||||
self.assertEqual(asked, [1])
|
||||
text = write_prompt(script, seconds=8.0, jobs={1: "person"}, video="swap the person")
|
||||
self.assertIn("<Subject 2> has the defined appearance from <Picture 1>: a man with curly brown hair", text)
|
||||
# A typed line wins, places and empty answers add nothing.
|
||||
typed = "Replace the guy with @1.\n@1 looks like a tall woman."
|
||||
self.assertEqual(add_looks(typed, {1: "person"}, describe), typed)
|
||||
self.assertEqual(add_looks("Swap him for @1.", {1: "place"}, describe), "Swap him for @1.")
|
||||
self.assertEqual(add_looks("Swap him for @1.", {1: "person"}, lambda slot: ""), "Swap him for @1.")
|
||||
said = "a man with curly brown hair, wearing dark-framed glasses, and no visible beard or facial hair."
|
||||
self.assertEqual(add_looks("Swap him for @1.", {1: "person"}, lambda slot: said),
|
||||
"Swap him for @1.\n@1 looks like a man with curly brown hair, wearing dark-framed glasses")
|
||||
|
||||
def test_a_swap_gets_the_firm_wording_without_a_looks_like_line(self):
|
||||
text = write_prompt("Replace the guy with @1.", seconds=8.0, jobs={1: "person"}, video="swap the person")
|
||||
self.assertIn("<Subject 2> has the face, hair and clothes of the person in <Picture 1>", text)
|
||||
self.assertIn("stays present throughout, from the first frame", text)
|
||||
self.assertIn("Do not show the guy in that role at any time", text)
|
||||
|
||||
def test_a_swap_with_a_model_adds_no_moments(self):
|
||||
node = AusBossH3PromptWriter()
|
||||
calls = []
|
||||
import nodes.node_h3_prompt_writer as writer
|
||||
real = writer.model_asker
|
||||
writer.model_asker = lambda clip, image: (lambda question, longest: calls.append(question) or "a woman with a dark bob")
|
||||
try:
|
||||
text = node.write("Swap the woman for @1.", 6, clip=object(), picture_1=torch.zeros(1, 8, 8, 3),
|
||||
video=torch.zeros(40, 8, 8, 3), video_job="swap the person")[0]
|
||||
finally:
|
||||
writer.model_asker = real
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertIn("a woman with a dark bob", text)
|
||||
self.assertNotIn("[Shot 2]", text)
|
||||
|
||||
def test_a_plain_swap_sentence_names_only_who_goes(self):
|
||||
for script, who in (
|
||||
("Replace the man in the red shirt with @1.", "the man in the red shirt"),
|
||||
("swap the guy with @1", "the guy"),
|
||||
("swap out the guy in the hat for @1 in the clip", "the guy in the hat"),
|
||||
("the cashier is now @1", "the cashier"),
|
||||
("make the dancer look like @1", "the dancer"),
|
||||
("swap @1 in for the drummer", "the drummer"),
|
||||
("Replace the woman with black hair with @1.\n@1 looks like a woman with platinum hair.", "the woman with black hair"),
|
||||
):
|
||||
text = write_prompt(script, seconds=8.0, jobs={1: "person"}, video="swap the person")
|
||||
self.assertIn(f"<Subject 1> is {who} in <Video 1>, selected for replacement.", text)
|
||||
self.assertNotIn("@1", text)
|
||||
|
||||
def test_typed_words_in_a_swap_say_who_goes(self):
|
||||
parts = parts_of("The child in the seat.", jobs={1: "person"}, video="swap the person")
|
||||
self.assertIn("<Subject 1> is the child in the seat in <Video 1>", parts["subject_definitions"])
|
||||
@@ -805,8 +748,7 @@ class VideoJobTests(unittest.TestCase):
|
||||
self.assertIn("<Subject 4> is the person in <Picture 2>", parts["subject_definitions"])
|
||||
self.assertIn("replace the person on the right with <Subject 4>", parts["retention_analysis"])
|
||||
self.assertIn("change only the people", parts["retention_analysis"])
|
||||
self.assertIn("<Subject 4> occupies the starting position of the person on the right", parts["detailed_description"])
|
||||
self.assertIn("<Subject 4> has the face, hair and clothes of the person in <Picture 2>", parts["detailed_description"])
|
||||
self.assertIn("<Subject 4> takes the place of the person on the right", parts["detailed_description"])
|
||||
self.assertNotIn("Show one person", parts["detailed_description"])
|
||||
|
||||
def test_a_swap_with_two_people_reads_who_goes_from_the_script(self):
|
||||
@@ -1478,16 +1420,6 @@ class ClipSoundTests(unittest.TestCase):
|
||||
self.assertNotIn("lips", line)
|
||||
self.assertNotIn("speaks as in", line)
|
||||
|
||||
def test_loud_sound_with_no_voice_does_not_make_them_speak(self):
|
||||
# The subway clip: one long stretch of train noise, loud in the voice band, no clear pitch.
|
||||
noise = heard([(1.1, 5.42, None, 0.0)])
|
||||
line = parts_of("@1 replaces the woman on the platform.", clip_sound=True, speech=noise, **SWAP_JOBS)["detailed_description"]
|
||||
self.assertNotIn("speaks as in", line)
|
||||
self.assertNotIn("lips", line)
|
||||
a_word = heard([(1.0, 1.3, 150.0, 0.2)]) # A cough or one short sound: under half a second of voice.
|
||||
line = parts_of("@1 replaces the woman on the platform.", clip_sound=True, speech=a_word, **SWAP_JOBS)["detailed_description"]
|
||||
self.assertNotIn("speaks as in", line)
|
||||
|
||||
def test_a_typed_sound_wins_over_the_clips_sound(self):
|
||||
parts = parts_of("@1 replaces the man in the hat.\nsound: rain on the roof", clip_sound=True, speech=plain([(0.4, 1.9)]), **SWAP_JOBS)
|
||||
self.assertIn("rain on the roof", parts["overall_soundscape"].lower())
|
||||
|
||||
+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()
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
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._image_hold_helpers import GONE, HELD_FOLDER, hold_pictures, keep_pictures
|
||||
|
||||
NOW = datetime(2026, 11, 10, 9, 30, 0)
|
||||
|
||||
|
||||
def settings(**changes):
|
||||
base = {
|
||||
"format": "png", "extension": "png", "width": 64, "height": 96, "exact": "",
|
||||
"filename_prefix": "shoot", "on_existing": "overwrite", "output_dir": "",
|
||||
"name_counter": True, "name_date": False, "name_time": False, "name_size": False,
|
||||
"name_batch": False, "caption": "",
|
||||
}
|
||||
base.update(changes)
|
||||
return base
|
||||
|
||||
|
||||
class HoldTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.box = tempfile.TemporaryDirectory()
|
||||
self.temp = Path(self.box.name) / "temp"
|
||||
self.output = Path(self.box.name) / "output"
|
||||
self.temp.mkdir()
|
||||
self.output.mkdir()
|
||||
|
||||
def tearDown(self):
|
||||
self.box.cleanup()
|
||||
|
||||
def hold(self, count=3, **changes):
|
||||
frames = [f"picture {index}".encode() for index in range(count)]
|
||||
return hold_pictures(self.temp, frames, settings(**changes), lambda path, frame: path.write_bytes(frame))
|
||||
|
||||
def keep(self, token, picks=None):
|
||||
return keep_pictures(self.temp, self.output, token, picks, now=NOW)
|
||||
|
||||
def test_a_held_run_saves_nothing_until_it_is_kept(self):
|
||||
token, previews = self.hold()
|
||||
self.assertEqual(len(previews), 3)
|
||||
self.assertTrue(all(entry["type"] == "temp" and entry["subfolder"] == HELD_FOLDER for entry in previews))
|
||||
self.assertEqual(list(self.output.iterdir()), [])
|
||||
answer = self.keep(token)
|
||||
self.assertEqual([item["path"] for item in answer["saved"]], ["shoot_00001.png", "shoot_00002.png", "shoot_00003.png"])
|
||||
self.assertEqual((self.output / "shoot_00002.png").read_bytes(), b"picture 1")
|
||||
|
||||
def test_one_picture_can_be_kept_and_is_not_saved_twice(self):
|
||||
token, _ = self.hold()
|
||||
first = self.keep(token, [2])
|
||||
self.assertEqual(first["saved"], [{"index": 2, "path": "shoot_00001.png"}])
|
||||
self.assertEqual((self.output / "shoot_00001.png").read_bytes(), b"picture 2")
|
||||
again = self.keep(token, [2])
|
||||
self.assertEqual(again["saved"], [])
|
||||
rest = self.keep(token)
|
||||
self.assertEqual([item["index"] for item in rest["saved"]], [0, 1])
|
||||
self.assertEqual(rest["kept"], [0, 1, 2])
|
||||
self.assertEqual(sorted(entry.name for entry in self.output.iterdir()), ["shoot_00001.png", "shoot_00002.png", "shoot_00003.png"])
|
||||
|
||||
def test_the_folder_and_caption_of_the_run_are_used(self):
|
||||
token, _ = self.hold(count=1, output_dir="sets/portraits", caption="a portrait")
|
||||
answer = self.keep(token)
|
||||
self.assertEqual(answer["saved"][0]["path"], "sets/portraits/shoot_00001.png")
|
||||
self.assertEqual((self.output / "sets/portraits/shoot_00001.txt").read_text(), "a portrait")
|
||||
|
||||
def test_an_exact_name_keeps_its_place_in_the_run(self):
|
||||
token, _ = self.hold(count=3, exact="portrait")
|
||||
whole = [item["path"] for item in self.keep(self.hold(count=3, exact="other")[0])["saved"]]
|
||||
picked = self.keep(token, [1])["saved"][0]["path"]
|
||||
self.assertEqual(picked, whole[1].replace("other", "portrait"))
|
||||
|
||||
def test_a_request_cannot_name_a_place(self):
|
||||
token, _ = self.hold()
|
||||
for bad in ["../" + token, token + "/..", "", None, 5, token.upper(), "g" * 16]:
|
||||
with self.assertRaises(ValueError):
|
||||
self.keep(bad)
|
||||
for picks in [[3], [-1], ["0"], [True], [0.0]]:
|
||||
with self.assertRaises(ValueError):
|
||||
self.keep(token, picks)
|
||||
self.assertEqual(list(self.output.iterdir()), [])
|
||||
|
||||
def test_pictures_that_are_gone_say_so(self):
|
||||
with self.assertRaises(LookupError) as caught:
|
||||
self.keep("0" * 16)
|
||||
self.assertEqual(str(caught.exception), GONE)
|
||||
token, previews = self.hold(count=1)
|
||||
(self.temp / HELD_FOLDER / previews[0]["filename"]).unlink()
|
||||
with self.assertRaises(LookupError):
|
||||
self.keep(token)
|
||||
|
||||
def test_a_folder_outside_the_output_folder_is_refused_at_the_click_too(self):
|
||||
token, _ = self.hold(count=1)
|
||||
manifest = self.temp / HELD_FOLDER / f"{token}.json"
|
||||
manifest.write_text(manifest.read_text().replace('"output_dir": ""', '"output_dir": "../elsewhere"'))
|
||||
with self.assertRaises(ValueError):
|
||||
self.keep(token)
|
||||
self.assertFalse((Path(self.box.name) / "elsewhere").exists())
|
||||
|
||||
|
||||
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:
|
||||
@@ -1828,36 +1775,6 @@ class TellTests(TouchCase):
|
||||
self.swap("_server", lambda: None)
|
||||
self.assertEqual(self.note(self.run_node("", ask="scope", unique_id=7))["pieces"]["list"], told["pieces"]["list"])
|
||||
|
||||
def test_a_run_that_stops_still_shows_the_face_the_picture_that_came_in(self):
|
||||
# Issue 301: the picture changed upstream, the run stopped, and the
|
||||
# face kept the old one until the image was unplugged and plugged in.
|
||||
other = touches(tap(75, 34), key="160x120-0000000000000000")
|
||||
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.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"))
|
||||
note = data["note"]
|
||||
self.assertEqual((note["key"], note["width"], note["height"]), (picture_key(self.picture), W, H))
|
||||
self.assertEqual((note["items"], note["selection"], note["stamp"]), ([], None, ""), "nothing picked on it")
|
||||
self.assertEqual(self.opened(note["picture"]).size[0] > 0, True)
|
||||
self.assertEqual(FakeSam.calls, [], "showing the picture asks SAM 3 nothing")
|
||||
|
||||
def test_a_stop_that_cannot_tell_the_face_stops_for_the_same_reason(self):
|
||||
def broken(*_args):
|
||||
raise ConnectionError("the page is gone")
|
||||
|
||||
self.server.send_sync = broken
|
||||
with self.assertRaises(RuntimeError) as stopped:
|
||||
self.run_node("", unique_id=7)
|
||||
self.assertEqual(str(stopped.exception), NOTHING_PICKED)
|
||||
self.server.send_sync = lambda *args: self.sent.append(args)
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.run_node("")
|
||||
self.assertEqual(self.sent, [], "no id: nobody to tell")
|
||||
|
||||
def test_a_page_that_cannot_be_told_never_stops_the_look(self):
|
||||
def broken(*_args):
|
||||
raise ConnectionError("the page is gone")
|
||||
@@ -2116,9 +2033,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")
|
||||
@@ -2133,7 +2048,7 @@ class ContractTests(unittest.TestCase):
|
||||
self.assertEqual((required["soften"][0], soften["default"], soften["min"], soften["max"], soften["step"]), ("FLOAT", 0.0, 0.0, 100.0, 0.1))
|
||||
smooth = required["smooth"][1]
|
||||
self.assertEqual((required["smooth"][0], smooth["default"], smooth["min"], smooth["max"]), ("INT", 0, 0, 64))
|
||||
self.assertEqual((required["fill_holes"][0], required["fill_holes"][1]["default"]), ("BOOLEAN", False))
|
||||
self.assertEqual((required["fill_holes"][0], required["fill_holes"][1]["default"]), ("BOOLEAN", True))
|
||||
self.assertEqual((required["if_nothing"][0], required["if_nothing"][1]["default"]), (list(IF_NOTHING), "stop the run"))
|
||||
self.assertEqual((optional["ask"][0], optional["ask"][1]["default"]), ("STRING", ""))
|
||||
self.assertEqual((optional["invert"][0], optional["invert"][1]["default"]), ("BOOLEAN", False))
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
"""Mask by Touch on a big picture: SAM 3 is asked about a smaller copy, and
|
||||
what it answers comes back at the picture's own size. ComfyUI's detector and
|
||||
text reader are stood in for by fakes, so this runs without ComfyUI and
|
||||
without a model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
|
||||
import torch
|
||||
|
||||
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 import _mask_by_touch_helpers as helpers # noqa: E402
|
||||
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 WORK_SIDE, WorkView # noqa: E402
|
||||
|
||||
UI = "ausboss_mask_by_touch"
|
||||
# The big picture, and the long side of the copy SAM 3 is asked about here.
|
||||
H, W = 300, 240
|
||||
SIDE = 60
|
||||
# Things as shares of the picture: (left, top, right, bottom).
|
||||
PERSON = (0.25, 0.1, 0.75, 0.95)
|
||||
FACE = (0.4, 0.15, 0.6, 0.3)
|
||||
THINGS = {"person": [(PERSON, 0.98)], "face": [(FACE, 0.9)]}
|
||||
|
||||
|
||||
def box(thing, height, width):
|
||||
left, top, right, bottom = thing
|
||||
mask = torch.zeros(height, width)
|
||||
mask[round(top * height):round(bottom * height), round(left * width):round(right * width)] = 1.0
|
||||
return mask
|
||||
|
||||
|
||||
def same_area(one, other):
|
||||
one, other = one.bool(), other.bool()
|
||||
return float((one & other).sum()) / max(1.0, float((one | other).sum()))
|
||||
|
||||
|
||||
class SizedSam:
|
||||
"""Stands in for ComfyUI's SAM3_Detect. It answers at the size of the
|
||||
picture it is given, as the real one does."""
|
||||
|
||||
calls: list = []
|
||||
|
||||
@classmethod
|
||||
def execute(cls, model, image, conditioning=None, positive_coords=None, negative_coords=None, threshold=0.5, refine_iterations=2, individual_masks=False):
|
||||
height, width = int(image.shape[1]), int(image.shape[2])
|
||||
call = {"size": (height, width)}
|
||||
cls.calls.append(call)
|
||||
if conditioning is not None:
|
||||
call["word"] = conditioning[0][2].rpartition(":")[0]
|
||||
found = THINGS.get(call["word"], [])
|
||||
masks = torch.stack([box(thing, height, width) for thing, _ in found]) if found else torch.zeros(0, height, width)
|
||||
return types.SimpleNamespace(result=(masks, [[{"score": sure} for _, sure in found]]))
|
||||
call["points"] = points = [(point["x"], point["y"]) for point in json.loads(positive_coords)]
|
||||
answer = torch.zeros(height, width)
|
||||
for thing in (FACE, PERSON): # the smallest thing that holds every point
|
||||
area = box(thing, height, width)
|
||||
if all(0 <= y < height and 0 <= x < width and float(area[y, x]) > 0 for x, y in points):
|
||||
answer = area
|
||||
break
|
||||
return types.SimpleNamespace(result=(answer[None], [[]]))
|
||||
|
||||
|
||||
class Encode:
|
||||
def encode(self, clip, text):
|
||||
return ([["read by", clip, text]],)
|
||||
|
||||
|
||||
class Folders:
|
||||
def __init__(self, folder):
|
||||
self.folder = folder
|
||||
|
||||
def get_temp_directory(self):
|
||||
return self.folder
|
||||
|
||||
def get_save_image_path(self, prefix, folder, _width, _height):
|
||||
counter = 1 + sum(1 for name in Path(folder).iterdir() if name.name.startswith(prefix + "_"))
|
||||
return folder, prefix, counter, "", prefix
|
||||
|
||||
|
||||
class WorkViewTests(unittest.TestCase):
|
||||
def test_a_picture_up_to_the_work_side_is_asked_about_as_it_is(self):
|
||||
frame = torch.rand(1, 40, WORK_SIDE, 3)
|
||||
view = WorkView(frame)
|
||||
self.assertTrue(view.same)
|
||||
self.assertIs(view.frame, frame)
|
||||
self.assertEqual((view.work_height, view.work_width), (40, WORK_SIDE))
|
||||
self.assertEqual(view.point((17, 5)), (17, 5))
|
||||
self.assertEqual(view.full_point((17, 5)), (17, 5))
|
||||
mask = torch.zeros(40, WORK_SIDE, dtype=torch.bool)
|
||||
self.assertIs(view.full(mask), mask)
|
||||
self.assertEqual(view.full_box([1, 2, 3, 4]), [1, 2, 3, 4])
|
||||
|
||||
def test_a_bigger_picture_gets_a_copy_with_the_long_side_at_the_work_side(self):
|
||||
frame = torch.rand(1, WORK_SIDE * 2, WORK_SIDE + 500, 3)
|
||||
view = WorkView(frame)
|
||||
self.assertFalse(view.same)
|
||||
self.assertEqual(view.work_height, WORK_SIDE)
|
||||
self.assertEqual(tuple(view.frame.shape), (1, view.work_height, view.work_width, 3))
|
||||
self.assertEqual(view.work_width, round((WORK_SIDE + 500) / 2))
|
||||
# Averaged down: the copy's mean is the picture's.
|
||||
self.assertAlmostEqual(float(view.frame.mean()), float(frame.mean()), places=3)
|
||||
|
||||
def test_a_place_on_the_copy_leads_back_to_itself(self):
|
||||
for height, width in ((4096, 4096), (3840, 2160), (2049, 8192), (5000, 3333)):
|
||||
view = WorkView(torch.zeros(1, height, width, 1))
|
||||
for x in (0, 1, 7, view.work_width // 3, view.work_width - 2, view.work_width - 1):
|
||||
for y in (0, 1, 5, view.work_height // 2, view.work_height - 1):
|
||||
full = view.full_point((x, y))
|
||||
self.assertTrue(0 <= full[0] < width and 0 <= full[1] < height)
|
||||
self.assertEqual(view.point(full), (x, y), f"{width}x{height} at {x},{y}")
|
||||
self.assertEqual(view.point((width - 1, height - 1)), (view.work_width - 1, view.work_height - 1))
|
||||
|
||||
def test_an_answer_comes_back_at_the_pictures_own_size(self):
|
||||
view = WorkView(torch.zeros(1, 4096, 3000, 3))
|
||||
small = box(FACE, view.work_height, view.work_width) > 0.5
|
||||
full = view.full(small)
|
||||
self.assertEqual(tuple(full.shape), (4096, 3000))
|
||||
self.assertEqual(full.dtype, torch.bool)
|
||||
self.assertGreater(same_area(full, box(FACE, 4096, 3000)), 0.98)
|
||||
x0, y0, x1, y1 = view.full_box(helpers.box_of(small))
|
||||
wanted = helpers.box_of(box(FACE, 4096, 3000) > 0.5)
|
||||
for got, want in zip((x0, y0, x1, y1), wanted):
|
||||
self.assertLessEqual(abs(got - want), 3)
|
||||
|
||||
|
||||
class BigPictureTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
SizedSam.calls = []
|
||||
node_module._MEMORY.clear()
|
||||
node_module._WORDS.clear()
|
||||
node_module._TRUST.clear()
|
||||
node_module._UNSTEADY.clear()
|
||||
node_module._NOTED.clear()
|
||||
self.swap(node_module, "_core_nodes", lambda: {"SAM3_Detect": SizedSam, "CLIPTextEncode": Encode})
|
||||
self.swap(node_module, "SCOPE_SPACING", 12)
|
||||
self.swap(node_module, "SCOPE_SECONDS_BLIND", 1e9)
|
||||
self.swap(helpers, "WORK_SIDE", SIDE)
|
||||
folder = tempfile.mkdtemp(prefix="ausboss_touch_big_")
|
||||
self.addCleanup(shutil.rmtree, folder, True)
|
||||
self.swap(node_module, "folder_paths", Folders(folder))
|
||||
self.picture = torch.rand(1, H, W, 3) * 0.9
|
||||
self.node = node_module.AusBossMaskByTouch()
|
||||
|
||||
def swap(self, module, attribute, value):
|
||||
self.addCleanup(setattr, module, attribute, getattr(module, attribute))
|
||||
setattr(module, attribute, value)
|
||||
|
||||
def run_node(self, text="", image=None, **overrides):
|
||||
values = {"grow": 0, "soften": 0.0, "smooth": 0, "fill_holes": False, "if_nothing": IF_NOTHING[1]}
|
||||
values.update(overrides)
|
||||
return self.node.pick(self.picture if image is None else image, "sam3 model", "sam3 clip", text, **values)
|
||||
|
||||
def touches(self, *items):
|
||||
return json.dumps({"v": 1, "items": list(items)})
|
||||
|
||||
def test_sam3_is_never_given_the_big_picture(self):
|
||||
self.run_node(self.touches({"t": "tap", "op": 1, "x": 120, "y": 66}, {"t": "all", "op": 1, "w": "person"}))
|
||||
self.assertTrue(SizedSam.calls)
|
||||
for call in SizedSam.calls:
|
||||
self.assertEqual(call["size"], (SIDE, 48), "the copy: 300 x 240 with its long side at 60")
|
||||
|
||||
def test_a_tap_takes_the_thing_at_its_place_and_the_mask_is_full_size(self):
|
||||
out = self.run_node(self.touches({"t": "tap", "op": 1, "x": 120, "y": 66})) # on the face
|
||||
mask = out["result"][0]
|
||||
self.assertEqual(tuple(mask.shape), (1, H, W))
|
||||
self.assertGreater(same_area(mask[0] > 0.5, box(FACE, H, W)), 0.9)
|
||||
# The point was asked where it lies on the copy.
|
||||
self.assertEqual(SizedSam.calls[0]["points"], [(120 * 48 // W, 66 * SIDE // H)])
|
||||
|
||||
def test_a_word_takes_every_thing_of_that_name_at_full_size(self):
|
||||
out = self.run_node(self.touches({"t": "all", "op": 1, "w": "person"}, {"t": "all", "op": 0, "w": "face"}))
|
||||
mask = out["result"][0][0] > 0.5
|
||||
wanted = (box(PERSON, H, W) > 0.5) & ~(box(FACE, H, W) > 0.5)
|
||||
self.assertGreater(same_area(mask, wanted), 0.93)
|
||||
self.assertEqual(out["result"][2], "person")
|
||||
|
||||
def test_a_stroke_is_asked_about_on_the_copy(self):
|
||||
stroke = {"t": "find", "op": 1, "r": 8, "pts": [[110, 60], [130, 70]]} # across the face
|
||||
out = self.run_node(self.touches(stroke))
|
||||
self.assertGreater(same_area(out["result"][0][0] > 0.5, box(FACE, H, W)), 0.9)
|
||||
for call in SizedSam.calls:
|
||||
for x, y in call.get("points", []):
|
||||
self.assertTrue(0 <= x < 48 and 0 <= y < SIDE)
|
||||
|
||||
def test_what_a_look_lights_up_is_what_a_click_on_it_takes(self):
|
||||
note = self.run_node(ask="scope")["ui"][UI][0]
|
||||
self.assertEqual((note["width"], note["height"]), (W, H))
|
||||
pieces = {piece["name"]: piece for piece in note["pieces"]["list"] if piece["name"]}
|
||||
self.assertEqual(set(pieces), {"person", "face"})
|
||||
face = pieces["face"]
|
||||
# Its own point is a pixel of the picture, inside the thing, and its box is the picture's.
|
||||
self.assertGreater(float(box(FACE, H, W)[face["y"], face["x"]]), 0)
|
||||
for got, want in zip(face["box"], helpers.box_of(box(FACE, H, W) > 0.5)):
|
||||
self.assertLessEqual(abs(got - want), 6)
|
||||
self.assertAlmostEqual(face["share"], float(box(FACE, H, W).mean()), delta=0.01)
|
||||
# The face picks it as a named touch at that point: nothing is asked again.
|
||||
before = len(SizedSam.calls)
|
||||
out = self.run_node(self.touches({"t": "name", "op": 1, "w": "face", "x": face["x"], "y": face["y"]}), ask="look")
|
||||
self.assertEqual(len(SizedSam.calls), before)
|
||||
self.assertGreater(same_area(out["result"][0][0] > 0.5, box(FACE, H, W)), 0.9)
|
||||
# A point piece is remembered under the picture's own pixel as well.
|
||||
spot = next((piece for piece in note["pieces"]["list"] if not piece["name"]), None)
|
||||
if spot is not None:
|
||||
self.run_node(self.touches({"t": "tap", "op": 1, "x": spot["x"], "y": spot["y"]}), ask="look")
|
||||
self.assertEqual(len(SizedSam.calls), before)
|
||||
|
||||
def test_a_small_picture_is_asked_about_as_it_is(self):
|
||||
small = torch.rand(1, 50, 40, 3) * 0.9
|
||||
out = self.run_node(self.touches({"t": "tap", "op": 1, "x": 20, "y": 11}), image=small)
|
||||
self.assertEqual([call["size"] for call in SizedSam.calls], [(50, 40)])
|
||||
self.assertTrue(torch.equal(out["result"][0][0] > 0.5, box(FACE, 50, 40) > 0.5), "exactly what SAM 3 answered")
|
||||
|
||||
def test_paint_stays_exactly_as_painted_on_the_big_picture(self):
|
||||
stroke = [[20, 20], [200, 20]]
|
||||
out = self.run_node(self.touches({"t": "paint", "op": 1, "r": 5, "pts": stroke}))
|
||||
wanted = helpers.stroke_area(tuple(tuple(point) for point in stroke), 5.0, H, W)
|
||||
self.assertTrue(torch.equal(out["result"][0][0] > 0.5, wanted))
|
||||
self.assertEqual(SizedSam.calls, [], "paint asks SAM 3 nothing")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user