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,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