Files
ComfyAssets-ComfyUI_PromptM…/web/js/image-helpers.js
T
Vito Sansevero 7f81aad4e5 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.
2026-10-27 12:41:39 -07: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);