Files
ComfyAssets-ComfyUI_PromptM…/web/js/sse-stream.js
T
Vito d11b354ed6 fix(tagging): legacy pages tag each image, not its prompt (#379, PR 2) (#394)
* fix(tagging): Tag output images tags each image, not its prompt (#379, PR 1)

The native "Tag output images" job now runs the image-tags loop over every
still image the output folders list, linked or not: each image gets its own
model tags (at most "Suggestions per image") in its image tag set, and its
bytes are marked auto-tagged in a new media_image_autotags table. No prompt
tag or prompt text is written.

- database/autotag_schema.py: one forward-only WITHOUT ROWID table, optional
  in old backups, refused without the annotation group.
- add_image_tags/replace_image_tags take mark_autotagged; autotag_state and
  autotagged_locators read the marks through the one current-record rule.
- "Skip already tagged" skips images whose current bytes are marked, counted
  in a stat-only pre-pass; hand tags do not count.
- The review counts images and estimates the time (WD14 on CPU or CUDA).
- System > Auto-tagging review rows and copy, D134 and the Auto-tagging
  contract follow.

Part of #379

* fix(tagging): end the batch job after its cleanup and report pre-pass progress (#379)

The job read as ended while it still held the model gate, so a quick
restart could be refused with model-busy. Pre-pass chunks now publish
progress, and a cancel between chunks keeps exact counts.

Part of #379

* fix(tagging): measured GPU rate, and an ended image-tags job shows no time left (#379)

The live check measured WD14 on CUDA at 0.167 s per image (100 images in
16.7 s with the model load), so the review estimate uses 0.17 s. A finished
or cancelled Auto-tag/Add tags job kept "Processing file N of N · about X
min left"; it now ends with "<verb> <state> · N files processed".

Part of #379

* fix(tagging): legacy pages tag each image, not its prompt (#379, PR 2)

The admin and gallery batch starts the shared Tag output images job
("Tag output images (legacy page)") and relays its progress; closing the
window cancels it. Review asks per image with Skip first and applies by
image and sha256; apply refuses a prompt_id body. The Tag image dialog
marks an image auto-tagged only after Generate suggestions on that
capture. A second legacy batch (also one that wins the start race)
answers JSON 409, and both pages now show that sentence.

* test(tagging): gallery review units apply by image; record each progress change

The gallery review unit tests still pinned the removed prompt_id apply.
The legacy batch spec read the progress text once per observer callback,
so a phase frame and the first image frame landing in one task lost the
phase (WebKit); it now records every mutation record.
2026-10-27 22:40:57 +00:00

155 lines
6.0 KiB
JavaScript

/**
* Server-sent events over fetch.
*
* EventSource can only GET. The auto-tag download/start and thumbnail
* generation endpoints are POST (they have side effects), so their progress
* streams are read with fetch and a small parser. `connect()` returns an
* object with the EventSource surface the pages already use: onmessage,
* onerror, onclose, readyState, close(), and addEventListener(name, fn) for
* named `event:` frames.
*
* Loadable in the browser (window.SseStream) and in Node for tests.
*/
(function (root) {
"use strict";
const CONNECTING = 0;
const OPEN = 1;
const CLOSED = 2;
/**
* Parse complete events out of a buffer of SSE text.
* @param {string} buffer
* @returns {{events: Array<{data: string, event?: string}>, rest: string}} parsed
* events and the trailing, still incomplete block
*/
function parseSseChunk(buffer) {
const normalized = buffer.replace(/\r\n/g, "\n");
const blocks = normalized.split("\n\n");
const rest = blocks.pop();
const events = [];
for (const block of blocks) {
const event = parseBlock(block);
if (event) events.push(event);
}
return { events, rest };
}
function parseBlock(block) {
const data = [];
let name;
for (const line of block.split("\n")) {
if (line === "" || line.startsWith(":")) continue;
const colon = line.indexOf(":");
const field = colon === -1 ? line : line.slice(0, colon);
let value = colon === -1 ? "" : line.slice(colon + 1);
if (value.startsWith(" ")) value = value.slice(1);
if (field === "data") data.push(value);
else if (field === "event") name = value;
}
if (data.length === 0) return null;
const event = { data: data.join("\n") };
if (name !== undefined) event.event = name;
return event;
}
/**
* @param {string} url
* @param {{method?: string, body?: string|FormData, fetchImpl?: typeof fetch}} [options]
* @returns {{onmessage: Function|null, onerror: Function|null, onclose: Function|null,
* readyState: number, close: Function, CONNECTING: number, OPEN: number, CLOSED: number}}
*/
// A non-2xx answer as an Error carrying its status and, when the body is
// the API's JSON {error}, that sentence as serverMessage for the page.
async function httpError(response) {
const error = new Error(`HTTP ${response.status}`);
error.status = response.status;
try {
const data = await response.json();
if (data && typeof data.error === "string" && data.error) error.serverMessage = data.error;
} catch (_) {
// Not JSON: the status alone.
}
return error;
}
function connect(url, { method = "GET", body, fetchImpl } = {}) {
const doFetch = fetchImpl || ((...args) => globalThis.fetch(...args));
const controller = new AbortController();
const listeners = new Map();
const source = {
onmessage: null,
onerror: null,
onclose: null,
readyState: CONNECTING,
CONNECTING,
OPEN,
CLOSED,
close() {
if (source.readyState === CLOSED) return;
source.readyState = CLOSED;
controller.abort();
},
/** Named `event:` frames reach listeners for that name; unnamed ones reach "message". */
addEventListener(name, listener) {
if (!listeners.has(name)) listeners.set(name, new Set());
listeners.get(name).add(listener);
},
removeEventListener(name, listener) {
const set = listeners.get(name);
if (set) set.delete(listener);
},
};
function deliver(event) {
if (source.onmessage) source.onmessage(event);
const set = listeners.get(event.event || "message");
if (!set) return;
for (const listener of Array.from(set)) {
if (source.readyState === CLOSED) return;
listener(event);
}
}
(async () => {
try {
const init = { method, headers: { Accept: "text/event-stream" }, signal: controller.signal };
if (body !== undefined) init.body = body;
const requestUrl = root.ApiClient ? root.ApiClient.resolveUrl(url) : url;
const response = await doFetch(requestUrl, init);
if (!response.ok) throw await httpError(response);
if (source.readyState === CLOSED) return;
source.readyState = OPEN;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (source.readyState === CLOSED) return;
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const { events, rest } = parseSseChunk(buffer);
buffer = rest;
for (const event of events) {
if (source.readyState === CLOSED) return;
deliver(event);
}
if (done) break;
}
source.readyState = CLOSED;
if (source.onclose) source.onclose();
} catch (error) {
if (source.readyState === CLOSED) return; // closed by the caller
source.readyState = CLOSED;
if (source.onerror) source.onerror(error);
}
})();
return source;
}
const api = { parseSseChunk, connect, CONNECTING, OPEN, CLOSED };
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.SseStream = api;
})(typeof window !== "undefined" ? window : globalThis);