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:
@@ -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'));
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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';
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user