1 Commits

Author SHA1 Message Date
f4aac46ea0 bilder gen 2026-08-17 23:41:49 +02:00
9 changed files with 1381 additions and 93 deletions

View File

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

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

View File

@@ -118,11 +118,58 @@ Jede Etappe hat ein Abnahmekriterium etwas, das man vorführen kann. Ohne da
**Abnahme:** Nach dem Onboarding stehen in `brands` sechs ausgefüllte Felder und in `attribute_scores` die ersten Attribute auf der brand-weiten Ebene (`folder_id = null`).
**Aufwand:** mittel.
### E4 · Modelle 🟡 **Kern steht (14.08.2026), Verbessern-Strecke offen**
### E4 · Modelle **Generierung steht (15.08.2026), Verbessern-Strecke offen**
**Modell generieren ist gebaut.** Anlegen erzeugt für Person, Produkt und Kulisse je **vier Entwurfs-Versionen** plus vier `modell_gen`-Jobs; freigegeben wird nichts, bis der Nutzer im Ergebnis-Screen eine Variante wählt. Die übrigen werden **archiviert, nicht gelöscht**, und `won_against_version_id` hält fest, wogegen sich die Siegerin durchgesetzt hat.
Prompt je Typ mit Identity-Lock am Anfang **und** am Ende, `merkmale` wörtlich, nur der Blickwinkel variiert über die vier Varianten. Der Studio-Block ist wörtlich aus P07 übernommen, wie es dort verlangt wird. Gesichter entstehen im 3:4-Headshot, Produkte und Kulissen quadratisch.
**Logos: zwei Wege, die sich ausschließen.** Die Brand-Regel schützt ein *vorhandenes* Markenzeichen davor, verzerrt, beschnitten, umgefärbt oder neu interpretiert zu werden. Eine Marke ohne Logo hat nichts zu schützen. Daraus:
- **Datei hochgeladen** → wird v1 und sofort freigegeben, ab da nie wieder generiert.
- **Keine Datei, nur Beschreibung** → vier Entwürfe. Es gibt kein Original, das verfremdet werden könnte.
- **Ist einmal etwas freigegeben, ist Schluss.** Der Dispatcher weist jeden weiteren `modell_gen`-Job auf dieses Asset ab geprüft: „Dieses Logo ist bereits freigegeben und wird laut Brand-Regel nicht neu generiert."
Beim Logo-Entwurf entfallen die globalen Regeln und der Schlusssatz „No text, no logo, no watermark" beide wären widersinnig, wenn genau ein Schriftzug entstehen soll.
**KI-Kennzeichnung** sitzt an jeder erzeugten Kachel (EU AI Act, §10).
Durchgespielt über die Oberfläche: Kulisse „Badezimmer, Morgenlicht" ohne Referenzbild angelegt → 4 Varianten erzeugt → v2 freigegeben, v1/v3/v4 archiviert. Fehlerpfad mit `ark` geprüft: Variante bleibt `entwurf`, der Screen zeigt „fehlgeschlagen" mit der Anbieter-Meldung und einen Knopf „Nochmal"; nach dem Wiederholen war die Variante da.
> **Drei Fehler, die ein paralleler Prüflauf über `prompts/` gefunden hat:** der Kulissen-Prompt hatte keine Schluss-Klammer (die vier Varianten wären auseinandergedriftet); ein gescheiterter Job hat die Version auf `archiviert` gesetzt und damit „vom Nutzer verworfen" mit „Generierung kaputt" verwechselt; und der Dispatcher hat `limit` **vor** dem Statusfilter angewendet bei gefüllter `jobs`-Tabelle hätte er wartende Jobs irgendwann nie mehr gefunden. Alle drei behoben.
### E4 · Grundlage 🟡 **Kern steht (14.08.2026), Verbessern-Strecke offen**
**Inhalt:** Modell anlegen (Person, Produkt, **Kulisse**), echte Referenzbild-Uploads, Versionierung mit Release-Prinzip, Modelle-Übersicht im Profil.
**Erledigt:** Anlegen mit Typwahl und Mehrfach-Bildauswahl (`modell/neu`), Upload nach `asset-references` mit Team-Rechten, Version 1 wird angelegt und sofort freigegeben, `assets.released_version_id` zeigt darauf. Übersicht im Profil nach Typ gruppiert, mit Titelbild aus der freigegebenen Version.
**Belegt:** Vier Modelle (2 Produkte, 2 Kulissen) über `scripts/seed-demo.mjs` als **Client** angelegt das beweist nebenbei, dass die Tabellen- und Bucket-Rechte aus E2 ausreichen. Alle vier Bilder laden in der App in Originalgröße.
**Offen:** weitere Versionen anlegen und freigeben (Verbessern-Strecke), `merkmale` als Token-Lock, Einwilligungs-Upload für Personen.
**P7 ist verdrahtet.** Der Dispatcher lädt die aktive P7-Zeile aus `prompt_templates`, füllt die fünf Slots (`USER_PROMPT`, `REGELN`, `ASSETS`, `ATTRIBUTE`, `ANWEISUNGEN` leere bekommen das Literal `(leer)`) und lässt P7 den englischen Bild-Prompt schreiben. Geprüft: alle fünf Platzhalter werden befüllt, keiner bleibt stehen. Bei Erfolg wird `jobs.prompt_template_version` mitgeschrieben sonst wäre später nicht rekonstruierbar, welche Prompt-Fassung ein Bild erzeugt hat.
Ist kein LLM erreichbar (heute: kein OpenRouter-Guthaben), greift der deterministische Zusammenbau als **Notlauf** und meldet das im Log. Die Kette läuft damit auch ohne Guthaben, nur mit dem einfacheren Prompt. Der Job hält in `prompt_sent` fest, was wirklich rausging.
**Referenzbilder gehen als Bild-Eingabe mit.** P7 verlangt es so: bei vorhandener Referenz die Person nicht neu beschreiben, sondern nur einen kurzen visuellen Anker setzen. Der Dispatcher lädt dafür das Bild der **freigegebenen** Version jedes belegten Slots und hängt es an bei OpenRouter als `image_url`, bei Ark als `image[]`. Höchstens vier, weil mehr die Merkmale mittelt statt sie zu schärfen (§8).
**Wirkung, im direkten Vergleich geprüft:** Vorher war die Person im Werbebild nur „eine blonde Frau" und die Kulisse ein ähnliches Badezimmer. Danach ist es dieselbe Person bis zu den Trägern des Tops, dieselbe Ablage, dieselbe Armatur, dasselbe Fenster. Die Identitätsdrift zwischen Modell und fertiger Szene ist damit weitgehend erledigt.
### Verbessern-Strecke ✅ **erledigt (15.08.2026)**
Im Ergebnis-Screen gibt es „Nachschärfen": ein Feld für die Korrektur, dann vier weitere Varianten. Die freigegebene Version bleibt gültig, bis bewusst eine neue gewählt wird.
Zwei Dinge, die dabei anders sind als in der ersten Runde:
- **Die Korrektur geht in P7s `ANWEISUNGEN`-Slot**, ausdrücklich mit Vorrang vor den Standardvorgaben. Ohne das gewinnt P7s eigener Satz „Neutral relaxed expression, no smile" gegen den Nutzerwunsch genau das ist im Test passiert.
- **Die Runde ankert an der freigegebenen Version**, nicht am ursprünglichen Upload. Sonst driftet das Aussehen mit jeder Runde weiter weg.
Durchgespielt an „Lena" mit der Korrektur „weicheres Licht, leichtes Lächeln": Licht und Mimik geändert, Gesicht erhalten.
> **Drei Fehler, die dieser eine Test aufgedeckt hat:** `ANWEISUNGEN` war leer, die Korrektur erreichte P7 also nie. Der Blickwinkel-Text schrieb „neutral relaxed expression" fest, was jede Mimik-Änderung unmöglich machte. Und beim Freigeben wurde nur `entwurf` archiviert die abgelöste Version blieb auf `freigegeben` stehen und erschien in der Liste als „Entwurf".
**Offen:** Einwilligungs-Upload für Personen.
### Enum-Erweiterung: Risiko geprüft, nicht mehr theoretisch
§2.6 warnte, Enum-Änderungen könnten die Spalte neu anlegen und Daten vernichten mit dem Zusatz „je nach Version". **Für Appwrite 1.8.1 am 15.08.2026 gegen eine Wegwerf-Tabelle geprüft:** additiver `PATCH` liefert 200, alle Bestandswerte überleben, der neue Wert ist sofort nutzbar. `setup-appwrite.mjs` zieht Enums seither automatisch nach **rein additiv**, live vorhandene Zusatzwerte werden gemeldet, nie entfernt.
**Zwei Auflagen:** Nach jedem Appwrite-Upgrade den Test wiederholen, bevor das Skript gegen Produktion läuft. Und `prompt_templates.key` steht auf einer Tabu-Liste die Tabelle ist mit 22 Zeilen gefüllt und trägt die Identität jedes Prompts; dort ist jede Enum-Berührung ein bewusstes Migrationsprojekt, kein Skriptlauf.
### E5 · Ordner ✅ **erledigt (14.08.2026)**
**Inhalt:** Ordner-Liste, Anlegen mit den zwei Schaltern, Ordner-Detail, aktiver Ordner als App-Zustand. Function `ordner-initialisieren`.

