Files
vendoo/lib/ai-local-images.mjs
Masterluke77andGitHub 6f48827f4d Vendoo 1.41.2 – Git Ignore Boundary & Module Tracking Hotfix (#18)
* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix

* fix: track native source modules with root-anchored runtime ignores
2026-07-09 17:09:00 +02:00

467 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { join, isAbsolute, dirname } from 'path';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { randomBytes, randomUUID } from 'crypto';
const ROOT_DIR = dirname(fileURLToPath(import.meta.url));
const PROJECT_DIR = join(ROOT_DIR, '..');
const DEFAULT_WORKFLOW_RELATIVE = 'local-ai/workflows/vendoo_flux_schnell_api.json';
function toPosix(path) {
return String(path || '').replace(/\\/g, '/');
}
function resolveWorkflowPath(pathValue = '') {
const trimmed = String(pathValue || '').trim();
if (!trimmed) return join(PROJECT_DIR, DEFAULT_WORKFLOW_RELATIVE);
return isAbsolute(trimmed) ? trimmed : join(PROJECT_DIR, trimmed);
}
function getEnvBool(value, fallback = false) {
if (value === undefined || value === null || value === '') return fallback;
return !['0', 'false', 'no', 'off'].includes(String(value).toLowerCase());
}
export function generateLocalImageToken() {
return randomBytes(24).toString('base64url');
}
export function getLocalImageConfig(settings = {}) {
const workflowRelative = String(settings.ai_model_photo_local_workflow_path || process.env.LOCAL_IMAGE_WORKFLOW_PATH || DEFAULT_WORKFLOW_RELATIVE).trim() || DEFAULT_WORKFLOW_RELATIVE;
const workflowPath = resolveWorkflowPath(workflowRelative);
return {
enabled: getEnvBool(settings.ai_model_photo_local_enabled ?? process.env.LOCAL_IMAGE_ENABLED, true),
url: String(settings.ai_model_photo_local_url || process.env.LOCAL_IMAGE_URL || 'http://127.0.0.1:8188').trim().replace(/\/$/, ''),
token: String(settings.ai_model_photo_local_token || process.env.LOCAL_IMAGE_TOKEN || '').trim(),
workflow_path: workflowPath,
workflow_relative: toPosix(workflowRelative),
install_path: String(settings.ai_model_photo_local_install_path || process.env.LOCAL_IMAGE_INSTALL_PATH || '').trim(),
require_token: getEnvBool(settings.ai_model_photo_local_require_token ?? process.env.LOCAL_IMAGE_REQUIRE_TOKEN, true),
allow_remote: getEnvBool(process.env.LOCAL_IMAGE_ALLOW_REMOTE, false),
};
}
function assertSafeLocalUrl(config) {
let parsed;
try { parsed = new URL(config.url); } catch { throw new Error('Ungültige lokale Bildserver-URL.'); }
const host = String(parsed.hostname || '').toLowerCase();
const loopback = ['127.0.0.1', 'localhost', '::1'].includes(host);
if (!loopback && !config.allow_remote) {
throw new Error('Aus Sicherheitsgründen sind für lokalen FLUX standardmäßig nur localhost/127.0.0.1 erlaubt.');
}
return { loopback, host };
}
function getAuthHeaders(config) {
const headers = {};
if (config.token) {
headers['Authorization'] = `Bearer ${config.token}`;
headers['X-API-Key'] = config.token;
}
return headers;
}
function getHeaders(config) {
return { ...getAuthHeaders(config), 'Content-Type': 'application/json' };
}
async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}
function replaceDeep(value, replacements) {
if (typeof value === 'string') {
let out = value;
for (const [key, val] of Object.entries(replacements)) {
out = out.split(key).join(String(val));
}
return out;
}
if (Array.isArray(value)) return value.map(item => replaceDeep(item, replacements));
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, replaceDeep(v, replacements)]));
}
return value;
}
function firstImageFromHistory(history = {}) {
for (const node of Object.values(history?.outputs || {})) {
if (Array.isArray(node?.images) && node.images.length) return node.images[0];
}
return null;
}
async function pollHistory(config, promptId, timeoutMs = 300000, onProgress = null, { onPhase = null, isCancelled = null } = {}) {
const started = Date.now();
let lastPayload = null;
while (Date.now() - started < timeoutMs) {
if (typeof isCancelled === 'function' && await isCancelled()) {
const error = new Error('FLUX-Auftrag wurde abgebrochen.');
error.code = 'FLUX_CANCELLED';
throw error;
}
const elapsedSeconds = Math.max(0, Math.round((Date.now() - started) / 1000));
const message = elapsedSeconds < 8
? 'ComfyUI übernimmt den Auftrag …'
: `FLUX rendert das Bild seit ${elapsedSeconds} Sekunden …`;
if (typeof onPhase === 'function') onPhase('rendering', message, { elapsed_seconds: elapsedSeconds });
if (typeof onProgress === 'function') {
const estimated = Math.min(88, 24 + Math.round((elapsedSeconds / 150) * 60));
onProgress(estimated, message);
}
const response = await fetchWithTimeout(`${config.url}/history/${promptId}`, { headers: getHeaders(config) }, 12000);
const payload = await response.json().catch(() => ({}));
lastPayload = payload?.[promptId] || payload;
const image = firstImageFromHistory(lastPayload);
if (image) return { history: lastPayload, image };
await new Promise(resolve => setTimeout(resolve, 2000));
}
throw new Error('Lokale FLUX/ComfyUI-Generierung hat nach fünf Minuten das Zeitlimit überschritten.');
}
async function fetchComfyImageAsBase64(config, imageInfo) {
const params = new URLSearchParams({ filename: imageInfo.filename, subfolder: imageInfo.subfolder || '', type: imageInfo.type || 'output' });
const response = await fetchWithTimeout(`${config.url}/view?${params.toString()}`, { headers: getHeaders(config) }, 60000);
if (!response.ok) throw new Error(`ComfyUI-Bildabruf fehlgeschlagen (${response.status})`);
return Buffer.from(await response.arrayBuffer()).toString('base64');
}
function parseDataUrl(dataUrl = '') {
const match = String(dataUrl || '').match(/^data:([^;,]+);base64,(.+)$/s);
if (!match) return null;
return { mime: match[1] || 'image/jpeg', buffer: Buffer.from(match[2], 'base64') };
}
async function uploadSourceImage(config, sourceDataUrl, onProgress = null) {
const parsed = parseDataUrl(sourceDataUrl);
if (!parsed?.buffer?.length) return null;
if (typeof onProgress === 'function') onProgress(8, 'Produktfotoreferenz wird an ComfyUI übertragen …');
const extension = parsed.mime.includes('png') ? 'png' : parsed.mime.includes('webp') ? 'webp' : 'jpg';
const filename = `vendoo-reference-${randomUUID()}.${extension}`;
const form = new FormData();
form.append('image', new Blob([parsed.buffer], { type: parsed.mime }), filename);
form.append('type', 'input');
form.append('overwrite', 'true');
const response = await fetchWithTimeout(`${config.url}/upload/image`, {
method: 'POST',
headers: getAuthHeaders(config),
body: form,
}, 60000);
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.name) {
const detail = payload?.error || payload?.message || `HTTP ${response.status}`;
throw new Error(`Produktfotoreferenz konnte nicht an ComfyUI übertragen werden: ${detail}`);
}
const imageName = payload.subfolder ? `${payload.subfolder}/${payload.name}` : payload.name;
return { imageName, payload };
}
function applySourceImageConditioning(workflow, imageName, mode = 'model', referenceStrength = 70, resolution = 768) {
if (!imageName || !workflow?.['7']?.inputs || !workflow?.['3']) return { workflow, used: false, denoise: 1 };
const strength = Math.max(30, Math.min(90, Number(referenceStrength || 70)));
const targetSize = Number(resolution) === 1024 ? 1024 : 768;
const baseDenoise = mode === 'flatlay' ? 0.52 : mode === 'ghost_mannequin' ? 0.64 : 0.80;
const denoise = Math.max(0.42, Math.min(0.88, Number((baseDenoise - ((strength - 60) * 0.0035)).toFixed(3))));
workflow['10'] = { inputs: { image: imageName, upload: 'image' }, class_type: 'LoadImage' };
workflow['11'] = {
inputs: { upscale_method: 'lanczos', width: targetSize, height: targetSize, crop: 'disabled', image: ['10', 0] },
class_type: 'ImageScale',
};
workflow['12'] = { inputs: { pixels: ['11', 0], vae: ['3', 2] }, class_type: 'VAEEncode' };
workflow['7'].inputs.latent_image = ['12', 0];
workflow['7'].inputs.denoise = denoise;
return { workflow, used: true, denoise };
}
export async function getLocalImageStatus(settings = {}) {
const config = getLocalImageConfig(settings);
const workflowExists = existsSync(config.workflow_path);
const configuredInstallRoot = config.install_path
? (isAbsolute(config.install_path) ? config.install_path : join(PROJECT_DIR, config.install_path))
: join(PROJECT_DIR, 'local-ai', 'comfyui');
const portableRoot = existsSync(join(configuredInstallRoot, 'ComfyUI', 'main.py'))
? configuredInstallRoot
: join(configuredInstallRoot, 'ComfyUI_windows_portable');
const comfyMain = join(portableRoot, 'ComfyUI', 'main.py');
const modelPath = join(portableRoot, 'ComfyUI', 'models', 'checkpoints', 'flux1-schnell-fp8.safetensors');
const status = {
enabled: config.enabled,
url: config.url,
token_set: !!config.token,
require_token: config.require_token,
workflow_path: config.workflow_relative,
workflow_exists: workflowExists,
install_path: config.install_path,
comfy_installed: existsSync(comfyMain),
model_installed: existsSync(modelPath),
model_path: modelPath,
reachable: false,
installed: existsSync(comfyMain),
localhost_only: !config.allow_remote,
reference_image_supported: true,
message: '',
};
try {
const security = assertSafeLocalUrl(config);
status.loopback = security.loopback;
const res = await fetchWithTimeout(`${config.url}/system_stats`, { headers: getHeaders(config) }, 8000);
status.reachable = res.ok;
status.installed = res.ok;
const payload = await res.json().catch(() => ({}));
status.system = payload?.system || payload || null;
status.message = res.ok
? `Lokaler Bildserver erreichbar${workflowExists ? '' : ' · Workflow-Datei fehlt noch'}.`
: `Lokaler Bildserver antwortet mit HTTP ${res.status}.`;
} catch (error) {
status.message = `Lokaler Bildserver nicht erreichbar: ${error.message || error}`;
}
return status;
}
export async function generateWithLocalFlux({
prompt,
settings = {},
mode = 'model',
variantIndex = 0,
sourceDataUrl = '',
onProgress = null,
onPhase = null,
onSubmitted = null,
isCancelled = null,
width = null,
height = null,
seed = null,
steps = null,
}) {
const config = getLocalImageConfig(settings);
const throwIfCancelled = async () => {
if (typeof isCancelled === 'function' && await isCancelled()) {
const error = new Error('FLUX-Auftrag wurde abgebrochen.');
error.code = 'FLUX_CANCELLED';
throw error;
}
};
await throwIfCancelled();
if (!config.enabled) throw new Error('Lokale FLUX-Bildgenerierung ist deaktiviert.');
assertSafeLocalUrl(config);
if (!existsSync(config.workflow_path)) throw new Error(`Workflow-Datei nicht gefunden: ${config.workflow_relative}`);
if (typeof onPhase === 'function') onPhase('preparing', 'Lokaler FLUX-Workflow wird vorbereitet …');
if (typeof onProgress === 'function') onProgress(3, 'Lokaler FLUX-Workflow wird vorbereitet …');
const workflowText = readFileSync(config.workflow_path, 'utf8').replace(/^/, '');
let raw;
try {
raw = JSON.parse(workflowText);
} catch (error) {
throw new Error(`Lokaler FLUX-Workflow ist kein gültiges JSON: ${error.message || error}`);
}
const replacements = {
'__PROMPT__': prompt,
'__NEGATIVE_PROMPT__': mode === 'prompt' ? '' : 'nsfw, nudity, lingerie, child, low quality, extra limbs, cropped garment, wrong garment, different clothing, changed logo, changed color',
'__SEED__': Number.isFinite(Number(seed)) ? Math.max(0, Math.min(2147483647, Number(seed))) : Math.floor(Math.random() * 2147483647),
'__MODE__': mode,
'__VARIANT_INDEX__': variantIndex + 1,
'__SOURCE_IMAGE__': sourceDataUrl || '',
'__CLIENT_ID__': 'vendoo',
};
let workflow = replaceDeep(raw, replacements);
if (workflow?.['7']?.inputs) workflow['7'].inputs.seed = replacements.__SEED__;
const resolution = Number(settings.ai_model_photo_local_resolution) === 1024 ? 1024 : 768;
const targetWidth = Number(width) > 0 ? Math.max(256, Math.min(2048, Math.round(Number(width) / 8) * 8)) : resolution;
const targetHeight = Number(height) > 0 ? Math.max(256, Math.min(2048, Math.round(Number(height) / 8) * 8)) : resolution;
if (workflow?.['6']?.inputs) {
workflow['6'].inputs.width = targetWidth;
workflow['6'].inputs.height = targetHeight;
}
const targetSteps = Number.isFinite(Number(steps)) ? Math.max(4, Math.min(20, Math.round(Number(steps)))) : null;
if (targetSteps && workflow?.['7']?.inputs) workflow['7'].inputs.steps = targetSteps;
if (mode === 'prompt' && workflow?.['7']?.inputs) {
workflow['7'].inputs.cfg = 1;
workflow['7'].inputs.sampler_name = 'euler';
workflow['7'].inputs.scheduler = 'simple';
workflow['7'].inputs.denoise = 1;
}
const useReference = getEnvBool(settings.ai_model_photo_use_source_reference, true) && !!sourceDataUrl;
let reference = { used: false, denoise: 1 };
if (useReference) {
const uploaded = await uploadSourceImage(config, sourceDataUrl, onProgress);
if (uploaded?.imageName) reference = applySourceImageConditioning(workflow, uploaded.imageName, mode, settings.ai_model_photo_reference_strength || 70, resolution);
}
if (typeof onPhase === 'function') onPhase('submitting', reference.used ? 'Produktfotoreferenz ist eingebunden.' : 'Text-Workflow ist vorbereitet.');
if (typeof onProgress === 'function') onProgress(16, reference.used ? 'Produktfotoreferenz ist eingebunden.' : 'Text-Workflow ist vorbereitet.');
await throwIfCancelled();
const submit = await fetchWithTimeout(`${config.url}/prompt`, {
method: 'POST',
headers: getHeaders(config),
body: JSON.stringify({ client_id: `vendoo-${randomUUID()}`, prompt: workflow }),
}, 30000);
const submitPayload = await submit.json().catch(() => ({}));
if (!submit.ok || !submitPayload.prompt_id) {
const message = submitPayload?.error?.message || submitPayload?.error || submitPayload?.message || `HTTP ${submit.status}`;
throw new Error(`ComfyUI-Prompt fehlgeschlagen: ${message}`);
}
if (typeof onSubmitted === 'function') await onSubmitted(submitPayload.prompt_id);
try {
await throwIfCancelled();
} catch (error) {
try { await cancelLocalFluxPrompt(submitPayload.prompt_id, settings); } catch {}
throw error;
}
if (typeof onPhase === 'function') onPhase('submitted', 'Auftrag wurde an FLUX übergeben …', { prompt_id: submitPayload.prompt_id });
if (typeof onProgress === 'function') onProgress(22, 'Auftrag wurde an FLUX übergeben …');
const done = await pollHistory(config, submitPayload.prompt_id, 300000, onProgress, { onPhase, isCancelled });
if (typeof onPhase === 'function') onPhase('downloading', 'Fertiges Bild wird von ComfyUI geladen …');
if (typeof onProgress === 'function') onProgress(92, 'Fertiges Bild wird von ComfyUI geladen …');
const base64 = await fetchComfyImageAsBase64(config, done.image);
if (typeof onPhase === 'function') onPhase('completed', 'Lokales FLUX-Bild wurde erzeugt.');
if (typeof onProgress === 'function') onProgress(100, 'Lokales FLUX-Bild wurde erzeugt.');
return {
base64,
model: 'flux-schnell-local',
provider: 'local',
label: 'Lokales FLUX / ComfyUI',
free: true,
prompt_id: submitPayload.prompt_id,
reference_used: reference.used,
reference_denoise: reference.denoise,
resolution,
width: targetWidth,
height: targetHeight,
seed: replacements.__SEED__,
steps: targetSteps || workflow?.['7']?.inputs?.steps || 6,
};
}
export async function waitForLocalFluxPrompt({ promptId, settings = {}, timeoutMs = 300000, onPhase = null, isCancelled = null } = {}) {
const config = getLocalImageConfig(settings);
if (!promptId) throw new Error('ComfyUI-Prompt-ID fehlt.');
assertSafeLocalUrl(config);
const done = await pollHistory(config, String(promptId), timeoutMs, null, { onPhase, isCancelled });
if (typeof onPhase === 'function') onPhase('downloading', 'Fertiges Bild wird von ComfyUI geladen …');
const base64 = await fetchComfyImageAsBase64(config, done.image);
return { base64, prompt_id: String(promptId), history: done.history, image: done.image };
}
export async function interruptLocalFlux(settings = {}) {
const config = getLocalImageConfig(settings);
assertSafeLocalUrl(config);
const response = await fetchWithTimeout(`${config.url}/interrupt`, {
method: 'POST',
headers: getHeaders(config),
body: JSON.stringify({}),
}, 15000);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload?.error || payload?.message || `ComfyUI-Abbruch fehlgeschlagen (${response.status})`);
return { ok: true, payload };
}
function comfyQueuePromptIds(items = []) {
return (Array.isArray(items) ? items : [])
.map(item => Array.isArray(item) ? item[1] : item?.prompt_id || item?.id)
.filter(Boolean)
.map(String);
}
export async function cancelLocalFluxPrompt(promptId, settings = {}) {
const id = String(promptId || '').trim();
if (!id) return { ok: false, mode: 'not_submitted' };
const config = getLocalImageConfig(settings);
assertSafeLocalUrl(config);
const queueResponse = await fetchWithTimeout(`${config.url}/queue`, { headers: getHeaders(config) }, 12000);
const queue = await queueResponse.json().catch(() => ({}));
if (!queueResponse.ok) throw new Error(queue?.error || queue?.message || `ComfyUI-Queue konnte nicht gelesen werden (${queueResponse.status})`);
const runningIds = comfyQueuePromptIds(queue?.queue_running);
const pendingIds = comfyQueuePromptIds(queue?.queue_pending);
if (pendingIds.includes(id)) {
const response = await fetchWithTimeout(`${config.url}/queue`, {
method: 'POST',
headers: getHeaders(config),
body: JSON.stringify({ delete: [id] }),
}, 15000);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload?.error || payload?.message || `ComfyUI-Warteschlangenauftrag konnte nicht entfernt werden (${response.status})`);
return { ok: true, mode: 'dequeued', prompt_id: id, payload };
}
if (runningIds.includes(id)) {
const interrupted = await interruptLocalFlux(settings);
return { ...interrupted, mode: 'interrupted', prompt_id: id };
}
return { ok: true, mode: 'already_finished_or_missing', prompt_id: id };
}
export async function getLocalFluxRuntimeInfo(settings = {}, { historyLimit = 20 } = {}) {
const config = getLocalImageConfig(settings);
assertSafeLocalUrl(config);
const fetchJson = async (path, timeout = 12000) => {
const response = await fetchWithTimeout(`${config.url}${path}`, { headers: getHeaders(config) }, timeout);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(`${path} antwortet mit HTTP ${response.status}`);
return payload;
};
const [systemResult, queueResult, historyResult] = await Promise.allSettled([
fetchJson('/system_stats'),
fetchJson('/queue'),
fetchJson(`/history?max_items=${Math.max(1, Math.min(100, Number(historyLimit) || 20))}`),
]);
const systemStats = systemResult.status === 'fulfilled' ? systemResult.value : null;
const queue = queueResult.status === 'fulfilled' ? queueResult.value : null;
const history = historyResult.status === 'fulfilled' ? historyResult.value : null;
const running = Array.isArray(queue?.queue_running) ? queue.queue_running.length : 0;
const pending = Array.isArray(queue?.queue_pending) ? queue.queue_pending.length : 0;
const historyItems = history && typeof history === 'object' ? Object.entries(history).slice(0, Math.max(1, Number(historyLimit) || 20)) : [];
return {
reachable: !!systemStats || !!queue || !!history,
url: config.url,
system_stats: systemStats,
queue,
history: Object.fromEntries(historyItems),
queue_summary: { running, pending, total: running + pending },
errors: {
system_stats: systemResult.status === 'rejected' ? systemResult.reason?.message || String(systemResult.reason) : null,
queue: queueResult.status === 'rejected' ? queueResult.reason?.message || String(queueResult.reason) : null,
history: historyResult.status === 'rejected' ? historyResult.reason?.message || String(historyResult.reason) : null,
},
};
}
export function ensureLocalFluxBootstrap({ token = '', installPath = '' } = {}) {
const localDir = join(PROJECT_DIR, 'local-ai');
const workflowsDir = join(localDir, 'workflows');
mkdirSync(workflowsDir, { recursive: true });
const workflowPath = join(workflowsDir, 'vendoo_flux_schnell_api.json');
if (!existsSync(workflowPath)) {
const workflow = {
'3': { inputs: { ckpt_name: 'flux1-schnell-fp8.safetensors' }, class_type: 'CheckpointLoaderSimple' },
'4': { inputs: { text: '__PROMPT__', clip: ['3', 1] }, class_type: 'CLIPTextEncode' },
'5': { inputs: { text: '__NEGATIVE_PROMPT__', clip: ['3', 1] }, class_type: 'CLIPTextEncode' },
'6': { inputs: { width: 1024, height: 1024, batch_size: 1 }, class_type: 'EmptyLatentImage' },
'7': { inputs: { seed: '__SEED__', steps: 6, cfg: 1, sampler_name: 'euler', scheduler: 'simple', denoise: 1, model: ['3', 0], positive: ['4', 0], negative: ['5', 0], latent_image: ['6', 0] }, class_type: 'KSampler' },
'8': { inputs: { samples: ['7', 0], vae: ['3', 2] }, class_type: 'VAEDecode' },
'9': { inputs: { filename_prefix: 'Vendoo/flux_prompt', images: ['8', 0] }, class_type: 'SaveImage' },
};
writeFileSync(workflowPath, JSON.stringify(workflow, null, 2), 'utf8');
}
const envLocalPath = join(localDir, '.env.local-flux');
if (!existsSync(envLocalPath)) {
writeFileSync(envLocalPath, [
'LOCAL_IMAGE_URL=http://127.0.0.1:8188',
`LOCAL_IMAGE_TOKEN=${token || generateLocalImageToken()}`,
`LOCAL_IMAGE_WORKFLOW_PATH=${DEFAULT_WORKFLOW_RELATIVE}`,
`LOCAL_IMAGE_INSTALL_PATH=${installPath}`,
].join('\n') + '\n', 'utf8');
}
return { workflow_path: toPosix(DEFAULT_WORKFLOW_RELATIVE), env_file: toPosix('local-ai/.env.local-flux') };
}