Files
DemonGatanjieu-Anomalous_Mo…/web/modules/image_keep.js
T
DemonGatanjieuandClaude Opus 5.5 74beded02c Combos kept from a picture show it and get a telling name
The card shows the output image with the main model's cover as a badge; the name is the
first LoRA (else the main model) and two telling tags; the toast after keeping shows the
picture and renames the combo on the spot, and the editor's title renames on a click and
opens the picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:06:32 +08:00

163 lines
8.6 KiB
JavaScript

/**
* Keeping an output image (the gallery's star, the image workbench) as one of three things:
* its whole workflow as a Workflow Recipe (recipe_save.js), its main model, LoRAs and
* positive prompt as a combo (搭配, a file of GET /anomalous/notebooks), or its prompts as a
* saved prompt (POST /anomalous/save_prompt_plan). The image's workflow is laid on a
* canvas of its own and read like a recipe saved from it (recipe_parser.js); its models are
* looked up among this computer's files. Each thing kept remembers its image, so
* GET /anomalous/kept_images can say what an image was kept as. No DOM.
*/
import { extractRecipeMetadata } from './recipe_parser.js';
import { categorizePromptSnippet, promptTitle } from './prompt_composition.js';
import { jsonResponse } from './ui_dom.js';
import { detachedGraph } from './detached_graph.js';
const MAIN_TYPES = ['checkpoints', 'unet', 'diffusion_models'];
// Tags nearly every picture has: a combo's name skips them so two combos read apart.
const COMMON_TAG = /^(?:\d+\s*(?:girls?|boys?|others?)|solo|solo focus|looking at viewer|simple background|white background|smile|blush|open mouth|upper body|full body|cowboy shot)$/i;
// The combo editor lists models without a base model under this value (notebook_canvas.js).
const UNLABELED_BASE_MODEL = '__unlabeled__';
const post = (url, body) => fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
/** The key of an output image in the map loadKeptImages() returns. */
export const keptKey = image => `${image?.subfolder || ''}\n${image?.filename || ''}`;
/** Map(keptKey → { recipe, combo, prompt }), each { filename, name } or null. */
export async function loadKeptImages(signal) {
const response = await fetch('/anomalous/kept_images', { cache: 'no-store', signal });
const { images = [] } = await jsonResponse(response, 'list kept images');
return new Map(images.map(item => [keptKey(item), item]));
}
const fileStem = name => String(name || '').split(/[\\/]/).pop().replace(/\.[^.]+$/, '');
/** What the image's workflow uses (extractRecipeMetadata) and a name: its prompt's first tags, else the file's. */
async function readImage(sourceImage) {
const info = await jsonResponse(await post('/anomalous/inspect_image_material', { source_image: sourceImage }), 'read image workflow');
const metadata = extractRecipeMetadata(detachedGraph(info.workflow));
const name = promptTitle(metadata.promptPositive[0]) || fileStem(sourceImage?.filename);
return { name, metadata };
}
/** A model's name as its card shows it: the name the user gave it, else the file's. */
const modelLabel = model => String(model?.metadata?.custom_name || '').trim() || fileStem(model?.filename);
/**
* A combo's name that tells combos apart: its first LoRA (else its main model), then the
* prompt's first two telling tags — "Ahri · fox ears, kimono"; the file's name when neither.
*/
export function comboTitle({ prompt = '', mainModel = null, loras = [], fallback = '' } = {}) {
const model = modelLabel(loras[0] || mainModel).slice(0, 40);
const subject = promptTitle(prompt, { count: 2, skip: COMMON_TAG });
return [model, subject].filter(Boolean).join(' · ') || fallback;
}
/** { path → this computer's model } for the paths found among `types`. */
async function localModels(paths, types) {
if (!paths.length) return {};
const response = await post('/anomalous/resolve_paths_to_previews', { paths, folder_types: types });
const { models = {} } = await jsonResponse(response, 'look up models');
// The editor's model entries carry no absolute path.
return Object.fromEntries(Object.entries(models).map(([path, { file_path, ...model }]) => [path, model]));
}
/** Every combo: [{ filename, name, data }]. */
export async function listCombos() {
const response = await fetch('/anomalous/notebooks', { cache: 'no-store' });
return (await jsonResponse(response, 'list combos')).notebooks || [];
}
const sameImage = (a, b) => Boolean(a?.filename && b?.filename) && keptKey(a) === keptKey(b);
/** `name`, or `name 2`, `name 3`… so a new combo never overwrites another one's file. */
export function freeComboName(name, combos) {
const taken = new Set(combos.map(note => note.filename.toLowerCase()));
const base = String(name || '').trim().replace(/[\\/:*?"<>|]+/g, ' ').trim().slice(0, 100) || 'Combo';
for (let n = 1; ; n += 1) {
const candidate = n === 1 ? base : `${base} ${n}`;
if (!taken.has(`${candidate}.json`.toLowerCase())) return candidate;
}
}
/**
* Keeps the image's main model, LoRAs and positive prompt as a combo; an image kept before
* gives that combo back (`existed`). Models this computer does not have are left out and
* named in `missing`. Returns { filename, name, existed, missing }.
*/
export async function keepImageAsCombo(sourceImage, { name = '' } = {}) {
const combos = await listCombos();
const kept = combos.find(note => sameImage(note.data?.source_image, sourceImage));
if (kept) return { filename: kept.filename, name: kept.name, existed: true, missing: [] };
const image = await readImage(sourceImage);
const main = image.metadata.baseModel || '';
const loraPaths = [...new Set((image.metadata.loras || []).map(lora => lora.name).filter(Boolean))];
const [mains, loras] = await Promise.all([
localModels(main ? [main] : [], MAIN_TYPES),
localModels(loraPaths, ['loras']),
]);
const mainModel = mains[main] || null;
const localLoras = loraPaths.filter(path => loras[path]).map(path => loras[path]);
const prompt = image.metadata.promptPositive.join('\n');
const comboName = freeComboName(name || comboTitle({ prompt, mainModel, loras: localLoras, fallback: image.name }), combos);
const data = {
name: comboName,
baseModel: mainModel ? String(mainModel.metadata?.baseModel || '').trim() || UNLABELED_BASE_MODEL : '',
mainModel,
loras: localLoras,
promptEn: prompt,
promptZh: '',
source_image: sourceImage,
};
const filename = `${comboName}.json`;
const response = await post('/anomalous/save_notebook', { filename, name: comboName, data });
if ((await jsonResponse(response, 'save combo')).status !== 'success') throw new Error('combo save failed');
const missing = [...(main && !mainModel ? [main] : []), ...loraPaths.filter(path => !loras[path])];
return { filename, name: comboName, existed: false, missing };
}
/** Gives the combo kept in `filename` the name `name` (its file keeps its name). Returns the name. */
export async function renameCombo(filename, name) {
const wanted = String(name || '').trim().slice(0, 100);
const note = (await listCombos()).find(item => item.filename === filename);
if (!wanted || !note) throw new Error('combo rename failed');
const response = await post('/anomalous/save_notebook', { filename, data: { ...note.data, name: wanted } });
if ((await jsonResponse(response, 'rename combo')).status !== 'success') throw new Error('combo rename failed');
return wanted;
}
/**
* Keeps the image's positive and negative prompts as one saved prompt; the same prompts
* saved before give that one back (`existed`). Returns { filename, name, existed }, or
* { empty: true } when the workflow has no prompt.
*/
export async function keepImagePrompts(sourceImage, { name = '' } = {}) {
const image = await readImage(sourceImage);
const positive = image.metadata.promptPositive.join('\n');
const negative = image.metadata.promptNegative.join('\n');
if (!positive.trim() && !negative.trim()) return { empty: true };
const promptName = String(name || image.name || sourceImage.filename).trim().slice(0, 120);
const parts = [['positive', positive], ['negative', negative]].filter(([, text]) => text.trim()).map(([role, text]) => ({
name: promptName,
category: role === 'negative' ? 'base' : categorizePromptSnippet(text),
role,
positive: role === 'positive' ? text : '',
negative: role === 'negative' ? text : '',
enabled: true,
}));
const response = await post('/anomalous/save_prompt_plan', {
name: promptName,
plan: { version: 2, positive, negative, parts },
source_image: sourceImage,
});
// The same prompts saved before come back as that saved prompt (409), not a copy.
const payload = response.status === 409 ? await response.json() : await jsonResponse(response, 'save prompts');
if (!['success', 'duplicate'].includes(payload.status)) throw new Error('prompt save failed');
return { filename: payload.filename, name: payload.name || promptName, existed: payload.status === 'duplicate' };
}