feat: custom formats (WxH/stickerN), JPG output option, failed-job status

- format.ts parses free formats from the key: sticker<N> -> N×N cm,
  <W>x<H> -> W×H cm. Fixes 'Unbekanntes Format: sticker5' (the seeded
  'Sticker 5 cm' recipe was unusable) and lets admins define ANY format
  without a migration. Guarded to <=300 cm.
- Output file format PNG/JPG: global default (settings.output_ext) plus
  per-recipe/per-conversion override. JPG only for non-alpha; cutouts
  stay PNG. file.ts sniffs content-type from magic bytes.
- Jobs can now be 'failed' (all items failed) -> red in the queue; add
  finished time to queue list + detail. migration 012.
- Studio: custom-measure input + Dateiformat selector; Admin: global
  Standard-Dateiformat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
2026-07-24 08:32:03 +00:00
parent efcb889c96
commit 3a061aa612
12 changed files with 142 additions and 21 deletions
@@ -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'));
+8
View File
@@ -76,6 +76,7 @@ export default function AdminApp() {
picdrop_host: s.picdrop_host, picdrop_protocol: s.picdrop_protocol, picdrop_port: s.picdrop_port, 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, 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, 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, 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, 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, picdrop_metadata_sidecar: !!s.picdrop_metadata_sidecar, nas_metadata_sidecar: !!s.nas_metadata_sidecar,
@@ -204,6 +205,13 @@ export default function AdminApp() {
<div className="feld"><label>Parallelität</label><input className="input" type="number" value={s.concurrency ?? 2} onChange={(e) => field('concurrency', e.target.value)} /></div> <div className="feld"><label>Parallelität</label><input className="input" type="number" value={s.concurrency ?? 2} onChange={(e) => field('concurrency', e.target.value)} /></div>
</div> </div>
<div className="feld"><label>Cricut-Bogenmaß (cm)</label><input className="input" value={s.cricut_sheet_cm ?? ''} onChange={(e) => field('cricut_sheet_cm', e.target.value)} /></div> <div className="feld"><label>Cricut-Bogenmaß (cm)</label><input className="input" value={s.cricut_sheet_cm ?? ''} onChange={(e) => field('cricut_sheet_cm', e.target.value)} /></div>
<div className="feld"><label>Standard-Dateiformat</label>
<select className="input" value={s.output_ext ?? 'png'} onChange={(e) => field('output_ext', e.target.value)}>
<option value="png">PNG (verlustfrei, groß)</option>
<option value="jpg">JPG (klein, Picdrop-freundlich)</option>
</select>
<div className="fein">Gilt für neue Umwandlungen ohne eigene Format-Wahl. Freigestellte Motive bleiben immer PNG.</div>
</div>
</div> </div>
</section> </section>
+15 -2
View File
@@ -5,6 +5,17 @@ const STATUS_DE: Record<string, string> = {
skipped: 'übersprungen', paused: 'angehalten', cancelled: 'abgebrochen', 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 }) { export default function QueueApp({ jobId }: { jobId: string | null }) {
const [job, setJob] = useState<any>(null); const [job, setJob] = useState<any>(null);
const [items, setItems] = useState<any[]>([]); const [items, setItems] = useState<any[]>([]);
@@ -40,7 +51,8 @@ export default function QueueApp({ jobId }: { jobId: string | null }) {
<a key={j.id} className="jobrow" href={`/warteschlange?job=${j.id}`}> <a key={j.id} className="jobrow" href={`/warteschlange?job=${j.id}`}>
<span className={`punkt ${j.status}`} /> <span className={`punkt ${j.status}`} />
<b>{j.done_count}/{j.total}</b> <b>{j.done_count}/{j.total}</b>
<span className="fein">{STATUS_DE[j.status] || j.status} · {j.by_name || ''}</span> <span className="fein">{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)}` : ''}</span>
</a> </a>
))} ))}
</div> </div>
@@ -59,7 +71,8 @@ export default function QueueApp({ jobId }: { jobId: string | null }) {
return ( return (
<div className="q"> <div className="q">
<div className="kopfzeile"> <div className="kopfzeile">
<span className="mono-label">Auftrag · {STATUS_DE[job?.status] || job?.status}</span> <span className="mono-label">Auftrag · {STATUS_DE[job?.status] || job?.status}
{job?.finished_at && ['done', 'failed', 'cancelled'].includes(job?.status) ? ` · beendet ${fmtTime(job.finished_at)}` : ''}</span>
<div className="reihe"> <div className="reihe">
{job?.status === 'running' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/pause`)}>Anhalten</button>} {job?.status === 'running' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/pause`)}>Anhalten</button>}
{job?.status === 'paused' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/resume`)}>Fortsetzen</button>} {job?.status === 'paused' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/resume`)}>Fortsetzen</button>}
+59 -7
View File
@@ -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: 'theframe', label: 'The Frame (16:9)', screen: [3840, 2160] },
{ id: 'hochformat', label: 'Hochformat (9:16)', screen: [2160, 3840] }, { id: 'hochformat', label: 'Hochformat (9:16)', screen: [2160, 3840] },
{ id: 'sticker', label: 'Sticker (freies Maß)', cm: [5, 5] }, { 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 = [ const TASKS = [
{ id: 'clean', name: 'Bereinigen', hint: 'Rahmen, Wand und Shop-Oberfläche entfernen.' }, { id: 'clean', name: 'Bereinigen', hint: 'Rahmen, Wand und Shop-Oberfläche entfernen.' },
{ id: 'cutout', name: 'Freistellen', hint: 'Nur das Motiv, Hintergrund transparent.' }, { 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 [portrait, setPortrait] = useState(true);
const [crop, setCrop] = useState<'crop' | 'extend'>('crop'); const [crop, setCrop] = useState<'crop' | 'extend'>('crop');
const [contourMm, setContourMm] = useState(3); 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 [custom, setCustom] = useState('');
const [desc, setDesc] = useState(''); const [desc, setDesc] = useState('');
const [delivery, setDelivery] = useState<'library' | 'picdrop' | 'both'>('library'); 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 showFormat = mode === 'each' ? hasFormat : true;
const wantsFormat = mode === 'each' ? hasFormat : format !== 'keep'; 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 const zielPx: [number, number] | null = fmt?.screen
? (fmt.screen as [number, number]) ? (fmt.screen as [number, number])
: fmt?.cm : effCm
? (portrait ? [px(fmt.cm[0]), px(fmt.cm[1])] : [px(fmt.cm[1]), px(fmt.cm[0])]) ? (portrait ? [px(effCm[0]), px(effCm[1])] : [px(effCm[1]), px(effCm[0])])
: null; : null;
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); }; 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 order = ['clean', 'cutout', 'format', 'contour', 'deliver'];
const tasksOut = mode === 'each' ? [...tasks].sort((a, b) => order.indexOf(a) - order.indexOf(b)) : (wantsFormat ? ['format'] : []); const tasksOut = mode === 'each' ? [...tasks].sort((a, b) => order.indexOf(a) - order.indexOf(b)) : (wantsFormat ? ['format'] : []);
const body = { 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, orientation: portrait ? 'portrait' : 'landscape', crop_mode: crop, dpi: 300,
contour_mm: tasks.includes('contour') ? contourMm : null, contour_mm: tasks.includes('contour') ? contourMm : null,
output_ext: outputExt || null,
custom_instruction: mode === 'each' ? (custom || null) : null, model_key: modelKey || null, custom_instruction: mode === 'each' ? (custom || null) : null, model_key: modelKey || null,
delivery, picdrop_gallery: gallery.trim() || null, delivery_target_id: targetId || 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 (!r) return;
if (['each', 'compose', 'generate'].includes(r.mode)) setMode(r.mode); if (['each', 'compose', 'generate'].includes(r.mode)) setMode(r.mode);
setTasks(r.tasks || ['clean', 'format']); 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'); setPortrait((r.orientation || 'portrait') !== 'landscape');
setCrop(r.crop_mode === 'extend' ? 'extend' : 'crop'); setCrop(r.crop_mode === 'extend' ? 'extend' : 'crop');
setContourMm(Number(r.contour_mm) || 3); 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 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 === 'each' ? (ready.length > 0 && tasks.length > 0 && !theframeConflict && !(tasks.includes('contour') && !hasCutout))
: mode === 'compose' ? (ready.length >= 1 && desc.trim().length > 0) : mode === 'compose' ? (ready.length >= 1 && desc.trim().length > 0)
: desc.trim().length > 0 : desc.trim().length > 0
@@ -202,10 +232,11 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
: (wantsFormat ? ['format'] : []); : (wantsFormat ? ['format'] : []);
const recipe = { const recipe = {
tasks: tasksOut, tasks: tasksOut,
output_format: wantsFormat ? format : 'keep', output_format: wantsFormat ? effFormat : 'keep',
orientation: portrait ? 'portrait' : 'landscape', orientation: portrait ? 'portrait' : 'landscape',
crop_mode: crop, dpi: 300, crop_mode: crop, dpi: 300,
contour_mm: tasks.includes('contour') ? contourMm : null, contour_mm: tasks.includes('contour') ? contourMm : null,
output_ext: outputExt || null,
custom_instruction: mode === 'each' ? (custom || null) : null, custom_instruction: mode === 'each' ? (custom || null) : null,
delivery, model_key: modelKey || null, delivery, model_key: modelKey || null,
picdrop_gallery: (delivery !== 'library' && gallery.trim()) ? gallery.trim() : null, picdrop_gallery: (delivery !== 'library' && gallery.trim()) ? gallery.trim() : null,
@@ -329,8 +360,15 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
<select className="select" value={format} onChange={(e) => setFormat(e.target.value)}> <select className="select" value={format} onChange={(e) => setFormat(e.target.value)}>
{FORMATS.map((f) => <option key={f.id} value={f.id}>{f.label}</option>)} {FORMATS.map((f) => <option key={f.id} value={f.id}>{f.label}</option>)}
</select> </select>
{isFreeFmt && (
<>
<input className="input" value={customFmt} onChange={(e) => 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 && <div className="fein warn">Bitte ein Maß angeben, z. B. 25x35" oder „5".</div>}
</>
)}
{theframeConflict && <div className="fein warn">Freistellen + The Frame ergibt keinen Sinn bitte eins wählen.</div>} {theframeConflict && <div className="fein warn">Freistellen + The Frame ergibt keinen Sinn bitte eins wählen.</div>}
{fmt?.cm && !fmt.screen && ( {effCm && (
<div className="schalter"> <div className="schalter">
<button className={portrait ? 'an' : ''} onClick={() => setPortrait(true)}>Hochformat</button> <button className={portrait ? 'an' : ''} onClick={() => setPortrait(true)}>Hochformat</button>
<button className={!portrait ? 'an' : ''} onClick={() => setPortrait(false)}>Querformat</button> <button className={!portrait ? 'an' : ''} onClick={() => setPortrait(false)}>Querformat</button>
@@ -349,6 +387,20 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
</div> </div>
)} )}
<div className="feld">
<label>Dateiformat</label>
<div className="schalter">
<button className={outputExt === '' ? 'an' : ''} onClick={() => setOutputExt('')}>Standard</button>
<button className={outputExt === 'png' ? 'an' : ''} onClick={() => setOutputExt('png')}>PNG</button>
<button className={outputExt === 'jpg' ? 'an' : ''} onClick={() => setOutputExt('jpg')}>JPG</button>
</div>
<div className="fein">
{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).'}
</div>
</div>
{mode === 'each' && tasks.includes('contour') && ( {mode === 'each' && tasks.includes('contour') && (
<div className="feld"> <div className="feld">
<label>Stickerrand (mm)</label> <label>Stickerrand (mm)</label>
+2
View File
@@ -52,6 +52,8 @@ async function deliverableBuffer(
buf: Buffer, filename: string, hasAlpha: boolean, buf: Buffer, filename: string, hasAlpha: boolean,
): Promise<{ buf: Buffer; name: string }> { ): Promise<{ buf: Buffer; name: string }> {
if (hasAlpha) return { buf, name: filename }; 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 { try {
const sharp = (await import('sharp')).default; const sharp = (await import('sharp')).default;
const meta = await sharp(buf, { failOn: 'none' }).metadata(); const meta = await sharp(buf, { failOn: 'none' }).metadata();
+11 -1
View File
@@ -45,7 +45,17 @@ export function resolveDimensions(input: ResolveInput): Dimensions | null {
let cm: [number, number] | undefined; let cm: [number, number] | undefined;
if (format in CM) cm = CM[format]; if (format in CM) cm = CM[format];
else if (format === 'sticker' || format === 'custom') cm = input.customCm ?? [5, 5]; 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 // Hochformat-Konvention → gewünschte Orientierung anwenden
let [wCm, hCm] = cm; let [wCm, hCm] = cm;
+18 -2
View File
@@ -22,6 +22,7 @@ interface RecipeSnapshot {
prompt_text?: string | null; // Beschreibung für compose/generate 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;
output_ext?: 'png' | 'jpg' | null; // Dateiformat-Override; sonst globale Einstellung
} }
/** Welches Modell-ID + Alpha-Fähigkeit? Aus models-Tabelle, sonst Default. */ /** Welches Modell-ID + Alpha-Fähigkeit? Aus models-Tabelle, sonst Default. */
@@ -130,8 +131,23 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
hasAlpha = true; 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); const key = resultKey(item.id);
await putObject(key, outBuf, 'image/png'); await putObject(key, outBuf, contentType);
// Vorschaubild (optional, laut Einstellung) // Vorschaubild (optional, laut Einstellung)
const cfg = await one<{ make_thumbnails: boolean; keep_sources: boolean; nas_enabled: boolean }>( const cfg = await one<{ make_thumbnails: boolean; keep_sources: boolean; nas_enabled: boolean }>(
@@ -145,7 +161,7 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
} catch (e) { console.error('[process] Thumbnail fehlgeschlagen', e); } } 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}`; const outputPx = `${fin.width}x${fin.height}`;
// Private Aufträge: kein Delivery, kein Backup, kein gespeicherter Prompt. // Private Aufträge: kein Delivery, kein Backup, kein gespeicherter Prompt.
const deliveryStatus = (!isPrivate && (r.delivery === 'picdrop' || r.delivery === 'both')) ? 'pending' : 'none'; const deliveryStatus = (!isPrivate && (r.delivery === 'picdrop' || r.delivery === 'both')) ? 'pending' : 'none';
+2 -1
View File
@@ -25,6 +25,7 @@ export const GET: APIRoute = async () => {
keep_sources: s.keep_sources, make_thumbnails: s.make_thumbnails, retention_days: s.retention_days, 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, metadata_sidecar: s.metadata_sidecar, api_token: s.api_token,
picdrop_metadata_sidecar: s.picdrop_metadata_sidecar, nas_metadata_sidecar: s.nas_metadata_sidecar, picdrop_metadata_sidecar: s.picdrop_metadata_sidecar, nas_metadata_sidecar: s.nas_metadata_sidecar,
output_ext: s.output_ext,
// Rechte / Datenschutz / NSFW // Rechte / Datenschutz / NSFW
library_visibility: s.library_visibility, anonymous_generations: s.anonymous_generations, library_visibility: s.library_visibility, anonymous_generations: s.anonymous_generations,
private_allowed: s.private_allowed, allow_nsfw: s.allow_nsfw, 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', 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', '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', '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', 'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path']) { 'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path']) {
if (col in b) { if (col in b) {
+6 -1
View File
@@ -31,7 +31,12 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
try { try {
let buf = await getObject(path); let buf = await getObject(path);
const download = q.get('download') === '1'; 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, // 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. // schneller auf Mobil) — reicht fürs „In Fotos sichern". Der PNG-Download bleibt unverändert.
if (q.get('preview') === '1' && !download && !item?.has_alpha) { if (q.get('preview') === '1' && !download && !item?.has_alpha) {
+4 -3
View File
@@ -16,11 +16,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
const b = await request.json(); const b = await request.json();
const row = await one( const row = await one(
`INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, contour_mm, `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) 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,false,$14) RETURNING *`, 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.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.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, 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 }); return json({ recipe: row });
}; };
+1 -1
View File
@@ -6,7 +6,7 @@ const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
const COLS = ['name', 'tasks', 'output_format', 'orientation', 'crop_mode', 'dpi', 'contour_mm', 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 }) => { export const PATCH: APIRoute = async ({ params, request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 }); if (!locals.user) return new Response('Unauthorized', { status: 401 });
+5 -3
View File
@@ -72,10 +72,12 @@ async function maybeComplete(jobId: string): Promise<void> {
(SELECT count(*) FROM items WHERE job_id=$1 AND status IN ('queued','running'))::int AS open (SELECT count(*) FROM items WHERE job_id=$1 AND status IN ('queued','running'))::int AS open
FROM jobs WHERE id=$1`, [jobId]); FROM jobs WHERE id=$1`, [jobId]);
if (row && row.open === 0) { 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( const done = await query(
`UPDATE jobs SET status='done', finished_at=now() `UPDATE jobs SET status=$2, finished_at=now()
WHERE id=$1 AND status NOT IN ('done','cancelled') RETURNING id`, [jobId]); WHERE id=$1 AND status NOT IN ('done','failed','cancelled') RETURNING id`, [jobId, finalStatus]);
if (done.length === 0) return; if (done.length === 0) return;
// Picdrop-Auslieferung für alle offenen Positionen anstoßen. // Picdrop-Auslieferung für alle offenen Positionen anstoßen.
try { await deliverPendingForJob(jobId); } catch (e) { console.error('[worker] Auslieferung:', e); } try { await deliverPendingForJob(jobId); } catch (e) { console.error('[worker] Auslieferung:', e); }