Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39a7599fd9 | ||
|
|
c4e519565b | ||
|
|
f18b8f6ca3 | ||
|
|
64ab3e9761 | ||
|
|
065f2cb14c | ||
|
|
3d99ddd35a | ||
|
|
224b4af1da | ||
|
|
0a7177b151 | ||
|
|
b31561d1a0 | ||
|
|
ba4ab486c3 | ||
|
|
4603256ebf | ||
|
|
52b23691dc | ||
|
|
df773658da | ||
|
|
d4636bb5e3 | ||
|
|
83f59cac93 | ||
|
|
cfbc42799a | ||
|
|
b4657e2b58 |
@@ -2,6 +2,52 @@
|
||||
|
||||
All notable changes to ComfyUI-AusBoss are documented here.
|
||||
|
||||
## 2.25.0 - 2026-11-12
|
||||
|
||||
- **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. It can also
|
||||
cut a piece or a shape such as 16:9 out of the picture without resizing,
|
||||
and you pick which part is kept: the center, a side or a corner. Arrows
|
||||
step through the pictures in the input folder. Two small pictures on the
|
||||
node show what goes in and what comes out, with both sizes and what is
|
||||
cut off, 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.
|
||||
|
||||
- **Clear VRAM from the right-click menu.** Right-click the empty canvas
|
||||
and pick **Clear VRAM 🆎** to unload every model and free memory on the
|
||||
ComfyUI server, the same as ComfyUI's own "Unload Models and Execution
|
||||
Cache". A short message says when it is done. Turn the entry off in
|
||||
Settings, 🆎 AusBoss, Memory.
|
||||
|
||||
- **Callout 🆎: Info card.** Right-click a Callout and pick **Info card**
|
||||
for a small card with a title, a list of labels and values, and small
|
||||
print, such as a table of run times. Write a row as `label | value`; a
|
||||
row in **bold** is drawn in teal so the one to pick stands out. The first
|
||||
line is the title, and a ⏱️ ℹ️ ⚠️ 💡 or ✅ at its start becomes a small
|
||||
icon. An older pack shows the card as a plain note with its rows written
|
||||
out.
|
||||
|
||||
- **Save Image shows big pictures at once.** The node now shows a small
|
||||
copy of each picture, a few hundred kilobytes, where it used to load the
|
||||
whole file. A 30 MB sheet on a ComfyUI opened from another computer took
|
||||
half a minute to show, or never did; it now shows as the run ends. The
|
||||
saved file is the same full-size file as before.
|
||||
|
||||
- **Save Image: the Save button is hard to miss.** With **Auto save** off,
|
||||
a row over the picture now says **Not saved yet** and holds a solid
|
||||
**Save picture** button. Before, it was a small SAVE label in the corner.
|
||||
With several pictures the button reads **Save all 4**, or **Save this
|
||||
one** when the arrows are on one picture. After a save the row says the
|
||||
name the picture was saved under.
|
||||
|
||||
- **A picture that loads slowly no longer leaves the old one up.** When a
|
||||
result took more than about 16 seconds to reach the browser, the node
|
||||
kept the picture from the run before. Now the new picture always shows,
|
||||
and the old one dims while the new one loads. This fixes Select Frame,
|
||||
Mask Refine, Mask by Name and LaMa Inpaint as well as Save Image.
|
||||
|
||||
## 2.24.0 - 2026-11-12
|
||||
|
||||
- **New node: Edit Lock.** Keeps every part of your picture that an edit
|
||||
|
||||
@@ -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. It can also cut a piece or a shape such as 16:9 out of the picture without resizing, and **Keep** picks which part stays: the center, a side or a corner. **Round to** keeps the result on a multiple of 8, 16 or whatever the model wants. The node shows the picture going in and coming out, with both sizes, 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.
|
||||
|
||||
@@ -37,6 +37,7 @@ NODE_MODULES = [
|
||||
"node_lama_inpaint",
|
||||
"node_layout_boxes",
|
||||
"node_literals",
|
||||
"node_load_image",
|
||||
"node_load_image_pad",
|
||||
"node_load_audio",
|
||||
"node_load_video",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="105" height="20" role="img" aria-label="release: v2.24.0">
|
||||
<title>release: v2.24.0</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="105" height="20" role="img" aria-label="release: v2.25.0">
|
||||
<title>release: v2.25.0</title>
|
||||
<path fill="#555" d="M0 0h48v20H0z"/>
|
||||
<path fill="#00b4aa" d="M48 0h57v20H48z"/>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text x="24" y="14">release</text>
|
||||
<text x="76.5" y="14">v2.24.0</text>
|
||||
<text x="76.5" y="14">v2.25.0</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 425 B After Width: | Height: | Size: 425 B |
+115
-22
@@ -13,10 +13,12 @@ import { BRAND, chainCallback, keepDomWidgetWidthAuto } from "../shared/index.mj
|
||||
import {
|
||||
ARROW_ANGLE,
|
||||
ARROW_CHOICES,
|
||||
CARD_TEMPLATE,
|
||||
DOUBLE_ARROWS,
|
||||
arrowText,
|
||||
fitFontSize,
|
||||
parseBlocks,
|
||||
parseCard,
|
||||
pastedLink,
|
||||
repointLinks,
|
||||
targetId,
|
||||
@@ -45,6 +47,10 @@ const LINK_TILES = {
|
||||
youtube: { label: "Watch Me on YouTube", url: "https://www.youtube.com/@ausbossdev" },
|
||||
discord: { label: "Join for Help or More Workflows", url: "https://discord.gg/RwVxY9Fuaj" },
|
||||
};
|
||||
// A new Info card opens at this size, which shows its starter text whole.
|
||||
const CARD_SIZE = [300, 230];
|
||||
// The card's text grows with the node like a note's, but a card stays a card.
|
||||
const CARD_FONT_MAX = 20;
|
||||
const FRAME_FILL = 0.8; // the share of the view a node brought into view takes, as in Workflow Switches
|
||||
const FONT = '"IBM Plex Sans", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif';
|
||||
|
||||
@@ -81,6 +87,20 @@ ${TILE_FACE_CSS}
|
||||
.ausboss-callout-card[data-tile] .ausboss-callout-url svg{width:36px;height:28px;flex:none}
|
||||
.ausboss-callout-card[data-tile] .ausboss-callout-out{margin-left:auto}
|
||||
.ausboss-callout-card[data-tile] .ausboss-callout-tools{right:27px;}
|
||||
.ausboss-callout-card[data-card]{background:#111818;border:1px solid #2e3c3b;}
|
||||
.ausboss-callout-card[data-card] .ausboss-callout-inner{padding:.8em 1em;color:#d9e4e3;font-weight:400;line-height:1.45;}
|
||||
.ausboss-callout-card[data-card] b{color:#f2f7f6;font-weight:650;}
|
||||
.ausboss-card-block{margin:0 0 .85em;}
|
||||
.ausboss-card-block:last-child{margin-bottom:0;}
|
||||
.ausboss-card-block>*+*{margin-top:.3em;}
|
||||
.ausboss-card-title{display:flex;align-items:center;gap:.4em;color:#f2f7f6;font-size:1.08em;font-weight:650;}
|
||||
.ausboss-card-icon{flex:none;width:1em;height:1em;color:#c9d6d4;}
|
||||
.ausboss-card-icon svg{display:block;width:100%;height:100%;}
|
||||
.ausboss-card-row{display:flex;justify-content:space-between;align-items:baseline;gap:1em;}
|
||||
.ausboss-card-row>span:last-child{flex:none;text-align:right;white-space:nowrap;}
|
||||
.ausboss-card-row.is-mark,.ausboss-card-row.is-mark b{color:#6fe0d6;font-weight:500;}
|
||||
.ausboss-card-small,.ausboss-card-small b{color:#93a6a4;}
|
||||
.ausboss-card-small{font-size:.92em;}
|
||||
.ausboss-callout-empty{color:#6f8987;font-size:14px;font-weight:400;}
|
||||
.ausboss-callout-arrow{display:inline-block;width:1.2em;height:1.2em;margin:0 .1em;vertical-align:-.26em;filter:drop-shadow(0 0 .3em rgba(0,180,170,.55));}
|
||||
.ausboss-callout-arrow svg{display:block;width:100%;height:100%;overflow:visible;}
|
||||
@@ -154,6 +174,25 @@ function arrowIcon(kind) {
|
||||
return root;
|
||||
}
|
||||
|
||||
// The small line icons an Info card's title can start with.
|
||||
const CARD_ICON_PATHS = {
|
||||
clock: ["M12 7v5l3.2 2"],
|
||||
info: ["M12 11v6", "M12 7.6v.01"],
|
||||
warning: ["M12 3.5 2.8 19.5h18.4Z", "M12 10v4", "M12 16.9v.01"],
|
||||
tip: ["M9.5 18h5", "M10.5 21h3", "M12 3a6 6 0 0 0-3.6 10.8c.7.6 1.1 1.3 1.1 2.2h5c0-.9.4-1.6 1.1-2.2A6 6 0 0 0 12 3Z"],
|
||||
check: ["M8 12.4l2.8 2.8 5.2-5.6"],
|
||||
};
|
||||
const CARD_ICON_RING = new Set(["clock", "info", "check"]);
|
||||
|
||||
function cardIcon(kind) {
|
||||
const span = el("span", "ausboss-card-icon");
|
||||
const root = svg("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" });
|
||||
if (CARD_ICON_RING.has(kind)) root.append(svg("circle", { cx: 12, cy: 12, r: 9 }));
|
||||
for (const d of CARD_ICON_PATHS[kind] ?? []) root.append(svg("path", { d }));
|
||||
span.append(root);
|
||||
return span;
|
||||
}
|
||||
|
||||
// The node a link names, looked up in the graph the note sits in.
|
||||
function linkedNode(state, id) {
|
||||
const graph = state.node.graph ?? app.graph;
|
||||
@@ -262,17 +301,79 @@ function bringIntoView(target) {
|
||||
}
|
||||
}
|
||||
|
||||
// One line's pieces into an element. The pieces of one link sit in one
|
||||
// element; a link that leaves ComfyUI ends with a small ↗.
|
||||
function appendPieces(state, into, line) {
|
||||
let run = null;
|
||||
let wrap = null;
|
||||
const close = () => {
|
||||
if (wrap?.classList.contains("ausboss-callout-url")) wrap.append(el("span", "ausboss-callout-out", "↗"));
|
||||
};
|
||||
for (const piece of line) {
|
||||
if (piece.run !== run) {
|
||||
close();
|
||||
run = piece.run;
|
||||
wrap = run === undefined ? null : linkRun(state, piece);
|
||||
if (wrap) into.append(wrap);
|
||||
}
|
||||
const target = wrap ?? into;
|
||||
if (piece.arrow) target.append(arrowSpan(state, piece.arrow, piece));
|
||||
else target.append(piece.bold ? el("b", "", piece.text) : document.createTextNode(piece.text));
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
// An Info card: a title, rows of label and value, and small print.
|
||||
function renderCard(state, blocks) {
|
||||
for (const lines of blocks) {
|
||||
const block = el("div", "ausboss-card-block");
|
||||
for (const line of lines) {
|
||||
let row;
|
||||
if (line.kind === "title") {
|
||||
row = el("div", "ausboss-card-title");
|
||||
if (line.icon) row.append(cardIcon(line.icon));
|
||||
const words = el("span");
|
||||
appendPieces(state, words, line.pieces);
|
||||
row.append(words);
|
||||
} else if (line.kind === "row") {
|
||||
row = el("div", `ausboss-card-row${line.highlight ? " is-mark" : ""}`);
|
||||
for (const cell of line.cells) {
|
||||
const side = el("span");
|
||||
appendPieces(state, side, cell);
|
||||
row.append(side);
|
||||
}
|
||||
} else {
|
||||
row = el("div", "ausboss-card-text");
|
||||
appendPieces(state, row, line.pieces);
|
||||
}
|
||||
if (line.small) row.classList.add("ausboss-card-small");
|
||||
block.append(row);
|
||||
}
|
||||
state.inner.append(block);
|
||||
}
|
||||
}
|
||||
|
||||
function renderText(state) {
|
||||
const style = state.node.properties?.ausbossCalloutTile;
|
||||
if (["youtube","discord","title"].includes(style)) state.card.dataset.tile = style;
|
||||
else delete state.card.dataset.tile;
|
||||
setTileFace(state.node, state.root, Boolean(state.card.dataset.tile), () => startEdit(state));
|
||||
const blocks = parseBlocks(state.valueWidget.value);
|
||||
// An Info card has no title bar, like a tile, but keeps the full editor
|
||||
// and saves as a plain Callout: an older pack shows its text as a note.
|
||||
if (style === "card") state.card.dataset.card = "";
|
||||
else delete state.card.dataset.card;
|
||||
const isCard = style === "card";
|
||||
setTileFace(state.node, state.root, Boolean(state.card.dataset.tile) || isCard, () => startEdit(state));
|
||||
const blocks = isCard ? parseCard(state.valueWidget.value) : parseBlocks(state.valueWidget.value);
|
||||
state.inner.replaceChildren();
|
||||
state.empty = blocks.length === 0;
|
||||
if (state.empty) {
|
||||
state.inner.append(el("div", "ausboss-callout-empty", "Double-click to write a note"));
|
||||
}
|
||||
if (isCard) {
|
||||
renderCard(state, blocks);
|
||||
fit(state);
|
||||
return;
|
||||
}
|
||||
for (const lines of blocks) {
|
||||
const point = el("div", "ausboss-callout-point");
|
||||
lines.forEach((line, index) => {
|
||||
@@ -280,21 +381,7 @@ function renderText(state) {
|
||||
// lines are told apart by a break, as before; a block needs none.
|
||||
const into = line.small ? el("div", "ausboss-callout-small") : point;
|
||||
if (index && !line.small && !lines[index - 1].small) point.append(document.createElement("br"));
|
||||
let run = null;
|
||||
let wrap = null;
|
||||
for (const piece of line) {
|
||||
// The pieces of one link sit in one element.
|
||||
if (piece.run !== run) {
|
||||
if (wrap?.classList.contains("ausboss-callout-url")) wrap.append(el("span", "ausboss-callout-out", "↗"));
|
||||
run = piece.run;
|
||||
wrap = run === undefined ? null : linkRun(state, piece);
|
||||
if (wrap) into.append(wrap);
|
||||
}
|
||||
const target = wrap ?? into;
|
||||
if (piece.arrow) target.append(arrowSpan(state, piece.arrow, piece));
|
||||
else target.append(piece.bold ? el("b", "", piece.text) : document.createTextNode(piece.text));
|
||||
}
|
||||
if (wrap?.classList.contains("ausboss-callout-url")) wrap.append(el("span", "ausboss-callout-out", "↗"));
|
||||
appendPieces(state, into, line);
|
||||
if (line.small) point.append(into);
|
||||
});
|
||||
state.inner.append(point);
|
||||
@@ -364,7 +451,7 @@ function fit(state) {
|
||||
inner.style.fontSize = `${px}px`;
|
||||
return inner.offsetHeight <= height && inner.scrollWidth <= width + 1;
|
||||
};
|
||||
const size = fitFontSize(fits);
|
||||
const size = fitFontSize(fits, undefined, card.dataset.card === undefined ? undefined : CARD_FONT_MAX);
|
||||
const social = card.dataset.tile === "youtube" || card.dataset.tile === "discord";
|
||||
inner.style.fontSize = `${social ? Math.min(size, 16) : size}px`;
|
||||
}
|
||||
@@ -453,7 +540,9 @@ function startEdit(state) {
|
||||
const wrap = el("div", "ausboss-callout-editor");
|
||||
const area = el("textarea", "ausboss-callout-edit");
|
||||
area.value = state.valueWidget.value ?? "";
|
||||
area.placeholder = "One idea per paragraph. **Bold** a word to stress it. The buttons below add an arrow, make a line small or make words a link.";
|
||||
area.placeholder = state.card.dataset.card === undefined
|
||||
? "One idea per paragraph. **Bold** a word to stress it. The buttons below add an arrow, make a line small or make words a link."
|
||||
: "The first line is the title. Write label | value for a row. **Bold** a whole row to make it teal. Small makes a line small print.";
|
||||
area.spellcheck = true;
|
||||
const bar = el("div", "ausboss-callout-arrowbar");
|
||||
bar.append(el("span", "", "Arrow"));
|
||||
@@ -687,7 +776,7 @@ app.registerExtension({
|
||||
const state = buildPanel(this);
|
||||
if (!state) return;
|
||||
renderText(state);
|
||||
if (state.card.dataset.tile && Number.isFinite(saved?.size?.[1])) {
|
||||
if ((state.card.dataset.tile || state.card.dataset.card !== undefined) && Number.isFinite(saved?.size?.[1])) {
|
||||
this.setSize?.([this.size[0], saved.size[1]]);
|
||||
}
|
||||
});
|
||||
@@ -699,14 +788,18 @@ app.registerExtension({
|
||||
chainCallback(nodeType.prototype, "getExtraMenuOptions", function (_canvas, options) {
|
||||
const state = this.__ausbossCallout;
|
||||
if (!state || !Array.isArray(options)) return;
|
||||
for (const [style, label] of [["plain","Plain callout"],["title","Title tile"],["youtube","Set YouTube link tile"],["discord","Set Discord link tile"]]) {
|
||||
for (const [style, label] of [["plain","Plain callout"],["card","Info card"],["title","Title tile"],["youtube","Set YouTube link tile"],["discord","Set Discord link tile"]]) {
|
||||
options.unshift({ content: label, callback: () => {
|
||||
this.properties ??= {};
|
||||
this.properties.ausbossCalloutTile = style;
|
||||
const preset = LINK_TILES[style];
|
||||
if (preset) commitWidgetValue(this, state.valueWidget, `[${preset.label}](${preset.url})`, app.canvas);
|
||||
// An empty card starts with text that shows how one is written.
|
||||
const starter = style === "card" && !String(state.valueWidget.value ?? "").trim();
|
||||
if (starter) commitWidgetValue(this, state.valueWidget, CARD_TEMPLATE, app.canvas);
|
||||
renderText(state);
|
||||
if (style !== "plain") this.setSize?.([this.size[0], style === "title" ? 86 : 78]);
|
||||
if (starter) this.setSize?.([Math.max(this.size[0], CARD_SIZE[0]), CARD_SIZE[1]]);
|
||||
else if (style !== "plain" && style !== "card") this.setSize?.([this.size[0], style === "title" ? 86 : 78]);
|
||||
markChanged();
|
||||
}});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Clear VRAM 🆎 — a right-click entry on the empty canvas that unloads every
|
||||
// model and frees memory on the ComfyUI server this page is connected to.
|
||||
//
|
||||
// It sends the same request as ComfyUI's own "Unload Models and Execution
|
||||
// Cache" command (top menu, Edit): POST /free with unload_models and
|
||||
// free_memory. That command is not on the right-click menu, so this entry
|
||||
// puts it one click away. A setting hides the entry; a ComfyUI that turns
|
||||
// manual unloading off (Comfy.Memory.AllowManualUnload) hides it too.
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { showToast } from "../shared/index.mjs";
|
||||
|
||||
const SETTING_ID = "AusBoss.Memory.ClearVramMenu";
|
||||
const MENU_LABEL = "Clear VRAM 🆎";
|
||||
|
||||
function setting(id) {
|
||||
return app.ui?.settings?.getSettingValue?.(id);
|
||||
}
|
||||
|
||||
async function clearVram() {
|
||||
try {
|
||||
const response = await api.fetchApi("/free", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ unload_models: true, free_memory: true }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`the server answered ${response.status}`);
|
||||
showToast({ severity: "success", summary: MENU_LABEL, detail: "Models unloaded and memory freed.", life: 3000 });
|
||||
} catch (error) {
|
||||
showToast({ severity: "error", summary: MENU_LABEL, detail: `Could not clear VRAM: ${error?.message ?? error}` });
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ausboss.clear.vram",
|
||||
settings: [
|
||||
{
|
||||
id: SETTING_ID,
|
||||
name: "Clear VRAM on the right-click menu",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
category: ["🆎 AusBoss", "Memory", "Clear VRAM menu"],
|
||||
tooltip:
|
||||
"Right-click the empty canvas for Clear VRAM: it unloads every model and frees "
|
||||
+ "memory on the ComfyUI server. Off hides the entry.",
|
||||
},
|
||||
],
|
||||
getCanvasMenuItems() {
|
||||
if (setting(SETTING_ID) === false) return [];
|
||||
if (setting("Comfy.Memory.AllowManualUnload") === false) return [];
|
||||
return [{ content: MENU_LABEL, callback: clearVram }];
|
||||
},
|
||||
});
|
||||
@@ -15,7 +15,38 @@ Right-click the Callout and choose **Title tile** to give your existing text a b
|
||||
- Double-click a title to edit it. **Save** or `Ctrl+Enter` keeps the change; **Cancel** or `Esc` leaves the original text.
|
||||
- Drag the visible bottom-right grip to resize. You can also focus the grip and use arrow keys; hold **Shift** for smaller changes. Resizing keeps the tile in place and supports Undo.
|
||||
|
||||
## Writing
|
||||
## Info card
|
||||
|
||||
A small card for facts people look up: run times, settings, what each choice
|
||||
does. Right-click the Callout and choose **Info card**. An empty note starts
|
||||
with sample text that shows each part.
|
||||
|
||||
Write it like this:
|
||||
|
||||
```text
|
||||
⏱️ Too slow?
|
||||
###### Redraw time on an RTX 5090. Smaller cards take longer.
|
||||
|
||||
2x, 18 steps | ~4 min
|
||||
2x, 10 steps | ~3 min
|
||||
1.5x, 18 steps | ~2.5 min
|
||||
**1.5x, 10 steps (default) | ~2 min**
|
||||
|
||||
###### 1.5x is a 4608-wide sheet, 2x is 6144.
|
||||
```
|
||||
|
||||
- The first line is the card's title. Start it with ⏱️ ℹ️ ⚠️ 💡 or ✅ and
|
||||
that emoji is drawn as a small icon.
|
||||
- A line with a `|` in it is a row: the words before the `|` on the left,
|
||||
the rest on the right.
|
||||
- Put the whole row in two stars, `**like this | value**`, and it is drawn
|
||||
in teal. Use it for the default or the one to pick.
|
||||
- Small print (the **Small** button) is a grey line, for a subtitle or a note
|
||||
at the bottom.
|
||||
- A blank line leaves a gap. Arrows and links work as in any callout.
|
||||
- Double-click the card to edit it. Choose **Plain callout** to turn it back
|
||||
into a note; the text stays.
|
||||
|
||||
|
||||
- On a plain callout, double-click the note, or press **Edit**, to type. Click outside the box to
|
||||
keep the text. **Esc** drops what you typed.
|
||||
@@ -90,5 +121,13 @@ In the text a link is written `[the words](https://example.com)`, or
|
||||
it to read the address before you click.
|
||||
- An older version of the pack shows a link as typed, brackets and address
|
||||
included, so the address can still be read and copied there.
|
||||
- An Info card is saved as an ordinary Callout with the text you typed.
|
||||
The card look is a setting on the node, so an older version of the pack
|
||||
shows the same text as a plain note, rows written out as `label | value`.
|
||||
A plain callout never reads `|` as a row, so notes written before look
|
||||
the same.
|
||||
- A row splits at its first `|` that is not inside a link. A row pasted
|
||||
from a Markdown table loses its outer pipes, and the table's `---` line
|
||||
is skipped. An Info card's text grows with the card, up to 20 px.
|
||||
- The font is IBM Plex Sans when the computer has it, otherwise the system
|
||||
font. The node loads no fonts and makes no network requests.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# 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.
|
||||
|
||||
Under that line are two small pictures: the file as it goes in, and the
|
||||
picture as it comes out. On the first one the part that is kept is lit and
|
||||
the part that is cut off is dark. Between them the node says the shape
|
||||
(`3:4 → 1:1`) and what happens (`77% size`, `cuts 449 px of height`).
|
||||
|
||||
## 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. | **Width** and **Height** |
|
||||
| **crop** | Cuts a piece of that size out of the picture. Nothing is resized. | **Width** and **Height** |
|
||||
| **crop to ratio** | Cuts the largest piece of a shape, such as 16:9, out of the picture. Nothing is resized. | **Ratio** (`ratio`) |
|
||||
|
||||
**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.
|
||||
|
||||
## Which part is kept
|
||||
|
||||
The three crop choices show **Keep** (`crop_from`). It picks the part of the
|
||||
picture that stays: `center` cuts evenly from both sides, `top` keeps the top
|
||||
and cuts from the bottom, `bottom left` keeps that corner, and so on.
|
||||
|
||||
You can also click the first small picture: click its top to keep the top,
|
||||
a corner to keep that corner. The lit part moves so you see what you get.
|
||||
|
||||
In **Ratio**, `width : height` takes the shape from the Width and Height
|
||||
boxes, for a shape that is not in the list (1600 and 900 give 16:9).
|
||||
|
||||
## 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
|
||||
box out at the side Keep names. The mask goes through the same resize and
|
||||
the same cut.
|
||||
- Crop and crop to ratio only cut. A box larger than the picture is cut down
|
||||
to the picture, and Round to rounds down so the piece stays inside it.
|
||||
Enlarge does not apply to them.
|
||||
- The small pictures are drawn on one scale, so a picture made half as large
|
||||
is drawn half as large.
|
||||
- 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.
|
||||
@@ -53,10 +53,14 @@ file's name. A caption on the `caption_text` input writes the paired `.txt`.
|
||||
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.
|
||||
The node shows a small copy of each picture (up to 2048 pixels on the
|
||||
long side), so a big file shows at once on a slow connection too. The
|
||||
saved file is always full size. The copies are in ComfyUI's temp folder.
|
||||
- **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.
|
||||
pictures. Off, a run only shows them here and saves nothing. A row over
|
||||
the picture then says **Not saved yet**, with a **Save picture** button.
|
||||
With several pictures it reads **Save all 4**, and with the arrows on one
|
||||
picture, **Save this one**. After a save the row says what is saved.
|
||||
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
|
||||
|
||||
@@ -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 or crop it (megapixels, longest side, a box, a ratio) 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`) |
|
||||
|
||||
+122
-25
@@ -14,7 +14,7 @@
|
||||
// switch lays each sampling step over the stage, with a Stop button on the
|
||||
// bar (shared/step_preview.mjs).
|
||||
|
||||
import { panelSwitch } from "../shared/panel_controls.mjs";
|
||||
import { panelSwitch, primaryAction } from "../shared/panel_controls.mjs";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { isForeignRun } from "../shared/prompt_scope.mjs";
|
||||
@@ -26,14 +26,16 @@ 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 { ALL, allLabel, gridShape, heldPlaces, keepIndex, keepWords, stepIndex, stepWithAll } from "../shared/picture_steps.mjs";
|
||||
import { addStepConsumer, mountStepOverlay, showsStepsOn, stepRun, stopStepRun, watchSteps } from "../shared/step_preview.mjs";
|
||||
import {
|
||||
describeNodePreview,
|
||||
describeSourcePreview,
|
||||
lightPreviewQuery,
|
||||
outputLocatorId,
|
||||
outputRecordCount,
|
||||
outputRecordQuery,
|
||||
ownResultIsCurrent,
|
||||
placeholderText,
|
||||
staleText,
|
||||
sourceFileWidget,
|
||||
@@ -54,6 +56,9 @@ const BAR_GAP = 6;
|
||||
const BAR_PADDING = 2;
|
||||
const PANEL_HEIGHT = STAGE_HEIGHT + BAR_HEIGHT + 4 + 16;
|
||||
const OFF_HEIGHT = BAR_HEIGHT + 16;
|
||||
// The row over pictures that wait for a Save, and the gap under it.
|
||||
const KEEP_HEIGHT = 30;
|
||||
const KEEP_SPACE = KEEP_HEIGHT + 4;
|
||||
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
|
||||
@@ -126,9 +131,9 @@ const NODE_CONFIG = {
|
||||
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 },
|
||||
],
|
||||
// Auto save off: a row over the picture says it is not saved yet and
|
||||
// holds the button that saves it.
|
||||
keep: "Save what is showing: every picture, or the one the arrows picked.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -152,16 +157,21 @@ function ensureCss() {
|
||||
.ausboss-input-preview.preview-off .ausboss-stepper{display:none;}
|
||||
.ausboss-input-preview-stage img,.ausboss-input-preview-stage video{display:none;position:absolute;inset:0;margin:auto;max-width:100%;max-height:100%;object-fit:contain;}
|
||||
.ausboss-input-preview-stage.show-image img{display:block;}
|
||||
.ausboss-input-preview-stage.loading-image img{opacity:.4;transition:opacity .3s ease 1.2s;}
|
||||
.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-keep{box-sizing:border-box;flex:none;display:flex;align-items:center;justify-content:space-between;gap:10px;height:${KEEP_HEIGHT}px;padding:0 4px 0 10px;border-radius:${RADIUS_CONTROL}px;background:${T.brandSoft};pointer-events:none;}
|
||||
.ausboss-input-preview-keep[hidden]{display:none;}
|
||||
.ausboss-input-preview-keep span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${T.brandInk};font:600 11px/1.2 ${FONT_UI};}
|
||||
.ausboss-input-preview-keep.done{background:${T.fill};}
|
||||
.ausboss-input-preview-keep.done span{color:${T.label};}
|
||||
.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};}
|
||||
@@ -472,7 +482,7 @@ function syncPreviewMode(state, resize) {
|
||||
if (resize) {
|
||||
const node = state.node;
|
||||
node.properties ??= {};
|
||||
const floor = node.computeSize?.()[1] ?? (enabled ? PANEL_HEIGHT : OFF_HEIGHT);
|
||||
const floor = node.computeSize?.()[1] ?? panelFloor(state);
|
||||
if (!enabled) {
|
||||
// Off: remember the height, then shrink to what is left.
|
||||
const height = Number(node.size?.[1]);
|
||||
@@ -509,7 +519,6 @@ 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;
|
||||
@@ -538,6 +547,7 @@ function showMedia(state, kind) {
|
||||
|
||||
function clearMedia(state) {
|
||||
state.img.removeAttribute("src");
|
||||
state.stage.classList.remove("loading-image");
|
||||
state.video.pause?.();
|
||||
state.video.removeAttribute("src");
|
||||
clearGrid(state);
|
||||
@@ -578,6 +588,7 @@ function showGrid(state, 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.addEventListener("error", () => dropLightPreview(state), { once: true });
|
||||
cell.src = url;
|
||||
return cell;
|
||||
}));
|
||||
@@ -630,12 +641,30 @@ function rewatchSource(state, source) {
|
||||
// record: an overwritten file reloads after the next run, and a plain refresh
|
||||
// never reloads the picture.
|
||||
const storedRand = new WeakMap();
|
||||
const outputRecord = (node) => app.nodeOutputs?.[outputLocatorId(node)];
|
||||
// Save Image also writes a small copy of each picture for its face, and the
|
||||
// panel shows that one: a few hundred kilobytes where the file can be tens
|
||||
// of megabytes. The copies are in the temp folder, which ComfyUI empties
|
||||
// when it starts; a record whose copy is gone goes back to the file itself.
|
||||
const lightGone = new WeakSet();
|
||||
function lightQuery(record, index = null) {
|
||||
return record && !lightGone.has(record) ? lightPreviewQuery(record, index) : null;
|
||||
}
|
||||
function storedResultUrl(node, index = null) {
|
||||
const record = app.nodeOutputs?.[outputLocatorId(node)];
|
||||
const query = outputRecordQuery(record, index);
|
||||
const record = outputRecord(node);
|
||||
const light = lightQuery(record, index);
|
||||
const query = light ?? outputRecordQuery(record, index);
|
||||
if (!query) return null;
|
||||
if (!storedRand.has(record)) storedRand.set(record, app.getRandParam?.() ?? "");
|
||||
return api.apiURL(`/view?${query}${app.getPreviewFormatParam?.() ?? ""}${storedRand.get(record)}`);
|
||||
return api.apiURL(`/view?${query}${light ? "" : app.getPreviewFormatParam?.() ?? ""}${storedRand.get(record)}`);
|
||||
}
|
||||
// A small copy that would not load: show the file itself from now on.
|
||||
function dropLightPreview(state) {
|
||||
const record = outputRecord(state.node);
|
||||
if (!lightQuery(record)) return false;
|
||||
lightGone.add(record);
|
||||
scheduleRefresh(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Several pictures from one run (a list, or a batch Save Image saved one by
|
||||
@@ -679,23 +708,66 @@ 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.
|
||||
// The row shows while the node holds pictures. It says what is saved, and
|
||||
// its button what a click will do (keepWords).
|
||||
function syncHeld(state, count = resultCount(state)) {
|
||||
const button = state.keepButton;
|
||||
if (!button) return;
|
||||
const row = state.keepRow;
|
||||
if (!row) return;
|
||||
const held = heldPictures(state);
|
||||
button.hidden = !held.length || count < 1;
|
||||
if (button.hidden) return;
|
||||
const hidden = !held.length || count < 1;
|
||||
if (row.hidden !== hidden) {
|
||||
row.hidden = hidden;
|
||||
fitKeepRow(state);
|
||||
}
|
||||
if (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);
|
||||
const words = keepWords({
|
||||
held: held.length, kept: kept.size, all, done, saving: Boolean(state.keeping), name: state.kept?.name ?? "",
|
||||
});
|
||||
state.keepNote.textContent = words.note;
|
||||
state.keepNote.title = state.kept?.path ? `ComfyUI/output/${state.kept.path}` : "";
|
||||
state.keepButton.textContent = words.button;
|
||||
state.keepButton.classList.toggle("quiet", words.quiet);
|
||||
state.keepButton.disabled = Boolean(state.keeping);
|
||||
row.classList.toggle("done", words.quiet && kept.size >= held.length);
|
||||
[...(state.grid?.children ?? [])].forEach((cell, index) => cell.classList.toggle("kept", kept.has(index)));
|
||||
}
|
||||
|
||||
// The row takes room of its own, so the picture keeps its floor: the node
|
||||
// grows to hold it, and a node with the preview off gives the room back.
|
||||
function fitKeepRow(state) {
|
||||
const node = state.node;
|
||||
if (previewEnabled(state)) {
|
||||
ensureNodeMinHeight(node);
|
||||
} else {
|
||||
const floor = Number(node.computeSize?.()?.[1]);
|
||||
if (Number.isFinite(floor)) node.setSize?.([node.size?.[0] ?? PANEL_MIN_WIDTH, floor]);
|
||||
}
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
function buildKeepRow(state, title, signal) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ausboss-input-preview-keep";
|
||||
row.hidden = true;
|
||||
const note = document.createElement("span");
|
||||
const button = primaryAction("Save picture", title);
|
||||
// Stop the graph from seeing the click as a canvas drag on the node.
|
||||
button.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
keepHeld(state);
|
||||
}, { signal });
|
||||
row.append(note, button);
|
||||
state.keepRow = row;
|
||||
state.keepNote = note;
|
||||
state.keepButton = button;
|
||||
return row;
|
||||
}
|
||||
|
||||
async function keepHeld(state) {
|
||||
const held = heldPictures(state);
|
||||
if (!held.length || state.keeping) return;
|
||||
@@ -729,6 +801,12 @@ async function keepHeld(state) {
|
||||
if (picture.token === token && done.has(picture.index)) kept.add(place);
|
||||
});
|
||||
saved.push(...(answer.saved ?? []));
|
||||
// One picture: the row says the name it was saved under.
|
||||
const path = held.length === 1 ? answer.saved?.[0]?.path : null;
|
||||
if (typeof path === "string" && path && state.kept) {
|
||||
state.kept.path = path;
|
||||
state.kept.name = path.slice(path.lastIndexOf("/") + 1);
|
||||
}
|
||||
skipped += answer.skipped?.length ?? 0;
|
||||
}
|
||||
toast(state, saved.length === 1
|
||||
@@ -798,7 +876,9 @@ function refresh(state) {
|
||||
? describeSourcePreview(source)
|
||||
: count > 1
|
||||
? { kind: "url", url: storedResultUrl(state.node, state.index) }
|
||||
: describeNodePreview(state.node, state.inputName, storedResultUrl(state.node));
|
||||
: describeNodePreview(state.node, state.inputName, storedResultUrl(state.node),
|
||||
// The small copy goes first: node.imgs is the full file.
|
||||
!lightQuery(outputRecord(state.node)) && ownResultIsCurrent(state.node, outputRecord(state.node), state.imgsFor));
|
||||
if (!described) {
|
||||
clearMedia(state);
|
||||
showPlaceholder(state, !!source);
|
||||
@@ -820,6 +900,9 @@ function refresh(state) {
|
||||
state.video.removeAttribute("src");
|
||||
if (state.img.getAttribute("src") !== url) {
|
||||
state.img.src = url;
|
||||
// The picture before stays up while this one loads. A slow load
|
||||
// dims it, so an old picture is never taken for the new one.
|
||||
state.stage.classList.toggle("loading-image", true);
|
||||
} else if (state.img.complete && state.img.naturalWidth > 0) {
|
||||
showMedia(state, "image");
|
||||
}
|
||||
@@ -842,8 +925,19 @@ function scheduleRefresh(state) {
|
||||
// Watching the property rather than an api event catches both routes that set
|
||||
// it - the progress frames streamed during a run and the ui payload delivered
|
||||
// when the node finishes - without this file having to know either event name.
|
||||
// The panel's floor: the bar and the picture, the bar alone with the
|
||||
// preview off, and the Save row on top while it shows.
|
||||
function panelFloor(state, enabled = state ? previewEnabled(state) : true) {
|
||||
const keep = state?.keepRow && !state.keepRow.hidden ? KEEP_SPACE : 0;
|
||||
return (enabled ? PANEL_HEIGHT : OFF_HEIGHT) + keep;
|
||||
}
|
||||
|
||||
function watchOwnResult(node, state) {
|
||||
let images = node.imgs;
|
||||
// Which output the pictures in node.imgs were loaded for. The frontend
|
||||
// gives up on a picture that loads slowly and keeps the run before, so
|
||||
// the panel checks this before it trusts node.imgs (ownResultIsCurrent).
|
||||
state.imgsFor = outputRecord(node);
|
||||
try {
|
||||
Object.defineProperty(node, "imgs", {
|
||||
configurable: true,
|
||||
@@ -851,6 +945,7 @@ function watchOwnResult(node, state) {
|
||||
get: () => images,
|
||||
set: (value) => {
|
||||
images = value;
|
||||
state.imgsFor = outputRecord(node);
|
||||
if (state.alive) scheduleRefresh(state);
|
||||
},
|
||||
});
|
||||
@@ -891,7 +986,7 @@ function buildPanel(node, config) {
|
||||
const widget = node.addDOMWidget(WIDGET_NAME, "ausboss_input_preview", root, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
getMinHeight: () => (enabled() ? PANEL_HEIGHT : OFF_HEIGHT),
|
||||
getMinHeight: () => panelFloor(node.__ausbossInputPreview, enabled()),
|
||||
});
|
||||
keepDomWidgetWidthAuto(widget);
|
||||
// Not saved with the workflow either: options.serialize only keeps it out
|
||||
@@ -903,7 +998,7 @@ function buildPanel(node, config) {
|
||||
// dragged taller. With the preview off the floor is the bar alone.
|
||||
fillNodeHeight(widget, {
|
||||
minWidth: () => previewMinWidth(node.__ausbossInputPreview),
|
||||
minHeight: () => (enabled() ? PANEL_HEIGHT : OFF_HEIGHT),
|
||||
minHeight: () => panelFloor(node.__ausbossInputPreview, enabled()),
|
||||
minNodeSize: [PANEL_MIN_WIDTH, 90],
|
||||
exactMinWidth: true,
|
||||
});
|
||||
@@ -934,6 +1029,7 @@ function buildPanel(node, config) {
|
||||
};
|
||||
livePanels.add(state);
|
||||
if (config.tools?.length) bar.append(buildTools(state, config.tools, abort.signal));
|
||||
if (config.keep) bar.after(buildKeepRow(state, config.keep, abort.signal));
|
||||
if (config.readout) {
|
||||
state.readout = document.createElement("span");
|
||||
state.readout.className = "ausboss-input-preview-readout";
|
||||
@@ -958,9 +1054,10 @@ function buildPanel(node, config) {
|
||||
watcher.observe(stage);
|
||||
abort.signal.addEventListener("abort", () => watcher.disconnect(), { once: true });
|
||||
}
|
||||
img.addEventListener("load", () => showMedia(state, "image"));
|
||||
img.addEventListener("load", () => { stage.classList.remove("loading-image"); showMedia(state, "image"); });
|
||||
img.addEventListener("error", () => {
|
||||
if (img.getAttribute("src")) showPlaceholder(state, true);
|
||||
stage.classList.remove("loading-image");
|
||||
if (img.getAttribute("src") && !dropLightPreview(state)) showPlaceholder(state, true);
|
||||
});
|
||||
video.addEventListener("loadeddata", () => showMedia(state, "video"));
|
||||
video.addEventListener("error", () => {
|
||||
|
||||
@@ -289,3 +289,86 @@ export function repointLinks(text, nodes, groups) {
|
||||
.replace(ARROW_RE, (all, _arrow, id) => (id === undefined ? all : `${all.slice(0, -id.length - 1)}${follow(`#${id}`)}`));
|
||||
return changed ? next : null;
|
||||
}
|
||||
|
||||
// Info card: a small card with a title, rows and small print, chosen from the
|
||||
// note's menu. Its text is the note's ordinary text with one addition: a line
|
||||
// with a | in it is a row, the words before it on the left and the rest on
|
||||
// the right. A row in **bold** is the one to notice and is drawn in teal. The
|
||||
// first line is the card's title; a clock, info, warning, bulb or tick emoji
|
||||
// at its start is drawn as a small icon. A plain callout reads none of this,
|
||||
// so every note written before looks as it did, and a release before this
|
||||
// one shows the card as a plain note with its rows written out.
|
||||
const CARD_ICONS = new Map([
|
||||
["⏱", "clock"], ["⏲", "clock"], ["⏰", "clock"], ["\u{1F552}", "clock"], ["⌛", "clock"], ["⏳", "clock"],
|
||||
["ℹ", "info"], ["⚠", "warning"], ["\u{1F4A1}", "tip"], ["✅", "check"], ["✔", "check"],
|
||||
]);
|
||||
const CARD_ICON_RE = new RegExp(`^(${[...CARD_ICONS.keys()].join("|")})${VS16}?\\s*`, "u");
|
||||
export const CARD_TEMPLATE = [
|
||||
"ℹ️ Title",
|
||||
`${SMALL_MARK}A line of small print under the title.`,
|
||||
"",
|
||||
"Label | value",
|
||||
"**The one to notice | value**",
|
||||
"",
|
||||
`${SMALL_MARK}A note at the bottom.`,
|
||||
].join("\n");
|
||||
|
||||
// Where a row splits: the first | that is not inside a link, or -1.
|
||||
function cellSplit(line) {
|
||||
const masked = line.replace(WORDS_LINK_RE, (all) => " ".repeat(all.length));
|
||||
return masked.indexOf("|");
|
||||
}
|
||||
|
||||
// A row's two sides, or null when the line is not a row. The outer pipes of
|
||||
// a pasted Markdown table row are dropped.
|
||||
export function cardCells(line) {
|
||||
const bare = String(line ?? "").trim().replace(/^\|/, "").replace(/\|$/, "");
|
||||
const at = cellSplit(bare);
|
||||
if (at < 0) return null;
|
||||
let label = bare.slice(0, at).trim();
|
||||
let value = bare.slice(at + 1).trim();
|
||||
if (!label || !value) return null;
|
||||
// **label | value** marks the whole row.
|
||||
if (label.startsWith("**") && !label.slice(2).includes("**") && value.endsWith("**") && !value.slice(0, -2).includes("**")) {
|
||||
label = `${label}**`;
|
||||
value = `**${value}`;
|
||||
}
|
||||
return [label, value];
|
||||
}
|
||||
|
||||
// Text -> blocks of lines. A line is { kind: "title", icon, pieces },
|
||||
// { kind: "row", cells: [pieces, pieces], highlight } or { kind: "text",
|
||||
// pieces }; small print carries `small: true`. A Markdown table's ---|---
|
||||
// line is dropped.
|
||||
export function parseCard(text) {
|
||||
const blocks = [];
|
||||
let lines = [];
|
||||
let first = true;
|
||||
const flush = () => {
|
||||
if (lines.length) blocks.push(lines);
|
||||
lines = [];
|
||||
};
|
||||
for (const raw of String(text ?? "").replace(/\r\n?/g, "\n").split("\n")) {
|
||||
const line = raw.replace(HEADING_RE, "").trim();
|
||||
if (!line) { flush(); continue; }
|
||||
if (/^\|?[\s:|-]*-[\s:|-]*\|?$/.test(line) && line.includes("|")) continue;
|
||||
const small = SMALL_RE.test(raw);
|
||||
const cells = cardCells(line);
|
||||
let item;
|
||||
if (first && !small && !cells) {
|
||||
const icon = CARD_ICON_RE.exec(line);
|
||||
item = { kind: "title", icon: icon ? CARD_ICONS.get(icon[1]) : null, pieces: pieces(icon ? line.slice(icon[0].length) : line) };
|
||||
} else if (cells) {
|
||||
const sides = cells.map((cell) => pieces(cell));
|
||||
const shown = sides.flat().filter((piece) => piece.arrow || piece.text?.trim());
|
||||
item = { kind: "row", cells: sides, highlight: shown.length > 0 && shown.every((piece) => piece.bold) };
|
||||
} else {
|
||||
item = { kind: "text", pieces: pieces(line) };
|
||||
}
|
||||
first = false;
|
||||
if (small) item.small = true;
|
||||
lines.push(item);
|
||||
}
|
||||
flush();
|
||||
return blocks;
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import { app } from "../../../scripts/app.js";
|
||||
// Must match `version` in pyproject.toml — scripts/release_preflight.py
|
||||
// enforces it. The backend serves its copy at /ausboss/pack_version; a
|
||||
// mismatch means the browser cached JavaScript from an older install.
|
||||
export const AUSBOSS_JS_VERSION = "2.24.0";
|
||||
export const AUSBOSS_JS_VERSION = "2.25.0";
|
||||
|
||||
export const BRAND = "#00b4aa"; // AusBoss teal — keep node accents consistent.
|
||||
export const BRAND_DARK = "#007f78";
|
||||
|
||||
@@ -107,16 +107,59 @@ export function outputRecordQuery(record, index = null) {
|
||||
}).toString();
|
||||
}
|
||||
|
||||
// The /view query of the small copy Save Image writes for its face
|
||||
// ({ ausboss_preview: ["ausboss_preview/<name>.webp"] }, one for each entry
|
||||
// of images, in the temp folder). Null when the record has none: an older
|
||||
// run, another node, or the preview switched off.
|
||||
export function lightPreviewQuery(record, index = null) {
|
||||
const places = record?.ausboss_preview;
|
||||
const count = outputRecordCount(record);
|
||||
if (!count || !Array.isArray(places) || places.length !== count) return null;
|
||||
const at = Number.isInteger(index) && index >= 0 && index < count ? index : count - 1;
|
||||
const place = places[at];
|
||||
if (typeof place !== "string" || !place) return null;
|
||||
const slash = place.lastIndexOf("/");
|
||||
return new URLSearchParams({
|
||||
filename: place.slice(slash + 1),
|
||||
subfolder: slash >= 0 ? place.slice(0, slash) : "",
|
||||
type: "temp",
|
||||
}).toString();
|
||||
}
|
||||
|
||||
// How many pictures the node's last run left in its output record.
|
||||
export function outputRecordCount(record) {
|
||||
return Array.isArray(record?.images) ? record.images.length : 0;
|
||||
}
|
||||
|
||||
// Is node.imgs the picture of this output record?
|
||||
//
|
||||
// The frontend loads a result into node.imgs with a time limit (two tries of
|
||||
// about 8 seconds). A picture that takes longer, a big one over a slow link,
|
||||
// is dropped without a word and node.imgs keeps the run before. `loadedFor`
|
||||
// is the record that was current when node.imgs was last set; the file name
|
||||
// is compared as well, for a newer run that arrived while a picture loaded.
|
||||
export function ownResultIsCurrent(node, record, loadedFor) {
|
||||
const images = node?.imgs;
|
||||
const src = Array.isArray(images) && images.length ? images[images.length - 1]?.src : null;
|
||||
if (typeof src !== "string" || !src) return false;
|
||||
const wanted = outputRecordQuery(record);
|
||||
// No stored picture to compare with, or a frame streamed during the run.
|
||||
if (!wanted || !/[?&]filename=/.test(src)) return true;
|
||||
if (loadedFor !== record) return false;
|
||||
const have = new URLSearchParams(src.slice(src.indexOf("?") + 1));
|
||||
const want = new URLSearchParams(wanted);
|
||||
return ["filename", "subfolder", "type"].every((key) => (have.get(key) ?? "") === (want.get(key) ?? ""));
|
||||
}
|
||||
|
||||
// What the panel shows, best first: this node's result (node.imgs, then the
|
||||
// URL of its stored output), then whatever feeds it, then nothing.
|
||||
export function describeNodePreview(node, inputName, storedUrl = null) {
|
||||
return describeOwnResult(node)
|
||||
?? (storedUrl ? { kind: "url", url: storedUrl } : null)
|
||||
// `ownIsCurrent` false: node.imgs is an older run's (ownResultIsCurrent), so
|
||||
// the stored output goes first.
|
||||
export function describeNodePreview(node, inputName, storedUrl = null, ownIsCurrent = true) {
|
||||
const stored = storedUrl ? { kind: "url", url: storedUrl } : null;
|
||||
return (ownIsCurrent ? null : stored)
|
||||
?? describeOwnResult(node)
|
||||
?? stored
|
||||
?? describeSourcePreview(upstreamNode(node, inputName));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// 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.
|
||||
//
|
||||
// Under it, the preview: the picture as it goes in with the part that is
|
||||
// kept lit, and the picture as it comes out, both on one scale, with what
|
||||
// the size choice does in words. While the choice cuts something, a click
|
||||
// on the first picture picks the part to keep.
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { CROP_MODES, changeText, cropSideAt, keptPart, placeText, planLoadSize, shapeText, sizeText, stepFile, thumbSizes } 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}
|
||||
.ausboss-load-view{display:flex;align-items:center;justify-content:center;gap:10px;height:100%;min-width:0;font:500 10px/1.25 ${FONT_UI};color:${T.faint}}
|
||||
.ausboss-load-thumb{position:relative;flex:none;overflow:hidden;border-radius:2px;background-color:${T.fill};background-repeat:no-repeat}
|
||||
.ausboss-load-thumb.in{background-size:100% 100%}
|
||||
.ausboss-load-thumb.in.pick{cursor:pointer}
|
||||
.ausboss-load-thumb.out{outline:1px solid ${T.brand};outline-offset:0}
|
||||
.ausboss-load-kept{position:absolute;box-sizing:border-box;border:1px solid ${T.brand};box-shadow:0 0 0 400px rgba(8,10,11,.68);pointer-events:none}
|
||||
.ausboss-load-note{flex:0 1 auto;min-width:0;display:flex;flex-direction:column;align-items:center;gap:2px;text-align:center;user-select:none}
|
||||
.ausboss-load-note b{color:${T.inkSoft};font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.ausboss-load-note span{white-space:nowrap}
|
||||
`;
|
||||
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;
|
||||
}
|
||||
|
||||
// The widest and tallest a preview picture is drawn.
|
||||
const THUMB_WIDTH = 76;
|
||||
const THUMB_HEIGHT = 68;
|
||||
|
||||
export function loadImagePreview(node) {
|
||||
ensureCss();
|
||||
const root = document.createElement("div");
|
||||
root.className = "ausboss-load-view";
|
||||
const input = document.createElement("div");
|
||||
input.className = "ausboss-load-thumb in";
|
||||
const kept = document.createElement("div");
|
||||
kept.className = "ausboss-load-kept";
|
||||
input.append(kept);
|
||||
const note = document.createElement("div");
|
||||
note.className = "ausboss-load-note";
|
||||
const output = document.createElement("div");
|
||||
output.className = "ausboss-load-thumb out";
|
||||
root.append(input, note, output);
|
||||
let last = {};
|
||||
|
||||
const side = () => node.widgets?.find((item) => item.name === "crop_from") ?? null;
|
||||
const sideLinked = () => node.inputs?.find((slot) => slot?.widget?.name === "crop_from")?.link != null;
|
||||
input.addEventListener("click", (event) => {
|
||||
const widget = side();
|
||||
if (!widget || !input.classList.contains("pick")) return;
|
||||
const box = input.getBoundingClientRect();
|
||||
if (!(box.width > 0 && box.height > 0)) return;
|
||||
const next = cropSideAt((event.clientX - box.left) / box.width, (event.clientY - box.top) / box.height);
|
||||
if (next === widget.value) return;
|
||||
commitWidgetValue(node, widget, next, app.canvas);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
});
|
||||
|
||||
const line = (tag, text) => {
|
||||
const item = document.createElement(tag);
|
||||
item.textContent = text;
|
||||
return item;
|
||||
};
|
||||
const sync = (values = last) => {
|
||||
last = values;
|
||||
const file = String(values.image ?? "");
|
||||
const source = file ? readSize(file, () => { if (String(last.image ?? "") === file) sync(); }) : null;
|
||||
const plan = source ? planLoadSize(source.width, source.height, values) : null;
|
||||
root.style.visibility = plan ? "" : "hidden";
|
||||
if (!plan) return;
|
||||
const picture = `url(${JSON.stringify(viewUrl(file))})`;
|
||||
const sizes = thumbSizes(source.width, source.height, plan, THUMB_WIDTH, THUMB_HEIGHT);
|
||||
const part = keptPart(plan);
|
||||
Object.assign(input.style, { width: `${sizes.input[0]}px`, height: `${sizes.input[1]}px`, backgroundImage: picture });
|
||||
Object.assign(kept.style, {
|
||||
left: `${part.x * 100}%`, top: `${part.y * 100}%`,
|
||||
width: `${part.width * 100}%`, height: `${part.height * 100}%`,
|
||||
});
|
||||
const spareWide = plan.resizeWidth - plan.width;
|
||||
const spareHigh = plan.resizeHeight - plan.height;
|
||||
Object.assign(output.style, {
|
||||
width: `${sizes.output[0]}px`, height: `${sizes.output[1]}px`, backgroundImage: picture,
|
||||
backgroundSize: `${(plan.resizeWidth / plan.width) * 100}% ${(plan.resizeHeight / plan.height) * 100}%`,
|
||||
backgroundPosition: `${spareWide > 0 ? (plan.cropX / spareWide) * 100 : 0}% ${spareHigh > 0 ? (plan.cropY / spareHigh) * 100 : 0}%`,
|
||||
});
|
||||
const picks = CROP_MODES.includes(values.size) && !sideLinked();
|
||||
input.classList.toggle("pick", picks);
|
||||
input.title = picks
|
||||
? "The picture going in. The lit part is kept. Click the part you want to keep."
|
||||
: "The picture going in. The lit part is kept.";
|
||||
output.title = "The picture coming out.";
|
||||
const from = shapeText(source.width, source.height);
|
||||
const to = shapeText(plan.width, plan.height);
|
||||
const change = changeText(source.width, source.height, plan);
|
||||
note.replaceChildren(
|
||||
line("b", from === to ? from : `${from} → ${to}`),
|
||||
...(change ? change.split(" · ").map((text) => line("span", text)) : [line("span", "no change")]),
|
||||
);
|
||||
};
|
||||
return { element: root, sync };
|
||||
}
|
||||
|
||||
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,206 @@
|
||||
// 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", "crop", "crop to ratio"]);
|
||||
// Which part of the picture a crop keeps, as a 3 x 3 grid read row by row.
|
||||
export const CROP_GRID = Object.freeze(["top left", "top", "top right", "left", "center", "right", "bottom left", "bottom", "bottom right"]);
|
||||
// The choices that cut something off, so the kept part matters.
|
||||
export const CROP_MODES = Object.freeze(["crop to fill", "crop", "crop to ratio"]);
|
||||
|
||||
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);
|
||||
// Rounded down to a multiple, or left alone when smaller than one step.
|
||||
const inside = (value, step) => (value >= step ? Math.floor(value / step) * step : value);
|
||||
// Where a cut starts along one edge with `spare` pixels to lose.
|
||||
const cropStart = (spare, side, low, high) => (side.includes(low) ? 0 : side.includes(high) ? spare : Math.floor(spare / 2));
|
||||
|
||||
function ratioTerms(ratio, width, height) {
|
||||
if (ratio === "width : height") return [Math.max(1, width), Math.max(1, height)];
|
||||
const [across, down] = String(ratio ?? "1:1").split(":").map((term) => Math.trunc(Number(term)));
|
||||
return [Math.max(1, across || 1), Math.max(1, down || 1)];
|
||||
}
|
||||
|
||||
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));
|
||||
const side = CROP_GRID.includes(values.crop_from) ? values.crop_from : "center";
|
||||
const cut = (resizeWidth, resizeHeight, width, height) => ({
|
||||
resizeWidth, resizeHeight,
|
||||
cropX: cropStart(resizeWidth - width, side, "left", "right"),
|
||||
cropY: cropStart(resizeHeight - height, side, "top", "bottom"),
|
||||
width, height,
|
||||
});
|
||||
|
||||
if (size === "crop") {
|
||||
return cut(sourceW, sourceH, inside(Math.min(boxW, sourceW), step), inside(Math.min(boxH, sourceH), step));
|
||||
}
|
||||
|
||||
if (size === "crop to ratio") {
|
||||
const [across, down] = ratioTerms(values.ratio, boxW, boxH);
|
||||
let width = sourceW;
|
||||
let height = sourceH;
|
||||
if (sourceW * down >= sourceH * across) width = Math.min(sourceW, Math.max(1, roundHalfUp((sourceH * across) / down)));
|
||||
else height = Math.min(sourceH, Math.max(1, roundHalfUp((sourceW * down) / across)));
|
||||
return cut(sourceW, sourceH, inside(width, step), inside(height, step));
|
||||
}
|
||||
|
||||
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 cut(resizeWidth, resizeHeight, 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}`;
|
||||
}
|
||||
|
||||
// What a plan does to the picture, in a few words for the node: how much it
|
||||
// is resized and how much is cut off, in the file's own pixels. "" when the
|
||||
// picture comes out as it went in.
|
||||
export function changeText(sourceWidth, sourceHeight, plan) {
|
||||
if (!plan || !(sourceWidth > 0 && sourceHeight > 0)) return "";
|
||||
const parts = [];
|
||||
const percent = Math.round((plan.resizeWidth / sourceWidth) * 100);
|
||||
if (plan.resizeWidth !== sourceWidth || plan.resizeHeight !== sourceHeight) parts.push(`${percent}% size`);
|
||||
const cutWide = Math.round(((plan.resizeWidth - plan.width) * sourceWidth) / plan.resizeWidth);
|
||||
const cutHigh = Math.round(((plan.resizeHeight - plan.height) * sourceHeight) / plan.resizeHeight);
|
||||
if (cutWide > 0 && cutHigh > 0) parts.push(`cuts ${cutWide} × ${cutHigh} px`);
|
||||
else if (cutWide > 0) parts.push(`cuts ${cutWide} px of width`);
|
||||
else if (cutHigh > 0) parts.push(`cuts ${cutHigh} px of height`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
const SHAPES = [[1, 1], [5, 4], [4, 3], [3, 2], [16, 10], [16, 9], [2, 1], [21, 9]];
|
||||
|
||||
// A picture's shape in words people use: "3:4", "16:9". A size within one
|
||||
// percent of a common shape gets that name (rounding to a multiple moves a
|
||||
// 16:9 picture a few pixels off it); any other gets "~1.34:1" or "~1:1.34".
|
||||
export function shapeText(width, height) {
|
||||
if (!(width > 0 && height > 0)) return "";
|
||||
const wide = width >= height;
|
||||
const value = wide ? width / height : height / width;
|
||||
const near = SHAPES.find(([across, down]) => Math.abs(value - across / down) <= (across / down) * 0.01);
|
||||
if (near) return wide ? `${near[0]}:${near[1]}` : `${near[1]}:${near[0]}`;
|
||||
const rounded = Math.round(value * 100) / 100;
|
||||
return wide ? `~${rounded}:1` : `~1:${rounded}`;
|
||||
}
|
||||
|
||||
// The part of the file a plan keeps, as fractions of the file's width and
|
||||
// height: { x, y, width, height }, all 0 to 1.
|
||||
export function keptPart(plan) {
|
||||
if (!plan) return { x: 0, y: 0, width: 1, height: 1 };
|
||||
return {
|
||||
x: plan.cropX / plan.resizeWidth,
|
||||
y: plan.cropY / plan.resizeHeight,
|
||||
width: Math.min(1, plan.width / plan.resizeWidth),
|
||||
height: Math.min(1, plan.height / plan.resizeHeight),
|
||||
};
|
||||
}
|
||||
|
||||
// The sizes two small pictures are drawn at so their difference shows: both
|
||||
// on one scale, the larger filling a box of maxWidth x maxHeight, and neither
|
||||
// edge under `least` pixels.
|
||||
export function thumbSizes(sourceWidth, sourceHeight, plan, maxWidth, maxHeight, least = 10) {
|
||||
const outW = plan?.width ?? sourceWidth;
|
||||
const outH = plan?.height ?? sourceHeight;
|
||||
const scale = Math.min(maxWidth / Math.max(sourceWidth, outW), maxHeight / Math.max(sourceHeight, outH));
|
||||
const at = (value) => Math.max(least, Math.round(value * scale));
|
||||
return { input: [at(sourceWidth), at(sourceHeight)], output: [at(outW), at(outH)] };
|
||||
}
|
||||
|
||||
// The crop side under a click at (x, y), each 0 to 1 across the picture.
|
||||
export function cropSideAt(x, y) {
|
||||
const third = (value) => (value < 1 / 3 ? 0 : value < 2 / 3 ? 1 : 2);
|
||||
return CROP_GRID[third(y) * 3 + third(x)];
|
||||
}
|
||||
|
||||
// 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}`;
|
||||
}
|
||||
@@ -25,7 +25,11 @@ export function ensurePanelControls() {
|
||||
.ausboss-panel-switch i::after{content:"";position:absolute;top:2px;left:2px;width:9px;height:9px;border-radius:50%;background:${T.inkSoft}}
|
||||
.ausboss-panel-switch[aria-checked=true] i{background:${T.brand}}
|
||||
.ausboss-panel-switch[aria-checked=true] i::after{left:13px;background:${T.inkHover}}
|
||||
.ausboss-compact-action:focus-visible,.ausboss-panel-switch:focus-visible{outline:2px solid ${T.brand};outline-offset:1px}
|
||||
.ausboss-primary-action{box-sizing:border-box;flex:none;height:22px;min-width:24px;padding:0 14px;border:0;border-radius:${RADIUS_XS}px;background:${T.brand};color:${T.onBrand};font:700 11px/1 ${controlFont};white-space:nowrap;cursor:pointer;pointer-events:auto}
|
||||
.ausboss-primary-action:hover:enabled{filter:brightness(1.12)}
|
||||
.ausboss-primary-action.quiet{background:${T.fill};color:${T.inkSoft}}
|
||||
.ausboss-primary-action:disabled{opacity:.6;cursor:default}
|
||||
.ausboss-compact-action:focus-visible,.ausboss-primary-action:focus-visible,.ausboss-panel-switch:focus-visible{outline:2px solid ${T.brand};outline-offset:1px}
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
@@ -40,6 +44,19 @@ export function compactAction(label, title = "") {
|
||||
return button;
|
||||
}
|
||||
|
||||
// The one thing a panel wants done next, as a solid button: Save Image's
|
||||
// "Save picture" over a picture that is not saved yet. `quiet` (a class the
|
||||
// panel sets) is the same button once it has nothing left to do.
|
||||
export function primaryAction(label, title = "") {
|
||||
ensurePanelControls();
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "ausboss-primary-action";
|
||||
button.textContent = label;
|
||||
button.title = title;
|
||||
return button;
|
||||
}
|
||||
|
||||
export function panelSwitch(label, title) {
|
||||
ensurePanelControls();
|
||||
const button = document.createElement("button");
|
||||
|
||||
@@ -69,3 +69,25 @@ export function heldPlaces(record) {
|
||||
});
|
||||
return held.length && held.every(Boolean) ? held : [];
|
||||
}
|
||||
|
||||
// What the row over held pictures says, and what its button does.
|
||||
// held how many pictures the node holds
|
||||
// kept how many of them are saved already
|
||||
// all a click keeps every picture (one picture, the grid, preview off)
|
||||
// done nothing is left to keep where the click would act
|
||||
// saving a save is on its way
|
||||
// name the file a single picture was saved as, when known
|
||||
// `quiet` is the button once it has nothing left to do.
|
||||
export function keepWords({ held = 0, kept = 0, all = true, done = false, saving = false, name = "" } = {}) {
|
||||
const note = !kept
|
||||
? "Not saved yet"
|
||||
: held < 2
|
||||
? (name ? `Saved as ${name}` : "Saved")
|
||||
: kept >= held ? `All ${held} saved` : `${kept} of ${held} saved`;
|
||||
if (saving) return { note, button: "Saving...", quiet: false };
|
||||
if (done) return { note, button: "Saved", quiet: true };
|
||||
const button = held < 2
|
||||
? "Save picture"
|
||||
: !all ? "Save this one" : kept ? "Save the rest" : `Save all ${held}`;
|
||||
return { note, button, quiet: false };
|
||||
}
|
||||
|
||||
@@ -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 { loadImagePreview, 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";
|
||||
@@ -27,6 +28,9 @@ import { valueToTake, wiredLimits } from "../shared/wired_limits.mjs";
|
||||
const viewUrl = (value) => api.apiURL(`/view?${mediaViewQuery(value)}`);
|
||||
|
||||
const isMode = (name, ...modes) => (values) => modes.includes(values[name]);
|
||||
// Load Image: the choices that read the width and height boxes.
|
||||
const loadImageUsesBox = (values) => ["fit inside", "crop to fill", "crop"].includes(values.size)
|
||||
|| (values.size === "crop to ratio" && values.ratio === "width : height");
|
||||
// Stitch Inpaint: Seam lives in the card's gear menu (shared/stitch_seam.mjs
|
||||
// holds the menu and the rules). The gear in the card's corner opens the
|
||||
// menu; a "blend in" chip beside it shows the mode at a glance and opens the
|
||||
@@ -434,6 +438,31 @@ 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 },
|
||||
{ panel: loadImagePreview, height: 72 },
|
||||
{ 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: "ratio", label: "Ratio", kind: "select", when: isMode("size", "crop to ratio") },
|
||||
{ widget: "width", label: "Width", suffix: "px", when: loadImageUsesBox },
|
||||
{ widget: "height", label: "Height", suffix: "px", when: loadImageUsesBox },
|
||||
{ widget: "crop_from", label: "Keep", kind: "select", when: isMode("size", "crop to fill", "crop", "crop to ratio") },
|
||||
{ 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,71 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "放大"
|
||||
},
|
||||
"crop_from": {
|
||||
"name": "保留部分"
|
||||
},
|
||||
"ratio": {
|
||||
"name": "比例"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "图像"
|
||||
},
|
||||
"1": {
|
||||
"name": "遮罩"
|
||||
},
|
||||
"2": {
|
||||
"name": "宽度"
|
||||
},
|
||||
"3": {
|
||||
"name": "高度"
|
||||
},
|
||||
"4": {
|
||||
"name": "文件名"
|
||||
},
|
||||
"5": {
|
||||
"name": "原始宽度"
|
||||
},
|
||||
"6": {
|
||||
"name": "原始高度"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AUSBOSS_NODES_LoadImagePad": {
|
||||
"display_name": "加载图像 + 扩边 🆎",
|
||||
"description": "在一个节点里加载图像并扩边成扩图画布。拖动节点上画布的任意一边,直观地设置每边的扩边量;遮罩正好覆盖新扩出的部分,可选择向内羽化跨过接缝。画布会取整到整数倍,但不会给你没扩的边加条带;设置百万像素目标时会先缩放原图,让遮罩接缝保持清晰。",
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
@@ -271,6 +272,36 @@ def frame_to_pil(frame: torch.Tensor) -> Image.Image:
|
||||
return Image.fromarray(array)
|
||||
|
||||
|
||||
# A small copy of each picture, for the node's face. The saved file can be
|
||||
# tens of megabytes; this one is a few hundred kilobytes, so the picture
|
||||
# shows at once on a slow connection too. It lives in the temp folder, which
|
||||
# ComfyUI empties when it starts.
|
||||
PREVIEW_FOLDER = "ausboss_preview"
|
||||
PREVIEW_EDGE = 2048
|
||||
|
||||
|
||||
def write_light_previews(temp_root, frames) -> list[str]:
|
||||
"""Write one small WebP per frame; returns each place below the temp folder.
|
||||
|
||||
Never in the way of a save: if anything goes wrong the list is empty
|
||||
and the node shows the saved file itself, as it always did.
|
||||
"""
|
||||
try:
|
||||
folder = Path(temp_root) / PREVIEW_FOLDER
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
token = secrets.token_hex(8)
|
||||
places = []
|
||||
for index, frame in enumerate(frames):
|
||||
image = frame_to_pil(frame)
|
||||
image.thumbnail((PREVIEW_EDGE, PREVIEW_EDGE), Image.Resampling.LANCZOS)
|
||||
name = f"{token}_{index:03d}.webp"
|
||||
image.save(folder / name, format="WEBP", quality=88, method=2)
|
||||
places.append(f"{PREVIEW_FOLDER}/{name}")
|
||||
return places
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _png_info(metadata: dict | None):
|
||||
if not metadata:
|
||||
return None
|
||||
@@ -340,6 +371,8 @@ __all__ = [
|
||||
"IMAGE_FORMATS",
|
||||
"IMAGE_SAVE_LOCK",
|
||||
"OUTPUT_DIR_RULE",
|
||||
"PREVIEW_EDGE",
|
||||
"PREVIEW_FOLDER",
|
||||
"counter_pattern",
|
||||
"encode_image",
|
||||
"existing_action",
|
||||
@@ -357,4 +390,5 @@ __all__ = [
|
||||
"sidecar_path",
|
||||
"split_prefix",
|
||||
"strip_image_extension",
|
||||
"write_light_previews",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""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",
|
||||
"crop",
|
||||
"crop to ratio",
|
||||
)
|
||||
# Which part of the picture a crop keeps.
|
||||
CROP_SIDES = (
|
||||
"center",
|
||||
"top",
|
||||
"bottom",
|
||||
"left",
|
||||
"right",
|
||||
"top left",
|
||||
"top right",
|
||||
"bottom left",
|
||||
"bottom right",
|
||||
)
|
||||
# The last one takes its shape from the width and height boxes.
|
||||
RATIOS = (
|
||||
"1:1",
|
||||
"4:3",
|
||||
"3:4",
|
||||
"3:2",
|
||||
"2:3",
|
||||
"16:9",
|
||||
"9:16",
|
||||
"5:4",
|
||||
"4:5",
|
||||
"21:9",
|
||||
"9:21",
|
||||
"2:1",
|
||||
"1:2",
|
||||
"width : height",
|
||||
)
|
||||
|
||||
|
||||
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 _multiple_inside(value: int, multiple: int) -> int:
|
||||
"""``value`` rounded down to a multiple, or left alone when it is smaller
|
||||
than one step: a cut never asks for more than the picture holds."""
|
||||
step = max(1, int(multiple))
|
||||
return (int(value) // step) * step if int(value) >= step else int(value)
|
||||
|
||||
|
||||
def _crop_start(spare: int, side: str, low: str, high: str) -> int:
|
||||
"""Where a cut starts along one edge with ``spare`` pixels to lose."""
|
||||
if low in side:
|
||||
return 0
|
||||
if high in side:
|
||||
return int(spare)
|
||||
return int(spare) // 2
|
||||
|
||||
|
||||
def _ratio_terms(ratio: str, width: int, height: int) -> tuple[int, int]:
|
||||
if ratio == "width : height":
|
||||
return max(1, int(width)), max(1, int(height))
|
||||
across, _, down = str(ratio).partition(":")
|
||||
return max(1, int(across)), max(1, int(down))
|
||||
|
||||
|
||||
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,
|
||||
crop_from: str = "center",
|
||||
ratio: str = "1:1",
|
||||
) -> 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.
|
||||
|
||||
``crop`` cuts a width x height piece out of the picture and ``crop to
|
||||
ratio`` cuts the largest piece of a shape; neither resizes. ``crop_from``
|
||||
names the part every cut keeps.
|
||||
"""
|
||||
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 crop_from not in CROP_SIDES:
|
||||
raise ValueError(f"crop_from must be one of {CROP_SIDES}, got '{crop_from}'.")
|
||||
if ratio not in RATIOS:
|
||||
raise ValueError(f"ratio must be one of {RATIOS}, got '{ratio}'.")
|
||||
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))
|
||||
|
||||
def cut(resize_w: int, resize_h: int, out_w: int, out_h: int) -> dict[str, int]:
|
||||
return {
|
||||
"resize_width": resize_w,
|
||||
"resize_height": resize_h,
|
||||
"crop_x": _crop_start(resize_w - out_w, crop_from, "left", "right"),
|
||||
"crop_y": _crop_start(resize_h - out_h, crop_from, "top", "bottom"),
|
||||
"width": out_w,
|
||||
"height": out_h,
|
||||
}
|
||||
|
||||
if size == "crop":
|
||||
out_w = _multiple_inside(min(max(1, int(width)), source_w), step)
|
||||
out_h = _multiple_inside(min(max(1, int(height)), source_h), step)
|
||||
return cut(source_w, source_h, out_w, out_h)
|
||||
|
||||
if size == "crop to ratio":
|
||||
across, down = _ratio_terms(ratio, width, height)
|
||||
if source_w * down >= source_h * across:
|
||||
out_h = source_h
|
||||
out_w = min(source_w, max(1, _round_half_up(source_h * across / down)))
|
||||
else:
|
||||
out_w = source_w
|
||||
out_h = min(source_h, max(1, _round_half_up(source_w * down / across)))
|
||||
return cut(source_w, source_h, _multiple_inside(out_w, step), _multiple_inside(out_h, step))
|
||||
|
||||
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 cut(plan["resize_width"], plan["resize_height"], canvas_w, 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__ = ["CROP_SIDES", "RATIOS", "SIZE_MODES", "apply_load_size", "frames_to_image_and_mask", "plan_load_size"]
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Load Image 🆎."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ._execution_helpers import ignore_extra_inputs
|
||||
from ._load_image_helpers import (
|
||||
CROP_SIDES,
|
||||
RATIOS,
|
||||
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. It can also cut a piece or a shape out of the picture "
|
||||
"without resizing, keeping the side you choose. The node shows the "
|
||||
"picture going in and coming out, with both sizes, before you run."
|
||||
)
|
||||
SEARCH_ALIASES = [
|
||||
"load image",
|
||||
"load picture",
|
||||
"image loader",
|
||||
"resize on load",
|
||||
"megapixels",
|
||||
"longest side",
|
||||
"crop",
|
||||
"aspect ratio",
|
||||
"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. crop cuts a piece out without "
|
||||
"resizing. crop to ratio cuts the largest piece "
|
||||
"of a shape."
|
||||
),
|
||||
},
|
||||
),
|
||||
"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, crop to fill and crop: the box's width in pixels.",
|
||||
},
|
||||
),
|
||||
"height": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1024,
|
||||
"min": 16,
|
||||
"max": 16384,
|
||||
"step": 1,
|
||||
"tooltip": "For fit inside, crop to fill and crop: 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."
|
||||
),
|
||||
},
|
||||
),
|
||||
"crop_from": (
|
||||
list(CROP_SIDES),
|
||||
{
|
||||
"default": "center",
|
||||
"tooltip": (
|
||||
"For the three crop choices: the part of the "
|
||||
"picture that is kept. top keeps the top and cuts "
|
||||
"from the bottom."
|
||||
),
|
||||
},
|
||||
),
|
||||
"ratio": (
|
||||
list(RATIOS),
|
||||
{
|
||||
"default": "1:1",
|
||||
"tooltip": (
|
||||
"For crop to ratio: the shape to cut, width to "
|
||||
"height. width : height uses the numbers in the "
|
||||
"width and height boxes."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
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,
|
||||
crop_from="center",
|
||||
ratio="1:1",
|
||||
):
|
||||
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),
|
||||
str(crop_from),
|
||||
str(ratio),
|
||||
)
|
||||
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"]
|
||||
@@ -18,6 +18,7 @@ from ._image_save_helpers import (
|
||||
resolve_output_root,
|
||||
sanitize_exact_name,
|
||||
sidecar_path,
|
||||
write_light_previews,
|
||||
)
|
||||
from ._route_errors import safe_route_error
|
||||
from ._step_preview_helpers import STEP_PREVIEW_DEFAULT, register_step_preview_handler
|
||||
@@ -406,7 +407,10 @@ class AusBossSaveImage:
|
||||
lambda path, frame: encode_image(path, frame, format, metadata),
|
||||
)
|
||||
return {
|
||||
"ui": {"images": previews, "ausboss_saved_path": [], "ausboss_held": [token]},
|
||||
"ui": {
|
||||
"images": previews, "ausboss_saved_path": [], "ausboss_held": [token],
|
||||
"ausboss_preview": self._light_previews(images, preview),
|
||||
},
|
||||
"result": ("", images),
|
||||
}
|
||||
|
||||
@@ -418,6 +422,7 @@ class AusBossSaveImage:
|
||||
actions = settle_destinations(planned, has_caption, output_root)
|
||||
|
||||
saved: list[Path] = []
|
||||
saved_frames = []
|
||||
for (path, _policy), action, frame in zip(planned, actions, images):
|
||||
if action == "skip":
|
||||
continue
|
||||
@@ -426,11 +431,13 @@ class AusBossSaveImage:
|
||||
if has_caption:
|
||||
sidecar_path(path).write_text(caption_value, encoding="utf-8")
|
||||
saved.append(path)
|
||||
saved_frames.append(frame)
|
||||
|
||||
# Every destination was confined to the output folder above, so each
|
||||
# saved file previews from there and is shown relative to it. The
|
||||
# preview switch is display only: these entries are also how the
|
||||
# queue lists the save, and there is no temp file to skip.
|
||||
# saved file previews from there and is shown relative to it. These
|
||||
# entries are also how the queue lists the save, so the preview
|
||||
# switch leaves them alone; it only skips the small copies the node
|
||||
# shows on its face (ausboss_preview).
|
||||
previews: list[dict] = []
|
||||
resolved_root = output_root.resolve()
|
||||
shown_paths: list[str] = []
|
||||
@@ -444,10 +451,24 @@ class AusBossSaveImage:
|
||||
})
|
||||
shown_paths.append(resolved.relative_to(resolved_root).as_posix())
|
||||
return {
|
||||
"ui": {"images": previews, "ausboss_saved_path": shown_paths[:1]},
|
||||
"ui": {
|
||||
"images": previews, "ausboss_saved_path": shown_paths[:1],
|
||||
"ausboss_preview": self._light_previews(saved_frames, preview),
|
||||
},
|
||||
"result": (str(saved[0]) if saved else "", images),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _light_previews(frames, preview) -> list[str]:
|
||||
# The node's face shows these instead of the full files. Preview
|
||||
# off: nothing is shown, so nothing is written.
|
||||
if not preview:
|
||||
return []
|
||||
try:
|
||||
return write_light_previews(folder_paths.get_temp_directory(), frames)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **_values):
|
||||
# A saver's job is the side effect: deleting the file and queueing
|
||||
|
||||
+3
-3
@@ -2,8 +2,8 @@
|
||||
name = "ausboss-nodes"
|
||||
# Manager/registry search matches against this text, so it names the actual
|
||||
# nodes instead of describing the collection abstractly.
|
||||
description = "ComfyUI-AusBoss (AusBoss nodes): image and video nodes with compact controls: Image and Video Crop + Rotate + Pad (frame or clip), Load Image + Pad, Image Folder, Image Resize, Align Image, Image Size, Color Match, Image Compare A/B, Realign to Source (experimental), Save Image (PNG, WebP/JXL, captions), Load Video, Load Audio, Save Video, Select Frame, Frame Interpolate, Mask Refine, Mask by Name and Mask by Touch (by name or click), LaMa Inpaint, Crop For Inpaint, Stitch Inpaint, Tiled Upscale and Tiled Upscale Stitch, FlashVSR Upscale, Edit Lock and Edit Lock Restore, LoRA Loader, Krea 2 Encode and Outpaint Model Patch, Latent Size, Layout Boxes, Seed, Select Every Nth, Split Batch, Merge Batches, Text, Text Lines, Prompt Variations, H3 Prompt Writer, Integer, Float, Math Expression, Show Text, Free Memory, Workflow Note, Workflow Note Tile, Callout, Callout Tile, Workflow Switches, Run Timer, and Step Preview. Example workflows for Qwen Image 2.1, Krea 2, Klein 9B and LTX 2.3."
|
||||
version = "2.24.0"
|
||||
description = "ComfyUI-AusBoss (AusBoss nodes): image and video nodes with compact controls: Image and Video Crop + Rotate + Pad (frame or clip), Load Image, Load Image + Pad, Image Folder, Image Resize, Align Image, Image Size, Color Match, Image Compare A/B, Realign to Source (experimental), Save Image (PNG, WebP/JXL, captions), Load Video, Load Audio, Save Video, Select Frame, Frame Interpolate, Mask Refine, Mask by Name and Mask by Touch, LaMa Inpaint, Crop For Inpaint, Stitch Inpaint, Tiled Upscale and Tiled Upscale Stitch, FlashVSR Upscale, Edit Lock and Edit Lock Restore, LoRA Loader, Krea 2 Encode and Outpaint Model Patch, Latent Size, Layout Boxes, Seed, Select Every Nth, Split Batch, Merge Batches, Text, Text Lines, Prompt Variations, H3 Prompt Writer, Integer, Float, Math Expression, Show Text, Free Memory, Workflow Note, Workflow Note Tile, Callout, Callout Tile, Workflow Switches, Run Timer, and Step Preview. Example workflows for Qwen Image 2.1, Krea 2, Klein 9B and LTX 2.3."
|
||||
version = "2.25.0"
|
||||
# comfy-cli serializes this to JSON verbatim, so `file` publishes the literal
|
||||
# string {"file": "LICENSE"} to the registry listing. `text` puts a readable
|
||||
# name there instead. Both forms are supported; the LICENSE file is unchanged.
|
||||
@@ -21,7 +21,7 @@ keywords = [
|
||||
"image folder", "batch folder", "callout", "sticky note", "arrows",
|
||||
"tiled upscale", "upscale", "tiles", "restore", "layout boxes", "bounding boxes", "moss image", "replace text", "rotate box",
|
||||
"step preview", "live preview", "stop run",
|
||||
"text lines", "prompt list", "prompt variations", "wildcards", "flashvsr", "video upscale", "edit lock",
|
||||
"text lines", "prompt list", "prompt variations", "wildcards", "flashvsr", "video upscale", "edit lock", "load image",
|
||||
]
|
||||
|
||||
# Read by comfy-cli into the registry's supported_os field. Nothing in the pack
|
||||
|
||||
@@ -73,6 +73,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",
|
||||
|
||||
+89
-1
@@ -5,7 +5,7 @@ import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
ARROW_ANGLE, ARROW_CHOICES, SMALL_MARK, arrowText, fitFontSize, linkTarget, parseBlocks, pastedLink, repointLinks, targetId,
|
||||
ARROW_ANGLE, ARROW_CHOICES, CARD_TEMPLATE, SMALL_MARK, arrowText, cardCells, parseCard, fitFontSize, linkTarget, parseBlocks, pastedLink, repointLinks, targetId,
|
||||
toggleSmall, urlLabel, wrapLink, tileLinkFields, tileLinkText,
|
||||
} from "../js/shared/callout.mjs";
|
||||
|
||||
@@ -322,3 +322,91 @@ test("a note pasted with what its links name follows the copies", () => {
|
||||
assert.equal(repointLinks("[elsewhere](#40) ⬆️ plain", nodes, groups), null);
|
||||
assert.equal(repointLinks(undefined, nodes, groups), null);
|
||||
});
|
||||
|
||||
// Info card. The words of a piece list, so a test reads like the card.
|
||||
const words = (pieces) => pieces.map((piece) => piece.text ?? "").join("");
|
||||
|
||||
test("an Info card reads a title, rows and small print from plain text", () => {
|
||||
const blocks = parseCard([
|
||||
"⏱ Too slow?",
|
||||
"###### Redraw time on an RTX 5090. Smaller cards take longer.",
|
||||
"",
|
||||
"2x, 18 steps | ~4 min",
|
||||
"2x, 10 steps | ~3 min",
|
||||
"1.5x, 18 steps | ~2.5 min",
|
||||
"**1.5x, 10 steps (default) | ~2 min**",
|
||||
"",
|
||||
"###### 1.5x is a 4608-wide sheet, 2x is 6144.",
|
||||
].join("\n"));
|
||||
assert.equal(blocks.length, 3);
|
||||
const [head, rows, foot] = blocks;
|
||||
assert.deepEqual(head.map((line) => line.kind), ["title", "text"]);
|
||||
assert.equal(head[0].icon, "clock");
|
||||
assert.equal(words(head[0].pieces), "Too slow?");
|
||||
assert.equal(head[1].small, true);
|
||||
assert.deepEqual(
|
||||
rows.map((row) => [row.kind, words(row.cells[0]), words(row.cells[1]), row.highlight]),
|
||||
[
|
||||
["row", "2x, 18 steps", "~4 min", false],
|
||||
["row", "2x, 10 steps", "~3 min", false],
|
||||
["row", "1.5x, 18 steps", "~2.5 min", false],
|
||||
["row", "1.5x, 10 steps (default)", "~2 min", true],
|
||||
],
|
||||
);
|
||||
assert.equal(foot[0].kind, "text");
|
||||
assert.equal(foot[0].small, true);
|
||||
});
|
||||
|
||||
test("a row is marked by bold on the whole row, written either way", () => {
|
||||
assert.equal(parseCard("T\n**a | b**")[0][1].highlight, true);
|
||||
assert.equal(parseCard("T\n**a** | **b**")[0][1].highlight, true);
|
||||
// Bold on one word stresses the word; the row is not marked.
|
||||
assert.equal(parseCard("T\n**a** b | c")[0][1].highlight, false);
|
||||
assert.equal(parseCard("T\na | **b**")[0][1].highlight, false);
|
||||
});
|
||||
|
||||
test("a row splits at the first | outside a link", () => {
|
||||
assert.deepEqual(cardCells("Size (1.5x | 2x)"), ["Size (1.5x", "2x)"]);
|
||||
assert.deepEqual(cardCells("[a|b](https://example.com/a|b) | v"), ["[a|b](https://example.com/a|b)", "v"]);
|
||||
assert.deepEqual(cardCells("a | b | c"), ["a", "b | c"]);
|
||||
// A pasted Markdown table row loses its outer pipes; its --- line is dropped.
|
||||
assert.deepEqual(cardCells("| 2x | ~4 min |"), ["2x", "~4 min"]);
|
||||
assert.deepEqual(parseCard("T\n| a | b |\n|---|:--:|\n| c | d |")[0].map((line) => line.kind), ["title", "row", "row"]);
|
||||
// One side empty is not a row.
|
||||
for (const line of ["a |", "| b", "|", "no pipe"]) assert.equal(cardCells(line), null, line);
|
||||
});
|
||||
|
||||
test("only the card's first line is its title, and only a known emoji is an icon", () => {
|
||||
assert.equal(parseCard("a | b\nT")[0][0].kind, "row");
|
||||
assert.equal(parseCard("###### small\nT")[0][0].kind, "text");
|
||||
assert.deepEqual(parseCard("T\nU")[0].map((line) => line.kind), ["title", "text"]);
|
||||
for (const [title, icon] of [["ℹ️ Note", "info"], ["⚠️ Careful", "warning"], ["💡 Tip", "tip"], ["✅ Done", "check"], ["⏱️Fast", "clock"], ["🙂 Hi", null], ["Plain", null]]) {
|
||||
const line = parseCard(title)[0][0];
|
||||
assert.equal(line.icon, icon, title);
|
||||
if (icon) assert.ok(!/^\s/.test(words(line.pieces)), title);
|
||||
}
|
||||
assert.equal(words(parseCard("🙂 Hi")[0][0].pieces), "🙂 Hi");
|
||||
});
|
||||
|
||||
test("the card's starter text shows every part of a card", () => {
|
||||
const [head, rows, foot] = parseCard(CARD_TEMPLATE);
|
||||
assert.equal(head[0].icon, "info");
|
||||
assert.equal(head[1].small, true);
|
||||
assert.deepEqual(rows.map((row) => row.highlight), [false, true]);
|
||||
assert.equal(foot[0].small, true);
|
||||
});
|
||||
|
||||
test("a plain callout reads a | as text, so no note written before changes", () => {
|
||||
const [[line]] = parseBlocks("2x, 18 steps | ~4 min");
|
||||
assert.deepEqual(line, [{ text: "2x, 18 steps | ~4 min", bold: false }]);
|
||||
});
|
||||
|
||||
test("an Info card saves as a plain Callout, so an older pack shows its text", () => {
|
||||
// Tiles save as Callout Tile and an older pack reports that node missing.
|
||||
// A card is readable as a plain note there, so it keeps the plain type.
|
||||
const source = readFileSync(join(ROOT, "js", "callout", "index.js"), "utf-8");
|
||||
const saved = /serializeNoteIdentity\(saved, (\[[^\]]*\])\.includes/.exec(source);
|
||||
assert.ok(saved, "the tile list in onSerialize moved");
|
||||
assert.ok(!saved[1].includes("card"), "an Info card would save as a Callout Tile");
|
||||
assert.match(source, /\["card","Info card"\]/, "the Info card is gone from the note's menu");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import vm from "node:vm";
|
||||
|
||||
// Run the real extension entry with a fake page in place of ComfyUI.
|
||||
function page({ settings = {}, answer = { ok: true, status: 200 } } = {}) {
|
||||
const requests = [];
|
||||
const toasts = [];
|
||||
const app = {
|
||||
ui: { settings: { getSettingValue: (id) => settings[id] } },
|
||||
registerExtension(extension) { this.extension = extension; },
|
||||
};
|
||||
const api = {
|
||||
async fetchApi(path, options) {
|
||||
requests.push({ path, ...options });
|
||||
if (answer instanceof Error) throw answer;
|
||||
return answer;
|
||||
},
|
||||
};
|
||||
const context = vm.createContext({ app, api, showToast: (toast) => toasts.push(toast), JSON });
|
||||
const source = readFileSync(new URL("../js/clear_vram/index.js", import.meta.url), "utf8")
|
||||
.replace(/^import[\s\S]*?;\s*/gm, "");
|
||||
vm.runInContext(source, context);
|
||||
return { extension: app.extension, requests, toasts };
|
||||
}
|
||||
|
||||
async function click(p) {
|
||||
const [item] = p.extension.getCanvasMenuItems();
|
||||
await item.callback();
|
||||
}
|
||||
|
||||
test("the setting follows the pack's id and category rule and is on by default", () => {
|
||||
const [setting] = page().extension.settings;
|
||||
assert.equal(setting.id, "AusBoss.Memory.ClearVramMenu");
|
||||
assert.equal(setting.type, "boolean");
|
||||
assert.equal(setting.defaultValue, true);
|
||||
assert.deepEqual([...setting.category], ["🆎 AusBoss", "Memory", "Clear VRAM menu"]);
|
||||
});
|
||||
|
||||
test("the canvas menu shows Clear VRAM unless the setting is off", () => {
|
||||
assert.deepEqual([...page().extension.getCanvasMenuItems().map((item) => item.content)], ["Clear VRAM 🆎"]);
|
||||
const off = page({ settings: { "AusBoss.Memory.ClearVramMenu": false } });
|
||||
assert.equal(off.extension.getCanvasMenuItems().length, 0);
|
||||
});
|
||||
|
||||
test("a ComfyUI that forbids manual unloading gets no entry", () => {
|
||||
const p = page({ settings: { "Comfy.Memory.AllowManualUnload": false } });
|
||||
assert.equal(p.extension.getCanvasMenuItems().length, 0);
|
||||
});
|
||||
|
||||
test("a click sends ComfyUI's own free request and confirms it", async () => {
|
||||
const p = page();
|
||||
await click(p);
|
||||
assert.equal(p.requests.length, 1);
|
||||
assert.equal(p.requests[0].path, "/free");
|
||||
assert.equal(p.requests[0].method, "POST");
|
||||
assert.deepEqual(JSON.parse(p.requests[0].body), { unload_models: true, free_memory: true });
|
||||
assert.equal(p.toasts.length, 1);
|
||||
assert.equal(p.toasts[0].severity, "success");
|
||||
});
|
||||
|
||||
test("a refused or failed request shows the error", async () => {
|
||||
for (const answer of [{ ok: false, status: 500 }, new Error("Failed to fetch")]) {
|
||||
const p = page({ answer });
|
||||
await click(p);
|
||||
assert.equal(p.toasts.length, 1);
|
||||
assert.equal(p.toasts[0].severity, "error");
|
||||
assert.match(p.toasts[0].detail, answer instanceof Error ? /Failed to fetch/ : /500/);
|
||||
}
|
||||
});
|
||||
Vendored
+473
@@ -0,0 +1,473 @@
|
||||
{"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", "crop_from", "ratio", "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,"center","1:1",912,1632,0,0,912,1632],
|
||||
[912,1632,"off",1.0,1024,1.0,1024,1024,16,true,"center","1:1",912,1632,0,0,912,1632],
|
||||
[912,1632,"megapixels",0.25,1024,1.0,1024,1024,1,true,"center","1:1",374,669,0,0,374,669],
|
||||
[912,1632,"megapixels",2.0,1024,1.0,1024,1024,1,true,"center","1:1",1057,1892,0,0,1057,1892],
|
||||
[912,1632,"longest side",1.0,1000,1.0,1024,1024,1,true,"center","1:1",559,1000,0,0,559,1000],
|
||||
[912,1632,"longest side",1.0,2048,1.0,1024,1024,1,true,"center","1:1",1144,2048,0,0,1144,2048],
|
||||
[912,1632,"scale",1.0,1024,0.33,1024,1024,1,true,"center","1:1",301,539,0,0,301,539],
|
||||
[912,1632,"scale",1.0,1024,1.5,1024,1024,1,true,"center","1:1",1368,2448,0,0,1368,2448],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1024,1024,1,true,"center","1:1",572,1024,0,0,572,1024],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,1,true,"center","1:1",1024,1833,0,404,1024,1024],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1280,720,1,true,"center","1:1",402,720,0,0,402,720],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1280,720,1,true,"center","1:1",1280,2291,0,785,1280,720],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1000,600,1,true,"center","1:1",335,600,0,0,335,600],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1000,600,1,true,"center","1:1",1000,1790,0,595,1000,600],
|
||||
[912,1632,"megapixels",0.25,1024,1.0,1024,1024,1,false,"center","1:1",374,669,0,0,374,669],
|
||||
[912,1632,"megapixels",2.0,1024,1.0,1024,1024,1,false,"center","1:1",912,1632,0,0,912,1632],
|
||||
[912,1632,"longest side",1.0,1000,1.0,1024,1024,1,false,"center","1:1",559,1000,0,0,559,1000],
|
||||
[912,1632,"longest side",1.0,2048,1.0,1024,1024,1,false,"center","1:1",912,1632,0,0,912,1632],
|
||||
[912,1632,"scale",1.0,1024,0.33,1024,1024,1,false,"center","1:1",301,539,0,0,301,539],
|
||||
[912,1632,"scale",1.0,1024,1.5,1024,1024,1,false,"center","1:1",912,1632,0,0,912,1632],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1024,1024,1,false,"center","1:1",572,1024,0,0,572,1024],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,1,false,"center","1:1",912,1632,0,360,912,912],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1280,720,1,false,"center","1:1",402,720,0,0,402,720],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1280,720,1,false,"center","1:1",912,1632,0,559,912,513],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1000,600,1,false,"center","1:1",335,600,0,0,335,600],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1000,600,1,false,"center","1:1",912,1632,0,542,912,547],
|
||||
[912,1632,"megapixels",0.25,1024,1.0,1024,1024,16,true,"center","1:1",368,672,0,0,368,672],
|
||||
[912,1632,"megapixels",2.0,1024,1.0,1024,1024,16,true,"center","1:1",1056,1888,0,0,1056,1888],
|
||||
[912,1632,"longest side",1.0,1000,1.0,1024,1024,16,true,"center","1:1",560,1008,0,0,560,1008],
|
||||
[912,1632,"longest side",1.0,2048,1.0,1024,1024,16,true,"center","1:1",1152,2048,0,0,1152,2048],
|
||||
[912,1632,"scale",1.0,1024,0.33,1024,1024,16,true,"center","1:1",304,544,0,0,304,544],
|
||||
[912,1632,"scale",1.0,1024,1.5,1024,1024,16,true,"center","1:1",1376,2448,0,0,1376,2448],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1024,1024,16,true,"center","1:1",576,1024,0,0,576,1024],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,16,true,"center","1:1",1024,1833,0,404,1024,1024],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1280,720,16,true,"center","1:1",400,720,0,0,400,720],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1280,720,16,true,"center","1:1",1280,2291,0,785,1280,720],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1000,600,16,true,"center","1:1",336,592,0,0,336,592],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1000,600,16,true,"center","1:1",1008,1804,0,598,1008,608],
|
||||
[912,1632,"megapixels",0.25,1024,1.0,1024,1024,16,false,"center","1:1",368,672,0,0,368,672],
|
||||
[912,1632,"megapixels",2.0,1024,1.0,1024,1024,16,false,"center","1:1",912,1632,0,0,912,1632],
|
||||
[912,1632,"longest side",1.0,1000,1.0,1024,1024,16,false,"center","1:1",560,1008,0,0,560,1008],
|
||||
[912,1632,"longest side",1.0,2048,1.0,1024,1024,16,false,"center","1:1",912,1632,0,0,912,1632],
|
||||
[912,1632,"scale",1.0,1024,0.33,1024,1024,16,false,"center","1:1",304,544,0,0,304,544],
|
||||
[912,1632,"scale",1.0,1024,1.5,1024,1024,16,false,"center","1:1",912,1632,0,0,912,1632],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1024,1024,16,false,"center","1:1",576,1024,0,0,576,1024],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,16,false,"center","1:1",912,1632,0,360,912,912],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1280,720,16,false,"center","1:1",400,720,0,0,400,720],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1280,720,16,false,"center","1:1",912,1632,0,560,912,512],
|
||||
[912,1632,"fit inside",1.0,1024,1.0,1000,600,16,false,"center","1:1",336,592,0,0,336,592],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1000,600,16,false,"center","1:1",912,1632,0,544,912,544],
|
||||
[1920,1080,"off",1.0,1024,1.0,1024,1024,1,true,"center","1:1",1920,1080,0,0,1920,1080],
|
||||
[1920,1080,"off",1.0,1024,1.0,1024,1024,16,true,"center","1:1",1920,1080,0,0,1920,1080],
|
||||
[1920,1080,"megapixels",0.25,1024,1.0,1024,1024,1,true,"center","1:1",667,375,0,0,667,375],
|
||||
[1920,1080,"megapixels",2.0,1024,1.0,1024,1024,1,true,"center","1:1",1886,1061,0,0,1886,1061],
|
||||
[1920,1080,"longest side",1.0,1000,1.0,1024,1024,1,true,"center","1:1",1000,563,0,0,1000,563],
|
||||
[1920,1080,"longest side",1.0,2048,1.0,1024,1024,1,true,"center","1:1",2048,1152,0,0,2048,1152],
|
||||
[1920,1080,"scale",1.0,1024,0.33,1024,1024,1,true,"center","1:1",634,356,0,0,634,356],
|
||||
[1920,1080,"scale",1.0,1024,1.5,1024,1024,1,true,"center","1:1",2880,1620,0,0,2880,1620],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1024,1024,1,true,"center","1:1",1024,576,0,0,1024,576],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,1,true,"center","1:1",1821,1024,398,0,1024,1024],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1280,720,1,true,"center","1:1",1280,720,0,0,1280,720],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1280,720,1,true,"center","1:1",1280,720,0,0,1280,720],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1000,600,1,true,"center","1:1",1000,563,0,0,1000,563],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1000,600,1,true,"center","1:1",1067,600,33,0,1000,600],
|
||||
[1920,1080,"megapixels",0.25,1024,1.0,1024,1024,1,false,"center","1:1",667,375,0,0,667,375],
|
||||
[1920,1080,"megapixels",2.0,1024,1.0,1024,1024,1,false,"center","1:1",1886,1061,0,0,1886,1061],
|
||||
[1920,1080,"longest side",1.0,1000,1.0,1024,1024,1,false,"center","1:1",1000,563,0,0,1000,563],
|
||||
[1920,1080,"longest side",1.0,2048,1.0,1024,1024,1,false,"center","1:1",1920,1080,0,0,1920,1080],
|
||||
[1920,1080,"scale",1.0,1024,0.33,1024,1024,1,false,"center","1:1",634,356,0,0,634,356],
|
||||
[1920,1080,"scale",1.0,1024,1.5,1024,1024,1,false,"center","1:1",1920,1080,0,0,1920,1080],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1024,1024,1,false,"center","1:1",1024,576,0,0,1024,576],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,1,false,"center","1:1",1821,1024,398,0,1024,1024],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1280,720,1,false,"center","1:1",1280,720,0,0,1280,720],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1280,720,1,false,"center","1:1",1280,720,0,0,1280,720],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1000,600,1,false,"center","1:1",1000,563,0,0,1000,563],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1000,600,1,false,"center","1:1",1067,600,33,0,1000,600],
|
||||
[1920,1080,"megapixels",0.25,1024,1.0,1024,1024,16,true,"center","1:1",672,368,0,0,672,368],
|
||||
[1920,1080,"megapixels",2.0,1024,1.0,1024,1024,16,true,"center","1:1",1888,1056,0,0,1888,1056],
|
||||
[1920,1080,"longest side",1.0,1000,1.0,1024,1024,16,true,"center","1:1",1008,560,0,0,1008,560],
|
||||
[1920,1080,"longest side",1.0,2048,1.0,1024,1024,16,true,"center","1:1",2048,1152,0,0,2048,1152],
|
||||
[1920,1080,"scale",1.0,1024,0.33,1024,1024,16,true,"center","1:1",640,352,0,0,640,352],
|
||||
[1920,1080,"scale",1.0,1024,1.5,1024,1024,16,true,"center","1:1",2880,1616,0,0,2880,1616],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1024,1024,16,true,"center","1:1",1024,576,0,0,1024,576],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,16,true,"center","1:1",1821,1024,398,0,1024,1024],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1280,720,16,true,"center","1:1",1280,720,0,0,1280,720],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1280,720,16,true,"center","1:1",1280,720,0,0,1280,720],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1000,600,16,true,"center","1:1",992,560,0,0,992,560],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1000,600,16,true,"center","1:1",1081,608,36,0,1008,608],
|
||||
[1920,1080,"megapixels",0.25,1024,1.0,1024,1024,16,false,"center","1:1",672,368,0,0,672,368],
|
||||
[1920,1080,"megapixels",2.0,1024,1.0,1024,1024,16,false,"center","1:1",1888,1056,0,0,1888,1056],
|
||||
[1920,1080,"longest side",1.0,1000,1.0,1024,1024,16,false,"center","1:1",1008,560,0,0,1008,560],
|
||||
[1920,1080,"longest side",1.0,2048,1.0,1024,1024,16,false,"center","1:1",1920,1072,0,0,1920,1072],
|
||||
[1920,1080,"scale",1.0,1024,0.33,1024,1024,16,false,"center","1:1",640,352,0,0,640,352],
|
||||
[1920,1080,"scale",1.0,1024,1.5,1024,1024,16,false,"center","1:1",1920,1072,0,0,1920,1072],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1024,1024,16,false,"center","1:1",1024,576,0,0,1024,576],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,16,false,"center","1:1",1821,1024,398,0,1024,1024],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1280,720,16,false,"center","1:1",1280,720,0,0,1280,720],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1280,720,16,false,"center","1:1",1280,720,0,0,1280,720],
|
||||
[1920,1080,"fit inside",1.0,1024,1.0,1000,600,16,false,"center","1:1",992,560,0,0,992,560],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1000,600,16,false,"center","1:1",1053,592,30,0,992,592],
|
||||
[640,480,"off",1.0,1024,1.0,1024,1024,1,true,"center","1:1",640,480,0,0,640,480],
|
||||
[640,480,"off",1.0,1024,1.0,1024,1024,16,true,"center","1:1",640,480,0,0,640,480],
|
||||
[640,480,"megapixels",0.25,1024,1.0,1024,1024,1,true,"center","1:1",577,433,0,0,577,433],
|
||||
[640,480,"megapixels",2.0,1024,1.0,1024,1024,1,true,"center","1:1",1633,1225,0,0,1633,1225],
|
||||
[640,480,"longest side",1.0,1000,1.0,1024,1024,1,true,"center","1:1",1000,750,0,0,1000,750],
|
||||
[640,480,"longest side",1.0,2048,1.0,1024,1024,1,true,"center","1:1",2048,1536,0,0,2048,1536],
|
||||
[640,480,"scale",1.0,1024,0.33,1024,1024,1,true,"center","1:1",211,158,0,0,211,158],
|
||||
[640,480,"scale",1.0,1024,1.5,1024,1024,1,true,"center","1:1",960,720,0,0,960,720],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1024,1024,1,true,"center","1:1",1024,768,0,0,1024,768],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,1,true,"center","1:1",1366,1024,171,0,1024,1024],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1280,720,1,true,"center","1:1",960,720,0,0,960,720],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1280,720,1,true,"center","1:1",1280,960,0,120,1280,720],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1000,600,1,true,"center","1:1",800,600,0,0,800,600],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1000,600,1,true,"center","1:1",1000,750,0,75,1000,600],
|
||||
[640,480,"megapixels",0.25,1024,1.0,1024,1024,1,false,"center","1:1",577,433,0,0,577,433],
|
||||
[640,480,"megapixels",2.0,1024,1.0,1024,1024,1,false,"center","1:1",640,480,0,0,640,480],
|
||||
[640,480,"longest side",1.0,1000,1.0,1024,1024,1,false,"center","1:1",640,480,0,0,640,480],
|
||||
[640,480,"longest side",1.0,2048,1.0,1024,1024,1,false,"center","1:1",640,480,0,0,640,480],
|
||||
[640,480,"scale",1.0,1024,0.33,1024,1024,1,false,"center","1:1",211,158,0,0,211,158],
|
||||
[640,480,"scale",1.0,1024,1.5,1024,1024,1,false,"center","1:1",640,480,0,0,640,480],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1024,1024,1,false,"center","1:1",640,480,0,0,640,480],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,1,false,"center","1:1",640,480,80,0,480,480],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1280,720,1,false,"center","1:1",640,480,0,0,640,480],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1280,720,1,false,"center","1:1",640,480,0,60,640,360],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1000,600,1,false,"center","1:1",640,480,0,0,640,480],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1000,600,1,false,"center","1:1",640,480,0,48,640,384],
|
||||
[640,480,"megapixels",0.25,1024,1.0,1024,1024,16,true,"center","1:1",576,432,0,0,576,432],
|
||||
[640,480,"megapixels",2.0,1024,1.0,1024,1024,16,true,"center","1:1",1632,1232,0,0,1632,1232],
|
||||
[640,480,"longest side",1.0,1000,1.0,1024,1024,16,true,"center","1:1",1008,752,0,0,1008,752],
|
||||
[640,480,"longest side",1.0,2048,1.0,1024,1024,16,true,"center","1:1",2048,1536,0,0,2048,1536],
|
||||
[640,480,"scale",1.0,1024,0.33,1024,1024,16,true,"center","1:1",208,160,0,0,208,160],
|
||||
[640,480,"scale",1.0,1024,1.5,1024,1024,16,true,"center","1:1",960,720,0,0,960,720],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1024,1024,16,true,"center","1:1",1024,768,0,0,1024,768],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,16,true,"center","1:1",1366,1024,171,0,1024,1024],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1280,720,16,true,"center","1:1",960,720,0,0,960,720],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1280,720,16,true,"center","1:1",1280,960,0,120,1280,720],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1000,600,16,true,"center","1:1",800,592,0,0,800,592],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1000,600,16,true,"center","1:1",1008,756,0,74,1008,608],
|
||||
[640,480,"megapixels",0.25,1024,1.0,1024,1024,16,false,"center","1:1",576,432,0,0,576,432],
|
||||
[640,480,"megapixels",2.0,1024,1.0,1024,1024,16,false,"center","1:1",640,480,0,0,640,480],
|
||||
[640,480,"longest side",1.0,1000,1.0,1024,1024,16,false,"center","1:1",640,480,0,0,640,480],
|
||||
[640,480,"longest side",1.0,2048,1.0,1024,1024,16,false,"center","1:1",640,480,0,0,640,480],
|
||||
[640,480,"scale",1.0,1024,0.33,1024,1024,16,false,"center","1:1",208,160,0,0,208,160],
|
||||
[640,480,"scale",1.0,1024,1.5,1024,1024,16,false,"center","1:1",640,480,0,0,640,480],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1024,1024,16,false,"center","1:1",640,480,0,0,640,480],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,16,false,"center","1:1",640,480,80,0,480,480],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1280,720,16,false,"center","1:1",640,480,0,0,640,480],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1280,720,16,false,"center","1:1",640,480,0,64,640,352],
|
||||
[640,480,"fit inside",1.0,1024,1.0,1000,600,16,false,"center","1:1",640,480,0,0,640,480],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1000,600,16,false,"center","1:1",640,480,0,48,640,384],
|
||||
[333,777,"off",1.0,1024,1.0,1024,1024,1,true,"center","1:1",333,777,0,0,333,777],
|
||||
[333,777,"off",1.0,1024,1.0,1024,1024,16,true,"center","1:1",333,777,0,0,333,777],
|
||||
[333,777,"megapixels",0.25,1024,1.0,1024,1024,1,true,"center","1:1",327,764,0,0,327,764],
|
||||
[333,777,"megapixels",2.0,1024,1.0,1024,1024,1,true,"center","1:1",926,2160,0,0,926,2160],
|
||||
[333,777,"longest side",1.0,1000,1.0,1024,1024,1,true,"center","1:1",429,1000,0,0,429,1000],
|
||||
[333,777,"longest side",1.0,2048,1.0,1024,1024,1,true,"center","1:1",878,2048,0,0,878,2048],
|
||||
[333,777,"scale",1.0,1024,0.33,1024,1024,1,true,"center","1:1",110,256,0,0,110,256],
|
||||
[333,777,"scale",1.0,1024,1.5,1024,1024,1,true,"center","1:1",500,1166,0,0,500,1166],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1024,1024,1,true,"center","1:1",439,1024,0,0,439,1024],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1024,1024,1,true,"center","1:1",1024,2390,0,683,1024,1024],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1280,720,1,true,"center","1:1",309,720,0,0,309,720],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1280,720,1,true,"center","1:1",1280,2987,0,1133,1280,720],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1000,600,1,true,"center","1:1",257,600,0,0,257,600],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1000,600,1,true,"center","1:1",1000,2334,0,867,1000,600],
|
||||
[333,777,"megapixels",0.25,1024,1.0,1024,1024,1,false,"center","1:1",327,764,0,0,327,764],
|
||||
[333,777,"megapixels",2.0,1024,1.0,1024,1024,1,false,"center","1:1",333,777,0,0,333,777],
|
||||
[333,777,"longest side",1.0,1000,1.0,1024,1024,1,false,"center","1:1",333,777,0,0,333,777],
|
||||
[333,777,"longest side",1.0,2048,1.0,1024,1024,1,false,"center","1:1",333,777,0,0,333,777],
|
||||
[333,777,"scale",1.0,1024,0.33,1024,1024,1,false,"center","1:1",110,256,0,0,110,256],
|
||||
[333,777,"scale",1.0,1024,1.5,1024,1024,1,false,"center","1:1",333,777,0,0,333,777],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1024,1024,1,false,"center","1:1",333,777,0,0,333,777],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1024,1024,1,false,"center","1:1",333,777,0,222,333,333],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1280,720,1,false,"center","1:1",309,720,0,0,309,720],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1280,720,1,false,"center","1:1",333,777,0,295,333,187],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1000,600,1,false,"center","1:1",257,600,0,0,257,600],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1000,600,1,false,"center","1:1",333,777,0,289,333,199],
|
||||
[333,777,"megapixels",0.25,1024,1.0,1024,1024,16,true,"center","1:1",320,768,0,0,320,768],
|
||||
[333,777,"megapixels",2.0,1024,1.0,1024,1024,16,true,"center","1:1",928,2160,0,0,928,2160],
|
||||
[333,777,"longest side",1.0,1000,1.0,1024,1024,16,true,"center","1:1",432,1008,0,0,432,1008],
|
||||
[333,777,"longest side",1.0,2048,1.0,1024,1024,16,true,"center","1:1",880,2048,0,0,880,2048],
|
||||
[333,777,"scale",1.0,1024,0.33,1024,1024,16,true,"center","1:1",112,256,0,0,112,256],
|
||||
[333,777,"scale",1.0,1024,1.5,1024,1024,16,true,"center","1:1",496,1168,0,0,496,1168],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1024,1024,16,true,"center","1:1",432,1024,0,0,432,1024],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1024,1024,16,true,"center","1:1",1024,2390,0,683,1024,1024],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1280,720,16,true,"center","1:1",304,720,0,0,304,720],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1280,720,16,true,"center","1:1",1280,2987,0,1133,1280,720],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1000,600,16,true,"center","1:1",256,592,0,0,256,592],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1000,600,16,true,"center","1:1",1008,2352,0,872,1008,608],
|
||||
[333,777,"megapixels",0.25,1024,1.0,1024,1024,16,false,"center","1:1",320,768,0,0,320,768],
|
||||
[333,777,"megapixels",2.0,1024,1.0,1024,1024,16,false,"center","1:1",320,768,0,0,320,768],
|
||||
[333,777,"longest side",1.0,1000,1.0,1024,1024,16,false,"center","1:1",320,768,0,0,320,768],
|
||||
[333,777,"longest side",1.0,2048,1.0,1024,1024,16,false,"center","1:1",320,768,0,0,320,768],
|
||||
[333,777,"scale",1.0,1024,0.33,1024,1024,16,false,"center","1:1",112,256,0,0,112,256],
|
||||
[333,777,"scale",1.0,1024,1.5,1024,1024,16,false,"center","1:1",320,768,0,0,320,768],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1024,1024,16,false,"center","1:1",320,768,0,0,320,768],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1024,1024,16,false,"center","1:1",320,747,0,213,320,320],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1280,720,16,false,"center","1:1",304,720,0,0,304,720],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1280,720,16,false,"center","1:1",320,747,0,285,320,176],
|
||||
[333,777,"fit inside",1.0,1024,1.0,1000,600,16,false,"center","1:1",256,592,0,0,256,592],
|
||||
[333,777,"crop to fill",1.0,1024,1.0,1000,600,16,false,"center","1:1",320,747,0,277,320,192],
|
||||
[8192,5464,"off",1.0,1024,1.0,1024,1024,1,true,"center","1:1",8192,5464,0,0,8192,5464],
|
||||
[8192,5464,"off",1.0,1024,1.0,1024,1024,16,true,"center","1:1",8192,5464,0,0,8192,5464],
|
||||
[8192,5464,"megapixels",0.25,1024,1.0,1024,1024,1,true,"center","1:1",612,408,0,0,612,408],
|
||||
[8192,5464,"megapixels",2.0,1024,1.0,1024,1024,1,true,"center","1:1",1732,1155,0,0,1732,1155],
|
||||
[8192,5464,"longest side",1.0,1000,1.0,1024,1024,1,true,"center","1:1",1000,667,0,0,1000,667],
|
||||
[8192,5464,"longest side",1.0,2048,1.0,1024,1024,1,true,"center","1:1",2048,1366,0,0,2048,1366],
|
||||
[8192,5464,"scale",1.0,1024,0.33,1024,1024,1,true,"center","1:1",2703,1803,0,0,2703,1803],
|
||||
[8192,5464,"scale",1.0,1024,1.5,1024,1024,1,true,"center","1:1",12288,8196,0,0,12288,8196],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1024,1024,1,true,"center","1:1",1024,683,0,0,1024,683],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1024,1024,1,true,"center","1:1",1536,1024,256,0,1024,1024],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1280,720,1,true,"center","1:1",1079,720,0,0,1079,720],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1280,720,1,true,"center","1:1",1280,854,0,67,1280,720],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1000,600,1,true,"center","1:1",900,600,0,0,900,600],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1000,600,1,true,"center","1:1",1000,667,0,33,1000,600],
|
||||
[8192,5464,"megapixels",0.25,1024,1.0,1024,1024,1,false,"center","1:1",612,408,0,0,612,408],
|
||||
[8192,5464,"megapixels",2.0,1024,1.0,1024,1024,1,false,"center","1:1",1732,1155,0,0,1732,1155],
|
||||
[8192,5464,"longest side",1.0,1000,1.0,1024,1024,1,false,"center","1:1",1000,667,0,0,1000,667],
|
||||
[8192,5464,"longest side",1.0,2048,1.0,1024,1024,1,false,"center","1:1",2048,1366,0,0,2048,1366],
|
||||
[8192,5464,"scale",1.0,1024,0.33,1024,1024,1,false,"center","1:1",2703,1803,0,0,2703,1803],
|
||||
[8192,5464,"scale",1.0,1024,1.5,1024,1024,1,false,"center","1:1",8192,5464,0,0,8192,5464],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1024,1024,1,false,"center","1:1",1024,683,0,0,1024,683],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1024,1024,1,false,"center","1:1",1536,1024,256,0,1024,1024],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1280,720,1,false,"center","1:1",1079,720,0,0,1079,720],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1280,720,1,false,"center","1:1",1280,854,0,67,1280,720],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1000,600,1,false,"center","1:1",900,600,0,0,900,600],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1000,600,1,false,"center","1:1",1000,667,0,33,1000,600],
|
||||
[8192,5464,"megapixels",0.25,1024,1.0,1024,1024,16,true,"center","1:1",608,416,0,0,608,416],
|
||||
[8192,5464,"megapixels",2.0,1024,1.0,1024,1024,16,true,"center","1:1",1728,1152,0,0,1728,1152],
|
||||
[8192,5464,"longest side",1.0,1000,1.0,1024,1024,16,true,"center","1:1",1008,672,0,0,1008,672],
|
||||
[8192,5464,"longest side",1.0,2048,1.0,1024,1024,16,true,"center","1:1",2048,1360,0,0,2048,1360],
|
||||
[8192,5464,"scale",1.0,1024,0.33,1024,1024,16,true,"center","1:1",2704,1808,0,0,2704,1808],
|
||||
[8192,5464,"scale",1.0,1024,1.5,1024,1024,16,true,"center","1:1",12288,8192,0,0,12288,8192],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1024,1024,16,true,"center","1:1",1024,688,0,0,1024,688],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1024,1024,16,true,"center","1:1",1536,1024,256,0,1024,1024],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1280,720,16,true,"center","1:1",1072,720,0,0,1072,720],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1280,720,16,true,"center","1:1",1280,854,0,67,1280,720],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1000,600,16,true,"center","1:1",896,592,0,0,896,592],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1000,600,16,true,"center","1:1",1008,673,0,32,1008,608],
|
||||
[8192,5464,"megapixels",0.25,1024,1.0,1024,1024,16,false,"center","1:1",608,416,0,0,608,416],
|
||||
[8192,5464,"megapixels",2.0,1024,1.0,1024,1024,16,false,"center","1:1",1728,1152,0,0,1728,1152],
|
||||
[8192,5464,"longest side",1.0,1000,1.0,1024,1024,16,false,"center","1:1",1008,672,0,0,1008,672],
|
||||
[8192,5464,"longest side",1.0,2048,1.0,1024,1024,16,false,"center","1:1",2048,1360,0,0,2048,1360],
|
||||
[8192,5464,"scale",1.0,1024,0.33,1024,1024,16,false,"center","1:1",2704,1808,0,0,2704,1808],
|
||||
[8192,5464,"scale",1.0,1024,1.5,1024,1024,16,false,"center","1:1",8192,5456,0,0,8192,5456],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1024,1024,16,false,"center","1:1",1024,688,0,0,1024,688],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1024,1024,16,false,"center","1:1",1536,1024,256,0,1024,1024],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1280,720,16,false,"center","1:1",1072,720,0,0,1072,720],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1280,720,16,false,"center","1:1",1280,854,0,67,1280,720],
|
||||
[8192,5464,"fit inside",1.0,1024,1.0,1000,600,16,false,"center","1:1",896,592,0,0,896,592],
|
||||
[8192,5464,"crop to fill",1.0,1024,1.0,1000,600,16,false,"center","1:1",992,662,0,35,992,592],
|
||||
[1000,1000,"longest side",1.0,1000,1.0,1024,1024,64,true,"center","1:1",1024,1024,0,0,1024,1024],
|
||||
[30,20,"megapixels",1.0,1024,1.0,1024,1024,64,false,"center","1:1",64,64,0,0,64,64],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1024,768,8,true,"center","1:1",1024,1833,0,532,1024,768],
|
||||
[912,1632,"crop",1.0,1024,1.0,513,300,8,true,"center","1:1",912,1632,200,668,512,296],
|
||||
[912,1632,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"center","4:3",912,1632,0,476,912,680],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,16,true,"top","1:1",1024,1833,0,0,1024,1024],
|
||||
[912,1632,"crop",1.0,1024,1.0,4096,512,64,true,"top","1:1",912,1632,8,0,896,512],
|
||||
[912,1632,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"top","3:4",912,1632,0,0,912,1216],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1024,768,1,true,"bottom","1:1",1024,1833,0,1065,1024,768],
|
||||
[912,1632,"crop",1.0,1024,1.0,768,768,1,true,"bottom","1:1",912,1632,72,864,768,768],
|
||||
[912,1632,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"bottom","3:2",912,1632,0,1024,912,608],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,8,false,"left","1:1",912,1632,0,360,912,912],
|
||||
[912,1632,"crop",1.0,1024,1.0,513,300,8,true,"left","1:1",912,1632,0,668,512,296],
|
||||
[912,1632,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"left","2:3",912,1632,0,132,912,1368],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1024,768,16,true,"right","1:1",1024,1833,0,532,1024,768],
|
||||
[912,1632,"crop",1.0,1024,1.0,4096,512,64,true,"right","1:1",912,1632,16,560,896,512],
|
||||
[912,1632,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"right","16:9",912,1632,0,560,912,512],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,1,true,"top left","1:1",1024,1833,0,0,1024,1024],
|
||||
[912,1632,"crop",1.0,1024,1.0,768,768,1,true,"top left","1:1",912,1632,0,0,768,768],
|
||||
[912,1632,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"top left","9:16",912,1632,0,0,912,1621],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1024,768,8,true,"top right","1:1",1024,1833,0,0,1024,768],
|
||||
[912,1632,"crop",1.0,1024,1.0,513,300,8,true,"top right","1:1",912,1632,400,0,512,296],
|
||||
[912,1632,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"top right","5:4",912,1632,0,0,912,728],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,16,false,"bottom left","1:1",912,1632,0,720,912,912],
|
||||
[912,1632,"crop",1.0,1024,1.0,4096,512,64,true,"bottom left","1:1",912,1632,0,1120,896,512],
|
||||
[912,1632,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"bottom left","4:5",912,1632,0,496,912,1136],
|
||||
[912,1632,"crop to fill",1.0,1024,1.0,1024,768,1,true,"bottom right","1:1",1024,1833,0,1065,1024,768],
|
||||
[912,1632,"crop",1.0,1024,1.0,768,768,1,true,"bottom right","1:1",912,1632,144,864,768,768],
|
||||
[912,1632,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"bottom right","21:9",912,1632,0,1241,912,391],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,8,true,"center","1:1",1821,1024,398,0,1024,1024],
|
||||
[1920,1080,"crop",1.0,1024,1.0,513,300,8,true,"center","1:1",1920,1080,704,392,512,296],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"center","9:21",1920,1080,732,0,456,1080],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1024,768,16,true,"top","1:1",1366,768,171,0,1024,768],
|
||||
[1920,1080,"crop",1.0,1024,1.0,4096,512,64,true,"top","1:1",1920,1080,0,0,1920,512],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"top","2:1",1920,1080,0,0,1920,960],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,1,false,"bottom","1:1",1821,1024,398,0,1024,1024],
|
||||
[1920,1080,"crop",1.0,1024,1.0,768,768,1,true,"bottom","1:1",1920,1080,576,312,768,768],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"bottom","1:2",1920,1080,690,0,540,1080],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1024,768,8,true,"left","1:1",1366,768,0,0,1024,768],
|
||||
[1920,1080,"crop",1.0,1024,1.0,513,300,8,true,"left","1:1",1920,1080,0,392,512,296],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"left","width : height",1920,1080,0,0,1920,1080],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,16,true,"right","1:1",1821,1024,797,0,1024,1024],
|
||||
[1920,1080,"crop",1.0,1024,1.0,4096,512,64,true,"right","1:1",1920,1080,0,284,1920,512],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"right","1:1",1920,1080,848,4,1072,1072],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1024,768,1,true,"top left","1:1",1366,768,0,0,1024,768],
|
||||
[1920,1080,"crop",1.0,1024,1.0,768,768,1,true,"top left","1:1",1920,1080,0,0,768,768],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"top left","4:3",1920,1080,0,0,1440,1080],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,8,false,"top right","1:1",1821,1024,797,0,1024,1024],
|
||||
[1920,1080,"crop",1.0,1024,1.0,513,300,8,true,"top right","1:1",1920,1080,1408,0,512,296],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"top right","3:4",1920,1080,1112,0,808,1080],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1024,768,16,true,"bottom left","1:1",1366,768,0,0,1024,768],
|
||||
[1920,1080,"crop",1.0,1024,1.0,4096,512,64,true,"bottom left","1:1",1920,1080,0,568,1920,512],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"bottom left","3:2",1920,1080,0,8,1616,1072],
|
||||
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,1,true,"bottom right","1:1",1821,1024,797,0,1024,1024],
|
||||
[1920,1080,"crop",1.0,1024,1.0,768,768,1,true,"bottom right","1:1",1920,1080,1152,312,768,768],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"bottom right","2:3",1920,1080,1200,0,720,1080],
|
||||
[1336,1784,"crop to fill",1.0,1024,1.0,1024,768,8,true,"center","1:1",1024,1368,0,300,1024,768],
|
||||
[1336,1784,"crop",1.0,1024,1.0,513,300,8,true,"center","1:1",1336,1784,412,744,512,296],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"center","16:9",1336,1784,0,516,1336,752],
|
||||
[1336,1784,"crop to fill",1.0,1024,1.0,1024,1024,16,false,"top","1:1",1024,1368,0,0,1024,1024],
|
||||
[1336,1784,"crop",1.0,1024,1.0,4096,512,64,true,"top","1:1",1336,1784,28,0,1280,512],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"top","9:16",1336,1784,172,0,992,1776],
|
||||
[1336,1784,"crop to fill",1.0,1024,1.0,1024,768,1,true,"bottom","1:1",1024,1368,0,600,1024,768],
|
||||
[1336,1784,"crop",1.0,1024,1.0,768,768,1,true,"bottom","1:1",1336,1784,284,1016,768,768],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"bottom","5:4",1336,1784,0,715,1336,1069],
|
||||
[1336,1784,"crop to fill",1.0,1024,1.0,1024,1024,8,true,"left","1:1",1024,1368,0,172,1024,1024],
|
||||
[1336,1784,"crop",1.0,1024,1.0,513,300,8,true,"left","1:1",1336,1784,0,744,512,296],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"left","4:5",1336,1784,0,60,1336,1664],
|
||||
[1336,1784,"crop to fill",1.0,1024,1.0,1024,768,16,true,"right","1:1",1024,1368,0,300,1024,768],
|
||||
[1336,1784,"crop",1.0,1024,1.0,4096,512,64,true,"right","1:1",1336,1784,56,636,1280,512],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"right","21:9",1336,1784,8,612,1328,560],
|
||||
[1336,1784,"crop to fill",1.0,1024,1.0,1024,1024,1,false,"top left","1:1",1024,1368,0,0,1024,1024],
|
||||
[1336,1784,"crop",1.0,1024,1.0,768,768,1,true,"top left","1:1",1336,1784,0,0,768,768],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"top left","9:21",1336,1784,0,0,765,1784],
|
||||
[1336,1784,"crop to fill",1.0,1024,1.0,1024,768,8,true,"top right","1:1",1024,1368,0,0,1024,768],
|
||||
[1336,1784,"crop",1.0,1024,1.0,513,300,8,true,"top right","1:1",1336,1784,824,0,512,296],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"top right","2:1",1336,1784,0,0,1336,664],
|
||||
[1336,1784,"crop to fill",1.0,1024,1.0,1024,1024,16,true,"bottom left","1:1",1024,1368,0,344,1024,1024],
|
||||
[1336,1784,"crop",1.0,1024,1.0,4096,512,64,true,"bottom left","1:1",1336,1784,0,1272,1280,512],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"bottom left","1:2",1336,1784,0,8,880,1776],
|
||||
[1336,1784,"crop to fill",1.0,1024,1.0,1024,768,1,true,"bottom right","1:1",1024,1368,0,600,1024,768],
|
||||
[1336,1784,"crop",1.0,1024,1.0,768,768,1,true,"bottom right","1:1",1336,1784,568,1016,768,768],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"bottom right","width : height",1336,1784,0,1032,1336,752],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,8,false,"center","1:1",640,480,80,0,480,480],
|
||||
[640,480,"crop",1.0,1024,1.0,513,300,8,true,"center","1:1",640,480,64,92,512,296],
|
||||
[640,480,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"center","1:1",640,480,80,0,480,480],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1024,768,16,true,"top","1:1",1024,768,0,0,1024,768],
|
||||
[640,480,"crop",1.0,1024,1.0,4096,512,64,true,"top","1:1",640,480,0,0,640,448],
|
||||
[640,480,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"top","4:3",640,480,0,0,640,480],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,1,true,"bottom","1:1",1366,1024,171,0,1024,1024],
|
||||
[640,480,"crop",1.0,1024,1.0,768,768,1,true,"bottom","1:1",640,480,0,0,640,480],
|
||||
[640,480,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"bottom","3:4",640,480,140,0,360,480],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1024,768,8,true,"left","1:1",1024,768,0,0,1024,768],
|
||||
[640,480,"crop",1.0,1024,1.0,513,300,8,true,"left","1:1",640,480,0,92,512,296],
|
||||
[640,480,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"left","3:2",640,480,0,28,640,424],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,16,false,"right","1:1",640,480,160,0,480,480],
|
||||
[640,480,"crop",1.0,1024,1.0,4096,512,64,true,"right","1:1",640,480,0,16,640,448],
|
||||
[640,480,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"right","2:3",640,480,320,0,320,480],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1024,768,1,true,"top left","1:1",1024,768,0,0,1024,768],
|
||||
[640,480,"crop",1.0,1024,1.0,768,768,1,true,"top left","1:1",640,480,0,0,640,480],
|
||||
[640,480,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"top left","16:9",640,480,0,0,640,360],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,8,true,"top right","1:1",1366,1024,342,0,1024,1024],
|
||||
[640,480,"crop",1.0,1024,1.0,513,300,8,true,"top right","1:1",640,480,128,0,512,296],
|
||||
[640,480,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"top right","9:16",640,480,376,0,264,480],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1024,768,16,true,"bottom left","1:1",1024,768,0,0,1024,768],
|
||||
[640,480,"crop",1.0,1024,1.0,4096,512,64,true,"bottom left","1:1",640,480,0,32,640,448],
|
||||
[640,480,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"bottom left","5:4",640,480,0,0,592,480],
|
||||
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,1,false,"bottom right","1:1",640,480,160,0,480,480],
|
||||
[640,480,"crop",1.0,1024,1.0,768,768,1,true,"bottom right","1:1",640,480,0,0,640,480],
|
||||
[640,480,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"bottom right","4:5",640,480,256,0,384,480],
|
||||
[1000,777,"crop to fill",1.0,1024,1.0,1024,768,8,true,"center","1:1",1024,796,0,14,1024,768],
|
||||
[1000,777,"crop",1.0,1024,1.0,513,300,8,true,"center","1:1",1000,777,244,240,512,296],
|
||||
[1000,777,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"center","21:9",1000,777,0,176,1000,424],
|
||||
[1000,777,"crop to fill",1.0,1024,1.0,1024,1024,16,true,"top","1:1",1318,1024,147,0,1024,1024],
|
||||
[1000,777,"crop",1.0,1024,1.0,4096,512,64,true,"top","1:1",1000,777,20,0,960,512],
|
||||
[1000,777,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"top","9:21",1000,777,340,0,320,768],
|
||||
[1000,777,"crop to fill",1.0,1024,1.0,1024,768,1,true,"bottom","1:1",1024,796,0,28,1024,768],
|
||||
[1000,777,"crop",1.0,1024,1.0,768,768,1,true,"bottom","1:1",1000,777,116,9,768,768],
|
||||
[1000,777,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"bottom","2:1",1000,777,0,277,1000,500],
|
||||
[1000,777,"crop to fill",1.0,1024,1.0,1024,1024,8,false,"left","1:1",999,776,0,0,776,776],
|
||||
[1000,777,"crop",1.0,1024,1.0,513,300,8,true,"left","1:1",1000,777,0,240,512,296],
|
||||
[1000,777,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"left","1:2",1000,777,0,0,384,776],
|
||||
[1000,777,"crop to fill",1.0,1024,1.0,1024,768,16,true,"right","1:1",1024,796,0,14,1024,768],
|
||||
[1000,777,"crop",1.0,1024,1.0,4096,512,64,true,"right","1:1",1000,777,40,132,960,512],
|
||||
[1000,777,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"right","width : height",1000,777,8,108,992,560],
|
||||
[1000,777,"crop to fill",1.0,1024,1.0,1024,1024,1,true,"top left","1:1",1318,1024,0,0,1024,1024],
|
||||
[1000,777,"crop",1.0,1024,1.0,768,768,1,true,"top left","1:1",1000,777,0,0,768,768],
|
||||
[1000,777,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"top left","1:1",1000,777,0,0,777,777],
|
||||
[1000,777,"crop to fill",1.0,1024,1.0,1024,768,8,true,"top right","1:1",1024,796,0,0,1024,768],
|
||||
[1000,777,"crop",1.0,1024,1.0,513,300,8,true,"top right","1:1",1000,777,488,0,512,296],
|
||||
[1000,777,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"top right","4:3",1000,777,0,0,1000,744],
|
||||
[1000,777,"crop to fill",1.0,1024,1.0,1024,1024,16,false,"bottom left","1:1",989,768,0,0,768,768],
|
||||
[1000,777,"crop",1.0,1024,1.0,4096,512,64,true,"bottom left","1:1",1000,777,0,265,960,512],
|
||||
[1000,777,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"bottom left","3:4",1000,777,0,9,576,768],
|
||||
[1000,777,"crop to fill",1.0,1024,1.0,1024,768,1,true,"bottom right","1:1",1024,796,0,28,1024,768],
|
||||
[1000,777,"crop",1.0,1024,1.0,768,768,1,true,"bottom right","1:1",1000,777,232,9,768,768],
|
||||
[1000,777,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"bottom right","3:2",1000,777,0,110,1000,667],
|
||||
[30,2000,"crop to fill",1.0,1024,1.0,1024,1024,8,true,"center","1:1",1024,68267,0,33621,1024,1024],
|
||||
[30,2000,"crop",1.0,1024,1.0,513,300,8,true,"center","1:1",30,2000,3,852,24,296],
|
||||
[30,2000,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"center","2:3",30,2000,3,980,24,40],
|
||||
[30,2000,"crop to fill",1.0,1024,1.0,1024,768,16,true,"top","1:1",1024,68267,0,0,1024,768],
|
||||
[30,2000,"crop",1.0,1024,1.0,4096,512,64,true,"top","1:1",30,2000,0,0,30,512],
|
||||
[30,2000,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"top","16:9",30,2000,7,0,16,16],
|
||||
[30,2000,"crop to fill",1.0,1024,1.0,1024,1024,1,false,"bottom","1:1",30,2000,0,1970,30,30],
|
||||
[30,2000,"crop",1.0,1024,1.0,768,768,1,true,"bottom","1:1",30,2000,0,1232,30,768],
|
||||
[30,2000,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"bottom","9:16",30,2000,0,1947,30,53],
|
||||
[30,2000,"crop to fill",1.0,1024,1.0,1024,768,8,true,"left","1:1",1024,68267,0,33749,1024,768],
|
||||
[30,2000,"crop",1.0,1024,1.0,513,300,8,true,"left","1:1",30,2000,0,852,24,296],
|
||||
[30,2000,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"left","5:4",30,2000,0,988,24,24],
|
||||
[30,2000,"crop to fill",1.0,1024,1.0,1024,1024,16,true,"right","1:1",1024,68267,0,33621,1024,1024],
|
||||
[30,2000,"crop",1.0,1024,1.0,4096,512,64,true,"right","1:1",30,2000,0,744,30,512],
|
||||
[30,2000,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"right","4:5",30,2000,14,984,16,32],
|
||||
[30,2000,"crop to fill",1.0,1024,1.0,1024,768,1,true,"top left","1:1",1024,68267,0,0,1024,768],
|
||||
[30,2000,"crop",1.0,1024,1.0,768,768,1,true,"top left","1:1",30,2000,0,0,30,768],
|
||||
[30,2000,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"top left","21:9",30,2000,0,0,30,13],
|
||||
[30,2000,"crop to fill",1.0,1024,1.0,1024,1024,8,false,"top right","1:1",24,1600,0,0,24,24],
|
||||
[30,2000,"crop",1.0,1024,1.0,513,300,8,true,"top right","1:1",30,2000,6,0,24,296],
|
||||
[30,2000,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"top right","9:21",30,2000,6,0,24,64],
|
||||
[30,2000,"crop to fill",1.0,1024,1.0,1024,768,16,true,"bottom left","1:1",1024,68267,0,67499,1024,768],
|
||||
[30,2000,"crop",1.0,1024,1.0,4096,512,64,true,"bottom left","1:1",30,2000,0,1488,30,512],
|
||||
[30,2000,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"bottom left","2:1",30,2000,0,1985,16,15],
|
||||
[30,2000,"crop to fill",1.0,1024,1.0,1024,1024,1,true,"bottom right","1:1",1024,68267,0,67243,1024,1024],
|
||||
[30,2000,"crop",1.0,1024,1.0,768,768,1,true,"bottom right","1:1",30,2000,0,1232,30,768],
|
||||
[30,2000,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"bottom right","1:2",30,2000,0,1940,30,60],
|
||||
[10,10,"crop to fill",1.0,1024,1.0,1024,768,8,true,"center","1:1",1024,1024,0,128,1024,768],
|
||||
[10,10,"crop",1.0,1024,1.0,513,300,8,true,"center","1:1",10,10,1,1,8,8],
|
||||
[10,10,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"center","width : height",10,10,1,2,8,6],
|
||||
[10,10,"crop to fill",1.0,1024,1.0,1024,1024,16,false,"top","1:1",16,16,0,0,16,16],
|
||||
[10,10,"crop",1.0,1024,1.0,4096,512,64,true,"top","1:1",10,10,0,0,10,10],
|
||||
[10,10,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"top","1:1",10,10,0,0,10,10],
|
||||
[10,10,"crop to fill",1.0,1024,1.0,1024,768,1,true,"bottom","1:1",1024,1024,0,256,1024,768],
|
||||
[10,10,"crop",1.0,1024,1.0,768,768,1,true,"bottom","1:1",10,10,0,0,10,10],
|
||||
[10,10,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"bottom","4:3",10,10,0,2,10,8],
|
||||
[10,10,"crop to fill",1.0,1024,1.0,1024,1024,8,true,"left","1:1",1024,1024,0,0,1024,1024],
|
||||
[10,10,"crop",1.0,1024,1.0,513,300,8,true,"left","1:1",10,10,0,1,8,8],
|
||||
[10,10,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"left","3:4",10,10,0,1,8,8],
|
||||
[10,10,"crop to fill",1.0,1024,1.0,1024,768,16,true,"right","1:1",1024,1024,0,128,1024,768],
|
||||
[10,10,"crop",1.0,1024,1.0,4096,512,64,true,"right","1:1",10,10,0,0,10,10],
|
||||
[10,10,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"right","3:2",10,10,0,1,10,7],
|
||||
[10,10,"crop to fill",1.0,1024,1.0,1024,1024,1,false,"top left","1:1",10,10,0,0,10,10],
|
||||
[10,10,"crop",1.0,1024,1.0,768,768,1,true,"top left","1:1",10,10,0,0,10,10],
|
||||
[10,10,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"top left","2:3",10,10,0,0,7,10],
|
||||
[10,10,"crop to fill",1.0,1024,1.0,1024,768,8,true,"top right","1:1",1024,1024,0,0,1024,768],
|
||||
[10,10,"crop",1.0,1024,1.0,513,300,8,true,"top right","1:1",10,10,2,0,8,8],
|
||||
[10,10,"crop to ratio",1.0,1024,1.0,1600,900,8,true,"top right","16:9",10,10,2,0,8,6],
|
||||
[10,10,"crop to fill",1.0,1024,1.0,1024,1024,16,true,"bottom left","1:1",1024,1024,0,0,1024,1024],
|
||||
[10,10,"crop",1.0,1024,1.0,4096,512,64,true,"bottom left","1:1",10,10,0,0,10,10],
|
||||
[10,10,"crop to ratio",1.0,1024,1.0,1600,900,16,true,"bottom left","9:16",10,10,0,0,6,10],
|
||||
[10,10,"crop to fill",1.0,1024,1.0,1024,768,1,true,"bottom right","1:1",1024,1024,0,256,1024,768],
|
||||
[10,10,"crop",1.0,1024,1.0,768,768,1,true,"bottom right","1:1",10,10,0,0,10,10],
|
||||
[10,10,"crop to ratio",1.0,1024,1.0,1600,900,1,true,"bottom right","5:4",10,10,0,2,10,8],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1280,544,1,true,"top","1:1",1336,1784,0,0,1336,1336],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1280,544,8,true,"bottom right","1:1",1920,1080,840,0,1080,1080],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1280,544,1,true,"top","4:3",1336,1784,0,0,1336,1002],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1280,544,8,true,"bottom right","4:3",1920,1080,480,0,1440,1080],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1280,544,1,true,"top","3:4",1336,1784,0,0,1336,1781],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1280,544,8,true,"bottom right","3:4",1920,1080,1112,0,808,1080],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1280,544,1,true,"top","3:2",1336,1784,0,0,1336,891],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1280,544,8,true,"bottom right","3:2",1920,1080,304,0,1616,1080],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1280,544,1,true,"top","2:3",1336,1784,73,0,1189,1784],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1280,544,8,true,"bottom right","2:3",1920,1080,1200,0,720,1080],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1280,544,1,true,"top","16:9",1336,1784,0,0,1336,752],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1280,544,8,true,"bottom right","16:9",1920,1080,0,0,1920,1080],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1280,544,1,true,"top","9:16",1336,1784,166,0,1004,1784],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1280,544,8,true,"bottom right","9:16",1920,1080,1312,0,608,1080],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1280,544,1,true,"top","5:4",1336,1784,0,0,1336,1069],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1280,544,8,true,"bottom right","5:4",1920,1080,576,0,1344,1080],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1280,544,1,true,"top","4:5",1336,1784,0,0,1336,1670],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1280,544,8,true,"bottom right","4:5",1920,1080,1056,0,864,1080],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1280,544,1,true,"top","21:9",1336,1784,0,0,1336,573],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1280,544,8,true,"bottom right","21:9",1920,1080,0,264,1920,816],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1280,544,1,true,"top","9:21",1336,1784,285,0,765,1784],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1280,544,8,true,"bottom right","9:21",1920,1080,1464,0,456,1080],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1280,544,1,true,"top","2:1",1336,1784,0,0,1336,668],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1280,544,8,true,"bottom right","2:1",1920,1080,0,120,1920,960],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1280,544,1,true,"top","1:2",1336,1784,222,0,892,1784],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1280,544,8,true,"bottom right","1:2",1920,1080,1384,0,536,1080],
|
||||
[1336,1784,"crop to ratio",1.0,1024,1.0,1280,544,1,true,"top","width : height",1336,1784,0,0,1336,568],
|
||||
[1920,1080,"crop to ratio",1.0,1024,1.0,1280,544,8,true,"bottom right","width : height",1920,1080,0,264,1920,816]
|
||||
]}
|
||||
Vendored
+47
@@ -583,6 +583,53 @@
|
||||
]
|
||||
]
|
||||
},
|
||||
"AUSBOSS_NODES_LoadImage": {
|
||||
"required": [
|
||||
"image",
|
||||
"size",
|
||||
"megapixels",
|
||||
"longest_side",
|
||||
"scale",
|
||||
"width",
|
||||
"height",
|
||||
"multiple",
|
||||
"method",
|
||||
"enlarge",
|
||||
"crop_from",
|
||||
"ratio"
|
||||
],
|
||||
"optional": [],
|
||||
"outputs": [
|
||||
[
|
||||
"IMAGE",
|
||||
"image"
|
||||
],
|
||||
[
|
||||
"MASK",
|
||||
"mask"
|
||||
],
|
||||
[
|
||||
"INT",
|
||||
"width"
|
||||
],
|
||||
[
|
||||
"INT",
|
||||
"height"
|
||||
],
|
||||
[
|
||||
"STRING",
|
||||
"filename"
|
||||
],
|
||||
[
|
||||
"INT",
|
||||
"original_width"
|
||||
],
|
||||
[
|
||||
"INT",
|
||||
"original_height"
|
||||
]
|
||||
]
|
||||
},
|
||||
"AUSBOSS_NODES_LoadImagePad": {
|
||||
"required": [
|
||||
"image",
|
||||
|
||||
@@ -4,6 +4,8 @@ import test from "node:test";
|
||||
import {
|
||||
describeNodePreview,
|
||||
describeOwnResult,
|
||||
lightPreviewQuery,
|
||||
ownResultIsCurrent,
|
||||
describeSourcePreview,
|
||||
linkById,
|
||||
outputLocatorId,
|
||||
@@ -192,3 +194,64 @@ test("without node.imgs the stored output is the result, ahead of the input", ()
|
||||
node.imgs = [{ src: "own-result" }];
|
||||
assert.deepEqual(describeNodePreview(node, "images", "/view?stored"), { kind: "url", url: "own-result" });
|
||||
});
|
||||
|
||||
test("a picture the frontend gave up loading does not hide the newer result", () => {
|
||||
// The frontend loads a result into node.imgs with a time limit. A 31 MB
|
||||
// sheet over a slow link ran past it, node.imgs kept the run before, and
|
||||
// the panel went on showing that older picture.
|
||||
const view = (name, folder = "", type = "output") =>
|
||||
`http://host/api/view?filename=${name}&subfolder=${folder}&type=${type}&rand=0.5`;
|
||||
const before = { images: [{ filename: "sheet_00009.png", subfolder: "", type: "output" }] };
|
||||
const after = { images: [{ filename: "75dc_000.png", subfolder: "ausboss_held", type: "temp" }] };
|
||||
const node = { inputs: [], imgs: [{ src: view("sheet_00009.png") }] };
|
||||
|
||||
// Loaded for the record on the node: node.imgs is the result.
|
||||
assert.equal(ownResultIsCurrent(node, before, before), true);
|
||||
// A newer run, and node.imgs was never set again: it is the run before.
|
||||
assert.equal(ownResultIsCurrent(node, after, before), false);
|
||||
assert.deepEqual(
|
||||
describeNodePreview(node, "images", "/view?stored", ownResultIsCurrent(node, after, before)),
|
||||
{ kind: "url", url: "/view?stored" },
|
||||
);
|
||||
// The same name written over (exact name, overwrite) is still a newer run.
|
||||
const again = { images: [{ filename: "sheet_00009.png", subfolder: "", type: "output" }] };
|
||||
assert.equal(ownResultIsCurrent(node, again, before), false);
|
||||
// Set while a still newer run came in: the names tell them apart.
|
||||
assert.equal(ownResultIsCurrent(node, after, after), false);
|
||||
// Once the frontend does load it, node.imgs is the result again.
|
||||
node.imgs = [{ src: view("75dc_000.png", "ausboss_held", "temp") }];
|
||||
assert.equal(ownResultIsCurrent(node, after, after), true);
|
||||
assert.deepEqual(
|
||||
describeNodePreview(node, "images", "/view?stored", ownResultIsCurrent(node, after, after)),
|
||||
{ kind: "url", url: node.imgs[0].src },
|
||||
);
|
||||
|
||||
// A frame streamed during a run is not a stored file: it stays the result.
|
||||
assert.equal(ownResultIsCurrent({ imgs: [{ src: "blob:http://host/1" }] }, after, before), true);
|
||||
// No stored picture to compare with: node.imgs is all there is.
|
||||
assert.equal(ownResultIsCurrent(node, null, undefined), true);
|
||||
assert.equal(ownResultIsCurrent(node, { images: [] }, before), true);
|
||||
// Nothing in node.imgs is never current.
|
||||
assert.equal(ownResultIsCurrent({ imgs: [] }, after, after), false);
|
||||
assert.equal(ownResultIsCurrent(null, after, after), false);
|
||||
});
|
||||
|
||||
test("Save Image's small copy is what the panel asks for", () => {
|
||||
const record = {
|
||||
images: [
|
||||
{ filename: "sheet_00001.png", subfolder: "", type: "output" },
|
||||
{ filename: "sheet_00002.png", subfolder: "", type: "output" },
|
||||
],
|
||||
ausboss_preview: ["ausboss_preview/ab12_000.webp", "ausboss_preview/ab12_001.webp"],
|
||||
};
|
||||
// The newest by default, or the one the arrows picked.
|
||||
assert.equal(lightPreviewQuery(record), "filename=ab12_001.webp&subfolder=ausboss_preview&type=temp");
|
||||
assert.equal(lightPreviewQuery(record, 0), "filename=ab12_000.webp&subfolder=ausboss_preview&type=temp");
|
||||
// No copies (an older run, another node, the preview off): the file itself.
|
||||
assert.equal(lightPreviewQuery({ images: record.images }), null);
|
||||
assert.equal(lightPreviewQuery({ images: record.images, ausboss_preview: [] }), null);
|
||||
// A list that does not match the pictures is not trusted.
|
||||
assert.equal(lightPreviewQuery({ images: record.images, ausboss_preview: ["ausboss_preview/x.webp"] }), null);
|
||||
assert.equal(lightPreviewQuery({ images: record.images, ausboss_preview: ["", 5] }), null);
|
||||
assert.equal(lightPreviewQuery(null), null);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
CROP_GRID, CROP_MODES, SIZE_MODES, changeText, cropSideAt, keptPart, placeText, planLoadSize, shapeText, sizeText, stepFile, thumbSizes,
|
||||
} 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, crop_from, ratio, ...wanted] = row;
|
||||
seen.add(size);
|
||||
const plan = planLoadSize(sourceWidth, sourceHeight, { size, megapixels, longest_side, scale, width, height, multiple, enlarge, crop_from, ratio });
|
||||
assert.deepEqual(
|
||||
[plan.resizeWidth, plan.resizeHeight, plan.cropX, plan.cropY, plan.width, plan.height],
|
||||
wanted,
|
||||
JSON.stringify(row.slice(0, 12)),
|
||||
);
|
||||
}
|
||||
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("a crop keeps the side that is asked for", () => {
|
||||
const cut = (crop_from, size = "crop to fill") => planLoadSize(200, 100, { size, width: 100, height: 100, multiple: 1, crop_from, ratio: "1:1" });
|
||||
for (const size of CROP_MODES) {
|
||||
assert.equal(cut("left", size).cropX, 0, size);
|
||||
assert.equal(cut("center", size).cropX, 50, size);
|
||||
assert.equal(cut("top right", size).cropX, 100, size);
|
||||
}
|
||||
assert.equal(cut("sideways").cropX, 50, "a side the node does not know is the middle");
|
||||
const tall = planLoadSize(100, 300, { size: "crop", width: 100, height: 100, multiple: 1, crop_from: "bottom" });
|
||||
assert.deepEqual([tall.resizeWidth, tall.resizeHeight, tall.cropY, tall.height], [100, 300, 200, 100], "crop does not resize");
|
||||
assert.equal(CROP_GRID.length, 9);
|
||||
assert.equal(new Set(CROP_GRID).size, 9);
|
||||
});
|
||||
|
||||
test("a click on the small picture picks the part under it", () => {
|
||||
assert.equal(cropSideAt(0.1, 0.1), "top left");
|
||||
assert.equal(cropSideAt(0.5, 0.05), "top");
|
||||
assert.equal(cropSideAt(0.5, 0.5), "center");
|
||||
assert.equal(cropSideAt(0.95, 0.5), "right");
|
||||
assert.equal(cropSideAt(0.99, 0.99), "bottom right");
|
||||
});
|
||||
|
||||
test("the preview lights the part that is kept and words what changes", () => {
|
||||
const top = planLoadSize(1336, 1784, { size: "crop to fill", width: 1024, height: 1024, multiple: 1, crop_from: "top" });
|
||||
const part = keptPart(top);
|
||||
assert.deepEqual([part.x, part.y, part.width], [0, 0, 1]);
|
||||
assert.ok(Math.abs(part.height - 1024 / 1368) < 1e-9);
|
||||
assert.equal(changeText(1336, 1784, top), "77% size · cuts 449 px of height");
|
||||
const piece = planLoadSize(1336, 1784, { size: "crop", width: 768, height: 768, multiple: 1 });
|
||||
assert.equal(changeText(1336, 1784, piece), "cuts 568 × 1016 px");
|
||||
assert.equal(changeText(1336, 1784, planLoadSize(1336, 1784, { size: "off" })), "");
|
||||
assert.equal(changeText(1336, 1784, planLoadSize(1336, 1784, { size: "scale", scale: 0.5, multiple: 1 })), "50% size");
|
||||
assert.deepEqual(keptPart(null), { x: 0, y: 0, width: 1, height: 1 });
|
||||
});
|
||||
|
||||
test("the two small pictures share one scale and stay in their box", () => {
|
||||
const half = planLoadSize(1336, 1784, { size: "scale", scale: 0.5, multiple: 1 });
|
||||
const sizes = thumbSizes(1336, 1784, half, 76, 68);
|
||||
assert.deepEqual(sizes.input, [51, 68]);
|
||||
assert.deepEqual(sizes.output, [25, 34], "half the size is drawn half as large");
|
||||
const grown = thumbSizes(100, 100, planLoadSize(100, 100, { size: "scale", scale: 8, multiple: 1 }), 76, 68);
|
||||
assert.deepEqual(grown.output, [68, 68]);
|
||||
assert.deepEqual(grown.input, [10, 10], "never too small to see");
|
||||
const wide = thumbSizes(4000, 500, null, 76, 68);
|
||||
assert.ok(wide.input[0] <= 76 && wide.input[1] >= 10);
|
||||
});
|
||||
|
||||
test("a shape is named the way people say it", () => {
|
||||
assert.equal(shapeText(1024, 1024), "1:1");
|
||||
assert.equal(shapeText(1336, 1784), "3:4");
|
||||
assert.equal(shapeText(1336, 752), "16:9", "rounded to a multiple of 8, still 16:9");
|
||||
assert.equal(shapeText(1080, 1920), "9:16");
|
||||
assert.equal(shapeText(1000, 777), "~1.29:1");
|
||||
assert.equal(shapeText(777, 1000), "~1:1.29");
|
||||
assert.equal(shapeText(0, 10), "");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { keepIndex, stepIndex, stepperLabel } from "../js/shared/picture_steps.mjs";
|
||||
import { keepIndex, keepWords, stepIndex, stepperLabel } from "../js/shared/picture_steps.mjs";
|
||||
|
||||
test("a new run keeps the picture that was showing, if it still has one", () => {
|
||||
assert.equal(keepIndex(1, 3), 1);
|
||||
@@ -74,3 +74,21 @@ test("held pictures map to their run and their place in it", async () => {
|
||||
assert.deepEqual(heldPlaces({ images: [name(a, 0), name(b, 0)], ausboss_held: [a] }), []);
|
||||
assert.deepEqual(heldPlaces(undefined), []);
|
||||
});
|
||||
|
||||
test("the row over held pictures says what is saved and what a click does", () => {
|
||||
// One picture: not saved, then saved under its name.
|
||||
assert.deepEqual(keepWords({ held: 1 }), { note: "Not saved yet", button: "Save picture", quiet: false });
|
||||
assert.deepEqual(keepWords({ held: 1, saving: true }), { note: "Not saved yet", button: "Saving...", quiet: false });
|
||||
assert.deepEqual(
|
||||
keepWords({ held: 1, kept: 1, done: true, name: "sheet_00001.png" }),
|
||||
{ note: "Saved as sheet_00001.png", button: "Saved", quiet: true },
|
||||
);
|
||||
assert.equal(keepWords({ held: 1, kept: 1, done: true }).note, "Saved");
|
||||
// Several, all showing: every one, then only the ones left.
|
||||
assert.deepEqual(keepWords({ held: 4 }), { note: "Not saved yet", button: "Save all 4", quiet: false });
|
||||
assert.deepEqual(keepWords({ held: 4, kept: 1 }), { note: "1 of 4 saved", button: "Save the rest", quiet: false });
|
||||
assert.deepEqual(keepWords({ held: 4, kept: 4, done: true }), { note: "All 4 saved", button: "Saved", quiet: true });
|
||||
// The arrows on one picture: that one alone.
|
||||
assert.deepEqual(keepWords({ held: 4, kept: 1, all: false }), { note: "1 of 4 saved", button: "Save this one", quiet: false });
|
||||
assert.deepEqual(keepWords({ held: 4, kept: 1, all: false, done: true }), { note: "1 of 4 saved", button: "Saved", quiet: true });
|
||||
});
|
||||
|
||||
@@ -508,3 +508,80 @@ class SaveImageLocalModeTests(SaveImageNodeTests):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class SaveImageLightPreviewTests(unittest.TestCase):
|
||||
"""The small copy the node shows on its face instead of the full file."""
|
||||
|
||||
def run_node(self, output_root, temp_root, **overrides):
|
||||
class FakeFolderPaths:
|
||||
@staticmethod
|
||||
def get_output_directory():
|
||||
return str(output_root)
|
||||
|
||||
@staticmethod
|
||||
def get_temp_directory():
|
||||
return str(temp_root)
|
||||
|
||||
values = {
|
||||
"images": gradient_batch(1, 64, 4096),
|
||||
"filename_prefix": "sheet",
|
||||
"format": "png",
|
||||
"save_metadata": False,
|
||||
}
|
||||
values.update(overrides)
|
||||
with patch.object(node_save_image, "folder_paths", FakeFolderPaths):
|
||||
return node_save_image.AusBossSaveImage().save(**values)
|
||||
|
||||
def test_a_big_picture_gets_a_small_copy_and_the_file_stays_full_size(self):
|
||||
from nodes._image_save_helpers import PREVIEW_EDGE
|
||||
with tempfile.TemporaryDirectory() as out, tempfile.TemporaryDirectory() as temp:
|
||||
result = self.run_node(out, temp)
|
||||
(place,) = result["ui"]["ausboss_preview"]
|
||||
self.assertRegex(place, r"^ausboss_preview/[0-9a-f]{16}_000\.webp$")
|
||||
with Image.open(Path(temp) / place) as small:
|
||||
self.assertEqual(small.format, "WEBP")
|
||||
self.assertEqual(small.size, (PREVIEW_EDGE, 32))
|
||||
with Image.open(result["result"][0]) as saved:
|
||||
self.assertEqual(saved.size, (4096, 64))
|
||||
# The queue still lists the saved file, not the copy.
|
||||
self.assertEqual(result["ui"]["images"], [{"filename": "sheet_00001.png", "subfolder": "", "type": "output"}])
|
||||
|
||||
def test_every_saved_picture_has_its_copy_in_order(self):
|
||||
with tempfile.TemporaryDirectory() as out, tempfile.TemporaryDirectory() as temp:
|
||||
result = self.run_node(out, temp, images=gradient_batch(3, 8, 16))
|
||||
places = result["ui"]["ausboss_preview"]
|
||||
self.assertEqual(len(places), len(result["ui"]["images"]))
|
||||
self.assertEqual([place[-8:] for place in places], ["000.webp", "001.webp", "002.webp"])
|
||||
for place in places:
|
||||
with Image.open(Path(temp) / place) as small:
|
||||
self.assertEqual(small.size, (16, 8))
|
||||
|
||||
def test_held_pictures_get_copies_too(self):
|
||||
with tempfile.TemporaryDirectory() as out, tempfile.TemporaryDirectory() as temp:
|
||||
result = self.run_node(out, temp, autosave=False)
|
||||
(place,) = result["ui"]["ausboss_preview"]
|
||||
self.assertTrue((Path(temp) / place).is_file())
|
||||
self.assertEqual(len(result["ui"]["ausboss_held"]), 1)
|
||||
|
||||
def test_preview_off_writes_no_copy(self):
|
||||
with tempfile.TemporaryDirectory() as out, tempfile.TemporaryDirectory() as temp:
|
||||
result = self.run_node(out, temp, preview=False)
|
||||
self.assertEqual(result["ui"]["ausboss_preview"], [])
|
||||
self.assertFalse((Path(temp) / "ausboss_preview").exists())
|
||||
self.assertTrue(Path(result["result"][0]).is_file())
|
||||
|
||||
def test_a_skipped_file_has_no_copy(self):
|
||||
with tempfile.TemporaryDirectory() as out, tempfile.TemporaryDirectory() as temp:
|
||||
(Path(out) / "photo.png").write_bytes(b"original")
|
||||
result = self.run_node(out, temp, exact_name="photo", on_existing="skip")
|
||||
self.assertEqual(result["ui"]["images"], [])
|
||||
self.assertEqual(result["ui"]["ausboss_preview"], [])
|
||||
|
||||
def test_a_copy_that_cannot_be_written_never_stops_the_save(self):
|
||||
with tempfile.TemporaryDirectory() as out, tempfile.TemporaryDirectory() as temp:
|
||||
blocked = Path(temp) / "not_a_folder"
|
||||
blocked.write_text("x", encoding="utf-8")
|
||||
result = self.run_node(out, blocked)
|
||||
self.assertEqual(result["ui"]["ausboss_preview"], [])
|
||||
self.assertTrue(Path(result["result"][0]).is_file())
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""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
|
||||
CROP_SIDES,
|
||||
RATIOS,
|
||||
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"]:
|
||||
plan = plan_load_size(*row[:12])
|
||||
got = [plan["resize_width"], plan["resize_height"], plan["crop_x"], plan["crop_y"], plan["width"], plan["height"]]
|
||||
self.assertEqual(got, row[12:], row[:12])
|
||||
|
||||
def test_a_crop_keeps_the_side_that_is_asked_for(self):
|
||||
# 200 x 100 into a 100 x 100 box: 100 pixels of width to lose.
|
||||
starts = {"left": 0, "top left": 0, "bottom left": 0, "center": 50, "top": 50, "bottom": 50, "right": 100, "top right": 100, "bottom right": 100}
|
||||
self.assertEqual(set(starts), set(CROP_SIDES))
|
||||
for side, start in starts.items():
|
||||
for size in ("crop to fill", "crop", "crop to ratio"):
|
||||
plan = plan_load_size(200, 100, size, width=100, height=100, multiple=1, crop_from=side, ratio="1:1")
|
||||
self.assertEqual((plan["crop_x"], plan["crop_y"], plan["width"], plan["height"]), (start, 0, 100, 100), (side, size))
|
||||
tall = plan_load_size(100, 300, "crop to fill", width=100, height=100, multiple=1, crop_from="bottom left")
|
||||
self.assertEqual((tall["crop_x"], tall["crop_y"]), (0, 200))
|
||||
|
||||
def test_crop_and_crop_to_ratio_never_resize_and_never_leave_the_picture(self):
|
||||
for source in ((1336, 1784), (1920, 1080), (30, 2000), (10, 10)):
|
||||
for multiple in (1, 8, 64):
|
||||
for size, ratio in (("crop", "1:1"), *(("crop to ratio", ratio) for ratio in RATIOS)):
|
||||
plan = plan_load_size(*source, size, width=1000, height=700, multiple=multiple, crop_from="bottom right", ratio=ratio)
|
||||
self.assertEqual((plan["resize_width"], plan["resize_height"]), source, (source, size, ratio))
|
||||
self.assertGreaterEqual(plan["width"], 1)
|
||||
self.assertGreaterEqual(plan["height"], 1)
|
||||
self.assertLessEqual(plan["crop_x"] + plan["width"], source[0])
|
||||
self.assertLessEqual(plan["crop_y"] + plan["height"], source[1])
|
||||
plan = plan_load_size(1920, 1080, "crop", width=1000, height=700, multiple=64)
|
||||
self.assertEqual((plan["width"], plan["height"]), (960, 640), "rounded down, inside the box")
|
||||
plan = plan_load_size(1336, 1784, "crop to ratio", multiple=1, ratio="16:9")
|
||||
self.assertEqual((plan["width"], plan["height"]), (1336, 752), "the widest 16:9 piece the picture holds")
|
||||
plan = plan_load_size(1336, 1784, "crop to ratio", width=1600, height=900, multiple=1, ratio="width : height")
|
||||
self.assertEqual((plan["width"], plan["height"]), (1336, 752), "the boxes give the shape")
|
||||
|
||||
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")
|
||||
with self.assertRaises(ValueError):
|
||||
plan_load_size(100, 100, "crop", crop_from="middle")
|
||||
with self.assertRaises(ValueError):
|
||||
plan_load_size(100, 100, "crop to ratio", ratio="7:5")
|
||||
|
||||
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", "crop_from", "ratio"],
|
||||
)
|
||||
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_a_crop_takes_the_side_that_is_asked_for_out_of_the_file(self):
|
||||
name = self.write("wide.png", width=200, height=100)
|
||||
whole, *_ = self.run_node(name)
|
||||
left, *_ = self.run_node(name, size="crop to fill", width=100, height=100, multiple=1, method="nearest", crop_from="left")
|
||||
self.assertTrue(torch.equal(left, whole[:, :, 0:100, :]))
|
||||
right, mask, width, height, *_ = self.run_node(name, size="crop", width=60, height=40, multiple=1, crop_from="bottom right")
|
||||
self.assertEqual((width, height), (60, 40))
|
||||
self.assertTrue(torch.equal(right, whole[:, 60:100, 140:200, :]), "a piece of the file, not resized")
|
||||
self.assertEqual(tuple(mask.shape), (1, 40, 60))
|
||||
shaped, _, width, height, *_ = self.run_node(name, size="crop to ratio", ratio="1:1", multiple=1, crop_from="top")
|
||||
self.assertEqual((width, height), (100, 100))
|
||||
self.assertTrue(torch.equal(shaped, whole[:, :, 50:150, :]))
|
||||
|
||||
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