feat: single-image transform, NAS/FTP in delivery, backup-all any target, metadata sidecar, prompt view, preset manager, API token + MCP server
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# Klarbild MCP-Server
|
||||
|
||||
Erlaubt einem KI-Assistenten (z. B. Claude), Bilder an Klarbild zu übergeben und dort zu
|
||||
verarbeiten — etwa „nimm die Bilder aus Ordner XY" oder „verarbeite die Fotos, die mir jemand
|
||||
per iMessage geschickt hat". Der Assistent besorgt die Bilddateien (mit seinen eigenen
|
||||
Werkzeugen) und ruft dann die Klarbild-Tools auf.
|
||||
|
||||
## Einrichtung
|
||||
|
||||
1. In Klarbild einen Token erzeugen: **Admin → „Automatisierung / MCP-Zugriff" → Token erzeugen**.
|
||||
2. Abhängigkeit installieren: `npm i @modelcontextprotocol/sdk`
|
||||
3. Server starten (bzw. im MCP-Client eintragen):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"klarbild": {
|
||||
"command": "node",
|
||||
"args": ["/pfad/zu/mcp/klarbild-mcp.mjs"],
|
||||
"env": {
|
||||
"KLARBILD_URL": "https://klarbild.heidrich-digital.de",
|
||||
"KLARBILD_TOKEN": "klb_…"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
- **list_recipes** — verfügbare Presets/Rezepte auflisten.
|
||||
- **process_images** — Bilder (lokale Pfade oder `data:`-URLs) hochladen und verarbeiten.
|
||||
Entweder `recipeId` (Preset) oder `mode` (`each`/`compose`/`generate`) + Optionen
|
||||
(`prompt_text`, `output_format`, `delivery`). Liefert die Auftrags-ID.
|
||||
- **job_status** — Status eines Auftrags abfragen.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
Der Token gibt vollen programmatischen Zugriff auf Upload/Verarbeitung (nicht auf den
|
||||
Admin-Bereich). Wie ein Passwort behandeln; bei Verdacht neuen Token erzeugen (der alte wird
|
||||
dadurch ungültig).
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
// Klarbild MCP-Server — erlaubt einem KI-Assistenten (z. B. Claude), lokale Bilder
|
||||
// oder anderweitig beschaffte Bilder (iMessage, Ordner XY …) an Klarbild zu übergeben
|
||||
// und dort zu verarbeiten. Der Assistent besorgt die Bilddateien; dieser Server lädt
|
||||
// sie hoch und legt einen Auftrag an.
|
||||
//
|
||||
// Setup:
|
||||
// npm i @modelcontextprotocol/sdk
|
||||
// KLARBILD_URL=https://klarbild.heidrich-digital.de \
|
||||
// KLARBILD_TOKEN=klb_xxx node mcp/klarbild-mcp.mjs
|
||||
// Den Token erzeugt man in Klarbild unter Admin → „Automatisierung / MCP-Zugriff".
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { basename } from 'node:path';
|
||||
|
||||
const BASE = (process.env.KLARBILD_URL || '').replace(/\/$/, '');
|
||||
const TOKEN = process.env.KLARBILD_TOKEN || '';
|
||||
if (!BASE || !TOKEN) { console.error('KLARBILD_URL und KLARBILD_TOKEN müssen gesetzt sein.'); process.exit(1); }
|
||||
|
||||
const authHeaders = { Authorization: `Bearer ${TOKEN}` };
|
||||
const api = (path) => `${BASE}${path}`;
|
||||
|
||||
async function uploadFile(pathOrBase64, name) {
|
||||
let buf, filename = name;
|
||||
if (pathOrBase64.startsWith('data:')) {
|
||||
buf = Buffer.from(pathOrBase64.split(',')[1], 'base64');
|
||||
filename = name || 'bild.png';
|
||||
} else {
|
||||
buf = await readFile(pathOrBase64);
|
||||
filename = name || basename(pathOrBase64);
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append('files', new Blob([buf]), filename);
|
||||
const r = await fetch(api('/api/uploads'), { method: 'POST', headers: authHeaders, body: fd });
|
||||
const j = await r.json();
|
||||
const info = (j.files || [])[0];
|
||||
if (!info?.source_path) throw new Error(info?.error || 'Upload fehlgeschlagen');
|
||||
return { source_path: info.source_path, filename };
|
||||
}
|
||||
|
||||
const server = new Server({ name: 'klarbild', version: '1.0.0' }, { capabilities: { tools: {} } });
|
||||
|
||||
const TOOLS = [
|
||||
{ name: 'list_recipes', description: 'Verfügbare Presets/Rezepte in Klarbild auflisten.',
|
||||
inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'process_images',
|
||||
description: 'Bilder an Klarbild übergeben und verarbeiten. Dateien als lokale Pfade ODER data:-URLs. Entweder recipeId ODER mode+Optionen angeben.',
|
||||
inputSchema: { type: 'object', properties: {
|
||||
files: { type: 'array', items: { type: 'string' }, description: 'Lokale Pfade oder data:-URLs' },
|
||||
recipeId: { type: 'string', description: 'Optional: Preset-ID (aus list_recipes)' },
|
||||
mode: { type: 'string', enum: ['each', 'compose', 'generate'], description: 'Falls kein recipeId' },
|
||||
prompt_text: { type: 'string', description: 'Beschreibung für compose/generate' },
|
||||
output_format: { type: 'string', description: 'z. B. 30x40, theframe, keep' },
|
||||
delivery: { type: 'string', enum: ['library', 'picdrop', 'both'] },
|
||||
}, required: ['files'] } },
|
||||
{ name: 'job_status', description: 'Status eines Auftrags abfragen.',
|
||||
inputSchema: { type: 'object', properties: { jobId: { type: 'string' } }, required: ['jobId'] } },
|
||||
];
|
||||
|
||||
server.setRequestHandler({ method: 'tools/list' }, async () => ({ tools: TOOLS }));
|
||||
|
||||
server.setRequestHandler({ method: 'tools/call' }, async (req) => {
|
||||
const { name, arguments: a = {} } = req.params;
|
||||
try {
|
||||
if (name === 'list_recipes') {
|
||||
const j = await (await fetch(api('/api/recipes'), { headers: authHeaders })).json();
|
||||
const list = (j.recipes || []).map((r) => `${r.id} — ${r.name} (${r.mode || 'each'})`).join('\n');
|
||||
return { content: [{ type: 'text', text: list || 'Keine Presets.' }] };
|
||||
}
|
||||
if (name === 'process_images') {
|
||||
const files = a.files || [];
|
||||
const mode = a.mode || 'each';
|
||||
const sources = (mode === 'generate') ? [] : await Promise.all(files.map((f) => uploadFile(f)));
|
||||
const body = a.recipeId
|
||||
? { recipeId: a.recipeId, mode, sources, prompt_text: a.prompt_text }
|
||||
: { mode, prompt_text: a.prompt_text, sources,
|
||||
recipe: { tasks: a.output_format && a.output_format !== 'keep' ? ['format'] : [],
|
||||
output_format: a.output_format || 'keep', delivery: a.delivery || 'library' },
|
||||
delivery: a.delivery || 'library' };
|
||||
const j = await (await fetch(api('/api/jobs'), { method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json();
|
||||
if (!j.jobId) throw new Error(j.error || 'Auftrag fehlgeschlagen');
|
||||
return { content: [{ type: 'text', text: `Auftrag angelegt: ${j.jobId}` }] };
|
||||
}
|
||||
if (name === 'job_status') {
|
||||
const j = await (await fetch(api(`/api/jobs/${a.jobId}`), { headers: authHeaders })).json();
|
||||
const job = j.job || j;
|
||||
return { content: [{ type: 'text', text: `Status: ${job.status} — ${job.done_count}/${job.total} fertig${job.failed_count ? `, ${job.failed_count} fehlgeschlagen` : ''}` }] };
|
||||
}
|
||||
return { content: [{ type: 'text', text: `Unbekanntes Tool: ${name}` }], isError: true };
|
||||
} catch (e) {
|
||||
return { content: [{ type: 'text', text: `Fehler: ${e?.message || e}` }], isError: true };
|
||||
}
|
||||
});
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
console.error('[klarbild-mcp] bereit');
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Backup für beliebige Ziele, Metadaten-Beileger, API-Token für externen Zugriff (MCP).
|
||||
|
||||
-- Jedes Zusatzziel kann als Backup-Ziel markiert werden (erhält ALLE Ergebnisse).
|
||||
ALTER TABLE delivery_targets ADD COLUMN IF NOT EXISTS is_backup bool NOT NULL DEFAULT false;
|
||||
|
||||
-- Metadaten als begleitende .md-Datei mitschicken (Prompt, Dateiname, Modell …).
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS metadata_sidecar bool NOT NULL DEFAULT false;
|
||||
|
||||
-- API-Token für programmatischen Zugriff (Klarbild-MCP / Automationen).
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS api_token text;
|
||||
|
||||
-- Backup-Status je Ziel und Position (für „auf mehreren Zielen gesichert").
|
||||
CREATE TABLE IF NOT EXISTS item_backups (
|
||||
item_id uuid REFERENCES items(id) ON DELETE CASCADE,
|
||||
target text NOT NULL, -- 'nas' | delivery_target-UUID
|
||||
status text NOT NULL DEFAULT 'mirrored' CHECK (status IN ('mirrored','failed')),
|
||||
at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (item_id, target)
|
||||
);
|
||||
@@ -33,6 +33,21 @@ export default function AdminApp() {
|
||||
setNt({ protocol: 'sftp' }); notify('Ziel angelegt.'); load();
|
||||
};
|
||||
const delTarget = async (id: string) => { if (!confirm('Ziel löschen?')) return; await fetch(`/api/admin/delivery-targets?id=${id}`, { method: 'DELETE' }); load(); };
|
||||
const toggleBackup = async (t: any) => {
|
||||
await fetch('/api/admin/delivery-targets', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: t.id, is_backup: !t.is_backup }) });
|
||||
load();
|
||||
};
|
||||
const backupAllTargets = async () => {
|
||||
if (!confirm('Alle fertigen Bilder auf alle Backup-Ziele (NAS + markierte Ziele) sichern?')) return;
|
||||
notify('Sichere … (kann dauern)');
|
||||
const r = await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'backup_all' }) }).then((x) => x.json());
|
||||
notify(r.items != null ? `${r.mirrored} Kopien auf Backup-Ziele${r.failed ? `, ${r.failed} Fehler` : ''}.` : (r.error || 'OK'));
|
||||
load();
|
||||
};
|
||||
const genToken = async () => {
|
||||
await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ generate_api_token: true }) });
|
||||
notify('API-Token erzeugt.'); load();
|
||||
};
|
||||
const testTarget = async (id: string) => {
|
||||
notify('Teste Ziel …');
|
||||
const r = await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'test', id }) }).then((x) => x.json());
|
||||
@@ -59,6 +74,7 @@ export default function AdminApp() {
|
||||
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,
|
||||
keep_sources: !!s.keep_sources, make_thumbnails: s.make_thumbnails !== false, retention_days: s.retention_days,
|
||||
metadata_sidecar: !!s.metadata_sidecar,
|
||||
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,
|
||||
};
|
||||
@@ -201,6 +217,8 @@ export default function AdminApp() {
|
||||
<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>
|
||||
<label className="schalt"><input type="checkbox" checked={!!s.metadata_sidecar} onChange={(e) => field('metadata_sidecar', e.target.checked)} />
|
||||
<span><b>Metadaten als .md mitschicken</b><em>Zu jedem Bild eine begleitende Textdatei (Prompt, Modell, Format …) bei Picdrop/NAS/FTP.</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>
|
||||
@@ -237,15 +255,18 @@ export default function AdminApp() {
|
||||
</section>
|
||||
|
||||
<section className="karte">
|
||||
<div className="kopfzeile"><span className="mono-label">Weitere Ausgabeziele (FTP/SFTP)</span></div>
|
||||
<div className="kopfzeile"><span className="mono-label">Weitere Ausgabeziele (FTP/SFTP)</span>
|
||||
<button className="mini" onClick={backupAllTargets}>Alle Bilder sichern</button></div>
|
||||
<div className="steuer">
|
||||
<div className="fein">Zusätzliche FTP/SFTP-Ziele — z. B. eine zweite Picdrop-Galerie oder ein anderer Server.
|
||||
Ein Preset (Rezept) oder Ordner kann fest auf ein Ziel zeigen; sonst gilt das Standard-Picdrop oben.</div>
|
||||
Ein Preset/Ordner kann fest auf ein Ziel zeigen; im Studio unter „Wohin?" wählbar (auch NAS).
|
||||
„Backup"-Ziele erhalten automatisch <b>alle</b> Ergebnisse (wie das NAS).</div>
|
||||
<div className="liste">
|
||||
{targets.map((t) => (
|
||||
<div key={t.id} className="zeile">
|
||||
<div><b>{t.name}</b> <span className="fein">{t.protocol}://{t.username}@{t.host}:{t.port} · {t.base_path || '/'}{t.password_set ? '' : ' · ⚠︎ kein Passwort'}</span></div>
|
||||
<div><b>{t.name}</b>{t.is_backup && <span className="pille">Backup</span>} <span className="fein">{t.protocol}://{t.username}@{t.host}:{t.port} · {t.base_path || '/'}{t.password_set ? '' : ' · ⚠︎ kein Passwort'}</span></div>
|
||||
<div className="reihe">
|
||||
<button className="mini" onClick={() => toggleBackup(t)}>{t.is_backup ? 'Backup aus' : 'Als Backup'}</button>
|
||||
<button className="mini" onClick={() => testTarget(t.id)}>Test</button>
|
||||
<button className="mini" onClick={() => delTarget(t.id)}>✕</button>
|
||||
</div>
|
||||
@@ -268,10 +289,23 @@ export default function AdminApp() {
|
||||
<div className="feld"><label>Passwort</label><input className="input" type="password" value={nt.password ?? ''} onChange={(e) => setNt({ ...nt, password: e.target.value })} /></div>
|
||||
</div>
|
||||
<div className="feld"><label>Basisordner</label><input className="input" placeholder="/" value={nt.base_path ?? ''} onChange={(e) => setNt({ ...nt, base_path: e.target.value })} /></div>
|
||||
<label className="schalt"><input type="checkbox" checked={!!nt.is_backup} onChange={(e) => setNt({ ...nt, is_backup: e.target.checked })} />
|
||||
<span><b>Als Backup-Ziel</b><em>Erhält automatisch alle Ergebnisse.</em></span></label>
|
||||
<button className="mini" onClick={addTarget}>Ziel hinzufügen</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="karte">
|
||||
<div className="kopfzeile"><span className="mono-label">Automatisierung / MCP-Zugriff</span></div>
|
||||
<div className="steuer">
|
||||
<div className="fein">API-Token für externen/programmatischen Zugriff (z. B. Klarbild-MCP: „nimm diese Bilder und verarbeite sie"). Als <code>Authorization: Bearer <Token></code> an <code>/api/uploads</code> und <code>/api/jobs</code>.</div>
|
||||
{s.api_token
|
||||
? <div className="feld"><label>API-Token</label><input className="input" readOnly value={s.api_token} onFocus={(e) => e.target.select()} /></div>
|
||||
: <div className="fein">Noch kein Token erzeugt.</div>}
|
||||
<button className="mini" onClick={genToken}>{s.api_token ? 'Neuen Token erzeugen' : 'Token erzeugen'}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button className="knopf" onClick={saveSettings}>Einstellungen speichern</button>
|
||||
|
||||
<section className="karte">
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface Item { id: string; filename: string; output_px: string; has_alpha: boolean;
|
||||
folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string; nas_status: string;
|
||||
model_used: string; variant_of: string | null; mode: string; thumb_path: string | null; created_at: string; }
|
||||
model_used: string; variant_of: string | null; mode: string; thumb_path: string | null; prompt_used: string | null; created_at: string; }
|
||||
|
||||
export default function LibraryApp() {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
@@ -12,6 +12,7 @@ export default function LibraryApp() {
|
||||
const [pick, setPick] = useState<Set<string>>(new Set());
|
||||
const [variantSel, setVariantSel] = useState<Record<string, string>>({});
|
||||
const [compare, setCompare] = useState<Item | null>(null);
|
||||
const [promptOf, setPromptOf] = useState<Item | null>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); };
|
||||
|
||||
@@ -176,6 +177,7 @@ export default function LibraryApp() {
|
||||
{v.nas_status === 'failed' && <span className="dbadge err">NAS ✕</span>}
|
||||
</div>
|
||||
<div className="reihe knapp">
|
||||
{v.prompt_used && <button className="mini" onClick={() => setPromptOf(v)}>Prompt</button>}
|
||||
{v.mode !== 'generate' && <button className="mini" onClick={() => setCompare(v)}>Vorher/Nachher</button>}
|
||||
<a className="mini" href={`/api/items/${v.id}/file?download=1`}>Laden</a>
|
||||
<button className="mini stark2" onClick={() => reuse(v.id)} title="Ergebnis im Studio weiterbearbeiten">Weiterbearbeiten</button>
|
||||
@@ -203,6 +205,16 @@ export default function LibraryApp() {
|
||||
<div className="lupe-fuss">{compare.filename} · {compare.output_px}px — Regler: links {compare.mode === 'compose' ? 'Vorlage' : 'Original'}, rechts Ergebnis</div>
|
||||
</div>
|
||||
)}
|
||||
{promptOf && (
|
||||
<div className="lupe" onClick={() => setPromptOf(null)}>
|
||||
<div className="promptbox" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="pb-kopf"><b>{promptOf.filename}</b>
|
||||
<button className="mini" onClick={() => { navigator.clipboard?.writeText(promptOf.prompt_used || ''); notify('Prompt kopiert.'); }}>Kopieren</button></div>
|
||||
<pre className="pb-text">{promptOf.prompt_used}</pre>
|
||||
<div className="fein">{promptOf.model_used ? `Modell: ${modelName(promptOf.model_used)} · ` : ''}{promptOf.output_px}px</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
<LibStyles />
|
||||
</div>
|
||||
@@ -254,6 +266,9 @@ function LibStyles() {
|
||||
.vergleich .oben{position:absolute;inset:0;clip-path:inset(0 0 0 50%);}
|
||||
.vergleich input[type=range]{position:absolute;left:0;right:0;bottom:-34px;width:100%;}
|
||||
.lupe-fuss{color:#EAEAE3;font-family:var(--font-mono);font-size:12px;margin-top:30px;text-align:center;}
|
||||
.promptbox{background:var(--card);border-radius:var(--radius);max-width:min(92vw,620px);width:100%;padding:16px;display:flex;flex-direction:column;gap:10px;}
|
||||
.pb-kopf{display:flex;justify-content:space-between;align-items:center;gap:10px;}
|
||||
.pb-text{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);font-size:12.5px;line-height:1.5;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:12px;max-height:50vh;overflow:auto;margin:0;}
|
||||
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;}
|
||||
`}</style>;
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ const TASKS = [
|
||||
|
||||
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: 'each', name: 'Bereinigen', hint: 'Screenshots bereinigen, freistellen, aufs Format bringen.' },
|
||||
{ id: 'compose', name: 'Umwandeln', hint: 'Ein Bild umwandeln (z. B. Foto → Ölgemälde) oder mehrere kombinieren — mit Text.' },
|
||||
{ id: 'generate', name: 'Neu erzeugen', hint: 'Ein komplett neues Bild allein aus Text.' },
|
||||
];
|
||||
|
||||
@@ -43,6 +43,7 @@ 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 }
|
||||
|
||||
export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
const [presets, setPresets] = useState<any[]>(recipes || []);
|
||||
const [mode, setMode] = useState<Mode>('each');
|
||||
const [pics, setPics] = useState<Pic[]>([]);
|
||||
const [tasks, setTasks] = useState<string[]>(['clean', 'format']);
|
||||
@@ -56,6 +57,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
const [gallery, setGallery] = useState('');
|
||||
const [targetId, setTargetId] = useState('');
|
||||
const [targets, setTargets] = useState<any[]>([]);
|
||||
const [presetSel, setPresetSel] = useState('');
|
||||
const [models, setModels] = useState<any[]>([]);
|
||||
const [modelKey, setModelKey] = useState<string>('');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
@@ -117,7 +119,27 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
if (def) setModelKey(def.model_id);
|
||||
}).catch(() => {});
|
||||
fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {});
|
||||
loadPresets();
|
||||
}, []);
|
||||
const loadPresets = () => fetch('/api/recipes').then((r) => r.json()).then((j) => setPresets(j.recipes || [])).catch(() => {});
|
||||
const saveAsPreset = async () => {
|
||||
const name = prompt('Name des Presets?'); if (!name?.trim()) return;
|
||||
const order = ['clean', 'cutout', 'format', 'contour', 'deliver'];
|
||||
const tasksOut = mode === 'each' ? [...tasks].sort((a, b) => order.indexOf(a) - order.indexOf(b)) : (wantsFormat ? ['format'] : []);
|
||||
const body = {
|
||||
name: name.trim(), mode, tasks: tasksOut, output_format: wantsFormat ? format : 'keep',
|
||||
orientation: portrait ? 'portrait' : 'landscape', crop_mode: crop, dpi: 300,
|
||||
contour_mm: tasks.includes('contour') ? contourMm : null,
|
||||
custom_instruction: mode === 'each' ? (custom || null) : null, model_key: modelKey || null,
|
||||
delivery, picdrop_gallery: gallery.trim() || null, delivery_target_id: targetId || null,
|
||||
};
|
||||
await fetch('/api/recipes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
notify('Preset gespeichert.'); loadPresets();
|
||||
};
|
||||
const deletePreset = async (id: string) => {
|
||||
if (!id || !confirm('Preset löschen?')) return;
|
||||
await fetch(`/api/recipes/${id}`, { method: 'DELETE' }); notify('Preset gelöscht.'); loadPresets();
|
||||
};
|
||||
|
||||
// Ergebnis aus der Bibliothek übernehmen: ?reuse=<itemId> lädt es als Vorlage.
|
||||
useEffect(() => {
|
||||
@@ -148,6 +170,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
|
||||
function applyRecipe(r: any) {
|
||||
if (!r) return;
|
||||
if (['each', 'compose', 'generate'].includes(r.mode)) setMode(r.mode);
|
||||
setTasks(r.tasks || ['clean', 'format']);
|
||||
setFormat(r.output_format || 'keep');
|
||||
setPortrait((r.orientation || 'portrait') !== 'landscape');
|
||||
@@ -163,7 +186,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
const ready = pics.filter((p) => p.source_path && !p.error);
|
||||
const canRun = !busy && (
|
||||
mode === 'each' ? (ready.length > 0 && tasks.length > 0 && !theframeConflict && !(tasks.includes('contour') && !hasCutout))
|
||||
: mode === 'compose' ? (ready.length >= 2 && desc.trim().length > 0)
|
||||
: mode === 'compose' ? (ready.length >= 1 && desc.trim().length > 0)
|
||||
: desc.trim().length > 0
|
||||
);
|
||||
|
||||
@@ -199,7 +222,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
}
|
||||
|
||||
const uploadHint = mode === 'compose'
|
||||
? 'Mindestens 2 Bilder — z. B. das Motiv und ein Foto von Frieda.'
|
||||
? 'Ein Bild umwandeln oder mehrere kombinieren (z. B. Motiv + Foto von Frieda).'
|
||||
: 'Mehrere gleichzeitig, bis zu 100. PNG, JPG, WEBP, HEIC.';
|
||||
|
||||
return (
|
||||
@@ -252,25 +275,28 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
|
||||
<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>)}
|
||||
<label>Presets</label>
|
||||
<div className="reihe-presets">
|
||||
<select className="select" value={presetSel}
|
||||
onChange={(e) => { setPresetSel(e.target.value); applyRecipe(presets.find((r) => r.id === e.target.value)); }}>
|
||||
<option value="">Preset wählen …</option>
|
||||
{presets.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
{presetSel && <button className="pbtn" title="Preset löschen" onClick={() => { deletePreset(presetSel); setPresetSel(''); }}>✕</button>}
|
||||
<button className="pbtn" title="Aktuelle Einstellungen als Preset speichern" onClick={saveAsPreset}>+ speichern</button>
|
||||
</div>
|
||||
</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. „Lass das Foto wie ein Ölgemälde aussehen.“ oder „…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.'
|
||||
? 'Ein Bild = umwandeln (z. B. Stil ändern). Mehrere Bilder = erstes ist Leitszene, weitere liefern Personen/Motive.'
|
||||
: 'Je genauer die Beschreibung, desto besser das Ergebnis.'}</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -358,12 +384,11 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
</div>
|
||||
{delivery !== 'library' && (
|
||||
<div className="ziel">
|
||||
{targets.length > 0 && (
|
||||
<select className="select" value={targetId} onChange={(e) => setTargetId(e.target.value)}>
|
||||
<option value="">Standard-Picdrop</option>
|
||||
<option value="nas">NAS</option>
|
||||
{targets.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<input className="input" value={gallery} onChange={(e) => setGallery(e.target.value)}
|
||||
placeholder="Galerie/Ordner (leer = Standard)" />
|
||||
</div>
|
||||
@@ -373,7 +398,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
<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'}
|
||||
: 'Bild erzeugen'}
|
||||
</button>
|
||||
<div className="fein mitte">Läuft serverseitig weiter — du kannst das Fenster schließen.</div>
|
||||
</div>
|
||||
@@ -433,6 +458,9 @@ function StudioStyles() {
|
||||
.schalter button.an{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;}
|
||||
.schalter.zart button{font-size:12.5px;}
|
||||
.ziel{display:flex;flex-direction:column;gap:7px;margin-top:8px;}
|
||||
.reihe-presets{display:flex;gap:6px;align-items:center;}
|
||||
.reihe-presets .select{flex:1;}
|
||||
.pbtn{background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);white-space:nowrap;}
|
||||
.schalter.wrap{flex-wrap:wrap;}
|
||||
.schalter.wrap button{flex:1 1 auto;min-width:110px;}
|
||||
.knopf{width:100%;border:none;border-radius:3px;padding:13px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Generische Datensicherung: spiegelt fertige Ergebnisse auf alle Backup-Ziele
|
||||
// (NAS aus den Einstellungen + jedes delivery_target mit is_backup), optional mit
|
||||
// begleitender Metadaten-.md-Datei. Struktur je Ziel: <Basisordner>/JJJJ-MM/.
|
||||
import { one, query } from './db';
|
||||
import { getObject } from './storage';
|
||||
import { uploadBuffer, loadTargetConfig, type PicdropCfg } from './picdrop';
|
||||
import { loadNasConfig } from './nas';
|
||||
import { buildMetadataMd, sidecarName } from './metadata';
|
||||
|
||||
export interface BackupDest { key: string; label: string; cfg: PicdropCfg; }
|
||||
|
||||
/** Alle aktiven Backup-Ziele einsammeln. */
|
||||
export async function backupDestinations(): Promise<BackupDest[]> {
|
||||
const dests: BackupDest[] = [];
|
||||
const nas = await loadNasConfig();
|
||||
if (nas) dests.push({ key: 'nas', label: 'NAS', cfg: nas });
|
||||
const targets = await query<any>(`SELECT id, name FROM delivery_targets WHERE is_backup=true`);
|
||||
for (const t of targets) {
|
||||
const cfg = await loadTargetConfig(t.id);
|
||||
if (cfg) dests.push({ key: t.id, label: t.name, cfg });
|
||||
}
|
||||
return dests;
|
||||
}
|
||||
|
||||
async function sidecarEnabled(): Promise<boolean> {
|
||||
const s = await one<{ metadata_sidecar: boolean }>('SELECT metadata_sidecar FROM settings WHERE id=1');
|
||||
return !!s?.metadata_sidecar;
|
||||
}
|
||||
|
||||
/** Ein Item auf alle Backup-Ziele spiegeln. */
|
||||
export async function backupItem(itemId: string): Promise<{ mirrored: number; failed: number }> {
|
||||
const dests = await backupDestinations();
|
||||
if (!dests.length) return { mirrored: 0, failed: 0 };
|
||||
const it = await one<any>('SELECT id, result_path, filename, created_at FROM items WHERE id=$1', [itemId]);
|
||||
if (!it?.result_path) return { mirrored: 0, failed: 0 };
|
||||
|
||||
const buf = await getObject(it.result_path);
|
||||
const ym = new Date(it.created_at || Date.now()).toISOString().slice(0, 7);
|
||||
const withMeta = await sidecarEnabled();
|
||||
const md = withMeta ? Buffer.from(await buildMetadataMd(itemId), 'utf8') : null;
|
||||
|
||||
let mirrored = 0, failed = 0;
|
||||
for (const d of dests) {
|
||||
try {
|
||||
await uploadBuffer(d.cfg, ym, it.filename || `${it.id}.png`, buf);
|
||||
if (md) await uploadBuffer(d.cfg, ym, sidecarName(it.filename), md);
|
||||
await query(`INSERT INTO item_backups (item_id, target, status) VALUES ($1,$2,'mirrored')
|
||||
ON CONFLICT (item_id, target) DO UPDATE SET status='mirrored', at=now()`, [itemId, d.key]);
|
||||
if (d.key === 'nas') await query(`UPDATE items SET nas_status='mirrored' WHERE id=$1`, [itemId]);
|
||||
mirrored++;
|
||||
} catch (e: any) {
|
||||
console.error('[backup]', d.label, itemId, e?.message || e);
|
||||
await query(`INSERT INTO item_backups (item_id, target, status) VALUES ($1,$2,'failed')
|
||||
ON CONFLICT (item_id, target) DO UPDATE SET status='failed', at=now()`, [itemId, d.key]);
|
||||
if (d.key === 'nas') await query(`UPDATE items SET nas_status='failed' WHERE id=$1`, [itemId]);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
return { mirrored, failed };
|
||||
}
|
||||
|
||||
/** Alle fertigen Bilder auf alle Backup-Ziele sichern (Nachsicherung). */
|
||||
export async function backupAll(): Promise<{ items: number; mirrored: number; failed: number }> {
|
||||
const dests = await backupDestinations();
|
||||
if (!dests.length) return { items: 0, mirrored: 0, failed: 0 };
|
||||
const rows = await query<{ id: string }>(
|
||||
`SELECT id FROM items WHERE status='done' AND result_path IS NOT NULL ORDER BY created_at`);
|
||||
let mirrored = 0, failed = 0;
|
||||
for (const it of rows) { const r = await backupItem(it.id); mirrored += r.mirrored; failed += r.failed; }
|
||||
return { items: rows.length, mirrored, failed };
|
||||
}
|
||||
+17
-2
@@ -17,15 +17,22 @@ async function galleryFor(item: { folder_id: string | null; job_id: string }): P
|
||||
return s?.picdrop_default_gallery || DEFAULT_GALLERY;
|
||||
}
|
||||
|
||||
async function cfgForKey(key: string | null | undefined): Promise<PicdropCfg | null> {
|
||||
if (!key) return null;
|
||||
if (key === 'nas') { const { loadNasConfig } = await import('./nas'); return (await loadNasConfig()) as unknown as PicdropCfg; }
|
||||
if (key === 'picdrop') return loadConfig();
|
||||
return loadTargetConfig(key); // UUID eines Zusatzziels
|
||||
}
|
||||
|
||||
/** Ziel-Zugang: 1) Ordner-Ziel 2) Rezept-Ziel 3) Standard-Picdrop (Einstellungen). */
|
||||
async function targetFor(item: { folder_id: string | null; job_id: string }): Promise<PicdropCfg | null> {
|
||||
if (item.folder_id) {
|
||||
const f = await one<{ delivery_target_id: string | null }>('SELECT delivery_target_id FROM folders WHERE id=$1', [item.folder_id]).catch(() => null);
|
||||
if (f?.delivery_target_id) { const c = await loadTargetConfig(f.delivery_target_id); if (c) return c; }
|
||||
if (f?.delivery_target_id) { const c = await cfgForKey(f.delivery_target_id); if (c) return c; }
|
||||
}
|
||||
const j = await one<{ recipe_snapshot: any }>('SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]);
|
||||
const tid = j?.recipe_snapshot?.delivery_target_id;
|
||||
if (tid) { const c = await loadTargetConfig(tid); if (c) return c; }
|
||||
if (tid) { const c = await cfgForKey(tid); if (c) return c; }
|
||||
return loadConfig(); // Standard-Picdrop
|
||||
}
|
||||
|
||||
@@ -41,6 +48,14 @@ export async function deliverItem(itemId: string): Promise<{ ok: boolean; messag
|
||||
const gallery = await galleryFor(it);
|
||||
const buf = await getObject(it.result_path);
|
||||
await uploadBuffer(cfg, gallery, it.filename || `${it.id}.png`, buf);
|
||||
// Optional: Metadaten als begleitende .md-Datei mitschicken.
|
||||
const md = await one<{ metadata_sidecar: boolean }>('SELECT metadata_sidecar FROM settings WHERE id=1');
|
||||
if (md?.metadata_sidecar) {
|
||||
try {
|
||||
const { buildMetadataMd, sidecarName } = await import('./metadata');
|
||||
await uploadBuffer(cfg, gallery, sidecarName(it.filename), Buffer.from(await buildMetadataMd(itemId), 'utf8'));
|
||||
} catch (e) { console.error('[delivery] Metadaten-Beileger fehlgeschlagen', e); }
|
||||
}
|
||||
await query(`UPDATE items SET delivery_status='delivered', delivered_at=now() WHERE id=$1`, [itemId]);
|
||||
return { ok: true, message: `Ausgeliefert nach „${gallery}".` };
|
||||
} catch (e: any) {
|
||||
|
||||
+4
-12
@@ -69,19 +69,11 @@ export async function purgeSources(): Promise<{ purged: number }> {
|
||||
return { purged };
|
||||
}
|
||||
|
||||
/** Spiegelt alle fertigen Bilder aufs NAS (einmalige Nachsicherung). */
|
||||
/** Spiegelt alle fertigen Bilder auf alle Backup-Ziele (NAS + is_backup-Ziele). */
|
||||
export async function mirrorAllToNas(): Promise<{ mirrored: number; failed: number; total: number }> {
|
||||
const { mirrorItemToNas, loadNasConfig } = await import('./nas');
|
||||
if (!(await loadNasConfig())) return { mirrored: 0, failed: 0, total: 0 };
|
||||
const rows = await query<{ id: string }>(
|
||||
`SELECT id FROM items WHERE status='done' AND result_path IS NOT NULL
|
||||
AND (nas_status IS NULL OR nas_status <> 'mirrored') ORDER BY created_at`);
|
||||
let mirrored = 0, failed = 0;
|
||||
for (const it of rows) {
|
||||
const r = await mirrorItemToNas(it.id);
|
||||
r.ok ? mirrored++ : failed++;
|
||||
}
|
||||
return { mirrored, failed, total: rows.length };
|
||||
const { backupAll } = await import('./backup');
|
||||
const r = await backupAll();
|
||||
return { mirrored: r.mirrored, failed: r.failed, total: r.items };
|
||||
}
|
||||
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { one } from './db';
|
||||
|
||||
/** Baut eine begleitende Metadaten-Datei (Markdown) zu einer Position. */
|
||||
export async function buildMetadataMd(itemId: string): Promise<string> {
|
||||
const it = await one<any>(
|
||||
`SELECT i.filename, i.output_px, i.has_alpha, i.model_used, i.prompt_used, i.cost,
|
||||
i.created_at, i.delivery_status, j.mode, j.recipe_snapshot
|
||||
FROM items i JOIN jobs j ON j.id=i.job_id WHERE i.id=$1`, [itemId]);
|
||||
if (!it) return '';
|
||||
const r = it.recipe_snapshot || {};
|
||||
const tasks = Array.isArray(r.tasks) ? r.tasks.join(', ') : '';
|
||||
const lines = [
|
||||
`# ${it.filename || itemId}`,
|
||||
'',
|
||||
`- **Erzeugt:** ${new Date(it.created_at).toISOString()}`,
|
||||
`- **Modus:** ${it.mode || 'each'}`,
|
||||
tasks ? `- **Aufgaben:** ${tasks}` : '',
|
||||
r.output_format ? `- **Format:** ${r.output_format}${r.orientation ? ` (${r.orientation})` : ''}` : '',
|
||||
it.output_px ? `- **Auflösung:** ${it.output_px} px${r.dpi ? ` @ ${r.dpi} dpi` : ''}` : '',
|
||||
`- **Transparenz:** ${it.has_alpha ? 'ja' : 'nein'}`,
|
||||
it.model_used ? `- **Modell:** ${it.model_used}` : '',
|
||||
it.cost != null ? `- **Kosten:** $${Number(it.cost).toFixed(4)}` : '',
|
||||
'',
|
||||
'## Prompt',
|
||||
'',
|
||||
'```',
|
||||
(it.prompt_used || '(kein Prompt gespeichert)'),
|
||||
'```',
|
||||
].filter((l) => l !== '');
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/** Dateiname des Beilegers: <ergebnis>.md */
|
||||
export function sidecarName(filename: string | null): string {
|
||||
const base = (filename || 'klarbild').replace(/\.[^.]+$/, '');
|
||||
return `${base}.md`;
|
||||
}
|
||||
+5
-7
@@ -91,7 +91,7 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
|
||||
// Prompt + ob das Modell überhaupt gebraucht wird, je Modus.
|
||||
let prompt: string;
|
||||
let needsModel: boolean;
|
||||
if (mode === 'compose') { prompt = buildComposePrompt(description, cropMode === 'extend'); needsModel = true; }
|
||||
if (mode === 'compose') { prompt = buildComposePrompt(description, cropMode === 'extend', sourceKeys.length); needsModel = true; }
|
||||
else if (mode === 'generate') { prompt = buildGeneratePrompt(description); needsModel = true; }
|
||||
else {
|
||||
prompt = buildPrompt({ tasks, cropMode, customInstruction: r.custom_instruction });
|
||||
@@ -161,13 +161,11 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
|
||||
for (const k of sourceKeys) await deleteObject(k).catch(() => {});
|
||||
}
|
||||
|
||||
// Zweite Sicherung auf NAS (best effort, blockiert den Erfolg nicht).
|
||||
if (cfg?.nas_enabled) {
|
||||
// Zweite Sicherung auf alle Backup-Ziele (NAS + is_backup-Ziele), best effort.
|
||||
try {
|
||||
const { mirrorItemToNas } = await import('./nas');
|
||||
await mirrorItemToNas(itemId);
|
||||
} catch (e) { console.error('[process] NAS-Spiegelung fehlgeschlagen', e); }
|
||||
}
|
||||
const { backupItem } = await import('./backup');
|
||||
await backupItem(itemId);
|
||||
} catch (e) { console.error('[process] Backup fehlgeschlagen', e); }
|
||||
|
||||
return { ok: true, cost };
|
||||
} catch (e: any) {
|
||||
|
||||
+14
-6
@@ -44,16 +44,24 @@ export function buildPrompt({ tasks, cropMode, customInstruction }: PromptOpts):
|
||||
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 = [
|
||||
/** Kombinieren/Umwandeln: eine ODER mehrere Vorlagen + Beschreibung → EIN neues Bild.
|
||||
* Bei einer Vorlage = Bild gemäß Anweisung umwandeln (z. B. Foto → Ölgemälde).
|
||||
* Bei mehreren = erste ist Leitbild, weitere liefern Elemente/Personen/Motive. */
|
||||
export function buildComposePrompt(description: string, extend = false, count = 2): string {
|
||||
const parts: string[] = [];
|
||||
if (count <= 1) {
|
||||
parts.push(
|
||||
'Transform the given image according to the instruction below into ONE new, ' +
|
||||
'high-resolution image. Keep the subject, composition and important details recognizable ' +
|
||||
'unless the instruction explicitly asks to change them.');
|
||||
} else {
|
||||
parts.push(
|
||||
'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.',
|
||||
];
|
||||
'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.');
|
||||
|
||||
+16
-1
@@ -1,14 +1,29 @@
|
||||
import { defineMiddleware } from 'astro:middleware';
|
||||
import { ensureInit } from './lib/init';
|
||||
import { readSession } from './lib/auth';
|
||||
import { one } from './lib/db';
|
||||
|
||||
const PUBLIC_PATHS = [/^\/login/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//, /^\/api\/telegram\/webhook/];
|
||||
|
||||
/** API-Token (Authorization: Bearer …) → synthetischer Admin-Nutzer für /api/*. */
|
||||
async function tokenUser(header: string | null): Promise<any | null> {
|
||||
const m = /^Bearer\s+(.+)$/i.exec(header || '');
|
||||
if (!m) return null;
|
||||
const s = await one<{ api_token: string | null }>('SELECT api_token FROM settings WHERE id=1');
|
||||
if (!s?.api_token || s.api_token !== m[1].trim()) return null;
|
||||
const admin = await one<{ id: string }>(`SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1`);
|
||||
return { uid: admin?.id || null, role: 'admin', name: 'API', username: 'api' };
|
||||
}
|
||||
|
||||
export const onRequest = defineMiddleware(async (ctx, next) => {
|
||||
// Health/Webhook dürfen laufen, auch wenn Init noch hakt — sonst blockiert nichts.
|
||||
try { await ensureInit(); } catch (e) { if (ctx.url.pathname !== '/api/health') throw e; }
|
||||
|
||||
const user = readSession(ctx.request.headers.get('cookie'));
|
||||
let user = readSession(ctx.request.headers.get('cookie'));
|
||||
// Programmatischer Zugriff per API-Token (nur für /api/*, außer Admin-Bereich).
|
||||
if (!user && ctx.url.pathname.startsWith('/api/') && !ctx.url.pathname.startsWith('/api/admin')) {
|
||||
user = await tokenUser(ctx.request.headers.get('authorization'));
|
||||
}
|
||||
ctx.locals.user = user;
|
||||
|
||||
const path = ctx.url.pathname;
|
||||
|
||||
@@ -8,12 +8,12 @@ const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const rows = await query<any>(`SELECT id, name, protocol, host, port, username, base_path,
|
||||
const rows = await query<any>(`SELECT id, name, protocol, host, port, username, base_path, is_backup,
|
||||
(password_enc IS NOT NULL) AS password_set FROM delivery_targets ORDER BY name`);
|
||||
return json({ targets: rows });
|
||||
};
|
||||
|
||||
// POST: anlegen | { action:'test', id } testen
|
||||
// POST: anlegen | { action:'test', id } | { action:'backup_all' }
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const b = await request.json();
|
||||
if (b.action === 'test') {
|
||||
@@ -21,12 +21,16 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
if (!cfg) return json({ ok: false, message: 'Ziel unvollständig konfiguriert.' });
|
||||
return json(await testConnection(cfg));
|
||||
}
|
||||
if (b.action === 'backup_all') {
|
||||
const { backupAll } = await import('../../../lib/backup');
|
||||
return json(await backupAll());
|
||||
}
|
||||
if (!b.name?.trim()) return json({ error: 'Name fehlt.' }, 400);
|
||||
const row = await one<any>(
|
||||
`INSERT INTO delivery_targets (name, protocol, host, port, username, password_enc, base_path)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
|
||||
`INSERT INTO delivery_targets (name, protocol, host, port, username, password_enc, base_path, is_backup)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
|
||||
[b.name.trim(), b.protocol || 'sftp', b.host || null, b.port || null, b.username || null,
|
||||
b.password ? encrypt(String(b.password)) : null, b.base_path || null]);
|
||||
b.password ? encrypt(String(b.password)) : null, b.base_path || null, !!b.is_backup]);
|
||||
return json({ id: row!.id });
|
||||
};
|
||||
|
||||
@@ -39,6 +43,7 @@ export const PATCH: APIRoute = async ({ request }) => {
|
||||
for (const c of ['name', 'protocol', 'host', 'port', 'username', 'base_path']) {
|
||||
if (c in b) set(c, b[c] === '' ? null : b[c]);
|
||||
}
|
||||
if ('is_backup' in b) set('is_backup', !!b.is_backup);
|
||||
if (b.password) set('password_enc', encrypt(String(b.password)));
|
||||
if (!sets.length) return json({ ok: true });
|
||||
args.push(b.id);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { one, query } from '../../../lib/db';
|
||||
import { encrypt, maskSecret, decrypt } from '../../../lib/crypto';
|
||||
|
||||
export const prerender = false;
|
||||
const randomToken = () => 'klb_' + randomBytes(24).toString('hex');
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
@@ -21,6 +23,7 @@ export const GET: APIRoute = async () => {
|
||||
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,
|
||||
metadata_sidecar: s.metadata_sidecar, api_token: s.api_token,
|
||||
// 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,
|
||||
@@ -35,10 +38,11 @@ export const PATCH: APIRoute = async ({ request }) => {
|
||||
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.nas_password) set('nas_password_enc', encrypt(String(b.nas_password)));
|
||||
const boolCols = ['keep_sources', 'make_thumbnails', 'nas_enabled'];
|
||||
if (b.generate_api_token) set('api_token', randomToken());
|
||||
const boolCols = ['keep_sources', 'make_thumbnails', 'nas_enabled', 'metadata_sidecar'];
|
||||
for (const col of ['picdrop_host', 'picdrop_protocol', 'picdrop_port', 'picdrop_user', 'picdrop_base_path',
|
||||
'picdrop_default_gallery', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url',
|
||||
'keep_sources', 'make_thumbnails', 'retention_days',
|
||||
'keep_sources', 'make_thumbnails', 'retention_days', 'metadata_sidecar', 'api_token',
|
||||
'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path']) {
|
||||
if (col in b) {
|
||||
const raw = b[col];
|
||||
|
||||
@@ -24,7 +24,7 @@ export const GET: APIRoute = async ({ url, locals }) => {
|
||||
|
||||
const rows = await query(
|
||||
`SELECT i.id, i.filename, i.output_px, i.has_alpha, i.result_path, i.thumb_path, i.folder_id,
|
||||
i.delivery_status, i.nas_status, i.created_at, i.model_used, i.variant_of, j.mode, u.display_name AS by_name,
|
||||
i.delivery_status, i.nas_status, i.prompt_used, i.created_at, i.model_used, i.variant_of, j.mode, u.display_name AS by_name,
|
||||
j.recipe_snapshot->'tasks' AS tasks
|
||||
FROM items i
|
||||
JOIN jobs j ON j.id = i.job_id
|
||||
|
||||
@@ -24,7 +24,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
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' && sources.length < 1) return json({ error: 'Mindestens 1 Bild nötig.' }, 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);
|
||||
|
||||
|
||||
@@ -16,10 +16,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const b = await request.json();
|
||||
const row = await one(
|
||||
`INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, contour_mm,
|
||||
model_key, delivery, picdrop_gallery, custom_instruction, is_default, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,false,$12) RETURNING *`,
|
||||
model_key, delivery, picdrop_gallery, delivery_target_id, custom_instruction, mode, is_default, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,false,$14) RETURNING *`,
|
||||
[b.name, JSON.stringify(b.tasks || []), b.output_format, b.orientation, b.crop_mode || 'crop',
|
||||
b.dpi || 300, b.contour_mm ?? null, b.model_key ?? null, b.delivery || 'library',
|
||||
b.picdrop_gallery ?? null, b.custom_instruction ?? null, locals.user.uid]);
|
||||
b.picdrop_gallery ?? null, b.delivery_target_id ?? null, b.custom_instruction ?? null,
|
||||
['each', 'compose', 'generate'].includes(b.mode) ? b.mode : 'each', locals.user.uid]);
|
||||
return json({ recipe: row });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { one, query } 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' } });
|
||||
|
||||
const COLS = ['name', 'tasks', 'output_format', 'orientation', 'crop_mode', 'dpi', 'contour_mm',
|
||||
'model_key', 'delivery', 'picdrop_gallery', 'delivery_target_id', 'custom_instruction', 'mode'];
|
||||
|
||||
export const PATCH: APIRoute = async ({ params, request, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const b = await request.json();
|
||||
const sets: string[] = []; const args: any[] = [];
|
||||
const set = (c: string, v: any) => { args.push(v); sets.push(`${c}=$${args.length}`); };
|
||||
for (const c of COLS) {
|
||||
if (c in b) set(c, c === 'tasks' ? JSON.stringify(b[c] || []) : (b[c] === '' ? null : b[c]));
|
||||
}
|
||||
if (!sets.length) return json({ error: 'Nichts zu ändern.' }, 400);
|
||||
args.push(params.id);
|
||||
const row = await one(`UPDATE recipes SET ${sets.join(',')} WHERE id=$${args.length} RETURNING *`, args);
|
||||
return json({ recipe: row });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ params, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
await query('DELETE FROM recipes WHERE id=$1', [params.id]);
|
||||
return json({ ok: true });
|
||||
};
|
||||
Reference in New Issue
Block a user