Files
videogen/scripts/job-dispatcher.mjs
2026-08-15 13:43:52 +02:00

267 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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); });