* 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.
163 lines
6.7 KiB
JavaScript
163 lines
6.7 KiB
JavaScript
/**
|
||
* 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);
|