From f1f585935d110b90f0ccbc9d15cecae028520ae7 Mon Sep 17 00:00:00 2001 From: xmarre <54859656+xmarre@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:42:16 +0200 Subject: [PATCH 01/14] Add grouped image execution backend --- image_conveyor.py | 326 ++++++++++++++++++++++++++++++---------------- 1 file changed, 213 insertions(+), 113 deletions(-) diff --git a/image_conveyor.py b/image_conveyor.py index 7d3dcd2..1b9e302 100644 --- a/image_conveyor.py +++ b/image_conveyor.py @@ -7,12 +7,21 @@ import nodes _STATE_VERSION = 1 +_MAX_IMAGES_PER_EXECUTION = 9 def _deep_copy_json(value: Any) -> Any: return json.loads(json.dumps(value)) +def _normalize_images_per_execution(value: Any) -> int: + try: + count = int(value) + except (TypeError, ValueError, OverflowError): + return 1 + return max(1, min(_MAX_IMAGES_PER_EXECUTION, count)) + + def _default_state() -> Dict[str, Any]: return { "version": _STATE_VERSION, @@ -20,18 +29,19 @@ def _default_state() -> Dict[str, Any]: "auto_queue": False, "dont_consume": False, "catch_canvas_drops": False, + "images_per_execution": 1, } def _default_ui_state() -> Dict[str, Any]: """ Return the default persisted UI state used by the node. - + The returned state contains the schema version and the UI-specific fields tracked across sessions: - `version`: schema version forced to the module `_STATE_VERSION`. - `selected_ids`: list of selected item IDs (empty by default). - `source_paths`: mapping of `{item_id: path}` that can override an item's stored `source_path` at runtime. - + Returns: ui_state (Dict[str, Any]): Default UI state with keys `version`, `selected_ids`, and `source_paths`. """ @@ -94,16 +104,19 @@ def _normalize_state(raw: Any) -> Dict[str, Any]: "auto_queue": bool(state.get("auto_queue", False)) if isinstance(state, dict) else False, "dont_consume": bool(state.get("dont_consume", False)) if isinstance(state, dict) else False, "catch_canvas_drops": bool(state.get("catch_canvas_drops", False)) if isinstance(state, dict) else False, + "images_per_execution": _normalize_images_per_execution( + state.get("images_per_execution", 1) if isinstance(state, dict) else 1 + ), } def _normalize_ui_state(raw: Any) -> Dict[str, Any]: """ Normalize a raw UI state payload into the expected runtime UI state structure. - + Parameters: raw (Any): Raw UI state value, typically a JSON string or already-parsed object. - + Returns: Dict[str, Any]: Normalized UI state with keys: - `version` (int): Schema version (set to the module `_STATE_VERSION`). @@ -132,38 +145,64 @@ def _normalize_ui_state(raw: Any) -> Dict[str, Any]: } +def _normalize_queue_member(value: Any) -> Optional[Dict[str, str]]: + if not isinstance(value, dict): + return None + item_id = str(value.get("id", "")).strip() + annotated = str(value.get("annotated", "")).strip() + if not item_id or not annotated: + return None + return {"id": item_id, "annotated": annotated} + + def _parse_queue_item(raw: Any) -> Optional[Dict[str, Any]]: - """ - Parse a queue payload and extract normalized `id` and `annotated` fields. - - Parameters: - raw (Any): JSON string or object representing a queue payload containing `id` and `annotated`. - - Returns: - result (Optional[Dict[str, str]]): A dict with keys `"id"` and `"annotated"` containing trimmed, non-empty string values when both are present; `None` if the payload is invalid or required fields are missing. - """ + """Parse a legacy single-item or new ordered group prompt reservation.""" payload = _safe_json_load(raw, {}) if not isinstance(payload, dict): return None - item_id = str(payload.get("id", "")).strip() - annotated = str(payload.get("annotated", "")).strip() - if not item_id or not annotated: + if "items" in payload: + raw_items = payload.get("items") + if not isinstance(raw_items, list) or not raw_items: + raise RuntimeError("Image Conveyor: queued image group reservation is invalid.") + items: List[Dict[str, str]] = [] + seen_ids = set() + for raw_item in raw_items: + member = _normalize_queue_member(raw_item) + if member is None or member["id"] in seen_ids: + raise RuntimeError("Image Conveyor: queued image group reservation is invalid.") + seen_ids.add(member["id"]) + items.append(member) + + first = items[0] + top_level = _normalize_queue_member(payload) + if top_level is not None and top_level != first: + raise RuntimeError("Image Conveyor: queued image group reservation is inconsistent.") + return { + "id": first["id"], + "annotated": first["annotated"], + "items": items, + "grouped": True, + } + + item = _normalize_queue_member(payload) + if item is None: return None return { - "id": item_id, - "annotated": annotated, + "id": item["id"], + "annotated": item["annotated"], + "grouped": False, } def _get_runtime_source_path(ui_state: Dict[str, Any], item: Dict[str, Any]) -> str: """ Resolve the runtime source path for an item, preferring a UI-provided override when present. - + Parameters: ui_state (dict): UI state that may contain a `source_paths` mapping of item IDs to source path overrides. item (dict): Item dictionary containing at least an `"id"` key and an optional `"source_path"` fallback. - + Returns: str: The trimmed source path from `ui_state["source_paths"][item["id"]]` if non-empty, otherwise the trimmed `item["source_path"]` (or an empty string if neither is set). """ @@ -176,55 +215,119 @@ def _get_runtime_source_path(ui_state: Dict[str, Any], item: Dict[str, Any]) -> def _find_item_by_id(state: Dict[str, Any], item_id: str) -> Tuple[int, Optional[Dict[str, Any]]]: - """ - Locate an item in the given state by its `id` and return its index and the item. - - Parameters: - state (Dict[str, Any]): Normalized state dictionary containing an "items" list of item dicts. - item_id (str): The `id` value to search for. - - Returns: - tuple: A pair `(index, item)` where `index` is the zero-based index of the matching item or `-1` if not found, and `item` is the matching item dictionary or `None` if not found. - """ + """Locate a queue item by its logical queue-entry ID.""" for index, item in enumerate(state["items"]): if item["id"] == item_id: return index, item return -1, None +def _insufficient_group_error(requested: int, available: int) -> RuntimeError: + return RuntimeError( + f"Image Conveyor: {requested} images per execution requested, " + f"but only {available} eligible queue images are available." + ) + + +def _select_group( + state: Dict[str, Any], + queue_item_json: Any, + *, + allow_processed: bool = False, +) -> List[Tuple[int, Dict[str, Any]]]: + """Resolve one complete ordered execution group from reservation or queue state.""" + count = _normalize_images_per_execution(state.get("images_per_execution", 1)) + reservation = _parse_queue_item(queue_item_json) + + if reservation is not None and reservation.get("grouped"): + reserved_items = reservation["items"] + if len(reserved_items) != count: + raise RuntimeError( + f"Image Conveyor: queued image group contains {len(reserved_items)} images, " + f"but this prompt requests {count}." + ) + selected: List[Tuple[int, Dict[str, Any]]] = [] + for reserved in reserved_items: + index, item = _find_item_by_id(state, reserved["id"]) + if item is None: + raise RuntimeError( + f"Image Conveyor: reserved queue image '{reserved['id']}' is no longer present." + ) + if item["annotated"] != reserved["annotated"]: + raise RuntimeError( + f"Image Conveyor: reserved queue image '{reserved['id']}' changed after it was queued." + ) + selected.append((index, item)) + return selected + + if reservation is not None: + if count != 1: + raise RuntimeError( + "Image Conveyor: a legacy single-image reservation cannot satisfy " + f"{count} images per execution. Queue this prompt again." + ) + index, item = _find_item_by_id(state, reservation["id"]) + if item is not None: + return [(index, item)] + for idx, candidate in enumerate(state["items"]): + if candidate["annotated"] == reservation["annotated"]: + return [(idx, candidate)] + + eligible = [ + (idx, item) + for idx, item in enumerate(state["items"]) + if item["status"] in {"pending", "queued"} + ] + if eligible: + if len(eligible) < count: + raise _insufficient_group_error(count, len(eligible)) + return eligible[:count] + + if allow_processed and state["items"]: + if len(state["items"]) < count: + raise _insufficient_group_error(count, len(state["items"])) + return list(enumerate(state["items"][:count])) + + if count > 1: + raise _insufficient_group_error(count, 0) + raise RuntimeError( + "Image Conveyor: no pending or queued images are available. " + "Add images or reset items back to pending." + ) + + def _select_item( state: Dict[str, Any], queue_item_json: Any, *, allow_processed: bool = False, ) -> Tuple[int, Dict[str, Any]]: - queued_item = _parse_queue_item(queue_item_json) - if queued_item is not None: - index, item = _find_item_by_id(state, queued_item["id"]) - if item is not None: - return index, item - for idx, candidate in enumerate(state["items"]): - if candidate["annotated"] == queued_item["annotated"]: - return idx, candidate - - for idx, item in enumerate(state["items"]): - if item["status"] in {"pending", "queued"}: - return idx, item - - if allow_processed and state["items"]: - return 0, state["items"][0] - - raise RuntimeError( - "Image Conveyor: no pending or queued images are available. " - "Add images or reset items back to pending." - ) + """Compatibility wrapper preserving the released single-item selection helper.""" + single_state = dict(state) + single_state["images_per_execution"] = 1 + return _select_group(single_state, queue_item_json, allow_processed=allow_processed)[0] class ImageConveyor: CATEGORY = "image" FUNCTION = "load_next" HAS_INTERMEDIATE_OUTPUT = True - RETURN_TYPES = ("IMAGE", "MASK", "STRING", "INT", "INT", "STRING") + RETURN_TYPES = ( + "IMAGE", + "MASK", + "STRING", + "INT", + "INT", + "STRING", + "IMAGE", + "IMAGE", + "IMAGE", + "IMAGE", + "IMAGE", + "IMAGE", + "IMAGE", + "IMAGE", + ) RETURN_NAMES = ( "image", "mask", @@ -232,6 +335,14 @@ class ImageConveyor: "index", "remaining_pending", "source_path", + "image_2", + "image_3", + "image_4", + "image_5", + "image_6", + "image_7", + "image_8", + "image_9", ) SEARCH_ALIASES = [ "image conveyor", @@ -277,113 +388,102 @@ class ImageConveyor: del ui_state_json state = _normalize_state(state_json) if not state["items"]: - return hashlib.sha256(str(state_json).encode("utf-8")).hexdigest() + empty_identity = ( + f"{state_json}|images_per_execution={state['images_per_execution']}" + ) + return hashlib.sha256(empty_identity.encode("utf-8")).hexdigest() - index, item = _select_item( + selected = _select_group( state, queue_item_json, allow_processed=state["dont_consume"] ) - path = folder_paths.get_annotated_filepath(item["annotated"]) hasher = hashlib.sha256() hasher.update(b"dont_consume=1" if state["dont_consume"] else b"dont_consume=0") - hasher.update(str(index).encode("utf-8")) - hasher.update(item["id"].encode("utf-8")) - hasher.update(item["annotated"].encode("utf-8")) - with open(path, "rb") as handle: - hasher.update(handle.read()) + hasher.update(f"|images_per_execution={state['images_per_execution']}".encode("utf-8")) + for slot, (_index, item) in enumerate(selected, start=1): + hasher.update(f"|slot={slot}|".encode("utf-8")) + hasher.update(item["id"].encode("utf-8")) + hasher.update(b"|") + hasher.update(item["annotated"].encode("utf-8")) + path = folder_paths.get_annotated_filepath(item["annotated"]) + with open(path, "rb") as handle: + while True: + chunk = handle.read(1024 * 1024) + if not chunk: + break + hasher.update(chunk) return hasher.hexdigest() @classmethod def VALIDATE_INPUTS(cls, state_json: Any, ui_state_json: Any = "", queue_item_json: Any = ""): - """ - Validate that a selectable image item exists in the provided state and that its annotated file exists. - - Parameters: - state_json (Any): Serialized node state or raw state structure to validate. - queue_item_json (Any): Optional queue payload used to select a specific item (e.g., `{"id": "...", "annotated": "..."}`). - - Returns: - True on successful validation. - str: An error message when validation fails. Possible messages: - "Image Conveyor: no images have been added to the node." — when the state contains no items. - Contents of the selection error (from `_select_item`) — when item selection fails. - "Image Conveyor: missing file ''." — when the selected item's annotated file is not found. - """ del ui_state_json state = _normalize_state(state_json) if not state["items"]: return "Image Conveyor: no images have been added to the node." try: - _index, item = _select_item( + selected = _select_group( state, queue_item_json, allow_processed=state["dont_consume"] ) except RuntimeError as exc: return str(exc) - if not folder_paths.exists_annotated_filepath(item["annotated"]): - return f"Image Conveyor: missing file '{item['annotated']}'." + for _index, item in selected: + if not folder_paths.exists_annotated_filepath(item["annotated"]): + return f"Image Conveyor: missing file '{item['annotated']}'." return True def load_next(self, state_json: Any, ui_state_json: Any = "", queue_item_json: Any = ""): - """ - Load the next selected image, compute remaining pending count, and produce a UI state delta. - - Parameters: - state_json (Any): Serialized node state (will be normalized) containing the list of items. - ui_state_json (Any): Serialized UI state (will be normalized); used to resolve the runtime source path for the selected item. - queue_item_json (Any): Optional queue payload that can influence which item is selected. - - Returns: - dict: A mapping with two keys: - - "result": A tuple containing: - - image: The loaded image object. - - mask: The loaded mask object (may be None). - - annotated (str): The resolved annotated file path that was loaded. - - index (int): 1-based index of the selected item within the state list. - - remaining_pending (int): Number of other items in the state whose status is "pending". - - source_path (str): The resolved runtime source path for the selected item (prefers UI override). - - "ui": A dict with key "batch_image_loader_delta" whose value is a single-item list containing a JSON string of the delta object with fields: - - version: State schema version. - - processed_item_id: ID of the processed item. - - processed_annotated: Annotated path of the processed item. - - new_status: The new status applied ("processed"). - """ state = _normalize_state(state_json) ui_state = _normalize_ui_state(ui_state_json) dont_consume = state["dont_consume"] - index, item = _select_item( + selected = _select_group( state, queue_item_json, allow_processed=dont_consume ) - annotated = item["annotated"] - source_path = _get_runtime_source_path(ui_state, item) - image, mask = nodes.LoadImage().load_image(annotated) + loaded_images: List[Any] = [] + first_mask = None + for slot, (_index, item) in enumerate(selected): + image, mask = nodes.LoadImage().load_image(item["annotated"]) + loaded_images.append(image) + if slot == 0: + first_mask = mask - remaining_pending = 0 - for idx, candidate in enumerate(state["items"]): - if not dont_consume and idx == index: - continue - if candidate["status"] == "pending": - remaining_pending += 1 + first_index, first_item = selected[0] + annotated = first_item["annotated"] + source_path = _get_runtime_source_path(ui_state, first_item) + selected_ids = {item["id"] for _index, item in selected} + remaining_pending = sum( + 1 + for item in state["items"] + if item["status"] == "pending" + and (dont_consume or item["id"] not in selected_ids) + ) + processed_items = [ + {"id": item["id"], "annotated": item["annotated"]} + for _index, item in selected + ] delta = { "version": _STATE_VERSION, - "processed_item_id": item["id"], + "processed_item_id": first_item["id"], "processed_annotated": annotated, + "processed_items": processed_items, "new_status": "processed", "consumed": not dont_consume, } + additional_images = loaded_images[1:] + [None] * (_MAX_IMAGES_PER_EXECUTION - len(loaded_images)) return { "result": ( - image, - mask, + loaded_images[0], + first_mask, annotated, - index + 1, + first_index + 1, remaining_pending, source_path, + *additional_images, ), "ui": { "batch_image_loader_delta": [json.dumps(delta, separators=(",", ":"))], From 4ece6ec3e1a455d5a08c959ac87c4e28c3639a98 Mon Sep 17 00:00:00 2001 From: xmarre <54859656+xmarre@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:43:31 +0200 Subject: [PATCH 02/14] Add queue group math helpers --- web/image_conveyor_math.mjs | 110 ++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/web/image_conveyor_math.mjs b/web/image_conveyor_math.mjs index 4ed919a..e1c1969 100644 --- a/web/image_conveyor_math.mjs +++ b/web/image_conveyor_math.mjs @@ -489,3 +489,113 @@ export function prepareManagedDuplicateCleanup(groups, protectedPaths = new Set( ), 0) return { groups: cleanupGroups, duplicateCount, reclaimableBytes, protectedCount } } + +export function normalizeImagesPerExecution(value) { + const number = Number(value) + if (!Number.isFinite(number)) return 1 + return Math.max(1, Math.min(9, Math.trunc(number))) +} + +export function selectExecutionGroup(items, imagesPerExecution, dontConsume = false) { + const source = Array.isArray(items) ? items : [] + const count = normalizeImagesPerExecution(imagesPerExecution) + const eligible = source.filter((item) => ( + dontConsume + ? item?.status === 'pending' || item?.status === 'queued' + : item?.status === 'pending' + )) + const candidates = dontConsume && eligible.length === 0 ? source : eligible + return candidates.slice(0, count) +} + +function normalizeReservationMember(value) { + if (!value || typeof value !== 'object') return null + const id = String(value.id ?? '').trim() + const annotated = String(value.annotated ?? '').trim() + return id && annotated ? { id, annotated } : null +} + +export function makeQueueReservationPayload(items) { + const members = (Array.isArray(items) ? items : []) + .map(normalizeReservationMember) + .filter(Boolean) + if (!members.length) return null + const first = members[0] + if (members.length === 1) return first + return { ...first, items: members } +} + +export function queueReservationMembers(payload) { + if (!payload || typeof payload !== 'object') return [] + if (Object.hasOwn(payload, 'items')) { + if (!Array.isArray(payload.items) || !payload.items.length) return [] + const members = payload.items.map(normalizeReservationMember) + if (members.some((member) => member == null)) return [] + const ids = new Set(members.map((member) => member.id)) + if (ids.size !== members.length) return [] + const first = normalizeReservationMember(payload) + if (first && (first.id !== members[0].id || first.annotated !== members[0].annotated)) return [] + return members + } + const member = normalizeReservationMember(payload) + return member ? [member] : [] +} + +export function markReservedGroupQueued(items, payload, dontConsume = false, now = Date.now()) { + if (dontConsume) return 0 + const members = queueReservationMembers(payload) + if (!members.length) return 0 + const ids = new Set(members.map((member) => member.id)) + let changed = 0 + for (const item of Array.isArray(items) ? items : []) { + if (!ids.has(item?.id)) continue + item.status = 'queued' + item.last_queued_at = now + changed += 1 + } + return changed +} + +export function processedItemIdsFromDelta(delta) { + if (!delta || typeof delta !== 'object' || delta.consumed === false) return [] + if (Array.isArray(delta.processed_items) && delta.processed_items.length) { + const ids = delta.processed_items + .map((item) => String(item?.id ?? '').trim()) + .filter(Boolean) + return Array.from(new Set(ids)) + } + const id = String(delta.processed_item_id ?? '').trim() + return id ? [id] : [] +} + +export function completeExecutionGroupCount(pendingCount, imagesPerExecution) { + const pending = Math.max(0, Math.floor(Number(pendingCount) || 0)) + const count = normalizeImagesPerExecution(imagesPerExecution) + return Math.floor(pending / count) +} + +export function calculateAutoQueueExtraExecutions( + pendingCount, + imagesPerExecution, + requestedBatchCount = 1 +) { + const completeGroups = completeExecutionGroupCount(pendingCount, imagesPerExecution) + const requested = Math.max(1, Math.floor(Number(requestedBatchCount) || 1)) + return Math.max(0, completeGroups - requested) +} + +export function shouldReanchorGalleryResize( + previousWidgetWidth, + currentWidgetWidth, + previousViewportWidth, + currentViewportWidth +) { + const previousWidget = Number(previousWidgetWidth) + const currentWidget = Number(currentWidgetWidth) + const previousViewport = Number(previousViewportWidth) + const currentViewport = Number(currentViewportWidth) + return Number.isFinite(previousWidget) && Number.isFinite(currentWidget) && + Number.isFinite(previousViewport) && Number.isFinite(currentViewport) && + previousWidget > 0 && currentWidget > 0 && + previousWidget !== currentWidget && previousViewport !== currentViewport +} From 3e4caa0e48067846771c9e3ee3952056fdc6f1d2 Mon Sep 17 00:00:00 2001 From: xmarre <54859656+xmarre@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:50:18 +0200 Subject: [PATCH 03/14] Reserve and process image execution groups --- web/image_conveyor.js | 136 +++++++++++++++++++++++++++++++----------- 1 file changed, 101 insertions(+), 35 deletions(-) diff --git a/web/image_conveyor.js b/web/image_conveyor.js index 030cdf5..e57df0c 100644 --- a/web/image_conveyor.js +++ b/web/image_conveyor.js @@ -3,23 +3,32 @@ import { api } from '../../scripts/api.js' import '../../scripts/domWidget.js' import { CARD_FOOTER_HEIGHT, + calculateAutoQueueExtraExecutions, calculateGalleryDropIntent, calculateMarqueeGridIndexes, calculateGalleryMetrics, calculateReorderDestinationIndex, calculateVisibleCardRange, - clientPointToScrollContent, chooseViewAfterClose, + clientPointToScrollContent, + completeExecutionGroupCount, dispatchKeyboundCommandFallback, groupDirectoryPickerFiles, isDragLeavingDocument, isGalleryViewportMeasurable, isHighVelocityScroll, isConveyorGalleryShortcut, + makeQueueReservationPayload, + markReservedGroupQueued, + normalizeImagesPerExecution, planCardSlotReuse, planViewScrollSwitch, prepareManagedDuplicateCleanup, + processedItemIdsFromDelta, + queueReservationMembers, restoreGraphCanvasFocus, + selectExecutionGroup, + shouldReanchorGalleryResize, isConveyorDeleteShortcut } from './image_conveyor_math.mjs' @@ -174,7 +183,8 @@ function defaultState() { items: [], auto_queue: false, dont_consume: false, - catch_canvas_drops: false + catch_canvas_drops: false, + images_per_execution: 1 } } @@ -229,7 +239,8 @@ function parseState(raw) { items, auto_queue: Boolean(state.auto_queue), dont_consume: Boolean(state.dont_consume), - catch_canvas_drops: Boolean(state.catch_canvas_drops) + catch_canvas_drops: Boolean(state.catch_canvas_drops), + images_per_execution: normalizeImagesPerExecution(state.images_per_execution ?? 1) } } @@ -270,7 +281,8 @@ function serializeState(state) { items: state.items, auto_queue: Boolean(state.auto_queue), dont_consume: Boolean(state.dont_consume), - catch_canvas_drops: Boolean(state.catch_canvas_drops) + catch_canvas_drops: Boolean(state.catch_canvas_drops), + images_per_execution: normalizeImagesPerExecution(state.images_per_execution) }, null, 0 @@ -472,11 +484,16 @@ function findFirstByStatus(state, statuses) { return state.items.find((item) => statuses.includes(item.status)) ?? null } +function findNextLoadGroup(state) { + return selectExecutionGroup( + state.items, + state.images_per_execution, + state.dont_consume + ) +} + function findNextLoadItem(state) { - if (state.dont_consume) { - return findFirstByStatus(state, ['pending', 'queued']) ?? state.items[0] ?? null - } - return findFirstByStatus(state, ['pending']) + return findNextLoadGroup(state)[0] ?? null } function countItemsByStatus(state, status) { @@ -520,8 +537,10 @@ const autoQueueCoordinator = { const { state } = getCurrentState(node) if (!state.auto_queue || state.dont_consume) continue const pendingCount = countItemsByStatus(state, 'pending') - if (pendingCount <= 0) continue - eligible.push({ node, pendingCount }) + const imagesPerExecution = normalizeImagesPerExecution(state.images_per_execution) + const completeGroups = completeExecutionGroupCount(pendingCount, imagesPerExecution) + if (completeGroups <= 0) continue + eligible.push({ node, pendingCount, imagesPerExecution, completeGroups }) } return eligible }, @@ -537,7 +556,7 @@ const autoQueueCoordinator = { if (eligibleNodes.length > 1 && !this.warnedAboutMultipleNodes) { this.warnedAboutMultipleNodes = true console.warn( - 'Image Conveyor: auto-queue is only applied when exactly one conveyor node with pending items has auto-queue enabled.' + 'Image Conveyor: auto-queue is only applied when exactly one conveyor node with complete pending groups has auto-queue enabled.' ) } if (eligibleNodes.length <= 1) { @@ -552,15 +571,19 @@ const autoQueueCoordinator = { 1, Math.floor(Number(event?.detail?.batchCount) || 1) ) - const { pendingCount } = eligibleNodes[0] - const extraCount = pendingCount - requestedBatchCount + const { pendingCount, imagesPerExecution } = eligibleNodes[0] + const extraCount = calculateAutoQueueExtraExecutions( + pendingCount, + imagesPerExecution, + requestedBatchCount + ) if (extraCount <= 0) return this.pendingInternalQueueRequests += 1 queueMicrotask(() => { void app.queuePrompt(0, extraCount).catch((error) => { console.error( - 'Image Conveyor: failed to auto-queue remaining pending images.', + 'Image Conveyor: failed to auto-queue remaining complete image groups.', error ) }) @@ -1659,6 +1682,7 @@ function ensureStyles() { .bil-settings-row { flex-wrap: wrap; padding: 0 7px 7px; } .bil-toggle { display: inline-flex; align-items: center; gap: 5px; user-select: none; white-space: nowrap; } .bil-toggle input { margin: 0; } + .bil-images-per-execution { max-width: 52px; padding-left: 5px; padding-right: 5px; } .bil-list { position: relative; min-height: 0; overflow: auto; flex: 1 1 0; overscroll-behavior: contain; outline: none; } .bil-list-inner, .bil-list-window { position: relative; min-height: 100%; } .bil-selection-marquee { position: absolute; z-index: 4; pointer-events: none; box-sizing: border-box; border: 1px solid rgba(125,185,255,.95); background: rgba(80,145,225,.18); box-shadow: inset 0 0 0 1px rgba(255,255,255,.08); } @@ -1706,12 +1730,19 @@ function ensureStyles() { function applyBackendDelta(node, delta) { if (!delta || typeof delta !== 'object') return + const processedIds = processedItemIdsFromDelta(delta) + if (!processedIds.length) return const { state, uiState } = getCurrentState(node) - const item = state.items.find((entry) => entry.id === delta.processed_item_id) - if (!item || delta.consumed === false) return - item.status = delta.new_status === 'processed' ? 'processed' : item.status - item.last_processed_at = Date.now() - updateState(node, state, uiState, { rerender: true }) + const processedSet = new Set(processedIds) + const now = Date.now() + let changed = false + for (const item of state.items) { + if (!processedSet.has(item.id)) continue + item.status = delta.new_status === 'processed' ? 'processed' : item.status + item.last_processed_at = now + changed = true + } + if (changed) updateState(node, state, uiState, { rerender: true }) } function attachQueueLifecycle(node) { @@ -1723,28 +1754,31 @@ function attachQueueLifecycle(node) { queueWidget.beforeQueued = () => { const { state } = getCurrentState(node) - const item = findNextLoadItem(state) + const count = normalizeImagesPerExecution(state.images_per_execution) + const group = findNextLoadGroup(state) updateQueueWidget( node, - item - ? { - id: item.id, - annotated: item.annotated - } - : null + group.length === count ? makeQueueReservationPayload(group) : null ) } queueWidget.afterQueued = () => { const queuePayload = safeJsonParse(queueWidget.value, {}) - if (!queuePayload?.id) return + const members = queueReservationMembers(queuePayload) + if (!members.length) return const { state, uiState } = getCurrentState(node) if (state.dont_consume) return - const item = state.items.find((entry) => entry.id === queuePayload.id) - if (!item) return - item.status = 'queued' - item.last_queued_at = Date.now() - updateState(node, state, uiState, { rerender: true }) + if (Array.isArray(queuePayload.items)) { + const byId = new Map(state.items.map((item) => [item.id, item])) + if (!members.every((member) => byId.get(member.id)?.annotated === member.annotated)) return + } + const changed = markReservedGroupQueued( + state.items, + queuePayload, + false, + Date.now() + ) + if (changed) updateState(node, state, uiState, { rerender: true }) } } @@ -2804,6 +2838,7 @@ function renderGalleryNode(node) { ctx.autoQueueCheckbox.checked = Boolean(state.auto_queue) ctx.dontConsumeCheckbox.checked = Boolean(state.dont_consume) ctx.canvasDropCheckbox.checked = Boolean(state.catch_canvas_drops) + ctx.imagesPerExecutionSelect.value = String(normalizeImagesPerExecution(state.images_per_execution)) if (!ctx.visibleItems.length) { hideUnusedCards(ctx); ctx.renderedRangeKey = '' @@ -3131,13 +3166,28 @@ function buildGalleryDom(node) { const autoQueue = makeToggle('Auto queue all pending', 'Auto queue all pending images') const dontConsume = makeToggle("Don't consume", 'Do not consume images') const canvasDrop = makeToggle('Catch canvas drops', 'Catch image drops anywhere on the canvas') + const imagesPerExecutionLabel = document.createElement('label') + imagesPerExecutionLabel.className = 'bil-toggle' + const imagesPerExecutionText = document.createElement('span') + imagesPerExecutionText.textContent = 'Images per execution' + const imagesPerExecutionSelect = document.createElement('select') + imagesPerExecutionSelect.className = 'bil-select bil-images-per-execution' + imagesPerExecutionSelect.setAttribute('aria-label', 'Images per execution') + imagesPerExecutionSelect.title = 'Number of consecutive Conveyor images returned by each execution. 1 keeps the normal single-image behavior; 2-9 expose additional images through image_2 ... image_9.' + for (let count = 1; count <= 9; count += 1) { + const option = document.createElement('option') + option.value = String(count) + option.textContent = String(count) + imagesPerExecutionSelect.appendChild(option) + } + imagesPerExecutionLabel.append(imagesPerExecutionText, imagesPerExecutionSelect) const selectVisibleBtn = document.createElement('button'); selectVisibleBtn.className = 'bil-btn'; selectVisibleBtn.type = 'button'; selectVisibleBtn.textContent = 'Select all' const clearQueuedBtn = document.createElement('button'); clearQueuedBtn.className = 'bil-btn'; clearQueuedBtn.type = 'button'; clearQueuedBtn.textContent = 'Clear queued' const clearProcessedBtn = document.createElement('button'); clearProcessedBtn.className = 'bil-btn'; clearProcessedBtn.type = 'button'; clearProcessedBtn.textContent = 'Remove processed' const jumpPendingBtn = document.createElement('button'); jumpPendingBtn.className = 'bil-btn'; jumpPendingBtn.type = 'button'; jumpPendingBtn.textContent = 'Jump to next pending' const cleanDuplicatesBtn = document.createElement('button'); cleanDuplicatesBtn.className = 'bil-btn'; cleanDuplicatesBtn.type = 'button'; cleanDuplicatesBtn.textContent = 'Clean exact duplicates' cleanDuplicatesBtn.title = 'Preview and remove byte-identical redundant files from the legacy input/image_conveyor folder' - settingsRow.append(autoQueue.label, dontConsume.label, canvasDrop.label, selectVisibleBtn, clearQueuedBtn, clearProcessedBtn, jumpPendingBtn, cleanDuplicatesBtn) + settingsRow.append(autoQueue.label, dontConsume.label, imagesPerExecutionLabel, canvasDrop.label, selectVisibleBtn, clearQueuedBtn, clearProcessedBtn, jumpPendingBtn, cleanDuplicatesBtn) settings.append(settingsSummary, settingsRow) const list = document.createElement('div') @@ -3166,7 +3216,8 @@ function buildGalleryDom(node) { contextLabel, setPendingBtn, setProcessedBtn, deleteSelectedBtn, contextAddBtn, cleanDuplicatesBtn, autoQueueCheckbox: autoQueue.checkbox, dontConsumeCheckbox: dontConsume.checkbox, - canvasDropCheckbox: canvasDrop.checkbox, list, listInner, listWindow, selectionMarquee, dropIndicator, + canvasDropCheckbox: canvasDrop.checkbox, imagesPerExecutionSelect, + list, listInner, listWindow, selectionMarquee, dropIndicator, browser: createBrowserState(), visibleItems: [], cardPool: [], draggedId: null, dragIntent: null, empty: null, state: null, uiState: null, renderVersion: 0, inputVersion: 0, renderedRangeKey: '', renderFrame: 0, renderViewportOnly: false, @@ -3175,6 +3226,7 @@ function buildGalleryDom(node) { deferThumbnailLoads: false, scrollSettleTimer: 0, scrollSampleTop: 0, scrollSampleAt: 0, listResizeObserver: null, widgetOuterHeight: 0, widgetInnerHeight: 0, widgetWidth: 0, + resizeAnchorWidgetWidth: 0, pointerInside: false, middlePanPointerId: null, documentPasteHandler: null, documentMiddlePanMoveHandler: null, documentMiddlePanEndHandler: null, documentMarqueeMoveHandler: null, documentMarqueeEndHandler: null, @@ -3337,6 +3389,12 @@ function buildGalleryDom(node) { }) autoQueue.checkbox.addEventListener('change', () => { const { state, uiState } = getRenderableState(node); state.auto_queue = autoQueue.checkbox.checked; updateState(node, state, uiState) }) dontConsume.checkbox.addEventListener('change', () => { const { state, uiState } = getRenderableState(node); state.dont_consume = dontConsume.checkbox.checked; updateState(node, state, uiState) }) + imagesPerExecutionSelect.addEventListener('change', () => { + const { state, uiState } = getRenderableState(node) + state.images_per_execution = normalizeImagesPerExecution(imagesPerExecutionSelect.value) + imagesPerExecutionSelect.value = String(state.images_per_execution) + updateState(node, state, uiState) + }) canvasDrop.checkbox.addEventListener('change', () => { const { state, uiState } = getRenderableState(node); state.catch_canvas_drops = canvasDrop.checkbox.checked; updateState(node, state, uiState) }) clearQueuedBtn.addEventListener('click', () => { const { state, uiState } = getRenderableState(node); for (const item of state.items) if (item.status === 'queued') item.status = 'pending'; updateState(node, state, uiState) }) clearProcessedBtn.addEventListener('click', () => { @@ -3403,6 +3461,9 @@ function buildGalleryDom(node) { suspendGalleryViewport(ctx) return } + const previousWidgetWidth = ctx.resizeAnchorWidgetWidth || ctx.widgetWidth + const currentWidgetWidth = ctx.widgetWidth + ctx.resizeAnchorWidgetWidth = currentWidgetWidth const resuming = ctx.galleryViewportSuspended if (resuming && ctx.pendingScrollRestore?.view !== ctx.browser.activeView) { ctx.pendingScrollRestore = { @@ -3421,7 +3482,12 @@ function buildGalleryDom(node) { ctx.renderedRangeKey = '' scheduleRenderNode(node, { viewportOnly: true, forceVisibleRows: true }) if (resuming) return - if (anchorId && previous && previous.width !== Math.floor(list.clientWidth || 0)) { + if (anchorId && previous && shouldReanchorGalleryResize( + previousWidgetWidth, + currentWidgetWidth, + previous.width, + Math.floor(list.clientWidth || 0) + )) { const view = ctx.browser.activeView const viewportEpoch = ctx.galleryViewportEpoch requestAnimationFrame(() => { From 16e72fbfa699f556cb2d2f71626692a55ed3b2b6 Mon Sep 17 00:00:00 2001 From: xmarre <54859656+xmarre@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:53:43 +0200 Subject: [PATCH 04/14] Test grouped backend selection and outputs --- tests/test_image_conveyor_state.py | 332 ++++++++++++++++++++++++++--- 1 file changed, 305 insertions(+), 27 deletions(-) diff --git a/tests/test_image_conveyor_state.py b/tests/test_image_conveyor_state.py index 5ddf45d..27d4cec 100644 --- a/tests/test_image_conveyor_state.py +++ b/tests/test_image_conveyor_state.py @@ -1,19 +1,27 @@ import importlib.util import json import sys +import tempfile import types import unittest from pathlib import Path from unittest import mock +FILE_PATHS = {} +EXISTING_ANNOTATED = set() + + fake_folder_paths = types.ModuleType("folder_paths") -fake_folder_paths.get_annotated_filepath = lambda annotated: annotated -fake_folder_paths.exists_annotated_filepath = lambda annotated: True +fake_folder_paths.get_annotated_filepath = lambda annotated: FILE_PATHS.get(annotated, annotated) +fake_folder_paths.exists_annotated_filepath = lambda annotated: annotated in EXISTING_ANNOTATED class FakeLoadImage: + calls = [] + def load_image(self, annotated): + self.calls.append(annotated) return f"image:{annotated}", f"mask:{annotated}" @@ -28,13 +36,14 @@ with mock.patch.dict(sys.modules, {"folder_paths": fake_folder_paths, "nodes": f SPEC.loader.exec_module(conveyor) -def item(item_id, annotated, status="pending"): +def item(item_id, annotated=None, status="pending", source_path=""): + annotated = annotated or f"{item_id}.png [input]" return { "id": item_id, "annotated": annotated, "filename": f"{item_id}.png", "subfolder": "", - "source_path": "", + "source_path": source_path, "type": "input", "status": status, "added_at": 1, @@ -43,48 +52,317 @@ def item(item_id, annotated, status="pending"): } -class QueueCompatibilityTest(unittest.TestCase): - def state(self, items, **options): +def grouped_payload(*items): + members = [{"id": entry["id"], "annotated": entry["annotated"]} for entry in items] + return json.dumps({ + "id": members[0]["id"], + "annotated": members[0]["annotated"], + "items": members, + }) + + +class ImageConveyorStateTest(unittest.TestCase): + def setUp(self): + FILE_PATHS.clear() + EXISTING_ANNOTATED.clear() + FakeLoadImage.calls.clear() + self.temp_dir = tempfile.TemporaryDirectory() + + def tearDown(self): + self.temp_dir.cleanup() + + def state(self, items, *, legacy=False, **options): payload = conveyor._default_state() payload["items"] = items payload.update(options) + if legacy: + payload.pop("images_per_execution", None) return json.dumps(payload) - def test_old_v1_state_normalizes_and_selects_next_pending(self): - raw = self.state([item("done", "done.png [input]", "processed"), item("next", "next.png [input]")]) + def materialize(self, entry, contents=b"image-bytes"): + path = Path(self.temp_dir.name) / f"{entry['id']}-{len(FILE_PATHS)}.img" + path.write_bytes(contents) + FILE_PATHS[entry["annotated"]] = str(path) + EXISTING_ANNOTATED.add(entry["annotated"]) + return path + + def materialize_all(self, items): + for index, entry in enumerate(items): + self.materialize(entry, f"bytes-{entry['id']}-{index}".encode()) + + def test_legacy_v1_state_without_count_normalizes_to_one(self): + raw = self.state([item("one")], legacy=True) state = conveyor._normalize_state(raw) + self.assertEqual(1, state["version"]) + self.assertEqual(1, state["images_per_execution"]) + + def test_images_per_execution_range_normalization(self): + cases = { + 0: 1, + -5: 1, + "bad": 1, + None: 1, + 10: 9, + 999: 9, + } + for raw_value, expected in cases.items(): + with self.subTest(raw_value=raw_value): + raw = self.state([], images_per_execution=raw_value) + self.assertEqual(expected, conveyor._normalize_state(raw)["images_per_execution"]) + for count in range(1, 10): + with self.subTest(count=count): + raw = self.state([], images_per_execution=count) + self.assertEqual(count, conveyor._normalize_state(raw)["images_per_execution"]) + + def test_existing_single_selection_semantics_remain(self): + entries = [item("done", status="processed"), item("next"), item("queued", status="queued")] + state = conveyor._normalize_state(self.state(entries)) index, selected = conveyor._select_item(state, "") self.assertEqual(1, index) self.assertEqual("next", selected["id"]) - def test_queue_reservation_selects_by_id_with_duplicate_annotated_references(self): - duplicate_path = "same.png [input]" - raw = self.state([item("first", duplicate_path), item("second", duplicate_path, "queued")]) - state = conveyor._normalize_state(raw) + def test_multi_selection_preserves_queue_order(self): + entries = [item("A"), item("B"), item("C"), item("D")] + state = conveyor._normalize_state(self.state(entries, images_per_execution=3)) + selected = conveyor._select_group(state, "") + self.assertEqual(["A", "B", "C"], [entry["id"] for _, entry in selected]) + + def test_duplicate_physical_paths_remain_distinct_logical_slots(self): + shared = "same.png [input]" + entries = [item("A1", shared), item("A2", shared), item("B", "other.png [input]")] + state = conveyor._normalize_state(self.state(entries, images_per_execution=3)) + selected = conveyor._select_group(state, "") + self.assertEqual(["A1", "A2", "B"], [entry["id"] for _, entry in selected]) + + def test_dont_consume_selects_same_group_repeatedly(self): + entries = [item("A"), item("B"), item("C"), item("D")] + raw = self.state(entries, images_per_execution=3, dont_consume=True) + node = conveyor.ImageConveyor() + first = node.load_next(raw) + second = node.load_next(raw) + self.assertEqual(first["result"][0], second["result"][0]) + self.assertEqual(first["result"][6], second["result"][6]) + self.assertEqual(first["result"][7], second["result"][7]) + self.assertFalse(json.loads(first["ui"]["batch_image_loader_delta"][0])["consumed"]) + self.assertFalse(json.loads(second["ui"]["batch_image_loader_delta"][0])["consumed"]) + + def test_dont_consume_processed_fallback_uses_first_n(self): + entries = [item("A", status="processed"), item("B", status="processed"), item("C", status="processed")] + state = conveyor._normalize_state( + self.state(entries, images_per_execution=2, dont_consume=True) + ) + selected = conveyor._select_group(state, "", allow_processed=True) + self.assertEqual(["A", "B"], [entry["id"] for _, entry in selected]) + + def test_dont_consume_does_not_fill_partial_eligible_group_with_processed(self): + entries = [item("A"), item("B", status="processed"), item("C", status="processed")] + state = conveyor._normalize_state( + self.state(entries, images_per_execution=2, dont_consume=True) + ) + with self.assertRaisesRegex(RuntimeError, "2 images per execution requested, but only 1 eligible"): + conveyor._select_group(state, "", allow_processed=True) + + def test_insufficient_group_has_clear_validation_error(self): + entries = [item("A"), item("B")] + raw = self.state(entries, images_per_execution=3) + result = conveyor.ImageConveyor.VALIDATE_INPUTS(raw) + self.assertIn("3 images per execution requested", result) + self.assertIn("only 2 eligible", result) + + def test_validate_inputs_checks_missing_secondary_file(self): + entries = [item("A"), item("B"), item("C")] + self.materialize(entries[0]) + self.materialize(entries[2]) + raw = self.state(entries, images_per_execution=3) + result = conveyor.ImageConveyor.VALIDATE_INPUTS(raw) + self.assertEqual("Image Conveyor: missing file 'B.png [input]'.", result) + + def test_legacy_queue_payload_count_one_preserves_id_selection(self): + shared = "same.png [input]" + entries = [item("first", shared), item("second", shared, status="queued")] + state = conveyor._normalize_state(self.state(entries)) index, selected = conveyor._select_item( - state, json.dumps({"id": "second", "annotated": duplicate_path}) + state, json.dumps({"id": "second", "annotated": shared}) ) self.assertEqual(1, index) self.assertEqual("second", selected["id"]) - def test_load_next_reports_processed_delta(self): - node = conveyor.ImageConveyor() - raw = self.state([item("one", "one.png [input]"), item("two", "two.png [input]")]) - output = node.load_next(raw) - self.assertEqual("image:one.png [input]", output["result"][0]) - self.assertEqual(1, output["result"][3]) - self.assertEqual(1, output["result"][4]) + def test_legacy_payload_cannot_invent_multi_image_group(self): + entries = [item("A"), item("B"), item("C")] + state = conveyor._normalize_state(self.state(entries, images_per_execution=3)) + with self.assertRaisesRegex(RuntimeError, "legacy single-image reservation cannot satisfy 3"): + conveyor._select_group( + state, + json.dumps({"id": "A", "annotated": entries[0]["annotated"]}), + ) + + def test_grouped_reservation_uses_payload_order(self): + a, b, c = item("A"), item("B"), item("C") + state = conveyor._normalize_state(self.state([a, b, c], images_per_execution=3)) + selected = conveyor._select_group(state, grouped_payload(a, b, c)) + self.assertEqual(["A", "B", "C"], [entry["id"] for _, entry in selected]) + + def test_grouped_reservation_missing_member_is_strict_error(self): + a, b, c = item("A"), item("B"), item("C") + state = conveyor._normalize_state(self.state([a, c], images_per_execution=3)) + with self.assertRaisesRegex(RuntimeError, "reserved queue image 'B' is no longer present"): + conveyor._select_group(state, grouped_payload(a, b, c)) + + def test_grouped_reservation_rejects_changed_member_path(self): + a, b, c = item("A"), item("B"), item("C") + payload = grouped_payload(a, b, c) + b["annotated"] = "changed.png [input]" + state = conveyor._normalize_state(self.state([a, b, c], images_per_execution=3)) + with self.assertRaisesRegex(RuntimeError, "reserved queue image 'B' changed"): + conveyor._select_group(state, payload) + + def test_grouped_reservation_order_survives_live_reorder(self): + a, b, c = item("A"), item("B"), item("C") + payload = grouped_payload(a, b, c) + state = conveyor._normalize_state(self.state([c, b, a], images_per_execution=3)) + selected = conveyor._select_group(state, payload) + self.assertEqual(["A", "B", "C"], [entry["id"] for _, entry in selected]) + + def test_output_layout_preserves_first_six_slots_and_appends_images(self): + self.assertEqual( + ("IMAGE", "MASK", "STRING", "INT", "INT", "STRING"), + conveyor.ImageConveyor.RETURN_TYPES[:6], + ) + self.assertEqual( + ("image", "mask", "path", "index", "remaining_pending", "source_path"), + conveyor.ImageConveyor.RETURN_NAMES[:6], + ) + self.assertEqual( + ("image_2", "image_3", "image_4", "image_5", "image_6", "image_7", "image_8", "image_9"), + conveyor.ImageConveyor.RETURN_NAMES[6:], + ) + self.assertEqual(("IMAGE",) * 8, conveyor.ImageConveyor.RETURN_TYPES[6:]) + self.assertEqual(14, len(conveyor.ImageConveyor.RETURN_TYPES)) + self.assertEqual(14, len(conveyor.ImageConveyor.RETURN_NAMES)) + + def test_result_mapping_and_inactive_image_outputs(self): + entries = [item("A"), item("B"), item("C"), item("D")] + raw = self.state(entries, images_per_execution=3) + result = conveyor.ImageConveyor().load_next(raw)["result"] + self.assertEqual(14, len(result)) + self.assertEqual("image:A.png [input]", result[0]) + self.assertEqual("image:B.png [input]", result[6]) + self.assertEqual("image:C.png [input]", result[7]) + self.assertEqual([None] * 6, list(result[8:14])) + self.assertEqual( + ["A.png [input]", "B.png [input]", "C.png [input]"], + FakeLoadImage.calls, + ) + + def test_metadata_outputs_describe_first_selected_item(self): + a, b, c = item("A", source_path="persisted/A.png"), item("B"), item("C") + raw = self.state([a, b, c], images_per_execution=3) + ui_state = json.dumps({ + "version": 1, + "selected_ids": [], + "source_paths": {"A": "runtime/A.png"}, + }) + result = conveyor.ImageConveyor().load_next(raw, ui_state)["result"] + self.assertEqual("mask:A.png [input]", result[1]) + self.assertEqual("A.png [input]", result[2]) + self.assertEqual(1, result[3]) + self.assertEqual("runtime/A.png", result[5]) + + def test_remaining_pending_is_group_aware_when_consuming(self): + entries = [item(str(index)) for index in range(10)] + raw = self.state(entries, images_per_execution=3) + result = conveyor.ImageConveyor().load_next(raw)["result"] + self.assertEqual(7, result[4]) + + def test_remaining_pending_is_unchanged_when_not_consuming(self): + entries = [item("A"), item("B"), item("C"), item("D")] + raw = self.state(entries, images_per_execution=3, dont_consume=True) + result = conveyor.ImageConveyor().load_next(raw)["result"] + self.assertEqual(4, result[4]) + + def test_backend_delta_contains_complete_ordered_group_and_legacy_fields(self): + entries = [item("A"), item("B"), item("C")] + raw = self.state(entries, images_per_execution=3) + output = conveyor.ImageConveyor().load_next(raw) delta = json.loads(output["ui"]["batch_image_loader_delta"][0]) - self.assertEqual("one", delta["processed_item_id"]) + self.assertEqual("A", delta["processed_item_id"]) + self.assertEqual("A.png [input]", delta["processed_annotated"]) + self.assertEqual( + ["A", "B", "C"], + [entry["id"] for entry in delta["processed_items"]], + ) + self.assertEqual("processed", delta["new_status"]) self.assertTrue(delta["consumed"]) - def test_dont_consume_keeps_processed_item_reusable(self): - node = conveyor.ImageConveyor() - raw = self.state([item("one", "one.png [input]", "processed")], dont_consume=True) - output = node.load_next(raw) - delta = json.loads(output["ui"]["batch_image_loader_delta"][0]) + def test_backend_delta_dont_consume_reports_false_for_complete_group(self): + entries = [item("A"), item("B"), item("C")] + raw = self.state(entries, images_per_execution=3, dont_consume=True) + delta = json.loads( + conveyor.ImageConveyor().load_next(raw)["ui"]["batch_image_loader_delta"][0] + ) self.assertFalse(delta["consumed"]) - self.assertEqual(0, output["result"][4]) + self.assertEqual(["A", "B", "C"], [entry["id"] for entry in delta["processed_items"]]) + + def test_is_changed_hashes_all_selected_images_and_order_only(self): + a, b, c, d = item("A"), item("B"), item("C"), item("D") + entries = [a, b, c, d] + self.materialize(a, b"a") + b_path = self.materialize(b, b"b") + c_path = self.materialize(c, b"c") + d_path = self.materialize(d, b"d") + + raw = self.state(entries, images_per_execution=3) + baseline = conveyor.ImageConveyor.IS_CHANGED(raw) + + b_path.write_bytes(b"b-changed") + changed_b = conveyor.ImageConveyor.IS_CHANGED(raw) + self.assertNotEqual(baseline, changed_b) + b_path.write_bytes(b"b") + + c_path.write_bytes(b"c-changed") + changed_c = conveyor.ImageConveyor.IS_CHANGED(raw) + self.assertNotEqual(baseline, changed_c) + c_path.write_bytes(b"c") + + swapped = self.state([a, c, b, d], images_per_execution=3) + self.assertNotEqual(baseline, conveyor.ImageConveyor.IS_CHANGED(swapped)) + + count_two = self.state(entries, images_per_execution=2) + self.assertNotEqual(baseline, conveyor.ImageConveyor.IS_CHANGED(count_two)) + + d_path.write_bytes(b"unrelated-changed") + self.assertEqual(baseline, conveyor.ImageConveyor.IS_CHANGED(raw)) + + def test_is_changed_changes_when_first_selected_image_changes(self): + a, b, c = item("A"), item("B"), item("C") + a_path = self.materialize(a, b"a") + self.materialize(b, b"b") + self.materialize(c, b"c") + raw = self.state([a, b, c], images_per_execution=3) + baseline = conveyor.ImageConveyor.IS_CHANGED(raw) + a_path.write_bytes(b"a-changed") + self.assertNotEqual(baseline, conveyor.ImageConveyor.IS_CHANGED(raw)) + + def test_explicit_reservation_hash_is_independent_of_later_live_order(self): + a, b, c = item("A"), item("B"), item("C") + self.materialize_all([a, b, c]) + payload = grouped_payload(a, b, c) + first = conveyor.ImageConveyor.IS_CHANGED( + self.state([a, b, c], images_per_execution=3), queue_item_json=payload + ) + reordered = conveyor.ImageConveyor.IS_CHANGED( + self.state([c, b, a], images_per_execution=3), queue_item_json=payload + ) + self.assertEqual(first, reordered) + + def test_validate_inputs_accepts_complete_group_when_all_files_exist(self): + entries = [item("A"), item("B"), item("C")] + self.materialize_all(entries) + raw = self.state(entries, images_per_execution=3) + self.assertIs(True, conveyor.ImageConveyor.VALIDATE_INPUTS(raw)) + + def test_legacy_node_class_mapping_is_preserved(self): + self.assertIs(conveyor.ImageConveyor, conveyor.NODE_CLASS_MAPPINGS["SequentialBatchImageLoader"]) if __name__ == "__main__": From d3ab4e0ec1e223017eecf40e22a325edffa2b5a2 Mon Sep 17 00:00:00 2001 From: xmarre <54859656+xmarre@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:54:24 +0200 Subject: [PATCH 05/14] Test frontend queue group lifecycle --- tests/test_queue_groups.mjs | 227 ++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 tests/test_queue_groups.mjs diff --git a/tests/test_queue_groups.mjs b/tests/test_queue_groups.mjs new file mode 100644 index 0000000..2ba83de --- /dev/null +++ b/tests/test_queue_groups.mjs @@ -0,0 +1,227 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + calculateAutoQueueExtraExecutions, + completeExecutionGroupCount, + makeQueueReservationPayload, + markReservedGroupQueued, + normalizeImagesPerExecution, + processedItemIdsFromDelta, + queueReservationMembers, + selectExecutionGroup, + shouldReanchorGalleryResize +} from '../web/image_conveyor_math.mjs' + +function queueItems(ids, status = 'pending') { + return ids.map((id) => ({ + id, + annotated: `${id}.png [input]`, + status, + last_queued_at: 0, + last_processed_at: 0 + })) +} + +function reserveAndQueue(items, count, dontConsume = false, now = 100) { + const group = selectExecutionGroup(items, count, dontConsume) + const payload = group.length === normalizeImagesPerExecution(count) + ? makeQueueReservationPayload(group) + : null + if (payload) markReservedGroupQueued(items, payload, dontConsume, now) + return { group, payload } +} + +function applyProcessedIds(items, delta, now = 200) { + const ids = new Set(processedItemIdsFromDelta(delta)) + let changed = 0 + for (const item of items) { + if (!ids.has(item.id)) continue + if (delta.new_status === 'processed') item.status = 'processed' + item.last_processed_at = now + changed += 1 + } + return changed +} + +test('normalizes images-per-execution to the persisted 1-9 range', () => { + assert.equal(normalizeImagesPerExecution(0), 1) + assert.equal(normalizeImagesPerExecution(-4), 1) + assert.equal(normalizeImagesPerExecution('bad'), 1) + assert.equal(normalizeImagesPerExecution(10), 9) + for (let count = 1; count <= 9; count += 1) { + assert.equal(normalizeImagesPerExecution(count), count) + } +}) + +test('reserves the first complete pending group in Conveyor order', () => { + const items = queueItems(['A', 'B', 'C', 'D', 'E', 'F']) + assert.deepEqual( + selectExecutionGroup(items, 3).map((item) => item.id), + ['A', 'B', 'C'] + ) +}) + +test('afterQueued marks the whole reserved group queued with one timestamp', () => { + const items = queueItems(['A', 'B', 'C', 'D']) + const { payload } = reserveAndQueue(items, 3, false, 12345) + assert.deepEqual(queueReservationMembers(payload).map((item) => item.id), ['A', 'B', 'C']) + assert.deepEqual(items.map((item) => item.status), ['queued', 'queued', 'queued', 'pending']) + assert.deepEqual(items.slice(0, 3).map((item) => item.last_queued_at), [12345, 12345, 12345]) +}) + +test('next consuming reservation skips every already queued group member', () => { + const items = queueItems(['A', 'B', 'C', 'D', 'E', 'F']) + reserveAndQueue(items, 3, false, 1) + const second = selectExecutionGroup(items, 3, false) + assert.deepEqual(second.map((item) => item.id), ['D', 'E', 'F']) +}) + +test('three prompt reservations over nine items have zero overlap', () => { + const items = queueItems(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']) + const reservations = [] + for (let prompt = 0; prompt < 3; prompt += 1) { + const { group } = reserveAndQueue(items, 3, false, prompt + 1) + reservations.push(group.map((item) => item.id)) + } + assert.deepEqual(reservations, [ + ['A', 'B', 'C'], + ['D', 'E', 'F'], + ['G', 'H', 'I'] + ]) + assert.equal(new Set(reservations.flat()).size, 9) +}) + +test("Don't consume leaves statuses unchanged and repeats the same group", () => { + const items = queueItems(['A', 'B', 'C', 'D']) + const first = reserveAndQueue(items, 3, true, 1) + const second = reserveAndQueue(items, 3, true, 2) + assert.deepEqual(first.group.map((item) => item.id), ['A', 'B', 'C']) + assert.deepEqual(second.group.map((item) => item.id), ['A', 'B', 'C']) + assert.deepEqual(items.map((item) => item.status), ['pending', 'pending', 'pending', 'pending']) + assert.deepEqual(items.map((item) => item.last_queued_at), [0, 0, 0, 0]) +}) + +test("Don't consume uses processed fallback only when no pending or queued items exist", () => { + const items = queueItems(['A', 'B', 'C'], 'processed') + assert.deepEqual( + selectExecutionGroup(items, 2, true).map((item) => item.id), + ['A', 'B'] + ) + + items[0].status = 'pending' + assert.deepEqual( + selectExecutionGroup(items, 2, true).map((item) => item.id), + ['A'] + ) +}) + +test('group payload preserves first-item compatibility fields and exact ordering', () => { + const items = queueItems(['A', 'B', 'C']) + const payload = makeQueueReservationPayload(items) + assert.equal(payload.id, 'A') + assert.equal(payload.annotated, 'A.png [input]') + assert.deepEqual(payload.items, [ + { id: 'A', annotated: 'A.png [input]' }, + { id: 'B', annotated: 'B.png [input]' }, + { id: 'C', annotated: 'C.png [input]' } + ]) + assert.deepEqual(queueReservationMembers(payload), payload.items) +}) + +test('single-item payload remains in legacy id/annotated shape', () => { + const [item] = queueItems(['A']) + const payload = makeQueueReservationPayload([item]) + assert.deepEqual(payload, { id: 'A', annotated: 'A.png [input]' }) + assert.deepEqual(queueReservationMembers(payload), [payload]) +}) + +test('duplicate physical paths remain independent reservation members by queue id', () => { + const items = [ + { id: 'A1', annotated: 'same.png [input]', status: 'pending' }, + { id: 'A2', annotated: 'same.png [input]', status: 'pending' }, + { id: 'B', annotated: 'other.png [input]', status: 'pending' } + ] + const payload = makeQueueReservationPayload(selectExecutionGroup(items, 3)) + assert.deepEqual(queueReservationMembers(payload).map((item) => item.id), ['A1', 'A2', 'B']) +}) + +test('new grouped backend delta identifies every processed logical item', () => { + const delta = { + processed_item_id: 'A', + processed_items: [ + { id: 'A', annotated: 'A.png [input]' }, + { id: 'B', annotated: 'B.png [input]' }, + { id: 'C', annotated: 'C.png [input]' } + ], + new_status: 'processed', + consumed: true + } + assert.deepEqual(processedItemIdsFromDelta(delta), ['A', 'B', 'C']) + const items = queueItems(['A', 'B', 'C', 'D'], 'queued') + assert.equal(applyProcessedIds(items, delta, 88), 3) + assert.deepEqual(items.map((item) => item.status), ['processed', 'processed', 'processed', 'queued']) + assert.deepEqual(items.map((item) => item.last_processed_at), [88, 88, 88, 0]) +}) + +test('legacy singular backend delta remains supported', () => { + const delta = { processed_item_id: 'B', new_status: 'processed', consumed: true } + assert.deepEqual(processedItemIdsFromDelta(delta), ['B']) + const items = queueItems(['A', 'B'], 'queued') + applyProcessedIds(items, delta, 99) + assert.deepEqual(items.map((item) => item.status), ['queued', 'processed']) +}) + +test('consumed false backend delta produces zero frontend status mutations', () => { + const delta = { + processed_item_id: 'A', + processed_items: [{ id: 'A' }, { id: 'B' }], + new_status: 'processed', + consumed: false + } + const items = queueItems(['A', 'B'], 'pending') + assert.deepEqual(processedItemIdsFromDelta(delta), []) + assert.equal(applyProcessedIds(items, delta), 0) + assert.deepEqual(items.map((item) => item.status), ['pending', 'pending']) +}) + +test('auto queue counts only complete groups', () => { + assert.equal(completeExecutionGroupCount(7, 3), 2) + assert.equal(calculateAutoQueueExtraExecutions(7, 3, 1), 1) +}) + +test('auto queue count one preserves released arithmetic', () => { + assert.equal(completeExecutionGroupCount(7, 1), 7) + assert.equal(calculateAutoQueueExtraExecutions(7, 1, 1), 6) +}) + +test('auto queue respects an already requested batch count', () => { + assert.equal(calculateAutoQueueExtraExecutions(12, 3, 2), 2) + assert.equal(calculateAutoQueueExtraExecutions(6, 3, 2), 0) + assert.equal(calculateAutoQueueExtraExecutions(5, 3, 2), 0) +}) + +test('incomplete groups are never encoded as complete reservations', () => { + const items = queueItems(['A', 'B']) + const group = selectExecutionGroup(items, 3) + assert.deepEqual(group.map((item) => item.id), ['A', 'B']) + const payload = group.length === normalizeImagesPerExecution(3) + ? makeQueueReservationPayload(group) + : null + assert.equal(payload, null) +}) + +test('clear queued semantics release every member because group state is per item', () => { + const items = queueItems(['A', 'B', 'C', 'D']) + reserveAndQueue(items, 3, false, 10) + for (const item of items) if (item.status === 'queued') item.status = 'pending' + assert.deepEqual(items.map((item) => item.status), ['pending', 'pending', 'pending', 'pending']) +}) + +test('scrollbar-only viewport width changes do not trigger resize reanchoring', () => { + assert.equal(shouldReanchorGalleryResize(620, 620, 603, 618), false) +}) + +test('real widget resize still triggers identity reanchoring', () => { + assert.equal(shouldReanchorGalleryResize(620, 760, 603, 743), true) +}) From 08da116392bdfb6bd78435173abbfa47dcfb5d19 Mon Sep 17 00:00:00 2001 From: xmarre <54859656+xmarre@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:54:41 +0200 Subject: [PATCH 06/14] Run grouped queue tests in CI --- .github/workflows/tests.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e995be4..1f67e7c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@v4 with: persist-credentials: false - fetch-depth: 2 + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 @@ -32,10 +32,12 @@ jobs: run: python -m pip install --upgrade pillow - name: Run Python tests - run: python -m unittest discover -s tests -v + run: python -m unittest discover -s tests -p 'test_*.py' -v - - name: Run gallery math tests - run: node --test tests/test_gallery_math.mjs + - name: Run frontend pure-function tests + run: | + node --test tests/test_gallery_math.mjs + node --test tests/test_queue_groups.mjs - name: Check JavaScript syntax run: | @@ -46,4 +48,4 @@ jobs: run: python -m py_compile __init__.py image_conveyor.py image_conveyor_server.py - name: Check whitespace errors - run: git diff --check HEAD^ + run: git diff --check "$(git merge-base HEAD origin/main)"...HEAD From cbc4438dd06cc60591af7bb60dc3e96740881c02 Mon Sep 17 00:00:00 2001 From: xmarre <54859656+xmarre@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:55:25 +0200 Subject: [PATCH 07/14] Document multi-image execution groups --- README.md | 70 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 76cd352..0ffad0a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A sequential, visual image queue for ComfyUI with an integrated input-folder bro ## What it does -Image Conveyor keeps a visible queue inside the graph and loads one image per prompt execution. The node starts with two permanent browser tabs: +Image Conveyor keeps a visible queue inside the graph and returns one ordered Conveyor image per prompt execution by default. **Images per execution** can be set from 1 through 9 when one node needs to provide several distinct consecutive images during the same execution. The node starts with two permanent browser tabs: - **Conveyor** is the ordered execution queue. Items retain their pending, queued, and processed states. - **Input Folder** browses the current ComfyUI `input/` directory recursively and adds existing images to the queue without uploading, copying, renaming, or serializing the folder listing into the workflow. @@ -87,6 +87,7 @@ Search and filters change the browser presentation only. Applying a Conveyor sor - Tab changes, scrolling, searching, filtering, focus, and thumbnail-size changes do not serialize `state_json`. - Input Folder and local-folder browsing datasets are runtime-only and never enlarge workflow JSON. - Queue mutations still commit the compatible version-1 queue schema. +- Multi-image execution loads and hashes only the selected group, with a maximum of nine images. It does not scan image contents across the rest of the Conveyor. The backend exposes input-only routes for recursive listing, exact upload resolution, managed duplicate cleanup, and thumbnails. Relative paths are containment-checked against ComfyUI's actual input directory; traversal, absolute paths, and symlink escapes are rejected. @@ -98,7 +99,19 @@ Each Conveyor item has one of three states: - `queued` - `processed` -Queued prompt runs reserve pending items in order. Successful execution marks the selected item processed. If a prompt fails before the node executes, its item may remain queued; **Clear queued** releases those reservations. +**Images per execution** controls the size of one ordered execution group: + +- `1` is the default and retains the normal single-image queue behavior; +- `2` through `9` reserve that many distinct consecutive queue entries for one prompt execution; +- consuming mode advances by complete groups; +- **Don't consume** leaves the selected group reusable, so an unchanged queue returns the same ordered group again; +- an incomplete group fails validation instead of repeating an image or silently filling missing slots. + +Queued prompt runs reserve complete pending groups in Conveyor order. The complete reservation is frozen into the queued prompt, so later live reordering does not change the reference order for that already-queued execution. Successful consuming execution marks every reserved member processed. If a prompt fails before the node executes, its members may remain queued; **Clear queued** releases those individual reservations. + +For example, with `A B C D E F`, **Images per execution = 2**, and consuming enabled, three queued prompts reserve `A B`, `C D`, and `E F`. With **Don't consume** enabled, a queue `A B C D` and count `3` returns `A B C` again on the next unchanged execution. + +Repeated logical queue entries remain valid. Two different Conveyor entries that both reference the same physical `same.png` file can occupy separate output slots because reservation identity follows the queue-entry IDs. Available queue controls include: @@ -107,13 +120,32 @@ Available queue controls include: - clear queued reservations; - remove processed entries; - apply queue sorting; +- set **Images per execution** from 1 through 9; - enable **Auto queue all pending**; - enable **Don't consume**; - enable **Catch canvas drops**; - preview and clean exact duplicates from the legacy managed input folder. +**Auto queue all pending** counts complete execution groups. If seven images are pending and the group size is three, two complete prompts can be queued and the seventh image remains pending until enough images exist for another complete group. + Deleting a Conveyor card removes the queue entry. The Input Folder tab is a non-destructive source picker and does not delete files. +## Multi-image wiring + +All image outputs are independent ComfyUI `IMAGE` values. Image Conveyor does not stack, resize, pad, or otherwise force selected references to a common geometry. + +For a three-image execution group, wire: + +```text +Image Conveyor.image -> downstream image/reference input 1 +Image Conveyor.image_2 -> downstream image/reference input 2 +Image Conveyor.image_3 -> downstream image/reference input 3 +``` + +The existing `image` output is always selected image #1. `image_2` through `image_9` map to subsequent queue entries in the reserved order. Outputs above the configured count are inactive. + +MiniMax H3 Ref2VA is one practical use case: `image`, `image_2`, `image_3`, and so on can be connected to its separate reference-image inputs while Image Conveyor remains model-agnostic. + ## Canvas-wide drop capture Enable **Catch canvas drops** to route external image/folder drops from the graph canvas to a conveyor: @@ -128,20 +160,32 @@ JSON and workflow drops are left to ComfyUI. ## Outputs -The node exposes: +The node exposes these stable output slots: -- `image` -- `mask` -- `path` -- `index` -- `remaining_pending` -- `source_path` +| Slot | Output | Meaning | +| ---: | --- | --- | +| 0 | `image` | Selected image #1 | +| 1 | `mask` | Mask for selected image #1 | +| 2 | `path` | Annotated ComfyUI path for selected image #1 | +| 3 | `index` | Conveyor index for selected image #1 | +| 4 | `remaining_pending` | Pending queue entries remaining under the current consume mode | +| 5 | `source_path` | Best-effort source hint for selected image #1 | +| 6 | `image_2` | Selected image #2 when active | +| 7 | `image_3` | Selected image #3 when active | +| 8 | `image_4` | Selected image #4 when active | +| 9 | `image_5` | Selected image #5 when active | +| 10 | `image_6` | Selected image #6 when active | +| 11 | `image_7` | Selected image #7 when active | +| 12 | `image_8` | Selected image #8 when active | +| 13 | `image_9` | Selected image #9 when active | `path` is the annotated ComfyUI input path actually loaded. `source_path` is an optional best-effort source hint. Absolute native paths are reduced to filename-only before persistence so exported workflows do not leak arbitrary local paths. ## Compatibility -The node keeps the existing `ImageConveyor` class, the legacy `SequentialBatchImageLoader` alias, outputs, queue item shape, version-1 state normalization, and prompt reservation/execution delta behavior. Existing workflows load without recreating the node. +The original six output slots remain at indices `0` through `5` in their existing order. The eight additional `IMAGE` outputs are appended at indices `6` through `13`, so saved links to the original outputs keep their indices. + +The node keeps the existing `ImageConveyor` class, the legacy `SequentialBatchImageLoader` alias, version-1 state schema, legacy single-item queue reservation shape, and legacy singular execution-delta fields. Version-1 workflows that do not contain `images_per_execution` normalize to `1`. Group reservations and group deltas extend the existing payloads additively. The frontend uses ComfyUI's custom widget + DOMWidget integration and remains VueNodes-compatible. @@ -165,7 +209,11 @@ Restart ComfyUI after installation or update. ```bash python -m unittest discover -s tests -p 'test_*.py' -v node --test tests/test_gallery_math.mjs +node --test tests/test_queue_groups.mjs node --check web/image_conveyor.js +node --check web/image_conveyor_math.mjs +python -m py_compile __init__.py image_conveyor.py image_conveyor_server.py +git diff --check ``` -The Python suite covers duplicate resolution, stale metadata, canonical selection, managed duplicate cleanup and revalidation, concurrent uploads, index recovery, recursive listing, thumbnails, path containment, and queue compatibility. The JavaScript tests verify responsive gallery geometry, drag lifecycle behavior, fixed and removable tab state, directory-picker grouping, high-speed card reuse, and bounded virtualization for a 10,000-item collection. +The Python suite covers queue/group compatibility, reservation strictness, output mapping, cache identity, duplicate resolution, stale metadata, canonical selection, managed duplicate cleanup and revalidation, concurrent uploads, index recovery, recursive listing, thumbnails, and path containment. The JavaScript tests verify queue-time group reservation, non-overlap, Don't consume behavior, group-aware auto-queue math, backend-delta compatibility, scrollbar-preservation resize behavior, responsive gallery geometry, drag lifecycle behavior, fixed and removable tab state, directory-picker grouping, high-speed card reuse, and bounded virtualization for a 10,000-item collection. From 5722210f4329030391195057b4d921e47d721a8d Mon Sep 17 00:00:00 2001 From: xmarre <54859656+xmarre@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:56:56 +0200 Subject: [PATCH 08/14] Preserve grouped cache metadata identity --- image_conveyor.py | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/image_conveyor.py b/image_conveyor.py index 1b9e302..f911611 100644 --- a/image_conveyor.py +++ b/image_conveyor.py @@ -1,5 +1,6 @@ import hashlib import json +import math from typing import Any, Dict, List, Optional, Tuple import folder_paths @@ -16,10 +17,12 @@ def _deep_copy_json(value: Any) -> Any: def _normalize_images_per_execution(value: Any) -> int: try: - count = int(value) + number = float(value) except (TypeError, ValueError, OverflowError): return 1 - return max(1, min(_MAX_IMAGES_PER_EXECUTION, count)) + if not math.isfinite(number): + return 1 + return max(1, min(_MAX_IMAGES_PER_EXECUTION, int(number))) def _default_state() -> Dict[str, Any]: @@ -165,6 +168,7 @@ def _parse_queue_item(raw: Any) -> Optional[Dict[str, Any]]: raw_items = payload.get("items") if not isinstance(raw_items, list) or not raw_items: raise RuntimeError("Image Conveyor: queued image group reservation is invalid.") + items: List[Dict[str, str]] = [] seen_ids = set() for raw_item in raw_items: @@ -196,16 +200,7 @@ def _parse_queue_item(raw: Any) -> Optional[Dict[str, Any]]: def _get_runtime_source_path(ui_state: Dict[str, Any], item: Dict[str, Any]) -> str: - """ - Resolve the runtime source path for an item, preferring a UI-provided override when present. - - Parameters: - ui_state (dict): UI state that may contain a `source_paths` mapping of item IDs to source path overrides. - item (dict): Item dictionary containing at least an `"id"` key and an optional `"source_path"` fallback. - - Returns: - str: The trimmed source path from `ui_state["source_paths"][item["id"]]` if non-empty, otherwise the trimmed `item["source_path"]` (or an empty string if neither is set). - """ + """Resolve the runtime source path, preferring the UI-only source-path override.""" source_paths = ui_state.get("source_paths", {}) if isinstance(ui_state, dict) else {} if isinstance(source_paths, dict): source_path = str(source_paths.get(item["id"], "")).strip() @@ -246,6 +241,7 @@ def _select_group( f"Image Conveyor: queued image group contains {len(reserved_items)} images, " f"but this prompt requests {count}." ) + selected: List[Tuple[int, Dict[str, Any]]] = [] for reserved in reserved_items: index, item = _find_item_by_id(state, reserved["id"]) @@ -266,6 +262,7 @@ def _select_group( "Image Conveyor: a legacy single-image reservation cannot satisfy " f"{count} images per execution. Queue this prompt again." ) + index, item = _find_item_by_id(state, reservation["id"]) if item is not None: return [(index, item)] @@ -388,9 +385,7 @@ class ImageConveyor: del ui_state_json state = _normalize_state(state_json) if not state["items"]: - empty_identity = ( - f"{state_json}|images_per_execution={state['images_per_execution']}" - ) + empty_identity = f"{state_json}|images_per_execution={state['images_per_execution']}" return hashlib.sha256(empty_identity.encode("utf-8")).hexdigest() selected = _select_group( @@ -400,8 +395,8 @@ class ImageConveyor: hasher = hashlib.sha256() hasher.update(b"dont_consume=1" if state["dont_consume"] else b"dont_consume=0") hasher.update(f"|images_per_execution={state['images_per_execution']}".encode("utf-8")) - for slot, (_index, item) in enumerate(selected, start=1): - hasher.update(f"|slot={slot}|".encode("utf-8")) + for slot, (index, item) in enumerate(selected, start=1): + hasher.update(f"|slot={slot}|index={index}|".encode("utf-8")) hasher.update(item["id"].encode("utf-8")) hasher.update(b"|") hasher.update(item["annotated"].encode("utf-8")) @@ -442,10 +437,11 @@ class ImageConveyor: state, queue_item_json, allow_processed=dont_consume ) + loader = nodes.LoadImage() loaded_images: List[Any] = [] first_mask = None for slot, (_index, item) in enumerate(selected): - image, mask = nodes.LoadImage().load_image(item["annotated"]) + image, mask = loader.load_image(item["annotated"]) loaded_images.append(image) if slot == 0: first_mask = mask @@ -474,7 +470,9 @@ class ImageConveyor: "consumed": not dont_consume, } - additional_images = loaded_images[1:] + [None] * (_MAX_IMAGES_PER_EXECUTION - len(loaded_images)) + additional_images = loaded_images[1:] + [None] * ( + _MAX_IMAGES_PER_EXECUTION - len(loaded_images) + ) return { "result": ( loaded_images[0], From 7ec86ecba98b6598568c30ab4ec3e4df668d31c9 Mon Sep 17 00:00:00 2001 From: xmarre <54859656+xmarre@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:57:53 +0200 Subject: [PATCH 09/14] Align cache tests with metadata identity --- tests/test_image_conveyor_state.py | 178 ++++++++++++----------------- 1 file changed, 74 insertions(+), 104 deletions(-) diff --git a/tests/test_image_conveyor_state.py b/tests/test_image_conveyor_state.py index 27d4cec..acb0ce2 100644 --- a/tests/test_image_conveyor_state.py +++ b/tests/test_image_conveyor_state.py @@ -11,7 +11,6 @@ from unittest import mock FILE_PATHS = {} EXISTING_ANNOTATED = set() - fake_folder_paths = types.ModuleType("folder_paths") fake_folder_paths.get_annotated_filepath = lambda annotated: FILE_PATHS.get(annotated, annotated) fake_folder_paths.exists_annotated_filepath = lambda annotated: annotated in EXISTING_ANNOTATED @@ -52,8 +51,8 @@ def item(item_id, annotated=None, status="pending", source_path=""): } -def grouped_payload(*items): - members = [{"id": entry["id"], "annotated": entry["annotated"]} for entry in items] +def grouped_payload(*entries): + members = [{"id": entry["id"], "annotated": entry["annotated"]} for entry in entries] return json.dumps({ "id": members[0]["id"], "annotated": members[0]["annotated"], @@ -71,9 +70,9 @@ class ImageConveyorStateTest(unittest.TestCase): def tearDown(self): self.temp_dir.cleanup() - def state(self, items, *, legacy=False, **options): + def state(self, entries, *, legacy=False, **options): payload = conveyor._default_state() - payload["items"] = items + payload["items"] = entries payload.update(options) if legacy: payload.pop("images_per_execution", None) @@ -86,85 +85,69 @@ class ImageConveyorStateTest(unittest.TestCase): EXISTING_ANNOTATED.add(entry["annotated"]) return path - def materialize_all(self, items): - for index, entry in enumerate(items): + def materialize_all(self, entries): + for index, entry in enumerate(entries): self.materialize(entry, f"bytes-{entry['id']}-{index}".encode()) def test_legacy_v1_state_without_count_normalizes_to_one(self): - raw = self.state([item("one")], legacy=True) - state = conveyor._normalize_state(raw) + state = conveyor._normalize_state(self.state([item("one")], legacy=True)) self.assertEqual(1, state["version"]) self.assertEqual(1, state["images_per_execution"]) def test_images_per_execution_range_normalization(self): - cases = { - 0: 1, - -5: 1, - "bad": 1, - None: 1, - 10: 9, - 999: 9, - } - for raw_value, expected in cases.items(): - with self.subTest(raw_value=raw_value): - raw = self.state([], images_per_execution=raw_value) - self.assertEqual(expected, conveyor._normalize_state(raw)["images_per_execution"]) + cases = {0: 1, -5: 1, "bad": 1, None: 1, 3.5: 3, 10: 9, 999: 9} + for value, expected in cases.items(): + with self.subTest(value=value): + state = conveyor._normalize_state(self.state([], images_per_execution=value)) + self.assertEqual(expected, state["images_per_execution"]) for count in range(1, 10): with self.subTest(count=count): - raw = self.state([], images_per_execution=count) - self.assertEqual(count, conveyor._normalize_state(raw)["images_per_execution"]) + state = conveyor._normalize_state(self.state([], images_per_execution=count)) + self.assertEqual(count, state["images_per_execution"]) def test_existing_single_selection_semantics_remain(self): entries = [item("done", status="processed"), item("next"), item("queued", status="queued")] - state = conveyor._normalize_state(self.state(entries)) - index, selected = conveyor._select_item(state, "") - self.assertEqual(1, index) - self.assertEqual("next", selected["id"]) + index, selected = conveyor._select_item(conveyor._normalize_state(self.state(entries)), "") + self.assertEqual((1, "next"), (index, selected["id"])) def test_multi_selection_preserves_queue_order(self): entries = [item("A"), item("B"), item("C"), item("D")] - state = conveyor._normalize_state(self.state(entries, images_per_execution=3)) - selected = conveyor._select_group(state, "") + selected = conveyor._select_group( + conveyor._normalize_state(self.state(entries, images_per_execution=3)), "" + ) self.assertEqual(["A", "B", "C"], [entry["id"] for _, entry in selected]) def test_duplicate_physical_paths_remain_distinct_logical_slots(self): shared = "same.png [input]" entries = [item("A1", shared), item("A2", shared), item("B", "other.png [input]")] - state = conveyor._normalize_state(self.state(entries, images_per_execution=3)) - selected = conveyor._select_group(state, "") + selected = conveyor._select_group( + conveyor._normalize_state(self.state(entries, images_per_execution=3)), "" + ) self.assertEqual(["A1", "A2", "B"], [entry["id"] for _, entry in selected]) def test_dont_consume_selects_same_group_repeatedly(self): entries = [item("A"), item("B"), item("C"), item("D")] raw = self.state(entries, images_per_execution=3, dont_consume=True) - node = conveyor.ImageConveyor() - first = node.load_next(raw) - second = node.load_next(raw) - self.assertEqual(first["result"][0], second["result"][0]) - self.assertEqual(first["result"][6], second["result"][6]) - self.assertEqual(first["result"][7], second["result"][7]) + first = conveyor.ImageConveyor().load_next(raw) + second = conveyor.ImageConveyor().load_next(raw) + self.assertEqual(first["result"][:1] + first["result"][6:8], second["result"][:1] + second["result"][6:8]) self.assertFalse(json.loads(first["ui"]["batch_image_loader_delta"][0])["consumed"]) self.assertFalse(json.loads(second["ui"]["batch_image_loader_delta"][0])["consumed"]) def test_dont_consume_processed_fallback_uses_first_n(self): entries = [item("A", status="processed"), item("B", status="processed"), item("C", status="processed")] - state = conveyor._normalize_state( - self.state(entries, images_per_execution=2, dont_consume=True) - ) + state = conveyor._normalize_state(self.state(entries, images_per_execution=2, dont_consume=True)) selected = conveyor._select_group(state, "", allow_processed=True) self.assertEqual(["A", "B"], [entry["id"] for _, entry in selected]) def test_dont_consume_does_not_fill_partial_eligible_group_with_processed(self): entries = [item("A"), item("B", status="processed"), item("C", status="processed")] - state = conveyor._normalize_state( - self.state(entries, images_per_execution=2, dont_consume=True) - ) + state = conveyor._normalize_state(self.state(entries, images_per_execution=2, dont_consume=True)) with self.assertRaisesRegex(RuntimeError, "2 images per execution requested, but only 1 eligible"): conveyor._select_group(state, "", allow_processed=True) def test_insufficient_group_has_clear_validation_error(self): - entries = [item("A"), item("B")] - raw = self.state(entries, images_per_execution=3) + raw = self.state([item("A"), item("B")], images_per_execution=3) result = conveyor.ImageConveyor.VALIDATE_INPUTS(raw) self.assertIn("3 images per execution requested", result) self.assertIn("only 2 eligible", result) @@ -173,8 +156,7 @@ class ImageConveyorStateTest(unittest.TestCase): entries = [item("A"), item("B"), item("C")] self.materialize(entries[0]) self.materialize(entries[2]) - raw = self.state(entries, images_per_execution=3) - result = conveyor.ImageConveyor.VALIDATE_INPUTS(raw) + result = conveyor.ImageConveyor.VALIDATE_INPUTS(self.state(entries, images_per_execution=3)) self.assertEqual("Image Conveyor: missing file 'B.png [input]'.", result) def test_legacy_queue_payload_count_one_preserves_id_selection(self): @@ -184,8 +166,7 @@ class ImageConveyorStateTest(unittest.TestCase): index, selected = conveyor._select_item( state, json.dumps({"id": "second", "annotated": shared}) ) - self.assertEqual(1, index) - self.assertEqual("second", selected["id"]) + self.assertEqual((1, "second"), (index, selected["id"])) def test_legacy_payload_cannot_invent_multi_image_group(self): entries = [item("A"), item("B"), item("C")] @@ -218,9 +199,8 @@ class ImageConveyorStateTest(unittest.TestCase): def test_grouped_reservation_order_survives_live_reorder(self): a, b, c = item("A"), item("B"), item("C") - payload = grouped_payload(a, b, c) state = conveyor._normalize_state(self.state([c, b, a], images_per_execution=3)) - selected = conveyor._select_group(state, payload) + selected = conveyor._select_group(state, grouped_payload(a, b, c)) self.assertEqual(["A", "B", "C"], [entry["id"] for _, entry in selected]) def test_output_layout_preserves_first_six_slots_and_appends_images(self): @@ -242,8 +222,9 @@ class ImageConveyorStateTest(unittest.TestCase): def test_result_mapping_and_inactive_image_outputs(self): entries = [item("A"), item("B"), item("C"), item("D")] - raw = self.state(entries, images_per_execution=3) - result = conveyor.ImageConveyor().load_next(raw)["result"] + result = conveyor.ImageConveyor().load_next( + self.state(entries, images_per_execution=3) + )["result"] self.assertEqual(14, len(result)) self.assertEqual("image:A.png [input]", result[0]) self.assertEqual("image:B.png [input]", result[6]) @@ -256,13 +237,14 @@ class ImageConveyorStateTest(unittest.TestCase): def test_metadata_outputs_describe_first_selected_item(self): a, b, c = item("A", source_path="persisted/A.png"), item("B"), item("C") - raw = self.state([a, b, c], images_per_execution=3) ui_state = json.dumps({ "version": 1, "selected_ids": [], "source_paths": {"A": "runtime/A.png"}, }) - result = conveyor.ImageConveyor().load_next(raw, ui_state)["result"] + result = conveyor.ImageConveyor().load_next( + self.state([a, b, c], images_per_execution=3), ui_state + )["result"] self.assertEqual("mask:A.png [input]", result[1]) self.assertEqual("A.png [input]", result[2]) self.assertEqual(1, result[3]) @@ -270,99 +252,87 @@ class ImageConveyorStateTest(unittest.TestCase): def test_remaining_pending_is_group_aware_when_consuming(self): entries = [item(str(index)) for index in range(10)] - raw = self.state(entries, images_per_execution=3) - result = conveyor.ImageConveyor().load_next(raw)["result"] + result = conveyor.ImageConveyor().load_next( + self.state(entries, images_per_execution=3) + )["result"] self.assertEqual(7, result[4]) def test_remaining_pending_is_unchanged_when_not_consuming(self): entries = [item("A"), item("B"), item("C"), item("D")] - raw = self.state(entries, images_per_execution=3, dont_consume=True) - result = conveyor.ImageConveyor().load_next(raw)["result"] + result = conveyor.ImageConveyor().load_next( + self.state(entries, images_per_execution=3, dont_consume=True) + )["result"] self.assertEqual(4, result[4]) def test_backend_delta_contains_complete_ordered_group_and_legacy_fields(self): entries = [item("A"), item("B"), item("C")] - raw = self.state(entries, images_per_execution=3) - output = conveyor.ImageConveyor().load_next(raw) + output = conveyor.ImageConveyor().load_next(self.state(entries, images_per_execution=3)) delta = json.loads(output["ui"]["batch_image_loader_delta"][0]) self.assertEqual("A", delta["processed_item_id"]) self.assertEqual("A.png [input]", delta["processed_annotated"]) - self.assertEqual( - ["A", "B", "C"], - [entry["id"] for entry in delta["processed_items"]], - ) + self.assertEqual(["A", "B", "C"], [entry["id"] for entry in delta["processed_items"]]) self.assertEqual("processed", delta["new_status"]) self.assertTrue(delta["consumed"]) def test_backend_delta_dont_consume_reports_false_for_complete_group(self): entries = [item("A"), item("B"), item("C")] - raw = self.state(entries, images_per_execution=3, dont_consume=True) - delta = json.loads( - conveyor.ImageConveyor().load_next(raw)["ui"]["batch_image_loader_delta"][0] + output = conveyor.ImageConveyor().load_next( + self.state(entries, images_per_execution=3, dont_consume=True) ) + delta = json.loads(output["ui"]["batch_image_loader_delta"][0]) self.assertFalse(delta["consumed"]) self.assertEqual(["A", "B", "C"], [entry["id"] for entry in delta["processed_items"]]) - def test_is_changed_hashes_all_selected_images_and_order_only(self): + def test_is_changed_hashes_selected_contents_order_count_and_metadata_index(self): a, b, c, d = item("A"), item("B"), item("C"), item("D") entries = [a, b, c, d] - self.materialize(a, b"a") + a_path = self.materialize(a, b"a") b_path = self.materialize(b, b"b") c_path = self.materialize(c, b"c") d_path = self.materialize(d, b"d") - raw = self.state(entries, images_per_execution=3) baseline = conveyor.ImageConveyor.IS_CHANGED(raw) + a_path.write_bytes(b"a-changed") + self.assertNotEqual(baseline, conveyor.ImageConveyor.IS_CHANGED(raw)) + a_path.write_bytes(b"a") + b_path.write_bytes(b"b-changed") - changed_b = conveyor.ImageConveyor.IS_CHANGED(raw) - self.assertNotEqual(baseline, changed_b) + self.assertNotEqual(baseline, conveyor.ImageConveyor.IS_CHANGED(raw)) b_path.write_bytes(b"b") c_path.write_bytes(b"c-changed") - changed_c = conveyor.ImageConveyor.IS_CHANGED(raw) - self.assertNotEqual(baseline, changed_c) + self.assertNotEqual(baseline, conveyor.ImageConveyor.IS_CHANGED(raw)) c_path.write_bytes(b"c") - swapped = self.state([a, c, b, d], images_per_execution=3) - self.assertNotEqual(baseline, conveyor.ImageConveyor.IS_CHANGED(swapped)) + self.assertNotEqual( + baseline, + conveyor.ImageConveyor.IS_CHANGED(self.state([a, c, b, d], images_per_execution=3)), + ) + self.assertNotEqual( + baseline, + conveyor.ImageConveyor.IS_CHANGED(self.state(entries, images_per_execution=2)), + ) - count_two = self.state(entries, images_per_execution=2) - self.assertNotEqual(baseline, conveyor.ImageConveyor.IS_CHANGED(count_two)) + shifted = self.state([item("done", status="processed"), a, b, c, d], images_per_execution=3) + self.assertNotEqual(baseline, conveyor.ImageConveyor.IS_CHANGED(shifted)) d_path.write_bytes(b"unrelated-changed") self.assertEqual(baseline, conveyor.ImageConveyor.IS_CHANGED(raw)) - def test_is_changed_changes_when_first_selected_image_changes(self): - a, b, c = item("A"), item("B"), item("C") - a_path = self.materialize(a, b"a") - self.materialize(b, b"b") - self.materialize(c, b"c") - raw = self.state([a, b, c], images_per_execution=3) - baseline = conveyor.ImageConveyor.IS_CHANGED(raw) - a_path.write_bytes(b"a-changed") - self.assertNotEqual(baseline, conveyor.ImageConveyor.IS_CHANGED(raw)) - - def test_explicit_reservation_hash_is_independent_of_later_live_order(self): - a, b, c = item("A"), item("B"), item("C") - self.materialize_all([a, b, c]) - payload = grouped_payload(a, b, c) - first = conveyor.ImageConveyor.IS_CHANGED( - self.state([a, b, c], images_per_execution=3), queue_item_json=payload - ) - reordered = conveyor.ImageConveyor.IS_CHANGED( - self.state([c, b, a], images_per_execution=3), queue_item_json=payload - ) - self.assertEqual(first, reordered) - def test_validate_inputs_accepts_complete_group_when_all_files_exist(self): entries = [item("A"), item("B"), item("C")] self.materialize_all(entries) - raw = self.state(entries, images_per_execution=3) - self.assertIs(True, conveyor.ImageConveyor.VALIDATE_INPUTS(raw)) + self.assertIs( + True, + conveyor.ImageConveyor.VALIDATE_INPUTS(self.state(entries, images_per_execution=3)), + ) def test_legacy_node_class_mapping_is_preserved(self): - self.assertIs(conveyor.ImageConveyor, conveyor.NODE_CLASS_MAPPINGS["SequentialBatchImageLoader"]) + self.assertIs( + conveyor.ImageConveyor, + conveyor.NODE_CLASS_MAPPINGS["SequentialBatchImageLoader"], + ) if __name__ == "__main__": From 824b11c3b79e09a6bea14977f8e2c5c8e3cedfea Mon Sep 17 00:00:00 2001 From: xmarre <54859656+xmarre@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:24:38 +0200 Subject: [PATCH 10/14] Fix CI comparison base selection --- .github/workflows/tests.yml | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1f67e7c..7c39b25 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -48,4 +48,29 @@ jobs: run: python -m py_compile __init__.py image_conveyor.py image_conveyor_server.py - name: Check whitespace errors - run: git diff --check "$(git merge-base HEAD origin/main)"...HEAD + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + BEFORE_SHA: ${{ github.event.before }} + run: | + set -euo pipefail + + if [[ "$EVENT_NAME" == "pull_request" && -n "$BASE_REF" ]]; then + git fetch --no-tags origin "$BASE_REF:refs/remotes/origin/$BASE_REF" + compare_ref="origin/$BASE_REF" + elif [[ "$EVENT_NAME" == "push" && -n "$BEFORE_SHA" && "$BEFORE_SHA" != "0000000000000000000000000000000000000000" ]]; then + if ! git cat-file -e "$BEFORE_SHA^{commit}" 2>/dev/null; then + git fetch --no-tags origin "$BEFORE_SHA" + fi + compare_ref="$BEFORE_SHA" + elif git rev-parse --verify origin/main >/dev/null 2>&1; then + compare_ref="origin/main" + elif git rev-parse --verify HEAD^ >/dev/null 2>&1; then + compare_ref="HEAD^" + else + compare_ref="HEAD" + fi + + merge_base="$(git merge-base HEAD "$compare_ref")" + git diff --check "$merge_base"...HEAD From a445232097b9c4e36f8ed6fd5eedc67418576ee4 Mon Sep 17 00:00:00 2001 From: xmarre <54859656+xmarre@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:25:29 +0200 Subject: [PATCH 11/14] Preserve validation errors through cache checks --- image_conveyor.py | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/image_conveyor.py b/image_conveyor.py index f911611..ed653b3 100644 --- a/image_conveyor.py +++ b/image_conveyor.py @@ -305,6 +305,12 @@ def _select_item( return _select_group(single_state, queue_item_json, allow_processed=allow_processed)[0] +def _unresolved_change_hash(state: Dict[str, Any], reason: str) -> str: + """Return a stable cache sentinel while input validation reports the real error.""" + identity = f"unresolved|images_per_execution={state['images_per_execution']}|{reason}" + return hashlib.sha256(identity.encode("utf-8")).hexdigest() + + class ImageConveyor: CATEGORY = "image" FUNCTION = "load_next" @@ -388,9 +394,12 @@ class ImageConveyor: empty_identity = f"{state_json}|images_per_execution={state['images_per_execution']}" return hashlib.sha256(empty_identity.encode("utf-8")).hexdigest() - selected = _select_group( - state, queue_item_json, allow_processed=state["dont_consume"] - ) + try: + selected = _select_group( + state, queue_item_json, allow_processed=state["dont_consume"] + ) + except RuntimeError as exc: + return _unresolved_change_hash(state, f"selection|{exc}") hasher = hashlib.sha256() hasher.update(b"dont_consume=1" if state["dont_consume"] else b"dont_consume=0") @@ -400,13 +409,19 @@ class ImageConveyor: hasher.update(item["id"].encode("utf-8")) hasher.update(b"|") hasher.update(item["annotated"].encode("utf-8")) - path = folder_paths.get_annotated_filepath(item["annotated"]) - with open(path, "rb") as handle: - while True: - chunk = handle.read(1024 * 1024) - if not chunk: - break - hasher.update(chunk) + try: + path = folder_paths.get_annotated_filepath(item["annotated"]) + with open(path, "rb") as handle: + while True: + chunk = handle.read(1024 * 1024) + if not chunk: + break + hasher.update(chunk) + except FileNotFoundError: + return _unresolved_change_hash( + state, + f"missing|slot={slot}|index={index}|id={item['id']}|annotated={item['annotated']}", + ) return hasher.hexdigest() @classmethod From a00837a606632758f3bcc912894b12b7a13b8cb7 Mon Sep 17 00:00:00 2001 From: xmarre <54859656+xmarre@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:25:50 +0200 Subject: [PATCH 12/14] Test cache sentinel validation paths --- tests/test_is_changed_validation.py | 103 ++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tests/test_is_changed_validation.py diff --git a/tests/test_is_changed_validation.py b/tests/test_is_changed_validation.py new file mode 100644 index 0000000..97d691d --- /dev/null +++ b/tests/test_is_changed_validation.py @@ -0,0 +1,103 @@ +import importlib.util +import json +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest import mock + + +FILE_PATHS = {} +EXISTING_ANNOTATED = set() + +fake_folder_paths = types.ModuleType("folder_paths") +fake_folder_paths.get_annotated_filepath = lambda annotated: FILE_PATHS.get(annotated, annotated) +fake_folder_paths.exists_annotated_filepath = lambda annotated: annotated in EXISTING_ANNOTATED + + +class FakeLoadImage: + def load_image(self, annotated): + return f"image:{annotated}", f"mask:{annotated}" + + +fake_nodes = types.ModuleType("nodes") +fake_nodes.LoadImage = FakeLoadImage + +MODULE_PATH = Path(__file__).resolve().parents[1] / "image_conveyor.py" +SPEC = importlib.util.spec_from_file_location("image_conveyor_validation_test", MODULE_PATH) +conveyor = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +with mock.patch.dict(sys.modules, {"folder_paths": fake_folder_paths, "nodes": fake_nodes}): + SPEC.loader.exec_module(conveyor) + + +def item(item_id): + return { + "id": item_id, + "annotated": f"{item_id}.png [input]", + "filename": f"{item_id}.png", + "subfolder": "", + "source_path": "", + "type": "input", + "status": "pending", + "added_at": 1, + "last_queued_at": 0, + "last_processed_at": 0, + } + + +def state(items, images_per_execution): + payload = conveyor._default_state() + payload["items"] = items + payload["images_per_execution"] = images_per_execution + return json.dumps(payload) + + +class IsChangedValidationTest(unittest.TestCase): + def setUp(self): + FILE_PATHS.clear() + EXISTING_ANNOTATED.clear() + self.temp_dir = tempfile.TemporaryDirectory() + + def tearDown(self): + self.temp_dir.cleanup() + + def materialize(self, entry, contents=b"image"): + path = Path(self.temp_dir.name) / f"{entry['id']}.img" + path.write_bytes(contents) + FILE_PATHS[entry["annotated"]] = str(path) + EXISTING_ANNOTATED.add(entry["annotated"]) + + def test_insufficient_group_returns_stable_sentinel_and_validation_message(self): + raw = state([item("A"), item("B")], 3) + + first = conveyor.ImageConveyor.IS_CHANGED(raw) + second = conveyor.ImageConveyor.IS_CHANGED(raw) + + self.assertEqual(first, second) + self.assertEqual(64, len(first)) + self.assertEqual( + "Image Conveyor: 3 images per execution requested, but only 2 eligible queue images are available.", + conveyor.ImageConveyor.VALIDATE_INPUTS(raw), + ) + + def test_missing_secondary_file_returns_stable_sentinel_and_validation_message(self): + a = item("A") + b = item("B") + self.materialize(a) + raw = state([a, b], 2) + + first = conveyor.ImageConveyor.IS_CHANGED(raw) + second = conveyor.ImageConveyor.IS_CHANGED(raw) + + self.assertEqual(first, second) + self.assertEqual(64, len(first)) + self.assertEqual( + "Image Conveyor: missing file 'B.png [input]'.", + conveyor.ImageConveyor.VALIDATE_INPUTS(raw), + ) + + +if __name__ == "__main__": + unittest.main() From d1c90d5f46d01c038d1f80f5947f4943e4ad16d9 Mon Sep 17 00:00:00 2001 From: xmarre <54859656+xmarre@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:52:48 +0200 Subject: [PATCH 13/14] Prepare v1.2.0 package metadata --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2da3ef3..d6b6615 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "image-conveyor" description = "A sequential drag-and-drop image queue node for ComfyUI." -version = "1.1.1" +version = "1.2.0" license = { file = "LICENSE" } dependencies = [] classifiers = [ From d111baea69e4706cdeae769e056183fad5601364 Mon Sep 17 00:00:00 2001 From: xmarre <54859656+xmarre@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:53:01 +0200 Subject: [PATCH 14/14] Add v1.2.0 release notes --- RELEASE_NOTES.md | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d5df0d0..bee27c2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,14 +1,33 @@ -# Image Conveyor v1.1.1 +# Image Conveyor v1.2.0 -Packaging fix for the v1.1 release. +Adds deterministic multi-image execution groups while preserving the existing single-image workflow and output indices. -## Packaging +## Multi-image execution groups -- Adds the missing `.comfyignore` used by the Comfy Registry publisher. -- Excludes development-only `.github/` and `tests/` content from Comfy Registry archives. -- Keeps the Image Conveyor v1.1.0 functionality and saved-workflow compatibility unchanged. +- Adds **Images per execution** with a range of 1 through 9; the default remains 1. +- Appends `image_2` through `image_9` after the existing six outputs, so saved links to the original outputs keep their indices. +- Reserves complete ordered image groups at queue time so rapidly queued prompts do not overlap. +- Keeps **Don't consume** reusable: unchanged queues return the same ordered group again. +- Treats repeated queue entries that reference the same physical file as distinct logical images by queue-entry ID. +- Rejects incomplete or stale reservations with readable validation errors instead of repeating/filling images. +- Loads each selected image independently without stacking, resizing, padding, or forcing matching geometry. +- Makes remaining-pending counts, auto-queue arithmetic, backend deltas, and cache identity group-aware. + +## Browser and scrolling + +- Fixes the remaining Conveyor/Input Folder scroll-position regression caused by scrollbar-induced `clientWidth` changes being mistaken for real node resizes. +- Keeps the independently stored scroll position for each browser tab while preserving identity re-anchoring for genuine node-width changes. + +## Compatibility + +- State schema remains version 1; workflows without `images_per_execution` normalize to 1. +- Existing output slots 0 through 5 remain unchanged. +- Legacy single-item queue reservations and singular backend delta fields remain supported. +- `ImageConveyor` and the legacy `SequentialBatchImageLoader` alias continue to use the same node class. ## Validation -- The v1.1 feature release passed 40 Python tests and 24 gallery tests before merge. -- The release workflow continues to validate Python/JavaScript syntax and whitespace before publishing a GitHub release. +- 64 Python tests pass. +- 24 gallery/interaction JavaScript tests pass. +- 20 multi-image/group JavaScript tests pass. +- JavaScript syntax checks, Python compilation, and whitespace checks pass.