* 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.
104 lines
4.8 KiB
JavaScript
104 lines
4.8 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const ImageHelpers = require('../../web/js/image-helpers.js');
|
|
function deferred() {
|
|
let resolve;
|
|
const promise = new Promise(yes => { resolve = yes; });
|
|
return { promise, resolve };
|
|
}
|
|
function fixture() {
|
|
const elements = new Map();
|
|
const requests = [];
|
|
const document = { addEventListener() {}, getElementById(id) {
|
|
if (!elements.has(id)) elements.set(id, {});
|
|
return elements.get(id);
|
|
} };
|
|
const context = vm.createContext({ document, window: {}, ImageHelpers,
|
|
escapeHtml: value => value, console: { error() {} },
|
|
ApiClient: { resolveUrl: value => value, fetchUrl: (url, options) => {
|
|
const request = deferred();
|
|
requests.push({ url, body: JSON.parse(options.body), ...request });
|
|
return request.promise;
|
|
} },
|
|
});
|
|
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
|
|
const manager = Object.create(Manager.prototype);
|
|
const notifications = [];
|
|
Object.assign(manager, { autoTagState: { reviewImages: [
|
|
{ id: 'digest', path: 'same.png', root_index: 1, url: '/same.png' },
|
|
{ id: 'other', path: 'next.png', root_index: 0, url: '/next.png' },
|
|
], reviewIndex: 0, currentTags: [], modelType: 'gguf' },
|
|
renderReviewTags() {}, showNotification: (...args) => notifications.push(args), hideModal() {}, loadImages() {},
|
|
});
|
|
const reply = (index, data) => requests[index].resolve({ json: async () => data });
|
|
return { manager, requests, reply, notifications };
|
|
}
|
|
const SHA = 'a'.repeat(64);
|
|
test('gallery review retains root identity and applies the tags to the image by its sha256 (#379)', async () => {
|
|
const { manager, requests, reply } = fixture();
|
|
const load = manager.loadNextReviewImage();
|
|
assert.equal(requests[0].body.root_index, 1);
|
|
assert.equal(requests[0].body.existing, 'merge');
|
|
reply(0, { success: true, prompt_id: 42, tags: ['smile'], sha256: SHA, image_tags: [] });
|
|
await load;
|
|
manager.loadNextReviewImage = () => {};
|
|
const apply = manager.applyReviewTags();
|
|
assert.equal(requests[1].url, '/prompt_manager/autotag/apply');
|
|
assert.deepEqual(requests[1].body, ImageHelpers.autotagApplyBody(
|
|
{ id: 'digest', path: 'same.png', root_index: 1 }, SHA, ['smile']));
|
|
assert.equal('prompt_id' in requests[1].body, false);
|
|
reply(1, { success: true });
|
|
await apply;
|
|
});
|
|
test('an unlinked image applies to the image like a linked one', async () => {
|
|
const { manager, requests, reply } = fixture();
|
|
const load = manager.loadNextReviewImage();
|
|
reply(0, { success: true, prompt_id: null, tags: ['smile'], sha256: SHA, image_tags: [] });
|
|
await load;
|
|
manager.loadNextReviewImage = () => {};
|
|
const apply = manager.applyReviewTags();
|
|
assert.equal(requests[1].body.sha256, SHA);
|
|
assert.deepEqual(requests[1].body.tags, ['smile']);
|
|
reply(1, { success: true });
|
|
await apply;
|
|
});
|
|
test('an answer without a captured sha256 never sends an apply', async () => {
|
|
const { manager, requests, reply } = fixture();
|
|
const load = manager.loadNextReviewImage();
|
|
reply(0, { success: true, prompt_id: 42, tags: ['smile'] });
|
|
await load;
|
|
manager.loadNextReviewImage = () => {};
|
|
await manager.applyReviewTags();
|
|
assert.equal(requests.filter(r => r.url === '/prompt_manager/autotag/apply').length, 0);
|
|
});
|
|
test('a failed apply keeps the current image available for retry', async () => {
|
|
const { manager, requests, reply } = fixture();
|
|
const load = manager.loadNextReviewImage();
|
|
reply(0, { success: true, prompt_id: 42, tags: ['smile'], sha256: SHA, image_tags: [] });
|
|
await load;
|
|
const apply = manager.applyReviewTags();
|
|
reply(1, { success: false, error: 'Try again' });
|
|
await apply;
|
|
assert.equal(manager.autoTagState.reviewIndex, 0);
|
|
assert.equal(requests.length, 2);
|
|
});
|
|
test('late generation responses cannot restore tags from a skipped or closed image', async () => {
|
|
const { manager, reply } = fixture();
|
|
const first = manager.loadNextReviewImage();
|
|
manager.autoTagState.reviewIndex = 1;
|
|
const second = manager.loadNextReviewImage();
|
|
reply(1, { success: true, prompt_id: 2, tags: ['current'] });
|
|
await second;
|
|
reply(0, { success: true, prompt_id: 1, tags: ['stale'] });
|
|
await first;
|
|
assert.deepEqual(Array.from(manager.autoTagState.currentTags), ['current']);
|
|
const third = manager.loadNextReviewImage();
|
|
manager.cancelReview();
|
|
reply(2, { success: true, prompt_id: 2, tags: ['closed'] });
|
|
await third;
|
|
assert.equal(manager.autoTagState.currentTags.length, 0);
|
|
});
|