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(() => {}); }