diff --git a/migrations/004_modes_thumbs_storage_nas.sql b/migrations/004_modes_thumbs_storage_nas.sql new file mode 100644 index 0000000..64702a5 --- /dev/null +++ b/migrations/004_modes_thumbs_storage_nas.sql @@ -0,0 +1,35 @@ +-- Klarbild — Erzeugungs-Modi, Vorschaubilder, Speicherverwaltung, NAS-Sicherung. +-- (04) Kombinieren aus mehreren Bildern + Freitext-Erzeugung, Thumbnails für die +-- Bibliothek, „nicht den Server vollmüllen" (Retention/Quellenlöschen), zweite +-- Datensicherung auf ein Synology-NAS. + +-- Auftrags-Modus: each = jede Vorlage einzeln (bisher), compose = mehrere Bilder + Text +-- zu EINEM neuen Bild, generate = reiner Freitext ohne Vorlage. +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS mode text NOT NULL DEFAULT 'each' + CHECK (mode IN ('each','compose','generate')); + +-- Positionen: mehrere Quellbilder (compose) + verkleinertes Vorschaubild. +ALTER TABLE items ADD COLUMN IF NOT EXISTS source_paths jsonb; +ALTER TABLE items ADD COLUMN IF NOT EXISTS thumb_path text; + +-- Rezepte können einen Modus vorbelegen (für Telegram-Standardrezepte). +ALTER TABLE recipes ADD COLUMN IF NOT EXISTS mode text NOT NULL DEFAULT 'each' + CHECK (mode IN ('each','compose','generate')); + +-- Speicherverwaltung ------------------------------------------------------- +ALTER TABLE settings ADD COLUMN IF NOT EXISTS keep_sources bool NOT NULL DEFAULT true; +ALTER TABLE settings ADD COLUMN IF NOT EXISTS make_thumbnails bool NOT NULL DEFAULT true; +ALTER TABLE settings ADD COLUMN IF NOT EXISTS retention_days int; -- NULL = unbegrenzt + +-- Zweite Sicherung auf Synology-NAS (SFTP/FTPS, wie Picdrop) ---------------- +ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_enabled bool NOT NULL DEFAULT false; +ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_host text; +ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_protocol text CHECK (nas_protocol IN ('ftps','sftp')); +ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_port int; +ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_user text; +ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_password_enc text; +ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_base_path text; + +-- Spiegel-Status je Position (für Sichtbarkeit/Wiederholung). +ALTER TABLE items ADD COLUMN IF NOT EXISTS nas_status text NOT NULL DEFAULT 'none' + CHECK (nas_status IN ('none','pending','mirrored','failed')); diff --git a/migrations/005_telegram_compose.sql b/migrations/005_telegram_compose.sql new file mode 100644 index 0000000..5cc1281 --- /dev/null +++ b/migrations/005_telegram_compose.sql @@ -0,0 +1,5 @@ +-- Telegram: Kombinieren/Erzeugen — Bildunterschrift als Beschreibung + Zwischenstatus. +ALTER TABLE telegram_drafts ADD COLUMN IF NOT EXISTS caption text; +ALTER TABLE telegram_drafts DROP CONSTRAINT IF EXISTS telegram_drafts_status_check; +ALTER TABLE telegram_drafts ADD CONSTRAINT telegram_drafts_status_check + CHECK (status IN ('collecting','awaiting_recipe','awaiting_compose_text','dispatched','discarded')); diff --git a/src/lib/openrouter.ts b/src/lib/openrouter.ts index 4295721..d9b3a8d 100644 --- a/src/lib/openrouter.ts +++ b/src/lib/openrouter.ts @@ -30,7 +30,8 @@ function friendlyFor(status: number): string { export interface GenerateOpts { model: string; prompt: string; - inputUrl?: string; // vorsignierte URL des Originals + inputUrl?: string; // eine Vorlage (Data-URL) — Kurzform + inputUrls?: string[]; // mehrere Vorlagen (compose) — bis zu 14/16 je Modell aspectRatio?: string; // "16:9", "3:4" … resolution?: string; // "2K" | "4K" — NICHT zusammen mit expliziten Pixeln background?: 'transparent' | 'opaque'; @@ -56,7 +57,8 @@ export async function generateImage(opts: GenerateOpts): Promise n: 1, output_format: opts.outputFormat || 'png', }; - if (opts.inputUrl) body.input_references = [{ type: 'image_url', image_url: { url: opts.inputUrl } }]; + const refs = opts.inputUrls?.length ? opts.inputUrls : (opts.inputUrl ? [opts.inputUrl] : []); + if (refs.length) body.input_references = refs.map((url) => ({ type: 'image_url', image_url: { url } })); if (opts.aspectRatio) body.aspect_ratio = opts.aspectRatio; if (opts.resolution && !opts.aspectRatio) body.resolution = opts.resolution; // nie beides if (opts.background) body.background = opts.background; diff --git a/src/lib/process.ts b/src/lib/process.ts index 038b4f5..77dcc65 100644 --- a/src/lib/process.ts +++ b/src/lib/process.ts @@ -1,13 +1,15 @@ import { one, query } from './db'; -import { getObject, putObject, resultKey } from './storage'; +import { getObject, putObject, deleteObject, resultKey, thumbKey } from './storage'; import { generateImage } from './openrouter'; -import { buildPrompt, type Task } from './prompts'; +import { buildPrompt, buildComposePrompt, buildGeneratePrompt, type Task } from './prompts'; import { resolveDimensions, aspectRatio, type Orientation } from './format'; import { finalizeToFormat, stickerContour, buildResultFilename, formatToken, type CropMode } from './pipeline'; import sharp from 'sharp'; const DEFAULT_MODEL = 'google/gemini-3.1-flash-image'; +type Mode = 'each' | 'compose' | 'generate'; + interface RecipeSnapshot { tasks: Task[]; output_format?: string; @@ -17,6 +19,7 @@ interface RecipeSnapshot { contour_mm?: number | null; model_key?: string | null; custom_instruction?: string | null; + prompt_text?: string | null; // Beschreibung für compose/generate delivery?: 'library' | 'picdrop' | 'both'; picdrop_gallery?: string | null; } @@ -42,47 +45,70 @@ async function toDataUrl(buf: Buffer): Promise { return `data:image/jpeg;base64,${small.toString('base64')}`; } +/** Kleines Vorschaubild (webp) für die Bibliothek — spart Bandbreite & Speicher. */ +async function makeThumb(buf: Buffer): Promise { + return sharp(buf, { failOn: 'none' }) + .resize(600, 600, { fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 72 }) + .toBuffer(); +} + export interface ProcessResult { ok: boolean; cost: number; error?: string; } /** Verarbeitet eine Position vollständig und aktualisiert ihren Datensatz. */ export async function processItem(itemId: string): Promise { - const item = await one<{ id: string; job_id: string; source_path: string; filename: string | null }>( - 'SELECT id, job_id, source_path, filename FROM items WHERE id=$1', [itemId]); + const item = await one<{ id: string; job_id: string; source_path: string | null; source_paths: string[] | null; filename: string | null }>( + 'SELECT id, job_id, source_path, source_paths, filename FROM items WHERE id=$1', [itemId]); if (!item) return { ok: false, cost: 0, error: 'Position nicht gefunden' }; - const job = await one<{ recipe_snapshot: RecipeSnapshot }>( - 'SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]); + const job = await one<{ recipe_snapshot: RecipeSnapshot; mode: Mode }>( + 'SELECT recipe_snapshot, mode FROM jobs WHERE id=$1', [item.job_id]); const r = (job?.recipe_snapshot || {}) as RecipeSnapshot; + const mode: Mode = job?.mode || 'each'; const tasks = r.tasks || []; const dpi = r.dpi ?? 300; const cropMode: CropMode = r.crop_mode === 'extend' ? 'extend' : 'crop'; const wantCutout = tasks.includes('cutout'); const wantContour = tasks.includes('contour'); + const description = (r.prompt_text || '').trim(); await query(`UPDATE items SET status='running', attempts=attempts+1 WHERE id=$1`, [itemId]); try { - const source = await getObject(item.source_path); - const target = r.tasks.includes('format') && r.output_format + // Quellen je Modus einsammeln + const sourceKeys: string[] = + mode === 'compose' ? (item.source_paths || []).filter(Boolean) + : mode === 'generate' ? [] + : (item.source_path ? [item.source_path] : []); + const sources = await Promise.all(sourceKeys.map((k) => getObject(k))); + + const target = tasks.includes('format') && r.output_format ? resolveDimensions({ format: r.output_format, orientation: r.orientation, dpi }) : null; const model = await pickModel(r.model_key); - const prompt = buildPrompt({ tasks, cropMode, customInstruction: r.custom_instruction }); - // Nur Modell aufrufen, wenn eine generative Aufgabe dabei ist. - const needsModel = tasks.includes('clean') || wantCutout || - (tasks.includes('format') && cropMode === 'extend') || !!r.custom_instruction; + // Prompt + ob das Modell überhaupt gebraucht wird, je Modus. + let prompt: string; + let needsModel: boolean; + if (mode === 'compose') { prompt = buildComposePrompt(description, cropMode === 'extend'); needsModel = true; } + else if (mode === 'generate') { prompt = buildGeneratePrompt(description); needsModel = true; } + else { + prompt = buildPrompt({ tasks, cropMode, customInstruction: r.custom_instruction }); + needsModel = tasks.includes('clean') || wantCutout || + (tasks.includes('format') && cropMode === 'extend') || !!r.custom_instruction; + } - let working = source; + let working = sources[0] || Buffer.alloc(0); let modelUsed: string | null = null; let cost = 0; if (needsModel) { + const inputUrls = sources.length ? await Promise.all(sources.map(toDataUrl)) : undefined; const gen = await generateImage({ model: model.id, prompt, - inputUrl: await toDataUrl(source), + inputUrls, aspectRatio: target ? aspectRatio(target.w, target.h) : undefined, background: wantCutout ? 'transparent' : undefined, outputFormat: 'png', @@ -91,6 +117,7 @@ export async function processItem(itemId: string): Promise { modelUsed = gen.model; cost = gen.cost; } + if (!working.length) throw new Error('Keine Bilddaten erzeugt'); // Lokale Nachbearbeitung: exakt aufs Zielmaß + dpi const fin = await finalizeToFormat(working, target, cropMode, dpi); @@ -105,27 +132,51 @@ export async function processItem(itemId: string): Promise { const key = resultKey(item.id); await putObject(key, outBuf, 'image/png'); + // Vorschaubild (optional, laut Einstellung) + const cfg = await one<{ make_thumbnails: boolean; keep_sources: boolean; nas_enabled: boolean }>( + 'SELECT make_thumbnails, keep_sources, nas_enabled FROM settings WHERE id=1'); + let thumbPath: string | null = null; + if (cfg?.make_thumbnails !== false) { + try { + const tk = thumbKey(item.id); + await putObject(tk, await makeThumb(outBuf), 'image/webp'); + thumbPath = tk; + } catch (e) { console.error('[process] Thumbnail fehlgeschlagen', e); } + } + const filename = buildResultFilename(item.filename, formatToken(r.output_format), 'png'); const outputPx = `${fin.width}x${fin.height}`; const deliveryStatus = (r.delivery === 'picdrop' || r.delivery === 'both') ? 'pending' : 'none'; + const nasStatus = cfg?.nas_enabled ? 'pending' : 'none'; await query( - `UPDATE items SET status='done', result_path=$2, filename=$3, output_px=$4, dpi=$5, - has_alpha=$6, model_used=$7, prompt_used=$8, cost=$9, error_message=NULL, - delivery_status=$10 WHERE id=$1`, - [itemId, key, filename, outputPx, dpi, hasAlpha, modelUsed, prompt.slice(0, 1000), - cost, deliveryStatus]); + `UPDATE items SET status='done', result_path=$2, thumb_path=$3, filename=$4, output_px=$5, dpi=$6, + has_alpha=$7, model_used=$8, prompt_used=$9, cost=$10, error_message=NULL, + delivery_status=$11, nas_status=$12 WHERE id=$1`, + [itemId, key, thumbPath, filename, outputPx, dpi, hasAlpha, modelUsed, prompt.slice(0, 1000), + cost, deliveryStatus, nasStatus]); + + // Quellbilder löschen, wenn nicht behalten (Speicher sparen). + if (cfg?.keep_sources === false) { + for (const k of sourceKeys) await deleteObject(k).catch(() => {}); + } + + // Zweite Sicherung auf NAS (best effort, blockiert den Erfolg nicht). + if (cfg?.nas_enabled) { + try { + const { mirrorItemToNas } = await import('./nas'); + await mirrorItemToNas(itemId); + } catch (e) { console.error('[process] NAS-Spiegelung fehlgeschlagen', e); } + } return { ok: true, cost }; } catch (e: any) { const real = e?.message || String(e); const status = e?.status ? ` [status ${e.status}]` : ''; console.error(`[process] Item ${itemId} fehlgeschlagen${status}: ${real}`, e?.stack || ''); - // Nutzeranzeige: freundlich, aber mit kompaktem Grund fürs Debugging in dieser Phase const msg = (e?.friendly ? `${e.friendly}` : real) + (e?.status ? ` (${e.status})` : ''); await query(`UPDATE items SET status='failed', error_message=$2 WHERE id=$1`, [itemId, String(msg).slice(0, 500)]); - // 402/Guthaben: nach oben durchreichen, damit der Worker den Auftrag anhält if (e?.status === 402) throw e; return { ok: false, cost: 0, error: msg }; } diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index 7db9814..8d4079b 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -43,3 +43,25 @@ export function buildPrompt({ tasks, cropMode, customInstruction }: PromptOpts): parts.push('Output only the resulting image.'); return parts.join(' '); } + +/** Kombinieren: mehrere Vorlagen + Beschreibung → EIN neues Bild. + * Die erste Vorlage ist das Leitbild; weitere liefern Elemente/Personen/Motive. */ +export function buildComposePrompt(description: string, extend = false): string { + const parts = [ + 'You are given several reference images. Combine them into ONE new, coherent, ' + + 'high-resolution image that follows the instruction below. Treat the first image as ' + + 'the main scene/style reference and use the other images as elements to integrate ' + + '(people, pets, objects) — match their identity, colors and lighting faithfully so ' + + 'they look naturally part of the same photo.', + ]; + if (description.trim()) parts.push(`Instruction: ${description.trim()}`); + if (extend) parts.push('Extend the scene naturally to fill the requested aspect ratio (outpainting).'); + parts.push('Output only the resulting image.'); + return parts.join(' '); +} + +/** Freitext: gar keine Vorlage → ein komplett neues Bild aus der Beschreibung. */ +export function buildGeneratePrompt(description: string): string { + const d = description.trim() || 'A clean, high-quality image.'; + return `Create a new, high-resolution image. ${d} Output only the resulting image.`; +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index e431cce..347d0f0 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -90,3 +90,6 @@ export function sourceKey(uuid: string, ext: string): string { export function resultKey(uuid: string): string { return `results/${new Date().getFullYear()}/${uuid}.png`; } +export function thumbKey(uuid: string): string { + return `thumbs/${new Date().getFullYear()}/${uuid}.webp`; +} diff --git a/src/pages/api/jobs/index.ts b/src/pages/api/jobs/index.ts index b8e795f..624cd4a 100644 --- a/src/pages/api/jobs/index.ts +++ b/src/pages/api/jobs/index.ts @@ -14,12 +14,19 @@ export const GET: APIRoute = async ({ locals }) => { return json({ jobs: rows }); }; -// Body: { recipeId?, recipe?, sources:[{source_path, filename, source_quality?}], origin? } +// Body: { recipeId?, recipe?, sources:[{source_path, filename, source_quality?}], +// mode?: 'each'|'compose'|'generate', prompt_text?, origin? } export const POST: APIRoute = async ({ request, locals }) => { if (!locals.user) return new Response('Unauthorized', { status: 401 }); const b = await request.json(); + const mode: 'each' | 'compose' | 'generate' = ['each', 'compose', 'generate'].includes(b.mode) ? b.mode : 'each'; const sources: any[] = b.sources || []; - if (!sources.length) return json({ error: 'Keine Bilder.' }, 400); + const promptText: string = (b.prompt_text || '').trim(); + + if (mode === 'each' && !sources.length) return json({ error: 'Keine Bilder.' }, 400); + if (mode === 'compose' && sources.length < 2) return json({ error: 'Zum Kombinieren mindestens 2 Bilder.' }, 400); + if (mode === 'compose' && !promptText) return json({ error: 'Bitte beschreiben, was entstehen soll.' }, 400); + if (mode === 'generate' && !promptText) return json({ error: 'Bitte einen Text eingeben.' }, 400); let snapshot: any = b.recipe; if (b.recipeId) { @@ -27,11 +34,11 @@ export const POST: APIRoute = async ({ request, locals }) => { if (!r) return json({ error: 'Rezept nicht gefunden.' }, 404); snapshot = r; } - if (!snapshot) return json({ error: 'Kein Rezept.' }, 400); + snapshot = snapshot || {}; // Rezept-Snapshot einfrieren const snap = { - tasks: snapshot.tasks || [], + tasks: snapshot.tasks || (mode === 'each' ? [] : []), output_format: snapshot.output_format, orientation: snapshot.orientation, crop_mode: snapshot.crop_mode || 'crop', @@ -39,21 +46,33 @@ export const POST: APIRoute = async ({ request, locals }) => { contour_mm: snapshot.contour_mm ?? null, model_key: snapshot.model_key ?? null, custom_instruction: snapshot.custom_instruction ?? null, + prompt_text: promptText || null, delivery: snapshot.delivery || 'library', picdrop_gallery: snapshot.picdrop_gallery ?? null, }; + const total = (mode === 'each') ? sources.length : 1; const job = await one<{ id: string }>( - `INSERT INTO jobs (created_by, origin, recipe_snapshot, status, total) - VALUES ($1,$2,$3,'queued',$4) RETURNING id`, - [locals.user.uid, b.origin || 'web', JSON.stringify(snap), sources.length]); + `INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total) + VALUES ($1,$2,$3,$4,'queued',$5) RETURNING id`, + [locals.user.uid, b.origin || 'web', mode, JSON.stringify(snap), total]); - for (let i = 0; i < sources.length; i++) { - const s = sources[i]; + if (mode === 'each') { + for (let i = 0; i < sources.length; i++) { + const s = sources[i]; + const item = await one<{ id: string }>( + `INSERT INTO items (job_id, position, status, source_path, filename, source_quality) + VALUES ($1,$2,'queued',$3,$4,$5) RETURNING id`, + [job!.id, i, s.source_path, s.filename || null, s.source_quality || 'original']); + await enqueue({ itemId: item!.id, jobId: job!.id }); + } + } else { + const srcPaths = sources.map((s) => s.source_path).filter(Boolean); + const base = mode === 'generate' ? 'neu' : 'kombiniert'; const item = await one<{ id: string }>( - `INSERT INTO items (job_id, position, status, source_path, filename, source_quality) - VALUES ($1,$2,'queued',$3,$4,$5) RETURNING id`, - [job!.id, i, s.source_path, s.filename || null, s.source_quality || 'original']); + `INSERT INTO items (job_id, position, status, source_paths, filename, source_quality) + VALUES ($1,0,'queued',$2,$3,'original') RETURNING id`, + [job!.id, srcPaths.length ? JSON.stringify(srcPaths) : null, base]); await enqueue({ itemId: item!.id, jobId: job!.id }); }