View File

@@ -272,7 +272,11 @@ Es gibt jetzt **zwei Stränge**, die parallel laufen können der Bild-Strang
4. **Appwrite aufsetzen** und das Schema aus `datenbank-aufbau.md` anlegen **inklusive `attribute_scores`-Auftrennung von Anfang an** (siehe Abhängigkeit in §12).
5. **Elo-Function implementieren** (deterministisch, gut testbar guter erster Code), scope-bewusst.
6. **Nischen- und Typ-Tag-Enums festlegen** blockiert P19, P22 und die Kopier-Kompatibilität.
7. **Bildmodell auswählen** Kriterium ist Multi-Referenz-Komposition (Person + Produkt + Kulisse konsistent), nicht reine Bildqualität. Vier Kandidaten stehen bereit: `google/gemini-3-pro-image`, `google/gemini-3.1-flash-image`, `openai/gpt-5-image` (OpenRouter) und `seedream-5-0-260128` (Ark eu-west). **Seedream hat den Standortvorteil** bei gleichwertigem Ergebnis bliebe der gesamte Bild-Strang in der EU und §10 wäre nur für Video zu klären. Der Test entscheidet, nicht die Herkunft.
7. ~~**Bildmodell auswählen**~~ **entschieden am 15.08.2026: `google/gemini-3-pro-image`** (über OpenRouter). Der Multi-Referenz-Test ist bestanden: Person, Produkt und Kulisse als getrennte Modelle angelegt, freigegeben und in **einer** Szene komponiert alle drei im Ergebnis erkennbar. Rund 0,14 $ je Bild, dazu ~0,01 $ für den P7-Prompt.
**Zwei bekannte Schwächen:** Das Modell ignoriert das angeforderte Seitenverhältnis und liefert 1408×768 die Formatwahl (1:1 / 4:5 / 9:16) in der App hat damit noch keine Wirkung. Und die Identität driftet zwischen Modell-Referenz und fertiger Szene sichtbar, weil Referenzbilder bisher **nicht** als Bild-Eingabe mitgehen, sondern nur als Merkmalstext. Beides ist nachrüstbar, keins blockiert.
**Seedream bleibt die DSGVO-nähere Option** sobald der Model Service im Ark-Konto freigeschaltet ist, lohnt der Gegentest: bei gleichwertigem Ergebnis bliebe der gesamte Bild-Strang in der EU.
8. **Prototyp erweitern:** Feed, Post-Detail, Kopier-Screen mit Slot-Chips, Ordner-Ansicht, Ordner anlegen (zwei Schalter).
9. Entscheidungen nachziehen: Name, Preisstufen **für beide Zielgruppen**, Ads- **und Social-**API-Anträge starten.

View File

