Files
vendoo/lib/ai-flux-prompt-jobs.mjs
T
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

618 lines
27 KiB
JavaScript

import { join } from 'path';
import { UPLOADS_DIR } from './runtime-paths.mjs';
import { mkdirSync, writeFileSync } from 'fs';
import { randomUUID } from 'crypto';
import {
generateWithLocalFlux,
waitForLocalFluxPrompt,
cancelLocalFluxPrompt,
} from './ai-local-images.mjs';
import { db, createFluxImage, getSettings } from './db.mjs';
const outputDir = join(UPLOADS_DIR, 'ai-generated');
mkdirSync(outputDir, { recursive: true });
const activeVariants = new Map();
let loopTimer = null;
let loopBusy = false;
const STYLE_SUFFIXES = {
none: '',
photorealistic: 'photorealistic image, natural materials, physically plausible lighting, realistic proportions, fine surface detail, clean coherent composition',
product: 'professional commercial product photography, clear hero subject, controlled studio lighting, crisp edges, realistic materials, premium catalog composition',
editorial: 'premium editorial photography, refined magazine composition, intentional styling, controlled light, elegant color harmony, realistic detail',
cinematic: 'cinematic still, purposeful framing, atmospheric but realistic lighting, natural depth, controlled contrast, coherent visual storytelling',
illustration: 'polished high-quality digital illustration, coherent shapes, intentional line and color treatment, refined detail, professional composition',
social: 'modern social media visual, immediately readable focal point, clean hierarchy, polished lighting, balanced composition, premium finish',
};
const PROFILE_CONFIG = {
fast: { maxEdge: 768, steps: 4 },
balanced: { maxEdge: 1024, steps: 4 },
quality: { maxEdge: 1024, steps: 6 },
custom: { maxEdge: 1536, steps: null },
};
const MODE_SUFFIXES = {
raw: '',
precise: 'The main subject must be unambiguous and visually dominant. Keep all requested objects, positions, materials, colors, perspective and environment consistent.',
creative: 'Create a visually distinctive but coherent interpretation while preserving the requested subject, purpose and essential attributes.',
};
const TERMINAL_VARIANT_STATUSES = new Set(['completed', 'failed', 'cancelled']);
const ACTIVE_JOB_STATUSES = new Set(['queued', 'running', 'cancelling']);
function normalizeWhitespace(value) {
return String(value || '').replace(/\s+/g, ' ').replace(/\s*,\s*/g, ', ').trim();
}
function removeNegativePromptPatterns(value) {
return normalizeWhitespace(value)
.replace(/\bwithout\s+([\w-]+)/gi, 'avoiding visible $1')
.replace(/\bohne\s+([\wäöüß-]+)/gi, 'mit einer Gestaltung, in der $1 nicht im Fokus steht');
}
export function buildFluxPrompt(prompt, { style = 'none', mode = 'precise', assistant = true } = {}) {
const cleaned = removeNegativePromptPatterns(prompt);
if (!assistant) return [cleaned, STYLE_SUFFIXES[style] || ''].filter(Boolean).join('. ');
const sections = [cleaned];
if (MODE_SUFFIXES[mode]) sections.push(MODE_SUFFIXES[mode]);
if (STYLE_SUFFIXES[style]) sections.push(STYLE_SUFFIXES[style]);
sections.push('single coherent scene, accurate anatomy and geometry where applicable, no unintended duplicate subjects, no watermark, no interface elements');
return sections.map(normalizeWhitespace).filter(Boolean).join('. ').replace(/\.{2,}/g, '.');
}
function clampDimension(value, fallback) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.max(256, Math.min(1536, Math.round(number / 8) * 8));
}
function cleanStyle(value) {
const style = String(value || 'none').toLowerCase();
return Object.hasOwn(STYLE_SUFFIXES, style) ? style : 'none';
}
function cleanProfile(value) {
const profile = String(value || 'balanced').toLowerCase();
return Object.hasOwn(PROFILE_CONFIG, profile) ? profile : 'balanced';
}
function cleanMode(value) {
const mode = String(value || 'precise').toLowerCase();
return Object.hasOwn(MODE_SUFFIXES, mode) ? mode : 'precise';
}
function cleanVariantCount(value) {
const count = Number(value);
return [1, 2, 4].includes(count) ? count : 1;
}
function normalizeSeed(value) {
return Number.isFinite(Number(value))
? Math.max(0, Math.min(2147483647, Math.round(Number(value))))
: Math.floor(Math.random() * 2147483647);
}
function randomSeed(excluded = new Set()) {
let seed = Math.floor(Math.random() * 2147483647);
while (excluded.has(seed)) seed = Math.floor(Math.random() * 2147483647);
return seed;
}
function buildSeeds(baseSeed, variants, seedLock, explicitSeeds = null) {
if (Array.isArray(explicitSeeds) && explicitSeeds.length === variants) {
return explicitSeeds.map(normalizeSeed);
}
const used = new Set();
const seeds = [];
const base = normalizeSeed(baseSeed);
for (let index = 0; index < variants; index += 1) {
let seed;
if (seedLock) seed = (base + index) % 2147483648;
else seed = index === 0 ? base : randomSeed(used);
while (used.has(seed)) seed = (seed + 1) % 2147483648;
used.add(seed);
seeds.push(seed);
}
return seeds;
}
function applyProfile({ profile, width, height, steps }) {
const cfg = PROFILE_CONFIG[profile];
let outWidth = clampDimension(width, 768);
let outHeight = clampDimension(height, 768);
if (profile !== 'custom') {
const edge = Math.max(outWidth, outHeight);
if (edge > cfg.maxEdge) {
const scale = cfg.maxEdge / edge;
outWidth = Math.max(256, Math.round((outWidth * scale) / 8) * 8);
outHeight = Math.max(256, Math.round((outHeight * scale) / 8) * 8);
}
}
return {
width: outWidth,
height: outHeight,
steps: cfg.steps ?? Math.max(4, Math.min(12, Math.round(Number(steps) || 4))),
};
}
function parseDate(value) {
if (!value) return null;
const iso = String(value).includes('T') ? String(value) : `${String(value).replace(' ', 'T')}Z`;
const time = Date.parse(iso);
return Number.isFinite(time) ? time : null;
}
function getVariantRows(jobId) {
return db.prepare(`SELECT v.*, i.image_path, i.favorite, i.prompt, i.final_prompt, i.width, i.height, i.steps, i.style, i.profile, i.prompt_mode
FROM flux_prompt_job_variants v
LEFT JOIN flux_images i ON i.id = v.image_id
WHERE v.job_id = ? ORDER BY v.variant_index`).all(String(jobId));
}
function publicVariant(row) {
return {
id: row.id,
job_id: row.job_id,
variant_index: Number(row.variant_index),
seed: Number(row.seed),
status: row.status,
phase: row.phase,
message: row.message || '',
prompt_id: row.prompt_id || null,
image_id: row.image_id || null,
image_path: row.image_path || null,
public_url: row.image_path ? `/uploads/${row.image_path}` : null,
favorite: Number(row.favorite || 0) === 1,
prompt: row.prompt || null,
final_prompt: row.final_prompt || null,
width: row.width ? Number(row.width) : null,
height: row.height ? Number(row.height) : null,
steps: row.steps ? Number(row.steps) : null,
style: row.style || null,
profile: row.profile || null,
prompt_mode: row.prompt_mode || null,
error: row.error_message || null,
duration_ms: row.duration_ms || null,
cancel_requested: Number(row.cancel_requested || 0) === 1,
created_at: row.created_at,
started_at: row.started_at,
completed_at: row.completed_at,
};
}
function publicJob(row, { includeVariants = true } = {}) {
if (!row) return null;
const variants = includeVariants ? getVariantRows(row.id).map(publicVariant) : undefined;
const createdMs = parseDate(row.created_at);
const startedMs = parseDate(row.started_at);
const completedMs = parseDate(row.completed_at);
return {
id: row.id,
prompt: row.prompt,
final_prompt: row.final_prompt,
width: Number(row.width),
height: Number(row.height),
steps: Number(row.steps),
style: row.style,
profile: row.profile,
prompt_mode: row.prompt_mode,
assistant: Number(row.assistant || 0) === 1,
variants_count: Number(row.variants),
seed_base: Number(row.seed_base),
seed_lock: Number(row.seed_lock || 0) === 1,
status: row.status,
phase: row.phase,
message: row.message || '',
error: row.error_message || null,
completed_variants: Number(row.completed_variants || 0),
source_job_id: row.source_job_id || null,
created_at: row.created_at,
started_at: row.started_at,
completed_at: row.completed_at,
archived_at: row.archived_at || null,
archived: Boolean(row.archived_at),
elapsed_ms: completedMs && startedMs ? completedMs - startedMs : startedMs ? Date.now() - startedMs : createdMs ? Date.now() - createdMs : null,
variants,
};
}
function fetchJobRow(id) {
return db.prepare('SELECT * FROM flux_prompt_jobs WHERE id = ?').get(String(id || ''));
}
function updateParentJob(jobId) {
const job = fetchJobRow(jobId);
if (!job) return null;
const variants = db.prepare('SELECT status, error_message FROM flux_prompt_job_variants WHERE job_id = ? ORDER BY variant_index').all(jobId);
const counts = variants.reduce((acc, item) => {
acc[item.status] = (acc[item.status] || 0) + 1;
return acc;
}, {});
const completed = counts.completed || 0;
const terminal = variants.length > 0 && variants.every(item => TERMINAL_VARIANT_STATUSES.has(item.status));
let status = job.status;
let phase = job.phase;
let message = job.message;
let errorMessage = null;
let completedAt = null;
if (terminal) {
completedAt = new Date().toISOString();
if (completed === variants.length) {
status = 'completed';
phase = 'completed';
message = `${completed} Bildvariante${completed === 1 ? '' : 'n'} fertig.`;
} else if ((counts.cancelled || 0) === variants.length) {
status = 'cancelled';
phase = 'cancelled';
message = 'Auftrag wurde abgebrochen.';
} else if (completed > 0) {
status = 'completed_with_errors';
phase = 'completed_with_errors';
message = `${completed} von ${variants.length} Varianten fertig.`;
errorMessage = variants.find(item => item.error_message)?.error_message || null;
} else {
status = 'failed';
phase = 'failed';
message = 'Alle Bildvarianten sind fehlgeschlagen.';
errorMessage = variants.find(item => item.error_message)?.error_message || null;
}
} else if ((counts.running || 0) > 0) {
status = job.status === 'cancelling' ? 'cancelling' : 'running';
phase = job.status === 'cancelling' ? 'cancelling' : 'rendering';
message = job.status === 'cancelling' ? 'Abbruch wird an ComfyUI übergeben …' : `${counts.running} Variante${counts.running === 1 ? '' : 'n'} läuft, ${counts.queued || 0} wartet.`;
} else if ((counts.queued || 0) > 0) {
status = job.status === 'cancelling' ? 'cancelling' : 'queued';
phase = job.status === 'cancelling' ? 'cancelling' : 'queued';
message = job.status === 'cancelling' ? 'Auftrag wird abgebrochen …' : `${counts.queued} Variante${counts.queued === 1 ? '' : 'n'} wartet.`;
}
db.prepare(`UPDATE flux_prompt_jobs SET status = ?, phase = ?, message = ?, error_message = ?, completed_variants = ?,
completed_at = COALESCE(?, completed_at), updated_at = datetime('now') WHERE id = ?`)
.run(status, phase, message, errorMessage, completed, completedAt, jobId);
return getFluxPromptJob(jobId);
}
function saveResultImage(job, variant, result, startedAt) {
const relative = `ai-generated/${randomUUID()}.png`;
writeFileSync(join(UPLOADS_DIR, relative), Buffer.from(result.base64, 'base64'));
const durationMs = Math.max(0, Date.now() - startedAt);
const image = createFluxImage({
prompt: job.prompt,
final_prompt: job.final_prompt,
image_path: relative,
width: job.width,
height: job.height,
seed: variant.seed,
steps: job.steps,
style: job.style,
profile: job.profile,
duration_ms: durationMs,
job_id: job.id,
variant_index: variant.variant_index,
prompt_mode: job.prompt_mode,
parameters: {
width: Number(job.width),
height: Number(job.height),
steps: Number(job.steps),
style: job.style,
profile: job.profile,
prompt_mode: job.prompt_mode,
seed_lock: Number(job.seed_lock || 0) === 1,
variants: Number(job.variants),
},
});
db.prepare(`UPDATE flux_prompt_job_variants SET status = 'completed', phase = 'completed', message = ?, image_id = ?,
duration_ms = ?, completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`)
.run(`Variante ${variant.variant_index} ist fertig.`, image.id, durationMs, variant.id);
return image;
}
async function variantCancelled(variantId) {
const row = db.prepare('SELECT cancel_requested FROM flux_prompt_job_variants WHERE id = ?').get(variantId);
return Number(row?.cancel_requested || 0) === 1;
}
async function runVariant(variantId, { resume = false } = {}) {
if (activeVariants.has(variantId)) return;
const variant = db.prepare('SELECT * FROM flux_prompt_job_variants WHERE id = ?').get(variantId);
if (!variant) return;
const job = fetchJobRow(variant.job_id);
if (!job || !ACTIVE_JOB_STATUSES.has(job.status)) return;
activeVariants.set(variantId, true);
const startedAt = parseDate(variant.started_at) || Date.now();
db.prepare(`UPDATE flux_prompt_job_variants SET status = 'running', phase = ?, message = ?, started_at = COALESCE(started_at, datetime('now')), updated_at = datetime('now') WHERE id = ?`)
.run(resume ? 'resuming' : 'preparing', resume ? 'Vorhandener ComfyUI-Auftrag wird wieder aufgenommen …' : 'FLUX-Workflow wird vorbereitet …', variantId);
db.prepare(`UPDATE flux_prompt_jobs SET status = CASE WHEN status = 'cancelling' THEN status ELSE 'running' END,
phase = CASE WHEN status = 'cancelling' THEN 'cancelling' ELSE 'rendering' END,
started_at = COALESCE(started_at, datetime('now')), updated_at = datetime('now') WHERE id = ?`).run(job.id);
try {
const settings = getSettings();
const onPhase = (phase, message) => {
db.prepare(`UPDATE flux_prompt_job_variants SET phase = ?, message = ?, updated_at = datetime('now') WHERE id = ?`)
.run(String(phase || 'running'), String(message || 'FLUX arbeitet …').slice(0, 500), variantId);
};
let result;
if (resume && variant.prompt_id) {
result = await waitForLocalFluxPrompt({
promptId: variant.prompt_id,
settings,
timeoutMs: 300000,
onPhase,
isCancelled: () => variantCancelled(variantId),
});
} else {
result = await generateWithLocalFlux({
prompt: job.final_prompt,
settings: { ...settings, ai_model_photo_use_source_reference: '0', ai_model_photo_local_resolution: String(Math.max(job.width, job.height) >= 1024 ? 1024 : 768) },
mode: 'prompt',
variantIndex: Math.max(0, Number(variant.variant_index) - 1),
sourceDataUrl: '',
width: Number(job.width),
height: Number(job.height),
seed: Number(variant.seed),
steps: Number(job.steps),
onPhase,
onSubmitted: async (promptId) => {
db.prepare(`UPDATE flux_prompt_job_variants SET prompt_id = ?, phase = 'submitted', message = 'An ComfyUI übergeben …', updated_at = datetime('now') WHERE id = ?`)
.run(String(promptId), variantId);
},
isCancelled: () => variantCancelled(variantId),
});
}
if (await variantCancelled(variantId)) {
const error = new Error('FLUX-Auftrag wurde abgebrochen.');
error.code = 'FLUX_CANCELLED';
throw error;
}
saveResultImage(job, variant, result, startedAt);
} catch (error) {
const cancelled = error?.code === 'FLUX_CANCELLED' || await variantCancelled(variantId);
db.prepare(`UPDATE flux_prompt_job_variants SET status = ?, phase = ?, message = ?, error_message = ?,
completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`)
.run(
cancelled ? 'cancelled' : 'failed',
cancelled ? 'cancelled' : 'failed',
cancelled ? 'Variante wurde abgebrochen.' : 'Bildgenerierung fehlgeschlagen.',
cancelled ? null : String(error?.message || error).slice(0, 2000),
variantId,
);
} finally {
activeVariants.delete(variantId);
updateParentJob(job.id);
queueMicrotask(() => processQueue().catch(() => {}));
}
}
function getMaxParallelJobs() {
const settings = getSettings();
return Math.max(1, Math.min(4, Math.round(Number(settings.flux_max_parallel_jobs) || 1)));
}
async function processQueue() {
if (loopBusy) return;
loopBusy = true;
try {
const maxParallel = getMaxParallelJobs();
while (activeVariants.size < maxParallel) {
const next = db.prepare(`SELECT v.id FROM flux_prompt_job_variants v
JOIN flux_prompt_jobs j ON j.id = v.job_id
WHERE v.status = 'queued' AND v.cancel_requested = 0 AND j.archived_at IS NULL AND j.status IN ('queued','running')
ORDER BY j.created_at, v.variant_index LIMIT 1`).get();
if (!next) break;
runVariant(next.id).catch(() => {});
await new Promise(resolve => setTimeout(resolve, 10));
}
} finally {
loopBusy = false;
}
}
function createJob({
prompt,
width = 768,
height = 768,
seed = null,
steps = 4,
style = 'none',
profile = 'balanced',
promptMode = 'precise',
assistant = true,
variants = 1,
seedLock = false,
sourceJobId = null,
explicitSeeds = null,
} = {}) {
const cleanPrompt = normalizeWhitespace(prompt);
if (cleanPrompt.length < 3) throw new Error('Bitte einen aussagekräftigen Prompt eingeben.');
if (cleanPrompt.length > 2000) throw new Error('Der Prompt darf höchstens 2.000 Zeichen lang sein.');
const normalizedProfile = cleanProfile(profile);
const dimensions = applyProfile({ profile: normalizedProfile, width, height, steps });
const normalizedStyle = cleanStyle(style);
const normalizedMode = cleanMode(promptMode);
const variantCount = cleanVariantCount(variants);
const baseSeed = normalizeSeed(seed);
const seeds = buildSeeds(baseSeed, variantCount, seedLock === true, explicitSeeds);
const id = randomUUID();
const finalPrompt = buildFluxPrompt(cleanPrompt, { style: normalizedStyle, mode: normalizedMode, assistant: assistant !== false });
const insert = db.transaction(() => {
db.prepare(`INSERT INTO flux_prompt_jobs
(id, prompt, final_prompt, width, height, steps, style, profile, prompt_mode, assistant, variants, seed_base, seed_lock,
status, phase, message, source_job_id, settings_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', 'queued', ?, ?, '{}')`)
.run(id, cleanPrompt, finalPrompt, dimensions.width, dimensions.height, dimensions.steps, normalizedStyle, normalizedProfile,
normalizedMode, assistant !== false ? 1 : 0, variantCount, seeds[0], seedLock === true ? 1 : 0,
`${variantCount} Variante${variantCount === 1 ? '' : 'n'} wartet${variantCount === 1 ? '' : 'en'} auf FLUX.`, sourceJobId || null);
const stmt = db.prepare(`INSERT INTO flux_prompt_job_variants
(id, job_id, variant_index, seed, status, phase, message) VALUES (?, ?, ?, ?, 'queued', 'queued', ?)`);
seeds.forEach((variantSeed, index) => stmt.run(randomUUID(), id, index + 1, variantSeed, `Variante ${index + 1} wartet.`));
});
insert();
processQueue().catch(() => {});
return getFluxPromptJob(id);
}
export function enqueueFluxPromptJob(payload = {}) {
return createJob(payload);
}
export function getFluxPromptJob(id) {
return publicJob(fetchJobRow(id));
}
function fluxStatusWhere(status = '') {
const normalized = String(status || '').trim();
if (normalized === 'active') return { sql: "status IN ('queued','running','cancelling')", args: [] };
if (normalized === 'completed') return { sql: "status IN ('completed','completed_with_errors')", args: [] };
if (['failed', 'cancelled', 'queued', 'running', 'cancelling', 'completed', 'completed_with_errors'].includes(normalized)) {
return { sql: 'status = ?', args: [normalized] };
}
return { sql: '', args: [] };
}
export function listFluxPromptJobs({ limit = 50, status = '', archived = false } = {}) {
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 50));
const statusClause = fluxStatusWhere(status);
const where = [archived ? 'archived_at IS NOT NULL' : 'archived_at IS NULL'];
if (statusClause.sql) where.push(statusClause.sql);
const rows = db.prepare(`SELECT * FROM flux_prompt_jobs WHERE ${where.join(' AND ')} ORDER BY created_at DESC LIMIT ?`)
.all(...statusClause.args, safeLimit);
return rows.map(row => publicJob(row));
}
export function paginateFluxPromptJobs({ page = 1, pageSize = 10, status = '', archived = false } = {}) {
const safePageSize = Math.max(5, Math.min(15, Number(pageSize) || 10));
const requestedPage = Math.max(1, Number(page) || 1);
const statusClause = fluxStatusWhere(status);
const where = [archived ? 'archived_at IS NOT NULL' : 'archived_at IS NULL'];
if (statusClause.sql) where.push(statusClause.sql);
const whereSql = where.join(' AND ');
const total = Number(db.prepare(`SELECT COUNT(*) AS count FROM flux_prompt_jobs WHERE ${whereSql}`).get(...statusClause.args)?.count || 0);
const totalPages = Math.max(1, Math.ceil(total / safePageSize));
const safePage = Math.min(requestedPage, totalPages);
const rows = db.prepare(`SELECT * FROM flux_prompt_jobs WHERE ${whereSql} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
.all(...statusClause.args, safePageSize, (safePage - 1) * safePageSize);
return {
jobs: rows.map(row => publicJob(row)),
pagination: { page: safePage, page_size: safePageSize, total, total_pages: totalPages },
};
}
function normalizeJobIds(ids = []) {
return [...new Set((Array.isArray(ids) ? ids : [ids]).map(value => String(value || '').trim()).filter(Boolean))].slice(0, 500);
}
export function archiveFluxPromptJobs(ids = []) {
const jobIds = normalizeJobIds(ids);
if (!jobIds.length) return { ok: true, archived: 0, skipped: 0 };
const placeholders = jobIds.map(() => '?').join(',');
const result = db.prepare(`UPDATE flux_prompt_jobs SET archived_at = datetime('now'), updated_at = datetime('now')
WHERE id IN (${placeholders}) AND archived_at IS NULL AND status NOT IN ('queued','running','cancelling')`).run(...jobIds);
return { ok: true, archived: Number(result.changes || 0), skipped: jobIds.length - Number(result.changes || 0) };
}
export function restoreFluxPromptJobs(ids = []) {
const jobIds = normalizeJobIds(ids);
if (!jobIds.length) return { ok: true, restored: 0 };
const placeholders = jobIds.map(() => '?').join(',');
const result = db.prepare(`UPDATE flux_prompt_jobs SET archived_at = NULL, updated_at = datetime('now') WHERE id IN (${placeholders}) AND archived_at IS NOT NULL`).run(...jobIds);
return { ok: true, restored: Number(result.changes || 0) };
}
export function deleteArchivedFluxPromptJobs(ids = [], { all = false } = {}) {
if (all) {
const result = db.prepare('DELETE FROM flux_prompt_jobs WHERE archived_at IS NOT NULL').run();
return { ok: true, deleted: Number(result.changes || 0) };
}
const jobIds = normalizeJobIds(ids);
if (!jobIds.length) return { ok: true, deleted: 0 };
const placeholders = jobIds.map(() => '?').join(',');
const result = db.prepare(`DELETE FROM flux_prompt_jobs WHERE archived_at IS NOT NULL AND id IN (${placeholders})`).run(...jobIds);
return { ok: true, deleted: Number(result.changes || 0) };
}
export function getFluxPromptJobSummary() {
const rows = db.prepare('SELECT status, COUNT(*) AS count FROM flux_prompt_jobs WHERE archived_at IS NULL GROUP BY status').all();
const jobs = Object.fromEntries(rows.map(row => [row.status, Number(row.count)]));
const archivedTotal = Number(db.prepare('SELECT COUNT(*) AS count FROM flux_prompt_jobs WHERE archived_at IS NOT NULL').get()?.count || 0);
const variants = db.prepare(`SELECT
SUM(CASE WHEN v.status = 'running' THEN 1 ELSE 0 END) AS running,
SUM(CASE WHEN v.status = 'queued' THEN 1 ELSE 0 END) AS queued
FROM flux_prompt_job_variants v JOIN flux_prompt_jobs j ON j.id = v.job_id WHERE j.archived_at IS NULL`).get();
return {
jobs,
archived_total: archivedTotal,
variants: { running: Number(variants?.running || 0), queued: Number(variants?.queued || 0) },
active_workers: activeVariants.size,
max_parallel: getMaxParallelJobs(),
};
}
export async function cancelFluxPromptJob(id) {
const job = fetchJobRow(id);
if (!job) return null;
if (!ACTIVE_JOB_STATUSES.has(job.status)) return getFluxPromptJob(id);
const runningVariants = db.prepare("SELECT id, prompt_id FROM flux_prompt_job_variants WHERE job_id = ? AND status = 'running'").all(id);
db.transaction(() => {
db.prepare("UPDATE flux_prompt_jobs SET status = 'cancelling', phase = 'cancelling', message = 'Abbruch wird ausgeführt …', updated_at = datetime('now') WHERE id = ?").run(id);
db.prepare("UPDATE flux_prompt_job_variants SET status = 'cancelled', phase = 'cancelled', message = 'Vor Start abgebrochen.', completed_at = datetime('now'), updated_at = datetime('now') WHERE job_id = ? AND status = 'queued'").run(id);
db.prepare("UPDATE flux_prompt_job_variants SET cancel_requested = 1, phase = 'cancelling', message = 'Abbruch angefordert …', updated_at = datetime('now') WHERE job_id = ? AND status = 'running'").run(id);
})();
if (runningVariants.length > 0) {
const settings = getSettings();
for (const variant of runningVariants) {
if (!variant.prompt_id) continue;
try { await cancelLocalFluxPrompt(variant.prompt_id, settings); } catch {}
}
}
updateParentJob(id);
return getFluxPromptJob(id);
}
export function retryFluxPromptJob(id) {
const source = fetchJobRow(id);
if (!source) return null;
const variants = getVariantRows(id);
return createJob({
prompt: source.prompt,
width: source.width,
height: source.height,
seed: source.seed_base,
steps: source.steps,
style: source.style,
profile: source.profile,
promptMode: source.prompt_mode,
assistant: Number(source.assistant || 0) === 1,
variants: source.variants,
seedLock: Number(source.seed_lock || 0) === 1,
sourceJobId: source.id,
explicitSeeds: variants.map(item => Number(item.seed)),
});
}
export function clearFluxPromptQueue() {
const jobIds = db.prepare(`SELECT DISTINCT job_id FROM flux_prompt_job_variants WHERE status = 'queued'`).all().map(row => row.job_id);
const result = db.prepare(`UPDATE flux_prompt_job_variants SET status = 'cancelled', phase = 'cancelled', message = 'Aus Warteschlange entfernt.',
completed_at = datetime('now'), updated_at = datetime('now') WHERE status = 'queued'`).run();
jobIds.forEach(updateParentJob);
return { ok: true, cleared_variants: Number(result.changes || 0), affected_jobs: jobIds.length };
}
export function stopFluxPromptJobLoop() {
if (loopTimer) clearInterval(loopTimer);
loopTimer = null;
}
export function startFluxPromptJobLoop() {
if (loopTimer) return;
const running = db.prepare("SELECT id, prompt_id FROM flux_prompt_job_variants WHERE status = 'running'").all();
for (const variant of running) {
if (variant.prompt_id) runVariant(variant.id, { resume: true }).catch(() => {});
else db.prepare("UPDATE flux_prompt_job_variants SET status = 'queued', phase = 'queued', message = 'Nach Vendoo-Neustart erneut eingereiht.', updated_at = datetime('now') WHERE id = ?").run(variant.id);
}
db.prepare("UPDATE flux_prompt_jobs SET status = 'queued', phase = 'queued', message = 'Nach Vendoo-Neustart wieder aufgenommen.', updated_at = datetime('now') WHERE status = 'running' AND id NOT IN (SELECT job_id FROM flux_prompt_job_variants WHERE status = 'running')").run();
loopTimer = setInterval(() => processQueue().catch(() => {}), 1000);
loopTimer.unref?.();
processQueue().catch(() => {});
}