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>
163 lines
8.6 KiB
JavaScript
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' };
|
|
}
|