feat(picture): add local image generation via mana-image-gen

Add LocalImageGenService that routes to the self-hosted FLUX.2 klein
model on the Mac Mini, eliminating Replicate API dependency for basic
image generation.

Changes:
- LocalImageGenService: wraps mana-image-gen HTTP API (/generate)
  with health checking, timeout handling, and GenerationResult compat
- GenerateService: routes to local or Replicate based on model config
  (replicateId starting with "local/" → LocalImageGenService)
- Local models always use sync mode (no webhooks needed, ~0.8s)
- Seed: add "FLUX.2 Klein (Lokal)" model with sortOrder -1 (shown first)
  - costPerGeneration: 0 (free, runs locally)
  - estimatedTimeSeconds: 1
- docker-compose: add IMAGE_GEN_SERVICE_URL env var for picture backend

Replicate remains available for premium models (Seedream, Nano Banana).
Local FLUX.2 klein becomes the default free option.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-24 10:38:30 +01:00
parent 0ddbad9aed
commit fc7d2942d0
5 changed files with 169 additions and 4 deletions

View file

@ -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() {

View file

@ -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 {}

View file

@ -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

View file

@ -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<string>('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<void> {
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<GenerationResult> {
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}`,
};
}
}
}

View file

@ -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