752 lines
35 KiB
JavaScript
752 lines
35 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* BrandLoop – Job-Dispatcher für `bild_gen`.
|
||
*
|
||
* Arbeitet die Warteschlange aus `jobs` ab: baut aus dem Slot-Rezept und dem
|
||
* Wissen des gewählten Ordners einen Prompt, lässt ein Bild erzeugen, legt es
|
||
* im Bucket `generated-images` ab und schreibt eine `post_images`-Zeile.
|
||
*
|
||
* **Warum serverseitig:** Der Anbieter-Schlüssel darf nicht ins Client-Bundle –
|
||
* aus einer Web-App ließe er sich sonst auslesen. Später wird daraus eine
|
||
* Appwrite-Function; bis dahin läuft dasselbe Skript lokal oder per Cron.
|
||
*
|
||
* Aufruf:
|
||
* node scripts/job-dispatcher.mjs # Anbieter aus BILD_ANBIETER
|
||
* BILD_ANBIETER=stub node scripts/job-dispatcher.mjs
|
||
*
|
||
* Anbieter:
|
||
* ark Seedream über BytePlus ModelArk
|
||
* openrouter Bildmodelle über OpenRouter
|
||
* stub erzeugt ein Platzhalterbild – zum Prüfen der Kette ohne Kosten
|
||
*/
|
||
import { pngErzeugen } from './lib/png.mjs';
|
||
|
||
const ENDPOINT = process.env.APPWRITE_ENDPOINT || 'https://appwrite.webklar.com/v1';
|
||
const PROJECT = process.env.APPWRITE_PROJECT || '6a5cee34002bb8360c34';
|
||
const DB = 'brandloop';
|
||
const BUCKET = 'generated-images';
|
||
/**
|
||
* `auto` (Standard) probiert der Reihe nach: Ark → OpenRouter → Platzhalter.
|
||
*
|
||
* Ark steht vorn, weil Seedream mit 0,045–0,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;
|
||
if (!KEY) { console.error('APPWRITE_API_KEY fehlt.'); process.exit(1); }
|
||
|
||
// ---- Appwrite -------------------------------------------------------------
|
||
const q = (o) => `queries[]=${encodeURIComponent(JSON.stringify(o))}`;
|
||
const limit = (n) => q({ method: 'limit', values: [n] });
|
||
|
||
async function api(method, path, body, form) {
|
||
const headers = { 'X-Appwrite-Project': PROJECT, 'X-Appwrite-Key': KEY };
|
||
if (!form) headers['Content-Type'] = 'application/json';
|
||
const res = await fetch(`${ENDPOINT}${path}`, {
|
||
method, headers, body: form ?? (body ? JSON.stringify(body) : undefined),
|
||
});
|
||
const text = await res.text();
|
||
let json; try { json = JSON.parse(text); } catch { json = { message: text }; }
|
||
if (res.status >= 300) throw new Error(`${method} ${path} → ${res.status} ${json.message}`);
|
||
return json;
|
||
}
|
||
|
||
const zeilen = (tabelle, ...queries) =>
|
||
api('GET', `/tablesdb/${DB}/tables/${tabelle}/rows?${queries.join('&')}`).then((r) => r.rows);
|
||
const zeile = (tabelle, id) => api('GET', `/tablesdb/${DB}/tables/${tabelle}/rows/${id}`);
|
||
const anlegen = (tabelle, data, permissions) =>
|
||
api('POST', `/tablesdb/${DB}/tables/${tabelle}/rows`, { rowId: 'unique()', data, permissions });
|
||
const aendern = (tabelle, id, data) =>
|
||
api('PATCH', `/tablesdb/${DB}/tables/${tabelle}/rows/${id}`, { data });
|
||
|
||
// ---- 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
|
||
* Prompt-Architektur sie vorsieht: Regeln, Assets, Attribute, User-Input.
|
||
*
|
||
* Das ist bewusst noch **nicht** P7: der echte Prompt ist versioniert und liegt
|
||
* in `prompt_templates`. Hier steht die Verdrahtung, damit die Kette prüfbar
|
||
* ist; der Text wird ersetzt, sobald P7 auf das Slot-System umgebaut ist.
|
||
*/
|
||
function promptBauen({ post, slots, assets, attribute, regeln, position }) {
|
||
const teile = [];
|
||
|
||
const kulisse = assets[slots.kulisse_asset_id];
|
||
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(`Setting: ${lock(kulisse)}`);
|
||
if (produkt) teile.push(`Product, exactly as described: ${lock(produkt)}`);
|
||
|
||
if (attribute.length) {
|
||
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 = ['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(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)}.`);
|
||
|
||
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. 4–6 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];
|
||
// SOF0–SOF15, 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 -------------------------------------------------------------
|
||
|
||
/**
|
||
* 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.
|
||
const saat = [...prompt].reduce((a, c) => (a * 31 + c.charCodeAt(0)) >>> 0, 7);
|
||
const h = saat % 360;
|
||
// Viertelgröße: der Platzhalter soll die Kette prüfen, nicht Rechenzeit
|
||
// verbrauchen. Gemeldet werden die **tatsächlichen** Maße – sonst stünde in
|
||
// `post_images` eine Zahl, die nicht zur Datei passt.
|
||
const [bw, bh] = [breite >> 2, hoehe >> 2];
|
||
return {
|
||
bytes: pngErzeugen(bw, bh, (x, y) => {
|
||
const t = y / bh;
|
||
const raster = x % 32 === 0 || y % 32 === 0 ? 18 : 0;
|
||
return hsl(h, 0.22, 0.14 + t * 0.2 + raster / 255);
|
||
}),
|
||
typ: 'image/png',
|
||
masse: [bw, bh],
|
||
kosten: 0,
|
||
};
|
||
}
|
||
|
||
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();
|
||
if (j.error) throw new Error(`${j.error.code}: ${j.error.message}`);
|
||
const url = j.data?.[0]?.url;
|
||
if (!url) throw new Error('Ark lieferte keine Bilddaten');
|
||
const bild = await fetch(url);
|
||
return { bytes: Buffer.from(await bild.arrayBuffer()), typ: 'image/jpeg', kosten: 0 };
|
||
}
|
||
|
||
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 },
|
||
...refBilder.map((url) => ({ type: 'image_url', image_url: { url } })),
|
||
],
|
||
}],
|
||
}),
|
||
});
|
||
const j = await r.json();
|
||
if (j.error) throw new Error(j.error.message ?? JSON.stringify(j.error));
|
||
const daten = j.choices?.[0]?.message?.images?.[0]?.image_url?.url;
|
||
if (!daten) throw new Error('OpenRouter lieferte keine Bilddaten');
|
||
const bytes = daten.startsWith('data:')
|
||
? Buffer.from(daten.split(',')[1], 'base64')
|
||
: Buffer.from(await (await fetch(daten)).arrayBuffer());
|
||
return { bytes, typ: 'image/png', kosten: j.usage?.cost ?? 0 };
|
||
}
|
||
|
||
throw new Error(`Unbekannter Anbieter "${ANBIETER}"`);
|
||
}
|
||
|
||
function hsl(h, s, l) {
|
||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||
const m = l - c / 2;
|
||
const [r, g, b] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x]
|
||
: h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x];
|
||
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
|
||
}
|
||
|
||
// ---- Ablauf ---------------------------------------------------------------
|
||
async function main() {
|
||
console.log(`Anbieter: ${ANBIETER}\n`);
|
||
|
||
// 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 Generierungs-Jobs.'); return; }
|
||
console.log(`${jobs.length} wartende(r) Job(s).\n`);
|
||
|
||
const postCache = new Map();
|
||
let fertig = 0, fehler = 0;
|
||
|
||
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 {
|
||
await aendern('jobs', job.$id, { status: 'laeuft' });
|
||
|
||
const post = await zeile('posts', refs.post_id);
|
||
const brand = await zeile('brands', post.brand_id);
|
||
const rolle = `team:${brand.team_id}`;
|
||
const rechte = [`read("${rolle}")`, `update("${rolle}")`, `delete("${rolle}")`];
|
||
|
||
let kontext = postCache.get(post.$id);
|
||
if (!kontext) {
|
||
const slots = JSON.parse(post.slots || '{}');
|
||
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);
|
||
// 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 {
|
||
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, 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).
|
||
const scores = post.folder_id
|
||
? await zeilen('attribute_scores', limit(8),
|
||
q({ method: 'equal', attribute: 'folder_id', values: [post.folder_id] }),
|
||
q({ method: 'orderDesc', attribute: 'score' }))
|
||
: [];
|
||
const attribute = [];
|
||
for (const s of scores.slice(0, 6)) {
|
||
try { attribute.push(await zeile('attributes', s.attribute_id)); } catch { /* egal */ }
|
||
}
|
||
const regeln = await zeilen('rules', limit(10));
|
||
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, anbieter } = await bildErzeugen(prompt, masse, kontext.refBilder ?? []);
|
||
const [bw, bh] = masseAusBytes(bytes) ?? echteMasse ?? masse;
|
||
|
||
const fd = new FormData();
|
||
fd.append('fileId', 'unique()');
|
||
fd.append('file', new Blob([bytes], { type: typ }), `post-${post.$id}-${refs.position}.png`);
|
||
for (const p of rechte) fd.append('permissions[]', p);
|
||
const datei = await api('POST', `/storage/buckets/${BUCKET}/files`, null, fd);
|
||
|
||
await anlegen('post_images', {
|
||
post_id: post.$id,
|
||
brand_id: post.brand_id,
|
||
position: refs.position ?? 1,
|
||
typ: 'motiv',
|
||
storage_file_id: datei.$id,
|
||
prompt_sent: prompt, // 🔒 nur eigenes Team – das ist das Betriebsgeheimnis
|
||
breite: bw, hoehe: bh,
|
||
}, rechte);
|
||
|
||
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] }));
|
||
if (bisher.length >= (post.bild_count ?? 1)) {
|
||
await aendern('posts', post.$id, { status: 'generiert' });
|
||
}
|
||
|
||
fertig++;
|
||
console.log(` ✓ ${kennung} ${(bytes.length / 1024).toFixed(0)} KB, ${bw}×${bh} [${anbieter ?? ANBIETER}]`);
|
||
} catch (e) {
|
||
fehler++;
|
||
const meldung = String(e.message).slice(0, 500);
|
||
console.log(` ✗ ${kennung} ${meldung}`);
|
||
await aendern('jobs', job.$id, { status: 'fehler', error: meldung }).catch(() => {});
|
||
if (refs.post_id) await aendern('posts', refs.post_id, { status: 'fehler' }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
console.log(`\n${fertig} erledigt, ${fehler} fehlgeschlagen.`);
|
||
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); });
|