Every PNG in the gallery has a star that asks what to keep: the whole workflow as a Workflow Recipe, its main model, LoRAs and positive prompt as a combo (models looked up on this computer, missing ones left out), or its prompts as a saved prompt. What is kept remembers its image, so GET /anomalous/kept_images fills the star and the menu opens what was kept. The image workbench's Keep opens the same menu instead of its name and tags form. Things are named after the prompt's first tags. The Material Library's Recent shelf and its route are gone: the gallery lists the same images. Gallery cards moved to ui_gallery_card.js, and deleting asks in the usual dialog. Two missing locale keys are fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
183 lines
8.4 KiB
JavaScript
183 lines
8.4 KiB
JavaScript
/**
|
|
* Saving a Workflow Recipe, shared by the canvas save (ui_recipes.js, after its dialog) and
|
|
* by keeping an output image's workflow: Recent's star, the image workbench, and moving the
|
|
* workflows kept as materials before recipes took over. An image's workflow is laid on a
|
|
* canvas of its own first, so its recipe is summarised exactly like one saved from the canvas.
|
|
*/
|
|
|
|
import { captureRecipeDraft } from './recipe_parser.js';
|
|
import { promptTitle } from './prompt_composition.js';
|
|
import { outputImageUrl, previewIsVideo } from './ui_recipe_media.js';
|
|
import { jsonResponse } from './ui_dom.js';
|
|
|
|
const post = (url, body) => fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
/** A small cover from an output image (a video's first frame), as a data URL. */
|
|
export async function captureOutputThumbnail(image) {
|
|
const url = outputImageUrl(image);
|
|
if (!url) return null;
|
|
const response = await fetch(url);
|
|
if (!response.ok) return null;
|
|
const blob = await response.blob();
|
|
if (previewIsVideo(url) || /^video\//i.test(blob.type)) {
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
const video = document.createElement('video');
|
|
video.src = objectUrl;
|
|
video.muted = true;
|
|
video.playsInline = true;
|
|
video.preload = 'auto';
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
video.onloadeddata = resolve;
|
|
video.onerror = reject;
|
|
video.load();
|
|
});
|
|
if (video.duration > 0.2) {
|
|
await new Promise((resolve) => {
|
|
video.onseeked = resolve;
|
|
video.currentTime = 0.1;
|
|
});
|
|
}
|
|
const maxEdge = 720;
|
|
const scale = Math.min(1, maxEdge / Math.max(video.videoWidth, video.videoHeight));
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = Math.max(1, Math.round(video.videoWidth * scale));
|
|
canvas.height = Math.max(1, Math.round(video.videoHeight * scale));
|
|
const context = canvas.getContext('2d');
|
|
if (!context) return null;
|
|
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
return canvas.toDataURL('image/webp', 0.72);
|
|
} finally {
|
|
URL.revokeObjectURL(objectUrl);
|
|
}
|
|
}
|
|
const bitmap = await createImageBitmap(blob);
|
|
try {
|
|
const maxEdge = 720;
|
|
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
|
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
|
const context = canvas.getContext('2d');
|
|
if (!context) return null;
|
|
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
return canvas.toDataURL('image/jpeg', 0.72);
|
|
} finally {
|
|
bitmap.close?.();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Saves `draft` (captureRecipeDraft) as a new recipe, or over `editing`, with its first
|
|
* parameter set. `details`: { name, tags, notes, thumbnail, sourceImage,
|
|
* saveModelPreviewSnapshots, verifyModelIdentities }. Returns { payload, filename }.
|
|
*/
|
|
export async function persistRecipe(draft, details, editing = null) {
|
|
let thumbnail = details.thumbnail || null;
|
|
if (details.sourceImage) {
|
|
try {
|
|
thumbnail = await captureOutputThumbnail(details.sourceImage);
|
|
} catch (error) {
|
|
console.warn('Could not persist bound recipe image thumbnail:', error);
|
|
}
|
|
}
|
|
const response = await post(editing ? '/anomalous/update_recipe' : '/anomalous/save_recipe', {
|
|
...(editing ? { filename: editing.filename } : {}),
|
|
name: details.name,
|
|
tags: details.tags,
|
|
notes: details.notes,
|
|
params: draft.metadata,
|
|
workflow: draft.workflow,
|
|
workflow_scope: draft.workflowScope,
|
|
thumbnail,
|
|
source_image: details.sourceImage,
|
|
presentation: { save_model_preview_snapshots: details.saveModelPreviewSnapshots },
|
|
verify_model_identities: details.verifyModelIdentities,
|
|
});
|
|
const payload = await response.json();
|
|
if (!response.ok || payload.status !== 'success') throw new Error('recipe save request failed');
|
|
const filename = payload.filename || editing?.filename;
|
|
if (filename) {
|
|
try {
|
|
const parameterResponse = await post('/anomalous/save_parameter', {
|
|
name: details.name,
|
|
tags: details.tags,
|
|
notes: details.notes,
|
|
params: draft.metadata,
|
|
workflow: draft.workflow,
|
|
recipe_filename: filename,
|
|
});
|
|
if (!parameterResponse.ok) throw new Error('parameter snapshot request failed');
|
|
} catch (error) {
|
|
// Recipe persistence is already successful; a snapshot failure
|
|
// must not make the user retry and create a duplicate recipe.
|
|
console.warn('Could not save the recipe parameter snapshot:', error);
|
|
}
|
|
}
|
|
return { payload, filename };
|
|
}
|
|
|
|
const sameImage = (a, b) => Boolean(a?.filename && b?.filename) && a.filename === b.filename && (a.subfolder || '') === (b.subfolder || '');
|
|
|
|
/**
|
|
* Keeps an output image's workflow as a recipe; an image kept before gives that recipe back
|
|
* (`existed`). `workflow` when the caller has it, else it is read from the image.
|
|
* Returns { filename, name, existed }.
|
|
*/
|
|
export async function keepImageAsRecipe(sourceImage, { name = '', tags = [], workflow = null } = {}) {
|
|
if (sourceImage) {
|
|
const { recipes = [] } = await jsonResponse(await fetch('/anomalous/recipes', { cache: 'no-store' }), 'list recipes');
|
|
const kept = recipes.find(recipe => sameImage(recipe.data?.source_image, sourceImage));
|
|
if (kept) return { filename: kept.filename, name: kept.data?.name || '', existed: true };
|
|
}
|
|
let suggested = '';
|
|
if (!workflow) {
|
|
const info = await jsonResponse(await post('/anomalous/inspect_image_material', { source_image: sourceImage }), 'read image workflow');
|
|
workflow = info.workflow;
|
|
suggested = info.suggested_name || '';
|
|
}
|
|
const graph = new globalThis.LiteGraph.LGraph();
|
|
graph.configure(JSON.parse(JSON.stringify(workflow)));
|
|
const draft = captureRecipeDraft(graph);
|
|
const recipeName = String(name || promptTitle(draft.metadata.promptPositive?.[0]) || suggested || sourceImage?.filename || 'Workflow').trim().slice(0, 120);
|
|
const { filename } = await persistRecipe(draft, {
|
|
name: recipeName, tags, notes: '', sourceImage,
|
|
saveModelPreviewSnapshots: true, verifyModelIdentities: false,
|
|
});
|
|
return { filename, name: recipeName, existed: false };
|
|
}
|
|
|
|
/** How many whole workflows are still kept as materials. */
|
|
export async function workflowMaterialCount() {
|
|
const payload = await jsonResponse(await fetch('/anomalous/materials?category=workflow&limit=1&page=1', { cache: 'no-store' }), 'count kept workflows');
|
|
return Number(payload.total) || 0;
|
|
}
|
|
|
|
/**
|
|
* Saves each workflow kept as a material as a recipe; the material file stays, marked as
|
|
* moved, so it is not listed again. `onProgress(done, total)`. Returns how many moved; one that fails stays a material.
|
|
*/
|
|
export async function moveWorkflowMaterials(onProgress = null) {
|
|
const { materials = [] } = await jsonResponse(await fetch('/anomalous/materials?category=workflow', { cache: 'no-store' }), 'list kept workflows');
|
|
let moved = 0;
|
|
for (const material of materials) {
|
|
try {
|
|
const url = `/anomalous/material_full?filename=${encodeURIComponent(material.filename)}&include_workflow=1`;
|
|
const data = (await jsonResponse(await fetch(url), 'read kept workflow')).data || {};
|
|
const image = data.source?.image?.type === 'output' ? data.source.image : null;
|
|
const recipe = await keepImageAsRecipe(image, { name: data.name, tags: data.tags || [], workflow: data.workflow });
|
|
await jsonResponse(await post('/anomalous/mark_material_moved', { filename: material.filename, recipe: recipe.filename }), 'mark moved');
|
|
moved += 1;
|
|
} catch (error) {
|
|
// One that cannot be read or saved stays a material; the others still move.
|
|
console.warn('[AMB] A kept workflow did not move to the recipes.', material.filename, error);
|
|
}
|
|
onProgress?.(moved, materials.length);
|
|
}
|
|
return moved;
|
|
}
|