bilder gen

This commit is contained in:
2026-08-17 23:41:49 +02:00
parent 9ecb292c26
commit f4aac46ea0
9 changed files with 1381 additions and 93 deletions

View File

@@ -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 },

View 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,
},
});

View File

@@ -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 },
});

View File

@@ -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,
}));
}
/**