diff --git a/apps/picture/apps/backend/src/db/seed.ts b/apps/picture/apps/backend/src/db/seed.ts index 0514a4fc5..37911757f 100644 --- a/apps/picture/apps/backend/src/db/seed.ts +++ b/apps/picture/apps/backend/src/db/seed.ts @@ -81,6 +81,31 @@ const defaultModels = [ costPerGeneration: 0.039, estimatedTimeSeconds: 3, }, + { + name: 'flux2-klein-local', + displayName: 'FLUX.2 Klein (Lokal)', + description: + 'Lokales Bildgenerierungsmodell auf eigenem Server in Deutschland. Keine Cloud-API, keine Kosten, volle Datenkontrolle. ~0.8s pro Bild.', + replicateId: 'local/flux2-klein', + version: null, + defaultWidth: 1024, + defaultHeight: 1024, + defaultSteps: 4, + defaultGuidanceScale: 0, + minWidth: 256, + minHeight: 256, + maxWidth: 2048, + maxHeight: 2048, + maxSteps: 8, + supportsNegativePrompt: false, + supportsImg2Img: false, + supportsSeed: true, + isActive: true, + isDefault: false, + sortOrder: -1, // Show first (local = preferred) + costPerGeneration: 0, + estimatedTimeSeconds: 1, + }, ]; async function seed() { diff --git a/apps/picture/apps/backend/src/generate/generate.module.ts b/apps/picture/apps/backend/src/generate/generate.module.ts index 71a0e2baa..0471f40be 100644 --- a/apps/picture/apps/backend/src/generate/generate.module.ts +++ b/apps/picture/apps/backend/src/generate/generate.module.ts @@ -2,12 +2,13 @@ import { Module } from '@nestjs/common'; import { GenerateController } from './generate.controller'; import { GenerateService } from './generate.service'; import { ReplicateService } from './replicate.service'; +import { LocalImageGenService } from './local-image-gen.service'; import { UploadModule } from '../upload/upload.module'; @Module({ imports: [UploadModule], controllers: [GenerateController], - providers: [GenerateService, ReplicateService], + providers: [GenerateService, ReplicateService, LocalImageGenService], exports: [GenerateService], }) export class GenerateModule {} diff --git a/apps/picture/apps/backend/src/generate/generate.service.ts b/apps/picture/apps/backend/src/generate/generate.service.ts index 8e498e8a2..542088217 100644 --- a/apps/picture/apps/backend/src/generate/generate.service.ts +++ b/apps/picture/apps/backend/src/generate/generate.service.ts @@ -15,6 +15,7 @@ import { Database } from '../db/connection'; import { imageGenerations, images, models } from '../db/schema'; import type { ImageGeneration, Image } from '../db/schema'; import { ReplicateService, GenerationParams } from './replicate.service'; +import { LocalImageGenService } from './local-image-gen.service'; import { StorageService } from '../upload/storage.service'; import { GenerateImageDto } from './dto/generate.dto'; @@ -37,6 +38,7 @@ export class GenerateService { constructor( @Inject(DATABASE_CONNECTION) private readonly db: Database, private readonly replicateService: ReplicateService, + private readonly localImageGenService: LocalImageGenService, private readonly storageService: StorageService, private readonly creditClient: CreditClientService, private configService: ConfigService @@ -161,7 +163,9 @@ export class GenerateService { // Use sync mode if: // 1. Client explicitly requested waitForResult // 2. Webhooks are not available (no HTTPS URL) - const useSyncMode = dto.waitForResult || !this.canUseWebhooks; + // 3. Local model (mana-image-gen is always synchronous) + const isLocalModel = model.replicateId.startsWith('local/'); + const useSyncMode = isLocalModel || dto.waitForResult || !this.canUseWebhooks; if (useSyncMode) { if (!this.canUseWebhooks && !dto.waitForResult) { @@ -222,8 +226,11 @@ export class GenerateService { .set({ status: 'processing' }) .where(eq(imageGenerations.id, generation.id)); - // Process generation with polling - const result = await this.replicateService.processGeneration(params); + // Route to local or Replicate provider + const isLocal = params.modelId.startsWith('local/'); + const result = isLocal + ? await this.localImageGenService.processGeneration(params) + : await this.replicateService.processGeneration(params); if (!result.success || !result.outputUrl) { await this.db diff --git a/apps/picture/apps/backend/src/generate/local-image-gen.service.ts b/apps/picture/apps/backend/src/generate/local-image-gen.service.ts new file mode 100644 index 000000000..107cfdd99 --- /dev/null +++ b/apps/picture/apps/backend/src/generate/local-image-gen.service.ts @@ -0,0 +1,131 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { GenerationParams, GenerationResult } from './replicate.service'; + +/** + * Local image generation service using mana-image-gen (FLUX.2 klein). + * Runs on the Mac Mini, ~0.8s per 1024x1024 image. + * + * API: POST http://localhost:3025/generate + * Images: GET http://localhost:3025/images/{filename} + */ +@Injectable() +export class LocalImageGenService { + private readonly logger = new Logger(LocalImageGenService.name); + private readonly baseUrl: string; + private readonly timeout: number; + private isAvailable = false; + + constructor(private configService: ConfigService) { + this.baseUrl = + this.configService.get('IMAGE_GEN_SERVICE_URL') || 'http://localhost:3025'; + this.timeout = 60_000; // 60s (FLUX.2 klein is fast, but allow margin) + this.checkHealth(); + } + + private async checkHealth(): Promise { + try { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 5000); + + const response = await fetch(`${this.baseUrl}/health`, { + signal: controller.signal, + }); + if (response.ok) { + const data = await response.json(); + this.isAvailable = data.flux_available === true; + if (this.isAvailable) { + this.logger.log(`mana-image-gen connected at ${this.baseUrl}`); + } else { + this.logger.warn('mana-image-gen is running but FLUX model not loaded'); + } + } + } catch { + this.isAvailable = false; + this.logger.warn(`mana-image-gen not available at ${this.baseUrl}`); + } + } + + getIsAvailable(): boolean { + return this.isAvailable; + } + + /** + * Generate an image using the local FLUX.2 klein model. + * Compatible with ReplicateService.processGeneration() return type. + */ + async processGeneration(params: GenerationParams): Promise { + const startTime = Date.now(); + + try { + this.logger.log(`Local generation: ${params.prompt.substring(0, 80)}...`); + + const controller = new AbortController(); + setTimeout(() => controller.abort(), this.timeout); + + const response = await fetch(`${this.baseUrl}/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: params.prompt, + width: params.width || 1024, + height: params.height || 1024, + steps: Math.min(params.steps || 4, 8), // FLUX.2 klein optimal at 4 steps, max 8 + seed: params.seed ?? -1, + output_format: 'png', + }), + signal: controller.signal, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + this.logger.error(`mana-image-gen error: ${response.status} - ${errorText}`); + return { + success: false, + error: `Local generation failed: ${response.status}`, + }; + } + + const data = await response.json(); + const generationTimeSeconds = (Date.now() - startTime) / 1000; + + if (!data.success || !data.image_url) { + return { + success: false, + error: data.error || 'No image generated', + }; + } + + // image_url is relative (e.g., "/images/abc123.png") — make absolute + const imageUrl = data.image_url.startsWith('http') + ? data.image_url + : `${this.baseUrl}${data.image_url}`; + + this.logger.log( + `Local generation complete: ${data.width}x${data.height} in ${data.generation_time?.toFixed(2) ?? generationTimeSeconds.toFixed(2)}s` + ); + + return { + success: true, + outputUrl: imageUrl, + format: 'png', + width: data.width || params.width, + height: data.height || params.height, + generationTimeSeconds: data.generation_time ?? generationTimeSeconds, + }; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error(`Local generation failed: ${msg}`); + + // Mark as unavailable if connection failed + if (msg.includes('abort') || msg.includes('ECONNREFUSED')) { + this.isAvailable = false; + } + + return { + success: false, + error: `Local generation failed: ${msg}`, + }; + } + } +} diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index a1fb31227..317b47971 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -1712,6 +1712,7 @@ services: DB_USER: postgres MANA_CORE_AUTH_URL: http://mana-auth:3001 REPLICATE_API_TOKEN: ${REPLICATE_API_TOKEN} + IMAGE_GEN_SERVICE_URL: http://host.docker.internal:3025 APP_ID: picture-app MANA_CORE_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY} S3_ENDPOINT: http://minio:9000