diff --git a/migrations/012_failed_jobs_and_output_ext.sql b/migrations/012_failed_jobs_and_output_ext.sql new file mode 100644 index 0000000..adf5ba7 --- /dev/null +++ b/migrations/012_failed_jobs_and_output_ext.sql @@ -0,0 +1,11 @@ +-- Aufträge dürfen jetzt auch „failed" sein (alle Positionen fehlgeschlagen) → rot in der Übersicht. +ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_status_check; +ALTER TABLE jobs ADD CONSTRAINT jobs_status_check + CHECK (status IN ('queued','running','paused','done','cancelled','failed')); + +-- Ausgabeformat der Bilddatei: global (settings) + optionaler Rezept-Override. +-- „png" = verlustfrei (Standard, nötig für Transparenz), „jpg" = klein/Picdrop-freundlich. +ALTER TABLE settings ADD COLUMN IF NOT EXISTS output_ext text NOT NULL DEFAULT 'png' + CHECK (output_ext IN ('png','jpg')); +ALTER TABLE recipes ADD COLUMN IF NOT EXISTS output_ext text + CHECK (output_ext IN ('png','jpg')); diff --git a/src/components/AdminApp.tsx b/src/components/AdminApp.tsx index d1b2efb..a692bb8 100644 --- a/src/components/AdminApp.tsx +++ b/src/components/AdminApp.tsx @@ -76,6 +76,7 @@ export default function AdminApp() { picdrop_host: s.picdrop_host, picdrop_protocol: s.picdrop_protocol, picdrop_port: s.picdrop_port, picdrop_user: s.picdrop_user, picdrop_base_path: s.picdrop_base_path, picdrop_default_gallery: s.picdrop_default_gallery, default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency, + output_ext: s.output_ext || 'png', cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url, keep_sources: !!s.keep_sources, make_thumbnails: s.make_thumbnails !== false, retention_days: s.retention_days, picdrop_metadata_sidecar: !!s.picdrop_metadata_sidecar, nas_metadata_sidecar: !!s.nas_metadata_sidecar, @@ -204,6 +205,13 @@ export default function AdminApp() {
field('concurrency', e.target.value)} />
field('cricut_sheet_cm', e.target.value)} />
+
+ +
Gilt für neue Umwandlungen ohne eigene Format-Wahl. Freigestellte Motive bleiben immer PNG.
+
diff --git a/src/components/QueueApp.tsx b/src/components/QueueApp.tsx index 47edd04..16e51fe 100644 --- a/src/components/QueueApp.tsx +++ b/src/components/QueueApp.tsx @@ -5,6 +5,17 @@ const STATUS_DE: Record = { skipped: 'übersprungen', paused: 'angehalten', cancelled: 'abgebrochen', }; +/** Zeitpunkt hübsch: heute → „HH:MM", sonst „TT.MM. HH:MM". */ +function fmtTime(iso: string | null): string { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d.getTime())) return ''; + const hm = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + const today = new Date(); + const sameDay = d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate(); + return sameDay ? hm : `${d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })} ${hm}`; +} + export default function QueueApp({ jobId }: { jobId: string | null }) { const [job, setJob] = useState(null); const [items, setItems] = useState([]); @@ -40,7 +51,8 @@ export default function QueueApp({ jobId }: { jobId: string | null }) { {j.done_count}/{j.total} - {STATUS_DE[j.status] || j.status} · {j.by_name || ''} + {STATUS_DE[j.status] || j.status}{j.by_name ? ` · ${j.by_name}` : ''} + {j.finished_at && ['done', 'failed', 'cancelled'].includes(j.status) ? ` · fertig ${fmtTime(j.finished_at)}` : ''} ))} @@ -59,7 +71,8 @@ export default function QueueApp({ jobId }: { jobId: string | null }) { return (
- Auftrag · {STATUS_DE[job?.status] || job?.status} + Auftrag · {STATUS_DE[job?.status] || job?.status} + {job?.finished_at && ['done', 'failed', 'cancelled'].includes(job?.status) ? ` · beendet ${fmtTime(job.finished_at)}` : ''}
{job?.status === 'running' && } {job?.status === 'paused' && } diff --git a/src/components/StudioApp.tsx b/src/components/StudioApp.tsx index 9fdd7fe..67d7d98 100644 --- a/src/components/StudioApp.tsx +++ b/src/components/StudioApp.tsx @@ -22,8 +22,19 @@ const FORMATS: { id: string; label: string; cm?: [number, number]; screen?: [num { id: 'theframe', label: 'The Frame (16:9)', screen: [3840, 2160] }, { id: 'hochformat', label: 'Hochformat (9:16)', screen: [2160, 3840] }, { id: 'sticker', label: 'Sticker (freies Maß)', cm: [5, 5] }, + { id: 'custom', label: 'Eigenes Maß (cm)' }, ]; +/** Freitext-Maß → Formatschlüssel + cm. „25x35" → 25×35 cm; „5" → 5×5 cm Sticker. */ +function parseCustomFmt(s: string): { key: string; cm: [number, number] | null } { + const t = (s || '').trim().replace(',', '.'); + const wh = /^(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)$/i.exec(t); + if (wh) return { key: `${wh[1]}x${wh[2]}`, cm: [parseFloat(wh[1]), parseFloat(wh[2])] }; + const n = /^(\d+(?:\.\d+)?)$/.exec(t); + if (n) return { key: `sticker${n[1]}`, cm: [parseFloat(n[1]), parseFloat(n[1])] }; + return { key: '', cm: null }; +} + const TASKS = [ { id: 'clean', name: 'Bereinigen', hint: 'Rahmen, Wand und Shop-Oberfläche entfernen.' }, { id: 'cutout', name: 'Freistellen', hint: 'Nur das Motiv, Hintergrund transparent.' }, @@ -51,6 +62,8 @@ export default function StudioApp({ recipes }: { recipes: any[] }) { const [portrait, setPortrait] = useState(true); const [crop, setCrop] = useState<'crop' | 'extend'>('crop'); const [contourMm, setContourMm] = useState(3); + const [customFmt, setCustomFmt] = useState(''); // Freitext-Maß für „Eigenes Maß"/„Sticker" + const [outputExt, setOutputExt] = useState<'' | 'png' | 'jpg'>(''); // '' = globale Standard-Einstellung const [custom, setCustom] = useState(''); const [desc, setDesc] = useState(''); const [delivery, setDelivery] = useState<'library' | 'picdrop' | 'both'>('library'); @@ -75,10 +88,18 @@ export default function StudioApp({ recipes }: { recipes: any[] }) { const showFormat = mode === 'each' ? hasFormat : true; const wantsFormat = mode === 'each' ? hasFormat : format !== 'keep'; + const isFreeFmt = format === 'custom' || format === 'sticker'; + const parsedFmt = isFreeFmt ? parseCustomFmt(customFmt) : { key: '', cm: null }; + // Für „Sticker" ohne Eingabe gilt weiter 5×5 cm; für „Eigenes Maß" braucht es eine Eingabe. + const effCm: [number, number] | null = fmt?.screen ? null + : parsedFmt.cm || (format === 'sticker' ? [5, 5] : (fmt?.cm || null)); + // Was tatsächlich als output_format gespeichert wird (z. B. „25x35", „sticker5"). + const effFormat = isFreeFmt ? (parsedFmt.key || (format === 'sticker' ? 'sticker' : '')) : format; + const zielPx: [number, number] | null = fmt?.screen ? (fmt.screen as [number, number]) - : fmt?.cm - ? (portrait ? [px(fmt.cm[0]), px(fmt.cm[1])] : [px(fmt.cm[1]), px(fmt.cm[0])]) + : effCm + ? (portrait ? [px(effCm[0]), px(effCm[1])] : [px(effCm[1]), px(effCm[0])]) : null; const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); }; @@ -130,9 +151,10 @@ export default function StudioApp({ recipes }: { recipes: any[] }) { const order = ['clean', 'cutout', 'format', 'contour', 'deliver']; const tasksOut = mode === 'each' ? [...tasks].sort((a, b) => order.indexOf(a) - order.indexOf(b)) : (wantsFormat ? ['format'] : []); const body = { - name: name.trim(), mode, tasks: tasksOut, output_format: wantsFormat ? format : 'keep', + name: name.trim(), mode, tasks: tasksOut, output_format: wantsFormat ? effFormat : 'keep', orientation: portrait ? 'portrait' : 'landscape', crop_mode: crop, dpi: 300, contour_mm: tasks.includes('contour') ? contourMm : null, + output_ext: outputExt || null, custom_instruction: mode === 'each' ? (custom || null) : null, model_key: modelKey || null, delivery, picdrop_gallery: gallery.trim() || null, delivery_target_id: targetId || null, }; @@ -175,7 +197,13 @@ export default function StudioApp({ recipes }: { recipes: any[] }) { if (!r) return; if (['each', 'compose', 'generate'].includes(r.mode)) setMode(r.mode); setTasks(r.tasks || ['clean', 'format']); - setFormat(r.output_format || 'keep'); + const of = r.output_format || 'keep'; + // Freies Maß aus dem Rezept zurückholen (kein bekannter Schlüssel = Eigenes Maß). + if (of !== 'keep' && !FORMATS.some((f) => f.id === of)) { + setFormat('custom'); + setCustomFmt(/^sticker[-_ ]?/i.test(of) ? of.replace(/^sticker[-_ ]?/i, '') : of); + } else setFormat(of); + setOutputExt(r.output_ext === 'jpg' || r.output_ext === 'png' ? r.output_ext : ''); setPortrait((r.orientation || 'portrait') !== 'landscape'); setCrop(r.crop_mode === 'extend' ? 'extend' : 'crop'); setContourMm(Number(r.contour_mm) || 3); @@ -187,7 +215,9 @@ export default function StudioApp({ recipes }: { recipes: any[] }) { } const ready = pics.filter((p) => p.source_path && !p.error); - const canRun = !busy && ( + // „Eigenes Maß" braucht eine gültige Eingabe, sonst kann nicht gestartet werden. + const freeFmtInvalid = wantsFormat && format === 'custom' && !parsedFmt.cm; + const canRun = !busy && !freeFmtInvalid && ( mode === 'each' ? (ready.length > 0 && tasks.length > 0 && !theframeConflict && !(tasks.includes('contour') && !hasCutout)) : mode === 'compose' ? (ready.length >= 1 && desc.trim().length > 0) : desc.trim().length > 0 @@ -202,10 +232,11 @@ export default function StudioApp({ recipes }: { recipes: any[] }) { : (wantsFormat ? ['format'] : []); const recipe = { tasks: tasksOut, - output_format: wantsFormat ? format : 'keep', + output_format: wantsFormat ? effFormat : 'keep', orientation: portrait ? 'portrait' : 'landscape', crop_mode: crop, dpi: 300, contour_mm: tasks.includes('contour') ? contourMm : null, + output_ext: outputExt || null, custom_instruction: mode === 'each' ? (custom || null) : null, delivery, model_key: modelKey || null, picdrop_gallery: (delivery !== 'library' && gallery.trim()) ? gallery.trim() : null, @@ -329,8 +360,15 @@ export default function StudioApp({ recipes }: { recipes: any[] }) { + {isFreeFmt && ( + <> + setCustomFmt(e.target.value)} + placeholder={format === 'sticker' ? 'z. B. 5 (=5×5 cm) oder 5x7' : 'z. B. 25x35 oder 5 für 5×5 cm'} /> + {freeFmtInvalid &&
Bitte ein Maß angeben, z. B. „25x35" oder „5".
} + + )} {theframeConflict &&
Freistellen + The Frame ergibt keinen Sinn — bitte eins wählen.
} - {fmt?.cm && !fmt.screen && ( + {effCm && (
@@ -349,6 +387,20 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
)} +
+ +
+ + + +
+
+ {outputExt === 'jpg' ? 'JPG — klein & Picdrop-freundlich. Freigestellte Motive bleiben trotzdem PNG (Transparenz).' + : outputExt === 'png' ? 'PNG — verlustfrei, unterstützt Transparenz. Große Dateien.' + : 'Standard aus den Admin-Einstellungen (global gesetzt).'} +
+
+ {mode === 'each' && tasks.includes('contour') && (
diff --git a/src/lib/delivery.ts b/src/lib/delivery.ts index 18e0a2b..3b268d9 100644 --- a/src/lib/delivery.ts +++ b/src/lib/delivery.ts @@ -52,6 +52,8 @@ async function deliverableBuffer( buf: Buffer, filename: string, hasAlpha: boolean, ): Promise<{ buf: Buffer; name: string }> { if (hasAlpha) return { buf, name: filename }; + // Schon JPEG (globales/Rezept-Format = jpg)? Dann unverändert lassen. + if (buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return { buf, name: filename }; try { const sharp = (await import('sharp')).default; const meta = await sharp(buf, { failOn: 'none' }).metadata(); diff --git a/src/lib/format.ts b/src/lib/format.ts index 7da9e37..859fd9e 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -45,7 +45,17 @@ export function resolveDimensions(input: ResolveInput): Dimensions | null { 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}`); + else { + // Frei definierte Formate direkt aus dem Schlüssel lesen — keine DB-Migration nötig: + // „sticker5" / „sticker-7,5" → quadratisch N×N cm; „25x35" / „25×35" → B×H cm. + const sq = /^sticker[-_ ]?(\d+(?:[.,]\d+)?)$/i.exec(format); + const wh = /^(\d+(?:[.,]\d+)?)\s*[x×*]\s*(\d+(?:[.,]\d+)?)$/i.exec(format); + const num = (s: string) => parseFloat(s.replace(',', '.')); + if (sq) { const n = num(sq[1]); cm = [n, n]; } + else if (wh) cm = [num(wh[1]), num(wh[2])]; + } + if (!cm || !cm[0] || !cm[1] || cm[0] > 300 || cm[1] > 300) + throw new Error(`Unbekanntes Format: ${format}`); // Hochformat-Konvention → gewünschte Orientierung anwenden let [wCm, hCm] = cm; diff --git a/src/lib/process.ts b/src/lib/process.ts index e3a65fc..aad9ef9 100644 --- a/src/lib/process.ts +++ b/src/lib/process.ts @@ -22,6 +22,7 @@ interface RecipeSnapshot { prompt_text?: string | null; // Beschreibung für compose/generate delivery?: 'library' | 'picdrop' | 'both'; picdrop_gallery?: string | null; + output_ext?: 'png' | 'jpg' | null; // Dateiformat-Override; sonst globale Einstellung } /** Welches Modell-ID + Alpha-Fähigkeit? Aus models-Tabelle, sonst Default. */ @@ -130,8 +131,23 @@ export async function processItem(itemId: string): Promise { hasAlpha = true; } + // Ausgabeformat: Rezept-Override, sonst globale Einstellung. JPG nur ohne Transparenz. + const gset = await one<{ output_ext: string | null }>('SELECT output_ext FROM settings WHERE id=1'); + const wantExt = (r.output_ext || gset?.output_ext || 'png').toLowerCase(); + let ext = 'png'; + let contentType = 'image/png'; + if (wantExt === 'jpg' && !hasAlpha) { + outBuf = await sharp(outBuf, { failOn: 'none' }) + .flatten({ background: '#ffffff' }) + .withMetadata({ density: dpi }) + .jpeg({ quality: 92, mozjpeg: true, chromaSubsampling: '4:4:4' }) + .toBuffer(); + ext = 'jpg'; + contentType = 'image/jpeg'; + } + const key = resultKey(item.id); - await putObject(key, outBuf, 'image/png'); + await putObject(key, outBuf, contentType); // Vorschaubild (optional, laut Einstellung) const cfg = await one<{ make_thumbnails: boolean; keep_sources: boolean; nas_enabled: boolean }>( @@ -145,7 +161,7 @@ export async function processItem(itemId: string): Promise { } 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), ext); const outputPx = `${fin.width}x${fin.height}`; // Private Aufträge: kein Delivery, kein Backup, kein gespeicherter Prompt. const deliveryStatus = (!isPrivate && (r.delivery === 'picdrop' || r.delivery === 'both')) ? 'pending' : 'none'; diff --git a/src/pages/api/admin/settings.ts b/src/pages/api/admin/settings.ts index 8ca82e1..4b1b1cb 100644 --- a/src/pages/api/admin/settings.ts +++ b/src/pages/api/admin/settings.ts @@ -25,6 +25,7 @@ export const GET: APIRoute = async () => { keep_sources: s.keep_sources, make_thumbnails: s.make_thumbnails, retention_days: s.retention_days, metadata_sidecar: s.metadata_sidecar, api_token: s.api_token, picdrop_metadata_sidecar: s.picdrop_metadata_sidecar, nas_metadata_sidecar: s.nas_metadata_sidecar, + output_ext: s.output_ext, // Rechte / Datenschutz / NSFW library_visibility: s.library_visibility, anonymous_generations: s.anonymous_generations, private_allowed: s.private_allowed, allow_nsfw: s.allow_nsfw, @@ -48,7 +49,7 @@ export const PATCH: APIRoute = async ({ request }) => { for (const col of ['picdrop_host', 'picdrop_protocol', 'picdrop_port', 'picdrop_user', 'picdrop_base_path', 'picdrop_default_gallery', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url', 'keep_sources', 'make_thumbnails', 'retention_days', 'metadata_sidecar', 'api_token', - 'picdrop_metadata_sidecar', 'nas_metadata_sidecar', + 'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'output_ext', 'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw', 'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path']) { if (col in b) { diff --git a/src/pages/api/items/[id]/file.ts b/src/pages/api/items/[id]/file.ts index 309552b..5a07861 100644 --- a/src/pages/api/items/[id]/file.ts +++ b/src/pages/api/items/[id]/file.ts @@ -31,7 +31,12 @@ export const GET: APIRoute = async ({ params, url, locals }) => { try { let buf = await getObject(path); const download = q.get('download') === '1'; - let contentType = path.endsWith('.webp') ? 'image/webp' : 'image/png'; + // Inhaltstyp aus den Magic Bytes ableiten — Ergebnisse können PNG oder JPG sein. + const sniff = (b: Buffer): string => + b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff ? 'image/jpeg' + : b.length > 12 && b.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp' + : 'image/png'; + let contentType = path.endsWith('.webp') ? 'image/webp' : sniff(buf); // Schnelle Vollbild-Vorschau: nicht-transparente Ergebnisse als JPEG (deutlich kleiner, // schneller auf Mobil) — reicht fürs „In Fotos sichern". Der PNG-Download bleibt unverändert. if (q.get('preview') === '1' && !download && !item?.has_alpha) { diff --git a/src/pages/api/recipes.ts b/src/pages/api/recipes.ts index ceaccfe..8c6e719 100644 --- a/src/pages/api/recipes.ts +++ b/src/pages/api/recipes.ts @@ -16,11 +16,12 @@ export const POST: APIRoute = async ({ request, locals }) => { const b = await request.json(); const row = await one( `INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, contour_mm, - model_key, delivery, picdrop_gallery, delivery_target_id, custom_instruction, mode, is_default, created_by) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,false,$14) RETURNING *`, + model_key, delivery, picdrop_gallery, delivery_target_id, custom_instruction, mode, output_ext, is_default, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,false,$15) RETURNING *`, [b.name, JSON.stringify(b.tasks || []), b.output_format, b.orientation, b.crop_mode || 'crop', b.dpi || 300, b.contour_mm ?? null, b.model_key ?? null, b.delivery || 'library', b.picdrop_gallery ?? null, b.delivery_target_id ?? null, b.custom_instruction ?? null, - ['each', 'compose', 'generate'].includes(b.mode) ? b.mode : 'each', locals.user.uid]); + ['each', 'compose', 'generate'].includes(b.mode) ? b.mode : 'each', + (b.output_ext === 'jpg' || b.output_ext === 'png') ? b.output_ext : null, locals.user.uid]); return json({ recipe: row }); }; diff --git a/src/pages/api/recipes/[id].ts b/src/pages/api/recipes/[id].ts index b26052c..af2e947 100644 --- a/src/pages/api/recipes/[id].ts +++ b/src/pages/api/recipes/[id].ts @@ -6,7 +6,7 @@ const json = (b: unknown, s = 200) => new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); const COLS = ['name', 'tasks', 'output_format', 'orientation', 'crop_mode', 'dpi', 'contour_mm', - 'model_key', 'delivery', 'picdrop_gallery', 'delivery_target_id', 'custom_instruction', 'mode']; + 'model_key', 'delivery', 'picdrop_gallery', 'delivery_target_id', 'custom_instruction', 'mode', 'output_ext']; export const PATCH: APIRoute = async ({ params, request, locals }) => { if (!locals.user) return new Response('Unauthorized', { status: 401 }); diff --git a/src/worker.ts b/src/worker.ts index 5d288bd..a3d601e 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -72,10 +72,12 @@ async function maybeComplete(jobId: string): Promise { (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) { - // Idempotent: nur der erste Übergang nach 'done' löst Auslieferung + Rückmeldung aus. + // Alles fehlgeschlagen, nichts fertig → Auftrag gilt als fehlgeschlagen (rot). + const finalStatus = row.done === 0 && row.failed > 0 ? 'failed' : 'done'; + // Idempotent: nur der erste Übergang löst Auslieferung + Rückmeldung aus. const done = await query( - `UPDATE jobs SET status='done', finished_at=now() - WHERE id=$1 AND status NOT IN ('done','cancelled') RETURNING id`, [jobId]); + `UPDATE jobs SET status=$2, finished_at=now() + WHERE id=$1 AND status NOT IN ('done','failed','cancelled') RETURNING id`, [jobId, finalStatus]); if (done.length === 0) return; // Picdrop-Auslieferung für alle offenen Positionen anstoßen. try { await deliverPendingForJob(jobId); } catch (e) { console.error('[worker] Auslieferung:', e); }