feat: compose + text-to-image generation modes (multi-image + free-text)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
+72
-21
@@ -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<string> {
|
||||
return `data:image/jpeg;base64,${small.toString('base64')}`;
|
||||
}
|
||||
|
||||
/** Kleines Vorschaubild (webp) für die Bibliothek — spart Bandbreite & Speicher. */
|
||||
async function makeThumb(buf: Buffer): Promise<Buffer> {
|
||||
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<ProcessResult> {
|
||||
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<ProcessResult> {
|
||||
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<ProcessResult> {
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user