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:
2026-07-23 19:19:47 +00:00
parent f5cbcdf4d1
commit 3e2e09a38d
2 changed files with 353 additions and 167 deletions
+209 -140
View File
@@ -31,11 +31,19 @@ const TASKS = [
{ 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);
interface Pic { id: string; src: string; name: string; source_path?: string; error?: string; uploading?: boolean }
export default function StudioApp({ recipes }: { recipes: any[] }) {
const [mode, setMode] = useState<Mode>('each');
const [pics, setPics] = useState<Pic[]>([]);
const [tasks, setTasks] = useState<string[]>(['clean', 'format']);
const [format, setFormat] = useState('30x40');
@@ -43,6 +51,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
const [crop, setCrop] = useState<'crop' | 'extend'>('crop');
const [contourMm, setContourMm] = useState(3);
const [custom, setCustom] = useState('');
const [desc, setDesc] = useState('');
const [delivery, setDelivery] = useState<'library' | 'picdrop' | 'both'>('library');
const [models, setModels] = useState<any[]>([]);
const [modelKey, setModelKey] = useState<string>('');
@@ -55,6 +64,9 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
const hasCutout = tasks.includes('cutout');
const hasFormat = tasks.includes('format');
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
? (fmt.screen as [number, number])
@@ -87,12 +99,13 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
useEffect(() => {
const onPaste = (e: ClipboardEvent) => {
if (mode === 'generate') return;
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith('image/'));
if (imgs.length) upload(imgs.map((i) => i.getAsFile()!).filter(Boolean));
};
window.addEventListener('paste', onPaste);
return () => window.removeEventListener('paste', onPaste);
}, [upload]);
}, [upload, mode]);
useEffect(() => {
fetch('/api/models').then((r) => r.json()).then((j) => {
@@ -105,8 +118,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
function toggleTask(id: string) {
setTasks((t) => {
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 === 'contour' && next.includes('contour') && !next.includes('cutout')) next = next; // wird per disabled verhindert
if (id === 'cutout' && !next.includes('cutout')) next = next.filter((x) => x !== 'contour');
return next;
});
}
@@ -124,24 +136,34 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
}
const ready = pics.filter((p) => p.source_path && !p.error);
const canRun = ready.length > 0 && !busy && tasks.length > 0 && !theframeConflict &&
!(tasks.includes('contour') && !hasCutout);
const canRun = !busy && (
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() {
if (!canRun) return;
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 = {
tasks: [...tasks].sort((a, b) => ['clean', 'cutout', 'format', 'contour', 'deliver'].indexOf(a) - ['clean', 'cutout', 'format', 'contour', 'deliver'].indexOf(b)),
output_format: hasFormat ? format : 'keep',
tasks: tasksOut,
output_format: wantsFormat ? format : 'keep',
orientation: portrait ? 'portrait' : 'landscape',
crop_mode: crop, dpi: 300,
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 {
const res = await fetch('/api/jobs', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipe, sources: ready.map((p) => ({ source_path: p.source_path, filename: p.name })) }),
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
const j = await res.json();
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); }
}
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 (
<div className="studio">
<section className="karte">
<div className="kopfzeile">
<span className="mono-label">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">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' : ''}` : ''}`}
{/* Modus-Umschalter */}
<div className="modus-leiste">
{MODES.map((m) => (
<button key={m.id} className={`modus-tab ${mode === m.id ? 'an' : ''}`} onClick={() => setMode(m.id)}>
<b>{m.name}</b><span>{m.hint}</span>
</button>
<div className="fein mitte">Läuft serverseitig weiter du kannst das Fenster schließen.</div>
</div>
</section>
))}
</div>
<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>}
<StudioStyles />
</div>
@@ -287,8 +348,15 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
function StudioStyles() {
return <style>{`
.studio{display:grid;grid-template-columns:1.1fr .9fr;gap:18px;align-items:start;}
@media(max-width:820px){.studio{grid-template-columns:1fr;}}
.studio{display:flex;flex-direction:column;gap:16px;}
.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);}
.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;}
@@ -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 .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;}
.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;}
.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:focus,.select:focus,.area:focus{border-color:var(--accent);}
.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.mitte{text-align:center;}
.fein.warn{color:var(--err);}