feat: image core — format math, sharp pipeline, sticker contour, prompts, pg-boss queue+worker
- format.ts: exact px (cm/2.54*dpi), catalog incl. The Frame/portrait, aspect ratio - pipeline.ts: finalizeToFormat (cover/attention crop, dpi metadata), stickerContour (alpha dilation -> white border), heic->png, german filename slug - prompts.ts: task prompts (clean/cutout/format) — reproduce, don't reinterpret - process.ts: per-item chain (load -> model /v1/images -> finalize -> contour -> store) - queue.ts + worker.ts: pg-boss (concurrency, retry, counters, 402 pause, completion) - verified locally: 30x40@300dpi = exact 3543x4724 + dpi meta; sticker white border + alpha
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
// Zielformate & exakte Pixelberechnung (siehe 01 §5, 06 §3).
|
||||||
|
// px = round(cm / 2.54 * dpi)
|
||||||
|
|
||||||
|
export type Orientation = 'portrait' | 'landscape';
|
||||||
|
|
||||||
|
export interface Dimensions { w: number; h: number; cm?: [number, number]; label: string; }
|
||||||
|
|
||||||
|
// cm-Formate: [Breite, Höhe] in Hochformat-Konvention (kurze Seite × lange Seite)
|
||||||
|
const CM: Record<string, [number, number]> = {
|
||||||
|
'9x13': [9, 13], '10x15': [10, 15], '13x18': [13, 18], '15x20': [15, 20],
|
||||||
|
'20x30': [20, 30], '30x40': [30, 40], '30x45': [30, 45], '40x50': [40, 50],
|
||||||
|
'40x60': [40, 60], '50x70': [50, 70], '60x90': [60, 90],
|
||||||
|
A4: [21, 29.7], A3: [29.7, 42], A2: [42, 59.4],
|
||||||
|
'20x20': [20, 20], '30x30': [30, 30],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reine Bildschirmformate (feste Pixel, kein cm/dpi)
|
||||||
|
const SCREEN: Record<string, [number, number]> = {
|
||||||
|
theframe: [3840, 2160], // 16:9 quer
|
||||||
|
hochformat: [2160, 3840], // 9:16 hoch
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cmToPx = (cm: number, dpi: number) => Math.round((cm / 2.54) * dpi);
|
||||||
|
|
||||||
|
export interface ResolveInput {
|
||||||
|
format: string; // Schlüssel oben, 'sticker', 'custom' oder 'keep'
|
||||||
|
orientation?: Orientation;
|
||||||
|
dpi?: number;
|
||||||
|
customCm?: [number, number]; // für 'sticker'/'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liefert exakte Zielpixel + Label. `keep` → null (Original behalten). */
|
||||||
|
export function resolveDimensions(input: ResolveInput): Dimensions | null {
|
||||||
|
const { format } = input;
|
||||||
|
const dpi = input.dpi ?? 300;
|
||||||
|
const orient = input.orientation ?? 'portrait';
|
||||||
|
|
||||||
|
if (format === 'keep') return null;
|
||||||
|
|
||||||
|
if (format in SCREEN) {
|
||||||
|
const [w, h] = SCREEN[format];
|
||||||
|
return { w, h, label: format === 'theframe' ? 'The Frame' : 'Hochformat' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let cm: [number, number] | undefined;
|
||||||
|
if (format in CM) cm = CM[format];
|
||||||
|
else if (format === 'sticker' || format === 'custom') cm = input.customCm ?? [5, 5];
|
||||||
|
if (!cm) throw new Error(`Unbekanntes Format: ${format}`);
|
||||||
|
|
||||||
|
// Hochformat-Konvention → gewünschte Orientierung anwenden
|
||||||
|
let [wCm, hCm] = cm;
|
||||||
|
if (orient === 'landscape') [wCm, hCm] = [hCm, wCm];
|
||||||
|
return { w: cmToPx(wCm, dpi), h: cmToPx(hCm, dpi), cm: [wCm, hCm],
|
||||||
|
label: `${cm[0]}×${cm[1]} cm` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vereinfachtes Seitenverhältnis als "b:h" für die Bild-API. */
|
||||||
|
export function aspectRatio(w: number, h: number): string {
|
||||||
|
const g = gcd(w, h);
|
||||||
|
return `${Math.round(w / g)}:${Math.round(h / g)}`;
|
||||||
|
}
|
||||||
|
function gcd(a: number, b: number): number { return b === 0 ? a : gcd(b, a % b); }
|
||||||
|
|
||||||
|
/** Reicht die native Auflösung für echte 300 dpi? (Hinweis in der UI.) */
|
||||||
|
export function upscaleWarning(srcW: number, srcH: number, target: Dimensions): boolean {
|
||||||
|
return srcW < target.w * 0.95 || srcH < target.h * 0.95;
|
||||||
|
}
|
||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
import { runMigrations } from './db';
|
import { runMigrations } from './db';
|
||||||
import { ensureBucket } from './storage';
|
import { ensureBucket } from './storage';
|
||||||
import { seed } from './seed';
|
import { seed } from './seed';
|
||||||
|
import { startImageWorker } from '../worker';
|
||||||
|
|
||||||
let started: Promise<void> | null = null;
|
let started: Promise<void> | null = null;
|
||||||
|
|
||||||
@@ -11,8 +12,8 @@ export function ensureInit(): Promise<void> {
|
|||||||
await runMigrations();
|
await runMigrations();
|
||||||
try { await ensureBucket(); } catch (e) { console.error('[init] S3 nicht bereit:', e); }
|
try { await ensureBucket(); } catch (e) { console.error('[init] S3 nicht bereit:', e); }
|
||||||
await seed();
|
await seed();
|
||||||
|
try { await startImageWorker(); } catch (e) { console.error('[init] Worker-Start fehlgeschlagen:', e); }
|
||||||
console.log('[init] Klarbild bereit.');
|
console.log('[init] Klarbild bereit.');
|
||||||
// TODO (Phase Bildkern): pg-boss-Worker hier starten.
|
|
||||||
})().catch((e) => { started = null; throw e; });
|
})().catch((e) => { started = null; throw e; });
|
||||||
}
|
}
|
||||||
return started;
|
return started;
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import sharp from 'sharp';
|
||||||
|
import type { Dimensions } from './format';
|
||||||
|
|
||||||
|
export type CropMode = 'crop' | 'extend';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bringt ein (Modell-)Ergebnis exakt auf das Zielmaß und schreibt dpi-Metadaten.
|
||||||
|
* target=null → Original behalten (nur dpi setzen). Erhält Alpha, falls vorhanden.
|
||||||
|
*/
|
||||||
|
export async function finalizeToFormat(
|
||||||
|
buffer: Buffer,
|
||||||
|
target: Dimensions | null,
|
||||||
|
cropMode: CropMode,
|
||||||
|
dpi: number,
|
||||||
|
): Promise<{ buffer: Buffer; width: number; height: number; hasAlpha: boolean }> {
|
||||||
|
let img = sharp(buffer, { failOn: 'none' });
|
||||||
|
const meta = await img.metadata();
|
||||||
|
const hasAlpha = !!meta.hasAlpha;
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
img = sharp(buffer, { failOn: 'none' }).resize(target.w, target.h, {
|
||||||
|
fit: 'cover',
|
||||||
|
position: cropMode === 'crop' ? sharp.strategy.attention : 'centre',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = await img
|
||||||
|
.withMetadata({ density: dpi })
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
|
||||||
|
return { buffer: out.data, width: out.info.width, height: out.info.height, hasAlpha };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weißer Stickerrand um das freigestellte Motiv (ohne Modell).
|
||||||
|
* Alpha extrahieren → Dilatation (Blur+Threshold) → weiße Konturebene → Original darüber.
|
||||||
|
* Radius = contourMm / 25.4 * dpi.
|
||||||
|
*/
|
||||||
|
export async function stickerContour(
|
||||||
|
buffer: Buffer,
|
||||||
|
contourMm: number,
|
||||||
|
dpi: number,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const base = sharp(buffer, { failOn: 'none' }).ensureAlpha();
|
||||||
|
const { width, height } = await base.metadata();
|
||||||
|
if (!width || !height) throw new Error('Bildmaße unbekannt');
|
||||||
|
|
||||||
|
const radiusPx = Math.max(1, Math.round((contourMm / 25.4) * dpi));
|
||||||
|
|
||||||
|
// Alpha als Graustufenmaske, per Blur+Threshold verbreitern (Dilatation).
|
||||||
|
const mask = await sharp(buffer, { failOn: 'none' })
|
||||||
|
.ensureAlpha()
|
||||||
|
.extractChannel('alpha')
|
||||||
|
.blur(Math.max(0.3, radiusPx / 2))
|
||||||
|
.threshold(1)
|
||||||
|
.toColourspace('b-w')
|
||||||
|
.raw()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// Weiße Ebene, die verbreiterte Maske als Alpha.
|
||||||
|
const whiteLayer = await sharp({
|
||||||
|
create: { width, height, channels: 3, background: { r: 255, g: 255, b: 255 } },
|
||||||
|
})
|
||||||
|
.joinChannel(mask, { raw: { width, height, channels: 1 } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// Original (RGBA) über die Konturebene legen.
|
||||||
|
const original = await base.png().toBuffer();
|
||||||
|
return sharp(whiteLayer)
|
||||||
|
.composite([{ input: original }])
|
||||||
|
.withMetadata({ density: dpi })
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HEIC/HEIF → PNG-Puffer (sharp kann HEIC im Standard-Build meist nicht). */
|
||||||
|
export async function heicToPng(buffer: Buffer): Promise<Buffer> {
|
||||||
|
const convert = (await import('heic-convert')).default as any;
|
||||||
|
const out = await convert({ buffer, format: 'PNG' });
|
||||||
|
return Buffer.from(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deutscher Datei-Slug: klein, mit Bindestrichen, Format angehängt. */
|
||||||
|
export function slugFilename(base: string, formatLabel: string, ext = 'png'): string {
|
||||||
|
const slug = (base || 'bild')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] || c))
|
||||||
|
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'bild';
|
||||||
|
const fmt = formatLabel.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||||
|
return `${slug}${fmt ? '-' + fmt : ''}.${ext}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { one, query } from './db';
|
||||||
|
import { getObject, putObject, resultKey } from './storage';
|
||||||
|
import { generateImage } from './openrouter';
|
||||||
|
import { buildPrompt, type Task } from './prompts';
|
||||||
|
import { resolveDimensions, aspectRatio, type Orientation } from './format';
|
||||||
|
import { finalizeToFormat, stickerContour, slugFilename, type CropMode } from './pipeline';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = 'google/gemini-3.1-flash-image';
|
||||||
|
|
||||||
|
interface RecipeSnapshot {
|
||||||
|
tasks: Task[];
|
||||||
|
output_format?: string;
|
||||||
|
orientation?: Orientation;
|
||||||
|
crop_mode?: CropMode;
|
||||||
|
dpi?: number;
|
||||||
|
contour_mm?: number | null;
|
||||||
|
model_key?: string | null;
|
||||||
|
custom_instruction?: string | null;
|
||||||
|
delivery?: 'library' | 'picdrop' | 'both';
|
||||||
|
picdrop_gallery?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Welches Modell-ID + Alpha-Fähigkeit? Aus models-Tabelle, sonst Default. */
|
||||||
|
async function pickModel(modelKey?: string | null): Promise<{ id: string; alpha: boolean }> {
|
||||||
|
if (modelKey) {
|
||||||
|
const m = await one<{ model_id: string; supports_alpha: boolean }>(
|
||||||
|
'SELECT model_id, supports_alpha FROM models WHERE label=$1 OR model_id=$1 LIMIT 1', [modelKey]);
|
||||||
|
if (m) return { id: m.model_id, alpha: m.supports_alpha };
|
||||||
|
}
|
||||||
|
const def = await one<{ model_id: string; supports_alpha: boolean }>(
|
||||||
|
'SELECT model_id, supports_alpha FROM models WHERE active AND is_default ORDER BY sort LIMIT 1');
|
||||||
|
if (def) return { id: def.model_id, alpha: def.supports_alpha };
|
||||||
|
return { id: DEFAULT_MODEL, alpha: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toDataUrl(buf: Buffer): Promise<string> {
|
||||||
|
const small = await sharp(buf, { failOn: 'none' })
|
||||||
|
.resize(1536, 1536, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: 90 })
|
||||||
|
.toBuffer();
|
||||||
|
return `data:image/jpeg;base64,${small.toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }>(
|
||||||
|
'SELECT id, job_id, source_path 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 r = (job?.recipe_snapshot || {}) as RecipeSnapshot;
|
||||||
|
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');
|
||||||
|
|
||||||
|
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
|
||||||
|
? 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;
|
||||||
|
|
||||||
|
let working = source;
|
||||||
|
let modelUsed: string | null = null;
|
||||||
|
let cost = 0;
|
||||||
|
|
||||||
|
if (needsModel) {
|
||||||
|
const gen = await generateImage({
|
||||||
|
model: model.id,
|
||||||
|
prompt,
|
||||||
|
inputUrl: await toDataUrl(source),
|
||||||
|
aspectRatio: target ? aspectRatio(target.w, target.h) : undefined,
|
||||||
|
background: wantCutout ? 'transparent' : undefined,
|
||||||
|
outputFormat: 'png',
|
||||||
|
});
|
||||||
|
working = gen.buffer;
|
||||||
|
modelUsed = gen.model;
|
||||||
|
cost = gen.cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lokale Nachbearbeitung: exakt aufs Zielmaß + dpi
|
||||||
|
const fin = await finalizeToFormat(working, target, cropMode, dpi);
|
||||||
|
let outBuf = fin.buffer;
|
||||||
|
let hasAlpha = fin.hasAlpha || wantCutout;
|
||||||
|
|
||||||
|
if (wantContour && r.contour_mm) {
|
||||||
|
outBuf = await stickerContour(outBuf, Number(r.contour_mm), dpi);
|
||||||
|
hasAlpha = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = resultKey(item.id);
|
||||||
|
await putObject(key, outBuf, 'image/png');
|
||||||
|
|
||||||
|
const label = target?.label || 'original';
|
||||||
|
const filename = slugFilename(label.replace(/\s+/g, '-'), target?.label || '');
|
||||||
|
const outputPx = `${fin.width}x${fin.height}`;
|
||||||
|
const deliveryStatus = (r.delivery === 'picdrop' || r.delivery === 'both') ? '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]);
|
||||||
|
|
||||||
|
return { ok: true, cost };
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.friendly || e?.message || 'Unbekannter Fehler';
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Aufgaben-Prompts für die Bild-API. Leitprinzip (01 §1): reproduzieren & erweitern,
|
||||||
|
// nicht neu interpretieren. Farben, Komposition, Texturen und v.a. Lettering treu lassen.
|
||||||
|
|
||||||
|
export type Task = 'clean' | 'cutout' | 'format' | 'contour' | 'deliver';
|
||||||
|
|
||||||
|
const CLEAN =
|
||||||
|
'Recreate ONLY the actual artwork/motif as a clean, complete, high-resolution image. ' +
|
||||||
|
'Remove everything around it that is not part of the motif: picture frames, walls, ' +
|
||||||
|
'phone/app UI bars, shop overlays, cart icons, price tags, watermarks from your own ' +
|
||||||
|
'screenshot, timestamps and reflections. Reconstruct edges that were cropped or hidden. ' +
|
||||||
|
'Keep the original style, colors, textures, composition and ESPECIALLY any lettering ' +
|
||||||
|
'pixel-faithful. Do not reinterpret, do not add new elements.';
|
||||||
|
|
||||||
|
const CUTOUT =
|
||||||
|
'Isolate only the main subject. The background must be fully transparent (alpha).';
|
||||||
|
|
||||||
|
const FORMAT_EXTEND =
|
||||||
|
'Extend the scene naturally to fill the requested aspect ratio (outpainting), keeping ' +
|
||||||
|
'the existing composition centered and consistent in style.';
|
||||||
|
|
||||||
|
const FORMAT_KEEP_RATIO =
|
||||||
|
'Produce the motif in the requested aspect ratio without distorting it.';
|
||||||
|
|
||||||
|
export interface PromptOpts {
|
||||||
|
tasks: Task[];
|
||||||
|
cropMode?: 'crop' | 'extend';
|
||||||
|
customInstruction?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut den kombinierten Prompt aus den Rezept-Aufgaben (feste Reihenfolge). */
|
||||||
|
export function buildPrompt({ tasks, cropMode, customInstruction }: PromptOpts): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (tasks.includes('clean')) parts.push(CLEAN);
|
||||||
|
if (tasks.includes('cutout')) parts.push(CUTOUT);
|
||||||
|
if (tasks.includes('format')) {
|
||||||
|
parts.push(cropMode === 'extend' ? FORMAT_EXTEND : FORMAT_KEEP_RATIO);
|
||||||
|
}
|
||||||
|
if (!tasks.includes('clean') && !tasks.includes('cutout') && parts.length === 0) {
|
||||||
|
// reines Format ohne Bereinigen: nur reproduzieren
|
||||||
|
parts.push('Reproduce the image faithfully, no stylistic changes.');
|
||||||
|
}
|
||||||
|
if (customInstruction?.trim()) parts.push(`Additional instruction: ${customInstruction.trim()}`);
|
||||||
|
parts.push('Output only the resulting image.');
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import PgBoss from 'pg-boss';
|
||||||
|
|
||||||
|
export const QUEUE = 'generate';
|
||||||
|
export interface GenerateJob { itemId: string; jobId: string; }
|
||||||
|
|
||||||
|
let boss: PgBoss | null = null;
|
||||||
|
|
||||||
|
export async function getBoss(): Promise<PgBoss> {
|
||||||
|
if (!boss) {
|
||||||
|
boss = new PgBoss({ connectionString: process.env.DATABASE_URL });
|
||||||
|
boss.on('error', (e) => console.error('[pg-boss]', e));
|
||||||
|
await boss.start();
|
||||||
|
await boss.createQueue(QUEUE);
|
||||||
|
}
|
||||||
|
return boss;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enqueue(job: GenerateJob): Promise<void> {
|
||||||
|
const b = await getBoss();
|
||||||
|
await b.send(QUEUE, job, { retryLimit: 2, retryBackoff: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startWorker(
|
||||||
|
concurrency: number,
|
||||||
|
handler: (job: GenerateJob) => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const b = await getBoss();
|
||||||
|
await b.work<GenerateJob>(
|
||||||
|
QUEUE,
|
||||||
|
{ batchSize: Math.max(1, concurrency), pollingIntervalSeconds: 2 },
|
||||||
|
async (jobs) => { await Promise.all(jobs.map((j) => handler(j.data))); },
|
||||||
|
);
|
||||||
|
console.log(`[queue] Worker aktiv (Parallelität ${concurrency}).`);
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
declare module 'heic-convert' {
|
||||||
|
interface ConvertOptions { buffer: Buffer; format: 'JPEG' | 'PNG'; quality?: number; }
|
||||||
|
const convert: (opts: ConvertOptions) => Promise<ArrayBuffer>;
|
||||||
|
export default convert;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { one, query } from './lib/db';
|
||||||
|
import { startWorker, type GenerateJob } from './lib/queue';
|
||||||
|
import { processItem } from './lib/process';
|
||||||
|
|
||||||
|
/** Startet den Warteschlangen-Arbeiter (im selben Node-Prozess wie die App). */
|
||||||
|
export async function startImageWorker(): Promise<void> {
|
||||||
|
const s = await one<{ concurrency: number }>('SELECT concurrency FROM settings WHERE id=1');
|
||||||
|
const concurrency = s?.concurrency ?? 2;
|
||||||
|
await startWorker(concurrency, handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(job: GenerateJob): Promise<void> {
|
||||||
|
// Angehaltene/abgebrochene Aufträge nicht verarbeiten.
|
||||||
|
const j = await one<{ status: string }>('SELECT status FROM jobs WHERE id=$1', [job.jobId]);
|
||||||
|
if (!j || j.status === 'paused' || j.status === 'cancelled') {
|
||||||
|
await query(`UPDATE items SET status='queued' WHERE id=$1 AND status='running'`, [job.itemId]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await query(`UPDATE jobs SET status='running' WHERE id=$1 AND status='queued'`, [job.jobId]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await processItem(job.itemId);
|
||||||
|
if (res.ok) {
|
||||||
|
await query(`UPDATE jobs SET done_count=done_count+1 WHERE id=$1`, [job.jobId]);
|
||||||
|
} else {
|
||||||
|
await query(`UPDATE jobs SET failed_count=failed_count+1 WHERE id=$1`, [job.jobId]);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.status === 402) {
|
||||||
|
// Guthaben erschöpft → Auftrag anhalten, Position zurück in die Schlange.
|
||||||
|
await query(`UPDATE jobs SET status='paused' WHERE id=$1`, [job.jobId]);
|
||||||
|
await query(`UPDATE items SET status='queued', error_message='Guthaben erschöpft' WHERE id=$1`,
|
||||||
|
[job.itemId]);
|
||||||
|
console.error('[worker] 402 — Auftrag angehalten', job.jobId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await query(`UPDATE items SET status='failed', error_message=$2 WHERE id=$1`,
|
||||||
|
[job.itemId, String(e?.message || e).slice(0, 500)]);
|
||||||
|
await query(`UPDATE jobs SET failed_count=failed_count+1 WHERE id=$1`, [job.jobId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await maybeComplete(job.jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auftrag abschließen, wenn alle Positionen fertig/fehlgeschlagen sind. */
|
||||||
|
async function maybeComplete(jobId: string): Promise<void> {
|
||||||
|
const row = await one<{ total: number; done: number; failed: number; open: number }>(
|
||||||
|
`SELECT total,
|
||||||
|
(SELECT count(*) FROM items WHERE job_id=$1 AND status='done')::int AS done,
|
||||||
|
(SELECT count(*) FROM items WHERE job_id=$1 AND status='failed')::int AS failed,
|
||||||
|
(SELECT count(*) FROM items WHERE job_id=$1 AND status IN ('queued','running'))::int AS open
|
||||||
|
FROM jobs WHERE id=$1`, [jobId]);
|
||||||
|
if (row && row.open === 0) {
|
||||||
|
await query(`UPDATE jobs SET status='done', finished_at=now() WHERE id=$1 AND status<>'cancelled'`,
|
||||||
|
[jobId]);
|
||||||
|
// TODO: Auslieferung (Picdrop) + Benachrichtigung (Telegram/n8n) anstoßen.
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user