* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix * fix: track native source modules with root-anchored runtime ignores
618 lines
27 KiB
JavaScript
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(() => {});
|
|
}
|