Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e191f0fc40 | ||
|
|
090dcdad62 | ||
|
|
a92017cff4 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 : [];
|
||||
}
|
||||
|
||||
@@ -1110,6 +1110,9 @@
|
||||
},
|
||||
"step_preview": {
|
||||
"name": "步骤预览"
|
||||
},
|
||||
"autosave": {
|
||||
"name": "自动保存"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Vendored
+2
-1
@@ -886,7 +886,8 @@
|
||||
"name_size",
|
||||
"name_batch",
|
||||
"preview",
|
||||
"step_preview"
|
||||
"step_preview",
|
||||
"autosave"
|
||||
],
|
||||
"outputs": [
|
||||
[
|
||||
|
||||
@@ -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), []);
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user