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:
@@ -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'));
|
||||||
@@ -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'));
|
||||||
@@ -30,7 +30,8 @@ function friendlyFor(status: number): string {
|
|||||||
export interface GenerateOpts {
|
export interface GenerateOpts {
|
||||||
model: string;
|
model: string;
|
||||||
prompt: 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" …
|
aspectRatio?: string; // "16:9", "3:4" …
|
||||||
resolution?: string; // "2K" | "4K" — NICHT zusammen mit expliziten Pixeln
|
resolution?: string; // "2K" | "4K" — NICHT zusammen mit expliziten Pixeln
|
||||||
background?: 'transparent' | 'opaque';
|
background?: 'transparent' | 'opaque';
|
||||||
@@ -56,7 +57,8 @@ export async function generateImage(opts: GenerateOpts): Promise<GenerateResult>
|
|||||||
n: 1,
|
n: 1,
|
||||||
output_format: opts.outputFormat || 'png',
|
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.aspectRatio) body.aspect_ratio = opts.aspectRatio;
|
||||||
if (opts.resolution && !opts.aspectRatio) body.resolution = opts.resolution; // nie beides
|
if (opts.resolution && !opts.aspectRatio) body.resolution = opts.resolution; // nie beides
|
||||||
if (opts.background) body.background = opts.background;
|
if (opts.background) body.background = opts.background;
|
||||||
|
|||||||
+71
-20
@@ -1,13 +1,15 @@
|
|||||||
import { one, query } from './db';
|
import { one, query } from './db';
|
||||||
import { getObject, putObject, resultKey } from './storage';
|
import { getObject, putObject, deleteObject, resultKey, thumbKey } from './storage';
|
||||||
import { generateImage } from './openrouter';
|
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 { resolveDimensions, aspectRatio, type Orientation } from './format';
|
||||||
import { finalizeToFormat, stickerContour, buildResultFilename, formatToken, type CropMode } from './pipeline';
|
import { finalizeToFormat, stickerContour, buildResultFilename, formatToken, type CropMode } from './pipeline';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
|
||||||
const DEFAULT_MODEL = 'google/gemini-3.1-flash-image';
|
const DEFAULT_MODEL = 'google/gemini-3.1-flash-image';
|
||||||
|
|
||||||
|
type Mode = 'each' | 'compose' | 'generate';
|
||||||
|
|
||||||
interface RecipeSnapshot {
|
interface RecipeSnapshot {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
output_format?: string;
|
output_format?: string;
|
||||||
@@ -17,6 +19,7 @@ interface RecipeSnapshot {
|
|||||||
contour_mm?: number | null;
|
contour_mm?: number | null;
|
||||||
model_key?: string | null;
|
model_key?: string | null;
|
||||||
custom_instruction?: string | null;
|
custom_instruction?: string | null;
|
||||||
|
prompt_text?: string | null; // Beschreibung für compose/generate
|
||||||
delivery?: 'library' | 'picdrop' | 'both';
|
delivery?: 'library' | 'picdrop' | 'both';
|
||||||
picdrop_gallery?: string | null;
|
picdrop_gallery?: string | null;
|
||||||
}
|
}
|
||||||
@@ -42,47 +45,70 @@ async function toDataUrl(buf: Buffer): Promise<string> {
|
|||||||
return `data:image/jpeg;base64,${small.toString('base64')}`;
|
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; }
|
export interface ProcessResult { ok: boolean; cost: number; error?: string; }
|
||||||
|
|
||||||
/** Verarbeitet eine Position vollständig und aktualisiert ihren Datensatz. */
|
/** Verarbeitet eine Position vollständig und aktualisiert ihren Datensatz. */
|
||||||
export async function processItem(itemId: string): Promise<ProcessResult> {
|
export async function processItem(itemId: string): Promise<ProcessResult> {
|
||||||
const item = await one<{ id: string; job_id: string; source_path: string; filename: string | null }>(
|
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, filename FROM items WHERE id=$1', [itemId]);
|
'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' };
|
if (!item) return { ok: false, cost: 0, error: 'Position nicht gefunden' };
|
||||||
|
|
||||||
const job = await one<{ recipe_snapshot: RecipeSnapshot }>(
|
const job = await one<{ recipe_snapshot: RecipeSnapshot; mode: Mode }>(
|
||||||
'SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]);
|
'SELECT recipe_snapshot, mode FROM jobs WHERE id=$1', [item.job_id]);
|
||||||
const r = (job?.recipe_snapshot || {}) as RecipeSnapshot;
|
const r = (job?.recipe_snapshot || {}) as RecipeSnapshot;
|
||||||
|
const mode: Mode = job?.mode || 'each';
|
||||||
const tasks = r.tasks || [];
|
const tasks = r.tasks || [];
|
||||||
const dpi = r.dpi ?? 300;
|
const dpi = r.dpi ?? 300;
|
||||||
const cropMode: CropMode = r.crop_mode === 'extend' ? 'extend' : 'crop';
|
const cropMode: CropMode = r.crop_mode === 'extend' ? 'extend' : 'crop';
|
||||||
const wantCutout = tasks.includes('cutout');
|
const wantCutout = tasks.includes('cutout');
|
||||||
const wantContour = tasks.includes('contour');
|
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]);
|
await query(`UPDATE items SET status='running', attempts=attempts+1 WHERE id=$1`, [itemId]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const source = await getObject(item.source_path);
|
// Quellen je Modus einsammeln
|
||||||
const target = r.tasks.includes('format') && r.output_format
|
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 })
|
? resolveDimensions({ format: r.output_format, orientation: r.orientation, dpi })
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const model = await pickModel(r.model_key);
|
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.
|
// Prompt + ob das Modell überhaupt gebraucht wird, je Modus.
|
||||||
const needsModel = tasks.includes('clean') || wantCutout ||
|
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;
|
(tasks.includes('format') && cropMode === 'extend') || !!r.custom_instruction;
|
||||||
|
}
|
||||||
|
|
||||||
let working = source;
|
let working = sources[0] || Buffer.alloc(0);
|
||||||
let modelUsed: string | null = null;
|
let modelUsed: string | null = null;
|
||||||
let cost = 0;
|
let cost = 0;
|
||||||
|
|
||||||
if (needsModel) {
|
if (needsModel) {
|
||||||
|
const inputUrls = sources.length ? await Promise.all(sources.map(toDataUrl)) : undefined;
|
||||||
const gen = await generateImage({
|
const gen = await generateImage({
|
||||||
model: model.id,
|
model: model.id,
|
||||||
prompt,
|
prompt,
|
||||||
inputUrl: await toDataUrl(source),
|
inputUrls,
|
||||||
aspectRatio: target ? aspectRatio(target.w, target.h) : undefined,
|
aspectRatio: target ? aspectRatio(target.w, target.h) : undefined,
|
||||||
background: wantCutout ? 'transparent' : undefined,
|
background: wantCutout ? 'transparent' : undefined,
|
||||||
outputFormat: 'png',
|
outputFormat: 'png',
|
||||||
@@ -91,6 +117,7 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
|
|||||||
modelUsed = gen.model;
|
modelUsed = gen.model;
|
||||||
cost = gen.cost;
|
cost = gen.cost;
|
||||||
}
|
}
|
||||||
|
if (!working.length) throw new Error('Keine Bilddaten erzeugt');
|
||||||
|
|
||||||
// Lokale Nachbearbeitung: exakt aufs Zielmaß + dpi
|
// Lokale Nachbearbeitung: exakt aufs Zielmaß + dpi
|
||||||
const fin = await finalizeToFormat(working, target, cropMode, 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);
|
const key = resultKey(item.id);
|
||||||
await putObject(key, outBuf, 'image/png');
|
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 filename = buildResultFilename(item.filename, formatToken(r.output_format), 'png');
|
||||||
const outputPx = `${fin.width}x${fin.height}`;
|
const outputPx = `${fin.width}x${fin.height}`;
|
||||||
const deliveryStatus = (r.delivery === 'picdrop' || r.delivery === 'both') ? 'pending' : 'none';
|
const deliveryStatus = (r.delivery === 'picdrop' || r.delivery === 'both') ? 'pending' : 'none';
|
||||||
|
const nasStatus = cfg?.nas_enabled ? 'pending' : 'none';
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`UPDATE items SET status='done', result_path=$2, filename=$3, output_px=$4, dpi=$5,
|
`UPDATE items SET status='done', result_path=$2, thumb_path=$3, filename=$4, output_px=$5, dpi=$6,
|
||||||
has_alpha=$6, model_used=$7, prompt_used=$8, cost=$9, error_message=NULL,
|
has_alpha=$7, model_used=$8, prompt_used=$9, cost=$10, error_message=NULL,
|
||||||
delivery_status=$10 WHERE id=$1`,
|
delivery_status=$11, nas_status=$12 WHERE id=$1`,
|
||||||
[itemId, key, filename, outputPx, dpi, hasAlpha, modelUsed, prompt.slice(0, 1000),
|
[itemId, key, thumbPath, filename, outputPx, dpi, hasAlpha, modelUsed, prompt.slice(0, 1000),
|
||||||
cost, deliveryStatus]);
|
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 };
|
return { ok: true, cost };
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const real = e?.message || String(e);
|
const real = e?.message || String(e);
|
||||||
const status = e?.status ? ` [status ${e.status}]` : '';
|
const status = e?.status ? ` [status ${e.status}]` : '';
|
||||||
console.error(`[process] Item ${itemId} fehlgeschlagen${status}: ${real}`, e?.stack || '');
|
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})` : '');
|
const msg = (e?.friendly ? `${e.friendly}` : real) + (e?.status ? ` (${e.status})` : '');
|
||||||
await query(`UPDATE items SET status='failed', error_message=$2 WHERE id=$1`,
|
await query(`UPDATE items SET status='failed', error_message=$2 WHERE id=$1`,
|
||||||
[itemId, String(msg).slice(0, 500)]);
|
[itemId, String(msg).slice(0, 500)]);
|
||||||
// 402/Guthaben: nach oben durchreichen, damit der Worker den Auftrag anhält
|
|
||||||
if (e?.status === 402) throw e;
|
if (e?.status === 402) throw e;
|
||||||
return { ok: false, cost: 0, error: msg };
|
return { ok: false, cost: 0, error: msg };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,3 +43,25 @@ export function buildPrompt({ tasks, cropMode, customInstruction }: PromptOpts):
|
|||||||
parts.push('Output only the resulting image.');
|
parts.push('Output only the resulting image.');
|
||||||
return parts.join(' ');
|
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.`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,3 +90,6 @@ export function sourceKey(uuid: string, ext: string): string {
|
|||||||
export function resultKey(uuid: string): string {
|
export function resultKey(uuid: string): string {
|
||||||
return `results/${new Date().getFullYear()}/${uuid}.png`;
|
return `results/${new Date().getFullYear()}/${uuid}.png`;
|
||||||
}
|
}
|
||||||
|
export function thumbKey(uuid: string): string {
|
||||||
|
return `thumbs/${new Date().getFullYear()}/${uuid}.webp`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,12 +14,19 @@ export const GET: APIRoute = async ({ locals }) => {
|
|||||||
return json({ jobs: rows });
|
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 }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
const b = await request.json();
|
const b = await request.json();
|
||||||
|
const mode: 'each' | 'compose' | 'generate' = ['each', 'compose', 'generate'].includes(b.mode) ? b.mode : 'each';
|
||||||
const sources: any[] = b.sources || [];
|
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;
|
let snapshot: any = b.recipe;
|
||||||
if (b.recipeId) {
|
if (b.recipeId) {
|
||||||
@@ -27,11 +34,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
if (!r) return json({ error: 'Rezept nicht gefunden.' }, 404);
|
if (!r) return json({ error: 'Rezept nicht gefunden.' }, 404);
|
||||||
snapshot = r;
|
snapshot = r;
|
||||||
}
|
}
|
||||||
if (!snapshot) return json({ error: 'Kein Rezept.' }, 400);
|
snapshot = snapshot || {};
|
||||||
|
|
||||||
// Rezept-Snapshot einfrieren
|
// Rezept-Snapshot einfrieren
|
||||||
const snap = {
|
const snap = {
|
||||||
tasks: snapshot.tasks || [],
|
tasks: snapshot.tasks || (mode === 'each' ? [] : []),
|
||||||
output_format: snapshot.output_format,
|
output_format: snapshot.output_format,
|
||||||
orientation: snapshot.orientation,
|
orientation: snapshot.orientation,
|
||||||
crop_mode: snapshot.crop_mode || 'crop',
|
crop_mode: snapshot.crop_mode || 'crop',
|
||||||
@@ -39,15 +46,18 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
contour_mm: snapshot.contour_mm ?? null,
|
contour_mm: snapshot.contour_mm ?? null,
|
||||||
model_key: snapshot.model_key ?? null,
|
model_key: snapshot.model_key ?? null,
|
||||||
custom_instruction: snapshot.custom_instruction ?? null,
|
custom_instruction: snapshot.custom_instruction ?? null,
|
||||||
|
prompt_text: promptText || null,
|
||||||
delivery: snapshot.delivery || 'library',
|
delivery: snapshot.delivery || 'library',
|
||||||
picdrop_gallery: snapshot.picdrop_gallery ?? null,
|
picdrop_gallery: snapshot.picdrop_gallery ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const total = (mode === 'each') ? sources.length : 1;
|
||||||
const job = await one<{ id: string }>(
|
const job = await one<{ id: string }>(
|
||||||
`INSERT INTO jobs (created_by, origin, recipe_snapshot, status, total)
|
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total)
|
||||||
VALUES ($1,$2,$3,'queued',$4) RETURNING id`,
|
VALUES ($1,$2,$3,$4,'queued',$5) RETURNING id`,
|
||||||
[locals.user.uid, b.origin || 'web', JSON.stringify(snap), sources.length]);
|
[locals.user.uid, b.origin || 'web', mode, JSON.stringify(snap), total]);
|
||||||
|
|
||||||
|
if (mode === 'each') {
|
||||||
for (let i = 0; i < sources.length; i++) {
|
for (let i = 0; i < sources.length; i++) {
|
||||||
const s = sources[i];
|
const s = sources[i];
|
||||||
const item = await one<{ id: string }>(
|
const item = await one<{ id: string }>(
|
||||||
@@ -56,6 +66,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
[job!.id, i, s.source_path, s.filename || null, s.source_quality || 'original']);
|
[job!.id, i, s.source_path, s.filename || null, s.source_quality || 'original']);
|
||||||
await enqueue({ itemId: item!.id, jobId: job!.id });
|
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_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 });
|
||||||
|
}
|
||||||
|
|
||||||
return json({ jobId: job!.id });
|
return json({ jobId: job!.id });
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user