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:
@@ -0,0 +1,2 @@
|
||||
-- Konfigurierbare Standard-Picdrop-Galerie (statt fest verdrahtet).
|
||||
ALTER TABLE settings ADD COLUMN IF NOT EXISTS picdrop_default_gallery text;
|
||||
@@ -37,7 +37,7 @@ export default function AdminApp() {
|
||||
const saveSettings = async () => {
|
||||
const body: any = {
|
||||
picdrop_host: s.picdrop_host, picdrop_protocol: s.picdrop_protocol, picdrop_port: s.picdrop_port,
|
||||
picdrop_user: s.picdrop_user, picdrop_base_path: s.picdrop_base_path,
|
||||
picdrop_user: s.picdrop_user, picdrop_base_path: s.picdrop_base_path, picdrop_default_gallery: s.picdrop_default_gallery,
|
||||
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,
|
||||
};
|
||||
@@ -126,7 +126,11 @@ export default function AdminApp() {
|
||||
</div>
|
||||
<div className="feld"><label>Passwort {s.picdrop_password_set && <em>(gesetzt)</em>}</label>
|
||||
<input className="input" type="password" placeholder={s.picdrop_password_set ? '••••••' : ''} value={pdPw} onChange={(e) => setPdPw(e.target.value)} /></div>
|
||||
<div className="feld"><label>Basisordner</label><input className="input" value={s.picdrop_base_path ?? '/'} onChange={(e) => field('picdrop_base_path', e.target.value)} /></div>
|
||||
<div className="zwei">
|
||||
<div className="feld"><label>Basisordner (SFTP-Wurzel)</label><input className="input" value={s.picdrop_base_path ?? '/'} onChange={(e) => field('picdrop_base_path', e.target.value)} /></div>
|
||||
<div className="feld"><label>Standard-Galerie</label><input className="input" placeholder="POSTER LEA" value={s.picdrop_default_gallery ?? ''} onChange={(e) => field('picdrop_default_gallery', e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="fein">Bilder landen in <b>Basisordner / Galerie</b> — z. B. <code>/ + POSTER LEA</code>. Pro Ordner lässt sich später eine eigene Galerie mappen.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
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; }
|
||||
folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string; model_used: string; }
|
||||
|
||||
export default function LibraryApp() {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [folders, setFolders] = useState<any[]>([]);
|
||||
const [models, setModels] = useState<any[]>([]);
|
||||
const modelLabel = (id: string) => models.find((m) => m.model_id === id)?.label || null;
|
||||
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||
const [compare, setCompare] = useState<Item | null>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); };
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [i, f] = await Promise.all([
|
||||
const [i, f, m] = await Promise.all([
|
||||
fetch('/api/items').then((r) => r.json()).catch(() => ({ items: [] })),
|
||||
fetch('/api/folders').then((r) => r.json()).catch(() => ({ folders: [] })),
|
||||
fetch('/api/models').then((r) => r.json()).catch(() => ({ models: [] })),
|
||||
]);
|
||||
setItems(i.items || []); setFolders(f.folders || []);
|
||||
setItems(i.items || []); setFolders(f.folders || []); setModels(m.models || []);
|
||||
}, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
@@ -67,7 +70,7 @@ export default function LibraryApp() {
|
||||
<span className="haken">{sel.has(v.id) ? '✓' : ''}</span>
|
||||
</div>
|
||||
<input className="name" defaultValue={v.filename || ''} onBlur={(e) => rename(v.id, e.target.value)} />
|
||||
<div className="meta">{(v.tasks || []).join(' · ')}{v.output_px ? ` · ${v.output_px}px` : ''}{v.has_alpha ? ' · transparent' : ''}
|
||||
<div className="meta">{(v.tasks || []).join(' · ')}{v.output_px ? ` · ${v.output_px}px` : ''}{v.has_alpha ? ' · transparent' : ''}{modelLabel(v.model_used) ? ` · ${modelLabel(v.model_used)}` : ''}
|
||||
{v.delivery_status === 'delivered' && <span className="dbadge ok">Picdrop ✓</span>}
|
||||
{v.delivery_status === 'pending' && <span className="dbadge">Picdrop …</span>}
|
||||
{v.delivery_status === 'failed' && <span className="dbadge err">Picdrop ✕</span>}
|
||||
|
||||
@@ -44,6 +44,8 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
const [contourMm, setContourMm] = useState(3);
|
||||
const [custom, setCustom] = useState('');
|
||||
const [delivery, setDelivery] = useState<'library' | 'picdrop' | 'both'>('library');
|
||||
const [models, setModels] = useState<any[]>([]);
|
||||
const [modelKey, setModelKey] = useState<string>('');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
@@ -92,6 +94,14 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
return () => window.removeEventListener('paste', onPaste);
|
||||
}, [upload]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/models').then((r) => r.json()).then((j) => {
|
||||
const ms = j.models || []; setModels(ms);
|
||||
const def = ms.find((m: any) => m.is_default) || ms[0];
|
||||
if (def) setModelKey(def.model_id);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
function toggleTask(id: string) {
|
||||
setTasks((t) => {
|
||||
let next = t.includes(id) ? t.filter((x) => x !== id) : [...t, id];
|
||||
@@ -110,6 +120,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
setContourMm(Number(r.contour_mm) || 3);
|
||||
setCustom(r.custom_instruction || '');
|
||||
setDelivery(r.delivery || 'library');
|
||||
if (r.model_key) setModelKey(r.model_key);
|
||||
}
|
||||
|
||||
const ready = pics.filter((p) => p.source_path && !p.error);
|
||||
@@ -125,7 +136,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
orientation: portrait ? 'portrait' : 'landscape',
|
||||
crop_mode: crop, dpi: 300,
|
||||
contour_mm: tasks.includes('contour') ? contourMm : null,
|
||||
custom_instruction: custom || null, delivery,
|
||||
custom_instruction: custom || null, delivery, model_key: modelKey || null,
|
||||
};
|
||||
try {
|
||||
const res = await fetch('/api/jobs', {
|
||||
@@ -234,6 +245,19 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{models.length > 0 && (
|
||||
<div className="feld">
|
||||
<label>Qualität</label>
|
||||
<div className="schalter wrap">
|
||||
{models.map((m) => (
|
||||
<button key={m.model_id} className={modelKey === m.model_id ? 'an' : ''}
|
||||
onClick={() => setModelKey(m.model_id)} title={m.description || ''}>{m.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="fein">{models.find((m) => m.model_id === modelKey)?.description || ''}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="feld">
|
||||
<label>Eigene Anweisung (optional)</label>
|
||||
<textarea className="area" rows={2} value={custom} onChange={(e) => setCustom(e.target.value)}
|
||||
@@ -300,6 +324,8 @@ function StudioStyles() {
|
||||
.schalter button{flex:1;background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px;cursor:pointer;font-family:inherit;font-size:13px;color:var(--soft);}
|
||||
.schalter button.an{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;}
|
||||
.schalter.zart button{font-size:12.5px;}
|
||||
.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;}
|
||||
.knopf:disabled{background:#C9C8BF;cursor:not-allowed;}
|
||||
.spin{width:13px;height:13px;display:inline-block;border:2px solid #fff;border-top-color:transparent;border-radius:50%;vertical-align:-2px;margin-right:9px;animation:sp 1s linear infinite;}
|
||||
|
||||
+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'`);
|
||||
|
||||
@@ -15,6 +15,7 @@ export const GET: APIRoute = async () => {
|
||||
openrouter_key_masked: orMask, openrouter_key_set: !!s.openrouter_key_enc,
|
||||
picdrop_host: s.picdrop_host, picdrop_protocol: s.picdrop_protocol, picdrop_port: s.picdrop_port,
|
||||
picdrop_user: s.picdrop_user, picdrop_base_path: s.picdrop_base_path,
|
||||
picdrop_default_gallery: s.picdrop_default_gallery,
|
||||
picdrop_password_set: !!s.picdrop_password_enc,
|
||||
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,
|
||||
@@ -29,7 +30,7 @@ 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)));
|
||||
for (const col of ['picdrop_host', 'picdrop_protocol', 'picdrop_port', 'picdrop_user', 'picdrop_base_path',
|
||||
'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url']) {
|
||||
'picdrop_default_gallery', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url']) {
|
||||
if (col in b) set(col, b[col] === '' ? null : b[col]);
|
||||
}
|
||||
if (!sets.length) return json({ ok: true });
|
||||
|
||||
@@ -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.folder_id,
|
||||
i.delivery_status, i.created_at, u.display_name AS by_name,
|
||||
i.delivery_status, i.created_at, i.model_used, u.display_name AS by_name,
|
||||
j.recipe_snapshot->'tasks' AS tasks
|
||||
FROM items i
|
||||
JOIN jobs j ON j.id = i.job_id
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { query } from '../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// Aktive Modelle mit freundlichen Namen (für Studio-Auswahl + Ergebnis-Anzeige).
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const models = await query(
|
||||
`SELECT model_id, label, description, is_default, supports_alpha FROM models
|
||||
WHERE active ORDER BY sort, label`);
|
||||
return new Response(JSON.stringify({ models }), { headers: { 'Content-Type': 'application/json' } });
|
||||
};
|
||||
Reference in New Issue
Block a user