feat: Studio mode selector + Telegram compose/generate (full parity)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
+209
-140
@@ -31,11 +31,19 @@ const TASKS = [
|
|||||||
{ id: 'contour', name: 'Kontur', hint: 'Weißen Stickerrand anlegen (nur mit Freistellen).' },
|
{ id: 'contour', name: 'Kontur', hint: 'Weißen Stickerrand anlegen (nur mit Freistellen).' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type Mode = 'each' | 'compose' | 'generate';
|
||||||
|
const MODES: { id: Mode; name: string; hint: string }[] = [
|
||||||
|
{ id: 'each', name: 'Bearbeiten', hint: 'Screenshots bereinigen, freistellen, aufs Format bringen.' },
|
||||||
|
{ id: 'compose', name: 'Kombinieren', hint: 'Mehrere Bilder + Beschreibung zu einem neuen Bild.' },
|
||||||
|
{ id: 'generate', name: 'Neu erzeugen', hint: 'Ein komplett neues Bild allein aus Text.' },
|
||||||
|
];
|
||||||
|
|
||||||
const px = (cm: number, dpi = 300) => Math.round((cm / 2.54) * dpi);
|
const px = (cm: number, dpi = 300) => Math.round((cm / 2.54) * dpi);
|
||||||
|
|
||||||
interface Pic { id: string; src: string; name: string; source_path?: string; error?: string; uploading?: boolean }
|
interface Pic { id: string; src: string; name: string; source_path?: string; error?: string; uploading?: boolean }
|
||||||
|
|
||||||
export default function StudioApp({ recipes }: { recipes: any[] }) {
|
export default function StudioApp({ recipes }: { recipes: any[] }) {
|
||||||
|
const [mode, setMode] = useState<Mode>('each');
|
||||||
const [pics, setPics] = useState<Pic[]>([]);
|
const [pics, setPics] = useState<Pic[]>([]);
|
||||||
const [tasks, setTasks] = useState<string[]>(['clean', 'format']);
|
const [tasks, setTasks] = useState<string[]>(['clean', 'format']);
|
||||||
const [format, setFormat] = useState('30x40');
|
const [format, setFormat] = useState('30x40');
|
||||||
@@ -43,6 +51,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
|||||||
const [crop, setCrop] = useState<'crop' | 'extend'>('crop');
|
const [crop, setCrop] = useState<'crop' | 'extend'>('crop');
|
||||||
const [contourMm, setContourMm] = useState(3);
|
const [contourMm, setContourMm] = useState(3);
|
||||||
const [custom, setCustom] = useState('');
|
const [custom, setCustom] = useState('');
|
||||||
|
const [desc, setDesc] = useState('');
|
||||||
const [delivery, setDelivery] = useState<'library' | 'picdrop' | 'both'>('library');
|
const [delivery, setDelivery] = useState<'library' | 'picdrop' | 'both'>('library');
|
||||||
const [models, setModels] = useState<any[]>([]);
|
const [models, setModels] = useState<any[]>([]);
|
||||||
const [modelKey, setModelKey] = useState<string>('');
|
const [modelKey, setModelKey] = useState<string>('');
|
||||||
@@ -55,6 +64,9 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
|||||||
const hasCutout = tasks.includes('cutout');
|
const hasCutout = tasks.includes('cutout');
|
||||||
const hasFormat = tasks.includes('format');
|
const hasFormat = tasks.includes('format');
|
||||||
const theframeConflict = hasCutout && (format === 'theframe' || format === 'hochformat');
|
const theframeConflict = hasCutout && (format === 'theframe' || format === 'hochformat');
|
||||||
|
// Format ist in compose/generate immer verfügbar (ohne „Format"-Aufgabe).
|
||||||
|
const showFormat = mode === 'each' ? hasFormat : true;
|
||||||
|
const wantsFormat = mode === 'each' ? hasFormat : format !== 'keep';
|
||||||
|
|
||||||
const zielPx: [number, number] | null = fmt?.screen
|
const zielPx: [number, number] | null = fmt?.screen
|
||||||
? (fmt.screen as [number, number])
|
? (fmt.screen as [number, number])
|
||||||
@@ -87,12 +99,13 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onPaste = (e: ClipboardEvent) => {
|
const onPaste = (e: ClipboardEvent) => {
|
||||||
|
if (mode === 'generate') return;
|
||||||
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith('image/'));
|
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith('image/'));
|
||||||
if (imgs.length) upload(imgs.map((i) => i.getAsFile()!).filter(Boolean));
|
if (imgs.length) upload(imgs.map((i) => i.getAsFile()!).filter(Boolean));
|
||||||
};
|
};
|
||||||
window.addEventListener('paste', onPaste);
|
window.addEventListener('paste', onPaste);
|
||||||
return () => window.removeEventListener('paste', onPaste);
|
return () => window.removeEventListener('paste', onPaste);
|
||||||
}, [upload]);
|
}, [upload, mode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/models').then((r) => r.json()).then((j) => {
|
fetch('/api/models').then((r) => r.json()).then((j) => {
|
||||||
@@ -105,8 +118,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
|||||||
function toggleTask(id: string) {
|
function toggleTask(id: string) {
|
||||||
setTasks((t) => {
|
setTasks((t) => {
|
||||||
let next = t.includes(id) ? t.filter((x) => x !== id) : [...t, id];
|
let next = t.includes(id) ? t.filter((x) => x !== id) : [...t, id];
|
||||||
if (id === 'cutout' && !next.includes('cutout')) next = next.filter((x) => x !== 'contour'); // Kontur braucht Freistellen
|
if (id === 'cutout' && !next.includes('cutout')) next = next.filter((x) => x !== 'contour');
|
||||||
if (id === 'contour' && next.includes('contour') && !next.includes('cutout')) next = next; // wird per disabled verhindert
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -124,24 +136,34 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ready = pics.filter((p) => p.source_path && !p.error);
|
const ready = pics.filter((p) => p.source_path && !p.error);
|
||||||
const canRun = ready.length > 0 && !busy && tasks.length > 0 && !theframeConflict &&
|
const canRun = !busy && (
|
||||||
!(tasks.includes('contour') && !hasCutout);
|
mode === 'each' ? (ready.length > 0 && tasks.length > 0 && !theframeConflict && !(tasks.includes('contour') && !hasCutout))
|
||||||
|
: mode === 'compose' ? (ready.length >= 2 && desc.trim().length > 0)
|
||||||
|
: desc.trim().length > 0
|
||||||
|
);
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (!canRun) return;
|
if (!canRun) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
const order = ['clean', 'cutout', 'format', 'contour', 'deliver'];
|
||||||
|
const tasksOut = mode === 'each'
|
||||||
|
? [...tasks].sort((a, b) => order.indexOf(a) - order.indexOf(b))
|
||||||
|
: (wantsFormat ? ['format'] : []);
|
||||||
const recipe = {
|
const recipe = {
|
||||||
tasks: [...tasks].sort((a, b) => ['clean', 'cutout', 'format', 'contour', 'deliver'].indexOf(a) - ['clean', 'cutout', 'format', 'contour', 'deliver'].indexOf(b)),
|
tasks: tasksOut,
|
||||||
output_format: hasFormat ? format : 'keep',
|
output_format: wantsFormat ? format : 'keep',
|
||||||
orientation: portrait ? 'portrait' : 'landscape',
|
orientation: portrait ? 'portrait' : 'landscape',
|
||||||
crop_mode: crop, dpi: 300,
|
crop_mode: crop, dpi: 300,
|
||||||
contour_mm: tasks.includes('contour') ? contourMm : null,
|
contour_mm: tasks.includes('contour') ? contourMm : null,
|
||||||
custom_instruction: custom || null, delivery, model_key: modelKey || null,
|
custom_instruction: mode === 'each' ? (custom || null) : null,
|
||||||
|
delivery, model_key: modelKey || null,
|
||||||
};
|
};
|
||||||
|
const body: any = { recipe, mode, delivery };
|
||||||
|
if (mode !== 'each') body.prompt_text = desc.trim();
|
||||||
|
body.sources = mode === 'generate' ? [] : ready.map((p) => ({ source_path: p.source_path, filename: p.name }));
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/jobs', {
|
const res = await fetch('/api/jobs', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||||
body: JSON.stringify({ recipe, sources: ready.map((p) => ({ source_path: p.source_path, filename: p.name })) }),
|
|
||||||
});
|
});
|
||||||
const j = await res.json();
|
const j = await res.json();
|
||||||
if (j.jobId) location.href = `/warteschlange?job=${j.jobId}`;
|
if (j.jobId) location.href = `/warteschlange?job=${j.jobId}`;
|
||||||
@@ -149,136 +171,175 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
|||||||
} catch { notify('Netzwerkfehler.'); setBusy(false); }
|
} catch { notify('Netzwerkfehler.'); setBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadHint = mode === 'compose'
|
||||||
|
? 'Mindestens 2 Bilder — z. B. das Motiv und ein Foto von Frieda.'
|
||||||
|
: 'Mehrere gleichzeitig, bis zu 100. PNG, JPG, WEBP, HEIC.';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="studio">
|
<div className="studio">
|
||||||
<section className="karte">
|
{/* Modus-Umschalter */}
|
||||||
<div className="kopfzeile">
|
<div className="modus-leiste">
|
||||||
<span className="mono-label">Vorlagen{pics.length ? ` · ${pics.length}` : ''}</span>
|
{MODES.map((m) => (
|
||||||
{pics.length > 0 && <button className="link" onClick={() => setPics([])}>alle entfernen</button>}
|
<button key={m.id} className={`modus-tab ${mode === m.id ? 'an' : ''}`} onClick={() => setMode(m.id)}>
|
||||||
</div>
|
<b>{m.name}</b><span>{m.hint}</span>
|
||||||
<div className="buehne">
|
|
||||||
{pics.length === 0 ? (
|
|
||||||
<div className={`drop ${dragging ? 'drag' : ''}`}
|
|
||||||
onClick={() => fileRef.current?.click()}
|
|
||||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
|
||||||
onDragLeave={() => setDragging(false)}
|
|
||||||
onDrop={(e) => { e.preventDefault(); setDragging(false); upload(e.dataTransfer.files); }}>
|
|
||||||
<span className="regmark" />
|
|
||||||
<p>Bilder hierher ziehen,<br />mit <span className="kbd">⌘/Strg + V</span> einfügen<br />oder zum Auswählen tippen.</p>
|
|
||||||
<p className="fein">Mehrere gleichzeitig, bis zu 100. PNG, JPG, WEBP, HEIC.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mini-raster">
|
|
||||||
{pics.map((b) => (
|
|
||||||
<div key={b.id} className={`mini-bild ${b.error ? 'err' : ''}`}>
|
|
||||||
{b.src && <img src={b.src} alt="" />}
|
|
||||||
{b.uploading && <span className="lade" />}
|
|
||||||
{b.error && <span className="badge">!</span>}
|
|
||||||
<button onClick={() => setPics((x) => x.filter((y) => y.id !== b.id))}>×</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button className="mini-add" onClick={() => fileRef.current?.click()}>+</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden
|
|
||||||
onChange={(e) => e.target.files && upload(e.target.files)} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="karte">
|
|
||||||
<div className="steuer">
|
|
||||||
{recipes?.length > 0 && (
|
|
||||||
<div className="feld">
|
|
||||||
<label>Rezept laden</label>
|
|
||||||
<select className="select" onChange={(e) => applyRecipe(recipes.find((r) => r.id === e.target.value))} defaultValue="">
|
|
||||||
<option value="" disabled>Voreinstellung wählen …</option>
|
|
||||||
{recipes.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="feld">
|
|
||||||
<label>Was soll passieren?</label>
|
|
||||||
<div className="modi">
|
|
||||||
{TASKS.map((m) => {
|
|
||||||
const on = tasks.includes(m.id);
|
|
||||||
const disabled = m.id === 'contour' && !hasCutout;
|
|
||||||
return (
|
|
||||||
<button key={m.id} disabled={disabled}
|
|
||||||
className={`modus ${on ? 'an' : ''} ${disabled ? 'aus' : ''}`}
|
|
||||||
onClick={() => toggleTask(m.id)}>
|
|
||||||
<b>{m.name}{on ? ' ✓' : ''}</b><span>{m.hint}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasFormat && (
|
|
||||||
<div className="feld">
|
|
||||||
<label>Ausgabeformat</label>
|
|
||||||
<select className="select" value={format} onChange={(e) => setFormat(e.target.value)}>
|
|
||||||
{FORMATS.map((f) => <option key={f.id} value={f.id}>{f.label}</option>)}
|
|
||||||
</select>
|
|
||||||
{theframeConflict && <div className="fein warn">Freistellen + The Frame ergibt keinen Sinn — bitte eins wählen.</div>}
|
|
||||||
{fmt?.cm && !fmt.screen && (
|
|
||||||
<div className="schalter">
|
|
||||||
<button className={portrait ? 'an' : ''} onClick={() => setPortrait(true)}>Hochformat</button>
|
|
||||||
<button className={!portrait ? 'an' : ''} onClick={() => setPortrait(false)}>Querformat</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{zielPx && <div className="fein">Ergibt exakt {zielPx[0]} × {zielPx[1]} Pixel bei 300 dpi — druckfertig.</div>}
|
|
||||||
{zielPx && (
|
|
||||||
<div className="schalter zart">
|
|
||||||
<button className={crop === 'crop' ? 'an' : ''} onClick={() => setCrop('crop')}>Zuschneiden</button>
|
|
||||||
<button className={crop === 'extend' ? 'an' : ''} onClick={() => setCrop('extend')}>Ränder ergänzen</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tasks.includes('contour') && (
|
|
||||||
<div className="feld">
|
|
||||||
<label>Stickerrand (mm)</label>
|
|
||||||
<input className="input" type="number" min={0} max={20} step={0.5}
|
|
||||||
value={contourMm} onChange={(e) => setContourMm(Number(e.target.value))} />
|
|
||||||
</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)}
|
|
||||||
placeholder="z. B. „Mach den Hintergrund heller, sonst alles lassen.“" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="feld">
|
|
||||||
<label>Wohin?</label>
|
|
||||||
<div className="schalter">
|
|
||||||
<button className={delivery === 'library' ? 'an' : ''} onClick={() => setDelivery('library')}>Bibliothek</button>
|
|
||||||
<button className={delivery === 'picdrop' ? 'an' : ''} onClick={() => setDelivery('picdrop')}>Picdrop</button>
|
|
||||||
<button className={delivery === 'both' ? 'an' : ''} onClick={() => setDelivery('both')}>Beides</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="knopf" disabled={!canRun} onClick={run}>
|
|
||||||
{busy ? <><span className="spin" />Wird angelegt …</> : `Loslegen${ready.length ? ` · ${ready.length} Bild${ready.length > 1 ? 'er' : ''}` : ''}`}
|
|
||||||
</button>
|
</button>
|
||||||
<div className="fein mitte">Läuft serverseitig weiter — du kannst das Fenster schließen.</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<div className="raster">
|
||||||
|
{mode !== 'generate' && (
|
||||||
|
<section className="karte">
|
||||||
|
<div className="kopfzeile">
|
||||||
|
<span className="mono-label">{mode === 'compose' ? 'Bausteine' : 'Vorlagen'}{pics.length ? ` · ${pics.length}` : ''}</span>
|
||||||
|
{pics.length > 0 && <button className="link" onClick={() => setPics([])}>alle entfernen</button>}
|
||||||
|
</div>
|
||||||
|
<div className="buehne">
|
||||||
|
{pics.length === 0 ? (
|
||||||
|
<div className={`drop ${dragging ? 'drag' : ''}`}
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||||
|
onDragLeave={() => setDragging(false)}
|
||||||
|
onDrop={(e) => { e.preventDefault(); setDragging(false); upload(e.dataTransfer.files); }}>
|
||||||
|
<span className="regmark" />
|
||||||
|
<p>Bilder hierher ziehen,<br />mit <span className="kbd">⌘/Strg + V</span> einfügen<br />oder zum Auswählen tippen.</p>
|
||||||
|
<p className="fein">{uploadHint}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mini-raster">
|
||||||
|
{pics.map((b) => (
|
||||||
|
<div key={b.id} className={`mini-bild ${b.error ? 'err' : ''}`}>
|
||||||
|
{b.src && <img src={b.src} alt="" />}
|
||||||
|
{b.uploading && <span className="lade" />}
|
||||||
|
{b.error && <span className="badge">!</span>}
|
||||||
|
<button onClick={() => setPics((x) => x.filter((y) => y.id !== b.id))}>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button className="mini-add" onClick={() => fileRef.current?.click()}>+</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden
|
||||||
|
onChange={(e) => e.target.files && upload(e.target.files)} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="karte">
|
||||||
|
<div className="steuer">
|
||||||
|
{mode === 'each' && recipes?.length > 0 && (
|
||||||
|
<div className="feld">
|
||||||
|
<label>Rezept laden</label>
|
||||||
|
<select className="select" onChange={(e) => applyRecipe(recipes.find((r) => r.id === e.target.value))} defaultValue="">
|
||||||
|
<option value="" disabled>Voreinstellung wählen …</option>
|
||||||
|
{recipes.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(mode === 'compose' || mode === 'generate') && (
|
||||||
|
<div className="feld">
|
||||||
|
<label>{mode === 'compose' ? 'Was soll entstehen?' : 'Beschreibung des neuen Bildes'}</label>
|
||||||
|
<textarea className="area gross" rows={4} value={desc} onChange={(e) => setDesc(e.target.value)}
|
||||||
|
placeholder={mode === 'compose'
|
||||||
|
? 'z. B. „Das Poolbild, aber mit unserer Hündin Frieda am Beckenrand.“'
|
||||||
|
: 'z. B. „Ein minimalistisches Poster mit einem Olivenzweig auf sandfarbenem Grund.“'} />
|
||||||
|
<div className="fein">{mode === 'compose'
|
||||||
|
? 'Das erste Bild ist die Leitszene, weitere liefern Personen/Motive.'
|
||||||
|
: 'Je genauer die Beschreibung, desto besser das Ergebnis.'}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'each' && (
|
||||||
|
<div className="feld">
|
||||||
|
<label>Was soll passieren?</label>
|
||||||
|
<div className="modi">
|
||||||
|
{TASKS.map((m) => {
|
||||||
|
const on = tasks.includes(m.id);
|
||||||
|
const disabled = m.id === 'contour' && !hasCutout;
|
||||||
|
return (
|
||||||
|
<button key={m.id} disabled={disabled}
|
||||||
|
className={`modus ${on ? 'an' : ''} ${disabled ? 'aus' : ''}`}
|
||||||
|
onClick={() => toggleTask(m.id)}>
|
||||||
|
<b>{m.name}{on ? ' ✓' : ''}</b><span>{m.hint}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFormat && (
|
||||||
|
<div className="feld">
|
||||||
|
<label>{mode === 'each' ? 'Ausgabeformat' : 'Format (optional)'}</label>
|
||||||
|
<select className="select" value={format} onChange={(e) => setFormat(e.target.value)}>
|
||||||
|
{FORMATS.map((f) => <option key={f.id} value={f.id}>{f.label}</option>)}
|
||||||
|
</select>
|
||||||
|
{theframeConflict && <div className="fein warn">Freistellen + The Frame ergibt keinen Sinn — bitte eins wählen.</div>}
|
||||||
|
{fmt?.cm && !fmt.screen && (
|
||||||
|
<div className="schalter">
|
||||||
|
<button className={portrait ? 'an' : ''} onClick={() => setPortrait(true)}>Hochformat</button>
|
||||||
|
<button className={!portrait ? 'an' : ''} onClick={() => setPortrait(false)}>Querformat</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{zielPx && <div className="fein">Ergibt exakt {zielPx[0]} × {zielPx[1]} Pixel bei 300 dpi — druckfertig.</div>}
|
||||||
|
{zielPx && mode !== 'generate' && (
|
||||||
|
<div className="schalter zart">
|
||||||
|
<button className={crop === 'crop' ? 'an' : ''} onClick={() => setCrop('crop')}>Zuschneiden</button>
|
||||||
|
<button className={crop === 'extend' ? 'an' : ''} onClick={() => setCrop('extend')}>Erweitern</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{zielPx && mode !== 'generate' && crop === 'extend' && (
|
||||||
|
<div className="fein">„Erweitern" erzeugt mehr Szene rund ums Motiv (Outpainting), statt zuzuschneiden — ideal für The Frame.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'each' && tasks.includes('contour') && (
|
||||||
|
<div className="feld">
|
||||||
|
<label>Stickerrand (mm)</label>
|
||||||
|
<input className="input" type="number" min={0} max={20} step={0.5}
|
||||||
|
value={contourMm} onChange={(e) => setContourMm(Number(e.target.value))} />
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'each' && (
|
||||||
|
<div className="feld">
|
||||||
|
<label>Eigene Anweisung (optional)</label>
|
||||||
|
<textarea className="area" rows={2} value={custom} onChange={(e) => setCustom(e.target.value)}
|
||||||
|
placeholder="z. B. „Mach den Hintergrund heller, sonst alles lassen.“" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="feld">
|
||||||
|
<label>Wohin?</label>
|
||||||
|
<div className="schalter">
|
||||||
|
<button className={delivery === 'library' ? 'an' : ''} onClick={() => setDelivery('library')}>Bibliothek</button>
|
||||||
|
<button className={delivery === 'picdrop' ? 'an' : ''} onClick={() => setDelivery('picdrop')}>Picdrop</button>
|
||||||
|
<button className={delivery === 'both' ? 'an' : ''} onClick={() => setDelivery('both')}>Beides</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="knopf" disabled={!canRun} onClick={run}>
|
||||||
|
{busy ? <><span className="spin" />Wird angelegt …</>
|
||||||
|
: mode === 'each' ? `Loslegen${ready.length ? ` · ${ready.length} Bild${ready.length > 1 ? 'er' : ''}` : ''}`
|
||||||
|
: mode === 'compose' ? 'Bild kombinieren' : 'Bild erzeugen'}
|
||||||
|
</button>
|
||||||
|
<div className="fein mitte">Läuft serverseitig weiter — du kannst das Fenster schließen.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
{toast && <div className="toast">{toast}</div>}
|
{toast && <div className="toast">{toast}</div>}
|
||||||
<StudioStyles />
|
<StudioStyles />
|
||||||
</div>
|
</div>
|
||||||
@@ -287,8 +348,15 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
|
|||||||
|
|
||||||
function StudioStyles() {
|
function StudioStyles() {
|
||||||
return <style>{`
|
return <style>{`
|
||||||
.studio{display:grid;grid-template-columns:1.1fr .9fr;gap:18px;align-items:start;}
|
.studio{display:flex;flex-direction:column;gap:16px;}
|
||||||
@media(max-width:820px){.studio{grid-template-columns:1fr;}}
|
.modus-leiste{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;}
|
||||||
|
@media(max-width:560px){.modus-leiste{grid-template-columns:1fr;}}
|
||||||
|
.modus-tab{text-align:left;background:var(--card);border:1px solid var(--line);border-radius:var(--radius-sm);padding:11px 13px;cursor:pointer;font-family:inherit;}
|
||||||
|
.modus-tab b{display:block;font-size:14px;font-weight:600;}
|
||||||
|
.modus-tab span{display:block;font-size:11px;color:var(--soft);margin-top:2px;line-height:1.4;}
|
||||||
|
.modus-tab.an{border-color:var(--accent);background:var(--accent-bg);}
|
||||||
|
.raster{display:grid;grid-template-columns:1.1fr .9fr;gap:18px;align-items:start;}
|
||||||
|
@media(max-width:820px){.raster{grid-template-columns:1fr;}}
|
||||||
.karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
|
.karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
|
||||||
.kopfzeile{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);}
|
.kopfzeile{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);}
|
||||||
.link{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;}
|
.link{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;}
|
||||||
@@ -305,12 +373,13 @@ function StudioStyles() {
|
|||||||
.mini-bild>button{position:absolute;top:3px;right:3px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(22,21,15,.75);color:#fff;cursor:pointer;font-size:14px;line-height:1;}
|
.mini-bild>button{position:absolute;top:3px;right:3px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(22,21,15,.75);color:#fff;cursor:pointer;font-size:14px;line-height:1;}
|
||||||
.mini-bild .badge{position:absolute;bottom:3px;left:3px;background:var(--err);color:#fff;width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;}
|
.mini-bild .badge{position:absolute;bottom:3px;left:3px;background:var(--err);color:#fff;width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;}
|
||||||
.lade{position:absolute;inset:0;background:var(--paper);opacity:.6;}
|
.lade{position:absolute;inset:0;background:var(--paper);opacity:.6;}
|
||||||
.mini-add{aspect-ratio:3/4;border:1.5px dashed var(--line);background:none;border-radius:3px;cursor:pointer;font-size:22px;color:var(--soft);}
|
.mini-add{aspect-ratio:1;border:1.5px dashed var(--line);background:none;border-radius:3px;cursor:pointer;font-size:22px;color:var(--soft);}
|
||||||
.steuer{padding:16px;display:flex;flex-direction:column;gap:15px;}
|
.steuer{padding:16px;display:flex;flex-direction:column;gap:15px;}
|
||||||
.feld label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--soft);margin-bottom:7px;}
|
.feld label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--soft);margin-bottom:7px;}
|
||||||
.input,.select,.area{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);outline:none;}
|
.input,.select,.area{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);outline:none;}
|
||||||
.input:focus,.select:focus,.area:focus{border-color:var(--accent);}
|
.input:focus,.select:focus,.area:focus{border-color:var(--accent);}
|
||||||
.area{font-size:13.5px;line-height:1.5;resize:vertical;}
|
.area{font-size:13.5px;line-height:1.5;resize:vertical;}
|
||||||
|
.area.gross{font-size:15px;line-height:1.55;}
|
||||||
.fein{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;}
|
.fein{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;}
|
||||||
.fein.mitte{text-align:center;}
|
.fein.mitte{text-align:center;}
|
||||||
.fein.warn{color:var(--err);}
|
.fein.warn{color:var(--err);}
|
||||||
|
|||||||
+144
-27
@@ -12,6 +12,8 @@ const BASE = process.env.PUBLIC_BASE_URL || '';
|
|||||||
|
|
||||||
let bot: Bot | null = null;
|
let bot: Bot | null = null;
|
||||||
let webhookSecret = '';
|
let webhookSecret = '';
|
||||||
|
// Zwischenspeicher: Freitext, aus dem der Nutzer per Knopf ein Bild erzeugen kann.
|
||||||
|
const pendingGenerate = new Map<number, string>();
|
||||||
|
|
||||||
export async function getToken(): Promise<string> {
|
export async function getToken(): Promise<string> {
|
||||||
const s = await one<{ telegram_bot_token_enc: string | null }>('SELECT telegram_bot_token_enc FROM settings WHERE id=1');
|
const s = await one<{ telegram_bot_token_enc: string | null }>('SELECT telegram_bot_token_enc FROM settings WHERE id=1');
|
||||||
@@ -66,20 +68,22 @@ async function tryPair(chatId: number, code: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Bündelung (Drafts) -----------------------------------------------------
|
// --- Bündelung (Drafts) -----------------------------------------------------
|
||||||
async function addToDraft(chatId: number, mediaGroup: string | null, fileRef: any, noticeMsgId?: number) {
|
async function addToDraft(chatId: number, mediaGroup: string | null, fileRef: any, caption?: string | null) {
|
||||||
const existing = await one<{ id: string; file_refs: any[] }>(
|
const existing = await one<{ id: string; file_refs: any[]; caption: string | null }>(
|
||||||
`SELECT id, file_refs FROM telegram_drafts WHERE chat_id=$1 AND status='collecting'
|
`SELECT id, file_refs, caption FROM telegram_drafts WHERE chat_id=$1 AND status='collecting'
|
||||||
ORDER BY last_received_at DESC LIMIT 1`, [chatId]);
|
ORDER BY last_received_at DESC LIMIT 1`, [chatId]);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const refs = [...(existing.file_refs || []), fileRef];
|
const refs = [...(existing.file_refs || []), fileRef];
|
||||||
await query(`UPDATE telegram_drafts SET file_refs=$2, last_received_at=now() WHERE id=$1`,
|
// Erste vorhandene Bildunterschrift als Beschreibung merken.
|
||||||
[existing.id, JSON.stringify(refs)]);
|
const cap = existing.caption || (caption?.trim() || null);
|
||||||
|
await query(`UPDATE telegram_drafts SET file_refs=$2, caption=$3, last_received_at=now() WHERE id=$1`,
|
||||||
|
[existing.id, JSON.stringify(refs), cap]);
|
||||||
return existing.id;
|
return existing.id;
|
||||||
}
|
}
|
||||||
const row = await one<{ id: string }>(
|
const row = await one<{ id: string }>(
|
||||||
`INSERT INTO telegram_drafts (chat_id, media_group_id, file_refs, status, notice_message_id)
|
`INSERT INTO telegram_drafts (chat_id, media_group_id, file_refs, caption, status)
|
||||||
VALUES ($1,$2,$3,'collecting',$4) RETURNING id`,
|
VALUES ($1,$2,$3,$4,'collecting') RETURNING id`,
|
||||||
[chatId, mediaGroup, JSON.stringify([fileRef]), noticeMsgId ?? null]);
|
[chatId, mediaGroup, JSON.stringify([fileRef]), caption?.trim() || null]);
|
||||||
return row!.id;
|
return row!.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,18 +91,21 @@ async function addToDraft(chatId: number, mediaGroup: string | null, fileRef: an
|
|||||||
export async function sweepDrafts(): Promise<void> {
|
export async function sweepDrafts(): Promise<void> {
|
||||||
const b = await getBot(); if (!b) return;
|
const b = await getBot(); if (!b) return;
|
||||||
const drafts = await query<any>(
|
const drafts = await query<any>(
|
||||||
`SELECT id, chat_id, file_refs FROM telegram_drafts
|
`SELECT id, chat_id, file_refs, caption FROM telegram_drafts
|
||||||
WHERE status='collecting' AND last_received_at < now() - interval '${Math.round(QUIET_MS / 1000)} seconds'`);
|
WHERE status='collecting' AND last_received_at < now() - interval '${Math.round(QUIET_MS / 1000)} seconds'`);
|
||||||
for (const d of drafts) {
|
for (const d of drafts) {
|
||||||
const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 8');
|
const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 6');
|
||||||
const kb = new InlineKeyboard();
|
const kb = new InlineKeyboard();
|
||||||
// Callback-Daten <64 Bytes: nur Rezept-UUID; der Draft wird beim Klick über den Chat gefunden.
|
// Callback-Daten <64 Bytes: nur Rezept-UUID; der Draft wird beim Klick über den Chat gefunden.
|
||||||
recipes.forEach((r, i) => { kb.text(r.name, `r:${r.id}`); if (i % 2 === 1) kb.row(); });
|
recipes.forEach((r, i) => { kb.text(r.name, `r:${r.id}`); if (i % 2 === 1) kb.row(); });
|
||||||
const n = (d.file_refs || []).length;
|
const n = (d.file_refs || []).length;
|
||||||
|
// Ab 2 Bildern zusätzlich „Kombinieren" anbieten.
|
||||||
|
if (n >= 2) { kb.row(); kb.text('🔀 Zu einem Bild kombinieren', 'c:x'); }
|
||||||
const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed');
|
const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed');
|
||||||
|
const capNote = d.caption ? `\n📝 Beschreibung erkannt: „${d.caption}" — für „Kombinieren".` : '';
|
||||||
try {
|
try {
|
||||||
await b.api.sendMessage(d.chat_id,
|
await b.api.sendMessage(d.chat_id,
|
||||||
`${n} Bild${n > 1 ? 'er' : ''} empfangen. Welches Rezept?` +
|
`${n} Bild${n > 1 ? 'er' : ''} empfangen. Was möchtest du tun?` + capNote +
|
||||||
(compressed ? '\n⚠️ Als Foto gesendet (komprimiert). Für volle Qualität als *Datei* senden.' : ''),
|
(compressed ? '\n⚠️ Als Foto gesendet (komprimiert). Für volle Qualität als *Datei* senden.' : ''),
|
||||||
{ reply_markup: kb, parse_mode: 'Markdown' });
|
{ reply_markup: kb, parse_mode: 'Markdown' });
|
||||||
await query(`UPDATE telegram_drafts SET status='awaiting_recipe' WHERE id=$1`, [d.id]);
|
await query(`UPDATE telegram_drafts SET status='awaiting_recipe' WHERE id=$1`, [d.id]);
|
||||||
@@ -107,19 +114,12 @@ export async function sweepDrafts(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Draft -> Auftrag -------------------------------------------------------
|
// --- Draft -> Auftrag -------------------------------------------------------
|
||||||
async function dispatchDraft(chatId: number, draftId: string, recipeId: string, b: Bot) {
|
|
||||||
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
|
|
||||||
if (!draft || draft.status === 'dispatched') return;
|
|
||||||
const recipe = await one<any>('SELECT * FROM recipes WHERE id=$1', [recipeId]);
|
|
||||||
if (!recipe) { await b.api.sendMessage(chatId, 'Rezept nicht gefunden.'); return; }
|
|
||||||
const link = await linkedUser(chatId);
|
|
||||||
|
|
||||||
await b.api.sendMessage(chatId, `⏳ Verarbeite ${(draft.file_refs || []).length} Bild(er) …`);
|
/** Lädt alle Bilder eines Drafts von Telegram und legt sie in den Objektspeicher. */
|
||||||
|
async function refsToSources(refs: any[], b: Bot): Promise<any[]> {
|
||||||
// Bilder von Telegram laden und in den Objektspeicher legen
|
|
||||||
const sources: any[] = [];
|
const sources: any[] = [];
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
for (const ref of draft.file_refs || []) {
|
for (const ref of refs || []) {
|
||||||
try {
|
try {
|
||||||
const file = await b.api.getFile(ref.file_id);
|
const file = await b.api.getFile(ref.file_id);
|
||||||
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
|
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
|
||||||
@@ -129,6 +129,32 @@ async function dispatchDraft(chatId: number, draftId: string, recipeId: string,
|
|||||||
sources.push({ source_path: key, filename: ref.name || 'telegram.jpg', quality: ref.quality || 'original' });
|
sources.push({ source_path: key, filename: ref.name || 'telegram.jpg', quality: ref.quality || 'original' });
|
||||||
} catch (e) { console.error('[telegram] Datei laden fehlgeschlagen', e); }
|
} catch (e) { console.error('[telegram] Datei laden fehlgeschlagen', e); }
|
||||||
}
|
}
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Basis-Snapshot aus dem Standard-Rezept des Chats (Format/Modell/Auslieferung). */
|
||||||
|
async function chatRecipeDefaults(chatId: number): Promise<any> {
|
||||||
|
const link = await linkedUser(chatId);
|
||||||
|
if (link?.default_recipe_id) {
|
||||||
|
const r = await one<any>('SELECT * FROM recipes WHERE id=$1', [link.default_recipe_id]);
|
||||||
|
if (r) return {
|
||||||
|
output_format: r.output_format, orientation: r.orientation, crop_mode: r.crop_mode || 'crop',
|
||||||
|
dpi: r.dpi || 300, model_key: r.model_key, delivery: r.delivery || 'library',
|
||||||
|
picdrop_gallery: r.picdrop_gallery,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { output_format: 'keep', crop_mode: 'crop', dpi: 300, delivery: 'library' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchDraft(chatId: number, draftId: string, recipeId: string, b: Bot) {
|
||||||
|
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
|
||||||
|
if (!draft || draft.status === 'dispatched') return;
|
||||||
|
const recipe = await one<any>('SELECT * FROM recipes WHERE id=$1', [recipeId]);
|
||||||
|
if (!recipe) { await b.api.sendMessage(chatId, 'Rezept nicht gefunden.'); return; }
|
||||||
|
const link = await linkedUser(chatId);
|
||||||
|
|
||||||
|
await b.api.sendMessage(chatId, `⏳ Verarbeite ${(draft.file_refs || []).length} Bild(er) …`);
|
||||||
|
const sources = await refsToSources(draft.file_refs || [], b);
|
||||||
if (!sources.length) { await b.api.sendMessage(chatId, '❌ Keine Bilder ladbar.'); return; }
|
if (!sources.length) { await b.api.sendMessage(chatId, '❌ Keine Bilder ladbar.'); return; }
|
||||||
|
|
||||||
const snap = {
|
const snap = {
|
||||||
@@ -138,8 +164,8 @@ async function dispatchDraft(chatId: number, draftId: string, recipeId: string,
|
|||||||
delivery: recipe.delivery || 'library', picdrop_gallery: recipe.picdrop_gallery,
|
delivery: recipe.delivery || 'library', picdrop_gallery: recipe.picdrop_gallery,
|
||||||
};
|
};
|
||||||
const job = await one<{ id: string }>(
|
const job = await one<{ id: string }>(
|
||||||
`INSERT INTO jobs (created_by, origin, recipe_snapshot, status, total, telegram_chat_id)
|
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
|
||||||
VALUES ($1,'telegram',$2,'queued',$3,$4) RETURNING id`,
|
VALUES ($1,'telegram','each',$2,'queued',$3,$4) RETURNING id`,
|
||||||
[link?.user_id || null, JSON.stringify(snap), sources.length, chatId]);
|
[link?.user_id || null, JSON.stringify(snap), sources.length, chatId]);
|
||||||
for (let i = 0; i < sources.length; i++) {
|
for (let i = 0; i < sources.length; i++) {
|
||||||
const it = await one<{ id: string }>(
|
const it = await one<{ id: string }>(
|
||||||
@@ -151,6 +177,55 @@ async function dispatchDraft(chatId: number, draftId: string, recipeId: string,
|
|||||||
await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]);
|
await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Kombinieren: alle Bilder des Drafts + Beschreibung → ein neues Bild. */
|
||||||
|
async function dispatchCompose(chatId: number, draftId: string, description: string, b: Bot) {
|
||||||
|
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
|
||||||
|
if (!draft || draft.status === 'dispatched') return;
|
||||||
|
const link = await linkedUser(chatId);
|
||||||
|
await b.api.sendMessage(chatId, '⏳ Kombiniere die Bilder …');
|
||||||
|
const sources = await refsToSources(draft.file_refs || [], b);
|
||||||
|
if (sources.length < 2) { await b.api.sendMessage(chatId, '❌ Zum Kombinieren brauche ich mindestens 2 Bilder.'); return; }
|
||||||
|
|
||||||
|
const d = await chatRecipeDefaults(chatId);
|
||||||
|
const wantsFormat = d.output_format && d.output_format !== 'keep';
|
||||||
|
const snap = {
|
||||||
|
tasks: wantsFormat ? ['format'] : [], output_format: d.output_format, orientation: d.orientation,
|
||||||
|
crop_mode: d.crop_mode || 'crop', dpi: d.dpi || 300, model_key: d.model_key,
|
||||||
|
prompt_text: description, delivery: d.delivery || 'library', picdrop_gallery: d.picdrop_gallery,
|
||||||
|
};
|
||||||
|
const job = await one<{ id: string }>(
|
||||||
|
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
|
||||||
|
VALUES ($1,'telegram','compose',$2,'queued',1,$3) RETURNING id`,
|
||||||
|
[link?.user_id || null, JSON.stringify(snap), chatId]);
|
||||||
|
const it = await one<{ id: string }>(
|
||||||
|
`INSERT INTO items (job_id, position, status, source_paths, filename, source_quality)
|
||||||
|
VALUES ($1,0,'queued',$2,'kombiniert','original') RETURNING id`,
|
||||||
|
[job!.id, JSON.stringify(sources.map((s) => s.source_path))]);
|
||||||
|
await enqueue({ itemId: it!.id, jobId: job!.id });
|
||||||
|
await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Freitext: ein komplett neues Bild allein aus der Beschreibung. */
|
||||||
|
async function dispatchGenerate(chatId: number, description: string, b: Bot) {
|
||||||
|
const link = await linkedUser(chatId);
|
||||||
|
await b.api.sendMessage(chatId, '⏳ Erzeuge ein neues Bild …');
|
||||||
|
const d = await chatRecipeDefaults(chatId);
|
||||||
|
const wantsFormat = d.output_format && d.output_format !== 'keep';
|
||||||
|
const snap = {
|
||||||
|
tasks: wantsFormat ? ['format'] : [], output_format: d.output_format, orientation: d.orientation,
|
||||||
|
crop_mode: d.crop_mode || 'crop', dpi: d.dpi || 300, model_key: d.model_key,
|
||||||
|
prompt_text: description, delivery: d.delivery || 'library', picdrop_gallery: d.picdrop_gallery,
|
||||||
|
};
|
||||||
|
const job = await one<{ id: string }>(
|
||||||
|
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
|
||||||
|
VALUES ($1,'telegram','generate',$2,'queued',1,$3) RETURNING id`,
|
||||||
|
[link?.user_id || null, JSON.stringify(snap), chatId]);
|
||||||
|
const it = await one<{ id: string }>(
|
||||||
|
`INSERT INTO items (job_id, position, status, filename, source_quality)
|
||||||
|
VALUES ($1,0,'queued','neu','original') RETURNING id`, [job!.id]);
|
||||||
|
await enqueue({ itemId: it!.id, jobId: job!.id });
|
||||||
|
}
|
||||||
|
|
||||||
/** Vom Worker aufgerufen: eine Rückmeldung an den Chat, wenn der Auftrag fertig ist. */
|
/** Vom Worker aufgerufen: eine Rückmeldung an den Chat, wenn der Auftrag fertig ist. */
|
||||||
export async function notifyJobDone(chatId: number, jobId: string): Promise<void> {
|
export async function notifyJobDone(chatId: number, jobId: string): Promise<void> {
|
||||||
const b = await getBot(); if (!b) return;
|
const b = await getBot(); if (!b) return;
|
||||||
@@ -184,9 +259,16 @@ function register(b: Bot) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
b.command('help', (ctx) => ctx.reply(
|
b.command('help', (ctx) => ctx.reply(
|
||||||
'So geht’s:\n• Bilder weiterleiten (einzeln oder als Album) — am besten als *Datei*.\n• Rezept wählen.\n• Ich melde mich einmal, wenn alles fertig ist — mit den Ergebnissen und Links.\n\nBefehle: /rezepte (Standard setzen) · /status · /start',
|
'So geht’s:\n• *Bearbeiten:* Bilder weiterleiten (am besten als *Datei*) → Rezept wählen.\n• *Kombinieren:* 2+ Bilder senden, dazu eine Bildunterschrift wie „mit unserer Hündin Frieda" → „🔀 Kombinieren".\n• *Neu erzeugen:* `/neu <Beschreibung>` — z. B. `/neu ein Olivenzweig auf Sand`.\n\nIch melde mich einmal, wenn alles fertig ist — mit den Ergebnissen und Links.\nBefehle: /neu · /rezepte (Standard setzen) · /status · /start',
|
||||||
{ parse_mode: 'Markdown' }));
|
{ parse_mode: 'Markdown' }));
|
||||||
|
|
||||||
|
b.command('neu', async (ctx) => {
|
||||||
|
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Bitte zuerst koppeln (Code aus dem Admin).');
|
||||||
|
const text = (ctx.match || '').toString().trim();
|
||||||
|
if (!text) return ctx.reply('Schreib z. B.: /neu ein minimalistisches Poster mit einem Olivenzweig auf Sand');
|
||||||
|
const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat.id, text, b2);
|
||||||
|
});
|
||||||
|
|
||||||
b.command('rezepte', async (ctx) => {
|
b.command('rezepte', async (ctx) => {
|
||||||
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Bitte zuerst koppeln (Code aus dem Admin).');
|
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Bitte zuerst koppeln (Code aus dem Admin).');
|
||||||
const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 10');
|
const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 10');
|
||||||
@@ -205,7 +287,8 @@ function register(b: Bot) {
|
|||||||
|
|
||||||
const onImage = async (ctx: any, fileId: string, name: string, quality: 'original' | 'compressed') => {
|
const onImage = async (ctx: any, fileId: string, name: string, quality: 'original' | 'compressed') => {
|
||||||
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('⛔️ Nicht gekoppelt. Bitte Kopplungscode aus dem Admin eingeben.');
|
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('⛔️ Nicht gekoppelt. Bitte Kopplungscode aus dem Admin eingeben.');
|
||||||
await addToDraft(ctx.chat.id, ctx.message?.media_group_id || null, { file_id: fileId, name, quality });
|
await addToDraft(ctx.chat.id, ctx.message?.media_group_id || null,
|
||||||
|
{ file_id: fileId, name, quality }, ctx.message?.caption || null);
|
||||||
};
|
};
|
||||||
b.on('message:photo', async (ctx) => {
|
b.on('message:photo', async (ctx) => {
|
||||||
const p = ctx.message.photo[ctx.message.photo.length - 1];
|
const p = ctx.message.photo[ctx.message.photo.length - 1];
|
||||||
@@ -219,9 +302,23 @@ function register(b: Bot) {
|
|||||||
|
|
||||||
b.on('message:text', async (ctx) => {
|
b.on('message:text', async (ctx) => {
|
||||||
if (ctx.message.text.startsWith('/')) return;
|
if (ctx.message.text.startsWith('/')) return;
|
||||||
if (await linkedUser(ctx.chat.id)) return; // gekoppelt: Text ignorieren
|
const linked = await linkedUser(ctx.chat.id);
|
||||||
|
if (linked) {
|
||||||
|
// Wartet ein Kombinieren-Draft auf die Beschreibung? → Text als Beschreibung nehmen.
|
||||||
|
const waiting = await one<{ id: string }>(
|
||||||
|
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status='awaiting_compose_text'
|
||||||
|
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat.id]);
|
||||||
|
if (waiting) {
|
||||||
|
const b2 = await getBot(); if (b2) await dispatchCompose(ctx.chat.id, waiting.id, ctx.message.text.trim(), b2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Sonst: Freitext als Angebot zum Neu-Erzeugen anbieten.
|
||||||
|
const kb = new InlineKeyboard().text('🎨 Neues Bild erzeugen', 'g:x');
|
||||||
|
pendingGenerate.set(ctx.chat.id, ctx.message.text.trim());
|
||||||
|
return ctx.reply('Soll ich daraus ein neues Bild erzeugen? (oder /help)', { reply_markup: kb });
|
||||||
|
}
|
||||||
if (await tryPair(ctx.chat.id, ctx.message.text)) {
|
if (await tryPair(ctx.chat.id, ctx.message.text)) {
|
||||||
return ctx.reply('✅ Verbunden! Leite mir jetzt Bilder weiter. /help für mehr.');
|
return ctx.reply('✅ Verbunden! Leite mir Bilder weiter, oder erzeuge mit /neu ein neues Bild. /help für mehr.');
|
||||||
}
|
}
|
||||||
return ctx.reply('Code ungültig oder abgelaufen. Neuen Code im Admin erzeugen.');
|
return ctx.reply('Code ungültig oder abgelaufen. Neuen Code im Admin erzeugen.');
|
||||||
});
|
});
|
||||||
@@ -241,5 +338,25 @@ function register(b: Bot) {
|
|||||||
await ctx.editMessageText('Alles klar, los geht’s.');
|
await ctx.editMessageText('Alles klar, los geht’s.');
|
||||||
const b2 = await getBot(); if (b2) await dispatchDraft(ctx.chat!.id, draft.id, recipeId, b2);
|
const b2 = await getBot(); if (b2) await dispatchDraft(ctx.chat!.id, draft.id, recipeId, b2);
|
||||||
}
|
}
|
||||||
|
if (kind === 'c') { // Kombinieren gewählt
|
||||||
|
const draft = await one<{ id: string; caption: string | null }>(
|
||||||
|
`SELECT id, caption FROM telegram_drafts WHERE chat_id=$1 AND status='awaiting_recipe'
|
||||||
|
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat!.id]);
|
||||||
|
if (!draft) return ctx.editMessageText('Kein offener Bild-Stapel gefunden — bitte Bilder neu senden.');
|
||||||
|
if (draft.caption?.trim()) {
|
||||||
|
await ctx.editMessageText(`Alles klar — kombiniere mit: „${draft.caption.trim()}".`);
|
||||||
|
const b2 = await getBot(); if (b2) await dispatchCompose(ctx.chat!.id, draft.id, draft.caption.trim(), b2);
|
||||||
|
} else {
|
||||||
|
await query(`UPDATE telegram_drafts SET status='awaiting_compose_text' WHERE id=$1`, [draft.id]);
|
||||||
|
await ctx.editMessageText('Beschreibe kurz, was entstehen soll (z. B. „das Bild, aber mit unserer Hündin Frieda"):');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (kind === 'g') { // Freitext → neues Bild
|
||||||
|
const text = pendingGenerate.get(ctx.chat!.id);
|
||||||
|
if (!text) return ctx.editMessageText('Text nicht mehr vorhanden — bitte erneut senden oder /neu nutzen.');
|
||||||
|
pendingGenerate.delete(ctx.chat!.id);
|
||||||
|
await ctx.editMessageText('Alles klar, erzeuge ein neues Bild …');
|
||||||
|
const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat!.id, text, b2);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user