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_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() {
|
||||
<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 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>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -5,6 +5,17 @@ const STATUS_DE: Record<string, string> = {
|
||||
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<any>(null);
|
||||
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}`}>
|
||||
<span className={`punkt ${j.status}`} />
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
@@ -59,7 +71,8 @@ export default function QueueApp({ jobId }: { jobId: string | null }) {
|
||||
return (
|
||||
<div className="q">
|
||||
<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">
|
||||
{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>}
|
||||
|
||||
@@ -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[] }) {
|
||||
<select className="select" value={format} onChange={(e) => setFormat(e.target.value)}>
|
||||
{FORMATS.map((f) => <option key={f.id} value={f.id}>{f.label}</option>)}
|
||||
</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>}
|
||||
{fmt?.cm && !fmt.screen && (
|
||||
{effCm && (
|
||||
<div className="schalter">
|
||||
<button className={portrait ? 'an' : ''} onClick={() => setPortrait(true)}>Hochformat</button>
|
||||
<button className={!portrait ? 'an' : ''} onClick={() => setPortrait(false)}>Querformat</button>
|
||||
@@ -349,6 +387,20 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
</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') && (
|
||||
<div className="feld">
|
||||
<label>Stickerrand (mm)</label>
|
||||
|
||||
@@ -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();
|
||||
|
||||
+11
-1
@@ -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;
|
||||
|
||||
+18
-2
@@ -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<ProcessResult> {
|
||||
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<ProcessResult> {
|
||||
} 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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+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
|
||||
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); }
|
||||
|
||||
Reference in New Issue
Block a user