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
+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_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>
+15 -2
View File
@@ -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>}
+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: '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>