Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfbc42799a | ||
|
|
b4657e2b58 |
+9
-12
@@ -4,6 +4,15 @@ All notable changes to ComfyUI-AusBoss are documented here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **New node: Load Image.** Loads a picture and sets its size in the same
|
||||
node, so a workflow needs no resize node after its loader. Size can be a
|
||||
megapixel count, a longest side, a scale, a box the picture fits inside, or
|
||||
a box it fills by cropping, rounded to a multiple you choose. Arrows step
|
||||
through the pictures in the input folder, and the node shows the size
|
||||
going in and coming out before you run. It also gives the file's name and
|
||||
its original size. With Size off the picture comes out exactly as the file
|
||||
has it.
|
||||
|
||||
- **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
|
||||
@@ -18,18 +27,6 @@ All notable changes to ComfyUI-AusBoss are documented here.
|
||||
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
|
||||
|
||||
@@ -80,6 +80,12 @@ It returns the `image`, a `mask` of everything the model paints (padding, see-th
|
||||
|
||||
Krea 2 Rotate + Outpaint turns a photo, pads it and paints the new area in one pass. [Try the Krea 2 Rotate + Outpaint example →](example_workflows/Krea%202%20Rotate%20%2B%20Outpaint%20%28AusBoss%29.json)
|
||||
|
||||
### Load Image
|
||||
|
||||
Loads a picture and sets its size in one node. Pick a file, upload one, or step through the input folder with the arrows. **Size** can stay off, or bring the picture to a megapixel count, a longest side, a scale, a box it must fit inside, or a box it fills by cropping. **Round to** keeps the result on a multiple of 8, 16 or whatever the model wants. The node shows the size going in and the size coming out before you run, such as `912 × 1632 → 1144 × 2048`.
|
||||
|
||||
It returns the `image`, a `mask` of its see-through parts, the new `width` and `height`, the `filename` for a save prefix, and the file's `original_width` and `original_height`.
|
||||
|
||||
### Load Image + Pad
|
||||
|
||||
Makes a canvas for outpainting: drag the edges out to add room around a picture. The Source list shows the picture under the pointer. The new area can be a solid color, the average color of the nearest edge, the edge pixels smeared outward, a blurred copy of the picture, or a mirror of it. The card also has a seam feather, a canvas multiple and a megapixel budget, and **Exact padding** opens one control per side. A budget of 0 keeps the picture's size, and anything higher resizes the picture before padding. A picture wired into `source_image` is padded instead of the chosen file.
|
||||
|
||||
@@ -36,6 +36,7 @@ NODE_MODULES = [
|
||||
"node_lama_inpaint",
|
||||
"node_layout_boxes",
|
||||
"node_literals",
|
||||
"node_load_image",
|
||||
"node_load_image_pad",
|
||||
"node_load_audio",
|
||||
"node_load_video",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Load Image
|
||||
|
||||
Loads a picture and sets its size in the same node, so a workflow does not
|
||||
need a resize node and a size node after its loader.
|
||||
|
||||
Pick a file in **Source**, press **Load** to upload one, or use the arrows
|
||||
under it to step through the pictures in ComfyUI's input folder. The line
|
||||
beside the arrows shows the size of the file and, when the size changes, the
|
||||
size that comes out:
|
||||
|
||||
`912 × 1632 → 1144 × 2048`
|
||||
|
||||
That is the real output size, worked out before you run.
|
||||
|
||||
## Size
|
||||
|
||||
**Size** (`size`) picks how the picture is resized. Each choice shows only
|
||||
the numbers it uses.
|
||||
|
||||
| Choice | What it does | Its number |
|
||||
|---|---|---|
|
||||
| **off** | The picture comes out exactly as the file has it. | none |
|
||||
| **megapixels** | Brings the whole picture to a total size and keeps its shape. | **Megapixels** (`megapixels`): 1.0 is about 1024 × 1024 |
|
||||
| **longest side** | Sets the longer edge; the other edge follows. | **Longest side** (`longest_side`), in pixels |
|
||||
| **scale** | Multiplies both edges. | **Scale** (`scale`): 0.5 is half, 2 is double |
|
||||
| **fit inside** | Makes the picture as big as it can be inside a box, with nothing cut off. | **Width** and **Height** (`width`, `height`) |
|
||||
| **crop to fill** | Covers the box completely and cuts off what sticks out, evenly from both sides. | **Width** and **Height** |
|
||||
|
||||
**Round to** (`multiple`) rounds the new width and height to a multiple of
|
||||
that number. Most models want 8 or 16. Set it to 1 to keep the numbers as
|
||||
they are. It does nothing while Size is off.
|
||||
|
||||
## More
|
||||
|
||||
- **Method** (`method`): how pixels are resampled. `lanczos` is the sharpest
|
||||
for photos. `nearest` keeps pixel art crisp.
|
||||
- **Enlarge** (`enlarge`): on, a small picture is made bigger to reach the
|
||||
size you asked for. Off, a picture is only ever made smaller: one that is
|
||||
already below the target comes out at its own size, and crop to fill cuts
|
||||
the box's shape out of it without growing it.
|
||||
|
||||
## The picture on the node
|
||||
|
||||
The picture under the settings is ComfyUI's own preview, the same one the
|
||||
stock Load Image shows. Drop a file on the node or paste one to upload it,
|
||||
and right-click the node for the mask editor and for copy and paste.
|
||||
|
||||
## Outputs
|
||||
|
||||
- `image`: the picture at its new size.
|
||||
- `mask`: white where the picture is see-through and black where it is
|
||||
solid, as the stock loader gives it. A picture with no transparency gives
|
||||
an all black mask. It is always the same size as `image`.
|
||||
- `width`, `height`: the size that comes out.
|
||||
- `filename`: the file's name without its folder or its ending
|
||||
(`shots/hero.png` gives `hero`), ready for a save node's prefix.
|
||||
- `original_width`, `original_height`: the size of the file before any
|
||||
resize. Use them to bring a result back to the size you started from.
|
||||
|
||||
## Which loader to use
|
||||
|
||||
- **Load Image** (this node): load a picture and set its size.
|
||||
- **Load Image + Pad**: add empty space around a picture for outpainting.
|
||||
- **Image Crop + Rotate + Pad**: turn, crop and pad a picture by hand.
|
||||
|
||||
## Technical details
|
||||
|
||||
- Every frame of an animated file comes through as a batch, all at the same
|
||||
new size. A phone photo is turned the way its EXIF data says.
|
||||
- With Size off the pixels are the file's own, bit for bit, and Round to is
|
||||
not applied.
|
||||
- Rounding moves each edge to the nearest multiple, so the shape can change
|
||||
by up to half a step. Fit inside rounds down instead, so the result never
|
||||
leaves its box.
|
||||
- Crop to fill resizes the picture until it covers the box, then cuts the
|
||||
middle out. The mask goes through the same resize and the same cut.
|
||||
- With Enlarge off, a rounded size is rounded down so it never passes the
|
||||
file's own size.
|
||||
- The size line on the node uses the same arithmetic as the run. The pack's
|
||||
tests hold the two to the same answers over a table of cases.
|
||||
- Files are read only from ComfyUI's input, output and temp folders. A name
|
||||
such as `photo.png [output]` loads a picture ComfyUI made.
|
||||
- The node runs again when the file changes on disk or a setting changes.
|
||||
@@ -50,18 +50,8 @@ 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.
|
||||
such as a folder of images), arrows beside the switch (‹ 2 / 5 ›) step
|
||||
through them.
|
||||
- **Preview 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
|
||||
|
||||
@@ -17,6 +17,7 @@ help panel.
|
||||
| Pick a picture size by shape and megapixels, in place of Empty Latent | Latent Size (`AUSBOSS_NODES_Resolution`) |
|
||||
| Resize a picture, or snap its size to a multiple | Image Resize (`AUSBOSS_NODES_ImageResize`), Align Image (`AUSBOSS_NODES_AlignImage`) |
|
||||
| Read a picture's width and height | Image Size (`AUSBOSS_NODES_ImageSize`) |
|
||||
| Load a picture and set its size (megapixels, longest side, a box) in one node | Load Image (`AUSBOSS_NODES_LoadImage`) |
|
||||
| Outpaint: load a picture and add empty space around it | Load Image + Pad (`AUSBOSS_NODES_LoadImagePad`) |
|
||||
| Turn, crop and pad a picture in one node | Image Crop + Rotate + Pad (`AUSBOSS_NODES_ImageCropRotatePad`) |
|
||||
| Inpaint only the masked part, then put it back | Crop For Inpaint (`AUSBOSS_NODES_CropForInpaint`) then Stitch Inpaint (`AUSBOSS_NODES_StitchInpaint`) |
|
||||
|
||||
+10
-199
@@ -26,7 +26,7 @@ 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 +55,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 +114,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
|
||||
@@ -153,16 +138,10 @@ function ensureCss() {
|
||||
.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-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;}
|
||||
@@ -509,14 +488,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 +509,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,53 +517,6 @@ 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.
|
||||
@@ -639,9 +569,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,107 +586,10 @@ 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);
|
||||
}
|
||||
|
||||
@@ -765,7 +597,6 @@ function refresh(state) {
|
||||
if (!state.alive) return;
|
||||
if (!previewEnabled(state)) {
|
||||
clearMedia(state);
|
||||
syncHeld(state);
|
||||
return;
|
||||
}
|
||||
const source = upstreamNode(state.node, state.inputName);
|
||||
@@ -773,9 +604,7 @@ function refresh(state) {
|
||||
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 +616,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 +701,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 +735,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 +769,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);
|
||||
|
||||
+2
-23
@@ -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`;
|
||||
@@ -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, {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// The row under the file picker on Load Image: arrows that step through the
|
||||
// input folder, where the file is in the list ("14 / 212"), and the size the
|
||||
// picture goes in at and comes out at ("912 × 1632 → 1144 × 2048"). The
|
||||
// widgets underneath stay the source of truth: an arrow only changes the
|
||||
// image widget's value, the way picking from the list does.
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { placeText, planLoadSize, sizeText, stepFile } from "./load_size.mjs";
|
||||
import { mediaViewQuery } from "./media_list.mjs";
|
||||
import { FONT_UI, RADIUS_CONTROL, THEME as T } from "./theme.mjs";
|
||||
import { commitWidgetValue, comboValues } from "./widget_card_math.mjs";
|
||||
|
||||
const CSS_ID = "ausboss-load-strip-css";
|
||||
// Sizes already read, by file: stepping back to a picture shows its size at once.
|
||||
const SIZES_KEPT = 200;
|
||||
const sizes = new Map();
|
||||
|
||||
function ensureCss() {
|
||||
if (document.getElementById(CSS_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = CSS_ID;
|
||||
style.textContent = `
|
||||
.ausboss-load-strip{display:flex;align-items:center;gap:6px;height:100%;min-width:0;font:500 11px/1 ${FONT_UI};color:${T.label}}
|
||||
.ausboss-load-step{flex:none;width:26px;height:100%;padding:0;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};color:${T.inkSoft};font:600 11px/1 ${FONT_UI};cursor:pointer}
|
||||
.ausboss-load-step:hover{background:${T.fillHover};color:${T.inkHover}}
|
||||
.ausboss-load-step:focus-visible{outline:2px solid ${T.brand};outline-offset:1px}
|
||||
.ausboss-load-step:disabled{opacity:.4;cursor:default}
|
||||
.ausboss-load-place{flex:none;color:${T.faint};font-variant-numeric:tabular-nums;white-space:nowrap;user-select:none}
|
||||
.ausboss-load-size{flex:1 1 0;min-width:0;overflow:hidden;text-align:right;white-space:nowrap;text-overflow:ellipsis;color:${T.inkSoft};font-weight:600;font-variant-numeric:tabular-nums}
|
||||
.ausboss-load-size b{color:${T.brandInk};font-weight:700}
|
||||
.ausboss-load-size i{color:${T.faint};font-style:normal;margin:0 5px}
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
const viewUrl = (value) => api.apiURL(`/view?${mediaViewQuery(value)}`);
|
||||
|
||||
// The picture's own size, read from the file the way the browser shows it.
|
||||
function readSize(file, done) {
|
||||
const known = sizes.get(file);
|
||||
if (known) return known;
|
||||
const picture = new Image();
|
||||
picture.onload = () => {
|
||||
if (sizes.size >= SIZES_KEPT) sizes.delete(sizes.keys().next().value);
|
||||
sizes.set(file, { width: picture.naturalWidth, height: picture.naturalHeight });
|
||||
done();
|
||||
};
|
||||
picture.src = viewUrl(file);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadImageStrip(node) {
|
||||
ensureCss();
|
||||
const root = document.createElement("div");
|
||||
root.className = "ausboss-load-strip";
|
||||
const widget = () => node.widgets?.find((item) => item.name === "image") ?? null;
|
||||
const linked = () => {
|
||||
const slot = node.inputs?.find((input) => input?.widget?.name === "image" || input?.name === "image");
|
||||
return slot?.link != null;
|
||||
};
|
||||
let last = {};
|
||||
|
||||
const step = (delta, label, title) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "ausboss-load-step";
|
||||
button.textContent = label;
|
||||
button.title = title;
|
||||
button.addEventListener("click", () => {
|
||||
const image = widget();
|
||||
if (!image) return;
|
||||
const next = stepFile(comboValues(image), image.value, delta);
|
||||
if (next === null || next === image.value) return;
|
||||
commitWidgetValue(node, image, next, app.canvas);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
});
|
||||
root.append(button);
|
||||
return button;
|
||||
};
|
||||
const back = step(-1, "◀", "The picture before this one in the input folder");
|
||||
const on = step(1, "▶", "The next picture in the input folder");
|
||||
const place = document.createElement("span");
|
||||
place.className = "ausboss-load-place";
|
||||
place.title = "Where this picture is in the input folder's list";
|
||||
const size = document.createElement("span");
|
||||
size.className = "ausboss-load-size";
|
||||
root.append(place, size);
|
||||
|
||||
const sync = (values = last) => {
|
||||
last = values;
|
||||
const image = widget();
|
||||
const files = comboValues(image);
|
||||
const file = String(values.image ?? "");
|
||||
place.textContent = placeText(files, file);
|
||||
const stuck = linked() || stepFile(files, file, 1) === null;
|
||||
back.disabled = stuck;
|
||||
on.disabled = stuck;
|
||||
const source = file ? readSize(file, () => { if (String(last.image ?? "") === file) sync(); }) : null;
|
||||
size.replaceChildren();
|
||||
if (!source) {
|
||||
size.title = "";
|
||||
return;
|
||||
}
|
||||
const plan = planLoadSize(source.width, source.height, values);
|
||||
const text = sizeText(source.width, source.height, plan);
|
||||
const [from, to] = text.split(" → ");
|
||||
size.append(from);
|
||||
if (to) {
|
||||
const arrow = document.createElement("i");
|
||||
arrow.textContent = "→";
|
||||
const out = document.createElement("b");
|
||||
out.textContent = to;
|
||||
size.append(arrow, out);
|
||||
}
|
||||
size.title = to
|
||||
? `The file is ${from}. The picture comes out at ${to}.`
|
||||
: `The picture comes out at its own size, ${from}.`;
|
||||
};
|
||||
return { element: root, sync };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Load Image: the size a picture comes out at, worked out in the browser so
|
||||
// the node can show it before a run. This repeats plan_load_size in
|
||||
// nodes/_load_image_helpers.py; tests/fixtures/load_size_cases.json holds
|
||||
// both to the same answers. No DOM in here.
|
||||
|
||||
export const SIZE_MODES = Object.freeze(["off", "megapixels", "longest side", "scale", "fit inside", "crop to fill"]);
|
||||
|
||||
const roundHalfUp = (value) => Math.floor(value + 0.5);
|
||||
const whole = (value, fallback) => (Number.isFinite(Number(value)) ? Math.trunc(Number(value)) : fallback);
|
||||
// Nearest multiple, never below one step.
|
||||
const nearest = (value, step) => Math.max(step, roundHalfUp(value / step) * step);
|
||||
// The largest multiple that is not above the value, never below one step.
|
||||
const below = (value, step) => Math.max(step, Math.floor(value / step) * step);
|
||||
|
||||
function target(sourceW, sourceH, size, { megapixels, longest_side: longestSide, scale, width, height }) {
|
||||
const scaled = (factor) => [Math.max(1, roundHalfUp(sourceW * factor)), Math.max(1, roundHalfUp(sourceH * factor))];
|
||||
if (size === "megapixels") {
|
||||
const amount = Number(megapixels);
|
||||
return amount > 0 ? scaled(Math.sqrt((amount * 1e6) / (sourceW * sourceH))) : [sourceW, sourceH];
|
||||
}
|
||||
if (size === "longest side") {
|
||||
const edge = whole(longestSide, 0);
|
||||
if (edge <= 0) return [sourceW, sourceH];
|
||||
const [w, h] = scaled(edge / Math.max(sourceW, sourceH));
|
||||
return sourceW >= sourceH ? [edge, h] : [w, edge];
|
||||
}
|
||||
if (size === "scale") {
|
||||
const factor = Number(scale);
|
||||
return factor > 0 ? scaled(factor) : [sourceW, sourceH];
|
||||
}
|
||||
const fit = Math.min(Math.max(1, whole(width, 1)) / sourceW, Math.max(1, whole(height, 1)) / sourceH);
|
||||
return scaled(fit);
|
||||
}
|
||||
|
||||
// { resizeWidth, resizeHeight, cropX, cropY, width, height } for a picture of
|
||||
// sourceWidth x sourceHeight and the node's values, or null when the picture
|
||||
// has no size yet or the choice is not one the node knows.
|
||||
export function planLoadSize(sourceWidth, sourceHeight, values = {}) {
|
||||
const sourceW = whole(sourceWidth, 0);
|
||||
const sourceH = whole(sourceHeight, 0);
|
||||
const size = values.size ?? "off";
|
||||
if (sourceW <= 0 || sourceH <= 0 || !SIZE_MODES.includes(size)) return null;
|
||||
const same = { resizeWidth: sourceW, resizeHeight: sourceH, cropX: 0, cropY: 0, width: sourceW, height: sourceH };
|
||||
if (size === "off") return same;
|
||||
const step = Math.max(1, whole(values.multiple, 1));
|
||||
const enlarge = values.enlarge !== false;
|
||||
const boxW = Math.max(1, whole(values.width, 1024));
|
||||
const boxH = Math.max(1, whole(values.height, 1024));
|
||||
|
||||
if (size === "crop to fill") {
|
||||
let width = boxW;
|
||||
let height = boxH;
|
||||
if (enlarge) {
|
||||
width = nearest(width, step);
|
||||
height = nearest(height, step);
|
||||
} else {
|
||||
const grow = Math.max(width / sourceW, height / sourceH);
|
||||
if (grow > 1) {
|
||||
width = Math.max(1, Math.floor(width / grow + 1e-6));
|
||||
height = Math.max(1, Math.floor(height / grow + 1e-6));
|
||||
}
|
||||
width = Math.min(below(width, step), Math.max(step, sourceW));
|
||||
height = Math.min(below(height, step), Math.max(step, sourceH));
|
||||
}
|
||||
const cover = Math.max(width / sourceW, height / sourceH);
|
||||
const resizeWidth = Math.max(width, Math.ceil(sourceW * cover - 1e-6));
|
||||
const resizeHeight = Math.max(height, Math.ceil(sourceH * cover - 1e-6));
|
||||
return {
|
||||
resizeWidth, resizeHeight,
|
||||
cropX: Math.floor((resizeWidth - width) / 2), cropY: Math.floor((resizeHeight - height) / 2),
|
||||
width, height,
|
||||
};
|
||||
}
|
||||
|
||||
let [targetW, targetH] = target(sourceW, sourceH, size, { ...values, width: boxW, height: boxH });
|
||||
if (!enlarge && (targetW > sourceW || targetH > sourceH)) [targetW, targetH] = [sourceW, sourceH];
|
||||
const rounded = (wanted, source, box) => {
|
||||
let value = nearest(wanted, step);
|
||||
if (size === "fit inside" && value > box) value = below(box, step);
|
||||
if (!enlarge && value > source) value = below(source, step);
|
||||
return value;
|
||||
};
|
||||
const width = rounded(targetW, sourceW, boxW);
|
||||
const height = rounded(targetH, sourceH, boxH);
|
||||
return { resizeWidth: width, resizeHeight: height, cropX: 0, cropY: 0, width, height };
|
||||
}
|
||||
|
||||
// "912 × 1632" for a picture that comes out as it went in, "912 × 1632 →
|
||||
// 1144 × 2048" when its size changes, "" while the picture's size is unknown.
|
||||
export function sizeText(sourceWidth, sourceHeight, plan) {
|
||||
if (!(sourceWidth > 0 && sourceHeight > 0)) return "";
|
||||
const from = `${sourceWidth} × ${sourceHeight}`;
|
||||
if (!plan || (plan.width === sourceWidth && plan.height === sourceHeight)) return from;
|
||||
return `${from} → ${plan.width} × ${plan.height}`;
|
||||
}
|
||||
|
||||
// The file one step before or after the current one in the list, going
|
||||
// round at the ends. A file that is not in the list steps to the first or
|
||||
// the last. null when the list has nothing to step to.
|
||||
export function stepFile(files, current, delta) {
|
||||
const list = (Array.isArray(files) ? files : []).filter((name) => name !== "" && name != null);
|
||||
if (!list.length) return null;
|
||||
const at = list.indexOf(current);
|
||||
if (at < 0) return delta < 0 ? list[list.length - 1] : list[0];
|
||||
if (list.length === 1) return null;
|
||||
return list[(at + (delta < 0 ? -1 : 1) + list.length) % list.length];
|
||||
}
|
||||
|
||||
// "14 / 212": where the current file is in the list. "" for an empty list,
|
||||
// "– / 212" for a file that is not in it.
|
||||
export function placeText(files, current) {
|
||||
const list = (Array.isArray(files) ? files : []).filter((name) => name !== "" && name != null);
|
||||
if (!list.length) return "";
|
||||
const at = list.indexOf(current);
|
||||
return `${at < 0 ? "–" : at + 1} / ${list.length}`;
|
||||
}
|
||||
@@ -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 : [];
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export const SOURCE_LOADERS = Object.freeze({
|
||||
LoadImageOutput: picture("image"),
|
||||
LoadVideo: video("file"),
|
||||
AUSBOSS_NODES_ImageCropRotatePad: picture("image"),
|
||||
AUSBOSS_NODES_LoadImage: picture("image"),
|
||||
// A wired source_image replaces the file, so the file may stay blank.
|
||||
AUSBOSS_NODES_LoadImagePad: (inputs) => (linked(inputs?.source_image) ? null : picture("image")(inputs)),
|
||||
AUSBOSS_NODES_LoadAudio: sound("audio"),
|
||||
|
||||
@@ -57,9 +57,9 @@ export function goesBehind(flying, ask, epoch) {
|
||||
|
||||
// The pack's own picture loaders. One of them wired straight into Mask by
|
||||
// Touch is run by the node itself, the way a press on Show picture runs it:
|
||||
// it may crop, turn or pad its file, so the file alone is not the picture,
|
||||
// it may crop, turn, pad or resize its file, so the file alone is not the picture,
|
||||
// but running it costs next to nothing.
|
||||
export const OWN_LOADERS = new Set(["AUSBOSS_NODES_ImageCropRotatePad", "AUSBOSS_NODES_LoadImagePad"]);
|
||||
export const OWN_LOADERS = new Set(["AUSBOSS_NODES_ImageCropRotatePad", "AUSBOSS_NODES_LoadImage", "AUSBOSS_NODES_LoadImagePad"]);
|
||||
|
||||
// What a loader will hand on, as far as the page can tell: its file and its
|
||||
// settings, as one text. Another text means another picture. Widgets that
|
||||
|
||||
@@ -12,6 +12,7 @@ import { formatWidgetVisibility } from "../shared/save_video_formats.mjs";
|
||||
import { hideInputsInDef } from "../shared/widget_visibility.mjs";
|
||||
import { castChips } from "../shared/h3_cast_chips.mjs";
|
||||
import { clipSetsDuration } from "../shared/h3_duration.mjs";
|
||||
import { loadImageStrip } from "../shared/load_image_strip.mjs";
|
||||
import { momentStrip } from "../shared/moment_strip.mjs";
|
||||
import { mountWidgetCard } from "../shared/widget_card.mjs";
|
||||
import { FONT_UI, THEME } from "../shared/theme.mjs";
|
||||
@@ -409,6 +410,28 @@ const CARDS = {
|
||||
title: "The longest the sound may be. Anything past it is cut off and shows in red. 0 means no limit." },
|
||||
],
|
||||
},
|
||||
// Load Image: the file, then where it is in the folder and the size it
|
||||
// goes in and comes out at, then the size settings. ComfyUI's own picture
|
||||
// preview sits under the card, so its mask editor and copy and paste work
|
||||
// as they do on the stock loader.
|
||||
AUSBOSS_NODES_LoadImage: {
|
||||
minWidth: 320, first: true, hide: ["upload"],
|
||||
rows: [
|
||||
{ widget: "image", label: "Source", kind: "select", preview: { kind: "image", url: viewUrl },
|
||||
button: { text: PANEL_LABELS.load, title: "Upload a picture into ComfyUI's input folder", onClick: (node) => node.widgets?.find((w) => w.name === "upload")?.callback?.() } },
|
||||
{ panel: loadImageStrip, height: 26 },
|
||||
{ widget: "size", label: "Size", kind: "select" },
|
||||
{ widget: "megapixels", label: "Megapixels", suffix: "MP", when: isMode("size", "megapixels") },
|
||||
{ widget: "longest_side", label: "Longest side", suffix: "px", when: isMode("size", "longest side") },
|
||||
{ widget: "scale", label: "Scale", suffix: "\u00d7", when: isMode("size", "scale") },
|
||||
{ widget: "width", label: "Width", suffix: "px", when: isMode("size", "fit inside", "crop to fill") },
|
||||
{ widget: "height", label: "Height", suffix: "px", when: isMode("size", "fit inside", "crop to fill") },
|
||||
{ widget: "multiple", label: "Round to", snap: true, step: 8, when: (values) => values.size !== "off" },
|
||||
{ group: "more", label: "More" },
|
||||
{ widget: "method", label: "Method", kind: "select", group: "more" },
|
||||
{ widget: "enlarge", label: "Enlarge", group: "more" },
|
||||
],
|
||||
},
|
||||
AUSBOSS_NODES_LoadImagePad: {
|
||||
minWidth: 340, first: true, hide: ["upload"],
|
||||
rows: [
|
||||
|
||||
@@ -651,6 +651,65 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"AUSBOSS_NODES_LoadImage": {
|
||||
"display_name": "加载图像 🆎",
|
||||
"description": "在同一个节点里加载图片并设定尺寸。可以选择文件、上传,或用箭头逐张浏览输入文件夹。尺寸可以关闭,也可以按百万像素、最长边、缩放倍数、放入一个框内,或裁剪填满一个框来调整。运行之前,节点就会显示输入尺寸和输出尺寸。",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "图像"
|
||||
},
|
||||
"size": {
|
||||
"name": "尺寸"
|
||||
},
|
||||
"megapixels": {
|
||||
"name": "百万像素"
|
||||
},
|
||||
"longest_side": {
|
||||
"name": "最长边"
|
||||
},
|
||||
"scale": {
|
||||
"name": "缩放倍数"
|
||||
},
|
||||
"width": {
|
||||
"name": "宽度"
|
||||
},
|
||||
"height": {
|
||||
"name": "高度"
|
||||
},
|
||||
"multiple": {
|
||||
"name": "取整倍数"
|
||||
},
|
||||
"method": {
|
||||
"name": "插值"
|
||||
},
|
||||
"enlarge": {
|
||||
"name": "放大"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "图像"
|
||||
},
|
||||
"1": {
|
||||
"name": "遮罩"
|
||||
},
|
||||
"2": {
|
||||
"name": "宽度"
|
||||
},
|
||||
"3": {
|
||||
"name": "高度"
|
||||
},
|
||||
"4": {
|
||||
"name": "文件名"
|
||||
},
|
||||
"5": {
|
||||
"name": "原始宽度"
|
||||
},
|
||||
"6": {
|
||||
"name": "原始高度"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AUSBOSS_NODES_LoadImagePad": {
|
||||
"display_name": "加载图像 + 扩边 🆎",
|
||||
"description": "在一个节点里加载图像并扩边成扩图画布。拖动节点上画布的任意一边,直观地设置每边的扩边量;遮罩正好覆盖新扩出的部分,可选择向内羽化跨过接缝。画布会取整到整数倍,但不会给你没扩的边加条带;设置百万像素目标时会先缩放原图,让遮罩接缝保持清晰。",
|
||||
@@ -1110,9 +1169,6 @@
|
||||
},
|
||||
"step_preview": {
|
||||
"name": "步骤预览"
|
||||
},
|
||||
"autosave": {
|
||||
"name": "自动保存"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Load Image: the size a picture comes out at, as plain numbers.
|
||||
|
||||
``plan_load_size`` turns the node's size choice into what the picture is
|
||||
resized to and what is cut from it. It holds no tensors, so it is tested as
|
||||
numbers, and ``js/shared/load_size.mjs`` repeats it for the size the node
|
||||
shows before a run (``tests/fixtures/load_size_cases.json`` holds both to the
|
||||
same answers). ``apply_load_size`` runs a plan on a picture and its mask.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from ._resize_helpers import plan_resize, resample_batch, resolve_target, snap_to_multiple
|
||||
|
||||
SIZE_MODES = (
|
||||
"off",
|
||||
"megapixels",
|
||||
"longest side",
|
||||
"scale",
|
||||
"fit inside",
|
||||
"crop to fill",
|
||||
)
|
||||
|
||||
|
||||
def _round_half_up(value: float) -> int:
|
||||
return int(math.floor(value + 0.5))
|
||||
|
||||
|
||||
def _multiple_below(value: int, multiple: int) -> int:
|
||||
"""The largest multiple that is not above ``value``, never below one step."""
|
||||
step = max(1, int(multiple))
|
||||
return max(step, (int(value) // step) * step)
|
||||
|
||||
|
||||
def plan_load_size(
|
||||
source_width: int,
|
||||
source_height: int,
|
||||
size: str = "off",
|
||||
megapixels: float = 1.0,
|
||||
longest_side: int = 1024,
|
||||
scale: float = 1.0,
|
||||
width: int = 1024,
|
||||
height: int = 1024,
|
||||
multiple: int = 1,
|
||||
enlarge: bool = True,
|
||||
) -> dict[str, int]:
|
||||
"""What one picture is resized to (``resize_width``/``resize_height``),
|
||||
where a crop starts in the resized picture (``crop_x``/``crop_y``) and
|
||||
the size that comes out (``width``/``height``).
|
||||
|
||||
``off`` gives the picture back as it is; ``multiple`` rounds only a size
|
||||
that is being changed. With ``enlarge`` off nothing comes out larger than
|
||||
the picture: a target above it leaves the picture at its own size, and
|
||||
``crop to fill`` cuts the box's shape out of the picture without growing
|
||||
it. ``fit inside`` rounds down, so the result never leaves its box.
|
||||
"""
|
||||
source_w, source_h = int(source_width), int(source_height)
|
||||
if source_w <= 0 or source_h <= 0:
|
||||
raise ValueError("Load Image needs a picture larger than 0x0.")
|
||||
if size not in SIZE_MODES:
|
||||
raise ValueError(f"size must be one of {SIZE_MODES}, got '{size}'.")
|
||||
if size == "off":
|
||||
return {
|
||||
"resize_width": source_w,
|
||||
"resize_height": source_h,
|
||||
"crop_x": 0,
|
||||
"crop_y": 0,
|
||||
"width": source_w,
|
||||
"height": source_h,
|
||||
}
|
||||
step = max(1, int(multiple))
|
||||
|
||||
if size == "crop to fill":
|
||||
box_w, box_h = max(1, int(width)), max(1, int(height))
|
||||
if not enlarge:
|
||||
grow = max(box_w / source_w, box_h / source_h)
|
||||
if grow > 1.0:
|
||||
# The box keeps its shape and shrinks to what the picture holds.
|
||||
box_w = max(1, int(math.floor(box_w / grow + 1e-6)))
|
||||
box_h = max(1, int(math.floor(box_h / grow + 1e-6)))
|
||||
canvas_w = min(_multiple_below(box_w, step), max(step, source_w))
|
||||
canvas_h = min(_multiple_below(box_h, step), max(step, source_h))
|
||||
else:
|
||||
canvas_w = snap_to_multiple(box_w, step)
|
||||
canvas_h = snap_to_multiple(box_h, step)
|
||||
plan = plan_resize(source_w, source_h, canvas_w, canvas_h, "cover_crop", 1)
|
||||
return {
|
||||
"resize_width": plan["resize_width"],
|
||||
"resize_height": plan["resize_height"],
|
||||
"crop_x": plan["crop_x"],
|
||||
"crop_y": plan["crop_y"],
|
||||
"width": canvas_w,
|
||||
"height": canvas_h,
|
||||
}
|
||||
|
||||
if size == "megapixels":
|
||||
target_w, target_h = resolve_target(source_w, source_h, "megapixels", megapixels=megapixels)
|
||||
elif size == "longest side":
|
||||
target_w, target_h = resolve_target(source_w, source_h, "longest_edge", edge_length=longest_side)
|
||||
elif size == "scale":
|
||||
target_w, target_h = resolve_target(source_w, source_h, "scale_factor", scale_factor=scale)
|
||||
else: # fit inside
|
||||
fit = min(max(1, int(width)) / source_w, max(1, int(height)) / source_h)
|
||||
target_w = max(1, _round_half_up(source_w * fit))
|
||||
target_h = max(1, _round_half_up(source_h * fit))
|
||||
if not enlarge and (target_w > source_w or target_h > source_h):
|
||||
target_w, target_h = source_w, source_h
|
||||
|
||||
def rounded(target: int, source: int, box: int) -> int:
|
||||
value = snap_to_multiple(target, step)
|
||||
if size == "fit inside" and value > box:
|
||||
value = _multiple_below(box, step)
|
||||
if not enlarge and value > source:
|
||||
value = _multiple_below(source, step)
|
||||
return value
|
||||
|
||||
out_w = rounded(target_w, source_w, max(1, int(width)))
|
||||
out_h = rounded(target_h, source_h, max(1, int(height)))
|
||||
return {
|
||||
"resize_width": out_w,
|
||||
"resize_height": out_h,
|
||||
"crop_x": 0,
|
||||
"crop_y": 0,
|
||||
"width": out_w,
|
||||
"height": out_h,
|
||||
}
|
||||
|
||||
|
||||
def frames_to_image_and_mask(frames) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""RGBA PIL frames to a BHWC RGB batch and a BHW mask.
|
||||
|
||||
The mask is what ComfyUI's own Load Image gives: white where the picture
|
||||
is see-through, black where it is solid, and all black for a picture
|
||||
with no transparency (here at the picture's own size).
|
||||
"""
|
||||
images, masks = [], []
|
||||
for frame in frames:
|
||||
array = np.asarray(frame.convert("RGBA"), dtype=np.float32) / 255.0
|
||||
images.append(torch.from_numpy(array[..., :3].copy()))
|
||||
masks.append(torch.from_numpy(1.0 - array[..., 3]))
|
||||
return torch.stack(images, dim=0), torch.stack(masks, dim=0)
|
||||
|
||||
|
||||
def apply_load_size(
|
||||
image: torch.Tensor, mask: torch.Tensor, plan: dict[str, int], method: str
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Run a plan on a BHWC picture and its BHW mask. A plan that changes
|
||||
nothing hands both back untouched."""
|
||||
resize_w, resize_h = int(plan["resize_width"]), int(plan["resize_height"])
|
||||
out_w, out_h = int(plan["width"]), int(plan["height"])
|
||||
if (int(image.shape[2]), int(image.shape[1])) != (resize_w, resize_h):
|
||||
image = resample_batch(image, resize_w, resize_h, method)
|
||||
mask = resample_batch(mask.unsqueeze(-1), resize_w, resize_h, method).squeeze(-1).clamp(0.0, 1.0)
|
||||
if (resize_w, resize_h) != (out_w, out_h):
|
||||
x0, y0 = int(plan["crop_x"]), int(plan["crop_y"])
|
||||
image = image[:, y0 : y0 + out_h, x0 : x0 + out_w, :].contiguous()
|
||||
mask = mask[:, y0 : y0 + out_h, x0 : x0 + out_w].contiguous()
|
||||
return image, mask
|
||||
|
||||
|
||||
__all__ = ["SIZE_MODES", "apply_load_size", "frames_to_image_and_mask", "plan_load_size"]
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Load Image 🆎."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ._execution_helpers import ignore_extra_inputs
|
||||
from ._load_image_helpers import (
|
||||
SIZE_MODES,
|
||||
apply_load_size,
|
||||
frames_to_image_and_mask,
|
||||
plan_load_size,
|
||||
)
|
||||
from ._media_helpers import list_input_images, load_image_frames, resolve_input_path
|
||||
from ._resize_helpers import INTERPOLATION_MODES
|
||||
from ._transform_engine import stable_file_fingerprint
|
||||
|
||||
|
||||
class AusBossLoadImage:
|
||||
CATEGORY = "🆎 AusBoss/Image"
|
||||
DESCRIPTION = (
|
||||
"Loads a picture and sets its size in the same node. Pick a file, "
|
||||
"upload one, or step through the input folder with the arrows. "
|
||||
"Size can stay off, or bring the picture to a megapixel count, a "
|
||||
"longest side, a scale, a box it must fit inside, or a box it fills "
|
||||
"by cropping. The node shows the size going in and the size coming "
|
||||
"out before you run."
|
||||
)
|
||||
SEARCH_ALIASES = [
|
||||
"load image",
|
||||
"load picture",
|
||||
"image loader",
|
||||
"resize on load",
|
||||
"megapixels",
|
||||
"longest side",
|
||||
"ausboss",
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": (
|
||||
list_input_images(),
|
||||
{
|
||||
"image_upload": True,
|
||||
"tooltip": "The picture to load. Pick one from ComfyUI's input folder or upload a new one.",
|
||||
},
|
||||
),
|
||||
"size": (
|
||||
list(SIZE_MODES),
|
||||
{
|
||||
"default": "off",
|
||||
"tooltip": (
|
||||
"How the picture is resized. off keeps it as it "
|
||||
"is. megapixels, longest side and scale keep its "
|
||||
"shape. fit inside shrinks or grows it to sit in "
|
||||
"a box. crop to fill covers the box and cuts off "
|
||||
"what sticks out."
|
||||
),
|
||||
},
|
||||
),
|
||||
"megapixels": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 1.0,
|
||||
"min": 0.01,
|
||||
"max": 64.0,
|
||||
"step": 0.01,
|
||||
"tooltip": "For megapixels: the picture's total size. 1.0 is about 1024 x 1024.",
|
||||
},
|
||||
),
|
||||
"longest_side": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1024,
|
||||
"min": 16,
|
||||
"max": 16384,
|
||||
"step": 1,
|
||||
"tooltip": "For longest side: the length of the longer edge, in pixels. The other edge follows.",
|
||||
},
|
||||
),
|
||||
"scale": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 1.0,
|
||||
"min": 0.05,
|
||||
"max": 8.0,
|
||||
"step": 0.01,
|
||||
"tooltip": "For scale: 0.5 is half the size, 2 is double.",
|
||||
},
|
||||
),
|
||||
"width": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1024,
|
||||
"min": 16,
|
||||
"max": 16384,
|
||||
"step": 1,
|
||||
"tooltip": "For fit inside and crop to fill: the box's width in pixels.",
|
||||
},
|
||||
),
|
||||
"height": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1024,
|
||||
"min": 16,
|
||||
"max": 16384,
|
||||
"step": 1,
|
||||
"tooltip": "For fit inside and crop to fill: the box's height in pixels.",
|
||||
},
|
||||
),
|
||||
"multiple": (
|
||||
"INT",
|
||||
{
|
||||
"default": 8,
|
||||
"min": 1,
|
||||
"max": 256,
|
||||
"step": 1,
|
||||
"tooltip": (
|
||||
"Rounds the new width and height to a multiple of "
|
||||
"this. Most models want 8 or 16. 1 leaves the "
|
||||
"numbers as they are. Not used while size is off."
|
||||
),
|
||||
},
|
||||
),
|
||||
"method": (
|
||||
list(INTERPOLATION_MODES),
|
||||
{
|
||||
"default": "lanczos",
|
||||
"tooltip": "How pixels are resampled. lanczos is the sharpest for photos; nearest keeps pixel art crisp.",
|
||||
},
|
||||
),
|
||||
"enlarge": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": (
|
||||
"On: a small picture is made bigger to reach the "
|
||||
"size you asked for. Off: a picture is only ever "
|
||||
"made smaller, and a small one comes out at its "
|
||||
"own size."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING", "INT", "INT")
|
||||
RETURN_NAMES = (
|
||||
"image",
|
||||
"mask",
|
||||
"width",
|
||||
"height",
|
||||
"filename",
|
||||
"original_width",
|
||||
"original_height",
|
||||
)
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"The picture, at its new size.",
|
||||
"White where the picture is see-through, black where it is solid. "
|
||||
"All black for a picture with no transparency. Same size as the picture.",
|
||||
"Width of the picture that comes out, in pixels.",
|
||||
"Height of the picture that comes out, in pixels.",
|
||||
"The file's name without its folder or its ending, ready for a save "
|
||||
"node's prefix.",
|
||||
"Width of the file before any resize.",
|
||||
"Height of the file before any resize.",
|
||||
)
|
||||
FUNCTION = "load"
|
||||
|
||||
def load(
|
||||
self,
|
||||
image,
|
||||
size="off",
|
||||
megapixels=1.0,
|
||||
longest_side=1024,
|
||||
scale=1.0,
|
||||
width=1024,
|
||||
height=1024,
|
||||
multiple=8,
|
||||
method="lanczos",
|
||||
enlarge=True,
|
||||
):
|
||||
path = resolve_input_path(image)
|
||||
picture, mask = frames_to_image_and_mask(load_image_frames(path))
|
||||
source_w, source_h = int(picture.shape[2]), int(picture.shape[1])
|
||||
plan = plan_load_size(
|
||||
source_w,
|
||||
source_h,
|
||||
str(size),
|
||||
float(megapixels),
|
||||
int(longest_side),
|
||||
float(scale),
|
||||
int(width),
|
||||
int(height),
|
||||
int(multiple),
|
||||
bool(enlarge),
|
||||
)
|
||||
picture, mask = apply_load_size(picture, mask, plan, str(method))
|
||||
return (
|
||||
picture,
|
||||
mask,
|
||||
int(plan["width"]),
|
||||
int(plan["height"]),
|
||||
path.stem,
|
||||
source_w,
|
||||
source_h,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@ignore_extra_inputs
|
||||
def VALIDATE_INPUTS(cls, image):
|
||||
try:
|
||||
resolve_input_path(image)
|
||||
except Exception as exc:
|
||||
return f"Load Image: {exc}"
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, image, **values):
|
||||
try:
|
||||
path = resolve_input_path(image)
|
||||
except Exception:
|
||||
path = image or ""
|
||||
return stable_file_fingerprint(path, {"image": image, **values})
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_LoadImage": AusBossLoadImage}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_LoadImage": "Load Image 🆎"}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
+44
-78
@@ -6,20 +6,26 @@ 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 +68,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 +277,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 +310,6 @@ class AusBossSaveImage:
|
||||
name_batch=False,
|
||||
preview=True,
|
||||
step_preview=STEP_PREVIEW_DEFAULT,
|
||||
autosave=True,
|
||||
prompt=None,
|
||||
extra_pnginfo=None,
|
||||
):
|
||||
@@ -376,46 +343,45 @@ 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),
|
||||
)
|
||||
return {
|
||||
"ui": {"images": previews, "ausboss_saved_path": [], "ausboss_held": [token]},
|
||||
"result": ("", images),
|
||||
}
|
||||
|
||||
with IMAGE_SAVE_LOCK:
|
||||
planned: list[tuple[Path, str]] = [] # (path, collision policy)
|
||||
if exact:
|
||||
base = strip_image_extension(exact)
|
||||
for name in plan_exact_names(base, extension, count):
|
||||
planned.append((root / name, on_existing))
|
||||
else:
|
||||
subfolder, base = split_prefix(filename_prefix)
|
||||
folder = root / subfolder if subfolder else root
|
||||
require_output_path(folder, output_root)
|
||||
stem = name_stem(
|
||||
base,
|
||||
{"date": bool(name_date), "time": bool(name_time), "size": bool(name_size)},
|
||||
now,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
counter = bool(name_counter)
|
||||
existing = [entry.name for entry in folder.iterdir()] if folder.is_dir() else []
|
||||
names = plan_local_names(
|
||||
stem, extension, count, counter=counter, batch=bool(name_batch),
|
||||
next_counter=next_free_counter(existing, stem),
|
||||
)
|
||||
# The shared lock keeps this counter free until writing finishes.
|
||||
# Without it, the legacy policy decides whether to overwrite.
|
||||
policy = "overwrite" if counter else on_existing
|
||||
planned.extend((folder / name, policy) for name in names)
|
||||
|
||||
# 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)
|
||||
for path, _policy in planned:
|
||||
require_output_path(path, output_root)
|
||||
if caption_value.strip():
|
||||
require_output_path(sidecar_path(path), output_root)
|
||||
actions = [
|
||||
existing_action(path.exists() or (bool(caption_value.strip()) and sidecar_path(path).exists()), policy)
|
||||
for path, policy in planned
|
||||
]
|
||||
|
||||
saved: list[Path] = []
|
||||
for (path, _policy), action, frame in zip(planned, actions, images):
|
||||
@@ -423,7 +389,7 @@ class AusBossSaveImage:
|
||||
continue
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
encode_image(path, frame, format, metadata)
|
||||
if has_caption:
|
||||
if caption_value.strip():
|
||||
sidecar_path(path).write_text(caption_value, encoding="utf-8")
|
||||
saved.append(path)
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ PUBLIC_NODE_IDS = {
|
||||
"AUSBOSS_NODES_LaMaInpaint",
|
||||
"AUSBOSS_NODES_LayoutBoxes",
|
||||
"AUSBOSS_NODES_LoadAudio",
|
||||
"AUSBOSS_NODES_LoadImage",
|
||||
"AUSBOSS_NODES_LoadImagePad",
|
||||
"AUSBOSS_NODES_LoadVideo",
|
||||
"AUSBOSS_NODES_LoraLoader",
|
||||
|
||||
Vendored
+256
@@ -0,0 +1,256 @@
|
||||
{"about":"Load Image size plans. nodes/_load_image_helpers.py made these; js/shared/load_size.mjs must give the same.",
|
||||
"columns":["source_width", "source_height", "size", "megapixels", "longest_side", "scale", "width", "height", "multiple", "enlarge", "resize_width", "resize_height", "crop_x", "crop_y", "out_width", "out_height"],
|
||||
"rows":[
|
||||
[912,1632,"off",1.0,1024,1.0,1024,1024,1,true,912,1632,0,0,912,1632],
|
||||
[912,1632,"off",1.0,1024,1.0,1024,1024,16,true,912,1632,0,0,912,1632],
|
||||
[912,1632,"megapixels",0.25,1024,1.0,1024,1024,1,true,374,669,0,0,374,669],
|
||||
[912,1632,"megapixels",2.0,1024,1.0,1024,1024,1,true,1057,1892,0,0,1057,1892],
|
||||
[912,1632,"longest side",1.0,1000,1.0,1024,1024,1,true,559,1000,0,0,559,1000],
|
||||
[912,1632,"longest side",1.0,2048,1.0,1024,1024,1,true,1144,2048,0,0,1144,2048],
|
||||
[912,1632,"scale",1.0,1024,0.33,1024,1024,1,true,301,539,0,0,301,539],
|
||||
[912,1632,"scale",1.0,1024,1.5,1024,1024,1,true,1368,2448,0,0,1368,2448],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1024,1024,1,true,572,1024,0,0,572,1024],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,1,true,1024,1833,0,404,1024,1024],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1280,720,1,true,402,720,0,0,402,720],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1280,720,1,true,1280,2291,0,785,1280,720],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1000,600,1,true,335,600,0,0,335,600],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1000,600,1,true,1000,1790,0,595,1000,600],
|
||||
[912,1632,"megapixels",0.25,1024,1.0,1024,1024,1,false,374,669,0,0,374,669],
|
||||
[912,1632,"megapixels",2.0,1024,1.0,1024,1024,1,false,912,1632,0,0,912,1632],
|
||||
[912,1632,"longest side",1.0,1000,1.0,1024,1024,1,false,559,1000,0,0,559,1000],
|
||||
[912,1632,"longest side",1.0,2048,1.0,1024,1024,1,false,912,1632,0,0,912,1632],
|
||||
[912,1632,"scale",1.0,1024,0.33,1024,1024,1,false,301,539,0,0,301,539],
|
||||
[912,1632,"scale",1.0,1024,1.5,1024,1024,1,false,912,1632,0,0,912,1632],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1024,1024,1,false,572,1024,0,0,572,1024],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,1,false,912,1632,0,360,912,912],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1280,720,1,false,402,720,0,0,402,720],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1280,720,1,false,912,1632,0,559,912,513],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1000,600,1,false,335,600,0,0,335,600],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1000,600,1,false,912,1632,0,542,912,547],
|
||||
[912,1632,"megapixels",0.25,1024,1.0,1024,1024,16,true,368,672,0,0,368,672],
|
||||
[912,1632,"megapixels",2.0,1024,1.0,1024,1024,16,true,1056,1888,0,0,1056,1888],
|
||||
[912,1632,"longest side",1.0,1000,1.0,1024,1024,16,true,560,1008,0,0,560,1008],
|
||||
[912,1632,"longest side",1.0,2048,1.0,1024,1024,16,true,1152,2048,0,0,1152,2048],
|
||||
[912,1632,"scale",1.0,1024,0.33,1024,1024,16,true,304,544,0,0,304,544],
|
||||
[912,1632,"scale",1.0,1024,1.5,1024,1024,16,true,1376,2448,0,0,1376,2448],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1024,1024,16,true,576,1024,0,0,576,1024],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,16,true,1024,1833,0,404,1024,1024],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1280,720,16,true,400,720,0,0,400,720],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1280,720,16,true,1280,2291,0,785,1280,720],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1000,600,16,true,336,592,0,0,336,592],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1000,600,16,true,1008,1804,0,598,1008,608],
|
||||
[912,1632,"megapixels",0.25,1024,1.0,1024,1024,16,false,368,672,0,0,368,672],
|
||||
[912,1632,"megapixels",2.0,1024,1.0,1024,1024,16,false,912,1632,0,0,912,1632],
|
||||
[912,1632,"longest side",1.0,1000,1.0,1024,1024,16,false,560,1008,0,0,560,1008],
|
||||
[912,1632,"longest side",1.0,2048,1.0,1024,1024,16,false,912,1632,0,0,912,1632],
|
||||
[912,1632,"scale",1.0,1024,0.33,1024,1024,16,false,304,544,0,0,304,544],
|
||||
[912,1632,"scale",1.0,1024,1.5,1024,1024,16,false,912,1632,0,0,912,1632],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1024,1024,16,false,576,1024,0,0,576,1024],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,16,false,912,1632,0,360,912,912],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1280,720,16,false,400,720,0,0,400,720],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1280,720,16,false,912,1632,0,560,912,512],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1000,600,16,false,336,592,0,0,336,592],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1000,600,16,false,912,1632,0,544,912,544],
|
||||
[1920,1080,"off",1.0,1024,1.0,1024,1024,1,true,1920,1080,0,0,1920,1080],
|
||||
[1920,1080,"off",1.0,1024,1.0,1024,1024,16,true,1920,1080,0,0,1920,1080],
|
||||
[1920,1080,"megapixels",0.25,1024,1.0,1024,1024,1,true,667,375,0,0,667,375],
|
||||
[1920,1080,"megapixels",2.0,1024,1.0,1024,1024,1,true,1886,1061,0,0,1886,1061],
|
||||
[1920,1080,"longest side",1.0,1000,1.0,1024,1024,1,true,1000,563,0,0,1000,563],
|
||||
[1920,1080,"longest side",1.0,2048,1.0,1024,1024,1,true,2048,1152,0,0,2048,1152],
|
||||
[1920,1080,"scale",1.0,1024,0.33,1024,1024,1,true,634,356,0,0,634,356],
|
||||
[1920,1080,"scale",1.0,1024,1.5,1024,1024,1,true,2880,1620,0,0,2880,1620],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1024,1024,1,true,1024,576,0,0,1024,576],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,1,true,1821,1024,398,0,1024,1024],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1280,720,1,true,1280,720,0,0,1280,720],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1280,720,1,true,1280,720,0,0,1280,720],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1000,600,1,true,1000,563,0,0,1000,563],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1000,600,1,true,1067,600,33,0,1000,600],
|
||||
[1920,1080,"megapixels",0.25,1024,1.0,1024,1024,1,false,667,375,0,0,667,375],
|
||||
[1920,1080,"megapixels",2.0,1024,1.0,1024,1024,1,false,1886,1061,0,0,1886,1061],
|
||||
[1920,1080,"longest side",1.0,1000,1.0,1024,1024,1,false,1000,563,0,0,1000,563],
|
||||
[1920,1080,"longest side",1.0,2048,1.0,1024,1024,1,false,1920,1080,0,0,1920,1080],
|
||||
[1920,1080,"scale",1.0,1024,0.33,1024,1024,1,false,634,356,0,0,634,356],
|
||||
[1920,1080,"scale",1.0,1024,1.5,1024,1024,1,false,1920,1080,0,0,1920,1080],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1024,1024,1,false,1024,576,0,0,1024,576],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,1,false,1821,1024,398,0,1024,1024],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1280,720,1,false,1280,720,0,0,1280,720],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1280,720,1,false,1280,720,0,0,1280,720],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1000,600,1,false,1000,563,0,0,1000,563],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1000,600,1,false,1067,600,33,0,1000,600],
|
||||
[1920,1080,"megapixels",0.25,1024,1.0,1024,1024,16,true,672,368,0,0,672,368],
|
||||
[1920,1080,"megapixels",2.0,1024,1.0,1024,1024,16,true,1888,1056,0,0,1888,1056],
|
||||
[1920,1080,"longest side",1.0,1000,1.0,1024,1024,16,true,1008,560,0,0,1008,560],
|
||||
[1920,1080,"longest side",1.0,2048,1.0,1024,1024,16,true,2048,1152,0,0,2048,1152],
|
||||
[1920,1080,"scale",1.0,1024,0.33,1024,1024,16,true,640,352,0,0,640,352],
|
||||
[1920,1080,"scale",1.0,1024,1.5,1024,1024,16,true,2880,1616,0,0,2880,1616],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1024,1024,16,true,1024,576,0,0,1024,576],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,16,true,1821,1024,398,0,1024,1024],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1280,720,16,true,1280,720,0,0,1280,720],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1280,720,16,true,1280,720,0,0,1280,720],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1000,600,16,true,992,560,0,0,992,560],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1000,600,16,true,1081,608,36,0,1008,608],
|
||||
[1920,1080,"megapixels",0.25,1024,1.0,1024,1024,16,false,672,368,0,0,672,368],
|
||||
[1920,1080,"megapixels",2.0,1024,1.0,1024,1024,16,false,1888,1056,0,0,1888,1056],
|
||||
[1920,1080,"longest side",1.0,1000,1.0,1024,1024,16,false,1008,560,0,0,1008,560],
|
||||
[1920,1080,"longest side",1.0,2048,1.0,1024,1024,16,false,1920,1072,0,0,1920,1072],
|
||||
[1920,1080,"scale",1.0,1024,0.33,1024,1024,16,false,640,352,0,0,640,352],
|
||||
[1920,1080,"scale",1.0,1024,1.5,1024,1024,16,false,1920,1072,0,0,1920,1072],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1024,1024,16,false,1024,576,0,0,1024,576],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,16,false,1821,1024,398,0,1024,1024],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1280,720,16,false,1280,720,0,0,1280,720],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1280,720,16,false,1280,720,0,0,1280,720],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1000,600,16,false,992,560,0,0,992,560],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1000,600,16,false,1053,592,30,0,992,592],
|
||||
[640,480,"off",1.0,1024,1.0,1024,1024,1,true,640,480,0,0,640,480],
|
||||
[640,480,"off",1.0,1024,1.0,1024,1024,16,true,640,480,0,0,640,480],
|
||||
[640,480,"megapixels",0.25,1024,1.0,1024,1024,1,true,577,433,0,0,577,433],
|
||||
[640,480,"megapixels",2.0,1024,1.0,1024,1024,1,true,1633,1225,0,0,1633,1225],
|
||||
[640,480,"longest side",1.0,1000,1.0,1024,1024,1,true,1000,750,0,0,1000,750],
|
||||
[640,480,"longest side",1.0,2048,1.0,1024,1024,1,true,2048,1536,0,0,2048,1536],
|
||||
[640,480,"scale",1.0,1024,0.33,1024,1024,1,true,211,158,0,0,211,158],
|
||||
[640,480,"scale",1.0,1024,1.5,1024,1024,1,true,960,720,0,0,960,720],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1024,1024,1,true,1024,768,0,0,1024,768],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,1,true,1366,1024,171,0,1024,1024],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1280,720,1,true,960,720,0,0,960,720],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1280,720,1,true,1280,960,0,120,1280,720],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1000,600,1,true,800,600,0,0,800,600],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1000,600,1,true,1000,750,0,75,1000,600],
|
||||
[640,480,"megapixels",0.25,1024,1.0,1024,1024,1,false,577,433,0,0,577,433],
|
||||
[640,480,"megapixels",2.0,1024,1.0,1024,1024,1,false,640,480,0,0,640,480],
|
||||
[640,480,"longest side",1.0,1000,1.0,1024,1024,1,false,640,480,0,0,640,480],
|
||||
[640,480,"longest side",1.0,2048,1.0,1024,1024,1,false,640,480,0,0,640,480],
|
||||
[640,480,"scale",1.0,1024,0.33,1024,1024,1,false,211,158,0,0,211,158],
|
||||
[640,480,"scale",1.0,1024,1.5,1024,1024,1,false,640,480,0,0,640,480],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1024,1024,1,false,640,480,0,0,640,480],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,1,false,640,480,80,0,480,480],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1280,720,1,false,640,480,0,0,640,480],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1280,720,1,false,640,480,0,60,640,360],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1000,600,1,false,640,480,0,0,640,480],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1000,600,1,false,640,480,0,48,640,384],
|
||||
[640,480,"megapixels",0.25,1024,1.0,1024,1024,16,true,576,432,0,0,576,432],
|
||||
[640,480,"megapixels",2.0,1024,1.0,1024,1024,16,true,1632,1232,0,0,1632,1232],
|
||||
[640,480,"longest side",1.0,1000,1.0,1024,1024,16,true,1008,752,0,0,1008,752],
|
||||
[640,480,"longest side",1.0,2048,1.0,1024,1024,16,true,2048,1536,0,0,2048,1536],
|
||||
[640,480,"scale",1.0,1024,0.33,1024,1024,16,true,208,160,0,0,208,160],
|
||||
[640,480,"scale",1.0,1024,1.5,1024,1024,16,true,960,720,0,0,960,720],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1024,1024,16,true,1024,768,0,0,1024,768],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,16,true,1366,1024,171,0,1024,1024],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1280,720,16,true,960,720,0,0,960,720],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1280,720,16,true,1280,960,0,120,1280,720],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1000,600,16,true,800,592,0,0,800,592],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1000,600,16,true,1008,756,0,74,1008,608],
|
||||
[640,480,"megapixels",0.25,1024,1.0,1024,1024,16,false,576,432,0,0,576,432],
|
||||
[640,480,"megapixels",2.0,1024,1.0,1024,1024,16,false,640,480,0,0,640,480],
|
||||
[640,480,"longest side",1.0,1000,1.0,1024,1024,16,false,640,480,0,0,640,480],
|
||||
[640,480,"longest side",1.0,2048,1.0,1024,1024,16,false,640,480,0,0,640,480],
|
||||
[640,480,"scale",1.0,1024,0.33,1024,1024,16,false,208,160,0,0,208,160],
|
||||
[640,480,"scale",1.0,1024,1.5,1024,1024,16,false,640,480,0,0,640,480],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1024,1024,16,false,640,480,0,0,640,480],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,16,false,640,480,80,0,480,480],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1280,720,16,false,640,480,0,0,640,480],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1280,720,16,false,640,480,0,64,640,352],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1000,600,16,false,640,480,0,0,640,480],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1000,600,16,false,640,480,0,48,640,384],
|
||||
[333,777,"off",1.0,1024,1.0,1024,1024,1,true,333,777,0,0,333,777],
|
||||
[333,777,"off",1.0,1024,1.0,1024,1024,16,true,333,777,0,0,333,777],
|
||||
[333,777,"megapixels",0.25,1024,1.0,1024,1024,1,true,327,764,0,0,327,764],
|
||||
[333,777,"megapixels",2.0,1024,1.0,1024,1024,1,true,926,2160,0,0,926,2160],
|
||||
[333,777,"longest side",1.0,1000,1.0,1024,1024,1,true,429,1000,0,0,429,1000],
|
||||
[333,777,"longest side",1.0,2048,1.0,1024,1024,1,true,878,2048,0,0,878,2048],
|
||||
[333,777,"scale",1.0,1024,0.33,1024,1024,1,true,110,256,0,0,110,256],
|
||||
[333,777,"scale",1.0,1024,1.5,1024,1024,1,true,500,1166,0,0,500,1166],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1024,1024,1,true,439,1024,0,0,439,1024],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1024,1024,1,true,1024,2390,0,683,1024,1024],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1280,720,1,true,309,720,0,0,309,720],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1280,720,1,true,1280,2987,0,1133,1280,720],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1000,600,1,true,257,600,0,0,257,600],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1000,600,1,true,1000,2334,0,867,1000,600],
|
||||
[333,777,"megapixels",0.25,1024,1.0,1024,1024,1,false,327,764,0,0,327,764],
|
||||
[333,777,"megapixels",2.0,1024,1.0,1024,1024,1,false,333,777,0,0,333,777],
|
||||
[333,777,"longest side",1.0,1000,1.0,1024,1024,1,false,333,777,0,0,333,777],
|
||||
[333,777,"longest side",1.0,2048,1.0,1024,1024,1,false,333,777,0,0,333,777],
|
||||
[333,777,"scale",1.0,1024,0.33,1024,1024,1,false,110,256,0,0,110,256],
|
||||
[333,777,"scale",1.0,1024,1.5,1024,1024,1,false,333,777,0,0,333,777],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1024,1024,1,false,333,777,0,0,333,777],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1024,1024,1,false,333,777,0,222,333,333],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1280,720,1,false,309,720,0,0,309,720],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1280,720,1,false,333,777,0,295,333,187],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1000,600,1,false,257,600,0,0,257,600],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1000,600,1,false,333,777,0,289,333,199],
|
||||
[333,777,"megapixels",0.25,1024,1.0,1024,1024,16,true,320,768,0,0,320,768],
|
||||
[333,777,"megapixels",2.0,1024,1.0,1024,1024,16,true,928,2160,0,0,928,2160],
|
||||
[333,777,"longest side",1.0,1000,1.0,1024,1024,16,true,432,1008,0,0,432,1008],
|
||||
[333,777,"longest side",1.0,2048,1.0,1024,1024,16,true,880,2048,0,0,880,2048],
|
||||
[333,777,"scale",1.0,1024,0.33,1024,1024,16,true,112,256,0,0,112,256],
|
||||
[333,777,"scale",1.0,1024,1.5,1024,1024,16,true,496,1168,0,0,496,1168],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1024,1024,16,true,432,1024,0,0,432,1024],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1024,1024,16,true,1024,2390,0,683,1024,1024],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1280,720,16,true,304,720,0,0,304,720],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1280,720,16,true,1280,2987,0,1133,1280,720],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1000,600,16,true,256,592,0,0,256,592],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1000,600,16,true,1008,2352,0,872,1008,608],
|
||||
[333,777,"megapixels",0.25,1024,1.0,1024,1024,16,false,320,768,0,0,320,768],
|
||||
[333,777,"megapixels",2.0,1024,1.0,1024,1024,16,false,320,768,0,0,320,768],
|
||||
[333,777,"longest side",1.0,1000,1.0,1024,1024,16,false,320,768,0,0,320,768],
|
||||
[333,777,"longest side",1.0,2048,1.0,1024,1024,16,false,320,768,0,0,320,768],
|
||||
[333,777,"scale",1.0,1024,0.33,1024,1024,16,false,112,256,0,0,112,256],
|
||||
[333,777,"scale",1.0,1024,1.5,1024,1024,16,false,320,768,0,0,320,768],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1024,1024,16,false,320,768,0,0,320,768],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1024,1024,16,false,320,747,0,213,320,320],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1280,720,16,false,304,720,0,0,304,720],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1280,720,16,false,320,747,0,285,320,176],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1000,600,16,false,256,592,0,0,256,592],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1000,600,16,false,320,747,0,277,320,192],
|
||||
[8192,5464,"off",1.0,1024,1.0,1024,1024,1,true,8192,5464,0,0,8192,5464],
|
||||
[8192,5464,"off",1.0,1024,1.0,1024,1024,16,true,8192,5464,0,0,8192,5464],
|
||||
[8192,5464,"megapixels",0.25,1024,1.0,1024,1024,1,true,612,408,0,0,612,408],
|
||||
[8192,5464,"megapixels",2.0,1024,1.0,1024,1024,1,true,1732,1155,0,0,1732,1155],
|
||||
[8192,5464,"longest side",1.0,1000,1.0,1024,1024,1,true,1000,667,0,0,1000,667],
|
||||
[8192,5464,"longest side",1.0,2048,1.0,1024,1024,1,true,2048,1366,0,0,2048,1366],
|
||||
[8192,5464,"scale",1.0,1024,0.33,1024,1024,1,true,2703,1803,0,0,2703,1803],
|
||||
[8192,5464,"scale",1.0,1024,1.5,1024,1024,1,true,12288,8196,0,0,12288,8196],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1024,1024,1,true,1024,683,0,0,1024,683],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1024,1024,1,true,1536,1024,256,0,1024,1024],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1280,720,1,true,1079,720,0,0,1079,720],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1280,720,1,true,1280,854,0,67,1280,720],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1000,600,1,true,900,600,0,0,900,600],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1000,600,1,true,1000,667,0,33,1000,600],
|
||||
[8192,5464,"megapixels",0.25,1024,1.0,1024,1024,1,false,612,408,0,0,612,408],
|
||||
[8192,5464,"megapixels",2.0,1024,1.0,1024,1024,1,false,1732,1155,0,0,1732,1155],
|
||||
[8192,5464,"longest side",1.0,1000,1.0,1024,1024,1,false,1000,667,0,0,1000,667],
|
||||
[8192,5464,"longest side",1.0,2048,1.0,1024,1024,1,false,2048,1366,0,0,2048,1366],
|
||||
[8192,5464,"scale",1.0,1024,0.33,1024,1024,1,false,2703,1803,0,0,2703,1803],
|
||||
[8192,5464,"scale",1.0,1024,1.5,1024,1024,1,false,8192,5464,0,0,8192,5464],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1024,1024,1,false,1024,683,0,0,1024,683],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1024,1024,1,false,1536,1024,256,0,1024,1024],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1280,720,1,false,1079,720,0,0,1079,720],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1280,720,1,false,1280,854,0,67,1280,720],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1000,600,1,false,900,600,0,0,900,600],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1000,600,1,false,1000,667,0,33,1000,600],
|
||||
[8192,5464,"megapixels",0.25,1024,1.0,1024,1024,16,true,608,416,0,0,608,416],
|
||||
[8192,5464,"megapixels",2.0,1024,1.0,1024,1024,16,true,1728,1152,0,0,1728,1152],
|
||||
[8192,5464,"longest side",1.0,1000,1.0,1024,1024,16,true,1008,672,0,0,1008,672],
|
||||
[8192,5464,"longest side",1.0,2048,1.0,1024,1024,16,true,2048,1360,0,0,2048,1360],
|
||||
[8192,5464,"scale",1.0,1024,0.33,1024,1024,16,true,2704,1808,0,0,2704,1808],
|
||||
[8192,5464,"scale",1.0,1024,1.5,1024,1024,16,true,12288,8192,0,0,12288,8192],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1024,1024,16,true,1024,688,0,0,1024,688],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1024,1024,16,true,1536,1024,256,0,1024,1024],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1280,720,16,true,1072,720,0,0,1072,720],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1280,720,16,true,1280,854,0,67,1280,720],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1000,600,16,true,896,592,0,0,896,592],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1000,600,16,true,1008,673,0,32,1008,608],
|
||||
[8192,5464,"megapixels",0.25,1024,1.0,1024,1024,16,false,608,416,0,0,608,416],
|
||||
[8192,5464,"megapixels",2.0,1024,1.0,1024,1024,16,false,1728,1152,0,0,1728,1152],
|
||||
[8192,5464,"longest side",1.0,1000,1.0,1024,1024,16,false,1008,672,0,0,1008,672],
|
||||
[8192,5464,"longest side",1.0,2048,1.0,1024,1024,16,false,2048,1360,0,0,2048,1360],
|
||||
[8192,5464,"scale",1.0,1024,0.33,1024,1024,16,false,2704,1808,0,0,2704,1808],
|
||||
[8192,5464,"scale",1.0,1024,1.5,1024,1024,16,false,8192,5456,0,0,8192,5456],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1024,1024,16,false,1024,688,0,0,1024,688],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1024,1024,16,false,1536,1024,256,0,1024,1024],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1280,720,16,false,1072,720,0,0,1072,720],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1280,720,16,false,1280,854,0,67,1280,720],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1000,600,16,false,896,592,0,0,896,592],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1000,600,16,false,992,662,0,35,992,592],
|
||||
[1000,1000,"longest side",1.0,1000,1.0,1024,1024,64,true,1024,1024,0,0,1024,1024],
|
||||
[30,20,"megapixels",1.0,1024,1.0,1024,1024,64,false,64,64,0,0,64,64]
|
||||
]}
|
||||
Vendored
+46
-2
@@ -566,6 +566,51 @@
|
||||
]
|
||||
]
|
||||
},
|
||||
"AUSBOSS_NODES_LoadImage": {
|
||||
"required": [
|
||||
"image",
|
||||
"size",
|
||||
"megapixels",
|
||||
"longest_side",
|
||||
"scale",
|
||||
"width",
|
||||
"height",
|
||||
"multiple",
|
||||
"method",
|
||||
"enlarge"
|
||||
],
|
||||
"optional": [],
|
||||
"outputs": [
|
||||
[
|
||||
"IMAGE",
|
||||
"image"
|
||||
],
|
||||
[
|
||||
"MASK",
|
||||
"mask"
|
||||
],
|
||||
[
|
||||
"INT",
|
||||
"width"
|
||||
],
|
||||
[
|
||||
"INT",
|
||||
"height"
|
||||
],
|
||||
[
|
||||
"STRING",
|
||||
"filename"
|
||||
],
|
||||
[
|
||||
"INT",
|
||||
"original_width"
|
||||
],
|
||||
[
|
||||
"INT",
|
||||
"original_height"
|
||||
]
|
||||
]
|
||||
},
|
||||
"AUSBOSS_NODES_LoadImagePad": {
|
||||
"required": [
|
||||
"image",
|
||||
@@ -886,8 +931,7 @@
|
||||
"name_size",
|
||||
"name_batch",
|
||||
"preview",
|
||||
"step_preview",
|
||||
"autosave"
|
||||
"step_preview"
|
||||
],
|
||||
"outputs": [
|
||||
[
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { SIZE_MODES, placeText, planLoadSize, sizeText, stepFile } from "../js/shared/load_size.mjs";
|
||||
|
||||
const table = JSON.parse(readFileSync(new URL("./fixtures/load_size_cases.json", import.meta.url), "utf8"));
|
||||
|
||||
test("the size the node shows is the size the run gives, case by case", () => {
|
||||
assert.ok(table.rows.length > 200);
|
||||
const seen = new Set();
|
||||
for (const row of table.rows) {
|
||||
const [sourceWidth, sourceHeight, size, megapixels, longest_side, scale, width, height, multiple, enlarge, ...wanted] = row;
|
||||
seen.add(size);
|
||||
const plan = planLoadSize(sourceWidth, sourceHeight, { size, megapixels, longest_side, scale, width, height, multiple, enlarge });
|
||||
assert.deepEqual(
|
||||
[plan.resizeWidth, plan.resizeHeight, plan.cropX, plan.cropY, plan.width, plan.height],
|
||||
wanted,
|
||||
JSON.stringify(row.slice(0, 10)),
|
||||
);
|
||||
}
|
||||
assert.deepEqual([...seen].sort(), [...SIZE_MODES].sort(), "every size choice is in the table");
|
||||
});
|
||||
|
||||
test("off gives the picture back whatever the other settings say", () => {
|
||||
const plan = planLoadSize(913, 1631, { size: "off", multiple: 64, megapixels: 4, enlarge: false });
|
||||
assert.deepEqual([plan.width, plan.height, plan.cropX, plan.cropY], [913, 1631, 0, 0]);
|
||||
});
|
||||
|
||||
test("longest side lands on the number asked for, and round to moves it to a multiple", () => {
|
||||
assert.deepEqual(planLoadSize(912, 1632, { size: "longest side", longest_side: 2048, multiple: 1 }).height, 2048);
|
||||
const rounded = planLoadSize(912, 1632, { size: "longest side", longest_side: 2000, multiple: 16 });
|
||||
assert.equal(rounded.height, 2000);
|
||||
assert.equal(rounded.width % 16, 0);
|
||||
});
|
||||
|
||||
test("with enlarge off a small picture keeps its size, and crop to fill only cuts", () => {
|
||||
const kept = planLoadSize(640, 480, { size: "megapixels", megapixels: 4, multiple: 1, enlarge: false });
|
||||
assert.deepEqual([kept.width, kept.height], [640, 480]);
|
||||
const cut = planLoadSize(800, 600, { size: "crop to fill", width: 1024, height: 1024, multiple: 1, enlarge: false });
|
||||
assert.deepEqual([cut.width, cut.height, cut.resizeWidth, cut.resizeHeight, cut.cropX, cut.cropY], [600, 600, 800, 600, 100, 0]);
|
||||
});
|
||||
|
||||
test("fit inside never leaves its box, rounding included", () => {
|
||||
const plan = planLoadSize(1920, 1080, { size: "fit inside", width: 1000, height: 1000, multiple: 64 });
|
||||
assert.ok(plan.width <= 1000 && plan.height <= 1000);
|
||||
assert.equal(plan.width % 64, 0);
|
||||
});
|
||||
|
||||
test("a picture with no size yet, or a choice the node does not know, has no plan", () => {
|
||||
assert.equal(planLoadSize(0, 0, { size: "scale" }), null);
|
||||
assert.equal(planLoadSize(100, 100, { size: "" }), null);
|
||||
assert.equal(planLoadSize(100, 100, { size: "stretch" }), null);
|
||||
});
|
||||
|
||||
test("the size line shows one size when nothing changes and both when it does", () => {
|
||||
assert.equal(sizeText(912, 1632, planLoadSize(912, 1632, { size: "off" })), "912 × 1632");
|
||||
assert.equal(
|
||||
sizeText(912, 1632, planLoadSize(912, 1632, { size: "longest side", longest_side: 2048, multiple: 8 })),
|
||||
"912 × 1632 → 1144 × 2048",
|
||||
);
|
||||
assert.equal(sizeText(0, 0, null), "");
|
||||
assert.equal(sizeText(912, 1632, null), "912 × 1632");
|
||||
});
|
||||
|
||||
test("the arrows go round the list, and a file that is not listed steps onto it", () => {
|
||||
const files = ["a.png", "b.png", "sub/c.png"];
|
||||
assert.equal(stepFile(files, "a.png", 1), "b.png");
|
||||
assert.equal(stepFile(files, "sub/c.png", 1), "a.png");
|
||||
assert.equal(stepFile(files, "a.png", -1), "sub/c.png");
|
||||
assert.equal(stepFile(files, "gone.png", 1), "a.png");
|
||||
assert.equal(stepFile(files, "gone.png", -1), "sub/c.png");
|
||||
assert.equal(stepFile(["only.png"], "only.png", 1), null);
|
||||
assert.equal(stepFile([""], "", 1), null, "an empty input folder lists one blank");
|
||||
assert.equal(stepFile(undefined, "a.png", 1), null);
|
||||
});
|
||||
|
||||
test("the place in the list counts from one", () => {
|
||||
const files = ["a.png", "b.png", "sub/c.png"];
|
||||
assert.equal(placeText(files, "b.png"), "2 / 3");
|
||||
assert.equal(placeText(files, "gone.png"), "– / 3");
|
||||
assert.equal(placeText([""], ""), "");
|
||||
});
|
||||
|
||||
test("Load Image is one of the loaders checked for a picked file before a run", async () => {
|
||||
const { SOURCE_LOADERS } = await import("../js/shared/run_check.mjs");
|
||||
assert.equal(typeof SOURCE_LOADERS.AUSBOSS_NODES_LoadImage, "function");
|
||||
assert.ok(SOURCE_LOADERS.AUSBOSS_NODES_LoadImage({ image: "" }));
|
||||
assert.equal(SOURCE_LOADERS.AUSBOSS_NODES_LoadImage({ image: "pic.png" }), null);
|
||||
});
|
||||
@@ -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,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()
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Load Image: the size plan as numbers, and the node on real files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
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 _media_helpers # noqa: E402
|
||||
from nodes._load_image_helpers import ( # noqa: E402
|
||||
SIZE_MODES,
|
||||
apply_load_size,
|
||||
plan_load_size,
|
||||
)
|
||||
from nodes.node_load_image import ( # noqa: E402
|
||||
NODE_CLASS_MAPPINGS,
|
||||
NODE_DISPLAY_NAME_MAPPINGS,
|
||||
AusBossLoadImage,
|
||||
)
|
||||
|
||||
TABLE = ROOT / "tests" / "fixtures" / "load_size_cases.json"
|
||||
|
||||
|
||||
class PlanTests(unittest.TestCase):
|
||||
def test_the_table_the_browser_is_held_to_is_what_the_planner_gives(self):
|
||||
table = json.loads(TABLE.read_text(encoding="utf-8"))
|
||||
self.assertGreater(len(table["rows"]), 200)
|
||||
for row in table["rows"]:
|
||||
source_w, source_h, size, megapixels, longest_side, scale, width, height, multiple, enlarge = row[:10]
|
||||
plan = plan_load_size(source_w, source_h, size, megapixels, longest_side, scale, width, height, multiple, enlarge)
|
||||
got = [plan["resize_width"], plan["resize_height"], plan["crop_x"], plan["crop_y"], plan["width"], plan["height"]]
|
||||
self.assertEqual(got, row[10:], row[:10])
|
||||
|
||||
def test_off_gives_the_picture_back_whatever_else_is_set(self):
|
||||
plan = plan_load_size(913, 1631, "off", megapixels=4.0, multiple=64, enlarge=False)
|
||||
self.assertEqual((plan["width"], plan["height"], plan["crop_x"], plan["crop_y"]), (913, 1631, 0, 0))
|
||||
|
||||
def test_each_choice_keeps_its_promise(self):
|
||||
plan = plan_load_size(912, 1632, "megapixels", megapixels=2.0, multiple=1)
|
||||
self.assertAlmostEqual(plan["width"] * plan["height"] / 1e6, 2.0, delta=0.01)
|
||||
self.assertAlmostEqual(plan["width"] / plan["height"], 912 / 1632, delta=0.002)
|
||||
plan = plan_load_size(912, 1632, "longest side", longest_side=2048, multiple=1)
|
||||
self.assertEqual((plan["width"], plan["height"]), (1144, 2048))
|
||||
plan = plan_load_size(1920, 1080, "longest side", longest_side=1280, multiple=1)
|
||||
self.assertEqual((plan["width"], plan["height"]), (1280, 720))
|
||||
plan = plan_load_size(1920, 1080, "scale", scale=0.5, multiple=1)
|
||||
self.assertEqual((plan["width"], plan["height"]), (960, 540))
|
||||
plan = plan_load_size(1920, 1080, "fit inside", width=1024, height=1024, multiple=1)
|
||||
self.assertEqual((plan["width"], plan["height"]), (1024, 576))
|
||||
plan = plan_load_size(1920, 1080, "crop to fill", width=1024, height=1024, multiple=1)
|
||||
self.assertEqual((plan["width"], plan["height"]), (1024, 1024))
|
||||
self.assertEqual((plan["resize_width"], plan["resize_height"]), (1821, 1024))
|
||||
self.assertEqual((plan["crop_x"], plan["crop_y"]), (398, 0))
|
||||
|
||||
def test_round_to_gives_multiples_and_fit_inside_stays_in_its_box(self):
|
||||
for size in SIZE_MODES[1:]:
|
||||
plan = plan_load_size(1000, 777, size, megapixels=1.3, longest_side=1111, scale=1.37, width=900, height=700, multiple=64)
|
||||
self.assertEqual((plan["width"] % 64, plan["height"] % 64), (0, 0), size)
|
||||
plan = plan_load_size(1920, 1080, "fit inside", width=1000, height=1000, multiple=64)
|
||||
self.assertLessEqual(plan["width"], 1000)
|
||||
self.assertLessEqual(plan["height"], 1000)
|
||||
|
||||
def test_with_enlarge_off_nothing_comes_out_bigger_than_the_file(self):
|
||||
for size in SIZE_MODES[1:]:
|
||||
for multiple in (1, 8, 16):
|
||||
plan = plan_load_size(640, 480, size, megapixels=4.0, longest_side=4096, scale=3.0, width=2048, height=2048, multiple=multiple, enlarge=False)
|
||||
self.assertLessEqual(plan["width"], 640, (size, multiple))
|
||||
self.assertLessEqual(plan["height"], 480, (size, multiple))
|
||||
self.assertLessEqual(plan["resize_width"], 640, (size, multiple))
|
||||
# Crop to fill still gives the box's shape, cut from the file as it is.
|
||||
plan = plan_load_size(800, 600, "crop to fill", width=1024, height=1024, multiple=1, enlarge=False)
|
||||
self.assertEqual((plan["width"], plan["height"], plan["resize_width"], plan["resize_height"], plan["crop_x"]), (600, 600, 800, 600, 100))
|
||||
# A picture that is big enough is resized as usual.
|
||||
plan = plan_load_size(4000, 3000, "longest side", longest_side=1024, multiple=1, enlarge=False)
|
||||
self.assertEqual((plan["width"], plan["height"]), (1024, 768))
|
||||
|
||||
def test_bad_input_is_named(self):
|
||||
with self.assertRaisesRegex(ValueError, "size must be one of"):
|
||||
plan_load_size(100, 100, "stretch")
|
||||
with self.assertRaisesRegex(ValueError, "larger than 0x0"):
|
||||
plan_load_size(0, 100, "off")
|
||||
|
||||
def test_a_plan_that_changes_nothing_hands_the_same_tensors_back(self):
|
||||
image, mask = torch.rand(1, 20, 30, 3), torch.zeros(1, 20, 30)
|
||||
out_image, out_mask = apply_load_size(image, mask, plan_load_size(30, 20, "off"), "lanczos")
|
||||
self.assertIs(out_image, image)
|
||||
self.assertIs(out_mask, mask)
|
||||
|
||||
|
||||
class NodeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# The temp folder stands in for ComfyUI's input folder.
|
||||
self.folder = tempfile.mkdtemp(prefix="ausboss_load_image_")
|
||||
self.addCleanup(shutil.rmtree, self.folder, True)
|
||||
stand_in = SimpleNamespace(
|
||||
get_input_directory=lambda: self.folder,
|
||||
get_annotated_filepath=lambda name: os.path.join(self.folder, name),
|
||||
)
|
||||
patcher = patch.object(_media_helpers, "folder_paths", stand_in)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def write(self, name="pic.png", width=96, height=64, mode="RGB"):
|
||||
path = Path(self.folder) / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
values = np.zeros((height, width, 4), dtype=np.uint8)
|
||||
values[..., 0] = np.linspace(0, 255, width, dtype=np.uint8)[None, :]
|
||||
values[..., 1] = np.linspace(0, 255, height, dtype=np.uint8)[:, None]
|
||||
values[..., 2] = 90
|
||||
values[..., 3] = 255
|
||||
values[: height // 2, : width // 2, 3] = 0 # the top left quarter is see-through
|
||||
Image.fromarray(values).convert(mode).save(path)
|
||||
return name
|
||||
|
||||
def run_node(self, name, **values):
|
||||
return AusBossLoadImage().load(name, **values)
|
||||
|
||||
def test_the_node_is_registered_under_its_permanent_key(self):
|
||||
self.assertIs(NODE_CLASS_MAPPINGS["AUSBOSS_NODES_LoadImage"], AusBossLoadImage)
|
||||
self.assertEqual(NODE_DISPLAY_NAME_MAPPINGS["AUSBOSS_NODES_LoadImage"], "Load Image \U0001f18e")
|
||||
spec = AusBossLoadImage.INPUT_TYPES()
|
||||
self.assertEqual(
|
||||
list(spec["required"]),
|
||||
["image", "size", "megapixels", "longest_side", "scale", "width", "height", "multiple", "method", "enlarge"],
|
||||
)
|
||||
self.assertEqual(spec["required"]["size"][0], list(SIZE_MODES))
|
||||
self.assertEqual(spec["required"]["size"][1]["default"], "off")
|
||||
self.assertEqual(len(AusBossLoadImage.RETURN_TYPES), len(AusBossLoadImage.RETURN_NAMES))
|
||||
self.assertEqual(len(AusBossLoadImage.RETURN_TYPES), len(AusBossLoadImage.OUTPUT_TOOLTIPS))
|
||||
for name, (_, options) in spec["required"].items():
|
||||
self.assertTrue(options.get("tooltip"), name)
|
||||
|
||||
def test_with_size_off_the_file_comes_out_as_it_is(self):
|
||||
name = self.write()
|
||||
image, mask, width, height, filename, original_w, original_h = self.run_node(name)
|
||||
self.assertEqual(tuple(image.shape), (1, 64, 96, 3))
|
||||
self.assertEqual(tuple(mask.shape), (1, 64, 96))
|
||||
self.assertEqual((width, height, original_w, original_h), (96, 64, 96, 64))
|
||||
self.assertEqual(filename, "pic")
|
||||
wanted = np.asarray(Image.open(Path(self.folder) / name).convert("RGB"), dtype=np.float32) / 255.0
|
||||
self.assertTrue(np.array_equal(image[0].numpy(), wanted), "every pixel as the file has it")
|
||||
self.assertEqual(float(mask.max()), 0.0, "no transparency: an all black mask")
|
||||
|
||||
def test_a_see_through_picture_gives_its_mask_the_way_the_stock_loader_does(self):
|
||||
name = self.write("cut.png", mode="RGBA")
|
||||
_, mask, *_ = self.run_node(name)
|
||||
self.assertEqual(float(mask[0, 5, 5]), 1.0, "see-through is white")
|
||||
self.assertEqual(float(mask[0, 60, 90]), 0.0, "solid is black")
|
||||
|
||||
def test_every_size_choice_gives_the_planned_size_for_picture_and_mask(self):
|
||||
name = self.write("cut.png", mode="RGBA")
|
||||
for size in SIZE_MODES[1:]:
|
||||
with self.subTest(size=size):
|
||||
values = {"megapixels": 0.02, "longest_side": 160, "scale": 1.5, "width": 80, "height": 80, "multiple": 8}
|
||||
image, mask, width, height, _, original_w, original_h = self.run_node(name, size=size, **values)
|
||||
plan = plan_load_size(96, 64, size, **values)
|
||||
self.assertEqual((width, height), (plan["width"], plan["height"]))
|
||||
self.assertEqual(tuple(image.shape), (1, height, width, 3))
|
||||
self.assertEqual(tuple(mask.shape), (1, height, width))
|
||||
self.assertEqual((original_w, original_h), (96, 64))
|
||||
self.assertGreaterEqual(float(image.min()), 0.0)
|
||||
self.assertLessEqual(float(image.max()), 1.0)
|
||||
self.assertGreaterEqual(float(mask.min()), 0.0)
|
||||
self.assertLessEqual(float(mask.max()), 1.0)
|
||||
|
||||
def test_crop_to_fill_cuts_the_middle(self):
|
||||
name = self.write("wide.png", width=200, height=100)
|
||||
image, _, width, height, *_ = self.run_node(name, size="crop to fill", width=100, height=100, multiple=1, method="nearest")
|
||||
self.assertEqual((width, height), (100, 100))
|
||||
whole, *_ = self.run_node(name)
|
||||
self.assertTrue(torch.equal(image, whole[:, :, 50:150, :]), "the middle of the file, untouched")
|
||||
|
||||
def test_enlarge_off_leaves_a_small_file_alone(self):
|
||||
name = self.write()
|
||||
image, _, width, height, *_ = self.run_node(name, size="longest side", longest_side=1024, multiple=1, enlarge=False)
|
||||
self.assertEqual((width, height), (96, 64))
|
||||
whole, *_ = self.run_node(name)
|
||||
self.assertTrue(torch.equal(image, whole))
|
||||
|
||||
def test_a_file_in_a_subfolder_gives_its_bare_name(self):
|
||||
name = self.write("shots/day one/hero.final.png")
|
||||
*_, filename, _, _ = self.run_node(name)
|
||||
self.assertEqual(filename, "hero.final")
|
||||
|
||||
def test_every_frame_of_an_animation_comes_through(self):
|
||||
frames = [Image.new("RGB", (40, 30), (index * 60, 20, 20)) for index in range(3)]
|
||||
path = Path(self.folder) / "turn.gif"
|
||||
frames[0].save(path, save_all=True, append_images=frames[1:], duration=80, loop=0)
|
||||
image, mask, width, height, *_ = self.run_node("turn.gif", size="scale", scale=2.0, multiple=1)
|
||||
self.assertEqual(tuple(image.shape), (3, 60, 80, 3))
|
||||
self.assertEqual(tuple(mask.shape), (3, 60, 80))
|
||||
self.assertEqual((width, height), (80, 60))
|
||||
|
||||
def test_a_missing_or_outside_file_stops_before_the_run_with_the_nodes_name(self):
|
||||
self.assertIs(AusBossLoadImage.VALIDATE_INPUTS(image=self.write()), True)
|
||||
for name in ("", "gone.png", "../secret.png", "//fileserver/share/pic.png"):
|
||||
verdict = AusBossLoadImage.VALIDATE_INPUTS(image=name)
|
||||
self.assertIsInstance(verdict, str, name)
|
||||
self.assertTrue(verdict.startswith("Load Image: "), verdict)
|
||||
|
||||
def test_a_changed_file_or_setting_runs_the_node_again(self):
|
||||
name = self.write()
|
||||
first = AusBossLoadImage.IS_CHANGED(name, size="off")
|
||||
self.assertEqual(first, AusBossLoadImage.IS_CHANGED(name, size="off"))
|
||||
self.assertNotEqual(first, AusBossLoadImage.IS_CHANGED(name, size="scale"))
|
||||
path = Path(self.folder) / name
|
||||
Image.new("RGB", (10, 10), (1, 2, 3)).save(path)
|
||||
os.utime(path, ns=(1, 1))
|
||||
self.assertNotEqual(first, AusBossLoadImage.IS_CHANGED(name, size="off"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -436,10 +436,11 @@ class LoaderValidationTests(TempFolders):
|
||||
"""The loaders refuse the same paths before a run, with one message each."""
|
||||
|
||||
def test_image_loaders_take_what_core_load_image_takes_and_nothing_else(self):
|
||||
from nodes import node_image_crop_rotate_pad, node_load_image_pad
|
||||
from nodes import node_image_crop_rotate_pad, node_load_image, node_load_image_pad
|
||||
|
||||
for module, cls, label in (
|
||||
(node_image_crop_rotate_pad, node_image_crop_rotate_pad.AusBossImageCropRotatePad, "Image Crop + Rotate + Pad"),
|
||||
(node_load_image, node_load_image.AusBossLoadImage, "Load Image"),
|
||||
(node_load_image_pad, node_load_image_pad.AusBossLoadImagePad, "Load Image + Pad"),
|
||||
):
|
||||
with self.subTest(node=label):
|
||||
|
||||
@@ -44,6 +44,7 @@ NAMED = {
|
||||
"AusBossImageFolder": ["source", "folder"],
|
||||
"AusBossLaMaInpaint": ["model"],
|
||||
"AusBossLoadAudio": ["audio"],
|
||||
"AusBossLoadImage": ["image"],
|
||||
"AusBossLoadImagePad": ["image", "source_image"],
|
||||
"AusBossLoadVideo": ["video"],
|
||||
"AusBossLoraLoader": ["loras", "on_missing"],
|
||||
|
||||
@@ -943,7 +943,7 @@ test("a name is shown only when SAM 3 is sure of it, and surer still for a big t
|
||||
});
|
||||
|
||||
test("the pack's own picture loaders are the ones the node runs by itself", () => {
|
||||
assert.deepEqual([...OWN_LOADERS].sort(), ["AUSBOSS_NODES_ImageCropRotatePad", "AUSBOSS_NODES_LoadImagePad"]);
|
||||
assert.deepEqual([...OWN_LOADERS].sort(), ["AUSBOSS_NODES_ImageCropRotatePad", "AUSBOSS_NODES_LoadImage", "AUSBOSS_NODES_LoadImagePad"]);
|
||||
// A core Load Image is not in the list: its file is the picture, and the node shows it without a run.
|
||||
assert.equal(OWN_LOADERS.has("LoadImage"), false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user