Compare commits

..
Author SHA1 Message Date
ausboss 89bda6ba11 Merge pull request #329 from ausboss/feat/text-lines-node
Text Lines: run what comes after once for each line of text
2026-11-11 19:56:16 -06:00
ausboss 47d1c4f739 Text Lines: run what comes after once for each line of text
A new node. Type a list of prompts, one on each line, or wire in a list
another node wrote. Empty lines are skipped, a number or bullet at the
start of a line can be taken off, and a limit stops a list that ran long.
2026-11-11 19:41:41 -06:00
21 changed files with 463 additions and 709 deletions
+5 -11
View File
@@ -18,17 +18,11 @@ 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.
- **New node: Text Lines.** It runs what comes after once for each line of
your text. Type five prompts, one on each line, and one run makes five
pictures. It also takes a list a text model wrote: empty lines are
skipped, a leading number or bullet comes off, and **Limit** stops a list
that ran long.
- **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 **+**
+4
View File
@@ -357,6 +357,10 @@ Puts batch `b` after `a`. When the sizes match, nothing is resized. When they di
A text box that grows with the node and sends its text, unchanged, down a `STRING` wire. Use it for a prompt, caption, path or other text that several nodes share. Spaces and line breaks are kept. Ctrl + Up and Ctrl + Down change the weight of the selected words, as in ComfyUI's own prompt boxes.
### Text Lines
Runs what comes after once for each line of your text. Type five prompts, one on each line, and one run makes five pictures. It also takes a list another node wrote, skips empty lines, and can take a leading `1.` or `-` off each line. `number` counts the lines from 1, to give each its own seed.
### H3 Prompt Writer
Type what happens, one line for each moment, and leave a blank line to cut to a new place. Connect up to four pictures and give each one a job: a person, a place, an outfit, a thing or a style. Copy the moves from a video, keep a clip going, or add a voice or a song. A timeline on the node shows when each moment plays before you run it. It needs no extra model and you never see H3's prompt format. Connect a small text model and it fills in a one-line idea.
+1
View File
@@ -53,6 +53,7 @@ NODE_MODULES = [
"node_select_frame",
"node_show_text",
"node_step_preview",
"node_text_lines",
"node_tiled_upscale",
"node_video_crop_rotate_pad",
"node_video_crop_rotate_pad_clip",
+2 -12
View File
@@ -50,18 +50,8 @@ file's name. A caption on the `caption_text` input writes the paired `.txt`.
shorter; on again, the node returns to its height with the last saved
image. It is display only: the file is saved either way and the queue
still lists it. When a run saves more than one image (a batch, or a list
such as a folder of images), they all show side by side. The arrows
beside the switch (‹ all 5 ›) go on to each one by itself (‹ 2 / 5 ›) and
back round to all of them. A run of more than 48 shows one at a time.
- **Auto save** (`autosave`, on by default): on, every run saves its
pictures. Off, a run only shows them here and saves nothing. A **SAVE**
button then sits over the picture: **SAVE ALL** keeps every picture of
the run, and with the arrows on one picture, **SAVE** keeps that one.
Use it to make a batch, look, and keep only the good ones. The pictures
are saved under the name and folder set above, in the format picked,
with the workflow inside if **Embed workflow** is on. They wait in
ComfyUI's temp folder, which is emptied when ComfyUI restarts, so save
what you want before that.
such as a folder of images), arrows beside the switch (‹ 2 / 5 ›) step
through them.
- **Preview steps** (`step_preview`, off by default): the switch left of
**preview** shows the picture while it is being made, one step at a time,
where the saved image goes, with a **STOP** button on the bar. Stop a run
+60
View File
@@ -0,0 +1,60 @@
# Text Lines
Runs what comes after once for each line of your text. Type five prompts,
one on each line, press Run once, and you get five pictures.
Use it for a list of prompts you typed, or for a list another node wrote,
such as a text model asked for "five different poses".
## How to use it
1. Type your lines in the box, or wire text into **text**.
2. Wire **line** to where one prompt would go, such as a text encoder's
prompt.
3. Run. Every node after it runs once for each line, and a Save Image at the
end saves one picture for each.
Empty lines are skipped. Models and pictures that do not depend on the line
are loaded once, not once for each line.
## Controls
- The text box (`text`): One line for each run.
- **Split at** (`split`): **each line** makes every line its own run.
**blank line** keeps lines together and starts the next run at an empty
line. Pick it when one prompt is several lines long.
- **Numbering** (`remove_numbering`): **remove** takes a number or bullet
off the start of each line, such as `1.`, `2)`, `-` or `[prompt 3]`.
Lists a text model writes usually have them. **keep** leaves every line
as written.
- **Limit** (`limit`): The most lines to run. 0 runs them all. Set it when
a text model might write more lines than you asked for.
## Outputs
- **line**: Each line, one at a time.
- **number**: The line's place in the list, starting at 1. Add it to a seed
with Math Expression (`a + b`) to give each line its own seed.
- **count**: How many lines run.
## Notes
- With no lines at all, the run stops at this node and says so.
- Every run uses the same seed unless you add **number** to it. Different
prompts still give different pictures.
- A line such as "2 women on a beach" or "1.5x zoom" keeps its number: only
a number followed by a period, bracket or colon counts as numbering.
## Technical details
**line** and **number** are ComfyUI lists (`OUTPUT_IS_LIST`), the same way
Image Folder hands out pictures. ComfyUI runs each later node once for each
item and keeps the results in order. A node that takes a whole list at once,
such as core Rebatch Images, gets all of them together.
Numbering that is removed: a number in square, round or CJK brackets with an
optional word before it (`[prompt 1]`, `(2)`), a word and a number followed by
a colon, period, bracket or dash (`Shot 3:`), a bare number followed by a
period, bracket or colon that has no digit right after it, and the bullets
`-`, `*` and `•` followed by a space. Only one marker is removed from each
line.
+1
View File
@@ -39,6 +39,7 @@ help panel.
| Turn, crop and pad a video: one frame, or the whole clip | Video Crop + Rotate + Pad → Frame (`AUSBOSS_NODES_VideoCropRotatePad`), → Clip (`AUSBOSS_NODES_VideoCropRotatePadClip`) |
| Change a clip's frame rate | Frame Interpolate (`AUSBOSS_NODES_FrameInterpolate`) |
| Work with frame batches | Select Frame, Select Every Nth, Split Batch, Merge Batches (`AUSBOSS_NODES_SelectFrame`, `_SelectEveryNth`, `_SplitBatch`, `_MergeBatches`) |
| Run a workflow once for each line of text, such as a list of prompts | Text Lines (`AUSBOSS_NODES_TextLines`) |
| One shared seed, number or text | Seed, Integer, Float, Text (`AUSBOSS_NODES_Seed`, `_Integer`, `_Float`, `_Text`) |
| Do maths on numbers | Math Expression (`AUSBOSS_NODES_MathExpression`) |
| Show text on the node, or the picture while it is made | Show Text (`AUSBOSS_NODES_ShowText`), Step Preview (`AUSBOSS_NODES_StepPreview`) |
+10 -199
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 { ALL, allLabel, gridShape, heldPlaces, keepIndex, stepIndex, stepWithAll } from "../shared/picture_steps.mjs";
import { keepIndex, stepIndex } from "../shared/picture_steps.mjs";
import { addStepConsumer, mountStepOverlay, showsStepsOn, stepRun, stopStepRun, watchSteps } from "../shared/step_preview.mjs";
import {
describeNodePreview,
@@ -55,11 +55,6 @@ const BAR_PADDING = 2;
const PANEL_HEIGHT = STAGE_HEIGHT + BAR_HEIGHT + 4 + 16;
const OFF_HEIGHT = BAR_HEIGHT + 16;
const INPUT_SIDE = 1; // LiteGraph.INPUT
// A run that left this many pictures or fewer opens with all of them side
// by side. More than that and each would be a speck, so the arrows go
// through them one at a time, as before.
const GRID_MAX = 48;
const GRID_GAP = 4;
// The node's height from before its preview was switched off, so switching
// it back on returns the node to it (saved with the workflow).
const HEIGHT_PROPERTY = "ausboss_preview_height";
@@ -119,17 +114,7 @@ const NODE_CONFIG = {
},
AUSBOSS_NODES_SelectFrame: { inputName: "frames", noun: "frames" },
// Its result is the file it just saved; the card sits above the panel.
// SAVE shows only for a run made with Auto save off, which holds its
// pictures until this button keeps them.
AUSBOSS_NODES_SaveImage: {
inputName: "images",
noun: "an image",
switchTitle: "Show the saved image here. The file is saved either way.",
steps: true,
tools: [
{ label: "SAVE", title: "Save what is showing: every picture, or the one the arrows picked.", action: keepHeld, held: true },
],
},
AUSBOSS_NODES_SaveImage: { inputName: "images", noun: "an image", switchTitle: "Show the saved image here. The file is saved either way.", steps: true },
};
// The picture sits out of flow, centred in the stage, so it can never set
@@ -153,16 +138,10 @@ function ensureCss() {
.ausboss-input-preview-stage img,.ausboss-input-preview-stage video{display:none;position:absolute;inset:0;margin:auto;max-width:100%;max-height:100%;object-fit:contain;}
.ausboss-input-preview-stage.show-image img{display:block;}
.ausboss-input-preview-stage.show-video video{display:block;}
.ausboss-input-preview-grid{display:none;position:absolute;inset:0;box-sizing:border-box;gap:${GRID_GAP}px;padding:${GRID_GAP}px;justify-content:center;align-content:center;}
.ausboss-input-preview-stage.show-grid .ausboss-input-preview-grid{display:grid;}
.ausboss-input-preview-stage .ausboss-input-preview-grid img{display:block;position:static;margin:0;width:100%;height:100%;min-width:0;min-height:0;max-width:none;max-height:none;object-fit:contain;}
.ausboss-input-preview-hint{display:none;max-width:86%;color:${T.faint};font:11px/1.35 ${FONT_UI};text-align:center;}
.ausboss-input-preview-stage:not(.show-image):not(.show-video):not(.show-grid) .ausboss-input-preview-hint{display:block;}
.ausboss-input-preview-stage:not(.show-image):not(.show-video) .ausboss-input-preview-hint{display:block;}
.ausboss-input-preview-tools{display:flex;gap:4px;pointer-events:auto;}
.ausboss-input-preview-tool{box-sizing:border-box;height:${BAR_HEIGHT}px;min-width:24px;padding:0 6px;border:1px solid transparent;border-radius:${RADIUS_XS}px;background:${T.fill};color:${T.label};font:600 9.5px/18px ${FONT_UI};letter-spacing:.02em;cursor:pointer;}
.ausboss-input-preview-tool[hidden]{display:none;}
.ausboss-input-preview-tool.keep{color:${T.brandInk};background:${T.brandSoft};}
.ausboss-input-preview-stage .ausboss-input-preview-grid img.kept{outline:2px solid ${T.brand};outline-offset:-2px;}
.ausboss-input-preview-tool:hover{color:${T.ink};background:${T.fillHover};}
.ausboss-input-preview-tool.active{color:${T.brandInk};background:${T.brandSoft};}
.ausboss-input-preview-tool:focus-visible{outline:2px solid ${T.brand};outline-offset:1px;}
@@ -509,14 +488,13 @@ function buildTools(state, tools, signal) {
tool.action(state);
}, { signal });
state.toolButtons[tool.label] = button;
if (tool.held) { button.hidden = true; state.keepButton = button; }
bar.append(button);
}
return bar;
}
function showHint(state, text) {
state.stage.classList.remove("show-image", "show-video", "show-grid");
state.stage.classList.remove("show-image", "show-video");
state.hint.textContent = text;
state.placeholder = null;
releaseSteps(state);
@@ -531,7 +509,6 @@ function showPlaceholder(state, connected) {
function showMedia(state, kind) {
state.stage.classList.toggle("show-image", kind === "image");
state.stage.classList.toggle("show-video", kind === "video");
state.stage.classList.toggle("show-grid", kind === "grid");
state.placeholder = null;
releaseSteps(state);
}
@@ -540,53 +517,6 @@ function clearMedia(state) {
state.img.removeAttribute("src");
state.video.pause?.();
state.video.removeAttribute("src");
clearGrid(state);
}
function clearGrid(state) {
if (!state.gridKey) return;
state.gridKey = "";
state.grid?.replaceChildren();
}
// Every picture of the run at once, each as big as the stage allows.
function layoutGrid(state) {
const cells = [...(state.grid?.children ?? [])];
if (!cells.length) return;
const loaded = cells.find((cell) => cell.naturalWidth > 0 && cell.naturalHeight > 0);
const aspect = loaded ? loaded.naturalWidth / loaded.naturalHeight : 1;
const { columns, rows } = gridShape(cells.length, state.stage.clientWidth, state.stage.clientHeight, aspect);
// Tracks the size of one picture, so the pictures sit together in the
// middle of the stage with no bands between the rows.
const wide = (state.stage.clientWidth - GRID_GAP * (columns + 1)) / columns;
const tall = (state.stage.clientHeight - GRID_GAP * (rows + 1)) / rows;
if (!(wide > 0) || !(tall > 0)) return;
const width = Math.floor(Math.min(wide, tall * aspect));
const height = Math.floor(width / aspect);
state.grid.style.gridTemplateColumns = `repeat(${columns}, ${width}px)`;
state.grid.style.gridTemplateRows = `repeat(${rows}, ${height}px)`;
}
function showGrid(state, count) {
const urls = Array.from({ length: count }, (_, index) => storedResultUrl(state.node, index) ?? "");
const key = urls.join("\n");
if (state.gridKey !== key) {
state.gridKey = key;
state.grid.replaceChildren(...urls.map((url, index) => {
const cell = document.createElement("img");
cell.alt = `Picture ${index + 1} of ${count}`;
cell.decoding = "async";
// The first picture to load tells the grid the shape to lay out for.
cell.addEventListener("load", () => layoutGrid(state), { once: true });
cell.src = url;
return cell;
}));
}
state.img.removeAttribute("src");
state.video.pause?.();
state.video.removeAttribute("src");
layoutGrid(state);
showMedia(state, "grid");
}
// Each source has one hook, with subscribers that can leave in any order.
@@ -639,9 +569,8 @@ function storedResultUrl(node, index = null) {
}
// Several pictures from one run (a list, or a batch Save Image saved one by
// one): they all show side by side, and the arrows in the bar go on to each
// one by itself and back round to all of them. One picture keeps the panel
// as it was, with no arrows.
// one): the arrows in the bar pick which one shows. One picture keeps the
// panel as it was, with no arrows.
function resultCount(state) {
if (state.stale || state.resultGone) return 0;
return outputRecordCount(app.nodeOutputs?.[outputLocatorId(state.node)]);
@@ -657,107 +586,10 @@ function previewMinWidth(state) {
return Math.max(PANEL_MIN_WIDTH, Math.ceil(state.node.size[0] - state.bar.clientWidth + needed));
}
// --- pictures held until Save (Save Image with Auto save off) ---------------
// The pictures this node is holding, in the order it shows them. Empty when
// the last run saved its pictures itself.
function heldPictures(state) {
if (state.stale || state.resultGone) return [];
return heldPlaces(app.nodeOutputs?.[outputLocatorId(state.node)]);
}
// Which of the shown pictures are kept, by their place on the node.
function keptOf(state, held) {
const key = held.map((picture) => `${picture.token}_${picture.index}`).join(" ");
if (state.kept?.key !== key) state.kept = { key, indexes: new Set() };
return state.kept.indexes;
}
// A click keeps every picture, or only the one the arrows picked. With the
// preview off there is no picture to pick, so it keeps them all.
function keepsAll(state, count) {
return count < 2 || showsAll(state, count) || !previewEnabled(state);
}
// The button says what a click will do: SAVE ALL over the grid, SAVE over
// one picture, SAVED once there is nothing left to keep there.
function syncHeld(state, count = resultCount(state)) {
const button = state.keepButton;
if (!button) return;
const held = heldPictures(state);
button.hidden = !held.length || count < 1;
if (button.hidden) return;
const kept = keptOf(state, held);
const all = keepsAll(state, count);
const done = all ? kept.size >= held.length : kept.has(state.index);
button.textContent = state.keeping ? "SAVING" : done ? "SAVED" : all && count > 1 ? "SAVE ALL" : "SAVE";
button.classList.toggle("keep", !done);
button.disabled = Boolean(state.keeping);
[...(state.grid?.children ?? [])].forEach((cell, index) => cell.classList.toggle("kept", kept.has(index)));
}
async function keepHeld(state) {
const held = heldPictures(state);
if (!held.length || state.keeping) return;
const count = resultCount(state);
const wanted = keepsAll(state, count) ? held.map((_, place) => place) : [state.index];
// One request for each run, in the order the pictures show.
const runs = new Map();
for (const place of wanted) {
const picture = held[place];
if (!picture) continue;
if (!runs.has(picture.token)) runs.set(picture.token, []);
runs.get(picture.token).push(picture.index);
}
state.keeping = true;
syncHeld(state);
const kept = keptOf(state, held);
const saved = [];
let skipped = 0;
try {
for (const [token, picks] of runs) {
const response = await api.fetchApi("/ausboss/save_image/keep", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, picks }),
});
const answer = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(answer.error || "Could not save.");
if (!state.alive) return;
const done = new Set(answer.kept ?? []);
held.forEach((picture, place) => {
if (picture.token === token && done.has(picture.index)) kept.add(place);
});
saved.push(...(answer.saved ?? []));
skipped += answer.skipped?.length ?? 0;
}
toast(state, saved.length === 1
? `Saved ${saved[0].path}`
: saved.length
? `Saved ${saved.length} pictures`
: skipped ? "Not saved: the file already exists" : "Already saved");
} catch (error) {
if (state.alive) toast(state, error?.message || "Could not save.");
} finally {
state.keeping = false;
if (state.alive) syncHeld(state);
}
}
function showsAll(state, count) {
return count > 1 && count <= GRID_MAX && state.all !== false;
}
function stepPicture(state, step) {
const count = resultCount(state);
if (count < 2) return;
if (count > GRID_MAX) {
state.index = stepIndex(state.index, count, step);
} else {
const at = stepWithAll(showsAll(state, count) ? ALL : state.index, count, step);
state.all = at === ALL;
if (!state.all) state.index = at;
}
state.index = stepIndex(state.index, count, step);
refresh(state);
}
@@ -765,7 +597,6 @@ function refresh(state) {
if (!state.alive) return;
if (!previewEnabled(state)) {
clearMedia(state);
syncHeld(state);
return;
}
const source = upstreamNode(state.node, state.inputName);
@@ -773,9 +604,7 @@ function refresh(state) {
const count = resultCount(state);
const hidden = state.stepper.root.hidden;
state.index = keepIndex(state.index, count);
const all = showsAll(state, count);
state.stepper.set(state.index, count, all ? allLabel(count) : undefined);
syncHeld(state, count);
state.stepper.set(state.index, count);
if (state.stepper.root.hidden !== hidden) growToNodeFloor(state.widget);
if (state.stale) {
// The node ran while its preview was off, so the picture it still has
@@ -787,13 +616,6 @@ function refresh(state) {
// A node that just stopped the run has no result for this picture: the
// one it still holds is from an earlier run (Mask by Name's last find,
// tinted), so show what feeds it instead.
if (all) {
showGrid(state, count);
// The grid's cells are new: mark the ones already kept.
syncHeld(state, count);
return;
}
clearGrid(state);
const described = state.resultGone
? describeSourcePreview(source)
: count > 1
@@ -879,9 +701,7 @@ function buildPanel(node, config) {
const toastEl = document.createElement("div");
toastEl.className = "ausboss-input-preview-toast";
toastEl.style.display = "none";
const grid = document.createElement("div");
grid.className = "ausboss-input-preview-grid";
stage.append(img, video, grid, hint, toastEl);
stage.append(img, video, hint, toastEl);
const bar = document.createElement("div");
bar.className = "ausboss-input-preview-bar";
root.append(bar, stage);
@@ -915,10 +735,7 @@ function buildPanel(node, config) {
noun: config.noun,
switchTitle: config.switchTitle,
advanced: config.advanced,
root, bar, stage, img, video, grid, hint, widget, abort,
// Several pictures open side by side; the arrows pick one (false).
all: true,
gridKey: "",
root, bar, stage, img, video, hint, widget, abort,
toast: toastEl,
toastTimer: 0,
toolButtons: null,
@@ -952,12 +769,6 @@ function buildPanel(node, config) {
installPreviewSwitch(state, abort.signal);
if (config.steps) installStepsSwitch(state, abort.signal);
// The grid is laid out for the stage's shape, so a resized node lays it out again.
if (typeof ResizeObserver === "function") {
const watcher = new ResizeObserver(() => { if (state.alive && state.gridKey) layoutGrid(state); });
watcher.observe(stage);
abort.signal.addEventListener("abort", () => watcher.disconnect(), { once: true });
}
img.addEventListener("load", () => showMedia(state, "image"));
img.addEventListener("error", () => {
if (img.getAttribute("src")) showPlaceholder(state, true);
+2 -23
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 = 320;
const CARD_HEIGHT = 288;
const WRAPPER_INSET = 16;
const HIDDEN = [
"filename_prefix", "format", "save_metadata", "exact_name", "on_existing",
"output_dir", "caption", "name_counter", "name_date", "name_time", "name_size", "name_batch", "autosave",
"output_dir", "caption", "name_counter", "name_date", "name_time", "name_size", "name_batch",
];
const FONT = `-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif`;
const MONO = `ui-monospace,SFMono-Regular,Menlo,Consolas,monospace`;
@@ -285,22 +285,6 @@ function buildCard(node) {
embedRow.append(el("span", "ausboss-save-embed-label", PANEL_LABELS.embed), embedSeg);
root.append(embedRow);
// Auto save: off shows the pictures without saving them; the Save button
// over the picture (js/input_preview) then keeps the ones that are wanted.
const autoRow = el("div", "ausboss-save-row");
const autoSeg = el("div", "ausboss-card-seg ausboss-card-bool");
autoSeg.setAttribute("role", "radiogroup");
const autoButtons = new Map();
const autoTitle = "On: every run saves its pictures. Off: the pictures only show here, and the Save button over them keeps the ones you want.";
for (const [on, text] of [[false, PANEL_LABELS.off], [true, PANEL_LABELS.on]]) {
const button = el("button", "", text);
button.type = "button"; button.title = autoTitle;
button.addEventListener("click", () => { if (setValue(node, "autosave", on)) { state.savedPath = null; notifyAusbossChange(); } state.refresh(); });
autoSeg.append(button); autoButtons.set(on, button);
}
autoRow.append(el("span", "ausboss-save-embed-label", "Auto save"), autoSeg);
root.append(autoRow);
root.addEventListener("pointerdown", (event) => {
if (event.target.closest("button,input,select")) event.stopPropagation();
});
@@ -346,11 +330,6 @@ function buildCard(node) {
button.classList.toggle("on", on === embed);
button.setAttribute("aria-checked", String(on === embed));
}
const auto = values.autosave !== false;
for (const [on, button] of autoButtons) {
button.classList.toggle("on", on === auto);
button.setAttribute("aria-checked", String(on === auto));
}
};
const domWidget = node.addDOMWidget(WIDGET_NAME, WIDGET_NAME, root, {
+2 -3
View File
@@ -52,10 +52,9 @@ export function makePictureStepper({ noun = "picture", height = 24, onStep, sign
root.append(arrow("‹", -1, `Previous ${noun}`), label, arrow("›", 1, `Next ${noun}`));
return {
root,
// `text` replaces the "2 / 5" for a stop that is not one picture.
set(index, count, text) {
set(index, count) {
root.hidden = !(count > 1);
label.textContent = text ?? stepperLabel(index, count);
label.textContent = stepperLabel(index, count);
},
};
}
-50
View File
@@ -19,53 +19,3 @@ export function stepIndex(index, count, step) {
export function stepperLabel(index, count) {
return count > 1 ? `${keepIndex(index, count) + 1} / ${count}` : "";
}
// The preview panel has one more stop, before picture 1: every picture at
// once. ALL stands for it where an index would be.
export const ALL = -1;
// One step back or on through "all, 1, 2 ... count", round at both ends.
export function stepWithAll(index, count, step) {
if (!(count > 0)) return ALL;
const stops = count + 1;
const at = index === ALL ? 0 : keepIndex(index, count) + 1;
const next = (((at + Math.sign(Number(step) || 0)) % stops) + stops) % stops;
return next === 0 ? ALL : next - 1;
}
export function allLabel(count) {
return count > 1 ? `all ${count}` : "";
}
// How to lay `count` pictures out in a box so each one draws as big as it
// can: the columns and rows that leave a picture of this shape (`aspect`,
// width over height) the most room.
export function gridShape(count, width, height, aspect = 1) {
const total = Math.max(1, Math.trunc(Number(count) || 0));
const shape = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
if (!(width > 0) || !(height > 0)) {
const columns = Math.ceil(Math.sqrt(total));
return { columns, rows: Math.ceil(total / columns) };
}
let best = { columns: 1, rows: total, size: 0 };
for (let columns = 1; columns <= total; columns += 1) {
const rows = Math.ceil(total / columns);
const size = Math.min(width / columns, (height / rows) * shape);
if (size > best.size) best = { columns, rows, size };
}
return { columns: best.columns, rows: best.rows };
}
// The held pictures in a Save Image result, in the order they show: each
// one's run token and its place in that run. A list run saves once for each
// item, so one result can hold several tokens. Empty when the result holds
// none, or when a picture in it is not one of the held ones.
export function heldPlaces(record) {
const tokens = new Set(Array.isArray(record?.ausboss_held) ? record.ausboss_held : []);
if (!tokens.size) return [];
const held = (Array.isArray(record.images) ? record.images : []).map((image) => {
const match = /^([0-9a-f]{16})_(\d{3})\./.exec(String(image?.filename ?? ""));
return match && tokens.has(match[1]) ? { token: match[1], index: Number(match[2]) } : null;
});
return held.length && held.every(Boolean) ? held : [];
}
+11
View File
@@ -340,6 +340,17 @@ const CARDS = {
rows: [{ widget: "text", kind: "textarea", placeholder: "Prompt, caption, or shared text", height: 100, grow: true,
looks: { big: { height: 62, placeholder: "type here" } } }],
},
// One line for each run. The three settings under the box say where a run
// ends, whether a leading "1." comes off, and how many lines run at most.
AUSBOSS_NODES_TextLines: {
minWidth: 320,
rows: [
{ widget: "text", kind: "textarea", placeholder: "One line for each run", height: 100, grow: true },
{ widget: "split", label: "Split at", kind: "segment" },
{ widget: "remove_numbering", label: "Numbering", onText: "remove", offText: "keep" },
{ widget: "limit", label: "Limit" },
],
},
// All the words go in the box, and the strip under it shows when each
// moment plays. The pictures, the video, the voice and the song arrive on
// sockets, and each picture's job sits under the words.
+29 -3
View File
@@ -1110,9 +1110,6 @@
},
"step_preview": {
"name": "步骤预览"
},
"autosave": {
"name": "自动保存"
}
},
"outputs": {
@@ -1301,6 +1298,35 @@
}
}
},
"AUSBOSS_NODES_TextLines": {
"display_name": "逐行文本 🆎",
"description": "文本的每一行都会让后面的节点运行一次。输入五条提示词,每行一条,运行一次就得到五张图。空行会跳过,行首的序号或项目符号可以去掉。",
"inputs": {
"text": {
"name": "文本"
},
"split": {
"name": "分隔方式"
},
"remove_numbering": {
"name": "去掉序号"
},
"limit": {
"name": "上限"
}
},
"outputs": {
"0": {
"name": "行"
},
"1": {
"name": "序号"
},
"2": {
"name": "数量"
}
}
},
"AUSBOSS_NODES_TiledUpscale": {
"display_name": "分块放大 🆎",
"description": "以百万像素设置成品尺寸,并把图片切成模型可以逐块重画的图块。一块就能装下的图片保持完整。把图块送进采样器,再和 stitcher 一起送进分块放大拼合 🆎。小图不会被重画到远超其细节的程度:最多重画到原百万像素的 max_growth 倍,再用普通方式放大。report 会说明对你的图片做了什么。",
-170
View File
@@ -1,170 +0,0 @@
"""Pictures Save Image holds until they are kept.
With Auto save off, a run writes its pictures to ComfyUI's temp folder and
shows them on the node. A click on the node's Save button keeps the ones
that are wanted: they are copied into the output folder under the names the
node would have given them. Everything here works from names this module
made itself; the only thing a caller sends is the run's token and which
pictures to keep.
"""
from __future__ import annotations
from datetime import datetime
import json
from pathlib import Path
import re
import secrets
import shutil
from ._image_save_helpers import (
IMAGE_SAVE_LOCK,
existing_action,
name_stem,
next_free_counter,
plan_exact_names,
plan_local_names,
require_output_path,
resolve_output_root,
sidecar_path,
split_prefix,
strip_image_extension,
)
HELD_FOLDER = "ausboss_held"
_TOKEN = re.compile(r"^[0-9a-f]{16}$")
GONE = "These pictures are no longer held. Run the workflow again."
def plan_destinations(settings: dict, count: int, now: datetime, root: Path, output_root: Path, picks=None):
"""Where each picture goes, as (path, what to do if it exists).
Call it with IMAGE_SAVE_LOCK held: the counter it reads stays free only
until the lock is let go. `picks` keeps some of a run's pictures; an
exact name then keeps the number it would have had in the whole run.
"""
wanted = list(range(count)) if picks is None else list(picks)
extension = settings["extension"]
exact = settings.get("exact") or ""
if exact:
names = plan_exact_names(strip_image_extension(exact), extension, count)
return [(root / names[index], settings["on_existing"]) for index in wanted]
subfolder, base = split_prefix(settings["filename_prefix"])
folder = root / subfolder if subfolder else root
require_output_path(folder, output_root)
stem = name_stem(
base,
{"date": bool(settings["name_date"]), "time": bool(settings["name_time"]), "size": bool(settings["name_size"])},
now,
int(settings["width"]),
int(settings["height"]),
)
counter = bool(settings["name_counter"])
existing = [entry.name for entry in folder.iterdir()] if folder.is_dir() else []
names = plan_local_names(
stem, extension, len(wanted), counter=counter, batch=bool(settings["name_batch"]),
next_counter=next_free_counter(existing, stem),
)
# With a counter the name is free by construction. Without one, the
# legacy policy decides whether to overwrite.
policy = "overwrite" if counter else settings["on_existing"]
return [(folder / name, policy) for name in names]
def settle_destinations(planned, has_caption: bool, output_root: Path) -> list[str]:
"""Check every destination, then say 'write' or 'skip' for each.
The whole batch is settled before any write: an "error" on a later
picture stops the save before the first one lands.
"""
for path, _policy in planned:
require_output_path(path, output_root)
if has_caption:
require_output_path(sidecar_path(path), output_root)
return [
existing_action(path.exists() or (has_caption and sidecar_path(path).exists()), policy)
for path, policy in planned
]
def _held_name(token: str, index: int, extension: str) -> str:
return f"{token}_{index:03d}.{extension}"
def hold_pictures(temp_root, frames, settings: dict, encode) -> tuple[str, list[dict]]:
"""Write a run's pictures to the temp folder and remember how to name them.
`encode(path, frame)` writes one picture in the run's format. Returns
the run's token and the entries the node shows them from.
"""
folder = Path(temp_root) / HELD_FOLDER
folder.mkdir(parents=True, exist_ok=True)
token = secrets.token_hex(8)
previews = []
for index, frame in enumerate(frames):
name = _held_name(token, index, settings["extension"])
encode(folder / name, frame)
previews.append({"filename": name, "subfolder": HELD_FOLDER, "type": "temp"})
manifest = dict(settings, count=len(previews), kept={})
(folder / f"{token}.json").write_text(json.dumps(manifest), encoding="utf-8")
return token, previews
def keep_pictures(temp_root, output_root, token, picks=None, now: datetime | None = None) -> dict:
"""Copy held pictures into the output folder. Returns what was saved.
`picks` lists which pictures of the run to keep, counted from 0; None
keeps them all. A picture kept before is not saved a second time.
"""
if not isinstance(token, str) or not _TOKEN.match(token):
raise ValueError("Save Image: that is not a run it is holding.")
folder = Path(temp_root) / HELD_FOLDER
manifest_path = folder / f"{token}.json"
if not manifest_path.is_file():
raise LookupError(GONE)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
count = int(manifest["count"])
kept = {int(index): str(path) for index, path in dict(manifest.get("kept") or {}).items()}
if picks is None:
wanted = list(range(count))
else:
wanted = []
for pick in picks:
if isinstance(pick, bool) or not isinstance(pick, int) or not 0 <= pick < count:
raise ValueError("Save Image: that picture is not in this run.")
if pick not in wanted:
wanted.append(pick)
todo = [index for index in wanted if index not in kept]
output_root = Path(output_root)
caption = str(manifest.get("caption") or "")
has_caption = bool(caption.strip())
skipped: list[int] = []
saved: list[int] = []
with IMAGE_SAVE_LOCK:
root = resolve_output_root(manifest.get("output_dir", ""), output_root)
planned = plan_destinations(manifest, count, now or datetime.now(), root, output_root, picks=todo)
actions = settle_destinations(planned, has_caption, output_root)
resolved_root = output_root.resolve()
for (path, _policy), action, index in zip(planned, actions, todo):
if action == "skip":
skipped.append(index)
continue
source = folder / _held_name(token, index, manifest["extension"])
if not source.is_file():
raise LookupError(GONE)
path.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, path)
if has_caption:
sidecar_path(path).write_text(caption, encoding="utf-8")
kept[index] = path.resolve().relative_to(resolved_root).as_posix()
saved.append(index)
manifest["kept"] = {str(index): path for index, path in kept.items()}
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
return {
"count": count,
"saved": [{"index": index, "path": kept[index]} for index in saved],
"kept": sorted(kept),
"skipped": skipped,
}
+67
View File
@@ -0,0 +1,67 @@
"""Cut a block of text into the items Text Lines runs one at a time.
Pure text handling with no ComfyUI imports, so it runs under the
standard-library tests.
"""
from __future__ import annotations
import re
SPLIT_EACH_LINE = "each line"
SPLIT_BLANK_LINE = "blank line"
SPLIT_MODES = (SPLIT_EACH_LINE, SPLIT_BLANK_LINE)
# The words a list writer puts before an item's number: "Prompt 3:", "Shot 2 -".
# Not "image" or "picture": an edit prompt may open with "Image 1: ...".
_LABEL = r"(?:prompt|shot|scene|line|step|item)"
# A marker at the start of an item that only counts or bullets it. A bare
# number needs punctuation after it, and that punctuation must not be followed
# by a digit, so "2 women on a beach", "1.5x zoom" and "10:30 at night" stay
# whole. A bullet needs a space after it, so "*bold* text" stays whole.
_MARKER = re.compile(
r"""^\s*(?:
[\[【(]\s*(?:%(label)s[\s_#-]*)?\d+\s*[\]】)]\s*[:.\-–—]? # [prompt 1] (2)
| %(label)s[\s_#-]*\d+\s*[:.)\-–—] # Prompt 1: Shot 2 -
| \#?\d+\s*[.):](?!\d) # 1. 2) #3:
| [-*•·–—](?=\s|$) # - bullet
)\s*"""
% {"label": _LABEL},
re.IGNORECASE | re.VERBOSE,
)
_BLANK_LINE = re.compile(r"\n[ \t]*(?:\n[ \t]*)+")
def strip_marker(item: str) -> str:
"""Take one leading number or bullet off an item."""
return _MARKER.sub("", item, count=1).strip()
def split_items(text, split=SPLIT_EACH_LINE, remove_numbering=True, limit=0) -> list[str]:
"""The items of `text`, in order, with empty ones left out.
`split` picks where one item ends: at every line break, or only at an
empty line (so an item can hold several lines). `remove_numbering` takes
a leading "1." or "-" off each item. `limit` keeps the first so many;
0 keeps them all.
"""
text = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
if split == SPLIT_BLANK_LINE:
parts = _BLANK_LINE.split(text)
else:
parts = text.split("\n")
items = []
for part in parts:
item = part.strip()
if remove_numbering:
item = strip_marker(item)
if item:
items.append(item)
limit = int(limit or 0)
if limit > 0:
items = items[:limit]
return items
+44 -78
View File
@@ -6,20 +6,26 @@ from datetime import datetime
from pathlib import Path
from ._execution_helpers import ignore_extra_inputs
from ._image_hold_helpers import hold_pictures, keep_pictures, plan_destinations, settle_destinations
from ._image_save_helpers import (
EXISTING_POLICIES,
FORMAT_EXTENSIONS,
IMAGE_FORMATS,
IMAGE_SAVE_LOCK,
encode_image,
existing_action,
fill_name_tokens,
metadata_disabled,
name_stem,
next_free_counter,
plan_exact_names,
plan_local_names,
resolve_output_root,
require_output_path,
sanitize_exact_name,
sidecar_path,
split_prefix,
strip_image_extension,
)
from ._route_errors import safe_route_error
from ._step_preview_helpers import STEP_PREVIEW_DEFAULT, register_step_preview_handler
from ._video_save_helpers import workflow_metadata
@@ -62,33 +68,6 @@ def _register_folder_route() -> None:
shown = "" if target == root else target.relative_to(root).as_posix()
return web.json_response({"path": shown, "folders": folders})
@PromptServer.instance.routes.post("/ausboss/save_image/keep")
async def keep_held(request):
"""Save pictures a run is holding (Auto save off). The body names the
run by the token the node was given and, optionally, which of its
pictures to keep. Names and folders come from what the run stored,
never from the request."""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "bad request"}, status=400)
if not isinstance(body, dict):
return web.json_response({"error": "bad request"}, status=400)
picks = body.get("picks")
if picks is not None and not isinstance(picks, list):
return web.json_response({"error": "bad request"}, status=400)
try:
answer = keep_pictures(
folder_paths.get_temp_directory(), folder_paths.get_output_directory(), body.get("token"), picks,
)
except LookupError as exc:
return web.json_response({"error": safe_route_error(exc, "Those pictures are gone.")}, status=404)
except ValueError as exc:
return web.json_response({"error": safe_route_error(exc, "Could not save.")}, status=400)
except Exception as exc:
return web.json_response({"error": safe_route_error(exc, "Could not save.")}, status=500)
return web.json_response(answer)
class AusBossSaveImage:
CATEGORY = "🆎 AusBoss/Image"
@@ -298,17 +277,6 @@ class AusBossSaveImage:
),
},
),
"autosave": (
"BOOLEAN",
{
"default": True,
"tooltip": (
"On: every run saves its pictures. Off: the "
"pictures only show on the node, and its Save "
"button keeps the ones you want."
),
},
),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
@@ -342,7 +310,6 @@ class AusBossSaveImage:
name_batch=False,
preview=True,
step_preview=STEP_PREVIEW_DEFAULT,
autosave=True,
prompt=None,
extra_pnginfo=None,
):
@@ -376,46 +343,45 @@ class AusBossSaveImage:
else:
exact = sanitize_exact_name(exact_name)
settings = {
"format": format,
"extension": extension,
"width": width,
"height": height,
"exact": exact,
"filename_prefix": filename_prefix,
"on_existing": on_existing,
"output_dir": str(output_dir or ""),
"name_counter": bool(name_counter),
"name_date": bool(name_date),
"name_time": bool(name_time),
"name_size": bool(name_size),
"name_batch": bool(name_batch),
"caption": caption_value,
}
has_caption = bool(caption_value.strip())
if not autosave:
# Nothing is saved yet. The pictures wait in the temp folder, in
# their final format, and the node's Save button keeps the ones
# that are wanted (the keep route above). A bad name or folder
# still stops the run here, not at the click.
with IMAGE_SAVE_LOCK:
settle_destinations(plan_destinations(settings, count, now, root, output_root), has_caption, output_root)
token, previews = hold_pictures(
folder_paths.get_temp_directory(), images, settings,
lambda path, frame: encode_image(path, frame, format, metadata),
)
return {
"ui": {"images": previews, "ausboss_saved_path": [], "ausboss_held": [token]},
"result": ("", images),
}
with IMAGE_SAVE_LOCK:
planned: list[tuple[Path, str]] = [] # (path, collision policy)
if exact:
base = strip_image_extension(exact)
for name in plan_exact_names(base, extension, count):
planned.append((root / name, on_existing))
else:
subfolder, base = split_prefix(filename_prefix)
folder = root / subfolder if subfolder else root
require_output_path(folder, output_root)
stem = name_stem(
base,
{"date": bool(name_date), "time": bool(name_time), "size": bool(name_size)},
now,
width,
height,
)
counter = bool(name_counter)
existing = [entry.name for entry in folder.iterdir()] if folder.is_dir() else []
names = plan_local_names(
stem, extension, count, counter=counter, batch=bool(name_batch),
next_counter=next_free_counter(existing, stem),
)
# The shared lock keeps this counter free until writing finishes.
# Without it, the legacy policy decides whether to overwrite.
policy = "overwrite" if counter else on_existing
planned.extend((folder / name, policy) for name in names)
# Validate the whole batch before any write, including caption targets,
# then settle every collision: an "error" on a later frame must stop
# the run before the first frame lands, not after.
planned = plan_destinations(settings, count, now, root, output_root)
actions = settle_destinations(planned, has_caption, output_root)
for path, _policy in planned:
require_output_path(path, output_root)
if caption_value.strip():
require_output_path(sidecar_path(path), output_root)
actions = [
existing_action(path.exists() or (bool(caption_value.strip()) and sidecar_path(path).exists()), policy)
for path, policy in planned
]
saved: list[Path] = []
for (path, _policy), action, frame in zip(planned, actions, images):
@@ -423,7 +389,7 @@ class AusBossSaveImage:
continue
path.parent.mkdir(parents=True, exist_ok=True)
encode_image(path, frame, format, metadata)
if has_caption:
if caption_value.strip():
sidecar_path(path).write_text(caption_value, encoding="utf-8")
saved.append(path)
+100
View File
@@ -0,0 +1,100 @@
"""Text Lines 🆎 — run what comes after once for each line of text."""
from __future__ import annotations
from ._text_lines_helpers import SPLIT_EACH_LINE, SPLIT_MODES, split_items
MAX_LIMIT = 1000
class AusBossTextLines:
CATEGORY = "🆎 AusBoss/Utility"
DESCRIPTION = (
"Runs what comes after once for each line of your text. Type five "
"prompts, one on each line, and one run makes five pictures. Empty "
"lines are skipped, and a number or bullet at the start of a line "
"can be taken off."
)
SEARCH_ALIASES = [
"prompt list",
"one prompt per line",
"each line",
"split text",
"text to list",
"batch prompts",
"queue prompts",
"ausboss",
]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "One line for each run. Type it here, or wire in text another node wrote.",
},
),
"split": (
list(SPLIT_MODES),
{
"default": SPLIT_EACH_LINE,
"tooltip": (
"each line: every line is its own run. blank line: lines stay "
"together, and an empty line starts the next run."
),
},
),
"remove_numbering": (
"BOOLEAN",
{
"default": True,
"tooltip": (
'On: a number or bullet at the start is taken off, like "1.", "-" '
'or "[prompt 2]". Off: every line stays as written.'
),
},
),
"limit": (
"INT",
{
"default": 0,
"min": 0,
"max": MAX_LIMIT,
"step": 1,
"tooltip": "The most lines to run. 0 runs them all.",
},
),
},
}
RETURN_TYPES = ("STRING", "INT", "INT")
RETURN_NAMES = ("line", "number", "count")
OUTPUT_IS_LIST = (True, True, False)
OUTPUT_TOOLTIPS = (
"Each line, one at a time. What comes after runs once for each.",
"The line's place in the list, starting at 1. Add it to a seed to give each run its own.",
"How many lines run.",
)
FUNCTION = "split"
def split(self, text, split=SPLIT_EACH_LINE, remove_numbering=True, limit=0):
items = split_items(text, split, remove_numbering, limit)
if not items:
raise ValueError(
"Text Lines has no lines to run. Type at least one line, or check the node that writes its text."
)
return (items, list(range(1, len(items) + 1)), len(items))
NODE_CLASS_MAPPINGS = {
"AUSBOSS_NODES_TextLines": AusBossTextLines,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AUSBOSS_NODES_TextLines": "Text Lines 🆎",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+1
View File
@@ -94,6 +94,7 @@ PUBLIC_NODE_IDS = {
"AUSBOSS_NODES_StitchInpaint",
"AUSBOSS_NODES_TiledUpscaleStitch",
"AUSBOSS_NODES_Text",
"AUSBOSS_NODES_TextLines",
"AUSBOSS_NODES_TiledUpscale",
"AUSBOSS_NODES_VideoCropRotatePad",
"AUSBOSS_NODES_VideoCropRotatePadClip",
+24 -2
View File
@@ -886,8 +886,7 @@
"name_size",
"name_batch",
"preview",
"step_preview",
"autosave"
"step_preview"
],
"outputs": [
[
@@ -1037,6 +1036,29 @@
]
]
},
"AUSBOSS_NODES_TextLines": {
"required": [
"text",
"split",
"remove_numbering",
"limit"
],
"optional": [],
"outputs": [
[
"STRING",
"line"
],
[
"INT",
"number"
],
[
"INT",
"count"
]
]
},
"AUSBOSS_NODES_TiledUpscale": {
"required": [
"image",
-48
View File
@@ -26,51 +26,3 @@ test("the label counts from one and is empty for a single picture", () => {
assert.equal(stepperLabel(0, 1), "");
assert.equal(stepperLabel(0, 0), "");
});
test("all the pictures are one more stop before the first", async () => {
const { ALL, allLabel, stepWithAll } = await import("../js/shared/picture_steps.mjs");
assert.equal(stepWithAll(ALL, 3, 1), 0);
assert.equal(stepWithAll(0, 3, 1), 1);
assert.equal(stepWithAll(2, 3, 1), ALL);
assert.equal(stepWithAll(ALL, 3, -1), 2);
assert.equal(stepWithAll(0, 3, -1), ALL);
assert.equal(stepWithAll(1, 3, 0), 1);
assert.equal(stepWithAll(0, 0, 1), ALL);
assert.equal(allLabel(5), "all 5");
assert.equal(allLabel(1), "");
});
test("the grid gives each picture the most room its shape allows", async () => {
const { gridShape } = await import("../js/shared/picture_steps.mjs");
// Five portraits in a tall stage: two across beats one row or one column.
assert.deepEqual(gridShape(5, 440, 1100, 3 / 4), { columns: 2, rows: 3 });
// The same five in a wide stage go in one row.
assert.deepEqual(gridShape(5, 1600, 440, 3 / 4), { columns: 5, rows: 1 });
// Wide pictures in a tall stage stack.
assert.deepEqual(gridShape(3, 400, 900, 16 / 9), { columns: 1, rows: 3 });
assert.deepEqual(gridShape(1, 400, 400, 1), { columns: 1, rows: 1 });
// A stage that has no size yet still gets a whole grid.
assert.deepEqual(gridShape(5, 0, 0, 1), { columns: 3, rows: 2 });
assert.deepEqual(gridShape(4, 400, 400, Number.NaN), { columns: 2, rows: 2 });
});
test("held pictures map to their run and their place in it", async () => {
const { heldPlaces } = await import("../js/shared/picture_steps.mjs");
const a = "0123456789abcdef";
const b = "fedcba9876543210";
const name = (token, index) => ({ filename: `${token}_00${index}.png`, subfolder: "ausboss_held", type: "temp" });
// One run of three pictures.
assert.deepEqual(
heldPlaces({ images: [name(a, 0), name(a, 1), name(a, 2)], ausboss_held: [a] }),
[{ token: a, index: 0 }, { token: a, index: 1 }, { token: a, index: 2 }],
);
// A list run: one picture from each of two runs.
assert.deepEqual(
heldPlaces({ images: [name(a, 0), name(b, 0)], ausboss_held: [a, b] }),
[{ token: a, index: 0 }, { token: b, index: 0 }],
);
// A saved run holds nothing, and a picture from another run is not trusted.
assert.deepEqual(heldPlaces({ images: [{ filename: "shot_00001.png" }] }), []);
assert.deepEqual(heldPlaces({ images: [name(a, 0), name(b, 0)], ausboss_held: [a] }), []);
assert.deepEqual(heldPlaces(undefined), []);
});
-110
View File
@@ -1,110 +0,0 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
del sys.modules["nodes"]
from nodes._image_hold_helpers import GONE, HELD_FOLDER, hold_pictures, keep_pictures
NOW = datetime(2026, 11, 10, 9, 30, 0)
def settings(**changes):
base = {
"format": "png", "extension": "png", "width": 64, "height": 96, "exact": "",
"filename_prefix": "shoot", "on_existing": "overwrite", "output_dir": "",
"name_counter": True, "name_date": False, "name_time": False, "name_size": False,
"name_batch": False, "caption": "",
}
base.update(changes)
return base
class HoldTests(unittest.TestCase):
def setUp(self):
self.box = tempfile.TemporaryDirectory()
self.temp = Path(self.box.name) / "temp"
self.output = Path(self.box.name) / "output"
self.temp.mkdir()
self.output.mkdir()
def tearDown(self):
self.box.cleanup()
def hold(self, count=3, **changes):
frames = [f"picture {index}".encode() for index in range(count)]
return hold_pictures(self.temp, frames, settings(**changes), lambda path, frame: path.write_bytes(frame))
def keep(self, token, picks=None):
return keep_pictures(self.temp, self.output, token, picks, now=NOW)
def test_a_held_run_saves_nothing_until_it_is_kept(self):
token, previews = self.hold()
self.assertEqual(len(previews), 3)
self.assertTrue(all(entry["type"] == "temp" and entry["subfolder"] == HELD_FOLDER for entry in previews))
self.assertEqual(list(self.output.iterdir()), [])
answer = self.keep(token)
self.assertEqual([item["path"] for item in answer["saved"]], ["shoot_00001.png", "shoot_00002.png", "shoot_00003.png"])
self.assertEqual((self.output / "shoot_00002.png").read_bytes(), b"picture 1")
def test_one_picture_can_be_kept_and_is_not_saved_twice(self):
token, _ = self.hold()
first = self.keep(token, [2])
self.assertEqual(first["saved"], [{"index": 2, "path": "shoot_00001.png"}])
self.assertEqual((self.output / "shoot_00001.png").read_bytes(), b"picture 2")
again = self.keep(token, [2])
self.assertEqual(again["saved"], [])
rest = self.keep(token)
self.assertEqual([item["index"] for item in rest["saved"]], [0, 1])
self.assertEqual(rest["kept"], [0, 1, 2])
self.assertEqual(sorted(entry.name for entry in self.output.iterdir()), ["shoot_00001.png", "shoot_00002.png", "shoot_00003.png"])
def test_the_folder_and_caption_of_the_run_are_used(self):
token, _ = self.hold(count=1, output_dir="sets/portraits", caption="a portrait")
answer = self.keep(token)
self.assertEqual(answer["saved"][0]["path"], "sets/portraits/shoot_00001.png")
self.assertEqual((self.output / "sets/portraits/shoot_00001.txt").read_text(), "a portrait")
def test_an_exact_name_keeps_its_place_in_the_run(self):
token, _ = self.hold(count=3, exact="portrait")
whole = [item["path"] for item in self.keep(self.hold(count=3, exact="other")[0])["saved"]]
picked = self.keep(token, [1])["saved"][0]["path"]
self.assertEqual(picked, whole[1].replace("other", "portrait"))
def test_a_request_cannot_name_a_place(self):
token, _ = self.hold()
for bad in ["../" + token, token + "/..", "", None, 5, token.upper(), "g" * 16]:
with self.assertRaises(ValueError):
self.keep(bad)
for picks in [[3], [-1], ["0"], [True], [0.0]]:
with self.assertRaises(ValueError):
self.keep(token, picks)
self.assertEqual(list(self.output.iterdir()), [])
def test_pictures_that_are_gone_say_so(self):
with self.assertRaises(LookupError) as caught:
self.keep("0" * 16)
self.assertEqual(str(caught.exception), GONE)
token, previews = self.hold(count=1)
(self.temp / HELD_FOLDER / previews[0]["filename"]).unlink()
with self.assertRaises(LookupError):
self.keep(token)
def test_a_folder_outside_the_output_folder_is_refused_at_the_click_too(self):
token, _ = self.hold(count=1)
manifest = self.temp / HELD_FOLDER / f"{token}.json"
manifest.write_text(manifest.read_text().replace('"output_dir": ""', '"output_dir": "../elsewhere"'))
with self.assertRaises(ValueError):
self.keep(token)
self.assertFalse((Path(self.box.name) / "elsewhere").exists())
if __name__ == "__main__":
unittest.main()
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
from pathlib import Path
import sys
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._text_lines_helpers import split_items, strip_marker
from nodes.node_text_lines import AusBossTextLines
class SplitItemsTests(unittest.TestCase):
def test_each_line_is_an_item_and_empty_lines_are_skipped(self):
text = "a red car\n\n \na blue boat \r\n\tgreen hills\n"
self.assertEqual(split_items(text), ["a red car", "a blue boat", "green hills"])
def test_blank_line_keeps_lines_together(self):
text = "first line\nsecond line\n\n\nnext item\n \nlast"
self.assertEqual(
split_items(text, "blank line"),
["first line\nsecond line", "next item", "last"],
)
def test_limit_keeps_the_first_items(self):
self.assertEqual(split_items("a\nb\nc\nd", limit=2), ["a", "b"])
self.assertEqual(split_items("a\nb", limit=0), ["a", "b"])
self.assertEqual(split_items("a\nb", limit=9), ["a", "b"])
def test_numbers_and_bullets_come_off(self):
cases = {
"1. close-up, soft smile": "close-up, soft smile",
"2) medium shot": "medium shot",
"#3: from above": "from above",
"- a bullet": "a bullet",
"* a star": "a star",
"• a dot": "a dot",
"[prompt 4] from the side": "from the side",
"【prompt_5】 looking back": "looking back",
"(6) arms crossed": "arms crossed",
"Prompt 7: laughing": "laughing",
"Shot 8 - wide": "wide",
"12. twelve": "twelve",
}
for line, want in cases.items():
self.assertEqual(strip_marker(line), want, line)
def test_text_that_only_starts_like_a_marker_stays(self):
for line in [
"2 women on a beach",
"1.5x zoom on the face",
"10:30 at night, a diner",
"3D render of a robot",
"*bold* light from the left",
"-90 degrees, seen from below",
"1920s street, a man in a hat",
"shot from below",
"Image 1: the woman, in the coat from image 2",
]:
self.assertEqual(strip_marker(line), line, line)
def test_numbering_stays_when_asked(self):
self.assertEqual(split_items("1. a\n- b", remove_numbering=False), ["1. a", "- b"])
def test_a_line_that_is_only_a_marker_is_skipped(self):
self.assertEqual(split_items("1.\n2. real\n-"), ["real"])
def test_nothing_gives_nothing(self):
self.assertEqual(split_items(""), [])
self.assertEqual(split_items(None), [])
self.assertEqual(split_items("\n \n"), [])
class TextLinesNodeTests(unittest.TestCase):
def test_outputs_lines_numbers_and_count(self):
lines, numbers, count = AusBossTextLines().split("1. one\n2. two\n\n3. three")
self.assertEqual(lines, ["one", "two", "three"])
self.assertEqual(numbers, [1, 2, 3])
self.assertEqual(count, 3)
def test_the_line_and_number_outputs_are_lists(self):
self.assertEqual(AusBossTextLines.OUTPUT_IS_LIST, (True, True, False))
self.assertEqual(AusBossTextLines.RETURN_NAMES, ("line", "number", "count"))
def test_no_lines_stops_the_run_with_plain_words(self):
with self.assertRaises(ValueError) as caught:
AusBossTextLines().split(" \n")
self.assertIn("no lines to run", str(caught.exception))
def test_inputs_and_their_order(self):
inputs = AusBossTextLines.INPUT_TYPES()
self.assertEqual(list(inputs), ["required"])
self.assertEqual(list(inputs["required"]), ["text", "split", "remove_numbering", "limit"])
if __name__ == "__main__":
unittest.main()