bilder gen
This commit is contained in:
@@ -75,7 +75,11 @@ export default function ProfilScreen() {
|
||||
<Text style={s.gruppeTitel}>{t.titel}</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={s.reihe}>
|
||||
{davon.map((m) => (
|
||||
<ModellKachel key={m.$id} modell={m} />
|
||||
<ModellKachel
|
||||
key={m.$id}
|
||||
modell={m}
|
||||
aufDruck={() => router.push({ pathname: '/modell/[id]', params: { id: m.$id } })}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
@@ -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 (
|
||||
<View style={s.kachel}>
|
||||
{quelle ? (
|
||||
<Image source={quelle} style={s.kachelBild} contentFit="cover" transition={150} />
|
||||
) : (
|
||||
<View style={[s.kachelBild, s.kachelLeer]}>
|
||||
<Ionicons name={iconFuer(modell.typ)} size={22} color={colors.faint} />
|
||||
</View>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={aufDruck}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${modell.name} öffnen`}
|
||||
style={({ pressed }) => [s.kachel, pressed ? s.gedrueckt : null]}>
|
||||
<View>
|
||||
{quelle ? (
|
||||
<Image source={quelle} style={s.kachelBild} contentFit="cover" transition={150} />
|
||||
) : (
|
||||
<View style={[s.kachelBild, s.kachelLeer]}>
|
||||
<Ionicons name={iconFuer(modell.typ)} size={22} color={colors.faint} />
|
||||
</View>
|
||||
)}
|
||||
{/* Ohne freigegebene Version ist das Modell nicht einsetzbar – das muss
|
||||
man sehen, bevor man es in einem Post auswählen will. */}
|
||||
{modell.wartet ? (
|
||||
<View style={s.wartetChip}>
|
||||
<Text style={s.wartetText}>wählen</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={s.kachelName} numberOfLines={1}>
|
||||
{modell.name}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
344
client/src/app/modell/[id].tsx
Normal file
344
client/src/app/modell/[id].tsx
Normal file
@@ -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<Asset | null>(null);
|
||||
const [versionen, setVersionen] = useState<AssetVersion[]>([]);
|
||||
const [lagen, setLagen] = useState<Map<string, VariantenLage>>(new Map());
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(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 (
|
||||
<SafeAreaView style={s.safe} edges={['top', 'left', 'right']}>
|
||||
<ScrollView contentContainerStyle={s.body}>
|
||||
<Text style={s.titel}>{asset?.name ?? 'Modell'}</Text>
|
||||
<Text style={s.unter}>
|
||||
{typTitel} · {versionen.length} Version(en) · {fertig} erzeugt
|
||||
{asset?.released_version_id ? ' · freigegeben' : ' · noch nichts freigegeben'}
|
||||
</Text>
|
||||
|
||||
{fehler ? <ErrorNote message={fehler} /> : null}
|
||||
|
||||
{offen.length > 0 && fertig < versionen.length ? (
|
||||
<View style={s.hinweisBox}>
|
||||
<Ionicons name="hourglass-outline" size={15} color={colors.gold} />
|
||||
<Text style={s.hinweisText}>
|
||||
{versionen.length - fertig} Variante(n) stehen noch aus. Der Dispatcher muss laufen:
|
||||
{'\n'}
|
||||
<Text style={s.mono}>BILD_ANBIETER=stub node scripts/job-dispatcher.mjs</Text>
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={s.abschnitt}>
|
||||
<Text style={s.abschnittTitel}>Varianten</Text>
|
||||
<Text style={s.erklaerung}>
|
||||
Nur eine Version wird verwendet – sie gilt als freigegeben, bis du sie bewusst
|
||||
ersetzt. Die übrigen werden archiviert, nicht gelöscht.
|
||||
</Text>
|
||||
<View style={s.gitter}>
|
||||
{versionen.map((v) => (
|
||||
<VariantenKachel
|
||||
key={v.$id}
|
||||
version={v}
|
||||
lage={lagen.get(v.$id)}
|
||||
freigegeben={v.$id === asset?.released_version_id}
|
||||
busy={busy === v.$id}
|
||||
aufFreigabe={() => void freigeben(v.$id)}
|
||||
aufWiederholen={() => void wiederholen(v)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Verbessern-Strecke: eine weitere Runde auf demselben Modell.
|
||||
Bei Logos gesperrt – ein freigegebenes Markenzeichen wird nicht
|
||||
neu interpretiert (Brand-Regel). */}
|
||||
{asset?.typ !== 'logo' ? (
|
||||
<View style={s.abschnitt}>
|
||||
<Text style={s.abschnittTitel}>Nachschärfen</Text>
|
||||
<Text style={s.erklaerung}>
|
||||
Erzeugt {VARIANTEN_JE_MODELL} weitere Varianten. Die freigegebene Version bleibt
|
||||
gültig, bis du bewusst eine neue wählst.
|
||||
</Text>
|
||||
<Field
|
||||
label="Was soll anders werden?"
|
||||
value={grund}
|
||||
onChangeText={setGrund}
|
||||
placeholder="z. B. weicheres Licht, weniger Kontrast, Blick zur Kamera"
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
style={s.mehrzeilig}
|
||||
/>
|
||||
<Button
|
||||
title="Neue Runde erzeugen"
|
||||
onPress={() => void rundeStarten()}
|
||||
busy={busy === 'runde'}
|
||||
disabled={grund.trim().length < 4}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{referenzen.length > 0 ? (
|
||||
<View style={s.abschnitt}>
|
||||
<Text style={s.abschnittTitel}>Deine Referenzbilder</Text>
|
||||
<Text style={s.erklaerung}>
|
||||
Die Vorlagen, aus denen die Merkmale stammen. Sie werden nicht veröffentlicht.
|
||||
</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={s.reihe}>
|
||||
{referenzen.map((fid) => (
|
||||
<Referenz key={fid} fileId={fid} />
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Button title="Zurück" variant="ghost" onPress={() => router.back()} />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function VariantenKachel({
|
||||
version, lage, freigegeben, busy, aufFreigabe, aufWiederholen,
|
||||
}: {
|
||||
version: AssetVersion;
|
||||
lage?: VariantenLage;
|
||||
freigegeben: boolean;
|
||||
busy: boolean;
|
||||
aufFreigabe: () => void;
|
||||
aufWiederholen: () => void;
|
||||
}) {
|
||||
const quelle = useBildQuelle(version.bild_file_id, BUCKET_BILDER);
|
||||
const archiviert = version.status === 'archiviert';
|
||||
const gescheitert = !version.bild_file_id && lage?.status === 'fehler';
|
||||
|
||||
const zustand = freigegeben ? 'freigegeben'
|
||||
: archiviert ? 'archiviert'
|
||||
: gescheitert ? 'fehlgeschlagen'
|
||||
: version.bild_file_id ? 'Entwurf'
|
||||
: 'wird erzeugt';
|
||||
|
||||
return (
|
||||
<View style={[s.kachel, archiviert ? s.kachelAus : null]}>
|
||||
<View style={[s.rahmen, freigegeben ? s.rahmenFrei : null, gescheitert ? s.rahmenFehler : null]}>
|
||||
{quelle ? (
|
||||
<Image source={quelle} style={s.bild} contentFit="cover" transition={150} />
|
||||
) : (
|
||||
<View style={[s.bild, s.bildLeer]}>
|
||||
<Ionicons
|
||||
name={gescheitert ? 'alert-circle-outline' : 'hourglass-outline'}
|
||||
size={18}
|
||||
color={gescheitert ? colors.accent : colors.faint}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{freigegeben ? (
|
||||
<View style={s.siegel}>
|
||||
<Ionicons name="checkmark" size={12} color="#fff" />
|
||||
</View>
|
||||
) : null}
|
||||
{/* EU AI Act: die Kennzeichnung gehört sichtbar ans Bild, nicht erst
|
||||
in den Export (projekt-uebersicht.md §10). */}
|
||||
{version.bild_file_id ? (
|
||||
<View style={s.kiChip}>
|
||||
<Text style={s.kiChipText}>KI</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={s.kachelZeile}>v{version.version_no} · {zustand}</Text>
|
||||
|
||||
{gescheitert ? (
|
||||
<>
|
||||
<Text style={s.fehlerText} numberOfLines={3}>{lage?.error}</Text>
|
||||
<Pressable onPress={aufWiederholen} disabled={busy} accessibilityRole="button" style={s.waehlen}>
|
||||
<Text style={s.waehlenText}>{busy ? '…' : 'Nochmal'}</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : !freigegeben && version.bild_file_id ? (
|
||||
<Pressable onPress={aufFreigabe} disabled={busy} accessibilityRole="button" style={s.waehlen}>
|
||||
<Text style={s.waehlenText}>{busy ? '…' : 'Diese nehmen'}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Referenz({ fileId }: { fileId: string }) {
|
||||
const quelle = useBildQuelle(fileId, BUCKET_REFERENZEN);
|
||||
return (
|
||||
<View style={s.refRahmen}>
|
||||
{quelle ? <Image source={quelle} style={s.bild} contentFit="cover" /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: colors.bg },
|
||||
body: { padding: space.xl, gap: space.xl, paddingBottom: space.xxl },
|
||||
titel: { ...text.title, color: colors.txt },
|
||||
unter: { ...text.label, color: colors.mut, marginTop: -space.lg },
|
||||
|
||||
hinweisBox: {
|
||||
flexDirection: 'row', gap: space.md, alignItems: 'flex-start',
|
||||
backgroundColor: 'rgba(251,191,36,0.10)',
|
||||
borderColor: colors.gold, borderWidth: StyleSheet.hairlineWidth,
|
||||
borderRadius: radius.md, padding: space.lg,
|
||||
},
|
||||
hinweisText: { ...text.label, color: colors.mut, lineHeight: 19, flex: 1 },
|
||||
mono: { ...text.mono, color: colors.txt },
|
||||
|
||||
abschnitt: { gap: space.sm },
|
||||
abschnittTitel: { ...text.heading, color: colors.txt },
|
||||
erklaerung: { ...text.label, color: colors.faint, lineHeight: 18 },
|
||||
mehrzeilig: { minHeight: 76, textAlignVertical: 'top' },
|
||||
|
||||
gitter: { flexDirection: 'row', flexWrap: 'wrap', gap: space.md, marginTop: space.sm },
|
||||
kachel: { width: 104, gap: space.xs },
|
||||
kachelAus: { opacity: 0.4 },
|
||||
rahmen: {
|
||||
width: 104, height: 104, borderRadius: radius.md, overflow: 'hidden',
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border, borderWidth: 2,
|
||||
},
|
||||
rahmenFrei: { borderColor: colors.ok },
|
||||
rahmenFehler: { borderColor: colors.accent },
|
||||
kiChip: {
|
||||
position: 'absolute', bottom: 4, left: 4,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: radius.pill,
|
||||
paddingHorizontal: 5, paddingVertical: 1,
|
||||
},
|
||||
kiChipText: { fontSize: 9, fontWeight: '700', color: '#fff', letterSpacing: 0.5 },
|
||||
fehlerText: { fontSize: 10, color: colors.accent, lineHeight: 13 },
|
||||
bild: { width: '100%', height: '100%' },
|
||||
bildLeer: { alignItems: 'center', justifyContent: 'center' },
|
||||
siegel: {
|
||||
position: 'absolute', top: 5, right: 5,
|
||||
width: 20, height: 20, borderRadius: 10, backgroundColor: colors.ok,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
kachelZeile: { ...text.label, color: colors.mut },
|
||||
waehlen: {
|
||||
borderRadius: radius.pill, paddingVertical: 5, alignItems: 'center',
|
||||
backgroundColor: colors.surface2, borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
waehlenText: { fontSize: 11, fontWeight: '700', color: colors.txt },
|
||||
|
||||
reihe: { gap: space.sm, paddingTop: space.sm, paddingRight: space.lg },
|
||||
refRahmen: {
|
||||
width: 64, height: 64, borderRadius: radius.sm, overflow: 'hidden',
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
});
|
||||
@@ -8,7 +8,15 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { Button, ErrorNote, Field } from '@/components/ui';
|
||||
import { Wahl } from '@/components/wahl';
|
||||
import { BILDER_EMPFOHLEN, BILDER_MAX, TYPEN, assetAnlegen, type AssetTyp } from '@/lib/assets';
|
||||
import {
|
||||
BILDER_EMPFOHLEN,
|
||||
BILDER_MAX,
|
||||
kannGenerieren,
|
||||
TYPEN,
|
||||
VARIANTEN_JE_MODELL,
|
||||
assetAnlegen,
|
||||
type AssetTyp,
|
||||
} from '@/lib/assets';
|
||||
import { fehlertext } from '@/lib/auth';
|
||||
import { useSession } from '@/lib/session';
|
||||
import type { Auswahl } from '@/lib/upload';
|
||||
@@ -21,6 +29,7 @@ export default function ModellNeuScreen() {
|
||||
const [typ, setTyp] = useState<AssetTyp>('gesicht');
|
||||
const [name, setName] = useState('');
|
||||
const [beschreibung, setBeschreibung] = useState('');
|
||||
const [merkmale, setMerkmale] = useState('');
|
||||
const [bilder, setBilder] = useState<Auswahl[]>([]);
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -50,13 +59,16 @@ export default function ModellNeuScreen() {
|
||||
setFehler(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await assetAnlegen(brand.$id, brand.team_id, {
|
||||
const asset = await assetAnlegen(brand.$id, brand.team_id, {
|
||||
typ,
|
||||
name: name.trim(),
|
||||
beschreibung: beschreibung.trim(),
|
||||
merkmale: merkmale.split('\n').map((z) => z.trim()).filter(Boolean),
|
||||
bilder,
|
||||
});
|
||||
router.back();
|
||||
// Bei generierbaren Typen wartet dort die Auswahl der Variante.
|
||||
if (kannGenerieren(typ, bilder.length > 0)) router.replace({ pathname: '/modell/[id]', params: { id: asset.$id } });
|
||||
else router.back();
|
||||
} catch (e) {
|
||||
setFehler(fehlertext(e));
|
||||
setBusy(false);
|
||||
@@ -83,17 +95,35 @@ export default function ModellNeuScreen() {
|
||||
label="Beschreibung"
|
||||
value={beschreibung}
|
||||
onChangeText={setBeschreibung}
|
||||
placeholder="Merkmale, die immer stimmen müssen – wörtlich, nicht blumig."
|
||||
placeholder="Für Menschen lesbar – was dieses Modell ausmacht."
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
style={s.mehrzeilig}
|
||||
/>
|
||||
|
||||
<View style={s.block}>
|
||||
<Field
|
||||
label="Merkmale – eines pro Zeile, englisch"
|
||||
value={merkmale}
|
||||
onChangeText={setMerkmale}
|
||||
placeholder={'long straight blonde hair with a centre parting\nwarm fair skin tone\noval face with pronounced cheekbones'}
|
||||
multiline
|
||||
numberOfLines={6}
|
||||
autoCapitalize="none"
|
||||
style={s.mehrzeilig}
|
||||
/>
|
||||
<Text style={s.hinweis}>
|
||||
Diese Zeilen gehen <Text style={s.betont}>wörtlich</Text> in den Prompt – umformulieren
|
||||
heißt, die Identität zu verlieren. Keine Namen, keine Fremdmarken, keine Altersangaben.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={s.block}>
|
||||
<Text style={s.label}>Referenzbilder</Text>
|
||||
<Text style={s.hinweis}>
|
||||
{BILDER_EMPFOHLEN} bis {BILDER_MAX} sind das Optimum. Mehr als sieben mitteln die
|
||||
Merkmale weg, statt sie zu schärfen.
|
||||
{kannGenerieren(typ, bilder.length > 0)
|
||||
? `Optional. ${BILDER_EMPFOHLEN} bis ${BILDER_MAX} sind das Optimum – mehr als sieben mitteln die Merkmale weg, statt sie zu schärfen.`
|
||||
: 'Pflicht. Das Logo wird genau so verwendet, wie du es hochlädst.'}
|
||||
</Text>
|
||||
|
||||
<View style={s.gitter}>
|
||||
@@ -118,12 +148,45 @@ export default function ModellNeuScreen() {
|
||||
{zuViele ? <Text style={s.warnung}>Über sieben Bilder verschlechtern die Konsistenz.</Text> : null}
|
||||
</View>
|
||||
|
||||
{typ === 'logo' ? (
|
||||
<View style={s.regelBox}>
|
||||
<Ionicons name="lock-closed-outline" size={15} color={colors.gold} />
|
||||
<Text style={s.regelText}>
|
||||
{bilder.length > 0 ? (
|
||||
<>
|
||||
Dein Logo wird <Text style={s.betont}>unverändert übernommen</Text> und ab jetzt
|
||||
nie neu erzeugt. So verlangt es eure Brand-Regel: nicht verzerren, nicht
|
||||
beschneiden, nicht umfärben, nicht neu interpretieren.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Ohne Datei entwerfe ich <Text style={s.betont}>Vorschläge</Text>. Das geht nur,
|
||||
solange es kein Logo gibt – sobald du eines freigibst, wird es geschützt und
|
||||
nicht mehr generiert.
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={s.actions}>
|
||||
<Button
|
||||
title="Modell anlegen"
|
||||
title={
|
||||
kannGenerieren(typ, bilder.length > 0)
|
||||
? `Anlegen und ${VARIANTEN_JE_MODELL} Varianten erzeugen`
|
||||
: 'Logo unverändert übernehmen'
|
||||
}
|
||||
onPress={anlegen}
|
||||
busy={busy}
|
||||
disabled={name.trim().length < 2 || bilder.length === 0}
|
||||
// Referenzbilder sind beim Generieren **optional** – sie schärfen
|
||||
// die Identität, ersetzen aber nicht die Beschreibung. Wird
|
||||
// stattdessen hochgeladen, ist die Datei der ganze Inhalt.
|
||||
disabled={
|
||||
name.trim().length < 2 ||
|
||||
(kannGenerieren(typ, bilder.length > 0)
|
||||
? beschreibung.trim().length < 10
|
||||
: bilder.length === 0)
|
||||
}
|
||||
/>
|
||||
<Button title="Abbrechen" variant="ghost" onPress={() => router.back()} />
|
||||
</View>
|
||||
@@ -141,6 +204,7 @@ const s = StyleSheet.create({
|
||||
block: { gap: space.sm },
|
||||
label: { ...text.label, color: colors.mut },
|
||||
hinweis: { ...text.label, color: colors.faint, lineHeight: 18 },
|
||||
betont: { color: colors.mut, fontWeight: '700' },
|
||||
gitter: { flexDirection: 'row', flexWrap: 'wrap', gap: space.md, marginTop: space.sm },
|
||||
kachel: {
|
||||
width: 88, height: 88, borderRadius: radius.sm, overflow: 'hidden',
|
||||
@@ -156,5 +220,12 @@ const s = StyleSheet.create({
|
||||
},
|
||||
plus: { alignItems: 'center', justifyContent: 'center', borderStyle: 'dashed' },
|
||||
warnung: { ...text.label, color: colors.gold },
|
||||
regelBox: {
|
||||
flexDirection: 'row', gap: space.md, alignItems: 'flex-start',
|
||||
backgroundColor: 'rgba(251,191,36,0.10)',
|
||||
borderColor: colors.gold, borderWidth: StyleSheet.hairlineWidth,
|
||||
borderRadius: radius.md, padding: space.lg,
|
||||
},
|
||||
regelText: { ...text.label, color: colors.mut, lineHeight: 19, flex: 1 },
|
||||
actions: { gap: space.md, marginTop: space.md },
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ID, Query, databaseId, tables } from './appwrite';
|
||||
import { BUCKET_REFERENZEN as BUCKET } from './dateien';
|
||||
import { BUCKET_BILDER, BUCKET_REFERENZEN as BUCKET } from './dateien';
|
||||
import { teamRechte } from './permissions';
|
||||
import { hochladen, type Auswahl } from './upload';
|
||||
|
||||
@@ -26,7 +26,11 @@ export type AssetVersion = {
|
||||
status: 'entwurf' | 'freigegeben' | 'archiviert';
|
||||
beschreibung_md?: string;
|
||||
merkmale?: string[];
|
||||
change_reason?: string;
|
||||
/** EINGABE – was der Nutzer hochgeladen hat. */
|
||||
reference_file_ids?: string[];
|
||||
/** ERGEBNIS – das für diese Version erzeugte Bild. */
|
||||
bild_file_id?: string;
|
||||
};
|
||||
|
||||
export const TYPEN: { wert: AssetTyp; titel: string; erklaerung: string; icon: string }[] = [
|
||||
@@ -64,16 +68,54 @@ export async function versionenZuAsset(assetId: string): Promise<AssetVersion[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt ein Modell samt erster Version an und gibt sie sofort frei.
|
||||
* Wieviele Varianten beim Anlegen erzeugt werden.
|
||||
*
|
||||
* Release-Prinzip (projekt-uebersicht.md §4): Bilder verwenden immer eine
|
||||
* *freigegebene* Version, damit nichts unbemerkt wegdriftet. Version 1 wird
|
||||
* hier direkt freigegeben, weil es sonst nichts gäbe, womit man arbeiten kann.
|
||||
* Vier, weil §8 vier bis sechs Referenzbilder als Optimum nennt und weil man
|
||||
* aus vier Vorschlägen noch bewusst auswählt – bei acht klickt man das erste
|
||||
* Passable an, und das Auswahlsignal wird wertlos.
|
||||
*/
|
||||
export const VARIANTEN_JE_MODELL = 4;
|
||||
|
||||
/**
|
||||
* Typen, für die beim Anlegen Varianten erzeugt werden.
|
||||
*
|
||||
* **Logo ist ein Sonderfall.** Die Brand-Regel verbietet, das Markenzeichen zu
|
||||
* verzerren, zu beschneiden, umzufärben oder neu zu interpretieren. Sie schützt
|
||||
* damit ein **vorhandenes** Logo – eine Marke ohne Logo hat nichts zu schützen.
|
||||
*
|
||||
* Daraus folgen zwei Wege, die sich gegenseitig ausschließen:
|
||||
* - **Logo hochladen** → wird v1 und sofort freigegeben. Ab da wird es nie
|
||||
* wieder generiert, nur noch unverändert eingesetzt. Das ist der Normalfall.
|
||||
* - **Logo entwerfen lassen** → nur wenn *keine* Datei hochgeladen wird. Es gibt
|
||||
* dann kein Original, das verfremdet werden könnte; es entsteht ein Vorschlag.
|
||||
*/
|
||||
export const GENERIERBAR: AssetTyp[] = ['gesicht', 'produkt', 'kulisse'];
|
||||
|
||||
/** Kann für diesen Typ und diese Eingabe generiert werden? */
|
||||
export function kannGenerieren(typ: AssetTyp, hatReferenzbilder: boolean): boolean {
|
||||
if (GENERIERBAR.includes(typ)) return true;
|
||||
// Logo: nur entwerfen, wenn nichts hochgeladen wurde.
|
||||
return typ === 'logo' && !hatReferenzbilder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt ein Modell an.
|
||||
*
|
||||
* **Logo:** nur hochladen. Eine der globalen Brand-Regeln verbietet es
|
||||
* ausdrücklich, das Logo generieren zu lassen – es darf nie verfremdet,
|
||||
* beschnitten oder neu interpretiert werden. Version 1 ist deshalb direkt die
|
||||
* freigegebene Version mit der hochgeladenen Datei.
|
||||
*
|
||||
* **Person, Produkt, Kulisse:** vier Entwurfs-Versionen plus je ein
|
||||
* `modell_gen`-Job. Freigegeben wird **nichts** – welche Variante gewinnt,
|
||||
* entscheidet der Nutzer im Ergebnis-Screen. Das ist das Release-Prinzip aus
|
||||
* §4: es wird immer eine bewusst freigegebene Version verwendet, nie die
|
||||
* neueste, damit das Aussehen nicht unbemerkt wegdriftet.
|
||||
*/
|
||||
export async function assetAnlegen(
|
||||
brandId: string,
|
||||
teamId: string,
|
||||
daten: { typ: AssetTyp; name: string; beschreibung: string; bilder: Auswahl[] },
|
||||
daten: { typ: AssetTyp; name: string; beschreibung: string; merkmale: string[]; bilder: Auswahl[] },
|
||||
): Promise<Asset> {
|
||||
const rechte = teamRechte(teamId);
|
||||
|
||||
@@ -90,35 +132,205 @@ export async function assetAnlegen(
|
||||
permissions: rechte,
|
||||
})) as unknown as Asset;
|
||||
|
||||
const version = (await tables.createRow({
|
||||
const basis = {
|
||||
asset_id: asset.$id,
|
||||
beschreibung_md: daten.beschreibung,
|
||||
// Token-Locking: diese Zeichenfolgen setzt der Prompt **wörtlich** ein.
|
||||
// Umschreiben heißt hier, die Identität zu verlieren.
|
||||
merkmale: daten.merkmale,
|
||||
reference_file_ids: fileIds,
|
||||
};
|
||||
|
||||
if (!kannGenerieren(daten.typ, fileIds.length > 0)) {
|
||||
const version = (await tables.createRow({
|
||||
databaseId,
|
||||
tableId: 'asset_versions',
|
||||
rowId: ID.unique(),
|
||||
data: { ...basis, version_no: 1, status: 'freigegeben' },
|
||||
permissions: rechte,
|
||||
})) as unknown as AssetVersion;
|
||||
|
||||
await tables.updateRow({
|
||||
databaseId, tableId: 'assets', rowId: asset.$id,
|
||||
data: { released_version_id: version.$id },
|
||||
});
|
||||
return { ...asset, released_version_id: version.$id };
|
||||
}
|
||||
|
||||
for (let i = 1; i <= VARIANTEN_JE_MODELL; i++) {
|
||||
const version = (await tables.createRow({
|
||||
databaseId,
|
||||
tableId: 'asset_versions',
|
||||
rowId: ID.unique(),
|
||||
data: { ...basis, version_no: i, status: 'entwurf', change_reason: `Variante ${i} der ersten Runde` },
|
||||
permissions: rechte,
|
||||
})) as unknown as AssetVersion;
|
||||
|
||||
await tables.createRow({
|
||||
databaseId,
|
||||
tableId: 'jobs',
|
||||
rowId: ID.unique(),
|
||||
data: {
|
||||
brand_id: brandId,
|
||||
typ: 'modell_gen',
|
||||
prompt_template_key: 'P7',
|
||||
status: 'wartend',
|
||||
refs: JSON.stringify({ asset_id: asset.$id, version_id: version.$id, variante: i }),
|
||||
},
|
||||
permissions: rechte,
|
||||
});
|
||||
}
|
||||
|
||||
return asset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet eine neue Runde Varianten auf einem bestehenden Modell.
|
||||
*
|
||||
* Die freigegebene Version bleibt freigegeben, bis der Nutzer bewusst eine neue
|
||||
* wählt – das Release-Prinzip aus §4 gilt auch hier. Bis dahin arbeitet die App
|
||||
* unverändert weiter mit dem alten Stand, statt heimlich auf einen Entwurf
|
||||
* umzuschwenken.
|
||||
*
|
||||
* `grund` beschreibt, was anders werden soll. Er landet in `change_reason` und
|
||||
* geht zusätzlich in den Prompt – ohne ihn wäre eine zweite Runde nur ein
|
||||
* neuer Würfelwurf mit demselben Text.
|
||||
*/
|
||||
export async function neueRunde(
|
||||
brandId: string,
|
||||
teamId: string,
|
||||
assetId: string,
|
||||
daten: { beschreibung: string; merkmale: string[]; grund: string },
|
||||
): Promise<void> {
|
||||
const rechte = teamRechte(teamId);
|
||||
const vorhanden = await versionenZuAsset(assetId);
|
||||
const start = Math.max(0, ...vorhanden.map((v) => v.version_no));
|
||||
// Referenzen der ersten Version mitnehmen – sie sind die Vorlage des Modells.
|
||||
const referenzen = vorhanden.sort((a, b) => a.version_no - b.version_no)[0]?.reference_file_ids ?? [];
|
||||
|
||||
for (let i = 1; i <= VARIANTEN_JE_MODELL; i++) {
|
||||
const version = (await tables.createRow({
|
||||
databaseId,
|
||||
tableId: 'asset_versions',
|
||||
rowId: ID.unique(),
|
||||
data: {
|
||||
asset_id: assetId,
|
||||
version_no: start + i,
|
||||
status: 'entwurf',
|
||||
beschreibung_md: daten.beschreibung,
|
||||
merkmale: daten.merkmale,
|
||||
reference_file_ids: referenzen,
|
||||
change_reason: daten.grund,
|
||||
},
|
||||
permissions: rechte,
|
||||
})) as unknown as AssetVersion;
|
||||
|
||||
await tables.createRow({
|
||||
databaseId,
|
||||
tableId: 'jobs',
|
||||
rowId: ID.unique(),
|
||||
data: {
|
||||
brand_id: brandId,
|
||||
typ: 'modell_gen',
|
||||
prompt_template_key: 'P7',
|
||||
status: 'wartend',
|
||||
refs: JSON.stringify({ asset_id: assetId, version_id: version.$id, variante: i }),
|
||||
},
|
||||
permissions: rechte,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type VariantenLage = { status: string; error?: string; variante: number };
|
||||
|
||||
/**
|
||||
* Der Job-Zustand je Version.
|
||||
*
|
||||
* Nötig, weil man einer Version selbst nicht ansieht, ob ihr Bild noch aussteht
|
||||
* oder die Generierung gescheitert ist – beides sieht nach „Entwurf ohne Bild"
|
||||
* aus. Der Grund steht ausschließlich im Job.
|
||||
*/
|
||||
export async function jobsZuAsset(assetId: string): Promise<Map<string, VariantenLage>> {
|
||||
const res = await tables.listRows({
|
||||
databaseId,
|
||||
tableId: 'asset_versions',
|
||||
tableId: 'jobs',
|
||||
queries: [Query.equal('typ', 'modell_gen'), Query.orderDesc('$createdAt'), Query.limit(100)],
|
||||
});
|
||||
const nach = new Map<string, VariantenLage>();
|
||||
for (const j of res.rows as unknown as { status: string; error?: string; refs?: string }[]) {
|
||||
try {
|
||||
const r = JSON.parse(j.refs ?? '{}');
|
||||
if (r.asset_id !== assetId || !r.version_id) continue;
|
||||
if (!nach.has(r.version_id)) nach.set(r.version_id, { status: j.status, error: j.error, variante: r.variante ?? 1 });
|
||||
} catch { /* kaputte refs überspringen */ }
|
||||
}
|
||||
return nach;
|
||||
}
|
||||
|
||||
/** Stellt eine gescheiterte Variante erneut in die Warteschlange. */
|
||||
export async function varianteWiederholen(
|
||||
brandId: string,
|
||||
teamId: string,
|
||||
assetId: string,
|
||||
versionId: string,
|
||||
variante: number,
|
||||
): Promise<void> {
|
||||
await tables.createRow({
|
||||
databaseId,
|
||||
tableId: 'jobs',
|
||||
rowId: ID.unique(),
|
||||
data: {
|
||||
asset_id: asset.$id,
|
||||
version_no: 1,
|
||||
status: 'freigegeben',
|
||||
beschreibung_md: daten.beschreibung,
|
||||
reference_file_ids: fileIds,
|
||||
brand_id: brandId,
|
||||
typ: 'modell_gen',
|
||||
prompt_template_key: 'P7',
|
||||
status: 'wartend',
|
||||
refs: JSON.stringify({ asset_id: assetId, version_id: versionId, variante }),
|
||||
},
|
||||
permissions: rechte,
|
||||
})) as unknown as AssetVersion;
|
||||
permissions: teamRechte(teamId),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt eine Version frei und archiviert die konkurrierenden.
|
||||
*
|
||||
* Archivieren statt löschen – ein verworfener Entwurf ist auch Wissen (§5), und
|
||||
* `won_against_version_id` hält fest, wogegen sich die Siegerin durchgesetzt hat.
|
||||
*/
|
||||
export async function versionFreigeben(assetId: string, versionId: string): Promise<void> {
|
||||
const alle = await versionenZuAsset(assetId);
|
||||
// Auch die **abgelöste** Version wird archiviert, nicht nur die Entwürfe.
|
||||
// Bliebe sie auf `freigegeben` stehen, gäbe es zwei Zeilen mit diesem Status,
|
||||
// von denen nur eine wirklich gilt – und die Anzeige zeigte sie als Entwurf.
|
||||
const verlierer = alle.filter((v) => v.$id !== versionId && v.status !== 'archiviert');
|
||||
|
||||
await tables.updateRow({
|
||||
databaseId,
|
||||
tableId: 'assets',
|
||||
rowId: asset.$id,
|
||||
data: { released_version_id: version.$id },
|
||||
databaseId, tableId: 'asset_versions', rowId: versionId,
|
||||
data: {
|
||||
status: 'freigegeben',
|
||||
won_against_version_id: verlierer[0]?.$id ?? null,
|
||||
},
|
||||
});
|
||||
for (const v of verlierer) {
|
||||
await tables.updateRow({
|
||||
databaseId, tableId: 'asset_versions', rowId: v.$id, data: { status: 'archiviert' },
|
||||
});
|
||||
}
|
||||
await tables.updateRow({
|
||||
databaseId, tableId: 'assets', rowId: assetId, data: { released_version_id: versionId },
|
||||
});
|
||||
|
||||
return { ...asset, released_version_id: version.$id };
|
||||
}
|
||||
|
||||
export async function assetLoeschen(id: string): Promise<void> {
|
||||
await tables.deleteRow({ databaseId, tableId: 'assets', rowId: id });
|
||||
}
|
||||
|
||||
export type AssetMitBild = Asset & { titelbildId?: string };
|
||||
export type AssetMitBild = Asset & {
|
||||
titelbildId?: string;
|
||||
/** In welchem Bucket das Titelbild liegt – generiert oder hochgeladen. */
|
||||
titelbildBucket?: string;
|
||||
/** true, solange keine Version freigegeben ist (Generierung läuft noch). */
|
||||
wartet?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Modelle samt Titelbild – das erste Referenzbild der **freigegebenen** Version.
|
||||
@@ -129,21 +341,31 @@ export type AssetMitBild = Asset & { titelbildId?: string };
|
||||
*/
|
||||
export async function assetsMitBild(brandId: string, typ?: AssetTyp): Promise<AssetMitBild[]> {
|
||||
const liste = await assetListe(brandId, typ);
|
||||
const versionIds = liste.map((a) => a.released_version_id).filter((v): v is string => !!v);
|
||||
if (!versionIds.length) return liste;
|
||||
if (!liste.length) return liste;
|
||||
|
||||
// Alle Versionen aller Modelle in einer Abfrage – nicht eine je Modell.
|
||||
const res = await tables.listRows({
|
||||
databaseId,
|
||||
tableId: 'asset_versions',
|
||||
queries: [Query.equal('$id', versionIds), Query.limit(100)],
|
||||
queries: [Query.equal('asset_id', liste.map((a) => a.$id)), Query.limit(200)],
|
||||
});
|
||||
const versionen = res.rows as unknown as AssetVersion[];
|
||||
|
||||
return liste.map((a) => {
|
||||
const meine = versionen.filter((v) => v.asset_id === a.$id);
|
||||
const frei = meine.find((v) => v.$id === a.released_version_id);
|
||||
// Ist noch nichts freigegeben, zeigt die Kachel den ersten fertigen Entwurf –
|
||||
// sonst bliebe sie leer, obwohl schon Varianten da sind.
|
||||
const ersatz = meine.find((v) => v.bild_file_id) ?? meine[0];
|
||||
const quelle = frei ?? ersatz;
|
||||
|
||||
return {
|
||||
...a,
|
||||
titelbildId: quelle?.bild_file_id ?? quelle?.reference_file_ids?.[0],
|
||||
titelbildBucket: quelle?.bild_file_id ? BUCKET_BILDER : BUCKET,
|
||||
wartet: !a.released_version_id,
|
||||
};
|
||||
});
|
||||
const nachId = new Map(
|
||||
(res.rows as unknown as AssetVersion[]).map((v) => [v.$id, v.reference_file_ids?.[0]]),
|
||||
);
|
||||
return liste.map((a) => ({
|
||||
...a,
|
||||
titelbildId: a.released_version_id ? nachId.get(a.released_version_id) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user