feat: model selection+visibility, better filenames, picdrop direct upload + configurable gallery

- studio: quality/model selector (friendly names from active models); library shows model used
- seed 2 models (Schnell/Beste Qualitaet); GET /api/models for studio+library
- filename: YYYY-MM-DD_motif_format.png (was redundant slug, no date)
- picdrop: direct upload to final name (fix .tmp- leftover from failed rename on picdrop); cleanupTmp helper
- configurable default gallery (migration 003, settings + admin field); base_path=SFTP root, gallery=subfolder
This commit is contained in:
2026-07-23 18:54:37 +00:00
parent b047f55687
commit 6d58c6c8fd
12 changed files with 120 additions and 28 deletions
+4 -2
View File
@@ -6,13 +6,15 @@ import { loadConfig, uploadBuffer } from './picdrop';
const DEFAULT_GALLERY = process.env.PICDROP_DEFAULT_GALLERY || 'POSTER LEA';
async function galleryFor(item: { folder_id: string | null; job_id: string }): Promise<string> {
// 1) Ordner-Mapping 2) Rezept 3) konfigurierte Standard-Galerie 4) Fallback-Konstante
if (item.folder_id) {
const f = await one<{ picdrop_gallery: string | null }>('SELECT picdrop_gallery FROM folders WHERE id=$1', [item.folder_id]);
if (f?.picdrop_gallery) return f.picdrop_gallery;
}
const j = await one<{ recipe_snapshot: any }>('SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]);
const g = j?.recipe_snapshot?.picdrop_gallery;
return g || DEFAULT_GALLERY;
if (j?.recipe_snapshot?.picdrop_gallery) return j.recipe_snapshot.picdrop_gallery;
const s = await one<{ picdrop_default_gallery: string | null }>('SELECT picdrop_default_gallery FROM settings WHERE id=1');
return s?.picdrop_default_gallery || DEFAULT_GALLERY;
}
/** Liefert ein fertiges Item an die passende Picdrop-Galerie aus. */
+20 -6
View File
@@ -44,11 +44,11 @@ export async function testConnection(cfg: PicdropCfg): Promise<{ ok: boolean; me
}
}
/** Lädt einen Puffer als Datei hoch (temp-Name → umbenennen, kein .filepart). */
/** Lädt einen Puffer direkt unter dem finalen Namen hoch.
* (Picdrop verträgt das Temp→Rename-Muster nicht — es blieben `.tmp-…`-Dateien liegen.) */
export async function uploadBuffer(cfg: PicdropCfg, gallery: string, filename: string, buf: Buffer): Promise<void> {
const dir = posixpath.join(cfg.basePath || '/', gallery || '');
const finalPath = posixpath.join(dir, filename);
const tmpPath = posixpath.join(dir, `.tmp-${Date.now()}-${filename}`);
const { Readable } = await import('node:stream');
if (cfg.protocol === 'sftp') {
@@ -57,8 +57,7 @@ export async function uploadBuffer(cfg: PicdropCfg, gallery: string, filename: s
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, tmpPath);
await c.rename(tmpPath, finalPath);
await c.put(buf, finalPath);
} finally { await c.end(); }
} else {
const { Client } = await import('basic-ftp');
@@ -66,8 +65,23 @@ export async function uploadBuffer(cfg: PicdropCfg, gallery: string, filename: s
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), tmpPath);
await c.rename(tmpPath, finalPath);
await c.uploadFrom(Readable.from(buf), finalPath);
} finally { c.close(); }
}
}
/** Löscht `.tmp-…`-Altlasten aus einer Galerie (einmalige Aufräumhilfe). */
export async function cleanupTmp(cfg: PicdropCfg, gallery: string): Promise<number> {
const dir = posixpath.join(cfg.basePath || '/', gallery || '');
let n = 0;
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 {
const list = await c.list(dir);
for (const e of list) if (e.name.startsWith('.tmp-')) { await c.delete(posixpath.join(dir, e.name)); n++; }
} finally { await c.end(); }
}
return n;
}
+24 -6
View File
@@ -82,13 +82,31 @@ export async function heicToPng(buffer: Buffer): Promise<Buffer> {
return Buffer.from(out);
}
/** Deutscher Datei-Slug: klein, mit Bindestrichen, Format angehängt. */
export function slugFilename(base: string, formatLabel: string, ext = 'png'): string {
const slug = (base || 'bild')
/** Klein, mit Bindestrichen, ohne Umlaut-Probleme. */
export function slugify(s: string): string {
return (s || '')
.toLowerCase()
.replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] || c))
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'bild';
const fmt = formatLabel.toLowerCase().replace(/[^a-z0-9]+/g, '');
return `${slug}${fmt ? '-' + fmt : ''}.${ext}`;
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);
}
const GENERIC = new Set(['', 'foto', 'photo', 'bild', 'image', 'img', 'telegram', 'unbenannt', 'klarbild', 'screenshot', 'pxl', 'whatsapp']);
/** Logischer Dateiname: JJJJ-MM-TT_<motiv>_<format>.<ext>
* z. B. 2026-07-23_lea_the-frame.png */
export function buildResultFilename(originalName: string | null, formatToken: string, ext = 'png'): string {
const raw = slugify((originalName || '').replace(/\.[^.]+$/, ''));
const motif = GENERIC.has(raw) ? 'klarbild' : raw;
const date = new Date().toISOString().slice(0, 10);
const fmt = slugify(formatToken) || 'original';
return `${date}_${motif}_${fmt}.${ext}`;
}
/** Formatschlüssel für den Dateinamen (aus dem output_format). */
export function formatToken(outputFormat: string | undefined): string {
if (!outputFormat || outputFormat === 'keep') return 'original';
if (outputFormat === 'theframe') return 'the-frame';
if (outputFormat === 'hochformat') return 'hochformat';
return outputFormat.toLowerCase(); // 30x40, a4, sticker, …
}
+4 -5
View File
@@ -3,7 +3,7 @@ import { getObject, putObject, resultKey } from './storage';
import { generateImage } from './openrouter';
import { buildPrompt, type Task } from './prompts';
import { resolveDimensions, aspectRatio, type Orientation } from './format';
import { finalizeToFormat, stickerContour, slugFilename, type CropMode } from './pipeline';
import { finalizeToFormat, stickerContour, buildResultFilename, formatToken, type CropMode } from './pipeline';
import sharp from 'sharp';
const DEFAULT_MODEL = 'google/gemini-3.1-flash-image';
@@ -46,8 +46,8 @@ export interface ProcessResult { ok: boolean; cost: number; error?: string; }
/** Verarbeitet eine Position vollständig und aktualisiert ihren Datensatz. */
export async function processItem(itemId: string): Promise<ProcessResult> {
const item = await one<{ id: string; job_id: string; source_path: string }>(
'SELECT id, job_id, source_path FROM items WHERE id=$1', [itemId]);
const item = await one<{ id: string; job_id: string; source_path: string; filename: string | null }>(
'SELECT id, job_id, source_path, filename FROM items WHERE id=$1', [itemId]);
if (!item) return { ok: false, cost: 0, error: 'Position nicht gefunden' };
const job = await one<{ recipe_snapshot: RecipeSnapshot }>(
@@ -105,8 +105,7 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
const key = resultKey(item.id);
await putObject(key, outBuf, 'image/png');
const label = target?.label || 'original';
const filename = slugFilename(label.replace(/\s+/g, '-'), target?.label || '');
const filename = buildResultFilename(item.filename, formatToken(r.output_format), 'png');
const outputPx = `${fin.width}x${fin.height}`;
const deliveryStatus = (r.delivery === 'picdrop' || r.delivery === 'both') ? 'pending' : 'none';
+10
View File
@@ -5,6 +5,16 @@ import { hashPassword } from './auth';
export async function seed(): Promise<void> {
// Einstellungen (eine Zeile)
await query(`INSERT INTO settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`);
// Standard-Galerie vorbelegen (wie initial gebrieft), falls leer
await query(`UPDATE settings SET picdrop_default_gallery='POSTER LEA' WHERE id=1 AND picdrop_default_gallery IS NULL`);
// Modelle vorbelegen (freundliche Namen, keine rohen IDs in der UI)
const mCount = await one<{ n: string }>(`SELECT count(*)::text AS n FROM models`);
if (mCount && Number(mCount.n) === 0) {
await query(`INSERT INTO models (model_id, label, description, active, is_default, supports_alpha, sort) VALUES
('google/gemini-3.1-flash-image','Schnell','In wenigen Sekunden fertig. Für den Alltag.',true,true,true,0),
('google/gemini-3-pro-image','Beste Qualität','Dauert länger, trifft Schrift und Details genauer.',true,false,true,1)`);
}
// Nutzer
const till = await one(`SELECT id FROM users WHERE username='till'`);