mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
0ddbad9aed
commit
fc7d2942d0
5 changed files with 169 additions and 4 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue