diff --git a/README.md b/README.md index 65c0f88..d1d2334 100644 --- a/README.md +++ b/README.md @@ -17,22 +17,40 @@ npm --prefix client run web ## Bildgenerierung -Die Warteschlange aus `jobs` arbeitet der Dispatcher ab – **serverseitig**, weil -der Anbieter-Schlüssel nicht ins Client-Bundle darf: +Zwei Job-Typen arbeitet der Dispatcher ab – **serverseitig**, weil der +Anbieter-Schlüssel nicht ins Client-Bundle darf: + +| Job | Entsteht bei | Ergebnis | +|---|---|---| +| `modell_gen` | Modell anlegen (Person, Produkt, Kulisse) | 4 Varianten als Entwurfs-Versionen, eine wird freigegeben | +| `bild_gen` | Post erstellen | Bilderkette in `post_images` | + +**Logo-Sonderfall:** Wer eine Datei hochlädt, bekommt sie unverändert als +freigegebene Version – ab da wird sie nie neu generiert. Wer keine hat, bekommt +Entwürfe. Sobald etwas freigegeben ist, weist der Dispatcher jede weitere +Generierung auf dieses Asset ab. So bleibt die Brand-Regel gewahrt, ohne Marken +ohne Logo auszusperren. ```bash -BILD_ANBIETER=stub node scripts/job-dispatcher.mjs +node scripts/job-dispatcher.mjs ``` -`stub` erzeugt Platzhalterbilder und macht die ganze Kette prüfbar, ohne Kosten. -`ark` und `openrouter` sprechen die echten Modelle an – beide setzen voraus, -dass das jeweilige Konto freigeschaltet bzw. aufgeladen ist: +Ohne Angabe läuft er auf `auto` und probiert der Reihe nach **Ark → OpenRouter → +Platzhalter**. Ark steht vorn, weil Seedream mit 0,045–0,090 $ je Bild günstiger +ist als Gemini mit 0,14 $ und in `eu-west` läuft. Ist Ark nicht freigeschaltet, +fällt die Kette still auf OpenRouter zurück; jede Zeile im Log nennt den +Anbieter, der geliefert hat. -- **BytePlus Ark:** Modell in der Konsole unter Model Service aktivieren -- **OpenRouter:** Guthaben aufladen +Unterschieden wird zwischen „Anbieter nicht verfügbar" (Konto zu, kein Guthaben) +und einem echten Fehler – nur beim ersten Fall wird weitergereicht, damit ein +inhaltlicher Fehler nicht stillschweigend überdeckt wird. -Danach reicht `BILD_ANBIETER=ark node scripts/job-dispatcher.mjs` – am Code -ändert sich nichts. +Fest wählen geht weiterhin: `BILD_ANBIETER=stub|ark|openrouter`. + +**Voraussetzungen je Anbieter:** OpenRouter braucht gekauftes Guthaben (eine +hinterlegte Karte genügt nicht – es ist ein Prepaid-Konto). BytePlus Ark braucht +die Aktivierung je Modell in der Konsole unter *Model activation*; das ist keine +Zahlungsfrage, verlangt aber die Zustimmung zu mehreren Verträgen. ## Demo-Daten diff --git a/client/src/app/(tabs)/profil.tsx b/client/src/app/(tabs)/profil.tsx index 7003e18..6f9b112 100644 --- a/client/src/app/(tabs)/profil.tsx +++ b/client/src/app/(tabs)/profil.tsx @@ -75,7 +75,11 @@ export default function ProfilScreen() { {t.titel} {davon.map((m) => ( - + router.push({ pathname: '/modell/[id]', params: { id: m.$id } })} + /> ))} @@ -143,21 +147,34 @@ function OrdnerZeile({ ); } -function ModellKachel({ modell }: { modell: AssetMitBild }) { - const quelle = useBildQuelle(modell.titelbildId); +function ModellKachel({ modell, aufDruck }: { modell: AssetMitBild; aufDruck: () => void }) { + const quelle = useBildQuelle(modell.titelbildId, modell.titelbildBucket); return ( - - {quelle ? ( - - ) : ( - - - - )} + [s.kachel, pressed ? s.gedrueckt : null]}> + + {quelle ? ( + + ) : ( + + + + )} + {/* Ohne freigegebene Version ist das Modell nicht einsetzbar – das muss + man sehen, bevor man es in einem Post auswählen will. */} + {modell.wartet ? ( + + wählen + + ) : null} + {modell.name} - + ); } @@ -202,6 +219,12 @@ const s = StyleSheet.create({ }, kachelLeer: { alignItems: 'center', justifyContent: 'center' }, kachelName: { ...text.label, color: colors.mut }, + wartetChip: { + position: 'absolute', bottom: 5, left: 5, + backgroundColor: colors.gold, borderRadius: radius.pill, + paddingHorizontal: space.sm, paddingVertical: 1, + }, + wartetText: { fontSize: 10, fontWeight: '700', color: '#000' }, fuss: { gap: space.md, marginTop: space.md }, fussText: { ...text.mono, color: colors.faint }, diff --git a/client/src/app/modell/[id].tsx b/client/src/app/modell/[id].tsx new file mode 100644 index 0000000..a470fec --- /dev/null +++ b/client/src/app/modell/[id].tsx @@ -0,0 +1,344 @@ +import { Ionicons } from '@expo/vector-icons'; +import { Image } from 'expo-image'; +import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useEffect, useState } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { Button, ErrorNote, Field } from '@/components/ui'; +import { + TYPEN, + VARIANTEN_JE_MODELL, + jobsZuAsset, + neueRunde, + varianteWiederholen, + versionFreigeben, + versionenZuAsset, + type Asset, + type AssetVersion, + type VariantenLage, +} from '@/lib/assets'; +import { useSession } from '@/lib/session'; +import { fehlertext } from '@/lib/auth'; +import { useBildQuelle } from '@/lib/bildquelle'; +import { BUCKET_BILDER, BUCKET_REFERENZEN } from '@/lib/dateien'; +import { databaseId, tables } from '@/lib/appwrite'; +import { colors, radius, space, text } from '@/theme/tokens'; + +export default function ModellErgebnisScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + + const { brand } = useSession(); + const [asset, setAsset] = useState(null); + const [versionen, setVersionen] = useState([]); + const [lagen, setLagen] = useState>(new Map()); + const [fehler, setFehler] = useState(null); + const [busy, setBusy] = useState(null); + const [grund, setGrund] = useState(''); + + const laden = useCallback(async () => { + if (!id) return; + try { + const a = (await tables.getRow({ databaseId, tableId: 'assets', rowId: id })) as unknown as Asset; + setAsset(a); + const [vs, js] = await Promise.all([versionenZuAsset(id), jobsZuAsset(id)]); + setVersionen(vs.sort((x, y) => x.version_no - y.version_no)); + setLagen(js); + } catch (e) { + setFehler(fehlertext(e)); + } + }, [id]); + + useFocusEffect( + useCallback(() => { + void laden(); + }, [laden]), + ); + + // Solange Varianten offen sind, selbst nachladen – der Dispatcher läuft + // außerhalb der App, es gibt also nichts, was den Screen sonst aufweckt. + const offeneJobs = versionen.some( + (v) => !v.bild_file_id && ['wartend', 'laeuft'].includes(lagen.get(v.$id)?.status ?? 'wartend'), + ); + useEffect(() => { + if (!offeneJobs) return; + const t = setInterval(() => void laden(), 5000); + return () => clearInterval(t); + }, [offeneJobs, laden]); + + async function rundeStarten() { + if (!brand?.team_id || !asset) return; + const vorlage = versionen[versionen.length - 1]; + setBusy('runde'); + setFehler(null); + try { + await neueRunde(brand.$id, brand.team_id, asset.$id, { + beschreibung: vorlage?.beschreibung_md ?? '', + merkmale: vorlage?.merkmale ?? [], + grund: grund.trim(), + }); + setGrund(''); + await laden(); + } catch (e) { + setFehler(fehlertext(e)); + } finally { + setBusy(null); + } + } + + async function wiederholen(version: AssetVersion) { + if (!brand?.team_id || !asset) return; + setBusy(version.$id); + try { + await varianteWiederholen(brand.$id, brand.team_id, asset.$id, version.$id, version.version_no); + await laden(); + } catch (e) { + setFehler(fehlertext(e)); + } finally { + setBusy(null); + } + } + + async function freigeben(versionId: string) { + if (!asset) return; + setBusy(versionId); + setFehler(null); + try { + await versionFreigeben(asset.$id, versionId); + await laden(); + } catch (e) { + setFehler(fehlertext(e)); + } finally { + setBusy(null); + } + } + + const typTitel = TYPEN.find((t) => t.wert === asset?.typ)?.titel ?? asset?.typ; + const offen = versionen.filter((v) => v.status === 'entwurf'); + const fertig = versionen.filter((v) => v.bild_file_id).length; + const referenzen = versionen[0]?.reference_file_ids ?? []; + + return ( + + + {asset?.name ?? 'Modell'} + + {typTitel} · {versionen.length} Version(en) · {fertig} erzeugt + {asset?.released_version_id ? ' · freigegeben' : ' · noch nichts freigegeben'} + + + {fehler ? : null} + + {offen.length > 0 && fertig < versionen.length ? ( + + + + {versionen.length - fertig} Variante(n) stehen noch aus. Der Dispatcher muss laufen: + {'\n'} + BILD_ANBIETER=stub node scripts/job-dispatcher.mjs + + + ) : null} + + + Varianten + + Nur eine Version wird verwendet – sie gilt als freigegeben, bis du sie bewusst + ersetzt. Die übrigen werden archiviert, nicht gelöscht. + + + {versionen.map((v) => ( + void freigeben(v.$id)} + aufWiederholen={() => void wiederholen(v)} + /> + ))} + + + + {/* Verbessern-Strecke: eine weitere Runde auf demselben Modell. + Bei Logos gesperrt – ein freigegebenes Markenzeichen wird nicht + neu interpretiert (Brand-Regel). */} + {asset?.typ !== 'logo' ? ( + + Nachschärfen + + Erzeugt {VARIANTEN_JE_MODELL} weitere Varianten. Die freigegebene Version bleibt + gültig, bis du bewusst eine neue wählst. + + +