ki integration
This commit is contained in:
266
scripts/job-dispatcher.mjs
Normal file
266
scripts/job-dispatcher.mjs
Normal file
@@ -0,0 +1,266 @@
|
||||
#!/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';
|
||||
const ANBIETER = process.env.BILD_ANBIETER || 'stub';
|
||||
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 });
|
||||
|
||||
// ---- Prompt-Bau (Vorstufe von P7) ----------------------------------------
|
||||
const FORMAT_MASSE = { '1:1': [1024, 1024], '4:5': [896, 1120], '9:16': [768, 1365] };
|
||||
|
||||
/**
|
||||
* 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];
|
||||
|
||||
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 (attribute.length) {
|
||||
teile.push(`Bewährt für diese Marke: ${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]}.`);
|
||||
|
||||
if (regeln.length) teile.push(regeln.map((r) => r.prompt_text).filter(Boolean).join(' '));
|
||||
|
||||
teile.push('Kein Text, kein Logo, kein Wasserzeichen im Bild.');
|
||||
return teile.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
// ---- Anbieter -------------------------------------------------------------
|
||||
async function bildErzeugen(prompt, [breite, hoehe]) {
|
||||
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') {
|
||||
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,
|
||||
}),
|
||||
});
|
||||
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') {
|
||||
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 }] }],
|
||||
}),
|
||||
});
|
||||
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`);
|
||||
|
||||
const jobs = (await zeilen('jobs', limit(MAX_JOBS), q({ method: 'orderAsc', attribute: '$createdAt' })))
|
||||
.filter((j) => j.typ === 'bild_gen' && j.status === 'wartend');
|
||||
|
||||
if (!jobs.length) { console.log('Keine wartenden bild_gen-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 */ }
|
||||
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);
|
||||
let beschreibung = '';
|
||||
if (a.released_version_id) {
|
||||
try { beschreibung = (await zeile('asset_versions', a.released_version_id)).beschreibung_md ?? ''; } catch { /* egal */ }
|
||||
}
|
||||
assets[id] = { name: a.name, typ: a.typ, beschreibung };
|
||||
}
|
||||
// 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 };
|
||||
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 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 });
|
||||
|
||||
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}`);
|
||||
} 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);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(`\nAbbruch: ${e.message}`); process.exit(1); });
|
||||
Reference in New Issue
Block a user