@@ -25,7 +25,20 @@ const ENDPOINT = process.env.APPWRITE_ENDPOINT || 'https://appwrite.webklar.com/
const PROJECT = process.env.APPWRITE_PROJECT || '6a5cee34002bb8360c34';
const DB = 'brandloop';
const BUCKET = 'generated-images';
const ANBIETER = process.env.BILD_ANBIETER || 'stub';
/**
* `auto` (Standard) probiert der Reihe nach: Ark → OpenRouter → Platzhalter.
*
* Ark steht vorn, weil Seedream mit 0,0450,090 $ je Bild klar günstiger ist als
* Gemini mit 0,14 $ **und** in `eu-west` läuft. Solange die Modelle im Ark-Konto
* nicht aktiviert sind, fällt die Kette still auf OpenRouter zurück wird Ark
* freigeschaltet, greift es ohne Konfigurationsänderung.
*
* Ein fest gesetzter Wert (`ark`, `openrouter`, `stub`) schaltet die Automatik ab.
*/
const ANBIETER = process.env.BILD_ANBIETER || 'auto';
const KETTE = ['ark', 'openrouter', 'stub'];
/** Anbieter, die in diesem Lauf schon als nicht verfügbar aufgefallen sind. */
const ausgefallen = new Set();
const MAX_JOBS = Number(process.env.MAX_JOBS || 20);
const KEY = process.env.APPWRITE_API_KEY;
@@ -55,8 +68,97 @@ const anlegen = (tabelle, data, permissions) =>
const aendern = (tabelle, id, data) =>
api('PATCH', `/tablesdb/${DB}/tables/${tabelle}/rows/${id}`, { data });
// ---- Prompt-Bau (Vorstufe von P7) ----------------------------------------
const FORMAT_MASSE = { '1:1': [1024, 1024], '4:5': [896, 1120], '9:16': [768, 1365] };
// ---- P7: der versionierte Prompt aus der Datenbank ------------------------
const FORMAT_MASSE = { '1:1': [1024, 1024], '4:5': [896, 1120], '9:16': [768, 1365], '3:4': [864, 1152] };
/** Die fünf Slots, die P7 laut Frontmatter erwartet. Mehr gibt es nicht. */
const P7_SLOTS = ['USER_PROMPT', 'REGELN', 'ASSETS', 'ATTRIBUTE', 'ANWEISUNGEN'];
let p7Cache = null;
/**
* Holt die aktive P7-Zeile aus `prompt_templates`.
*
* Warum aus der Datenbank und nicht aus einer Konstante im Skript: P7 ist
* versioniert, und `jobs.prompt_template_version` soll festhalten, welche
* Fassung ein Bild erzeugt hat. Ohne das ist später nicht mehr rekonstruierbar,
* warum ein altes Bild anders aussieht als ein neues genau dafür ist die
* Versionierung da.
*/
async function p7Laden() {
if (p7Cache !== undefined && p7Cache !== null) return p7Cache;
const rows = await zeilen('prompt_templates',
q({ method: 'equal', attribute: 'key', values: ['P7'] }),
q({ method: 'equal', attribute: 'aktiv', values: [true] }),
q({ method: 'orderDesc', attribute: 'version' }),
limit(5));
// P8 ist eine Familie mit Adaptern, P7 nicht der Kern hat model_adapter=null.
p7Cache = rows.find((r) => !r.model_adapter) ?? rows[0] ?? null;
return p7Cache;
}
/**
* Füllt die Slots. Leere Slots bekommen das Literal `(leer)` so steht es in
* der Slot-Registry; ein unbefüllter Platzhalter im Prompt wäre schlimmer als
* ein ehrliches „(leer)".
*/
function slotsFuellen(vorlage, werte) {
let text = vorlage;
for (const slot of P7_SLOTS) {
const wert = (werte[slot] ?? '').toString().trim() || '(leer)';
text = text.replaceAll(`{{${slot}}}`, wert);
}
return text;
}
/**
* Lässt P7 den englischen Bild-Prompt schreiben.
*
* Gibt `null` zurück, wenn kein LLM erreichbar ist dann übernimmt der
* deterministische Zusammenbau. Das ist bewusst kein harter Fehler: ohne
* Guthaben soll die Kette trotzdem laufen, nur eben mit dem einfacheren Prompt.
*/
async function p7Ausfuehren(gefuellt) {
const key = process.env.OPENROUTER_API_KEY;
if (!key) return null;
try {
const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: process.env.OPENROUTER_TEXT_MODEL || 'anthropic/claude-sonnet-4.5',
messages: [{ role: 'user', content: gefuellt }],
max_tokens: 1200,
}),
});
const j = await r.json();
if (j.error) { console.log(` (P7 nicht nutzbar: ${String(j.error.message).slice(0, 80)} nutze Zusammenbau)`); return null; }
const text = j.choices?.[0]?.message?.content?.trim();
return text || null;
} catch (e) {
console.log(` (P7 nicht erreichbar: ${String(e.message).slice(0, 60)} nutze Zusammenbau)`);
return null;
}
}
/**
* Regeln sind zweisprachig gespeichert: „Deutscher Satz. (EN: english sentence.)"
* In einen englischen Prompt gehört nur der englische Teil sonst verwässert
* die deutsche Hälfte die Anweisung und kostet obendrein Tokens.
*
* Sobald P7 als echter LLM-Aufruf läuft, entfällt das hier: P7 bekommt die
* Regeln als Slot und formuliert selbst einen zusammenhängenden Prompt.
*/
function regelnEnglisch(regeln) {
return (regeln ?? [])
.map((r) => {
const t = r.prompt_text ?? '';
const m = t.match(/\(EN:\s*([\s\S]+?)\)\s*$/);
return (m ? m[1] : t).trim();
})
.filter(Boolean)
.join(' ');
}
/**
* Setzt den Prompt aus vier Quellen zusammen in der Reihenfolge, in der die
@@ -73,28 +175,244 @@ function promptBauen({ post, slots, assets, attribute, regeln, position }) {
const produkt = assets[slots.produkt_asset_id];
const person = assets[slots.person_asset_id];
// `merkmale` sind token-gelockt: sie gehen **wörtlich** hinein, nie umformuliert.
// Beschreibungstext dient nur als Auffangnetz, wenn keine Merkmale gepflegt sind.
const lock = (a) => (a.merkmale?.length ? a.merkmale.join(', ') : (a.beschreibung ?? ''));
// Identity-Lock am Anfang: was die Person ausmacht, steht vor allem anderen.
if (person) teile.push(`Character reference — ${lock(person)}.`);
teile.push(post.user_prompt?.trim() || post.titel || 'Werbebild');
if (kulisse) teile.push(`Ort: ${kulisse.name}. ${kulisse.beschreibung ?? ''}`.trim());
if (produkt) teile.push(`Produkt, exakt wie beschrieben: ${produkt.name}. ${produkt.beschreibung ?? ''}`.trim());
if (person) teile.push(`Person: ${person.name}. ${person.beschreibung ?? ''}`.trim());
if (kulisse) teile.push(`Setting: ${lock(kulisse)}`);
if (produkt) teile.push(`Product, exactly as described: ${lock(produkt)}`);
if (attribute.length) {
teile.push(`Bewährt für diese Marke: ${attribute.map((a) => a.name).join(', ')}.`);
teile.push(`Proven for this brand: ${attribute.map((a) => a.name).join(', ')}.`);
}
// Nur Position und Winkel variieren über die Kette alles andere bleibt
// konstant, sonst ist es keine Kette, sondern sind es Einzelbilder (§15).
const winkel = ['frontal auf Augenhöhe', 'leicht seitlich von links', 'leichte Aufsicht',
'Detailaufnahme näher am Motiv', 'weiter gefasst, mehr Umgebung'];
teile.push(`Bild ${position} der Serie: ${winkel[(position - 1) % winkel.length]}.`);
const winkel = ['straight-on at eye level', 'slightly from the left', 'slight top-down view',
'closer detail of the subject', 'wider, showing more of the surroundings'];
teile.push(`Image ${position} of the series: ${winkel[(position - 1) % winkel.length]}.`);
if (regeln.length) teile.push(regeln.map((r) => r.prompt_text).filter(Boolean).join(' '));
if (regeln.length) teile.push(regelnEnglisch(regeln));
teile.push('No text, no logo, no watermark in the image.');
// Identity-Lock ein zweites Mal am Schluss. Klingt redundant, ist es nicht:
// über die Länge eines Prompts verliert das Modell die Identität, wenn sie
// nur einmal am Anfang steht (projekt-uebersicht.md §8).
if (person) teile.push(`The person must match exactly: ${lock(person)}.`);
teile.push('Kein Text, kein Logo, kein Wasserzeichen im Bild.');
return teile.filter(Boolean).join('\n');
}
/**
* Qualitätsblock aus P07-bild-prompt-generator.md dort steht ausdrücklich
* „wörtlich verwenden". Deshalb hier unverändert kopiert und nicht umformuliert.
*/
const P7_STUDIO = 'Mid-gray seamless studio background — even neutral mid-gray, no seam line, no gradient. Relight from scratch overriding any reference lighting: one broad diffused source from camera-left and slightly above, a soft triangle of light on the shadow cheek, gentle wrap, no hard shadow edges, no rim light, no hair light, no kicker. Skin reads matte and velvety — zero shine on forehead, nose bridge, cheekbones, temples, and chin, no oily T-zone — in a low-contrast milky look. Skin renders at its true natural skin tone, warmth preserved and natural, never pale or washed-out or cool-shifted by the background. Real peach fuzz at the jaw and hairline, real soft fine even pore texture, subsurface scattering reading as semi-translucent biology, never plastic, never waxy AI render, never glass-skin, never harsh — fine flattering texture, no acne, no blemishes, no rough pores. Photographed on a 50mm prime at a wide aperture, natural round bokeh, even sharpness, soft natural film grain. Photographed not generated.';
/** Wieviele Varianten je Modell erzeugt werden Gegenstück zu `VARIANTEN_JE_MODELL` im Client. */
const VARIANTEN_ANZAHL = 4;
/** Winkel-Varianten je Typ. 46 Referenzbilder sind laut §8 das Optimum. */
const VARIANTEN = {
gesicht: [
'straight-on at eye level, looking into the lens',
'three-quarter turn to camera-left, eyes to the lens',
'three-quarter turn to camera-right, eyes to the lens',
'full profile from camera-left',
'slight chin-down, eyes to the lens',
'head and shoulders slightly further back, more of the shoulders visible',
],
produkt: [
'straight-on front view at label height, label fully legible',
'three-quarter view showing front and one side',
'rotated to show the opposite side',
'slight top-down view showing cap and shoulder of the container',
'tight detail of the label and material finish',
'full object with generous empty space around it',
],
logo: [
'centred, generous even margin on all sides',
'centred, slightly larger in frame',
'horizontal lockup — mark and wordmark side by side',
'stacked lockup — mark above the wordmark',
'the mark alone, without the wordmark',
'monochrome version in a single flat colour',
],
kulisse: [
'straight-on wide view of the whole space',
'shifted one step to the left, same height',
'shifted one step to the right, same height',
'slightly closer, the main surface filling more of the frame',
'wider, showing more of the surrounding room',
'lower camera height, closer to the main surface',
],
};
/**
* Prompt für die Generierung eines **Modells** (Person, Produkt, Kulisse).
*
* Anders als beim Post geht es hier nicht um eine Werbeszene, sondern um eine
* saubere, wiederverwendbare Referenz: neutraler Hintergrund, kein Beiwerk,
* nur Position und Winkel variieren. Was hier entsteht, ist die Vorlage, gegen
* die später jedes Bild dieses Modells gemessen wird.
*/
function promptFuerModell({ typ, name, beschreibung, merkmale, regeln, variante, grund }) {
const lock = merkmale?.length ? merkmale.join(', ') : (beschreibung || name);
const winkel = (VARIANTEN[typ] ?? VARIANTEN.produkt)[(variante - 1) % 6];
const teile = [];
if (typ === 'gesicht') {
teile.push(`Character reference sheet — ${lock}.`);
teile.push(`Framing: head and shoulders, ${winkel}.`);
// Mimik ist Vorgabe, keine Kamera-Eigenschaft: eine Korrektur soll sie
// überstimmen können. Stünde sie fest im Blickwinkel-Text, wäre „leichtes
// Lächeln" nie erreichbar.
if (!grund) teile.push('Neutral relaxed expression.');
teile.push('No jewellery, plain neutral crew-neck top, no logos on clothing.');
teile.push(P7_STUDIO);
teile.push(`The person must match exactly: ${lock}.`);
} else if (typ === 'produkt') {
teile.push(`Product reference — ${lock}.`);
teile.push(`Framing: ${winkel}.`);
teile.push('The object is reproduced exactly as described — shape, proportions, material, label layout and colours unchanged. Nothing added, nothing redesigned.');
teile.push('Mid-gray seamless studio background, even neutral mid-gray, no seam line, no gradient. One broad diffused source from camera-left and slightly above, a soft fill from the right, no hard shadow edges, no coloured spill. Real material response — matte where matte, gloss where gloss. Photographed on a 50mm prime, even sharpness across the object, soft natural film grain. Photographed not generated.');
teile.push('No hands, no people, no props, no surface decoration.');
teile.push(`The product must match exactly: ${lock}.`);
} else if (typ === 'logo') {
// Nur für Marken **ohne** Logo. Ein vorhandenes wird nie neu erzeugt.
teile.push(`Logo design proposal — ${lock}.`);
teile.push(`Layout: ${winkel}.`);
teile.push('Flat vector-style mark on a plain white background. Clean geometry, even stroke weights, no gradients, no bevels, no drop shadows, no 3D, no mockup, no photograph. A limited palette of at most two colours. Legible when reduced to a small size.');
teile.push('This is an original design proposal — it must not resemble the mark of any existing company.');
} else {
// Kulisse: eine leere Platte, die später bespielt wird
teile.push(`Empty location plate — ${lock}.`);
teile.push(`Framing: ${winkel}.`);
teile.push('No people, no hands, no products, no bottles or jars, no branding. The main surface in the centre of the frame is deliberately clear and empty, ready to be dressed later.');
teile.push('Deep enough focus that the back of the space stays readable. True atmospheric perspective with visible air between planes. Photographed on a real camera, soft natural film grain, no HDR overprocessing, no digital oversharpening.');
// Schluss-Klammer wie bei Person und Produkt. Fehlte hier ohne sie
// driftet die Kulisse über die vier Varianten auseinander, und genau das
// soll die Identity-Lock-Klammer verhindern (§8).
teile.push(`The location must match exactly: ${lock}.`);
}
// Bei einer zweiten Runde sagt der Grund, was anders werden soll. Ohne ihn
// wäre die Wiederholung nur ein neuer Würfelwurf mit demselben Text.
if (grund) teile.push(`Correction for this round — apply deliberately: ${grund}`);
// Beim Logo-Entwurf wären beide Zusätze widersinnig: die globalen Regeln
// sagen „das Logo nie generieren" (sie meinen: nicht in Szenen verfremden),
// und ein Wortmarken-Logo besteht gerade aus Text. Für alle anderen Typen
// bleiben sie bindend.
if (typ !== 'logo') {
if (regeln?.length) teile.push(regelnEnglisch(regeln));
teile.push('No text, no logo, no watermark in the image.');
}
return teile.filter(Boolean).join('\n');
}
/**
* Liest Breite und Höhe aus den Bilddaten selbst.
*
* Nötig, weil nicht jeder Anbieter die angeforderte Größe liefert: an
* OpenRouter lässt sich derzeit gar kein Format übergeben, das Modell
* entscheidet selbst. Würde man die *angeforderte* Größe speichern, stünde in
* `post_images`/`asset_versions` eine Zahl, die nicht zur Datei passt und
* jedes Layout, das sich darauf verlässt, rechnet falsch.
*/
function masseAusBytes(buf) {
// PNG: IHDR beginnt fest bei Byte 16
if (buf.length > 24 && buf.readUInt32BE(0) === 0x89504e47) {
return [buf.readUInt32BE(16), buf.readUInt32BE(20)];
}
// JPEG: bis zum ersten SOF-Marker laufen
if (buf.length > 4 && buf[0] === 0xff && buf[1] === 0xd8) {
let i = 2;
while (i + 9 < buf.length) {
if (buf[i] !== 0xff) { i++; continue; }
const marker = buf[i + 1];
// SOF0SOF15, ohne DHT(c4), JPG(c8) und DAC(cc)
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
return [buf.readUInt16BE(i + 7), buf.readUInt16BE(i + 5)];
}
i += 2 + buf.readUInt16BE(i + 2);
}
}
return null;
}
/**
* Lädt eine Datei aus einem Bucket und gibt sie als data:-URL zurück.
* Der Server-Key darf das; die Zeilenrechte gelten für Clients.
*/
async function dateiAlsDataUrl(bucketId, fileId) {
const res = await fetch(`${ENDPOINT}/storage/buckets/${bucketId}/files/${fileId}/view?project=${PROJECT}`, {
headers: { 'X-Appwrite-Project': PROJECT, 'X-Appwrite-Key': KEY },
});
if (!res.ok) throw new Error(`Referenzbild ${fileId}: HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
const typ = res.headers.get('content-type')?.split(';')[0] || 'image/png';
return `data:${typ};base64,${buf.toString('base64')}`;
}
/**
* Sammelt die Bild-Referenzen eines Assets.
*
* Reihenfolge mit Absicht: Zuerst das Bild der **freigegebenen** Version das
* ist der verbindliche Stand. Erst danach die hochgeladenen Vorlagen. Ohne
* echte Bild-Eingabe bliebe nur der Merkmalstext, und genau daran driftet die
* Identität zwischen Modell und fertiger Szene auseinander.
*
* Höchstens vier: mehr mittelt die Merkmale weg statt sie zu schärfen (§8).
*/
async function referenzenLaden(asset, version, max = 4) {
const ids = [];
if (version?.bild_file_id) ids.push([BUCKET, version.bild_file_id]);
for (const fid of version?.reference_file_ids ?? []) ids.push(['asset-references', fid]);
const urls = [];
for (const [bucket, fid] of ids.slice(0, max)) {
try { urls.push(await dateiAlsDataUrl(bucket, fid)); } catch { /* fehlende Datei überspringen */ }
}
return urls;
}
// ---- Anbieter -------------------------------------------------------------
async function bildErzeugen(prompt, [breite, hoehe]) {
/**
* Erzeugt ein Bild und wählt bei `auto` selbst den Anbieter.
*
* Unterschieden wird bewusst zwischen „Anbieter nicht verfügbar" (Konto nicht
* freigeschaltet, kein Guthaben) und „Aufruf fehlgeschlagen". Nur beim ersten
* Fall wird weitergereicht ein inhaltlicher Fehler soll sichtbar bleiben und
* nicht stillschweigend vom nächsten Anbieter überdeckt werden.
*/
async function bildErzeugen(prompt, masse, refBilder = []) {
if (ANBIETER !== 'auto') return bildErzeugenMit(ANBIETER, prompt, masse, refBilder);
let letzter = null;
for (const kandidat of KETTE) {
if (ausgefallen.has(kandidat)) continue;
try {
const ergebnis = await bildErzeugenMit(kandidat, prompt, masse, refBilder);
return { ...ergebnis, anbieter: kandidat };
} catch (e) {
const meldung = String(e.message);
const nichtVerfuegbar = /ModelNotOpen|Insufficient credits|AuthenticationError|401|402|fehlt/i.test(meldung);
if (!nichtVerfuegbar) throw e;
ausgefallen.add(kandidat);
console.log(` (${kandidat} nicht verfügbar: ${meldung.slice(0, 70)} weiter mit ${KETTE[KETTE.indexOf(kandidat) + 1] ?? ''})`);
letzter = e;
}
}
throw letzter ?? new Error('Kein Anbieter verfügbar');
}
async function bildErzeugenMit(ANBIETER, prompt, [breite, hoehe], refBilder = []) {
if (ANBIETER === 'stub') {
// Ruhiger Verlauf mit Rasterlinien erkennbar als Platzhalter, aber ein
// echtes Bild, damit Upload, Anzeige und Kettenlogik geprüft werden können.
@@ -117,12 +435,17 @@ async function bildErzeugen(prompt, [breite, hoehe]) {
}
if (ANBIETER === 'ark') {
if (!process.env.ARK_APAC_API_KEY || !process.env.ARK_APAC_BASE_URL) {
throw new Error('ARK_APAC_API_KEY oder ARK_APAC_BASE_URL fehlt');
}
const r = await fetch(`${process.env.ARK_APAC_BASE_URL}/images/generations`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.ARK_APAC_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: process.env.ARK_IMAGE_MODEL || 'seedream-5-0-260128',
prompt, size: `${breite}x${hoehe}`, response_format: 'url', watermark: false,
// Seedream nimmt Referenzbilder direkt entgegen.
...(refBilder.length ? { image: refBilder } : {}),
}),
});
const j = await r.json();
@@ -134,13 +457,20 @@ async function bildErzeugen(prompt, [breite, hoehe]) {
}
if (ANBIETER === 'openrouter') {
if (!process.env.OPENROUTER_API_KEY) throw new Error('OPENROUTER_API_KEY fehlt');
const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: process.env.OPENROUTER_IMAGE_MODEL || 'google/gemini-3-pro-image',
modalities: ['image', 'text'],
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
messages: [{
role: 'user',
content: [
{ type: 'text', text: prompt },
...refBilder.map((url) => ({ type: 'image_url', image_url: { url } })),
],
}],
}),
});
const j = await r.json();
@@ -169,10 +499,16 @@ function hsl(h, s, l) {
async function main() {
console.log(`Anbieter: ${ANBIETER}\n`);
const jobs = (await zeilen('jobs', limit(MAX_JOBS), q({ method: 'orderAsc', attribute: '$createdAt' })))
.filter((j) => j.typ === 'bild_gen' && j.status === 'wartend');
// Filter gehört in die Abfrage, nicht dahinter: `limit` greift serverseitig
// ZUERST. Wer erst N Zeilen holt und dann filtert, findet in einer gefüllten
// Tabelle irgendwann gar keine wartenden Jobs mehr.
const jobs = await zeilen('jobs',
q({ method: 'equal', attribute: 'status', values: ['wartend'] }),
q({ method: 'equal', attribute: 'typ', values: ['bild_gen', 'modell_gen'] }),
q({ method: 'orderAsc', attribute: '$createdAt' }),
limit(MAX_JOBS));
if (!jobs.length) { console.log('Keine wartenden bild_gen-Jobs.'); return; }
if (!jobs.length) { console.log('Keine wartenden Generierungs-Jobs.'); return; }
console.log(`${jobs.length} wartende(r) Job(s).\n`);
const postCache = new Map();
@@ -181,6 +517,27 @@ async function main() {
for (const job of jobs) {
let refs = {};
try { refs = JSON.parse(job.refs || '{}'); } catch { /* leer lassen */ }
if (job.typ === 'modell_gen') {
const kennung = `Modell ${refs.asset_id ?? '?'}#${refs.variante ?? '?'}`;
try {
await aendern('jobs', job.$id, { status: 'laeuft' });
const info = await modellJobAusfuehren(job, refs);
fertig++;
console.log(`${kennung} ${info}`);
} catch (e) {
fehler++;
const meldung = String(e.message).slice(0, 500);
console.log(`${kennung} ${meldung}`);
// Version bleibt bewusst `entwurf`. Sie auf `archiviert` zu setzen wäre
// falsch: „archiviert" heißt im Modell „vom Nutzer verworfen". Ein
// fehlgeschlagener Job ist etwas anderes der Grund steht im Job, und
// die Variante ist wiederholbar.
await aendern('jobs', job.$id, { status: 'fehler', error: meldung }).catch(() => {});
}
continue;
}
const kennung = `${refs.post_id ?? '?'}#${refs.position ?? '?'}`;
try {
@@ -197,11 +554,28 @@ async function main() {
const assets = {};
for (const id of [slots.kulisse_asset_id, slots.produkt_asset_id, slots.person_asset_id].filter(Boolean)) {
const a = await zeile('assets', id);
let beschreibung = '';
// Immer die **freigegebene** Version nie die neueste. Sonst driftet
// das Aussehen unbemerkt mit jedem Entwurf (Release-Prinzip, §4).
let beschreibung = '', merkmale = [];
if (a.released_version_id) {
try { beschreibung = (await zeile('asset_versions', a.released_version_id)).beschreibung_md ?? ''; } catch { /* egal */ }
try {
const v = await zeile('asset_versions', a.released_version_id);
beschreibung = v.beschreibung_md ?? '';
merkmale = v.merkmale ?? [];
} catch { /* egal */ }
}
assets[id] = { name: a.name, typ: a.typ, beschreibung };
assets[id] = { name: a.name, typ: a.typ, beschreibung, merkmale };
}
// Bild-Referenzen der freigegebenen Versionen. Ohne sie kennt das Modell
// nur den Merkmalstext daran driftet die Identität auseinander.
const refBilder = [];
for (const id of [slots.person_asset_id, slots.produkt_asset_id, slots.kulisse_asset_id].filter(Boolean)) {
const a = await zeile('assets', id);
if (!a.released_version_id) continue;
try {
const v = await zeile('asset_versions', a.released_version_id);
refBilder.push(...(await referenzenLaden(a, v, 1)));
} catch { /* ohne Referenz weiter */ }
}
// Die besten Attribute **des gewählten Ordners** nicht die der Marke.
// Der Ordner überschreibt die brand-weite Ebene vollständig (§9).
@@ -215,14 +589,14 @@ async function main() {
try { attribute.push(await zeile('attributes', s.attribute_id)); } catch { /* egal */ }
}
const regeln = await zeilen('rules', limit(10));
kontext = { slots, assets, attribute, regeln };
kontext = { slots, assets, attribute, regeln, refBilder };
postCache.set(post.$id, kontext);
}
const masse = FORMAT_MASSE[post.format] ?? FORMAT_MASSE['4:5'];
const prompt = promptBauen({ post, ...kontext, position: refs.position ?? 1 });
const { bytes, typ, kosten, masse: echteMasse } = await bildErzeugen(prompt, masse);
const [bw, bh] = echteMasse ?? masse;
const { bytes, typ, kosten, masse: echteMasse, anbieter } = await bildErzeugen(prompt, masse, kontext.refBilder ?? []);
const [bw, bh] = masseAusBytes(bytes) ?? echteMasse ?? masse;
const fd = new FormData();
fd.append('fileId', 'unique()');
@@ -240,7 +614,7 @@ async function main() {
breite: bw, hoehe: bh,
}, rechte);
await aendern('jobs', job.$id, { status: 'fertig', cost_usd: kosten });
await aendern('jobs', job.$id, { status: 'fertig', cost_usd: kosten, prompt_sent: prompt });
const bisher = await zeilen('post_images', limit(50),
q({ method: 'equal', attribute: 'post_id', values: [post.$id] }));
@@ -249,7 +623,7 @@ async function main() {
}
fertig++;
console.log(`${kennung} ${(bytes.length / 1024).toFixed(0)} KB, ${bw}×${bh}`);
console.log(`${kennung} ${(bytes.length / 1024).toFixed(0)} KB, ${bw}×${bh} [${anbieter ?? ANBIETER}]`);
} catch (e) {
fehler++;
const meldung = String(e.message).slice(0, 500);
@@ -263,4 +637,115 @@ async function main() {
if (fehler) process.exit(1);
}
/**
* Erzeugt **eine Variante** eines Modells und hängt sie an ihre Entwurfs-Version.
*
* Freigegeben wird hier nichts: welche Variante gewinnt, entscheidet der Nutzer
* im Ergebnis-Screen. Das ist das Release-Prinzip aus §4 Bilder verwenden
* immer eine bewusst freigegebene Version, nie einfach die neueste.
*/
async function modellJobAusfuehren(job, refs) {
const asset = await zeile('assets', refs.asset_id);
const version = await zeile('asset_versions', refs.version_id);
const brand = await zeile('brands', asset.brand_id);
const rolle = `team:${brand.team_id}`;
const rechte = [`read("${rolle}")`, `update("${rolle}")`, `delete("${rolle}")`];
// Die Brand-Regel schützt ein **vorhandenes** Logo davor, verfremdet zu
// werden. Ein Entwurf für eine Marke ohne Logo verletzt sie nicht ein
// bereits hochgeladenes oder freigegebenes Logo neu zu erzeugen dagegen schon.
if (asset.typ === 'logo') {
if (asset.released_version_id) {
throw new Error('Dieses Logo ist bereits freigegeben und wird laut Brand-Regel nicht neu generiert nur unverändert eingesetzt.');
}
if ((version.reference_file_ids ?? []).length) {
throw new Error('Zu diesem Logo wurde eine Datei hochgeladen. Sie wird unverändert verwendet, nicht neu interpretiert.');
}
}
const regeln = await zeilen('rules', limit(10));
const grund = version.change_reason && !/^Variante \d+ der ersten Runde$/.test(version.change_reason)
? version.change_reason : '';
const variante = refs.variante ?? 1;
const winkel = (VARIANTEN[asset.typ] ?? VARIANTEN.produkt)[(variante - 1) % 6];
// Erst P7 fragen das ist der versionierte Prompt aus der Datenbank und
// damit die verbindliche Quelle. Nur wenn kein LLM erreichbar ist, greift der
// deterministische Zusammenbau als Notlauf.
const p7 = await p7Laden();
let prompt = null;
let quelle = 'Zusammenbau';
if (p7?.static_core_md) {
const gefuellt = slotsFuellen(p7.static_core_md, {
USER_PROMPT: `Erzeuge ein wiederverwendbares Referenzbild für das Modell „${asset.name}" (Typ: ${asset.typ}). Variante ${variante} von ${VARIANTEN_ANZAHL}: ${winkel}. Neutrale Referenz, keine Werbeszene.`,
REGELN: regelnEnglisch(regeln),
ASSETS: JSON.stringify([{
name: asset.name,
typ: asset.typ,
merkmale: version.merkmale ?? [],
beschreibung: version.beschreibung_md ?? '',
referenz_ids: version.reference_file_ids ?? [],
}], null, 1),
// Beim Anlegen gibt es kein Ordner-Wissen. Beim Nachschärfen dagegen ist
// die Korrektur das Wichtigste am ganzen Auftrag sie muss ausdrücklich
// Vorrang vor den Standardvorgaben von P7 bekommen, sonst gewinnt dessen
// eigener Satz („Neutral relaxed expression, no smile") gegen den Wunsch
// des Nutzers.
ATTRIBUTE: '',
ANWEISUNGEN: grund
? `KORREKTUR FÜR DIESE RUNDE — hat Vorrang vor allen Standardvorgaben dieses Prompts, auch vor Vorgaben zu Mimik, Licht und Blickrichtung: ${grund}`
: '',
});
const erzeugt = await p7Ausfuehren(gefuellt);
if (erzeugt) { prompt = erzeugt; quelle = `P7 v${p7.version}`; }
}
if (!prompt) {
prompt = promptFuerModell({
typ: asset.typ,
name: asset.name,
beschreibung: version.beschreibung_md ?? '',
merkmale: version.merkmale ?? [],
regeln,
variante,
});
}
// Gesichter im 3:4-Headshot (P07: „3:4-Headshot"), Produkte und Kulissen
// quadratisch sie sind Referenz, kein fertiges Motiv.
const masse = asset.typ === 'gesicht' ? [864, 1152] : [1024, 1024];
// Die freigegebene Version ist der verbindliche Stand eine neue Runde muss
// an ihr ankern, nicht nur am ursprünglichen Upload. Sonst driftet das
// Aussehen mit jeder Runde weiter weg.
let freigegeben = null;
if (asset.released_version_id && asset.released_version_id !== version.$id) {
try { freigegeben = await zeile('asset_versions', asset.released_version_id); } catch { /* egal */ }
}
const refBilder = [
...(freigegeben ? await referenzenLaden(asset, freigegeben, 1) : []),
...(await referenzenLaden(asset, version, 3)),
].slice(0, 4);
const { bytes, typ, kosten, masse: echteMasse, anbieter } = await bildErzeugen(prompt, masse, refBilder);
const [bw, bh] = masseAusBytes(bytes) ?? echteMasse ?? masse;
const fd = new FormData();
fd.append('fileId', 'unique()');
fd.append('file', new Blob([bytes], { type: typ }), `modell-${asset.$id}-v${version.version_no}.png`);
for (const p of rechte) fd.append('permissions[]', p);
const datei = await api('POST', `/storage/buckets/${BUCKET}/files`, null, fd);
await aendern('asset_versions', version.$id, { bild_file_id: datei.$id });
await aendern('jobs', job.$id, {
status: 'fertig',
cost_usd: kosten,
prompt_sent: prompt,
// Festhalten, WELCHE Prompt-Fassung dieses Bild erzeugt hat. Ohne das ist
// später nicht rekonstruierbar, warum ein altes Bild anders aussieht.
...(quelle.startsWith('P7') ? { prompt_template_version: p7?.version } : {}),
});
return `${(bytes.length / 1024).toFixed(0)} KB, ${bw}×${bh}, ${asset.typ}${asset.name}" v${version.version_no} [${anbieter ?? ANBIETER} · ${quelle}]`;
}
main().catch((e) => { console.error(`\nAbbruch: ${e.message}`); process.exit(1); });

View File

@@ -283,7 +283,11 @@ const TABLES = [
str('merkmale', 255, { array: true }),
str('change_reason', 512),
id('won_against_version_id'), // Vorher/Nachher-Vote
id('reference_file_ids', { array: true }), // → Bucket asset-references
id('reference_file_ids', { array: true }), // EINGABE: hochgeladene Referenzen
// ERGEBNIS: das für diese Version erzeugte Bild. Bewusst getrennt von
// `reference_file_ids` sonst ließe sich später nicht mehr unterscheiden,
// was der Nutzer beigesteuert und was das KI-Modell erzeugt hat.
id('bild_file_id'), // → Bucket generated-images
id('einwilligung_file_id'), // → Bucket consents
],
indexes: [{ key: 'idx_asset', type: 'key', columns: ['asset_id', 'version_no'] }],
@@ -362,9 +366,19 @@ const TABLES = [
enm('typ', [
'bild_gen', 'video_gen', 'tagging', 'vergleich', 'script',
'slot_analyse', 'ordner_vorschlag', 'werbetext', // P19, P20, P21
// Eigener Typ statt `bild_gen` mitzubenutzen: Modell-Generierung hat
// andere Kosten, andere Fehlerbilder und einen anderen Ergebnis-Screen.
// Alles über `refs` zu unterscheiden, macht Kostenauswertung und
// Prompt-Debugging unnötig mühsam.
'modell_gen',
], { required: true }),
str('prompt_template_key', 16), // welcher Prompt …
int('prompt_template_version'), // … in welcher Version lief
// 🔒 Was wirklich ans Modell ging. projekt-uebersicht.md §9 führt
// `jobs.prompt_sent` als eines der drei Felder, die exklusiv dem internen
// Team gehören im Schema fehlte es bisher. Ohne dieses Feld ist bei
// Job-Typen ohne eigene Ergebnis-Tabelle kein Prompt-Debugging möglich.
md('prompt_sent'),
enm('status', ['wartend', 'laeuft', 'fertig', 'fehler'], { default: 'wartend' }),
flt('cost_usd'), int('tokens'),
str('error', 4096),
@@ -540,6 +554,63 @@ const BUCKETS = [
{ id: 'generated-images', name: 'Generierte Bilder' },
];
/**
* Zieht fehlende Enum-Werte auf einer bestehenden Spalte nach.
*
* Der Plan hat das lange als gefährlich geführt (§2.6: „je nach Version Löschen
* und Neuanlegen der Spalte, also Datenverlust"). **Für Appwrite 1.8.1 ist das
* am 15.08.2026 gegen eine Wegwerf-Tabelle geprüft worden:** `PATCH` auf die
* Enum-Spalte liefert 200, alle Bestandswerte überleben, der neue Wert ist
* sofort nutzbar. Bei einem Server-Upgrade gehört dieser Test wiederholt.
*
* Bewusst nur **additiv**: Werte, die live existieren und im Skript fehlen,
* werden gemeldet, aber nie entfernt sonst verlören bestehende Zeilen ihren
* Wert, und genau davor warnt der Plan zu Recht.
*/
/**
* Enum-Spalten, die **nie** automatisch nachgezogen werden.
*
* `prompt_templates.key` ist mit 22 Zeilen gefüllt und trägt die Identität
* jedes Prompts. Selbst wenn der additive PATCH technisch hält hier ist jede
* Enum-Berührung ein Migrationsprojekt mit Prüfung, kein Skriptlauf.
*/
const ENUM_TABU = new Set(['prompt_templates.key']);
async function enumNachziehen(tableId, c, live) {
const key = c.body.key;
if (c.type !== 'enum') { skipped++; console.log(` = ${tableId}.${key} (existiert)`); return; }
if (ENUM_TABU.has(`${tableId}.${key}`)) {
const fehlt = (c.body.elements ?? []).filter((e) => !(live.elements ?? []).includes(e));
if (fehlt.length) {
warned++;
console.warn(` ! ${tableId}.${key}: [${fehlt.join(', ')}] fehlen NICHT automatisch geändert (Tabu-Liste), bitte bewusst migrieren`);
} else { skipped++; console.log(` = ${tableId}.${key} (existiert)`); }
return;
}
const soll = c.body.elements ?? [];
const ist = live.elements ?? [];
const fehlend = soll.filter((e) => !ist.includes(e));
const zusaetzlich = ist.filter((e) => !soll.includes(e));
if (zusaetzlich.length) {
warned++;
console.warn(` ! ${tableId}.${key}: live zusätzlich [${zusaetzlich.join(', ')}] bleibt stehen, wird nicht entfernt`);
}
if (!fehlend.length) { skipped++; console.log(` = ${tableId}.${key} (existiert)`); return; }
const body = {
elements: [...ist, ...fehlend],
required: !!c.body.required,
default: c.body.required ? null : (c.body.default ?? null),
};
await ensure(`${tableId}.${key} +[${fehlend.join(',')}]`, 'PATCH',
`/tablesdb/${DB_ID}/tables/${tableId}/columns/enum/${key}`, body);
updated++; created--; // ensure() zählt als "angelegt" hier ist es eine Änderung
await waitForColumn(tableId, key);
}
// ---- Ablauf ---------------------------------------------------------------
async function waitForColumn(tableId, key) {
for (let i = 0; i < 30; i++) {
@@ -592,8 +663,11 @@ async function main() {
for (const c of t.columns) {
// Erst GET: Appwrite prüft das Zeilengrößen-Limit VOR dem Duplikat-Check,
// ein erneutes POST auf große Spalten gäbe sonst 400 statt 409.
const { status } = await api('GET', `/tablesdb/${DB_ID}/tables/${t.id}/columns/${c.body.key}`);
if (status === 200) { skipped++; console.log(` = ${t.id}.${c.body.key} (existiert)`); continue; }
const { status, json } = await api('GET', `/tablesdb/${DB_ID}/tables/${t.id}/columns/${c.body.key}`);
if (status === 200) {
await enumNachziehen(t.id, c, json);
continue;
}
await ensure(`${t.id}.${c.body.key}`, 'POST', `/tablesdb/${DB_ID}/tables/${t.id}/columns/${c.type}`, c.body);
}
}