Compare commits

...
Author SHA1 Message Date
ausboss e191f0fc40 Save Image: SAVE ALL keeps every picture of a list run
A list run saves once for each item, so one result holds several held
runs. The button used only the first, and kept one picture of six.
2026-11-11 19:52:41 -06:00
ausboss 090dcdad62 Save Image can wait for a click before it saves
Auto save off: a run shows its pictures on the node and saves nothing.
SAVE ALL keeps every picture, SAVE keeps the one the arrows picked.
Held pictures wait in the temp folder and are copied to the output
folder under the names the node would have given them.
2026-11-11 19:44:19 -06:00
ausboss a92017cff4 Save Image shows every picture of a run at once
A run that saved several pictures showed one, with arrows to step
through them. They now show side by side, as the core Save Image does.
The arrows go on to each picture by itself and round again to all of
them. The other preview nodes show a batch the same way.
2026-11-11 19:41:27 -06:00
12 changed files with 710 additions and 61 deletions
+12
View File
@@ -18,6 +18,18 @@ All notable changes to ComfyUI-AusBoss are documented here.
Edit Lock's `edit_lock` output. It also gives a mask of what the edit
changed and a one-line report.
- **Save Image shows every picture of a run at once.** When a run saves
several pictures, they now show side by side in the node, the way the
core Save Image does. The arrows still go to each picture by itself, and
round again to all of them. Select Frame, Mask Refine, Mask by Name and
LaMa Inpaint show a batch the same way.
- **Save Image can wait for a click before it saves.** Switch **Auto save**
off and a run only shows its pictures on the node. **SAVE ALL** over them
keeps every one; step to one picture with the arrows and **SAVE** keeps
just that one. Good for making a batch and keeping the best. Auto save is
on unless you switch it off, so nothing changes for workflows you have.
- **Mask by Touch: type a name to take it out of the mask.** The name field
under the picture now has a sign in front. Click it to switch between **+**
(add by name) and **−** (take away by name), then type "face" or "hair" and
+12 -2
View File
@@ -50,8 +50,18 @@ file's name. A caption on the `caption_text` input writes the paired `.txt`.
shorter; on again, the node returns to its height with the last saved
image. It is display only: the file is saved either way and the queue
still lists it. When a run saves more than one image (a batch, or a list
such as a folder of images), arrows beside the switch (‹ 2 / 5 ›) step
through them.
such as a folder of images), they all show side by side. The arrows
beside the switch (‹ all 5 ›) go on to each one by itself (‹ 2 / 5 ›) and
back round to all of them. A run of more than 48 shows one at a time.
- **Auto save** (`autosave`, on by default): on, every run saves its
pictures. Off, a run only shows them here and saves nothing. A **SAVE**
button then sits over the picture: **SAVE ALL** keeps every picture of
the run, and with the arrows on one picture, **SAVE** keeps that one.
Use it to make a batch, look, and keep only the good ones. The pictures
are saved under the name and folder set above, in the format picked,
with the workflow inside if **Embed workflow** is on. They wait in
ComfyUI's temp folder, which is emptied when ComfyUI restarts, so save
what you want before that.
- **Preview steps** (`step_preview`, off by default): the switch left of
**preview** shows the picture while it is being made, one step at a time,
where the saved image goes, with a **STOP** button on the bar. Stop a run
+199 -10
View File
@@ -26,7 +26,7 @@ import { suppressCoreImagePreview } from "../shared/core_preview.mjs";
import { autoMaskValues } from "../shared/mask_auto.mjs";
import { hideInputsInDef, hideWidget, setWidgetVisible } from "../shared/widget_visibility.mjs";
import { makePictureStepper } from "../shared/picture_stepper.mjs";
import { keepIndex, stepIndex } from "../shared/picture_steps.mjs";
import { ALL, allLabel, gridShape, heldPlaces, keepIndex, stepIndex, stepWithAll } from "../shared/picture_steps.mjs";
import { addStepConsumer, mountStepOverlay, showsStepsOn, stepRun, stopStepRun, watchSteps } from "../shared/step_preview.mjs";
import {
describeNodePreview,
@@ -55,6 +55,11 @@ const BAR_PADDING = 2;
const PANEL_HEIGHT = STAGE_HEIGHT + BAR_HEIGHT + 4 + 16;
const OFF_HEIGHT = BAR_HEIGHT + 16;
const INPUT_SIDE = 1; // LiteGraph.INPUT
// A run that left this many pictures or fewer opens with all of them side
// by side. More than that and each would be a speck, so the arrows go
// through them one at a time, as before.
const GRID_MAX = 48;
const GRID_GAP = 4;
// The node's height from before its preview was switched off, so switching
// it back on returns the node to it (saved with the workflow).
const HEIGHT_PROPERTY = "ausboss_preview_height";
@@ -114,7 +119,17 @@ const NODE_CONFIG = {
},
AUSBOSS_NODES_SelectFrame: { inputName: "frames", noun: "frames" },
// Its result is the file it just saved; the card sits above the panel.
AUSBOSS_NODES_SaveImage: { inputName: "images", noun: "an image", switchTitle: "Show the saved image here. The file is saved either way.", steps: true },
// SAVE shows only for a run made with Auto save off, which holds its
// pictures until this button keeps them.
AUSBOSS_NODES_SaveImage: {
inputName: "images",
noun: "an image",
switchTitle: "Show the saved image here. The file is saved either way.",
steps: true,
tools: [
{ label: "SAVE", title: "Save what is showing: every picture, or the one the arrows picked.", action: keepHeld, held: true },
],
},
};
// The picture sits out of flow, centred in the stage, so it can never set
@@ -138,10 +153,16 @@ function ensureCss() {
.ausboss-input-preview-stage img,.ausboss-input-preview-stage video{display:none;position:absolute;inset:0;margin:auto;max-width:100%;max-height:100%;object-fit:contain;}
.ausboss-input-preview-stage.show-image img{display:block;}
.ausboss-input-preview-stage.show-video video{display:block;}
.ausboss-input-preview-grid{display:none;position:absolute;inset:0;box-sizing:border-box;gap:${GRID_GAP}px;padding:${GRID_GAP}px;justify-content:center;align-content:center;}
.ausboss-input-preview-stage.show-grid .ausboss-input-preview-grid{display:grid;}
.ausboss-input-preview-stage .ausboss-input-preview-grid img{display:block;position:static;margin:0;width:100%;height:100%;min-width:0;min-height:0;max-width:none;max-height:none;object-fit:contain;}
.ausboss-input-preview-hint{display:none;max-width:86%;color:${T.faint};font:11px/1.35 ${FONT_UI};text-align:center;}
.ausboss-input-preview-stage:not(.show-image):not(.show-video) .ausboss-input-preview-hint{display:block;}
.ausboss-input-preview-stage:not(.show-image):not(.show-video):not(.show-grid) .ausboss-input-preview-hint{display:block;}
.ausboss-input-preview-tools{display:flex;gap:4px;pointer-events:auto;}
.ausboss-input-preview-tool{box-sizing:border-box;height:${BAR_HEIGHT}px;min-width:24px;padding:0 6px;border:1px solid transparent;border-radius:${RADIUS_XS}px;background:${T.fill};color:${T.label};font:600 9.5px/18px ${FONT_UI};letter-spacing:.02em;cursor:pointer;}
.ausboss-input-preview-tool[hidden]{display:none;}
.ausboss-input-preview-tool.keep{color:${T.brandInk};background:${T.brandSoft};}
.ausboss-input-preview-stage .ausboss-input-preview-grid img.kept{outline:2px solid ${T.brand};outline-offset:-2px;}
.ausboss-input-preview-tool:hover{color:${T.ink};background:${T.fillHover};}
.ausboss-input-preview-tool.active{color:${T.brandInk};background:${T.brandSoft};}
.ausboss-input-preview-tool:focus-visible{outline:2px solid ${T.brand};outline-offset:1px;}
@@ -488,13 +509,14 @@ function buildTools(state, tools, signal) {
tool.action(state);
}, { signal });
state.toolButtons[tool.label] = button;
if (tool.held) { button.hidden = true; state.keepButton = button; }
bar.append(button);
}
return bar;
}
function showHint(state, text) {
state.stage.classList.remove("show-image", "show-video");
state.stage.classList.remove("show-image", "show-video", "show-grid");
state.hint.textContent = text;
state.placeholder = null;
releaseSteps(state);
@@ -509,6 +531,7 @@ function showPlaceholder(state, connected) {
function showMedia(state, kind) {
state.stage.classList.toggle("show-image", kind === "image");
state.stage.classList.toggle("show-video", kind === "video");
state.stage.classList.toggle("show-grid", kind === "grid");
state.placeholder = null;
releaseSteps(state);
}
@@ -517,6 +540,53 @@ function clearMedia(state) {
state.img.removeAttribute("src");
state.video.pause?.();
state.video.removeAttribute("src");
clearGrid(state);
}
function clearGrid(state) {
if (!state.gridKey) return;
state.gridKey = "";
state.grid?.replaceChildren();
}
// Every picture of the run at once, each as big as the stage allows.
function layoutGrid(state) {
const cells = [...(state.grid?.children ?? [])];
if (!cells.length) return;
const loaded = cells.find((cell) => cell.naturalWidth > 0 && cell.naturalHeight > 0);
const aspect = loaded ? loaded.naturalWidth / loaded.naturalHeight : 1;
const { columns, rows } = gridShape(cells.length, state.stage.clientWidth, state.stage.clientHeight, aspect);
// Tracks the size of one picture, so the pictures sit together in the
// middle of the stage with no bands between the rows.
const wide = (state.stage.clientWidth - GRID_GAP * (columns + 1)) / columns;
const tall = (state.stage.clientHeight - GRID_GAP * (rows + 1)) / rows;
if (!(wide > 0) || !(tall > 0)) return;
const width = Math.floor(Math.min(wide, tall * aspect));
const height = Math.floor(width / aspect);
state.grid.style.gridTemplateColumns = `repeat(${columns}, ${width}px)`;
state.grid.style.gridTemplateRows = `repeat(${rows}, ${height}px)`;
}
function showGrid(state, count) {
const urls = Array.from({ length: count }, (_, index) => storedResultUrl(state.node, index) ?? "");
const key = urls.join("\n");
if (state.gridKey !== key) {
state.gridKey = key;
state.grid.replaceChildren(...urls.map((url, index) => {
const cell = document.createElement("img");
cell.alt = `Picture ${index + 1} of ${count}`;
cell.decoding = "async";
// The first picture to load tells the grid the shape to lay out for.
cell.addEventListener("load", () => layoutGrid(state), { once: true });
cell.src = url;
return cell;
}));
}
state.img.removeAttribute("src");
state.video.pause?.();
state.video.removeAttribute("src");
layoutGrid(state);
showMedia(state, "grid");
}
// Each source has one hook, with subscribers that can leave in any order.
@@ -569,8 +639,9 @@ function storedResultUrl(node, index = null) {
}
// Several pictures from one run (a list, or a batch Save Image saved one by
// one): the arrows in the bar pick which one shows. One picture keeps the
// panel as it was, with no arrows.
// one): they all show side by side, and the arrows in the bar go on to each
// one by itself and back round to all of them. One picture keeps the panel
// as it was, with no arrows.
function resultCount(state) {
if (state.stale || state.resultGone) return 0;
return outputRecordCount(app.nodeOutputs?.[outputLocatorId(state.node)]);
@@ -586,10 +657,107 @@ function previewMinWidth(state) {
return Math.max(PANEL_MIN_WIDTH, Math.ceil(state.node.size[0] - state.bar.clientWidth + needed));
}
// --- pictures held until Save (Save Image with Auto save off) ---------------
// The pictures this node is holding, in the order it shows them. Empty when
// the last run saved its pictures itself.
function heldPictures(state) {
if (state.stale || state.resultGone) return [];
return heldPlaces(app.nodeOutputs?.[outputLocatorId(state.node)]);
}
// Which of the shown pictures are kept, by their place on the node.
function keptOf(state, held) {
const key = held.map((picture) => `${picture.token}_${picture.index}`).join(" ");
if (state.kept?.key !== key) state.kept = { key, indexes: new Set() };
return state.kept.indexes;
}
// A click keeps every picture, or only the one the arrows picked. With the
// preview off there is no picture to pick, so it keeps them all.
function keepsAll(state, count) {
return count < 2 || showsAll(state, count) || !previewEnabled(state);
}
// The button says what a click will do: SAVE ALL over the grid, SAVE over
// one picture, SAVED once there is nothing left to keep there.
function syncHeld(state, count = resultCount(state)) {
const button = state.keepButton;
if (!button) return;
const held = heldPictures(state);
button.hidden = !held.length || count < 1;
if (button.hidden) return;
const kept = keptOf(state, held);
const all = keepsAll(state, count);
const done = all ? kept.size >= held.length : kept.has(state.index);
button.textContent = state.keeping ? "SAVING" : done ? "SAVED" : all && count > 1 ? "SAVE ALL" : "SAVE";
button.classList.toggle("keep", !done);
button.disabled = Boolean(state.keeping);
[...(state.grid?.children ?? [])].forEach((cell, index) => cell.classList.toggle("kept", kept.has(index)));
}
async function keepHeld(state) {
const held = heldPictures(state);
if (!held.length || state.keeping) return;
const count = resultCount(state);
const wanted = keepsAll(state, count) ? held.map((_, place) => place) : [state.index];
// One request for each run, in the order the pictures show.
const runs = new Map();
for (const place of wanted) {
const picture = held[place];
if (!picture) continue;
if (!runs.has(picture.token)) runs.set(picture.token, []);
runs.get(picture.token).push(picture.index);
}
state.keeping = true;
syncHeld(state);
const kept = keptOf(state, held);
const saved = [];
let skipped = 0;
try {
for (const [token, picks] of runs) {
const response = await api.fetchApi("/ausboss/save_image/keep", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, picks }),
});
const answer = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(answer.error || "Could not save.");
if (!state.alive) return;
const done = new Set(answer.kept ?? []);
held.forEach((picture, place) => {
if (picture.token === token && done.has(picture.index)) kept.add(place);
});
saved.push(...(answer.saved ?? []));
skipped += answer.skipped?.length ?? 0;
}
toast(state, saved.length === 1
? `Saved ${saved[0].path}`
: saved.length
? `Saved ${saved.length} pictures`
: skipped ? "Not saved: the file already exists" : "Already saved");
} catch (error) {
if (state.alive) toast(state, error?.message || "Could not save.");
} finally {
state.keeping = false;
if (state.alive) syncHeld(state);
}
}
function showsAll(state, count) {
return count > 1 && count <= GRID_MAX && state.all !== false;
}
function stepPicture(state, step) {
const count = resultCount(state);
if (count < 2) return;
state.index = stepIndex(state.index, count, step);
if (count > GRID_MAX) {
state.index = stepIndex(state.index, count, step);
} else {
const at = stepWithAll(showsAll(state, count) ? ALL : state.index, count, step);
state.all = at === ALL;
if (!state.all) state.index = at;
}
refresh(state);
}
@@ -597,6 +765,7 @@ function refresh(state) {
if (!state.alive) return;
if (!previewEnabled(state)) {
clearMedia(state);
syncHeld(state);
return;
}
const source = upstreamNode(state.node, state.inputName);
@@ -604,7 +773,9 @@ function refresh(state) {
const count = resultCount(state);
const hidden = state.stepper.root.hidden;
state.index = keepIndex(state.index, count);
state.stepper.set(state.index, count);
const all = showsAll(state, count);
state.stepper.set(state.index, count, all ? allLabel(count) : undefined);
syncHeld(state, count);
if (state.stepper.root.hidden !== hidden) growToNodeFloor(state.widget);
if (state.stale) {
// The node ran while its preview was off, so the picture it still has
@@ -616,6 +787,13 @@ function refresh(state) {
// A node that just stopped the run has no result for this picture: the
// one it still holds is from an earlier run (Mask by Name's last find,
// tinted), so show what feeds it instead.
if (all) {
showGrid(state, count);
// The grid's cells are new: mark the ones already kept.
syncHeld(state, count);
return;
}
clearGrid(state);
const described = state.resultGone
? describeSourcePreview(source)
: count > 1
@@ -701,7 +879,9 @@ function buildPanel(node, config) {
const toastEl = document.createElement("div");
toastEl.className = "ausboss-input-preview-toast";
toastEl.style.display = "none";
stage.append(img, video, hint, toastEl);
const grid = document.createElement("div");
grid.className = "ausboss-input-preview-grid";
stage.append(img, video, grid, hint, toastEl);
const bar = document.createElement("div");
bar.className = "ausboss-input-preview-bar";
root.append(bar, stage);
@@ -735,7 +915,10 @@ function buildPanel(node, config) {
noun: config.noun,
switchTitle: config.switchTitle,
advanced: config.advanced,
root, bar, stage, img, video, hint, widget, abort,
root, bar, stage, img, video, grid, hint, widget, abort,
// Several pictures open side by side; the arrows pick one (false).
all: true,
gridKey: "",
toast: toastEl,
toastTimer: 0,
toolButtons: null,
@@ -769,6 +952,12 @@ function buildPanel(node, config) {
installPreviewSwitch(state, abort.signal);
if (config.steps) installStepsSwitch(state, abort.signal);
// The grid is laid out for the stage's shape, so a resized node lays it out again.
if (typeof ResizeObserver === "function") {
const watcher = new ResizeObserver(() => { if (state.alive && state.gridKey) layoutGrid(state); });
watcher.observe(stage);
abort.signal.addEventListener("abort", () => watcher.disconnect(), { once: true });
}
img.addEventListener("load", () => showMedia(state, "image"));
img.addEventListener("error", () => {
if (img.getAttribute("src")) showPlaceholder(state, true);
+23 -2
View File
@@ -29,11 +29,11 @@ const NODE_MIN_WIDTH = 340;
const NAME_TITLE = "The local filename; subfolders are allowed. The tags below are appended to it. Tokens work: %date:yyyy-MM-dd%, %width%, %KSampler.seed%.";
// Captions, fields, the two-line preview, chips, pills, the embed row and
// the gaps between them; the frontend's wrapper insets ride on top.
const CARD_HEIGHT = 288;
const CARD_HEIGHT = 320;
const WRAPPER_INSET = 16;
const HIDDEN = [
"filename_prefix", "format", "save_metadata", "exact_name", "on_existing",
"output_dir", "caption", "name_counter", "name_date", "name_time", "name_size", "name_batch",
"output_dir", "caption", "name_counter", "name_date", "name_time", "name_size", "name_batch", "autosave",
];
const FONT = `-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif`;
const MONO = `ui-monospace,SFMono-Regular,Menlo,Consolas,monospace`;
@@ -285,6 +285,22 @@ function buildCard(node) {
embedRow.append(el("span", "ausboss-save-embed-label", PANEL_LABELS.embed), embedSeg);
root.append(embedRow);
// Auto save: off shows the pictures without saving them; the Save button
// over the picture (js/input_preview) then keeps the ones that are wanted.
const autoRow = el("div", "ausboss-save-row");
const autoSeg = el("div", "ausboss-card-seg ausboss-card-bool");
autoSeg.setAttribute("role", "radiogroup");
const autoButtons = new Map();
const autoTitle = "On: every run saves its pictures. Off: the pictures only show here, and the Save button over them keeps the ones you want.";
for (const [on, text] of [[false, PANEL_LABELS.off], [true, PANEL_LABELS.on]]) {
const button = el("button", "", text);
button.type = "button"; button.title = autoTitle;
button.addEventListener("click", () => { if (setValue(node, "autosave", on)) { state.savedPath = null; notifyAusbossChange(); } state.refresh(); });
autoSeg.append(button); autoButtons.set(on, button);
}
autoRow.append(el("span", "ausboss-save-embed-label", "Auto save"), autoSeg);
root.append(autoRow);
root.addEventListener("pointerdown", (event) => {
if (event.target.closest("button,input,select")) event.stopPropagation();
});
@@ -330,6 +346,11 @@ function buildCard(node) {
button.classList.toggle("on", on === embed);
button.setAttribute("aria-checked", String(on === embed));
}
const auto = values.autosave !== false;
for (const [on, button] of autoButtons) {
button.classList.toggle("on", on === auto);
button.setAttribute("aria-checked", String(on === auto));
}
};
const domWidget = node.addDOMWidget(WIDGET_NAME, WIDGET_NAME, root, {
+3 -2
View File
@@ -52,9 +52,10 @@ export function makePictureStepper({ noun = "picture", height = 24, onStep, sign
root.append(arrow("‹", -1, `Previous ${noun}`), label, arrow("›", 1, `Next ${noun}`));
return {
root,
set(index, count) {
// `text` replaces the "2 / 5" for a stop that is not one picture.
set(index, count, text) {
root.hidden = !(count > 1);
label.textContent = stepperLabel(index, count);
label.textContent = text ?? stepperLabel(index, count);
},
};
}
+50
View File
@@ -19,3 +19,53 @@ export function stepIndex(index, count, step) {
export function stepperLabel(index, count) {
return count > 1 ? `${keepIndex(index, count) + 1} / ${count}` : "";
}
// The preview panel has one more stop, before picture 1: every picture at
// once. ALL stands for it where an index would be.
export const ALL = -1;
// One step back or on through "all, 1, 2 ... count", round at both ends.
export function stepWithAll(index, count, step) {
if (!(count > 0)) return ALL;
const stops = count + 1;
const at = index === ALL ? 0 : keepIndex(index, count) + 1;
const next = (((at + Math.sign(Number(step) || 0)) % stops) + stops) % stops;
return next === 0 ? ALL : next - 1;
}
export function allLabel(count) {
return count > 1 ? `all ${count}` : "";
}
// How to lay `count` pictures out in a box so each one draws as big as it
// can: the columns and rows that leave a picture of this shape (`aspect`,
// width over height) the most room.
export function gridShape(count, width, height, aspect = 1) {
const total = Math.max(1, Math.trunc(Number(count) || 0));
const shape = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
if (!(width > 0) || !(height > 0)) {
const columns = Math.ceil(Math.sqrt(total));
return { columns, rows: Math.ceil(total / columns) };
}
let best = { columns: 1, rows: total, size: 0 };
for (let columns = 1; columns <= total; columns += 1) {
const rows = Math.ceil(total / columns);
const size = Math.min(width / columns, (height / rows) * shape);
if (size > best.size) best = { columns, rows, size };
}
return { columns: best.columns, rows: best.rows };
}
// The held pictures in a Save Image result, in the order they show: each
// one's run token and its place in that run. A list run saves once for each
// item, so one result can hold several tokens. Empty when the result holds
// none, or when a picture in it is not one of the held ones.
export function heldPlaces(record) {
const tokens = new Set(Array.isArray(record?.ausboss_held) ? record.ausboss_held : []);
if (!tokens.size) return [];
const held = (Array.isArray(record.images) ? record.images : []).map((image) => {
const match = /^([0-9a-f]{16})_(\d{3})\./.exec(String(image?.filename ?? ""));
return match && tokens.has(match[1]) ? { token: match[1], index: Number(match[2]) } : null;
});
return held.length && held.every(Boolean) ? held : [];
}
+3
View File
@@ -1110,6 +1110,9 @@
},
"step_preview": {
"name": "步骤预览"
},
"autosave": {
"name": "自动保存"
}
},
"outputs": {
+170
View File
@@ -0,0 +1,170 @@
"""Pictures Save Image holds until they are kept.
With Auto save off, a run writes its pictures to ComfyUI's temp folder and
shows them on the node. A click on the node's Save button keeps the ones
that are wanted: they are copied into the output folder under the names the
node would have given them. Everything here works from names this module
made itself; the only thing a caller sends is the run's token and which
pictures to keep.
"""
from __future__ import annotations
from datetime import datetime
import json
from pathlib import Path
import re
import secrets
import shutil
from ._image_save_helpers import (
IMAGE_SAVE_LOCK,
existing_action,
name_stem,
next_free_counter,
plan_exact_names,
plan_local_names,
require_output_path,
resolve_output_root,
sidecar_path,
split_prefix,
strip_image_extension,
)
HELD_FOLDER = "ausboss_held"
_TOKEN = re.compile(r"^[0-9a-f]{16}$")
GONE = "These pictures are no longer held. Run the workflow again."
def plan_destinations(settings: dict, count: int, now: datetime, root: Path, output_root: Path, picks=None):
"""Where each picture goes, as (path, what to do if it exists).
Call it with IMAGE_SAVE_LOCK held: the counter it reads stays free only
until the lock is let go. `picks` keeps some of a run's pictures; an
exact name then keeps the number it would have had in the whole run.
"""
wanted = list(range(count)) if picks is None else list(picks)
extension = settings["extension"]
exact = settings.get("exact") or ""
if exact:
names = plan_exact_names(strip_image_extension(exact), extension, count)
return [(root / names[index], settings["on_existing"]) for index in wanted]
subfolder, base = split_prefix(settings["filename_prefix"])
folder = root / subfolder if subfolder else root
require_output_path(folder, output_root)
stem = name_stem(
base,
{"date": bool(settings["name_date"]), "time": bool(settings["name_time"]), "size": bool(settings["name_size"])},
now,
int(settings["width"]),
int(settings["height"]),
)
counter = bool(settings["name_counter"])
existing = [entry.name for entry in folder.iterdir()] if folder.is_dir() else []
names = plan_local_names(
stem, extension, len(wanted), counter=counter, batch=bool(settings["name_batch"]),
next_counter=next_free_counter(existing, stem),
)
# With a counter the name is free by construction. Without one, the
# legacy policy decides whether to overwrite.
policy = "overwrite" if counter else settings["on_existing"]
return [(folder / name, policy) for name in names]
def settle_destinations(planned, has_caption: bool, output_root: Path) -> list[str]:
"""Check every destination, then say 'write' or 'skip' for each.
The whole batch is settled before any write: an "error" on a later
picture stops the save before the first one lands.
"""
for path, _policy in planned:
require_output_path(path, output_root)
if has_caption:
require_output_path(sidecar_path(path), output_root)
return [
existing_action(path.exists() or (has_caption and sidecar_path(path).exists()), policy)
for path, policy in planned
]
def _held_name(token: str, index: int, extension: str) -> str:
return f"{token}_{index:03d}.{extension}"
def hold_pictures(temp_root, frames, settings: dict, encode) -> tuple[str, list[dict]]:
"""Write a run's pictures to the temp folder and remember how to name them.
`encode(path, frame)` writes one picture in the run's format. Returns
the run's token and the entries the node shows them from.
"""
folder = Path(temp_root) / HELD_FOLDER
folder.mkdir(parents=True, exist_ok=True)
token = secrets.token_hex(8)
previews = []
for index, frame in enumerate(frames):
name = _held_name(token, index, settings["extension"])
encode(folder / name, frame)
previews.append({"filename": name, "subfolder": HELD_FOLDER, "type": "temp"})
manifest = dict(settings, count=len(previews), kept={})
(folder / f"{token}.json").write_text(json.dumps(manifest), encoding="utf-8")
return token, previews
def keep_pictures(temp_root, output_root, token, picks=None, now: datetime | None = None) -> dict:
"""Copy held pictures into the output folder. Returns what was saved.
`picks` lists which pictures of the run to keep, counted from 0; None
keeps them all. A picture kept before is not saved a second time.
"""
if not isinstance(token, str) or not _TOKEN.match(token):
raise ValueError("Save Image: that is not a run it is holding.")
folder = Path(temp_root) / HELD_FOLDER
manifest_path = folder / f"{token}.json"
if not manifest_path.is_file():
raise LookupError(GONE)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
count = int(manifest["count"])
kept = {int(index): str(path) for index, path in dict(manifest.get("kept") or {}).items()}
if picks is None:
wanted = list(range(count))
else:
wanted = []
for pick in picks:
if isinstance(pick, bool) or not isinstance(pick, int) or not 0 <= pick < count:
raise ValueError("Save Image: that picture is not in this run.")
if pick not in wanted:
wanted.append(pick)
todo = [index for index in wanted if index not in kept]
output_root = Path(output_root)
caption = str(manifest.get("caption") or "")
has_caption = bool(caption.strip())
skipped: list[int] = []
saved: list[int] = []
with IMAGE_SAVE_LOCK:
root = resolve_output_root(manifest.get("output_dir", ""), output_root)
planned = plan_destinations(manifest, count, now or datetime.now(), root, output_root, picks=todo)
actions = settle_destinations(planned, has_caption, output_root)
resolved_root = output_root.resolve()
for (path, _policy), action, index in zip(planned, actions, todo):
if action == "skip":
skipped.append(index)
continue
source = folder / _held_name(token, index, manifest["extension"])
if not source.is_file():
raise LookupError(GONE)
path.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, path)
if has_caption:
sidecar_path(path).write_text(caption, encoding="utf-8")
kept[index] = path.resolve().relative_to(resolved_root).as_posix()
saved.append(index)
manifest["kept"] = {str(index): path for index, path in kept.items()}
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
return {
"count": count,
"saved": [{"index": index, "path": kept[index]} for index in saved],
"kept": sorted(kept),
"skipped": skipped,
}
+78 -44
View File
@@ -6,26 +6,20 @@ from datetime import datetime
from pathlib import Path
from ._execution_helpers import ignore_extra_inputs
from ._image_hold_helpers import hold_pictures, keep_pictures, plan_destinations, settle_destinations
from ._image_save_helpers import (
EXISTING_POLICIES,
FORMAT_EXTENSIONS,
IMAGE_FORMATS,
IMAGE_SAVE_LOCK,
encode_image,
existing_action,
fill_name_tokens,
metadata_disabled,
name_stem,
next_free_counter,
plan_exact_names,
plan_local_names,
resolve_output_root,
require_output_path,
sanitize_exact_name,
sidecar_path,
split_prefix,
strip_image_extension,
)
from ._route_errors import safe_route_error
from ._step_preview_helpers import STEP_PREVIEW_DEFAULT, register_step_preview_handler
from ._video_save_helpers import workflow_metadata
@@ -68,6 +62,33 @@ def _register_folder_route() -> None:
shown = "" if target == root else target.relative_to(root).as_posix()
return web.json_response({"path": shown, "folders": folders})
@PromptServer.instance.routes.post("/ausboss/save_image/keep")
async def keep_held(request):
"""Save pictures a run is holding (Auto save off). The body names the
run by the token the node was given and, optionally, which of its
pictures to keep. Names and folders come from what the run stored,
never from the request."""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "bad request"}, status=400)
if not isinstance(body, dict):
return web.json_response({"error": "bad request"}, status=400)
picks = body.get("picks")
if picks is not None and not isinstance(picks, list):
return web.json_response({"error": "bad request"}, status=400)
try:
answer = keep_pictures(
folder_paths.get_temp_directory(), folder_paths.get_output_directory(), body.get("token"), picks,
)
except LookupError as exc:
return web.json_response({"error": safe_route_error(exc, "Those pictures are gone.")}, status=404)
except ValueError as exc:
return web.json_response({"error": safe_route_error(exc, "Could not save.")}, status=400)
except Exception as exc:
return web.json_response({"error": safe_route_error(exc, "Could not save.")}, status=500)
return web.json_response(answer)
class AusBossSaveImage:
CATEGORY = "🆎 AusBoss/Image"
@@ -277,6 +298,17 @@ class AusBossSaveImage:
),
},
),
"autosave": (
"BOOLEAN",
{
"default": True,
"tooltip": (
"On: every run saves its pictures. Off: the "
"pictures only show on the node, and its Save "
"button keeps the ones you want."
),
},
),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
@@ -310,6 +342,7 @@ class AusBossSaveImage:
name_batch=False,
preview=True,
step_preview=STEP_PREVIEW_DEFAULT,
autosave=True,
prompt=None,
extra_pnginfo=None,
):
@@ -343,45 +376,46 @@ class AusBossSaveImage:
else:
exact = sanitize_exact_name(exact_name)
with IMAGE_SAVE_LOCK:
planned: list[tuple[Path, str]] = [] # (path, collision policy)
if exact:
base = strip_image_extension(exact)
for name in plan_exact_names(base, extension, count):
planned.append((root / name, on_existing))
else:
subfolder, base = split_prefix(filename_prefix)
folder = root / subfolder if subfolder else root
require_output_path(folder, output_root)
stem = name_stem(
base,
{"date": bool(name_date), "time": bool(name_time), "size": bool(name_size)},
now,
width,
height,
)
counter = bool(name_counter)
existing = [entry.name for entry in folder.iterdir()] if folder.is_dir() else []
names = plan_local_names(
stem, extension, count, counter=counter, batch=bool(name_batch),
next_counter=next_free_counter(existing, stem),
)
# The shared lock keeps this counter free until writing finishes.
# Without it, the legacy policy decides whether to overwrite.
policy = "overwrite" if counter else on_existing
planned.extend((folder / name, policy) for name in names)
settings = {
"format": format,
"extension": extension,
"width": width,
"height": height,
"exact": exact,
"filename_prefix": filename_prefix,
"on_existing": on_existing,
"output_dir": str(output_dir or ""),
"name_counter": bool(name_counter),
"name_date": bool(name_date),
"name_time": bool(name_time),
"name_size": bool(name_size),
"name_batch": bool(name_batch),
"caption": caption_value,
}
has_caption = bool(caption_value.strip())
if not autosave:
# Nothing is saved yet. The pictures wait in the temp folder, in
# their final format, and the node's Save button keeps the ones
# that are wanted (the keep route above). A bad name or folder
# still stops the run here, not at the click.
with IMAGE_SAVE_LOCK:
settle_destinations(plan_destinations(settings, count, now, root, output_root), has_caption, output_root)
token, previews = hold_pictures(
folder_paths.get_temp_directory(), images, settings,
lambda path, frame: encode_image(path, frame, format, metadata),
)
return {
"ui": {"images": previews, "ausboss_saved_path": [], "ausboss_held": [token]},
"result": ("", images),
}
with IMAGE_SAVE_LOCK:
# Validate the whole batch before any write, including caption targets,
# then settle every collision: an "error" on a later frame must stop
# the run before the first frame lands, not after.
for path, _policy in planned:
require_output_path(path, output_root)
if caption_value.strip():
require_output_path(sidecar_path(path), output_root)
actions = [
existing_action(path.exists() or (bool(caption_value.strip()) and sidecar_path(path).exists()), policy)
for path, policy in planned
]
planned = plan_destinations(settings, count, now, root, output_root)
actions = settle_destinations(planned, has_caption, output_root)
saved: list[Path] = []
for (path, _policy), action, frame in zip(planned, actions, images):
@@ -389,7 +423,7 @@ class AusBossSaveImage:
continue
path.parent.mkdir(parents=True, exist_ok=True)
encode_image(path, frame, format, metadata)
if caption_value.strip():
if has_caption:
sidecar_path(path).write_text(caption_value, encoding="utf-8")
saved.append(path)
+2 -1
View File
@@ -886,7 +886,8 @@
"name_size",
"name_batch",
"preview",
"step_preview"
"step_preview",
"autosave"
],
"outputs": [
[
+48
View File
@@ -26,3 +26,51 @@ test("the label counts from one and is empty for a single picture", () => {
assert.equal(stepperLabel(0, 1), "");
assert.equal(stepperLabel(0, 0), "");
});
test("all the pictures are one more stop before the first", async () => {
const { ALL, allLabel, stepWithAll } = await import("../js/shared/picture_steps.mjs");
assert.equal(stepWithAll(ALL, 3, 1), 0);
assert.equal(stepWithAll(0, 3, 1), 1);
assert.equal(stepWithAll(2, 3, 1), ALL);
assert.equal(stepWithAll(ALL, 3, -1), 2);
assert.equal(stepWithAll(0, 3, -1), ALL);
assert.equal(stepWithAll(1, 3, 0), 1);
assert.equal(stepWithAll(0, 0, 1), ALL);
assert.equal(allLabel(5), "all 5");
assert.equal(allLabel(1), "");
});
test("the grid gives each picture the most room its shape allows", async () => {
const { gridShape } = await import("../js/shared/picture_steps.mjs");
// Five portraits in a tall stage: two across beats one row or one column.
assert.deepEqual(gridShape(5, 440, 1100, 3 / 4), { columns: 2, rows: 3 });
// The same five in a wide stage go in one row.
assert.deepEqual(gridShape(5, 1600, 440, 3 / 4), { columns: 5, rows: 1 });
// Wide pictures in a tall stage stack.
assert.deepEqual(gridShape(3, 400, 900, 16 / 9), { columns: 1, rows: 3 });
assert.deepEqual(gridShape(1, 400, 400, 1), { columns: 1, rows: 1 });
// A stage that has no size yet still gets a whole grid.
assert.deepEqual(gridShape(5, 0, 0, 1), { columns: 3, rows: 2 });
assert.deepEqual(gridShape(4, 400, 400, Number.NaN), { columns: 2, rows: 2 });
});
test("held pictures map to their run and their place in it", async () => {
const { heldPlaces } = await import("../js/shared/picture_steps.mjs");
const a = "0123456789abcdef";
const b = "fedcba9876543210";
const name = (token, index) => ({ filename: `${token}_00${index}.png`, subfolder: "ausboss_held", type: "temp" });
// One run of three pictures.
assert.deepEqual(
heldPlaces({ images: [name(a, 0), name(a, 1), name(a, 2)], ausboss_held: [a] }),
[{ token: a, index: 0 }, { token: a, index: 1 }, { token: a, index: 2 }],
);
// A list run: one picture from each of two runs.
assert.deepEqual(
heldPlaces({ images: [name(a, 0), name(b, 0)], ausboss_held: [a, b] }),
[{ token: a, index: 0 }, { token: b, index: 0 }],
);
// A saved run holds nothing, and a picture from another run is not trusted.
assert.deepEqual(heldPlaces({ images: [{ filename: "shot_00001.png" }] }), []);
assert.deepEqual(heldPlaces({ images: [name(a, 0), name(b, 0)], ausboss_held: [a] }), []);
assert.deepEqual(heldPlaces(undefined), []);
});
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
del sys.modules["nodes"]
from nodes._image_hold_helpers import GONE, HELD_FOLDER, hold_pictures, keep_pictures
NOW = datetime(2026, 11, 10, 9, 30, 0)
def settings(**changes):
base = {
"format": "png", "extension": "png", "width": 64, "height": 96, "exact": "",
"filename_prefix": "shoot", "on_existing": "overwrite", "output_dir": "",
"name_counter": True, "name_date": False, "name_time": False, "name_size": False,
"name_batch": False, "caption": "",
}
base.update(changes)
return base
class HoldTests(unittest.TestCase):
def setUp(self):
self.box = tempfile.TemporaryDirectory()
self.temp = Path(self.box.name) / "temp"
self.output = Path(self.box.name) / "output"
self.temp.mkdir()
self.output.mkdir()
def tearDown(self):
self.box.cleanup()
def hold(self, count=3, **changes):
frames = [f"picture {index}".encode() for index in range(count)]
return hold_pictures(self.temp, frames, settings(**changes), lambda path, frame: path.write_bytes(frame))
def keep(self, token, picks=None):
return keep_pictures(self.temp, self.output, token, picks, now=NOW)
def test_a_held_run_saves_nothing_until_it_is_kept(self):
token, previews = self.hold()
self.assertEqual(len(previews), 3)
self.assertTrue(all(entry["type"] == "temp" and entry["subfolder"] == HELD_FOLDER for entry in previews))
self.assertEqual(list(self.output.iterdir()), [])
answer = self.keep(token)
self.assertEqual([item["path"] for item in answer["saved"]], ["shoot_00001.png", "shoot_00002.png", "shoot_00003.png"])
self.assertEqual((self.output / "shoot_00002.png").read_bytes(), b"picture 1")
def test_one_picture_can_be_kept_and_is_not_saved_twice(self):
token, _ = self.hold()
first = self.keep(token, [2])
self.assertEqual(first["saved"], [{"index": 2, "path": "shoot_00001.png"}])
self.assertEqual((self.output / "shoot_00001.png").read_bytes(), b"picture 2")
again = self.keep(token, [2])
self.assertEqual(again["saved"], [])
rest = self.keep(token)
self.assertEqual([item["index"] for item in rest["saved"]], [0, 1])
self.assertEqual(rest["kept"], [0, 1, 2])
self.assertEqual(sorted(entry.name for entry in self.output.iterdir()), ["shoot_00001.png", "shoot_00002.png", "shoot_00003.png"])
def test_the_folder_and_caption_of_the_run_are_used(self):
token, _ = self.hold(count=1, output_dir="sets/portraits", caption="a portrait")
answer = self.keep(token)
self.assertEqual(answer["saved"][0]["path"], "sets/portraits/shoot_00001.png")
self.assertEqual((self.output / "sets/portraits/shoot_00001.txt").read_text(), "a portrait")
def test_an_exact_name_keeps_its_place_in_the_run(self):
token, _ = self.hold(count=3, exact="portrait")
whole = [item["path"] for item in self.keep(self.hold(count=3, exact="other")[0])["saved"]]
picked = self.keep(token, [1])["saved"][0]["path"]
self.assertEqual(picked, whole[1].replace("other", "portrait"))
def test_a_request_cannot_name_a_place(self):
token, _ = self.hold()
for bad in ["../" + token, token + "/..", "", None, 5, token.upper(), "g" * 16]:
with self.assertRaises(ValueError):
self.keep(bad)
for picks in [[3], [-1], ["0"], [True], [0.0]]:
with self.assertRaises(ValueError):
self.keep(token, picks)
self.assertEqual(list(self.output.iterdir()), [])
def test_pictures_that_are_gone_say_so(self):
with self.assertRaises(LookupError) as caught:
self.keep("0" * 16)
self.assertEqual(str(caught.exception), GONE)
token, previews = self.hold(count=1)
(self.temp / HELD_FOLDER / previews[0]["filename"]).unlink()
with self.assertRaises(LookupError):
self.keep(token)
def test_a_folder_outside_the_output_folder_is_refused_at_the_click_too(self):
token, _ = self.hold(count=1)
manifest = self.temp / HELD_FOLDER / f"{token}.json"
manifest.write_text(manifest.read_text().replace('"output_dir": ""', '"output_dir": "../elsewhere"'))
with self.assertRaises(ValueError):
self.keep(token)
self.assertFalse((Path(self.box.name) / "elsewhere").exists())
if __name__ == "__main__":
unittest.main()