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:
+4
-2
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
|
||||
@@ -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'`);
|
||||
|
||||
Reference in New Issue
Block a user