Compare commits

..

6 Commits

19 changed files with 860 additions and 217 deletions
@@ -0,0 +1,35 @@
-- Klarbild — Erzeugungs-Modi, Vorschaubilder, Speicherverwaltung, NAS-Sicherung.
-- (04) Kombinieren aus mehreren Bildern + Freitext-Erzeugung, Thumbnails für die
-- Bibliothek, „nicht den Server vollmüllen" (Retention/Quellenlöschen), zweite
-- Datensicherung auf ein Synology-NAS.
-- Auftrags-Modus: each = jede Vorlage einzeln (bisher), compose = mehrere Bilder + Text
-- zu EINEM neuen Bild, generate = reiner Freitext ohne Vorlage.
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS mode text NOT NULL DEFAULT 'each'
CHECK (mode IN ('each','compose','generate'));
-- Positionen: mehrere Quellbilder (compose) + verkleinertes Vorschaubild.
ALTER TABLE items ADD COLUMN IF NOT EXISTS source_paths jsonb;
ALTER TABLE items ADD COLUMN IF NOT EXISTS thumb_path text;
-- Rezepte können einen Modus vorbelegen (für Telegram-Standardrezepte).
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS mode text NOT NULL DEFAULT 'each'
CHECK (mode IN ('each','compose','generate'));
-- Speicherverwaltung -------------------------------------------------------
ALTER TABLE settings ADD COLUMN IF NOT EXISTS keep_sources bool NOT NULL DEFAULT true;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS make_thumbnails bool NOT NULL DEFAULT true;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS retention_days int; -- NULL = unbegrenzt
-- Zweite Sicherung auf Synology-NAS (SFTP/FTPS, wie Picdrop) ----------------
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_enabled bool NOT NULL DEFAULT false;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_host text;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_protocol text CHECK (nas_protocol IN ('ftps','sftp'));
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_port int;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_user text;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_password_enc text;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_base_path text;
-- Spiegel-Status je Position (für Sichtbarkeit/Wiederholung).
ALTER TABLE items ADD COLUMN IF NOT EXISTS nas_status text NOT NULL DEFAULT 'none'
CHECK (nas_status IN ('none','pending','mirrored','failed'));
+5
View File
@@ -0,0 +1,5 @@
-- Telegram: Kombinieren/Erzeugen — Bildunterschrift als Beschreibung + Zwischenstatus.
ALTER TABLE telegram_drafts ADD COLUMN IF NOT EXISTS caption text;
ALTER TABLE telegram_drafts DROP CONSTRAINT IF EXISTS telegram_drafts_status_check;
ALTER TABLE telegram_drafts ADD CONSTRAINT telegram_drafts_status_check
CHECK (status IN ('collecting','awaiting_recipe','awaiting_compose_text','dispatched','discarded'));
+70 -4
View File
@@ -8,20 +8,24 @@ export default function AdminApp() {
const [users, setUsers] = useState<any[]>([]); const [users, setUsers] = useState<any[]>([]);
const [tgLinks, setTgLinks] = useState<any[]>([]); const [tgLinks, setTgLinks] = useState<any[]>([]);
const [toast, setToast] = useState<string | null>(null); const [toast, setToast] = useState<string | null>(null);
const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [nasPw, setNasPw] = useState('');
const [storage, setStorage] = useState<any>(null);
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); }; const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); };
const load = useCallback(async () => { const load = useCallback(async () => {
const [a, b, c, d, e] = await Promise.all([ const [a, b, c, d, e, f] = await Promise.all([
fetch('/api/admin/settings').then((r) => r.json()), fetch('/api/admin/settings').then((r) => r.json()),
fetch('/api/admin/stats').then((r) => r.json()), fetch('/api/admin/stats').then((r) => r.json()),
fetch('/api/admin/models?available=1').then((r) => r.json()), fetch('/api/admin/models?available=1').then((r) => r.json()),
fetch('/api/admin/users').then((r) => r.json()), fetch('/api/admin/users').then((r) => r.json()),
fetch('/api/admin/telegram/links').then((r) => r.json()).catch(() => ({ links: [] })), fetch('/api/admin/telegram/links').then((r) => r.json()).catch(() => ({ links: [] })),
fetch('/api/admin/storage').then((r) => r.json()).catch(() => ({ stats: null })),
]); ]);
setS(a.settings || {}); setStats(b); setModels(c.models || []); setAvail(c.available || []); setS(a.settings || {}); setStats(b); setModels(c.models || []); setAvail(c.available || []);
setUsers(d.users || []); setTgLinks(e.links || []); setUsers(d.users || []); setTgLinks(e.links || []); setStorage(f.stats || null);
}, []); }, []);
const fmtBytes = (n: number | null) => n == null ? '—'
: n > 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n > 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.round(n / 1e3)} KB`;
const pairCode = async (userId: string) => { const pairCode = async (userId: string) => {
const r = await fetch('/api/admin/telegram/pairing-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }) }).then((x) => x.json()); const r = await fetch('/api/admin/telegram/pairing-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }) }).then((x) => x.json());
@@ -40,17 +44,33 @@ export default function AdminApp() {
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,
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,
nas_enabled: !!s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port,
nas_user: s.nas_user, nas_base_path: s.nas_base_path,
}; };
if (orKey.trim()) body.openrouter_key = orKey.trim(); if (orKey.trim()) body.openrouter_key = orKey.trim();
if (pdPw.trim()) body.picdrop_password = pdPw.trim(); if (pdPw.trim()) body.picdrop_password = pdPw.trim();
if (nasPw.trim()) body.nas_password = nasPw.trim();
await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
setOrKey(''); setPdPw(''); notify('Gespeichert.'); load(); setOrKey(''); setPdPw(''); setNasPw(''); notify('Gespeichert.'); load();
}; };
const testPicdrop = async () => { const testPicdrop = async () => {
notify('Teste …'); notify('Teste …');
const r = await fetch('/api/admin/test-picdrop', { method: 'POST' }).then((x) => x.json()); const r = await fetch('/api/admin/test-picdrop', { method: 'POST' }).then((x) => x.json());
notify(r.message || (r.ok ? 'OK' : 'Fehler')); notify(r.message || (r.ok ? 'OK' : 'Fehler'));
}; };
const testNas = async () => {
notify('Teste NAS …');
const r = await fetch('/api/admin/test-nas', { method: 'POST' }).then((x) => x.json());
notify(r.message || (r.ok ? 'OK' : 'Fehler'));
};
const storageAction = async (action: string, confirmMsg?: string) => {
if (confirmMsg && !confirm(confirmMsg)) return;
notify('Räume auf …');
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }) }).then((x) => x.json());
notify(r.deleted != null ? `${r.deleted} alte Positionen entfernt.` : r.purged != null ? `${r.purged} Quelldateien gelöscht.` : (r.error || 'OK'));
load();
};
const addModel = async (m: any) => { const addModel = async (m: any) => {
await fetch('/api/admin/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, await fetch('/api/admin/models', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_id: m.model_id, label: m.name, supports_alpha: m.supports_alpha, active: true }) }); body: JSON.stringify({ model_id: m.model_id, label: m.name, supports_alpha: m.supports_alpha, active: true }) });
@@ -134,6 +154,47 @@ export default function AdminApp() {
</div> </div>
</section> </section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Speicher & Aufräumen</span>
{storage && <span className="fein">{fmtBytes(storage.bytes)} · {storage.results} Ergebnisse · {storage.sources} Quellen · {storage.thumbs} Vorschau</span>}</div>
<div className="steuer">
<label className="schalt"><input type="checkbox" checked={s.make_thumbnails !== false} onChange={(e) => field('make_thumbnails', e.target.checked)} />
<span><b>Vorschaubilder erzeugen</b><em>Kleine, sparsame Bilder für die Bibliotheksvorschau.</em></span></label>
<label className="schalt"><input type="checkbox" checked={!s.keep_sources} onChange={(e) => field('keep_sources', !e.target.checked)} />
<span><b>Quellbilder nach Bearbeitung löschen</b><em>Spart Platz Originale werden nach dem Ergebnis entfernt.</em></span></label>
<div className="feld"><label>Aufbewahrung (Tage)</label>
<input className="input" type="number" min={0} placeholder="leer = unbegrenzt" value={s.retention_days ?? ''} onChange={(e) => field('retention_days', e.target.value)} />
<div className="fein">Ältere Bilder werden automatisch (täglich) entfernt. Leer = nichts löschen.</div></div>
<div className="reihe">
<button className="mini" onClick={() => storageAction('purge_sources', 'Alle Quellbilder fertiger Positionen löschen? Ergebnisse bleiben erhalten.')}>Quellen jetzt löschen</button>
{s.retention_days ? <button className="mini" onClick={() => storageAction('retention', `Positionen älter als ${s.retention_days} Tage jetzt löschen?`)}>Retention jetzt anwenden</button> : null}
</div>
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Synology-NAS (zweite Sicherung)</span>
<button className="mini" onClick={testNas}>Verbindung testen</button></div>
<div className="steuer">
<label className="schalt"><input type="checkbox" checked={!!s.nas_enabled} onChange={(e) => field('nas_enabled', e.target.checked)} />
<span><b>Ergebnisse zusätzlich aufs NAS spiegeln</b><em>Jedes fertige Bild wird nach <code>klarbild/JJJJ-MM/</code> gesichert.</em></span></label>
<div className="zwei">
<div className="feld"><label>Host</label><input className="input" placeholder="z. B. nas.local oder DDNS" value={s.nas_host ?? ''} onChange={(e) => field('nas_host', e.target.value)} /></div>
<div className="feld"><label>Port</label><input className="input" type="number" value={s.nas_port ?? 22} onChange={(e) => field('nas_port', e.target.value)} /></div>
</div>
<div className="zwei">
<div className="feld"><label>Protokoll</label>
<select className="input" value={s.nas_protocol ?? 'sftp'} onChange={(e) => field('nas_protocol', e.target.value)}>
<option value="sftp">SFTP (22)</option><option value="ftps">FTPS (21)</option></select></div>
<div className="feld"><label>Benutzer</label><input className="input" value={s.nas_user ?? ''} onChange={(e) => field('nas_user', e.target.value)} /></div>
</div>
<div className="feld"><label>Passwort {s.nas_password_set && <em>(gesetzt)</em>}</label>
<input className="input" type="password" placeholder={s.nas_password_set ? '••••••' : ''} value={nasPw} onChange={(e) => setNasPw(e.target.value)} /></div>
<div className="feld"><label>Basisordner auf dem NAS</label><input className="input" placeholder="/homes/klarbild oder /volume1/…" value={s.nas_base_path ?? ''} onChange={(e) => field('nas_base_path', e.target.value)} /></div>
<div className="fein">Bei Synology am einfachsten SFTP aktivieren (Systemsteuerung Terminal &amp; SNMP SFTP). Zugangsdaten werden verschlüsselt gespeichert.</div>
</div>
</section>
<button className="knopf" onClick={saveSettings}>Einstellungen speichern</button> <button className="knopf" onClick={saveSettings}>Einstellungen speichern</button>
<section className="karte"> <section className="karte">
@@ -213,6 +274,11 @@ function AdminStyles() {
.zeile b{font-size:14px;} .zeile b{font-size:14px;}
.pille{font-family:var(--font-mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;background:var(--accent-bg);color:var(--accent);padding:2px 7px;border-radius:20px;margin-left:6px;} .pille{font-family:var(--font-mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;background:var(--accent-bg);color:var(--accent);padding:2px 7px;border-radius:20px;margin-left:6px;}
.pille.alpha{background:var(--paper);color:var(--soft);} .pille.alpha{background:var(--paper);color:var(--soft);}
.schalt{display:flex;gap:10px;align-items:flex-start;cursor:pointer;}
.schalt input{margin-top:3px;width:16px;height:16px;accent-color:var(--accent);flex:0 0 auto;}
.schalt b{display:block;font-size:14px;}
.schalt em{display:block;font-style:normal;font-size:11.5px;color:var(--soft);margin-top:1px;line-height:1.4;}
.schalt code{font-family:var(--font-mono);font-size:11px;}
.avail{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px;} .avail{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px;}
.reihe{display:flex;gap:6px;} .reihe{display:flex;gap:6px;}
.mini{background:var(--card);border:1px solid var(--line);border-radius:3px;padding:6px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);} .mini{background:var(--card);border:1px solid var(--line);border-radius:3px;padding:6px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);}
+1 -1
View File
@@ -66,7 +66,7 @@ export default function LibraryApp() {
{items.map((v) => ( {items.map((v) => (
<article key={v.id} className={`kachel ${sel.has(v.id) ? 'gewaehlt' : ''}`}> <article key={v.id} className={`kachel ${sel.has(v.id) ? 'gewaehlt' : ''}`}>
<div className="kachel-bild" onClick={() => toggle(v.id)}> <div className="kachel-bild" onClick={() => toggle(v.id)}>
<img src={`/api/items/${v.id}/file`} alt="" loading="lazy" /> <img src={`/api/items/${v.id}/file?thumb=1`} alt="" loading="lazy" />
<span className="haken">{sel.has(v.id) ? '✓' : ''}</span> <span className="haken">{sel.has(v.id) ? '✓' : ''}</span>
</div> </div>
<input className="name" defaultValue={v.filename || ''} onBlur={(e) => rename(v.id, e.target.value)} /> <input className="name" defaultValue={v.filename || ''} onBlur={(e) => rename(v.id, e.target.value)} />
+209 -140
View File
@@ -31,11 +31,19 @@ const TASKS = [
{ id: 'contour', name: 'Kontur', hint: 'Weißen Stickerrand anlegen (nur mit Freistellen).' }, { id: 'contour', name: 'Kontur', hint: 'Weißen Stickerrand anlegen (nur mit Freistellen).' },
]; ];
type Mode = 'each' | 'compose' | 'generate';
const MODES: { id: Mode; name: string; hint: string }[] = [
{ id: 'each', name: 'Bearbeiten', hint: 'Screenshots bereinigen, freistellen, aufs Format bringen.' },
{ id: 'compose', name: 'Kombinieren', hint: 'Mehrere Bilder + Beschreibung zu einem neuen Bild.' },
{ id: 'generate', name: 'Neu erzeugen', hint: 'Ein komplett neues Bild allein aus Text.' },
];
const px = (cm: number, dpi = 300) => Math.round((cm / 2.54) * dpi); const px = (cm: number, dpi = 300) => Math.round((cm / 2.54) * dpi);
interface Pic { id: string; src: string; name: string; source_path?: string; error?: string; uploading?: boolean } interface Pic { id: string; src: string; name: string; source_path?: string; error?: string; uploading?: boolean }
export default function StudioApp({ recipes }: { recipes: any[] }) { export default function StudioApp({ recipes }: { recipes: any[] }) {
const [mode, setMode] = useState<Mode>('each');
const [pics, setPics] = useState<Pic[]>([]); const [pics, setPics] = useState<Pic[]>([]);
const [tasks, setTasks] = useState<string[]>(['clean', 'format']); const [tasks, setTasks] = useState<string[]>(['clean', 'format']);
const [format, setFormat] = useState('30x40'); const [format, setFormat] = useState('30x40');
@@ -43,6 +51,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
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 [custom, setCustom] = useState(''); const [custom, setCustom] = useState('');
const [desc, setDesc] = useState('');
const [delivery, setDelivery] = useState<'library' | 'picdrop' | 'both'>('library'); const [delivery, setDelivery] = useState<'library' | 'picdrop' | 'both'>('library');
const [models, setModels] = useState<any[]>([]); const [models, setModels] = useState<any[]>([]);
const [modelKey, setModelKey] = useState<string>(''); const [modelKey, setModelKey] = useState<string>('');
@@ -55,6 +64,9 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
const hasCutout = tasks.includes('cutout'); const hasCutout = tasks.includes('cutout');
const hasFormat = tasks.includes('format'); const hasFormat = tasks.includes('format');
const theframeConflict = hasCutout && (format === 'theframe' || format === 'hochformat'); const theframeConflict = hasCutout && (format === 'theframe' || format === 'hochformat');
// Format ist in compose/generate immer verfügbar (ohne „Format"-Aufgabe).
const showFormat = mode === 'each' ? hasFormat : true;
const wantsFormat = mode === 'each' ? hasFormat : format !== 'keep';
const zielPx: [number, number] | null = fmt?.screen const zielPx: [number, number] | null = fmt?.screen
? (fmt.screen as [number, number]) ? (fmt.screen as [number, number])
@@ -87,12 +99,13 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
useEffect(() => { useEffect(() => {
const onPaste = (e: ClipboardEvent) => { const onPaste = (e: ClipboardEvent) => {
if (mode === 'generate') return;
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith('image/')); const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith('image/'));
if (imgs.length) upload(imgs.map((i) => i.getAsFile()!).filter(Boolean)); if (imgs.length) upload(imgs.map((i) => i.getAsFile()!).filter(Boolean));
}; };
window.addEventListener('paste', onPaste); window.addEventListener('paste', onPaste);
return () => window.removeEventListener('paste', onPaste); return () => window.removeEventListener('paste', onPaste);
}, [upload]); }, [upload, mode]);
useEffect(() => { useEffect(() => {
fetch('/api/models').then((r) => r.json()).then((j) => { fetch('/api/models').then((r) => r.json()).then((j) => {
@@ -105,8 +118,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
function toggleTask(id: string) { function toggleTask(id: string) {
setTasks((t) => { setTasks((t) => {
let next = t.includes(id) ? t.filter((x) => x !== id) : [...t, id]; let next = t.includes(id) ? t.filter((x) => x !== id) : [...t, id];
if (id === 'cutout' && !next.includes('cutout')) next = next.filter((x) => x !== 'contour'); // Kontur braucht Freistellen if (id === 'cutout' && !next.includes('cutout')) next = next.filter((x) => x !== 'contour');
if (id === 'contour' && next.includes('contour') && !next.includes('cutout')) next = next; // wird per disabled verhindert
return next; return next;
}); });
} }
@@ -124,24 +136,34 @@ 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 = ready.length > 0 && !busy && tasks.length > 0 && !theframeConflict && const canRun = !busy && (
!(tasks.includes('contour') && !hasCutout); mode === 'each' ? (ready.length > 0 && tasks.length > 0 && !theframeConflict && !(tasks.includes('contour') && !hasCutout))
: mode === 'compose' ? (ready.length >= 2 && desc.trim().length > 0)
: desc.trim().length > 0
);
async function run() { async function run() {
if (!canRun) return; if (!canRun) return;
setBusy(true); setBusy(true);
const order = ['clean', 'cutout', 'format', 'contour', 'deliver'];
const tasksOut = mode === 'each'
? [...tasks].sort((a, b) => order.indexOf(a) - order.indexOf(b))
: (wantsFormat ? ['format'] : []);
const recipe = { const recipe = {
tasks: [...tasks].sort((a, b) => ['clean', 'cutout', 'format', 'contour', 'deliver'].indexOf(a) - ['clean', 'cutout', 'format', 'contour', 'deliver'].indexOf(b)), tasks: tasksOut,
output_format: hasFormat ? format : 'keep', output_format: wantsFormat ? format : '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,
custom_instruction: custom || null, delivery, model_key: modelKey || null, custom_instruction: mode === 'each' ? (custom || null) : null,
delivery, model_key: modelKey || null,
}; };
const body: any = { recipe, mode, delivery };
if (mode !== 'each') body.prompt_text = desc.trim();
body.sources = mode === 'generate' ? [] : ready.map((p) => ({ source_path: p.source_path, filename: p.name }));
try { try {
const res = await fetch('/api/jobs', { const res = await fetch('/api/jobs', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
body: JSON.stringify({ recipe, sources: ready.map((p) => ({ source_path: p.source_path, filename: p.name })) }),
}); });
const j = await res.json(); const j = await res.json();
if (j.jobId) location.href = `/warteschlange?job=${j.jobId}`; if (j.jobId) location.href = `/warteschlange?job=${j.jobId}`;
@@ -149,136 +171,175 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
} catch { notify('Netzwerkfehler.'); setBusy(false); } } catch { notify('Netzwerkfehler.'); setBusy(false); }
} }
const uploadHint = mode === 'compose'
? 'Mindestens 2 Bilder — z. B. das Motiv und ein Foto von Frieda.'
: 'Mehrere gleichzeitig, bis zu 100. PNG, JPG, WEBP, HEIC.';
return ( return (
<div className="studio"> <div className="studio">
<section className="karte"> {/* Modus-Umschalter */}
<div className="kopfzeile"> <div className="modus-leiste">
<span className="mono-label">Vorlagen{pics.length ? ` · ${pics.length}` : ''}</span> {MODES.map((m) => (
{pics.length > 0 && <button className="link" onClick={() => setPics([])}>alle entfernen</button>} <button key={m.id} className={`modus-tab ${mode === m.id ? 'an' : ''}`} onClick={() => setMode(m.id)}>
</div> <b>{m.name}</b><span>{m.hint}</span>
<div className="buehne">
{pics.length === 0 ? (
<div className={`drop ${dragging ? 'drag' : ''}`}
onClick={() => fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => { e.preventDefault(); setDragging(false); upload(e.dataTransfer.files); }}>
<span className="regmark" />
<p>Bilder hierher ziehen,<br />mit <span className="kbd">/Strg + V</span> einfügen<br />oder zum Auswählen tippen.</p>
<p className="fein">Mehrere gleichzeitig, bis zu 100. PNG, JPG, WEBP, HEIC.</p>
</div>
) : (
<div className="mini-raster">
{pics.map((b) => (
<div key={b.id} className={`mini-bild ${b.error ? 'err' : ''}`}>
{b.src && <img src={b.src} alt="" />}
{b.uploading && <span className="lade" />}
{b.error && <span className="badge">!</span>}
<button onClick={() => setPics((x) => x.filter((y) => y.id !== b.id))}>×</button>
</div>
))}
<button className="mini-add" onClick={() => fileRef.current?.click()}>+</button>
</div>
)}
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden
onChange={(e) => e.target.files && upload(e.target.files)} />
</div>
</section>
<section className="karte">
<div className="steuer">
{recipes?.length > 0 && (
<div className="feld">
<label>Rezept laden</label>
<select className="select" onChange={(e) => applyRecipe(recipes.find((r) => r.id === e.target.value))} defaultValue="">
<option value="" disabled>Voreinstellung wählen </option>
{recipes.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</div>
)}
<div className="feld">
<label>Was soll passieren?</label>
<div className="modi">
{TASKS.map((m) => {
const on = tasks.includes(m.id);
const disabled = m.id === 'contour' && !hasCutout;
return (
<button key={m.id} disabled={disabled}
className={`modus ${on ? 'an' : ''} ${disabled ? 'aus' : ''}`}
onClick={() => toggleTask(m.id)}>
<b>{m.name}{on ? ' ✓' : ''}</b><span>{m.hint}</span>
</button>
);
})}
</div>
</div>
{hasFormat && (
<div className="feld">
<label>Ausgabeformat</label>
<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>
{theframeConflict && <div className="fein warn">Freistellen + The Frame ergibt keinen Sinn bitte eins wählen.</div>}
{fmt?.cm && !fmt.screen && (
<div className="schalter">
<button className={portrait ? 'an' : ''} onClick={() => setPortrait(true)}>Hochformat</button>
<button className={!portrait ? 'an' : ''} onClick={() => setPortrait(false)}>Querformat</button>
</div>
)}
{zielPx && <div className="fein">Ergibt exakt {zielPx[0]} × {zielPx[1]} Pixel bei 300 dpi druckfertig.</div>}
{zielPx && (
<div className="schalter zart">
<button className={crop === 'crop' ? 'an' : ''} onClick={() => setCrop('crop')}>Zuschneiden</button>
<button className={crop === 'extend' ? 'an' : ''} onClick={() => setCrop('extend')}>Ränder ergänzen</button>
</div>
)}
</div>
)}
{tasks.includes('contour') && (
<div className="feld">
<label>Stickerrand (mm)</label>
<input className="input" type="number" min={0} max={20} step={0.5}
value={contourMm} onChange={(e) => setContourMm(Number(e.target.value))} />
</div>
)}
{models.length > 0 && (
<div className="feld">
<label>Qualität</label>
<div className="schalter wrap">
{models.map((m) => (
<button key={m.model_id} className={modelKey === m.model_id ? 'an' : ''}
onClick={() => setModelKey(m.model_id)} title={m.description || ''}>{m.label}</button>
))}
</div>
<div className="fein">{models.find((m) => m.model_id === modelKey)?.description || ''}</div>
</div>
)}
<div className="feld">
<label>Eigene Anweisung (optional)</label>
<textarea className="area" rows={2} value={custom} onChange={(e) => setCustom(e.target.value)}
placeholder="z. B. „Mach den Hintergrund heller, sonst alles lassen.“" />
</div>
<div className="feld">
<label>Wohin?</label>
<div className="schalter">
<button className={delivery === 'library' ? 'an' : ''} onClick={() => setDelivery('library')}>Bibliothek</button>
<button className={delivery === 'picdrop' ? 'an' : ''} onClick={() => setDelivery('picdrop')}>Picdrop</button>
<button className={delivery === 'both' ? 'an' : ''} onClick={() => setDelivery('both')}>Beides</button>
</div>
</div>
<button className="knopf" disabled={!canRun} onClick={run}>
{busy ? <><span className="spin" />Wird angelegt </> : `Loslegen${ready.length ? ` · ${ready.length} Bild${ready.length > 1 ? 'er' : ''}` : ''}`}
</button> </button>
<div className="fein mitte">Läuft serverseitig weiter du kannst das Fenster schließen.</div> ))}
</div> </div>
</section>
<div className="raster">
{mode !== 'generate' && (
<section className="karte">
<div className="kopfzeile">
<span className="mono-label">{mode === 'compose' ? 'Bausteine' : 'Vorlagen'}{pics.length ? ` · ${pics.length}` : ''}</span>
{pics.length > 0 && <button className="link" onClick={() => setPics([])}>alle entfernen</button>}
</div>
<div className="buehne">
{pics.length === 0 ? (
<div className={`drop ${dragging ? 'drag' : ''}`}
onClick={() => fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => { e.preventDefault(); setDragging(false); upload(e.dataTransfer.files); }}>
<span className="regmark" />
<p>Bilder hierher ziehen,<br />mit <span className="kbd">/Strg + V</span> einfügen<br />oder zum Auswählen tippen.</p>
<p className="fein">{uploadHint}</p>
</div>
) : (
<div className="mini-raster">
{pics.map((b) => (
<div key={b.id} className={`mini-bild ${b.error ? 'err' : ''}`}>
{b.src && <img src={b.src} alt="" />}
{b.uploading && <span className="lade" />}
{b.error && <span className="badge">!</span>}
<button onClick={() => setPics((x) => x.filter((y) => y.id !== b.id))}>×</button>
</div>
))}
<button className="mini-add" onClick={() => fileRef.current?.click()}>+</button>
</div>
)}
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden
onChange={(e) => e.target.files && upload(e.target.files)} />
</div>
</section>
)}
<section className="karte">
<div className="steuer">
{mode === 'each' && recipes?.length > 0 && (
<div className="feld">
<label>Rezept laden</label>
<select className="select" onChange={(e) => applyRecipe(recipes.find((r) => r.id === e.target.value))} defaultValue="">
<option value="" disabled>Voreinstellung wählen </option>
{recipes.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</div>
)}
{(mode === 'compose' || mode === 'generate') && (
<div className="feld">
<label>{mode === 'compose' ? 'Was soll entstehen?' : 'Beschreibung des neuen Bildes'}</label>
<textarea className="area gross" rows={4} value={desc} onChange={(e) => setDesc(e.target.value)}
placeholder={mode === 'compose'
? 'z. B. „Das Poolbild, aber mit unserer Hündin Frieda am Beckenrand.“'
: 'z. B. „Ein minimalistisches Poster mit einem Olivenzweig auf sandfarbenem Grund.“'} />
<div className="fein">{mode === 'compose'
? 'Das erste Bild ist die Leitszene, weitere liefern Personen/Motive.'
: 'Je genauer die Beschreibung, desto besser das Ergebnis.'}</div>
</div>
)}
{mode === 'each' && (
<div className="feld">
<label>Was soll passieren?</label>
<div className="modi">
{TASKS.map((m) => {
const on = tasks.includes(m.id);
const disabled = m.id === 'contour' && !hasCutout;
return (
<button key={m.id} disabled={disabled}
className={`modus ${on ? 'an' : ''} ${disabled ? 'aus' : ''}`}
onClick={() => toggleTask(m.id)}>
<b>{m.name}{on ? ' ✓' : ''}</b><span>{m.hint}</span>
</button>
);
})}
</div>
</div>
)}
{showFormat && (
<div className="feld">
<label>{mode === 'each' ? 'Ausgabeformat' : 'Format (optional)'}</label>
<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>
{theframeConflict && <div className="fein warn">Freistellen + The Frame ergibt keinen Sinn bitte eins wählen.</div>}
{fmt?.cm && !fmt.screen && (
<div className="schalter">
<button className={portrait ? 'an' : ''} onClick={() => setPortrait(true)}>Hochformat</button>
<button className={!portrait ? 'an' : ''} onClick={() => setPortrait(false)}>Querformat</button>
</div>
)}
{zielPx && <div className="fein">Ergibt exakt {zielPx[0]} × {zielPx[1]} Pixel bei 300 dpi druckfertig.</div>}
{zielPx && mode !== 'generate' && (
<div className="schalter zart">
<button className={crop === 'crop' ? 'an' : ''} onClick={() => setCrop('crop')}>Zuschneiden</button>
<button className={crop === 'extend' ? 'an' : ''} onClick={() => setCrop('extend')}>Erweitern</button>
</div>
)}
{zielPx && mode !== 'generate' && crop === 'extend' && (
<div className="fein">Erweitern" erzeugt mehr Szene rund ums Motiv (Outpainting), statt zuzuschneiden — ideal für The Frame.</div>
)}
</div>
)}
{mode === 'each' && tasks.includes('contour') && (
<div className="feld">
<label>Stickerrand (mm)</label>
<input className="input" type="number" min={0} max={20} step={0.5}
value={contourMm} onChange={(e) => setContourMm(Number(e.target.value))} />
</div>
)}
{models.length > 0 && (
<div className="feld">
<label>Qualität</label>
<div className="schalter wrap">
{models.map((m) => (
<button key={m.model_id} className={modelKey === m.model_id ? 'an' : ''}
onClick={() => setModelKey(m.model_id)} title={m.description || ''}>{m.label}</button>
))}
</div>
<div className="fein">{models.find((m) => m.model_id === modelKey)?.description || ''}</div>
</div>
)}
{mode === 'each' && (
<div className="feld">
<label>Eigene Anweisung (optional)</label>
<textarea className="area" rows={2} value={custom} onChange={(e) => setCustom(e.target.value)}
placeholder="z. B. Mach den Hintergrund heller, sonst alles lassen." />
</div>
)}
<div className="feld">
<label>Wohin?</label>
<div className="schalter">
<button className={delivery === 'library' ? 'an' : ''} onClick={() => setDelivery('library')}>Bibliothek</button>
<button className={delivery === 'picdrop' ? 'an' : ''} onClick={() => setDelivery('picdrop')}>Picdrop</button>
<button className={delivery === 'both' ? 'an' : ''} onClick={() => setDelivery('both')}>Beides</button>
</div>
</div>
<button className="knopf" disabled={!canRun} onClick={run}>
{busy ? <><span className="spin" />Wird angelegt …</>
: mode === 'each' ? `Loslegen${ready.length ? ` · ${ready.length} Bild${ready.length > 1 ? 'er' : ''}` : ''}`
: mode === 'compose' ? 'Bild kombinieren' : 'Bild erzeugen'}
</button>
<div className="fein mitte">Läuft serverseitig weiter — du kannst das Fenster schließen.</div>
</div>
</section>
</div>
{toast && <div className="toast">{toast}</div>} {toast && <div className="toast">{toast}</div>}
<StudioStyles /> <StudioStyles />
</div> </div>
@@ -287,8 +348,15 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
function StudioStyles() { function StudioStyles() {
return <style>{` return <style>{`
.studio{display:grid;grid-template-columns:1.1fr .9fr;gap:18px;align-items:start;} .studio{display:flex;flex-direction:column;gap:16px;}
@media(max-width:820px){.studio{grid-template-columns:1fr;}} .modus-leiste{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;}
@media(max-width:560px){.modus-leiste{grid-template-columns:1fr;}}
.modus-tab{text-align:left;background:var(--card);border:1px solid var(--line);border-radius:var(--radius-sm);padding:11px 13px;cursor:pointer;font-family:inherit;}
.modus-tab b{display:block;font-size:14px;font-weight:600;}
.modus-tab span{display:block;font-size:11px;color:var(--soft);margin-top:2px;line-height:1.4;}
.modus-tab.an{border-color:var(--accent);background:var(--accent-bg);}
.raster{display:grid;grid-template-columns:1.1fr .9fr;gap:18px;align-items:start;}
@media(max-width:820px){.raster{grid-template-columns:1fr;}}
.karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);} .karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
.kopfzeile{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);} .kopfzeile{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);}
.link{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;} .link{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;}
@@ -305,12 +373,13 @@ function StudioStyles() {
.mini-bild>button{position:absolute;top:3px;right:3px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(22,21,15,.75);color:#fff;cursor:pointer;font-size:14px;line-height:1;} .mini-bild>button{position:absolute;top:3px;right:3px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(22,21,15,.75);color:#fff;cursor:pointer;font-size:14px;line-height:1;}
.mini-bild .badge{position:absolute;bottom:3px;left:3px;background:var(--err);color:#fff;width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;} .mini-bild .badge{position:absolute;bottom:3px;left:3px;background:var(--err);color:#fff;width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;}
.lade{position:absolute;inset:0;background:var(--paper);opacity:.6;} .lade{position:absolute;inset:0;background:var(--paper);opacity:.6;}
.mini-add{aspect-ratio:3/4;border:1.5px dashed var(--line);background:none;border-radius:3px;cursor:pointer;font-size:22px;color:var(--soft);} .mini-add{aspect-ratio:1;border:1.5px dashed var(--line);background:none;border-radius:3px;cursor:pointer;font-size:22px;color:var(--soft);}
.steuer{padding:16px;display:flex;flex-direction:column;gap:15px;} .steuer{padding:16px;display:flex;flex-direction:column;gap:15px;}
.feld label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--soft);margin-bottom:7px;} .feld label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--soft);margin-bottom:7px;}
.input,.select,.area{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);outline:none;} .input,.select,.area{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);outline:none;}
.input:focus,.select:focus,.area:focus{border-color:var(--accent);} .input:focus,.select:focus,.area:focus{border-color:var(--accent);}
.area{font-size:13.5px;line-height:1.5;resize:vertical;} .area{font-size:13.5px;line-height:1.5;resize:vertical;}
.area.gross{font-size:15px;line-height:1.55;}
.fein{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;} .fein{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;}
.fein.mitte{text-align:center;} .fein.mitte{text-align:center;}
.fein.warn{color:var(--err);} .fein.warn{color:var(--err);}
+2
View File
@@ -15,6 +15,8 @@ export function ensureInit(): Promise<void> {
try { await startImageWorker(); } catch (e) { console.error('[init] Worker-Start fehlgeschlagen:', e); } try { await startImageWorker(); } catch (e) { console.error('[init] Worker-Start fehlgeschlagen:', e); }
try { const { setupTelegram } = await import('./telegram'); await setupTelegram(); } try { const { setupTelegram } = await import('./telegram'); await setupTelegram(); }
catch (e) { console.error('[init] Telegram-Setup fehlgeschlagen:', e); } catch (e) { console.error('[init] Telegram-Setup fehlgeschlagen:', e); }
try { const { startMaintenance } = await import('./maintenance'); startMaintenance(); }
catch (e) { console.error('[init] Wartung-Start fehlgeschlagen:', e); }
console.log('[init] Klarbild bereit.'); console.log('[init] Klarbild bereit.');
})().catch((e) => { started = null; throw e; }); })().catch((e) => { started = null; throw e; });
} }
+87
View File
@@ -0,0 +1,87 @@
// Speicherverwaltung: Nutzung anzeigen, Aufräumen (Retention), Quellen löschen.
// Ziel: „den Server nicht vollmüllen".
import { stat, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { one, query } from './db';
import { deleteObject } from './storage';
const DRIVER = (process.env.STORAGE_DRIVER || 'fs').toLowerCase();
const DIR = process.env.STORAGE_DIR || '/data';
async function dirBytes(path: string): Promise<{ bytes: number; files: number }> {
let bytes = 0, files = 0;
let entries: any[] = [];
try { entries = await readdir(path, { withFileTypes: true }); } catch { return { bytes, files }; }
for (const e of entries) {
const p = join(path, e.name);
if (e.isDirectory()) { const s = await dirBytes(p); bytes += s.bytes; files += s.files; }
else { try { const st = await stat(p); bytes += st.size; files++; } catch { /* ignore */ } }
}
return { bytes, files };
}
export interface StorageStats {
driver: string; bytes: number | null; files: number | null;
sources: number; results: number; thumbs: number; items: number;
}
/** Speicher-Kennzahlen. Byte-Genauigkeit nur beim fs-Treiber. */
export async function storageStats(): Promise<StorageStats> {
const counts = await one<any>(`SELECT
count(*) FILTER (WHERE source_path IS NOT NULL)::int AS sources,
count(*) FILTER (WHERE result_path IS NOT NULL)::int AS results,
count(*) FILTER (WHERE thumb_path IS NOT NULL)::int AS thumbs,
count(*)::int AS items FROM items`);
let bytes: number | null = null, files: number | null = null;
if (DRIVER === 'fs') { const s = await dirBytes(DIR); bytes = s.bytes; files = s.files; }
return { driver: DRIVER, bytes, files,
sources: counts?.sources || 0, results: counts?.results || 0,
thumbs: counts?.thumbs || 0, items: counts?.items || 0 };
}
/** Löscht Positionen (samt Objekten), die älter als N Tage sind. */
export async function runRetention(days: number): Promise<{ deleted: number }> {
if (!days || days <= 0) return { deleted: 0 };
const rows = await query<any>(
`SELECT id, source_path, source_paths, result_path, thumb_path FROM items
WHERE created_at < now() - ($1 || ' days')::interval`, [String(days)]);
for (const it of rows) {
const keys = [it.source_path, it.result_path, it.thumb_path,
...((it.source_paths as string[]) || [])].filter(Boolean);
for (const k of keys) await deleteObject(k).catch(() => {});
await query('DELETE FROM items WHERE id=$1', [it.id]);
}
return { deleted: rows.length };
}
/** Löscht nur die Quellbilder fertiger Positionen (Ergebnisse bleiben). */
export async function purgeSources(): Promise<{ purged: number }> {
const rows = await query<any>(
`SELECT id, source_path, source_paths FROM items WHERE status='done'
AND (source_path IS NOT NULL OR source_paths IS NOT NULL)`);
let purged = 0;
for (const it of rows) {
const keys = [it.source_path, ...((it.source_paths as string[]) || [])].filter(Boolean);
for (const k of keys) await deleteObject(k).catch(() => {});
await query('UPDATE items SET source_path=NULL, source_paths=NULL WHERE id=$1', [it.id]);
purged += keys.length;
}
return { purged };
}
let timer: NodeJS.Timeout | null = null;
/** Täglicher Retention-Lauf (falls in den Einstellungen aktiviert). */
export function startMaintenance(): void {
if (timer) return;
const tick = async () => {
try {
const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1');
if (s?.retention_days && s.retention_days > 0) {
const r = await runRetention(s.retention_days);
if (r.deleted) console.log(`[maintenance] Retention: ${r.deleted} alte Positionen entfernt.`);
}
} catch (e) { console.error('[maintenance]', e); }
};
timer = setInterval(tick, 6 * 60 * 60 * 1000); // alle 6 h
setTimeout(tick, 60 * 1000); // erster Lauf nach 1 min
}
+88
View File
@@ -0,0 +1,88 @@
// Zweite Datensicherung auf ein Synology-NAS (SFTP oder FTPS, wie Picdrop).
// Ergebnisse werden zusätzlich zur Bibliothek/Picdrop auf das NAS gespiegelt.
import posixpath from 'node:path/posix';
import { one, query } from './db';
import { decrypt } from './crypto';
import { getObject } from './storage';
export interface NasCfg {
host: string; protocol: 'ftps' | 'sftp'; port: number;
user: string; password: string; basePath: string;
}
export async function loadNasConfig(): Promise<NasCfg | null> {
const s = await one<any>(`SELECT nas_enabled, nas_host, nas_protocol, nas_port, nas_user,
nas_password_enc, nas_base_path FROM settings WHERE id=1`);
if (!s?.nas_enabled || !s?.nas_host || !s?.nas_user || !s?.nas_password_enc) return null;
let password = '';
try { password = decrypt(s.nas_password_enc); } catch { return null; }
return {
host: s.nas_host, protocol: (s.nas_protocol || 'sftp'),
port: s.nas_port || (s.nas_protocol === 'ftps' ? 21 : 22),
user: s.nas_user, password, basePath: s.nas_base_path || '/',
};
}
export async function testNas(cfg: NasCfg): Promise<{ ok: boolean; message: string }> {
try {
if (cfg.protocol === 'sftp') {
const SftpClient = (await import('ssh2-sftp-client')).default;
const c = new SftpClient();
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 15000 });
await c.list(cfg.basePath || '/');
await c.end();
} else {
const { Client } = await import('basic-ftp');
const c = new Client(15000);
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
await c.list(cfg.basePath || '/');
c.close();
}
return { ok: true, message: 'NAS-Verbindung erfolgreich.' };
} catch (e: any) {
return { ok: false, message: e?.message || 'NAS-Verbindung fehlgeschlagen.' };
}
}
async function uploadToNas(cfg: NasCfg, remoteDir: string, filename: string, buf: Buffer): Promise<void> {
const dir = posixpath.join(cfg.basePath || '/', remoteDir);
const finalPath = posixpath.join(dir, filename);
const { Readable } = await import('node:stream');
if (cfg.protocol === 'sftp') {
const SftpClient = (await import('ssh2-sftp-client')).default;
const c = new SftpClient();
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 20000 });
try {
if (!(await c.exists(dir))) await c.mkdir(dir, true);
await c.put(buf, finalPath);
} finally { await c.end(); }
} else {
const { Client } = await import('basic-ftp');
const c = new Client(20000);
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
try {
await c.ensureDir(dir);
await c.uploadFrom(Readable.from(buf), finalPath);
} finally { c.close(); }
}
}
/** Spiegelt ein fertiges Item auf das NAS (Ordnerstruktur klarbild/JJJJ-MM/). */
export async function mirrorItemToNas(itemId: string): Promise<{ ok: boolean; message: string }> {
const cfg = await loadNasConfig();
if (!cfg) return { ok: false, message: 'NAS nicht konfiguriert.' };
const it = await one<any>('SELECT id, result_path, filename, created_at FROM items WHERE id=$1', [itemId]);
if (!it?.result_path) return { ok: false, message: 'Kein Ergebnis vorhanden.' };
await query(`UPDATE items SET nas_status='pending' WHERE id=$1`, [itemId]);
try {
const buf = await getObject(it.result_path);
const ym = new Date(it.created_at || Date.now()).toISOString().slice(0, 7); // JJJJ-MM
await uploadToNas(cfg, posixpath.join('klarbild', ym), it.filename || `${it.id}.png`, buf);
await query(`UPDATE items SET nas_status='mirrored' WHERE id=$1`, [itemId]);
return { ok: true, message: 'Auf NAS gesichert.' };
} catch (e: any) {
console.error('[nas] mirror', itemId, 'fehlgeschlagen:', e?.message || e);
await query(`UPDATE items SET nas_status='failed' WHERE id=$1`, [itemId]);
return { ok: false, message: e?.message || 'NAS-Sicherung fehlgeschlagen.' };
}
}
+4 -2
View File
@@ -30,7 +30,8 @@ function friendlyFor(status: number): string {
export interface GenerateOpts { export interface GenerateOpts {
model: string; model: string;
prompt: string; prompt: string;
inputUrl?: string; // vorsignierte URL des Originals inputUrl?: string; // eine Vorlage (Data-URL) — Kurzform
inputUrls?: string[]; // mehrere Vorlagen (compose) — bis zu 14/16 je Modell
aspectRatio?: string; // "16:9", "3:4" … aspectRatio?: string; // "16:9", "3:4" …
resolution?: string; // "2K" | "4K" — NICHT zusammen mit expliziten Pixeln resolution?: string; // "2K" | "4K" — NICHT zusammen mit expliziten Pixeln
background?: 'transparent' | 'opaque'; background?: 'transparent' | 'opaque';
@@ -56,7 +57,8 @@ export async function generateImage(opts: GenerateOpts): Promise<GenerateResult>
n: 1, n: 1,
output_format: opts.outputFormat || 'png', output_format: opts.outputFormat || 'png',
}; };
if (opts.inputUrl) body.input_references = [{ type: 'image_url', image_url: { url: opts.inputUrl } }]; const refs = opts.inputUrls?.length ? opts.inputUrls : (opts.inputUrl ? [opts.inputUrl] : []);
if (refs.length) body.input_references = refs.map((url) => ({ type: 'image_url', image_url: { url } }));
if (opts.aspectRatio) body.aspect_ratio = opts.aspectRatio; if (opts.aspectRatio) body.aspect_ratio = opts.aspectRatio;
if (opts.resolution && !opts.aspectRatio) body.resolution = opts.resolution; // nie beides if (opts.resolution && !opts.aspectRatio) body.resolution = opts.resolution; // nie beides
if (opts.background) body.background = opts.background; if (opts.background) body.background = opts.background;
+72 -21
View File
@@ -1,13 +1,15 @@
import { one, query } from './db'; import { one, query } from './db';
import { getObject, putObject, resultKey } from './storage'; import { getObject, putObject, deleteObject, resultKey, thumbKey } from './storage';
import { generateImage } from './openrouter'; import { generateImage } from './openrouter';
import { buildPrompt, type Task } from './prompts'; import { buildPrompt, buildComposePrompt, buildGeneratePrompt, type Task } from './prompts';
import { resolveDimensions, aspectRatio, type Orientation } from './format'; import { resolveDimensions, aspectRatio, type Orientation } from './format';
import { finalizeToFormat, stickerContour, buildResultFilename, formatToken, type CropMode } from './pipeline'; import { finalizeToFormat, stickerContour, buildResultFilename, formatToken, type CropMode } from './pipeline';
import sharp from 'sharp'; import sharp from 'sharp';
const DEFAULT_MODEL = 'google/gemini-3.1-flash-image'; const DEFAULT_MODEL = 'google/gemini-3.1-flash-image';
type Mode = 'each' | 'compose' | 'generate';
interface RecipeSnapshot { interface RecipeSnapshot {
tasks: Task[]; tasks: Task[];
output_format?: string; output_format?: string;
@@ -17,6 +19,7 @@ interface RecipeSnapshot {
contour_mm?: number | null; contour_mm?: number | null;
model_key?: string | null; model_key?: string | null;
custom_instruction?: string | null; custom_instruction?: string | null;
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;
} }
@@ -42,47 +45,70 @@ async function toDataUrl(buf: Buffer): Promise<string> {
return `data:image/jpeg;base64,${small.toString('base64')}`; return `data:image/jpeg;base64,${small.toString('base64')}`;
} }
/** Kleines Vorschaubild (webp) für die Bibliothek — spart Bandbreite & Speicher. */
async function makeThumb(buf: Buffer): Promise<Buffer> {
return sharp(buf, { failOn: 'none' })
.resize(600, 600, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 72 })
.toBuffer();
}
export interface ProcessResult { ok: boolean; cost: number; error?: string; } export interface ProcessResult { ok: boolean; cost: number; error?: string; }
/** Verarbeitet eine Position vollständig und aktualisiert ihren Datensatz. */ /** Verarbeitet eine Position vollständig und aktualisiert ihren Datensatz. */
export async function processItem(itemId: string): Promise<ProcessResult> { export async function processItem(itemId: string): Promise<ProcessResult> {
const item = await one<{ id: string; job_id: string; source_path: string; filename: string | null }>( const item = await one<{ id: string; job_id: string; source_path: string | null; source_paths: string[] | null; filename: string | null }>(
'SELECT id, job_id, source_path, filename FROM items WHERE id=$1', [itemId]); 'SELECT id, job_id, source_path, source_paths, filename FROM items WHERE id=$1', [itemId]);
if (!item) return { ok: false, cost: 0, error: 'Position nicht gefunden' }; if (!item) return { ok: false, cost: 0, error: 'Position nicht gefunden' };
const job = await one<{ recipe_snapshot: RecipeSnapshot }>( const job = await one<{ recipe_snapshot: RecipeSnapshot; mode: Mode }>(
'SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]); 'SELECT recipe_snapshot, mode FROM jobs WHERE id=$1', [item.job_id]);
const r = (job?.recipe_snapshot || {}) as RecipeSnapshot; const r = (job?.recipe_snapshot || {}) as RecipeSnapshot;
const mode: Mode = job?.mode || 'each';
const tasks = r.tasks || []; const tasks = r.tasks || [];
const dpi = r.dpi ?? 300; const dpi = r.dpi ?? 300;
const cropMode: CropMode = r.crop_mode === 'extend' ? 'extend' : 'crop'; const cropMode: CropMode = r.crop_mode === 'extend' ? 'extend' : 'crop';
const wantCutout = tasks.includes('cutout'); const wantCutout = tasks.includes('cutout');
const wantContour = tasks.includes('contour'); const wantContour = tasks.includes('contour');
const description = (r.prompt_text || '').trim();
await query(`UPDATE items SET status='running', attempts=attempts+1 WHERE id=$1`, [itemId]); await query(`UPDATE items SET status='running', attempts=attempts+1 WHERE id=$1`, [itemId]);
try { try {
const source = await getObject(item.source_path); // Quellen je Modus einsammeln
const target = r.tasks.includes('format') && r.output_format const sourceKeys: string[] =
mode === 'compose' ? (item.source_paths || []).filter(Boolean)
: mode === 'generate' ? []
: (item.source_path ? [item.source_path] : []);
const sources = await Promise.all(sourceKeys.map((k) => getObject(k)));
const target = tasks.includes('format') && r.output_format
? resolveDimensions({ format: r.output_format, orientation: r.orientation, dpi }) ? resolveDimensions({ format: r.output_format, orientation: r.orientation, dpi })
: null; : null;
const model = await pickModel(r.model_key); const model = await pickModel(r.model_key);
const prompt = buildPrompt({ tasks, cropMode, customInstruction: r.custom_instruction });
// Nur Modell aufrufen, wenn eine generative Aufgabe dabei ist. // Prompt + ob das Modell überhaupt gebraucht wird, je Modus.
const needsModel = tasks.includes('clean') || wantCutout || let prompt: string;
(tasks.includes('format') && cropMode === 'extend') || !!r.custom_instruction; let needsModel: boolean;
if (mode === 'compose') { prompt = buildComposePrompt(description, cropMode === 'extend'); needsModel = true; }
else if (mode === 'generate') { prompt = buildGeneratePrompt(description); needsModel = true; }
else {
prompt = buildPrompt({ tasks, cropMode, customInstruction: r.custom_instruction });
needsModel = tasks.includes('clean') || wantCutout ||
(tasks.includes('format') && cropMode === 'extend') || !!r.custom_instruction;
}
let working = source; let working = sources[0] || Buffer.alloc(0);
let modelUsed: string | null = null; let modelUsed: string | null = null;
let cost = 0; let cost = 0;
if (needsModel) { if (needsModel) {
const inputUrls = sources.length ? await Promise.all(sources.map(toDataUrl)) : undefined;
const gen = await generateImage({ const gen = await generateImage({
model: model.id, model: model.id,
prompt, prompt,
inputUrl: await toDataUrl(source), inputUrls,
aspectRatio: target ? aspectRatio(target.w, target.h) : undefined, aspectRatio: target ? aspectRatio(target.w, target.h) : undefined,
background: wantCutout ? 'transparent' : undefined, background: wantCutout ? 'transparent' : undefined,
outputFormat: 'png', outputFormat: 'png',
@@ -91,6 +117,7 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
modelUsed = gen.model; modelUsed = gen.model;
cost = gen.cost; cost = gen.cost;
} }
if (!working.length) throw new Error('Keine Bilddaten erzeugt');
// Lokale Nachbearbeitung: exakt aufs Zielmaß + dpi // Lokale Nachbearbeitung: exakt aufs Zielmaß + dpi
const fin = await finalizeToFormat(working, target, cropMode, dpi); const fin = await finalizeToFormat(working, target, cropMode, dpi);
@@ -105,27 +132,51 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
const key = resultKey(item.id); const key = resultKey(item.id);
await putObject(key, outBuf, 'image/png'); await putObject(key, outBuf, 'image/png');
// Vorschaubild (optional, laut Einstellung)
const cfg = await one<{ make_thumbnails: boolean; keep_sources: boolean; nas_enabled: boolean }>(
'SELECT make_thumbnails, keep_sources, nas_enabled FROM settings WHERE id=1');
let thumbPath: string | null = null;
if (cfg?.make_thumbnails !== false) {
try {
const tk = thumbKey(item.id);
await putObject(tk, await makeThumb(outBuf), 'image/webp');
thumbPath = tk;
} 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), 'png');
const outputPx = `${fin.width}x${fin.height}`; const outputPx = `${fin.width}x${fin.height}`;
const deliveryStatus = (r.delivery === 'picdrop' || r.delivery === 'both') ? 'pending' : 'none'; const deliveryStatus = (r.delivery === 'picdrop' || r.delivery === 'both') ? 'pending' : 'none';
const nasStatus = cfg?.nas_enabled ? 'pending' : 'none';
await query( await query(
`UPDATE items SET status='done', result_path=$2, filename=$3, output_px=$4, dpi=$5, `UPDATE items SET status='done', result_path=$2, thumb_path=$3, filename=$4, output_px=$5, dpi=$6,
has_alpha=$6, model_used=$7, prompt_used=$8, cost=$9, error_message=NULL, has_alpha=$7, model_used=$8, prompt_used=$9, cost=$10, error_message=NULL,
delivery_status=$10 WHERE id=$1`, delivery_status=$11, nas_status=$12 WHERE id=$1`,
[itemId, key, filename, outputPx, dpi, hasAlpha, modelUsed, prompt.slice(0, 1000), [itemId, key, thumbPath, filename, outputPx, dpi, hasAlpha, modelUsed, prompt.slice(0, 1000),
cost, deliveryStatus]); cost, deliveryStatus, nasStatus]);
// Quellbilder löschen, wenn nicht behalten (Speicher sparen).
if (cfg?.keep_sources === false) {
for (const k of sourceKeys) await deleteObject(k).catch(() => {});
}
// Zweite Sicherung auf NAS (best effort, blockiert den Erfolg nicht).
if (cfg?.nas_enabled) {
try {
const { mirrorItemToNas } = await import('./nas');
await mirrorItemToNas(itemId);
} catch (e) { console.error('[process] NAS-Spiegelung fehlgeschlagen', e); }
}
return { ok: true, cost }; return { ok: true, cost };
} catch (e: any) { } catch (e: any) {
const real = e?.message || String(e); const real = e?.message || String(e);
const status = e?.status ? ` [status ${e.status}]` : ''; const status = e?.status ? ` [status ${e.status}]` : '';
console.error(`[process] Item ${itemId} fehlgeschlagen${status}: ${real}`, e?.stack || ''); console.error(`[process] Item ${itemId} fehlgeschlagen${status}: ${real}`, e?.stack || '');
// Nutzeranzeige: freundlich, aber mit kompaktem Grund fürs Debugging in dieser Phase
const msg = (e?.friendly ? `${e.friendly}` : real) + (e?.status ? ` (${e.status})` : ''); const msg = (e?.friendly ? `${e.friendly}` : real) + (e?.status ? ` (${e.status})` : '');
await query(`UPDATE items SET status='failed', error_message=$2 WHERE id=$1`, await query(`UPDATE items SET status='failed', error_message=$2 WHERE id=$1`,
[itemId, String(msg).slice(0, 500)]); [itemId, String(msg).slice(0, 500)]);
// 402/Guthaben: nach oben durchreichen, damit der Worker den Auftrag anhält
if (e?.status === 402) throw e; if (e?.status === 402) throw e;
return { ok: false, cost: 0, error: msg }; return { ok: false, cost: 0, error: msg };
} }
+22
View File
@@ -43,3 +43,25 @@ export function buildPrompt({ tasks, cropMode, customInstruction }: PromptOpts):
parts.push('Output only the resulting image.'); parts.push('Output only the resulting image.');
return parts.join(' '); return parts.join(' ');
} }
/** Kombinieren: mehrere Vorlagen + Beschreibung → EIN neues Bild.
* Die erste Vorlage ist das Leitbild; weitere liefern Elemente/Personen/Motive. */
export function buildComposePrompt(description: string, extend = false): string {
const parts = [
'You are given several reference images. Combine them into ONE new, coherent, ' +
'high-resolution image that follows the instruction below. Treat the first image as ' +
'the main scene/style reference and use the other images as elements to integrate ' +
'(people, pets, objects) — match their identity, colors and lighting faithfully so ' +
'they look naturally part of the same photo.',
];
if (description.trim()) parts.push(`Instruction: ${description.trim()}`);
if (extend) parts.push('Extend the scene naturally to fill the requested aspect ratio (outpainting).');
parts.push('Output only the resulting image.');
return parts.join(' ');
}
/** Freitext: gar keine Vorlage → ein komplett neues Bild aus der Beschreibung. */
export function buildGeneratePrompt(description: string): string {
const d = description.trim() || 'A clean, high-quality image.';
return `Create a new, high-resolution image. ${d} Output only the resulting image.`;
}
+3
View File
@@ -90,3 +90,6 @@ export function sourceKey(uuid: string, ext: string): string {
export function resultKey(uuid: string): string { export function resultKey(uuid: string): string {
return `results/${new Date().getFullYear()}/${uuid}.png`; return `results/${new Date().getFullYear()}/${uuid}.png`;
} }
export function thumbKey(uuid: string): string {
return `thumbs/${new Date().getFullYear()}/${uuid}.webp`;
}
+144 -27
View File
@@ -12,6 +12,8 @@ const BASE = process.env.PUBLIC_BASE_URL || '';
let bot: Bot | null = null; let bot: Bot | null = null;
let webhookSecret = ''; let webhookSecret = '';
// Zwischenspeicher: Freitext, aus dem der Nutzer per Knopf ein Bild erzeugen kann.
const pendingGenerate = new Map<number, string>();
export async function getToken(): Promise<string> { export async function getToken(): Promise<string> {
const s = await one<{ telegram_bot_token_enc: string | null }>('SELECT telegram_bot_token_enc FROM settings WHERE id=1'); const s = await one<{ telegram_bot_token_enc: string | null }>('SELECT telegram_bot_token_enc FROM settings WHERE id=1');
@@ -66,20 +68,22 @@ async function tryPair(chatId: number, code: string): Promise<boolean> {
} }
// --- Bündelung (Drafts) ----------------------------------------------------- // --- Bündelung (Drafts) -----------------------------------------------------
async function addToDraft(chatId: number, mediaGroup: string | null, fileRef: any, noticeMsgId?: number) { async function addToDraft(chatId: number, mediaGroup: string | null, fileRef: any, caption?: string | null) {
const existing = await one<{ id: string; file_refs: any[] }>( const existing = await one<{ id: string; file_refs: any[]; caption: string | null }>(
`SELECT id, file_refs FROM telegram_drafts WHERE chat_id=$1 AND status='collecting' `SELECT id, file_refs, caption FROM telegram_drafts WHERE chat_id=$1 AND status='collecting'
ORDER BY last_received_at DESC LIMIT 1`, [chatId]); ORDER BY last_received_at DESC LIMIT 1`, [chatId]);
if (existing) { if (existing) {
const refs = [...(existing.file_refs || []), fileRef]; const refs = [...(existing.file_refs || []), fileRef];
await query(`UPDATE telegram_drafts SET file_refs=$2, last_received_at=now() WHERE id=$1`, // Erste vorhandene Bildunterschrift als Beschreibung merken.
[existing.id, JSON.stringify(refs)]); const cap = existing.caption || (caption?.trim() || null);
await query(`UPDATE telegram_drafts SET file_refs=$2, caption=$3, last_received_at=now() WHERE id=$1`,
[existing.id, JSON.stringify(refs), cap]);
return existing.id; return existing.id;
} }
const row = await one<{ id: string }>( const row = await one<{ id: string }>(
`INSERT INTO telegram_drafts (chat_id, media_group_id, file_refs, status, notice_message_id) `INSERT INTO telegram_drafts (chat_id, media_group_id, file_refs, caption, status)
VALUES ($1,$2,$3,'collecting',$4) RETURNING id`, VALUES ($1,$2,$3,$4,'collecting') RETURNING id`,
[chatId, mediaGroup, JSON.stringify([fileRef]), noticeMsgId ?? null]); [chatId, mediaGroup, JSON.stringify([fileRef]), caption?.trim() || null]);
return row!.id; return row!.id;
} }
@@ -87,18 +91,21 @@ async function addToDraft(chatId: number, mediaGroup: string | null, fileRef: an
export async function sweepDrafts(): Promise<void> { export async function sweepDrafts(): Promise<void> {
const b = await getBot(); if (!b) return; const b = await getBot(); if (!b) return;
const drafts = await query<any>( const drafts = await query<any>(
`SELECT id, chat_id, file_refs FROM telegram_drafts `SELECT id, chat_id, file_refs, caption FROM telegram_drafts
WHERE status='collecting' AND last_received_at < now() - interval '${Math.round(QUIET_MS / 1000)} seconds'`); WHERE status='collecting' AND last_received_at < now() - interval '${Math.round(QUIET_MS / 1000)} seconds'`);
for (const d of drafts) { for (const d of drafts) {
const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 8'); const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 6');
const kb = new InlineKeyboard(); const kb = new InlineKeyboard();
// Callback-Daten <64 Bytes: nur Rezept-UUID; der Draft wird beim Klick über den Chat gefunden. // Callback-Daten <64 Bytes: nur Rezept-UUID; der Draft wird beim Klick über den Chat gefunden.
recipes.forEach((r, i) => { kb.text(r.name, `r:${r.id}`); if (i % 2 === 1) kb.row(); }); recipes.forEach((r, i) => { kb.text(r.name, `r:${r.id}`); if (i % 2 === 1) kb.row(); });
const n = (d.file_refs || []).length; const n = (d.file_refs || []).length;
// Ab 2 Bildern zusätzlich „Kombinieren" anbieten.
if (n >= 2) { kb.row(); kb.text('🔀 Zu einem Bild kombinieren', 'c:x'); }
const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed'); const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed');
const capNote = d.caption ? `\n📝 Beschreibung erkannt: „${d.caption}" — für „Kombinieren".` : '';
try { try {
await b.api.sendMessage(d.chat_id, await b.api.sendMessage(d.chat_id,
`${n} Bild${n > 1 ? 'er' : ''} empfangen. Welches Rezept?` + `${n} Bild${n > 1 ? 'er' : ''} empfangen. Was möchtest du tun?` + capNote +
(compressed ? '\n⚠️ Als Foto gesendet (komprimiert). Für volle Qualität als *Datei* senden.' : ''), (compressed ? '\n⚠️ Als Foto gesendet (komprimiert). Für volle Qualität als *Datei* senden.' : ''),
{ reply_markup: kb, parse_mode: 'Markdown' }); { reply_markup: kb, parse_mode: 'Markdown' });
await query(`UPDATE telegram_drafts SET status='awaiting_recipe' WHERE id=$1`, [d.id]); await query(`UPDATE telegram_drafts SET status='awaiting_recipe' WHERE id=$1`, [d.id]);
@@ -107,19 +114,12 @@ export async function sweepDrafts(): Promise<void> {
} }
// --- Draft -> Auftrag ------------------------------------------------------- // --- Draft -> Auftrag -------------------------------------------------------
async function dispatchDraft(chatId: number, draftId: string, recipeId: string, b: Bot) {
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
if (!draft || draft.status === 'dispatched') return;
const recipe = await one<any>('SELECT * FROM recipes WHERE id=$1', [recipeId]);
if (!recipe) { await b.api.sendMessage(chatId, 'Rezept nicht gefunden.'); return; }
const link = await linkedUser(chatId);
await b.api.sendMessage(chatId, `⏳ Verarbeite ${(draft.file_refs || []).length} Bild(er) …`); /** Lädt alle Bilder eines Drafts von Telegram und legt sie in den Objektspeicher. */
async function refsToSources(refs: any[], b: Bot): Promise<any[]> {
// Bilder von Telegram laden und in den Objektspeicher legen
const sources: any[] = []; const sources: any[] = [];
const token = await getToken(); const token = await getToken();
for (const ref of draft.file_refs || []) { for (const ref of refs || []) {
try { try {
const file = await b.api.getFile(ref.file_id); const file = await b.api.getFile(ref.file_id);
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`; const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
@@ -129,6 +129,32 @@ async function dispatchDraft(chatId: number, draftId: string, recipeId: string,
sources.push({ source_path: key, filename: ref.name || 'telegram.jpg', quality: ref.quality || 'original' }); sources.push({ source_path: key, filename: ref.name || 'telegram.jpg', quality: ref.quality || 'original' });
} catch (e) { console.error('[telegram] Datei laden fehlgeschlagen', e); } } catch (e) { console.error('[telegram] Datei laden fehlgeschlagen', e); }
} }
return sources;
}
/** Basis-Snapshot aus dem Standard-Rezept des Chats (Format/Modell/Auslieferung). */
async function chatRecipeDefaults(chatId: number): Promise<any> {
const link = await linkedUser(chatId);
if (link?.default_recipe_id) {
const r = await one<any>('SELECT * FROM recipes WHERE id=$1', [link.default_recipe_id]);
if (r) return {
output_format: r.output_format, orientation: r.orientation, crop_mode: r.crop_mode || 'crop',
dpi: r.dpi || 300, model_key: r.model_key, delivery: r.delivery || 'library',
picdrop_gallery: r.picdrop_gallery,
};
}
return { output_format: 'keep', crop_mode: 'crop', dpi: 300, delivery: 'library' };
}
async function dispatchDraft(chatId: number, draftId: string, recipeId: string, b: Bot) {
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
if (!draft || draft.status === 'dispatched') return;
const recipe = await one<any>('SELECT * FROM recipes WHERE id=$1', [recipeId]);
if (!recipe) { await b.api.sendMessage(chatId, 'Rezept nicht gefunden.'); return; }
const link = await linkedUser(chatId);
await b.api.sendMessage(chatId, `⏳ Verarbeite ${(draft.file_refs || []).length} Bild(er) …`);
const sources = await refsToSources(draft.file_refs || [], b);
if (!sources.length) { await b.api.sendMessage(chatId, '❌ Keine Bilder ladbar.'); return; } if (!sources.length) { await b.api.sendMessage(chatId, '❌ Keine Bilder ladbar.'); return; }
const snap = { const snap = {
@@ -138,8 +164,8 @@ async function dispatchDraft(chatId: number, draftId: string, recipeId: string,
delivery: recipe.delivery || 'library', picdrop_gallery: recipe.picdrop_gallery, delivery: recipe.delivery || 'library', picdrop_gallery: recipe.picdrop_gallery,
}; };
const job = await one<{ id: string }>( const job = await one<{ id: string }>(
`INSERT INTO jobs (created_by, origin, recipe_snapshot, status, total, telegram_chat_id) `INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
VALUES ($1,'telegram',$2,'queued',$3,$4) RETURNING id`, VALUES ($1,'telegram','each',$2,'queued',$3,$4) RETURNING id`,
[link?.user_id || null, JSON.stringify(snap), sources.length, chatId]); [link?.user_id || null, JSON.stringify(snap), sources.length, chatId]);
for (let i = 0; i < sources.length; i++) { for (let i = 0; i < sources.length; i++) {
const it = await one<{ id: string }>( const it = await one<{ id: string }>(
@@ -151,6 +177,55 @@ async function dispatchDraft(chatId: number, draftId: string, recipeId: string,
await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]); await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]);
} }
/** Kombinieren: alle Bilder des Drafts + Beschreibung → ein neues Bild. */
async function dispatchCompose(chatId: number, draftId: string, description: string, b: Bot) {
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
if (!draft || draft.status === 'dispatched') return;
const link = await linkedUser(chatId);
await b.api.sendMessage(chatId, '⏳ Kombiniere die Bilder …');
const sources = await refsToSources(draft.file_refs || [], b);
if (sources.length < 2) { await b.api.sendMessage(chatId, '❌ Zum Kombinieren brauche ich mindestens 2 Bilder.'); return; }
const d = await chatRecipeDefaults(chatId);
const wantsFormat = d.output_format && d.output_format !== 'keep';
const snap = {
tasks: wantsFormat ? ['format'] : [], output_format: d.output_format, orientation: d.orientation,
crop_mode: d.crop_mode || 'crop', dpi: d.dpi || 300, model_key: d.model_key,
prompt_text: description, delivery: d.delivery || 'library', picdrop_gallery: d.picdrop_gallery,
};
const job = await one<{ id: string }>(
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
VALUES ($1,'telegram','compose',$2,'queued',1,$3) RETURNING id`,
[link?.user_id || null, JSON.stringify(snap), chatId]);
const it = await one<{ id: string }>(
`INSERT INTO items (job_id, position, status, source_paths, filename, source_quality)
VALUES ($1,0,'queued',$2,'kombiniert','original') RETURNING id`,
[job!.id, JSON.stringify(sources.map((s) => s.source_path))]);
await enqueue({ itemId: it!.id, jobId: job!.id });
await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]);
}
/** Freitext: ein komplett neues Bild allein aus der Beschreibung. */
async function dispatchGenerate(chatId: number, description: string, b: Bot) {
const link = await linkedUser(chatId);
await b.api.sendMessage(chatId, '⏳ Erzeuge ein neues Bild …');
const d = await chatRecipeDefaults(chatId);
const wantsFormat = d.output_format && d.output_format !== 'keep';
const snap = {
tasks: wantsFormat ? ['format'] : [], output_format: d.output_format, orientation: d.orientation,
crop_mode: d.crop_mode || 'crop', dpi: d.dpi || 300, model_key: d.model_key,
prompt_text: description, delivery: d.delivery || 'library', picdrop_gallery: d.picdrop_gallery,
};
const job = await one<{ id: string }>(
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
VALUES ($1,'telegram','generate',$2,'queued',1,$3) RETURNING id`,
[link?.user_id || null, JSON.stringify(snap), chatId]);
const it = await one<{ id: string }>(
`INSERT INTO items (job_id, position, status, filename, source_quality)
VALUES ($1,0,'queued','neu','original') RETURNING id`, [job!.id]);
await enqueue({ itemId: it!.id, jobId: job!.id });
}
/** Vom Worker aufgerufen: eine Rückmeldung an den Chat, wenn der Auftrag fertig ist. */ /** Vom Worker aufgerufen: eine Rückmeldung an den Chat, wenn der Auftrag fertig ist. */
export async function notifyJobDone(chatId: number, jobId: string): Promise<void> { export async function notifyJobDone(chatId: number, jobId: string): Promise<void> {
const b = await getBot(); if (!b) return; const b = await getBot(); if (!b) return;
@@ -184,9 +259,16 @@ function register(b: Bot) {
}); });
b.command('help', (ctx) => ctx.reply( b.command('help', (ctx) => ctx.reply(
'So gehts:\n• Bilder weiterleiten (einzeln oder als Album) — am besten als *Datei*.\n• Rezept wählen.\nIch melde mich einmal, wenn alles fertig ist — mit den Ergebnissen und Links.\n\nBefehle: /rezepte (Standard setzen) · /status · /start', 'So gehts:\n• *Bearbeiten:* Bilder weiterleiten (am besten als *Datei*) → Rezept wählen.\n• *Kombinieren:* 2+ Bilder senden, dazu eine Bildunterschrift wie „mit unserer Hündin Frieda" → „🔀 Kombinieren".\n• *Neu erzeugen:* `/neu <Beschreibung>` — z. B. `/neu ein Olivenzweig auf Sand`.\n\nIch melde mich einmal, wenn alles fertig ist — mit den Ergebnissen und Links.\nBefehle: /neu · /rezepte (Standard setzen) · /status · /start',
{ parse_mode: 'Markdown' })); { parse_mode: 'Markdown' }));
b.command('neu', async (ctx) => {
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Bitte zuerst koppeln (Code aus dem Admin).');
const text = (ctx.match || '').toString().trim();
if (!text) return ctx.reply('Schreib z. B.: /neu ein minimalistisches Poster mit einem Olivenzweig auf Sand');
const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat.id, text, b2);
});
b.command('rezepte', async (ctx) => { b.command('rezepte', async (ctx) => {
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Bitte zuerst koppeln (Code aus dem Admin).'); if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Bitte zuerst koppeln (Code aus dem Admin).');
const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 10'); const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 10');
@@ -205,7 +287,8 @@ function register(b: Bot) {
const onImage = async (ctx: any, fileId: string, name: string, quality: 'original' | 'compressed') => { const onImage = async (ctx: any, fileId: string, name: string, quality: 'original' | 'compressed') => {
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('⛔️ Nicht gekoppelt. Bitte Kopplungscode aus dem Admin eingeben.'); if (!(await linkedUser(ctx.chat.id))) return ctx.reply('⛔️ Nicht gekoppelt. Bitte Kopplungscode aus dem Admin eingeben.');
await addToDraft(ctx.chat.id, ctx.message?.media_group_id || null, { file_id: fileId, name, quality }); await addToDraft(ctx.chat.id, ctx.message?.media_group_id || null,
{ file_id: fileId, name, quality }, ctx.message?.caption || null);
}; };
b.on('message:photo', async (ctx) => { b.on('message:photo', async (ctx) => {
const p = ctx.message.photo[ctx.message.photo.length - 1]; const p = ctx.message.photo[ctx.message.photo.length - 1];
@@ -219,9 +302,23 @@ function register(b: Bot) {
b.on('message:text', async (ctx) => { b.on('message:text', async (ctx) => {
if (ctx.message.text.startsWith('/')) return; if (ctx.message.text.startsWith('/')) return;
if (await linkedUser(ctx.chat.id)) return; // gekoppelt: Text ignorieren const linked = await linkedUser(ctx.chat.id);
if (linked) {
// Wartet ein Kombinieren-Draft auf die Beschreibung? → Text als Beschreibung nehmen.
const waiting = await one<{ id: string }>(
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status='awaiting_compose_text'
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat.id]);
if (waiting) {
const b2 = await getBot(); if (b2) await dispatchCompose(ctx.chat.id, waiting.id, ctx.message.text.trim(), b2);
return;
}
// Sonst: Freitext als Angebot zum Neu-Erzeugen anbieten.
const kb = new InlineKeyboard().text('🎨 Neues Bild erzeugen', 'g:x');
pendingGenerate.set(ctx.chat.id, ctx.message.text.trim());
return ctx.reply('Soll ich daraus ein neues Bild erzeugen? (oder /help)', { reply_markup: kb });
}
if (await tryPair(ctx.chat.id, ctx.message.text)) { if (await tryPair(ctx.chat.id, ctx.message.text)) {
return ctx.reply('✅ Verbunden! Leite mir jetzt Bilder weiter. /help für mehr.'); return ctx.reply('✅ Verbunden! Leite mir Bilder weiter, oder erzeuge mit /neu ein neues Bild. /help für mehr.');
} }
return ctx.reply('Code ungültig oder abgelaufen. Neuen Code im Admin erzeugen.'); return ctx.reply('Code ungültig oder abgelaufen. Neuen Code im Admin erzeugen.');
}); });
@@ -241,5 +338,25 @@ function register(b: Bot) {
await ctx.editMessageText('Alles klar, los gehts.'); await ctx.editMessageText('Alles klar, los gehts.');
const b2 = await getBot(); if (b2) await dispatchDraft(ctx.chat!.id, draft.id, recipeId, b2); const b2 = await getBot(); if (b2) await dispatchDraft(ctx.chat!.id, draft.id, recipeId, b2);
} }
if (kind === 'c') { // Kombinieren gewählt
const draft = await one<{ id: string; caption: string | null }>(
`SELECT id, caption FROM telegram_drafts WHERE chat_id=$1 AND status='awaiting_recipe'
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat!.id]);
if (!draft) return ctx.editMessageText('Kein offener Bild-Stapel gefunden — bitte Bilder neu senden.');
if (draft.caption?.trim()) {
await ctx.editMessageText(`Alles klar — kombiniere mit: „${draft.caption.trim()}".`);
const b2 = await getBot(); if (b2) await dispatchCompose(ctx.chat!.id, draft.id, draft.caption.trim(), b2);
} else {
await query(`UPDATE telegram_drafts SET status='awaiting_compose_text' WHERE id=$1`, [draft.id]);
await ctx.editMessageText('Beschreibe kurz, was entstehen soll (z. B. „das Bild, aber mit unserer Hündin Frieda"):');
}
}
if (kind === 'g') { // Freitext → neues Bild
const text = pendingGenerate.get(ctx.chat!.id);
if (!text) return ctx.editMessageText('Text nicht mehr vorhanden — bitte erneut senden oder /neu nutzen.');
pendingGenerate.delete(ctx.chat!.id);
await ctx.editMessageText('Alles klar, erzeuge ein neues Bild …');
const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat!.id, text, b2);
}
}); });
} }
+15 -2
View File
@@ -19,6 +19,11 @@ export const GET: APIRoute = async () => {
picdrop_password_set: !!s.picdrop_password_enc, picdrop_password_set: !!s.picdrop_password_enc,
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,
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,
// Speicherverwaltung
keep_sources: s.keep_sources, make_thumbnails: s.make_thumbnails, retention_days: s.retention_days,
// NAS-Sicherung
nas_enabled: s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port,
nas_user: s.nas_user, nas_base_path: s.nas_base_path, nas_password_set: !!s.nas_password_enc,
} }); } });
}; };
@@ -29,9 +34,17 @@ export const PATCH: APIRoute = async ({ request }) => {
if (b.openrouter_key) set('openrouter_key_enc', encrypt(String(b.openrouter_key))); if (b.openrouter_key) set('openrouter_key_enc', encrypt(String(b.openrouter_key)));
if (b.picdrop_password) set('picdrop_password_enc', encrypt(String(b.picdrop_password))); if (b.picdrop_password) set('picdrop_password_enc', encrypt(String(b.picdrop_password)));
if (b.nas_password) set('nas_password_enc', encrypt(String(b.nas_password)));
const boolCols = ['keep_sources', 'make_thumbnails', 'nas_enabled'];
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',
if (col in b) set(col, b[col] === '' ? null : b[col]); 'keep_sources', 'make_thumbnails', 'retention_days',
'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path']) {
if (col in b) {
const raw = b[col];
const val = boolCols.includes(col) ? !!raw : (raw === '' ? null : raw);
set(col, val);
}
} }
if (!sets.length) return json({ ok: true }); if (!sets.length) return json({ ok: true });
await query(`UPDATE settings SET ${sets.join(',')} WHERE id=1`, args); await query(`UPDATE settings SET ${sets.join(',')} WHERE id=1`, args);
+21
View File
@@ -0,0 +1,21 @@
import type { APIRoute } from 'astro';
import { storageStats, runRetention, purgeSources } from '../../../lib/maintenance';
import { one } from '../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = async () => json({ stats: await storageStats() });
// POST { action: 'retention' | 'purge_sources' }
export const POST: APIRoute = async ({ request }) => {
const b = await request.json().catch(() => ({}));
if (b.action === 'purge_sources') return json(await purgeSources());
if (b.action === 'retention') {
const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1');
if (!s?.retention_days) return json({ error: 'Keine Aufbewahrungsfrist gesetzt.' }, 400);
return json(await runRetention(s.retention_days));
}
return json({ error: 'Unbekannte Aktion.' }, 400);
};
+12
View File
@@ -0,0 +1,12 @@
import type { APIRoute } from 'astro';
import { loadNasConfig, testNas } from '../../../lib/nas';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const POST: APIRoute = async () => {
const cfg = await loadNasConfig();
if (!cfg) return json({ ok: false, message: 'NAS nicht vollständig konfiguriert (oder deaktiviert).' });
return json(await testNas(cfg));
};
+14 -6
View File
@@ -7,15 +7,23 @@ export const prerender = false;
// Streamt das Ergebnis- (oder Quell-)Bild aus dem Objektspeicher. // Streamt das Ergebnis- (oder Quell-)Bild aus dem Objektspeicher.
export const GET: APIRoute = async ({ params, url, locals }) => { export const GET: APIRoute = async ({ params, url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 }); if (!locals.user) return new Response('Unauthorized', { status: 401 });
const which = url.searchParams.get('src') === '1' ? 'source_path' : 'result_path'; const q = url.searchParams;
const item = await one<any>(`SELECT ${which} AS path, filename, has_alpha FROM items WHERE id=$1`, [params.id]); const wantThumb = q.get('thumb') === '1';
if (!item?.path) return new Response('Nicht gefunden', { status: 404 }); const wantSrc = q.get('src') === '1';
const item = await one<any>(
`SELECT source_path, result_path, thumb_path, filename, has_alpha FROM items WHERE id=$1`, [params.id]);
// Vorschau: Thumbnail bevorzugen, sonst Ergebnis. Quelle nur explizit.
const path = wantSrc ? item?.source_path
: wantThumb ? (item?.thumb_path || item?.result_path)
: item?.result_path;
if (!path) return new Response('Nicht gefunden', { status: 404 });
try { try {
const buf = await getObject(item.path); const buf = await getObject(path);
const download = url.searchParams.get('download') === '1'; const download = q.get('download') === '1';
const isWebp = path.endsWith('.webp');
return new Response(buf, { return new Response(buf, {
headers: { headers: {
'Content-Type': item.has_alpha ? 'image/png' : 'image/png', 'Content-Type': isWebp ? 'image/webp' : 'image/png',
'Cache-Control': 'private, max-age=300', 'Cache-Control': 'private, max-age=300',
...(download ? { 'Content-Disposition': `attachment; filename="${item.filename || 'klarbild.png'}"` } : {}), ...(download ? { 'Content-Disposition': `attachment; filename="${item.filename || 'klarbild.png'}"` } : {}),
}, },
+31 -12
View File
@@ -14,12 +14,19 @@ export const GET: APIRoute = async ({ locals }) => {
return json({ jobs: rows }); return json({ jobs: rows });
}; };
// Body: { recipeId?, recipe?, sources:[{source_path, filename, source_quality?}], origin? } // Body: { recipeId?, recipe?, sources:[{source_path, filename, source_quality?}],
// mode?: 'each'|'compose'|'generate', prompt_text?, origin? }
export const POST: APIRoute = async ({ request, locals }) => { export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 }); if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json(); const b = await request.json();
const mode: 'each' | 'compose' | 'generate' = ['each', 'compose', 'generate'].includes(b.mode) ? b.mode : 'each';
const sources: any[] = b.sources || []; const sources: any[] = b.sources || [];
if (!sources.length) return json({ error: 'Keine Bilder.' }, 400); const promptText: string = (b.prompt_text || '').trim();
if (mode === 'each' && !sources.length) return json({ error: 'Keine Bilder.' }, 400);
if (mode === 'compose' && sources.length < 2) return json({ error: 'Zum Kombinieren mindestens 2 Bilder.' }, 400);
if (mode === 'compose' && !promptText) return json({ error: 'Bitte beschreiben, was entstehen soll.' }, 400);
if (mode === 'generate' && !promptText) return json({ error: 'Bitte einen Text eingeben.' }, 400);
let snapshot: any = b.recipe; let snapshot: any = b.recipe;
if (b.recipeId) { if (b.recipeId) {
@@ -27,11 +34,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
if (!r) return json({ error: 'Rezept nicht gefunden.' }, 404); if (!r) return json({ error: 'Rezept nicht gefunden.' }, 404);
snapshot = r; snapshot = r;
} }
if (!snapshot) return json({ error: 'Kein Rezept.' }, 400); snapshot = snapshot || {};
// Rezept-Snapshot einfrieren // Rezept-Snapshot einfrieren
const snap = { const snap = {
tasks: snapshot.tasks || [], tasks: snapshot.tasks || (mode === 'each' ? [] : []),
output_format: snapshot.output_format, output_format: snapshot.output_format,
orientation: snapshot.orientation, orientation: snapshot.orientation,
crop_mode: snapshot.crop_mode || 'crop', crop_mode: snapshot.crop_mode || 'crop',
@@ -39,21 +46,33 @@ export const POST: APIRoute = async ({ request, locals }) => {
contour_mm: snapshot.contour_mm ?? null, contour_mm: snapshot.contour_mm ?? null,
model_key: snapshot.model_key ?? null, model_key: snapshot.model_key ?? null,
custom_instruction: snapshot.custom_instruction ?? null, custom_instruction: snapshot.custom_instruction ?? null,
prompt_text: promptText || null,
delivery: snapshot.delivery || 'library', delivery: snapshot.delivery || 'library',
picdrop_gallery: snapshot.picdrop_gallery ?? null, picdrop_gallery: snapshot.picdrop_gallery ?? null,
}; };
const total = (mode === 'each') ? sources.length : 1;
const job = await one<{ id: string }>( const job = await one<{ id: string }>(
`INSERT INTO jobs (created_by, origin, recipe_snapshot, status, total) `INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total)
VALUES ($1,$2,$3,'queued',$4) RETURNING id`, VALUES ($1,$2,$3,$4,'queued',$5) RETURNING id`,
[locals.user.uid, b.origin || 'web', JSON.stringify(snap), sources.length]); [locals.user.uid, b.origin || 'web', mode, JSON.stringify(snap), total]);
for (let i = 0; i < sources.length; i++) { if (mode === 'each') {
const s = sources[i]; for (let i = 0; i < sources.length; i++) {
const s = sources[i];
const item = await one<{ id: string }>(
`INSERT INTO items (job_id, position, status, source_path, filename, source_quality)
VALUES ($1,$2,'queued',$3,$4,$5) RETURNING id`,
[job!.id, i, s.source_path, s.filename || null, s.source_quality || 'original']);
await enqueue({ itemId: item!.id, jobId: job!.id });
}
} else {
const srcPaths = sources.map((s) => s.source_path).filter(Boolean);
const base = mode === 'generate' ? 'neu' : 'kombiniert';
const item = await one<{ id: string }>( const item = await one<{ id: string }>(
`INSERT INTO items (job_id, position, status, source_path, filename, source_quality) `INSERT INTO items (job_id, position, status, source_paths, filename, source_quality)
VALUES ($1,$2,'queued',$3,$4,$5) RETURNING id`, VALUES ($1,0,'queued',$2,$3,'original') RETURNING id`,
[job!.id, i, s.source_path, s.filename || null, s.source_quality || 'original']); [job!.id, srcPaths.length ? JSON.stringify(srcPaths) : null, base]);
await enqueue({ itemId: item!.id, jobId: job!.id }); await enqueue({ itemId: item!.id, jobId: job!.id });
} }
+25 -2
View File
@@ -10,6 +10,17 @@ export async function startImageWorker(): Promise<void> {
await startWorker(concurrency, handle); await startWorker(concurrency, handle);
} }
/** Monatliches Budget erreicht? (Summe usage.cost im laufenden Kalendermonat.) */
async function budgetExceeded(): Promise<boolean> {
const s = await one<{ monthly_budget: number | null }>('SELECT monthly_budget FROM settings WHERE id=1');
const cap = s?.monthly_budget ? Number(s.monthly_budget) : 0;
if (!cap || cap <= 0) return false;
const row = await one<{ spent: number }>(
`SELECT COALESCE(sum(cost),0)::float AS spent FROM items
WHERE cost IS NOT NULL AND created_at >= date_trunc('month', now())`);
return (row?.spent || 0) >= cap;
}
async function handle(job: GenerateJob): Promise<void> { async function handle(job: GenerateJob): Promise<void> {
// Angehaltene/abgebrochene Aufträge nicht verarbeiten. // Angehaltene/abgebrochene Aufträge nicht verarbeiten.
const j = await one<{ status: string }>('SELECT status FROM jobs WHERE id=$1', [job.jobId]); const j = await one<{ status: string }>('SELECT status FROM jobs WHERE id=$1', [job.jobId]);
@@ -17,6 +28,15 @@ async function handle(job: GenerateJob): Promise<void> {
await query(`UPDATE items SET status='queued' WHERE id=$1 AND status='running'`, [job.itemId]); await query(`UPDATE items SET status='queued' WHERE id=$1 AND status='running'`, [job.itemId]);
return; return;
} }
// Kostendeckel: bei Erreichen Auftrag anhalten, Position zurück in die Schlange.
if (await budgetExceeded()) {
await query(`UPDATE jobs SET status='paused' WHERE id=$1`, [job.jobId]);
await query(`UPDATE items SET status='queued', error_message='Monatsbudget erreicht' WHERE id=$1`, [job.itemId]);
console.error('[worker] Monatsbudget erreicht — Auftrag angehalten', job.jobId);
return;
}
await query(`UPDATE jobs SET status='running' WHERE id=$1 AND status='queued'`, [job.jobId]); await query(`UPDATE jobs SET status='running' WHERE id=$1 AND status='queued'`, [job.jobId]);
try { try {
@@ -52,8 +72,11 @@ 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) {
await query(`UPDATE jobs SET status='done', finished_at=now() WHERE id=$1 AND status<>'cancelled'`, // Idempotent: nur der erste Übergang nach 'done' löst Auslieferung + Rückmeldung aus.
[jobId]); const done = await query(
`UPDATE jobs SET status='done', finished_at=now()
WHERE id=$1 AND status NOT IN ('done','cancelled') RETURNING id`, [jobId]);
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); }
// Telegram-Rückmeldung, wenn der Auftrag aus einem Chat kam. // Telegram-Rückmeldung, wenn der Auftrag aus einem Chat kam.