Files
ComfyAssets-ComfyUI_PromptM…/web/js/image-helpers.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

163 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Pure helpers for image entries rendered by the admin and gallery pages.
*
* Image rows come from the database (`width`, `height`, `file_size` are stored
* from a POST body and may be strings) or from the output-folder scan (`id` is
* a path digest, `path` is the file). Everything these helpers return for HTML
* is already escaped, so callers can drop it into text or a quoted attribute.
*
* Loadable in the browser (window.ImageHelpers) and in Node for tests.
*/
(function (root) {
"use strict";
const escapeHtml =
typeof module !== "undefined" && module.exports
? require("./html-escape.js").escapeHtml
: root.escapeHtml;
const UNKNOWN_SIZE = "Unknown size";
/**
* "640×480 | 1.2 MB" for a card body or a viewer caption, escaped.
*
* @param {{width?: unknown, height?: unknown, file_size?: unknown}|null|undefined} image
* @param {{formatFileSize?: (bytes: number) => string, separator?: string}} [options]
* @returns {string} escaped text; "Unknown size" when a dimension is missing
*/
function formatImageCaption(image, { formatFileSize, separator = " | " } = {}) {
const entry = image || {};
const parts = [];
if (entry.width && entry.height) {
parts.push(`${escapeHtml(entry.width)}×${escapeHtml(entry.height)}`);
} else {
parts.push(UNKNOWN_SIZE);
}
const bytes = Number(entry.file_size);
if (formatFileSize && Number.isFinite(bytes) && bytes > 0) {
parts.push(escapeHtml(formatFileSize(bytes)));
}
return parts.join(escapeHtml(separator));
}
/**
* A complete standalone page showing a workflow as pretty-printed JSON.
* Written into a blank same-origin window, so the JSON must not be able
* to close the <pre> and inject markup.
*
* @param {unknown} workflow
* @returns {string} HTML document
*/
function workflowDocumentHtml(workflow) {
const json = escapeHtml(JSON.stringify(workflow, null, 2));
return [
"<!DOCTYPE html>",
"<html>",
"<head><meta charset=\"utf-8\"><title>ComfyUI Workflow Data</title></head>",
"<body style=\"background: #111; color: #fff; font-family: monospace; padding: 20px;\">",
"<h2>ComfyUI Workflow JSON</h2>",
`<pre style="background: #222; padding: 15px; border-radius: 5px; overflow: auto;">${json}</pre>`,
"</body>",
"</html>",
].join("\n");
}
/**
* Body for POST /prompt_manager/autotag/single.
*
* Database rows (admin review, /images/all) have an integer id the server
* looks up; output-folder entries (gallery review, /images/output) have a
* path digest as id, which would 404, so those are addressed by path.
*
* @param {{id?: unknown, path?: unknown}} image
* `existing` (#379): "skip" answers `already_tagged` for an image a model
* already tagged; anything else is "merge" (always suggest).
*
* @param {{id?: unknown, path?: unknown}} image
* @param {{modelType: string, generalThreshold?: number, characterThreshold?: number, prompt?: string, existing?: string}} settings
* @returns {object} JSON-serialisable request body
*/
function autotagSingleBody(image, settings) {
const target = autotagTarget(image);
const existing = settings.existing === "skip" ? "skip" : "merge";
const modelType = String(settings.modelType || "");
const params = modelType.startsWith("wd14")
? {
general_threshold: settings.generalThreshold,
character_threshold: settings.characterThreshold,
}
: { prompt: settings.prompt };
return { ...target, model_type: modelType, existing, ...params };
}
/** The image a legacy single/apply body names: its row id, or its path (and root). */
function autotagTarget(image) {
const entry = image || {};
const target =
typeof entry.path === "string" && entry.path && !Number.isInteger(entry.id)
? { path: entry.path }
: { image_id: entry.id };
if ("path" in target && Number.isInteger(entry.root_index)) {
target.root_index = entry.root_index;
}
return target;
}
/**
* Body for POST /prompt_manager/autotag/apply (#379): the image, the
* `sha256` /autotag/single answered and the reviewed tags. The tags go to
* that image only, never to its prompt; an empty list marks it reviewed.
*/
function autotagApplyBody(image, sha256, tags) {
return { ...autotagTarget(image), sha256: String(sha256 || ""), tags: Array.isArray(tags) ? [...tags] : [] };
}
/** The legacy batch's completion toast (#379): images, never prompts. */
function autotagCompleteMessage(tagged) {
const count = Number.isSafeInteger(tagged) && tagged > 0 ? tagged : 0;
return `Auto tagging complete! Tagged ${count} ${count === 1 ? "image" : "images"}.`;
}
/**
* Next source to try after an <img> fails. Cards show the thumbnail first;
* when it fails (stale cache, half-written file) the original is worth one
* attempt. Returns null when there is nothing left to try.
* @param {string} currentSrc
* @param {DOMStringMap|object|undefined} dataset `data-original`, `data-fell-back`
*/
function fallbackImageSource(currentSrc, dataset) {
if (!dataset || dataset.fellBack) return null;
const original = dataset.original;
if (!original || original === currentSrc) return null;
return original;
}
function metadataLookupUrl(imageUrl) {
try {
const url = new URL(imageUrl, 'http://prompt-manager.invalid');
const marker = '/prompt_manager/images/serve/';
const index = url.pathname.indexOf(marker);
if (index < 0) return null;
const relativePath = decodeURIComponent(url.pathname.slice(index + marker.length));
const query = new URLSearchParams();
if (url.searchParams.has('root')) query.set('root', url.searchParams.get('root'));
const suffix = query.size ? `?${query}` : '';
return `/prompt_manager/images/prompt/${encodeURIComponent(relativePath)}${suffix}`;
} catch {
return null;
}
}
const api = {
formatImageCaption,
workflowDocumentHtml,
autotagSingleBody,
autotagApplyBody,
autotagCompleteMessage,
fallbackImageSource,
metadataLookupUrl,
};
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.ImageHelpers = api;
})(typeof window !== "undefined" ? window : globalThis);