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:
2026-07-23 21:29:43 +00:00
parent 5cfafb456c
commit 295f0ce91e
19 changed files with 479 additions and 69 deletions
+71
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+37
View File
@@ -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`;
}
+6 -8
View File
@@ -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) {
try {
const { mirrorItemToNas } = await import('./nas');
await mirrorItemToNas(itemId);
} catch (e) { console.error('[process] NAS-Spiegelung fehlgeschlagen', e); }
}
// Zweite Sicherung auf alle Backup-Ziele (NAS + is_backup-Ziele), best effort.
try {
const { backupItem } = await import('./backup');
await backupItem(itemId);
} catch (e) { console.error('[process] Backup fehlgeschlagen', e); }
return { ok: true, cost };
} catch (e: any) {
+18 -10
View File
@@ -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 = [
'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.',
];
/** 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.');
}
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.');