bilder gen

This commit is contained in:
knso committed 2026-08-17 23:41:49 +02:00
1 parent 9ecb292c26
commit f4aac46ea0
9 files changed
+1381 -93

No files matched your search

+254 -32
View File
@@ -1,5 +1,5 @@
import { ID, Query, databaseId, tables } from './appwrite';
import { BUCKET_REFERENZEN as BUCKET } from './dateien';
import { BUCKET_BILDER, BUCKET_REFERENZEN as BUCKET } from './dateien';
import { teamRechte } from './permissions';
import { hochladen, type Auswahl } from './upload';
@@ -26,7 +26,11 @@ export type AssetVersion = {
status: 'entwurf' | 'freigegeben' | 'archiviert';
beschreibung_md?: string;
merkmale?: string[];
change_reason?: string;
/** EINGABE – was der Nutzer hochgeladen hat. */
reference_file_ids?: string[];
/** ERGEBNIS – das für diese Version erzeugte Bild. */
bild_file_id?: string;
};
export const TYPEN: { wert: AssetTyp; titel: string; erklaerung: string; icon: string }[] = [
@@ -64,16 +68,54 @@ export async function versionenZuAsset(assetId: string): Promise<AssetVersion[]>
}
/**
* Legt ein Modell samt erster Version an und gibt sie sofort frei.
* Wieviele Varianten beim Anlegen erzeugt werden.
*
* Release-Prinzip (projekt-uebersicht.md §4): Bilder verwenden immer eine
* *freigegebene* Version, damit nichts unbemerkt wegdriftet. Version 1 wird
* hier direkt freigegeben, weil es sonst nichts gäbe, womit man arbeiten kann.
* Vier, weil §8 vier bis sechs Referenzbilder als Optimum nennt und weil man
* aus vier Vorschlägen noch bewusst auswählt – bei acht klickt man das erste
* Passable an, und das Auswahlsignal wird wertlos.
*/
export const VARIANTEN_JE_MODELL = 4;
/**
* Typen, für die beim Anlegen Varianten erzeugt werden.
*
* **Logo ist ein Sonderfall.** Die Brand-Regel verbietet, das Markenzeichen zu
* verzerren, zu beschneiden, umzufärben oder neu zu interpretieren. Sie schützt
* damit ein **vorhandenes** Logo – eine Marke ohne Logo hat nichts zu schützen.
*
* Daraus folgen zwei Wege, die sich gegenseitig ausschließen:
* - **Logo hochladen** → wird v1 und sofort freigegeben. Ab da wird es nie
* wieder generiert, nur noch unverändert eingesetzt. Das ist der Normalfall.
* - **Logo entwerfen lassen** → nur wenn *keine* Datei hochgeladen wird. Es gibt
* dann kein Original, das verfremdet werden könnte; es entsteht ein Vorschlag.
*/
export const GENERIERBAR: AssetTyp[] = ['gesicht', 'produkt', 'kulisse'];
/** Kann für diesen Typ und diese Eingabe generiert werden? */
export function kannGenerieren(typ: AssetTyp, hatReferenzbilder: boolean): boolean {
if (GENERIERBAR.includes(typ)) return true;
// Logo: nur entwerfen, wenn nichts hochgeladen wurde.
return typ === 'logo' && !hatReferenzbilder;
}
/**
* Legt ein Modell an.
*
* **Logo:** nur hochladen. Eine der globalen Brand-Regeln verbietet es
* ausdrücklich, das Logo generieren zu lassen – es darf nie verfremdet,
* beschnitten oder neu interpretiert werden. Version 1 ist deshalb direkt die
* freigegebene Version mit der hochgeladenen Datei.
*
* **Person, Produkt, Kulisse:** vier Entwurfs-Versionen plus je ein
* `modell_gen`-Job. Freigegeben wird **nichts** – welche Variante gewinnt,
* entscheidet der Nutzer im Ergebnis-Screen. Das ist das Release-Prinzip aus
* §4: es wird immer eine bewusst freigegebene Version verwendet, nie die
* neueste, damit das Aussehen nicht unbemerkt wegdriftet.
*/
export async function assetAnlegen(
brandId: string,
teamId: string,
daten: { typ: AssetTyp; name: string; beschreibung: string; bilder: Auswahl[] },
daten: { typ: AssetTyp; name: string; beschreibung: string; merkmale: string[]; bilder: Auswahl[] },
): Promise<Asset> {
const rechte = teamRechte(teamId);
@@ -90,35 +132,205 @@ export async function assetAnlegen(
permissions: rechte,
})) as unknown as Asset;
const version = (await tables.createRow({
const basis = {
asset_id: asset.$id,
beschreibung_md: daten.beschreibung,
// Token-Locking: diese Zeichenfolgen setzt der Prompt **wörtlich** ein.
// Umschreiben heißt hier, die Identität zu verlieren.
merkmale: daten.merkmale,
reference_file_ids: fileIds,
};
if (!kannGenerieren(daten.typ, fileIds.length > 0)) {
const version = (await tables.createRow({
databaseId,
tableId: 'asset_versions',
rowId: ID.unique(),
data: { ...basis, version_no: 1, status: 'freigegeben' },
permissions: rechte,
})) as unknown as AssetVersion;
await tables.updateRow({
databaseId, tableId: 'assets', rowId: asset.$id,
data: { released_version_id: version.$id },
});
return { ...asset, released_version_id: version.$id };
}
for (let i = 1; i <= VARIANTEN_JE_MODELL; i++) {
const version = (await tables.createRow({
databaseId,
tableId: 'asset_versions',
rowId: ID.unique(),
data: { ...basis, version_no: i, status: 'entwurf', change_reason: `Variante ${i} der ersten Runde` },
permissions: rechte,
})) as unknown as AssetVersion;
await tables.createRow({
databaseId,
tableId: 'jobs',
rowId: ID.unique(),
data: {
brand_id: brandId,
typ: 'modell_gen',
prompt_template_key: 'P7',
status: 'wartend',
refs: JSON.stringify({ asset_id: asset.$id, version_id: version.$id, variante: i }),
},
permissions: rechte,
});
}
return asset;
}
/**
* Startet eine neue Runde Varianten auf einem bestehenden Modell.
*
* Die freigegebene Version bleibt freigegeben, bis der Nutzer bewusst eine neue
* wählt – das Release-Prinzip aus §4 gilt auch hier. Bis dahin arbeitet die App
* unverändert weiter mit dem alten Stand, statt heimlich auf einen Entwurf
* umzuschwenken.
*
* `grund` beschreibt, was anders werden soll. Er landet in `change_reason` und
* geht zusätzlich in den Prompt – ohne ihn wäre eine zweite Runde nur ein
* neuer Würfelwurf mit demselben Text.
*/
export async function neueRunde(
brandId: string,
teamId: string,
assetId: string,
daten: { beschreibung: string; merkmale: string[]; grund: string },
): Promise<void> {
const rechte = teamRechte(teamId);
const vorhanden = await versionenZuAsset(assetId);
const start = Math.max(0, ...vorhanden.map((v) => v.version_no));
// Referenzen der ersten Version mitnehmen – sie sind die Vorlage des Modells.
const referenzen = vorhanden.sort((a, b) => a.version_no - b.version_no)[0]?.reference_file_ids ?? [];
for (let i = 1; i <= VARIANTEN_JE_MODELL; i++) {
const version = (await tables.createRow({
databaseId,
tableId: 'asset_versions',
rowId: ID.unique(),
data: {
asset_id: assetId,
version_no: start + i,
status: 'entwurf',
beschreibung_md: daten.beschreibung,
merkmale: daten.merkmale,
reference_file_ids: referenzen,
change_reason: daten.grund,
},
permissions: rechte,
})) as unknown as AssetVersion;
await tables.createRow({
databaseId,
tableId: 'jobs',
rowId: ID.unique(),
data: {
brand_id: brandId,
typ: 'modell_gen',
prompt_template_key: 'P7',
status: 'wartend',
refs: JSON.stringify({ asset_id: assetId, version_id: version.$id, variante: i }),
},
permissions: rechte,
});
}
}
export type VariantenLage = { status: string; error?: string; variante: number };
/**
* Der Job-Zustand je Version.
*
* Nötig, weil man einer Version selbst nicht ansieht, ob ihr Bild noch aussteht
* oder die Generierung gescheitert ist – beides sieht nach „Entwurf ohne Bild"
* aus. Der Grund steht ausschließlich im Job.
*/
export async function jobsZuAsset(assetId: string): Promise<Map<string, VariantenLage>> {
const res = await tables.listRows({
databaseId,
tableId: 'asset_versions',
tableId: 'jobs',
queries: [Query.equal('typ', 'modell_gen'), Query.orderDesc('$createdAt'), Query.limit(100)],
});
const nach = new Map<string, VariantenLage>();
for (const j of res.rows as unknown as { status: string; error?: string; refs?: string }[]) {
try {
const r = JSON.parse(j.refs ?? '{}');
if (r.asset_id !== assetId || !r.version_id) continue;
if (!nach.has(r.version_id)) nach.set(r.version_id, { status: j.status, error: j.error, variante: r.variante ?? 1 });
} catch { /* kaputte refs überspringen */ }
}
return nach;
}
/** Stellt eine gescheiterte Variante erneut in die Warteschlange. */
export async function varianteWiederholen(
brandId: string,
teamId: string,
assetId: string,
versionId: string,
variante: number,
): Promise<void> {
await tables.createRow({
databaseId,
tableId: 'jobs',
rowId: ID.unique(),
data: {
asset_id: asset.$id,
version_no: 1,
status: 'freigegeben',
beschreibung_md: daten.beschreibung,
reference_file_ids: fileIds,
brand_id: brandId,
typ: 'modell_gen',
prompt_template_key: 'P7',
status: 'wartend',
refs: JSON.stringify({ asset_id: assetId, version_id: versionId, variante }),
},
permissions: rechte,
})) as unknown as AssetVersion;
permissions: teamRechte(teamId),
});
}
/**
* Gibt eine Version frei und archiviert die konkurrierenden.
*
* Archivieren statt löschen – ein verworfener Entwurf ist auch Wissen (§5), und
* `won_against_version_id` hält fest, wogegen sich die Siegerin durchgesetzt hat.
*/
export async function versionFreigeben(assetId: string, versionId: string): Promise<void> {
const alle = await versionenZuAsset(assetId);
// Auch die **abgelöste** Version wird archiviert, nicht nur die Entwürfe.
// Bliebe sie auf `freigegeben` stehen, gäbe es zwei Zeilen mit diesem Status,
// von denen nur eine wirklich gilt – und die Anzeige zeigte sie als Entwurf.
const verlierer = alle.filter((v) => v.$id !== versionId && v.status !== 'archiviert');
await tables.updateRow({
databaseId,
tableId: 'assets',
rowId: asset.$id,
data: { released_version_id: version.$id },
databaseId, tableId: 'asset_versions', rowId: versionId,
data: {
status: 'freigegeben',
won_against_version_id: verlierer[0]?.$id ?? null,
},
});
for (const v of verlierer) {
await tables.updateRow({
databaseId, tableId: 'asset_versions', rowId: v.$id, data: { status: 'archiviert' },
});
}
await tables.updateRow({
databaseId, tableId: 'assets', rowId: assetId, data: { released_version_id: versionId },
});
return { ...asset, released_version_id: version.$id };
}
export async function assetLoeschen(id: string): Promise<void> {
await tables.deleteRow({ databaseId, tableId: 'assets', rowId: id });
}
export type AssetMitBild = Asset & { titelbildId?: string };
export type AssetMitBild = Asset & {
titelbildId?: string;
/** In welchem Bucket das Titelbild liegt – generiert oder hochgeladen. */
titelbildBucket?: string;
/** true, solange keine Version freigegeben ist (Generierung läuft noch). */
wartet?: boolean;
};
/**
* Modelle samt Titelbild – das erste Referenzbild der **freigegebenen** Version.
@@ -129,21 +341,31 @@ export type AssetMitBild = Asset & { titelbildId?: string };
*/
export async function assetsMitBild(brandId: string, typ?: AssetTyp): Promise<AssetMitBild[]> {
const liste = await assetListe(brandId, typ);
const versionIds = liste.map((a) => a.released_version_id).filter((v): v is string => !!v);
if (!versionIds.length) return liste;
if (!liste.length) return liste;
// Alle Versionen aller Modelle in einer Abfrage – nicht eine je Modell.
const res = await tables.listRows({
databaseId,
tableId: 'asset_versions',
queries: [Query.equal('$id', versionIds), Query.limit(100)],
queries: [Query.equal('asset_id', liste.map((a) => a.$id)), Query.limit(200)],
});
const versionen = res.rows as unknown as AssetVersion[];
return liste.map((a) => {
const meine = versionen.filter((v) => v.asset_id === a.$id);
const frei = meine.find((v) => v.$id === a.released_version_id);
// Ist noch nichts freigegeben, zeigt die Kachel den ersten fertigen Entwurf –
// sonst bliebe sie leer, obwohl schon Varianten da sind.
const ersatz = meine.find((v) => v.bild_file_id) ?? meine[0];
const quelle = frei ?? ersatz;
return {
...a,
titelbildId: quelle?.bild_file_id ?? quelle?.reference_file_ids?.[0],
titelbildBucket: quelle?.bild_file_id ? BUCKET_BILDER : BUCKET,
wartet: !a.released_version_id,
};
});
const nachId = new Map(
(res.rows as unknown as AssetVersion[]).map((v) => [v.$id, v.reference_file_ids?.[0]]),
);
return liste.map((a) => ({
...a,
titelbildId: a.released_version_id ? nachId.get(a.released_version_id) : undefined,
}));
}
/**