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

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