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,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`;
|
||||
}
|
||||
+6
-8
@@ -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
@@ -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.');
|
||||
|
||||
Reference in New Issue
Block a user