diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index af3d3c1..51e8d6e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -527,6 +527,11 @@ covered by the `styles.css` manifest. named by `comboTitle` — first LoRA or main model · two telling tags — and renamed by `renameCombo`; its prompts as a saved prompt, reading what was kept) without DOM. A new combo's toast is `ui_kept_toast.js` (the image's thumbnail, the name in a box to rename it on the spot, Open). + An image becomes a node-structure combo: `sampler_inputs.js` (no DOM) picks the nodes upstream + of its samplers' model / positive / negative / conditioning inputs (not through images, + latents or masks), and `image_keep_nodes.js` saves them with `captureStructure`, every value + as the image had it, models renamed to where they are on this computer; a workflow with no + sampler gives the older main model + LoRAs + prompt combo. - `ui_combos.js` renders the Combos page's list (搭配: a main model, LoRAs and a prompt, formerly Prompt Notes): search, ? (what a combo is), New, and a card per combo with its model's cover (one kept from an output image shows that image, the model's cover as a badge) and Put on canvas; a card opens the combo's editor, and dragged onto the canvas (`material_drag.js`) becomes a new group of nodes where it is dropped. Combos keep the notes' files. diff --git a/CHANGELOG.md b/CHANGELOG.md index a8af02e..8e2fc6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ ### ★ See only the kept pictures (图库只看收藏) - **★ Kept** beside the gallery's search lists only the pictures you kept as a workflow, combo or prompt (the ★ on their cards). Works with the search and with Pictures / Videos. Pointing at a card's ★ says what it was kept as. +### 📌 A picture kept as a combo keeps its nodes as they were (图片存搭配,节点原样记住) +- **Keep → Its combo** now keeps the nodes that made the picture's model and prompts: the model and CLIP loaders, the whole LoRA chain (Power Lora Loader and other packs' nodes too), CLIP skip and model patches, the positive **and** negative prompt nodes, with every value as the picture had it. The sampler, size and decoding are left out, so Put on canvas gives a group you wire to your own sampler, like a combo saved from the canvas. +- It used to keep only the main model, the LoRAs' names and the positive prompt: LoRA strengths came back as 1.0, and the negative prompt and CLIP skip were lost. **A combo only remembers; it never changes a value** (now one of the plugin's rules). Combos kept before stay as they are. +- Models are named by where they are on this computer; one this computer lacks is kept by the picture's name and said after keeping. A workflow without a sampler to read from still gives the simple kind. + ### ⭐ Combos kept from a picture are easy to tell apart (图片存的搭配一眼认得出) - **The card shows the picture you kept it from**, with the main model's cover as a small round badge in its corner, instead of the model's cover alone (several combos of one model used to look the same). - **A name that says which one it is**: the first LoRA (or the main model) and the prompt's first two telling tags, like *Ahri · fox ears, kimono*, skipping words nearly every picture has (*1girl*, *solo*, *looking at viewer*…). diff --git a/docs/architecture/material-library.md b/docs/architecture/material-library.md index a695288..2cf6f54 100644 --- a/docs/architecture/material-library.md +++ b/docs/architecture/material-library.md @@ -116,8 +116,11 @@ override, which travels into the saved record but never writes back to a Recipe. The gallery's star and the image workbench's Keep (`ui_keep_menu.js`) keep an output PNG as one of three things, each recording the image as its source: the whole workflow as a -Workflow Recipe (`recipe_save.js`, `source_image`), its main model, LoRAs and positive -prompt as a combo (`image_keep.js`; models are looked up among this computer's files with +Workflow Recipe (`recipe_save.js`, `source_image`), the nodes that make its sampler's model +and prompts as a node-structure combo (`image_keep_nodes.js`, `sampler_inputs.js`: loaders, +LoRA chains, CLIP skip, model patches and both prompt nodes, every value as it was; a combo +never changes a value) — or, for a workflow with no sampler, its main model, LoRAs and +positive prompt as a combo (`image_keep.js`; models are looked up among this computer's files with `/anomalous/resolve_paths_to_previews`, missing ones are left out; the notebook file keeps `source_image`, which the Combos card and the editor's title show; the name is the first LoRA's (else the main model's) · the prompt's first two tags that are neither quality words diff --git a/tests/sampler_inputs.mjs b/tests/sampler_inputs.mjs new file mode 100644 index 0000000..4b86462 --- /dev/null +++ b/tests/sampler_inputs.mjs @@ -0,0 +1,43 @@ +// What a combo kept from an output image holds (web/modules/sampler_inputs.js). +import assert from 'node:assert/strict'; +import { isSampler, samplerSideNodes } from '../web/modules/sampler_inputs.js'; + +// A small graph: links [id, from, to, type]; inputs name the link feeding them. +function graph(nodes, links) { + const byId = new Map(links.map(([id, from, , type]) => [id, { id, origin_id: from, type }])); + return { + _nodes: nodes.map(([id, type, inputs = {}]) => ({ id, type, inputs: Object.entries(inputs).map(([name, link]) => ({ name, link })) })), + getLink: id => byId.get(id) ?? null, + }; +} + +const g = graph([ + [1, 'CheckpointLoaderSimple'], + [2, 'LoraLoader', { model: 1, clip: 2 }], + [3, 'CLIPSetLastLayer', { clip: 3 }], + [4, 'CLIPTextEncode', { clip: 4 }], + [5, 'CLIPTextEncode', { clip: 5 }], + [6, 'EmptyLatentImage'], + [7, 'KSampler', { model: 6, positive: 7, negative: 8, latent_image: 9 }], + [8, 'VAEDecode', { samples: 10, vae: 11 }], + [9, 'LoadImage'], + [10, 'ControlNetApply', { conditioning: 12, image: 13 }], + [11, 'Note'], +], [ + [1, 1, 2, 'MODEL'], [2, 1, 2, 'CLIP'], [3, 2, 3, 'CLIP'], [4, 3, 4, 'CLIP'], [5, 3, 5, 'CLIP'], + [6, 2, 7, 'MODEL'], [7, 10, 7, 'CONDITIONING'], [8, 5, 7, 'CONDITIONING'], [9, 6, 7, 'LATENT'], + [10, 7, 8, 'LATENT'], [11, 1, 8, 'VAE'], [12, 4, 10, 'CONDITIONING'], [13, 9, 10, 'IMAGE'], +]); + +assert.equal(isSampler(g._nodes[6]), true); +assert.equal(isSampler(g._nodes[1]), false); +// Loaders, the LoRA, CLIP skip, both prompts and what sits between them and the sampler; +// not the sampler, the latent, the decode, the image loader or a note. +assert.deepEqual(samplerSideNodes(g).map(node => node.id), [1, 2, 3, 4, 5, 10]); +// A workflow without a sampler: nothing to keep. +assert.deepEqual(samplerSideNodes(graph([[1, 'CheckpointLoaderSimple']], [])), []); +// A guider (Flux): model and conditioning. +const flux = graph([[1, 'UNETLoader'], [2, 'CLIPTextEncode'], [3, 'BasicGuider', { model: 1, conditioning: 2 }]], + [[1, 1, 3, 'MODEL'], [2, 2, 3, 'CONDITIONING']]); +assert.deepEqual(samplerSideNodes(flux).map(node => node.id), [1, 2]); +console.log('sampler inputs: ok'); diff --git a/web/modules/image_keep.js b/web/modules/image_keep.js index 0e01161..9a461fc 100644 --- a/web/modules/image_keep.js +++ b/web/modules/image_keep.js @@ -1,8 +1,10 @@ /** * 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 + * its whole workflow as a Workflow Recipe (recipe_save.js), the nodes that make its sampler's + * model and prompts as a combo (搭配, a file of GET /anomalous/notebooks; image_keep_nodes.js, + * every value as the image had it; a workflow without a sampler to read from gives its main + * model, LoRAs and positive prompt), 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. @@ -40,9 +42,10 @@ 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 graph = detachedGraph(info.workflow); + const metadata = extractRecipeMetadata(graph); const name = promptTitle(metadata.promptPositive[0]) || fileStem(sourceImage?.filename); - return { name, metadata }; + return { name, metadata, graph }; } /** A model's name as its card shows it: the name the user gave it, else the file's. */ @@ -85,16 +88,41 @@ export function freeComboName(name, combos) { } } +/** Saves combo `data` under a free name from `wanted`. Returns { filename, name }. */ +async function saveCombo(data, wanted, combos) { + const comboName = freeComboName(wanted, combos); + const filename = `${comboName}.json`; + const response = await post('/anomalous/save_notebook', { filename, name: comboName, data: { ...data, name: comboName } }); + if ((await jsonResponse(response, 'save combo')).status !== 'success') throw new Error('combo save failed'); + return { filename, name: comboName }; +} + /** - * 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 }. + * Keeps the image as a combo of the nodes that make its sampler's model and prompts, every + * value as it was; an image kept before gives that combo back (`existed`). Models this + * computer does not have are named in `missing` (kept by the workflow's name, `missingKept`). A workflow + * without a sampler to read from gives a combo of its main model, LoRAs and positive prompt. + * 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 prompt = image.metadata.promptPositive.join('\n'); + // Loaded only here: it reaches the canvas modules (no DOM in this file, and tests import it). + const { keepGraphNodes } = await import('./image_keep_nodes.js'); + const nodes = await keepGraphNodes(image.graph, localModels); + if (nodes) { + const title = comboTitle({ prompt, mainModel: nodes.mainModel, loras: nodes.loras, fallback: image.name }); + const saved = await saveCombo({ ...nodes.data, source_image: sourceImage }, name || title, combos); + return { ...saved, existed: false, missing: nodes.missing, missingKept: true }; + } + return keepSimpleCombo(sourceImage, image, prompt, name, combos); +} + +/** The older kind of combo: the image's main model, LoRAs and positive prompt. */ +async function keepSimpleCombo(sourceImage, image, prompt, name, combos) { const main = image.metadata.baseModel || ''; const loraPaths = [...new Set((image.metadata.loras || []).map(lora => lora.name).filter(Boolean))]; const [mains, loras] = await Promise.all([ @@ -103,22 +131,16 @@ export async function keepImageAsCombo(sourceImage, { name = '' } = {}) { ]); 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, + const saved = await saveCombo({ 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'); + }, name || comboTitle({ prompt, mainModel, loras: localLoras, fallback: image.name }), combos); const missing = [...(main && !mainModel ? [main] : []), ...loraPaths.filter(path => !loras[path])]; - return { filename, name: comboName, existed: false, missing }; + return { ...saved, existed: false, missing }; } /** Gives the combo kept in `filename` the name `name` (its file keeps its name). Returns the name. */ diff --git a/web/modules/image_keep_nodes.js b/web/modules/image_keep_nodes.js new file mode 100644 index 0000000..11ce10e --- /dev/null +++ b/web/modules/image_keep_nodes.js @@ -0,0 +1,55 @@ +/** + * An output image kept as a combo of its own nodes (image_keep.js keepImageAsCombo): the part + * of its workflow that makes the sampler's model and prompts (sampler_inputs.js), laid on a + * graph of its own, saved like nodes picked on the canvas (combo_structure.js) with every value + * as the image had it: LoRA strengths, CLIP skip, the negative prompt. A combo never changes a + * value (AGENTS.md, 功能原则). Models are looked up among this computer's files: one found + * there is named by where it is here; one not found keeps the workflow's name and is reported. + */ + +import { captureStructure } from './combo_structure.js'; +import { NONE, STRUCTURED } from './combo_slots.js'; +import { slotLabel } from './ui_combo_structure.js'; +import { samplerSideNodes } from './sampler_inputs.js'; + +const MAIN_FOLDERS = new Set(['checkpoints', 'unet', 'diffusion_models']); +const relOf = model => [String(model.subfolder || '').replace(/^\/+|\/+$/g, ''), model.filename].filter(Boolean).join('/'); + +/** + * The combo data of `graph`'s sampler side, or null when the workflow has no sampler to read + * from. `localModels(paths, folders)` gives { path → this computer's model }. Returns { data: + * { kind, structure, values, covers }, mainModel, loras, missing }. + */ +export async function keepGraphNodes(graph, localModels) { + const nodes = samplerSideNodes(graph); + if (!nodes.length) return null; + const { structure, values } = await captureStructure(nodes, { labelFor: slotLabel }); + if (!structure) return null; + // An empty row of a stack node is no slot (as when saving from the canvas, unticked). + const slots = structure.slots.filter(slot => !(slot.optional && NONE.test(String(values[slot.id] ?? '')))); + const kept = Object.fromEntries(slots.map(slot => [slot.id, values[slot.id]])); + const covers = {}; + const missing = []; + let mainModel = null; + const loras = []; + const models = slots.filter(slot => slot.kind === 'model' && !NONE.test(String(kept[slot.id] ?? ''))); + const folders = [...new Set(models.map(slot => slot.folder).filter(Boolean))]; + const found = await localModels([...new Set(models.map(slot => kept[slot.id]))], folders); + for (const slot of models) { + const model = found[kept[slot.id]]; + if (!model) { + missing.push(kept[slot.id]); + continue; + } + kept[slot.id] = relOf(model); + if (model.preview_url) covers[slot.id] = model.preview_url; + if (!mainModel && MAIN_FOLDERS.has(slot.folder)) mainModel = model; + if (slot.folder === 'loras') loras.push(model); + } + return { + data: { kind: STRUCTURED, structure: { ...structure, slots }, values: kept, covers }, + mainModel, + loras, + missing, + }; +} diff --git a/web/modules/locales.js b/web/modules/locales.js index 1450bbe..acc675c 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -820,7 +820,7 @@ export const i18n = { keepAs_recipe: "整个工作流", keepAs_recipeHint: "存成工作流配方,在“工作流”页,可以整个打开", keepAs_combo: "模型搭配", - keepAs_comboHint: "主模型 + LoRA + 正向提示词,在“搭配”页一键放到画布", + keepAs_comboHint: "出这张图的模型、LoRA 和正负提示词节点,数值原样记下,在“搭配”页一键放到画布", keepAs_prompt: "只要提示词", keepAs_promptHint: "正向和负向提示词,存进提示词库", keepKeptAs: "已存:{name},点这里打开", @@ -852,6 +852,7 @@ export const i18n = { keptAs_prompt: "已存下提示词:{name}", keepExisted: "之前就存过了:{name}", keepComboMissing: "有 {count} 个模型本机没有,搭配里先空着。", + keepComboMissingNamed: "有 {count} 个模型这台电脑没有,先按原图的名字记着;在搭配里点它换一个本机的模型就行。", keepOpen: "打开", keepRemove: "取消收藏", keepRemoveHint: "把存下的这一项移到系统回收站", @@ -3632,7 +3633,7 @@ export const i18n = { keepAs_recipe: "The whole workflow", keepAs_recipeHint: "As a Workflow Recipe on the Workflows page, to open whole", keepAs_combo: "Its combo", - keepAs_comboHint: "Main model, LoRAs and positive prompt, on the Combos page", + keepAs_comboHint: "The nodes that made its model and prompts, every value as it was, on the Combos page", keepAs_prompt: "Just the prompts", keepAs_promptHint: "The positive and negative prompts, in your saved prompts", keepKeptAs: "Kept as {name}; open it", @@ -3664,6 +3665,7 @@ export const i18n = { keptAs_prompt: "Kept the prompts: {name}", keepExisted: "Kept before: {name}", keepComboMissing: "{count} of its models are not on this computer, so the combo leaves them out.", + keepComboMissingNamed: "{count} of its models are not on this computer; the combo keeps them by the picture's names; click one in the combo to choose a model of yours.", keepOpen: "Open", keepRemove: "Remove", keepRemoveHint: "Move what was kept to the Recycle Bin", diff --git a/web/modules/sampler_inputs.js b/web/modules/sampler_inputs.js new file mode 100644 index 0000000..f760002 --- /dev/null +++ b/web/modules/sampler_inputs.js @@ -0,0 +1,46 @@ +/** + * The part of a workflow that makes a sampler's model and prompts (what a combo kept from an + * output image holds): every node upstream of a sampler's model / positive / negative / + * conditioning inputs — model and CLIP loaders, LoRA chains, CLIP skip and model patches, the + * prompt nodes — but not the sampler itself, nor anything reached only through an image, + * latent or mask (image loaders, sizes, VAE decoding). A sampler is a node with a model input + * and a positive or conditioning input (KSampler, KSampler Advanced, CFG / Basic guiders). + * No DOM; `graph` is a LiteGraph graph (or anything with nodes and getLink / links). + */ + +const FEEDS = new Set(['model', 'positive', 'negative', 'conditioning']); +const CUT = new Set(['IMAGE', 'LATENT', 'MASK']); + +const inputNames = node => (node.inputs || []).map(input => input?.name); + +/** Whether `node` is a sampler (or a guider) whose model and prompts a combo keeps. */ +export function isSampler(node) { + const names = inputNames(node); + return names.includes('model') && (names.includes('positive') || names.includes('conditioning')); +} + +/** The nodes upstream of the samplers' model and prompt inputs, in the graph's order. */ +export function samplerSideNodes(graph) { + const nodes = graph?._nodes || graph?.nodes || []; + const byId = new Map(nodes.map(node => [node.id, node])); + const linkOf = id => graph.getLink?.(id) ?? graph.links?.[id] ?? null; + const samplers = new Set(nodes.filter(isSampler).map(node => node.id)); + const kept = new Set(); + const waiting = []; + const follow = (input) => { + if (input?.link == null) return; + const link = linkOf(input.link); + if (!link || CUT.has(String(link.type ?? input.type ?? '').toUpperCase())) return; + waiting.push(link.origin_id); + }; + for (const id of samplers) { + for (const input of byId.get(id).inputs || []) if (FEEDS.has(input?.name)) follow(input); + } + while (waiting.length) { + const id = waiting.pop(); + if (kept.has(id) || samplers.has(id) || !byId.has(id)) continue; + kept.add(id); + for (const input of byId.get(id).inputs || []) follow(input); + } + return nodes.filter(node => kept.has(node.id)); +} diff --git a/web/modules/ui_combos.js b/web/modules/ui_combos.js index f2e932b..03aa82e 100644 --- a/web/modules/ui_combos.js +++ b/web/modules/ui_combos.js @@ -91,7 +91,7 @@ function comboCard(owner, note) { }); use.title = t('comboUseHint'); body.append(use); - card.append(structured ? cover({ preview_url: structured.cover }) : cover(data.mainModel, data.source_image), body); + card.append(cover(structured ? { preview_url: structured.cover } : data.mainModel, data.source_image), body); bindMaterialDrag(card, owner, { payload: () => ({ type: 'combo', filename: note.filename, dragHint: t('comboDragHint'), dragTargetHint: t('comboDragCanvas') }), accepts: () => false, diff --git a/web/modules/ui_keep_menu.js b/web/modules/ui_keep_menu.js index bbb476a..d8a42e1 100644 --- a/web/modules/ui_keep_menu.js +++ b/web/modules/ui_keep_menu.js @@ -41,10 +41,13 @@ function place(menu, anchor) { menu.style.top = `${below + height > window.innerHeight - 8 ? Math.max(8, box.top - height - 6) : below}px`; } +/** The models a new combo names that this computer lacks: kept by the picture's name (nodes), or left out. */ +const missingWords = result => t(result.missingKept ? 'keepComboMissingNamed' : 'keepComboMissing', { count: result.missing.length }); + function keptMessage(spec, result) { if (result.existed) return t('keepExisted', { name: result.name }); const message = t(`keptAs_${spec.kind}`, { name: result.name }); - return result.missing?.length ? `${message} ${t('keepComboMissing', { count: result.missing.length })}` : message; + return result.missing?.length ? `${message} ${missingWords(result)}` : message; } async function keep(owner, spec, sourceImage, kept, { name, onKept, beforeOpen }) { @@ -62,7 +65,7 @@ async function keep(owner, spec, sourceImage, kept, { name, onKept, beforeOpen } }; // A new combo's name is a guess (image_keep.js comboTitle): it can be renamed right here. if (spec.kind === 'combo' && !result.existed) { - const missing = result.missing?.length ? t('keepComboMissing', { count: result.missing.length }) : ''; + const missing = result.missing?.length ? missingWords(result) : ''; showKeptComboToast(sourceImage, kept.combo, { message: [t('keptAs_comboShort'), missing].filter(Boolean).join(' '), onRenamed: (name) => { diff --git a/web/modules/ui_quick_search.js b/web/modules/ui_quick_search.js index ec160c9..84dd4dd 100644 --- a/web/modules/ui_quick_search.js +++ b/web/modules/ui_quick_search.js @@ -37,7 +37,8 @@ function comboSearchItem(note) { const data = note.data || {}; if (data.kind === STRUCTURED) { const meta = structuredComboMeta(data); - return comboItem(note, { detail: meta.meta, thumb: meta.cover, text: meta.prompt }); + const thumb = data.source_image?.filename ? outputThumbnail(data.source_image) : meta.cover; + return comboItem(note, { detail: meta.meta, thumb, text: meta.prompt }); } const loras = (data.loras || []).length; const detail = [modelName(data.mainModel), loras ? t('comboLoraCount', { count: loras }) : ''].filter(Boolean).join(' · ');