#!/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); });