mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 23:29:39 +02:00
- Add uload project with apps/web structure
- Reorganize from flat to monorepo structure
- Remove PocketBase binary and local data
- Update to pnpm and @uload/web namespace
- Add picture project to monorepo
- Remove embedded git repository
- Unify all package names to @{project}/{app} schema:
- @maerchenzauber/* (was @storyteller/*)
- @manacore/* (was manacore-*, manacore)
- @manadeck/* (was web, backend, manadeck)
- @memoro/* (was memoro-web, landing, memoro)
- @picture/* (already unified)
- @uload/web
- Add convenient dev scripts for all apps:
- pnpm dev:{project}:web
- pnpm dev:{project}:landing
- pnpm dev:{project}:mobile
- pnpm dev:{project}:backend
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
507 lines
15 KiB
TypeScript
507 lines
15 KiB
TypeScript
/**
|
|
* PROCESS GENERATION LIBRARY
|
|
*
|
|
* Pure functions for image generation via Replicate API.
|
|
* This module contains NO Deno.serve() so it can be safely imported
|
|
* by other Edge Functions.
|
|
*
|
|
* Can be imported by:
|
|
* - process-generation/index.ts (Edge Function wrapper)
|
|
* - process-jobs/index.ts (Worker function)
|
|
* - Any other function that needs to generate images
|
|
*/
|
|
|
|
// Supported model types and their configurations
|
|
interface ModelConfig {
|
|
id: string;
|
|
version?: string;
|
|
supportsImg2Img: boolean;
|
|
supportsAspectRatio: boolean;
|
|
supportsDimensions: boolean;
|
|
}
|
|
|
|
export interface GenerationParams {
|
|
prompt: string;
|
|
negative_prompt?: string | null;
|
|
model_id: string;
|
|
model_version?: string | null;
|
|
width: number;
|
|
height: number;
|
|
num_inference_steps: number;
|
|
guidance_scale: number;
|
|
seed?: number | null;
|
|
source_image_url?: string | null;
|
|
strength?: number | null;
|
|
}
|
|
|
|
export interface GenerationResult {
|
|
success: boolean;
|
|
output_url?: string;
|
|
format?: string;
|
|
width?: number;
|
|
height?: number;
|
|
error?: string;
|
|
generation_time_seconds?: number;
|
|
}
|
|
|
|
/**
|
|
* Calculate greatest common divisor for aspect ratio simplification
|
|
*/
|
|
function gcd(a: number, b: number): number {
|
|
return b === 0 ? a : gcd(b, a % b);
|
|
}
|
|
|
|
/**
|
|
* Simplify aspect ratio to smallest whole numbers (e.g., 1920:1080 -> 16:9)
|
|
*/
|
|
function simplifyAspectRatio(width: number, height: number): string {
|
|
const divisor = gcd(width, height);
|
|
const simplifiedWidth = width / divisor;
|
|
const simplifiedHeight = height / divisor;
|
|
return `${simplifiedWidth}:${simplifiedHeight}`;
|
|
}
|
|
|
|
/**
|
|
* Convert image URL to base64 data URI for img2img
|
|
*/
|
|
async function convertImageToBase64(imageUrl: string): Promise<string> {
|
|
console.log('Converting image to base64:', imageUrl);
|
|
|
|
const imageResponse = await fetch(imageUrl);
|
|
if (!imageResponse.ok) {
|
|
throw new Error('Failed to fetch source image');
|
|
}
|
|
|
|
const imageBuffer = await imageResponse.arrayBuffer();
|
|
const base64String = btoa(String.fromCharCode(...new Uint8Array(imageBuffer)));
|
|
const contentType = imageResponse.headers.get('content-type') || 'image/jpeg';
|
|
const dataUri = `data:${contentType};base64,${base64String}`;
|
|
|
|
console.log('Image converted to base64, length:', dataUri.length);
|
|
return dataUri;
|
|
}
|
|
|
|
/**
|
|
* Build model-specific input parameters for Replicate API
|
|
*/
|
|
function buildModelInput(params: GenerationParams, sourceImageBase64?: string | null): any {
|
|
const {
|
|
prompt,
|
|
model_id,
|
|
width,
|
|
height,
|
|
num_inference_steps,
|
|
guidance_scale,
|
|
seed,
|
|
strength
|
|
} = params;
|
|
|
|
let finalWidth = width;
|
|
let finalHeight = height;
|
|
const simplifiedRatio = simplifyAspectRatio(width, height);
|
|
|
|
console.log('Building input for model:', model_id);
|
|
console.log('Dimensions:', finalWidth, 'x', finalHeight);
|
|
console.log('Aspect ratio:', simplifiedRatio);
|
|
|
|
let input: any = {};
|
|
|
|
// FLUX Schnell - Uses aspect_ratio with specific supported ratios
|
|
if (model_id.includes('flux-schnell')) {
|
|
const supportedRatios = ['1:1', '16:9', '21:9', '3:2', '2:3', '4:5', '5:4', '3:4', '4:3', '9:16', '9:21'];
|
|
|
|
// Find closest supported ratio
|
|
let fluxAspectRatio = simplifiedRatio;
|
|
if (!supportedRatios.includes(simplifiedRatio)) {
|
|
const [w, h] = simplifiedRatio.split(':').map(Number);
|
|
const targetRatio = w / h;
|
|
|
|
let closestRatio = '1:1';
|
|
let minDiff = Infinity;
|
|
|
|
for (const ratio of supportedRatios) {
|
|
const [rw, rh] = ratio.split(':').map(Number);
|
|
const r = rw / rh;
|
|
const diff = Math.abs(r - targetRatio);
|
|
if (diff < minDiff) {
|
|
minDiff = diff;
|
|
closestRatio = ratio;
|
|
}
|
|
}
|
|
|
|
fluxAspectRatio = closestRatio;
|
|
console.log(`Mapped ${simplifiedRatio} to closest supported ratio: ${fluxAspectRatio}`);
|
|
}
|
|
|
|
// Calculate actual dimensions (Flux Schnell uses 1024px on shorter side)
|
|
const [aspectW, aspectH] = fluxAspectRatio.split(':').map(Number);
|
|
if (aspectW > aspectH) {
|
|
finalHeight = 1024;
|
|
finalWidth = Math.round((finalHeight * aspectW) / aspectH);
|
|
} else if (aspectW < aspectH) {
|
|
finalWidth = 1024;
|
|
finalHeight = Math.round((finalWidth * aspectH) / aspectW);
|
|
} else {
|
|
finalWidth = 1024;
|
|
finalHeight = 1024;
|
|
}
|
|
|
|
console.log(`Final dimensions for Flux Schnell: ${finalWidth}x${finalHeight}`);
|
|
|
|
input = {
|
|
prompt,
|
|
num_inference_steps,
|
|
guidance: guidance_scale,
|
|
num_outputs: 1,
|
|
aspect_ratio: fluxAspectRatio,
|
|
output_format: 'webp',
|
|
output_quality: 90,
|
|
};
|
|
}
|
|
// FLUX Dev / FLUX Krea Dev - Supports dimensions and img2img
|
|
else if (model_id.includes('flux-krea-dev') || model_id.includes('flux-dev')) {
|
|
input = {
|
|
prompt,
|
|
num_inference_steps,
|
|
guidance_scale,
|
|
num_outputs: 1,
|
|
width: finalWidth,
|
|
height: finalHeight,
|
|
output_format: 'webp',
|
|
output_quality: 90,
|
|
};
|
|
|
|
if (sourceImageBase64 && strength !== null) {
|
|
input.image = sourceImageBase64;
|
|
input.prompt_strength = 1 - strength; // Flux uses inverse
|
|
console.log('Added img2img params for Flux Dev, prompt_strength:', input.prompt_strength);
|
|
}
|
|
}
|
|
// Ideogram V3 Turbo - Uses aspect_ratio
|
|
else if (model_id.includes('ideogram-v3-turbo') || model_id.includes('ideogram')) {
|
|
input = {
|
|
prompt,
|
|
aspect_ratio: simplifiedRatio,
|
|
model: 'turbo',
|
|
style_type: 'auto',
|
|
};
|
|
if (seed) input.seed = seed;
|
|
}
|
|
// Imagen 4 Fast - Uses aspect_ratio
|
|
else if (model_id.includes('imagen-4-fast') || model_id.includes('imagen')) {
|
|
input = {
|
|
prompt,
|
|
aspect_ratio: simplifiedRatio,
|
|
safety_tolerance: 2,
|
|
output_format: 'png',
|
|
};
|
|
}
|
|
// SDXL Lightning - 4 steps, no guidance, supports img2img
|
|
else if (model_id.includes('sdxl-lightning')) {
|
|
input = {
|
|
prompt,
|
|
width: finalWidth,
|
|
height: finalHeight,
|
|
num_inference_steps: 4, // Always 4 for Lightning
|
|
guidance_scale: 0, // No guidance for Lightning
|
|
disable_safety_checker: false,
|
|
output_format: 'webp',
|
|
output_quality: 90,
|
|
};
|
|
|
|
if (sourceImageBase64 && strength !== null) {
|
|
input.image = sourceImageBase64;
|
|
input.strength = strength;
|
|
console.log('Added img2img params for SDXL Lightning, strength:', input.strength);
|
|
}
|
|
|
|
if (seed) input.seed = seed;
|
|
}
|
|
// Regular SDXL - Full parameters, supports img2img
|
|
else if (model_id.includes('sdxl')) {
|
|
input = {
|
|
prompt,
|
|
width: finalWidth,
|
|
height: finalHeight,
|
|
num_inference_steps,
|
|
guidance_scale,
|
|
refine: 'expert_ensemble_refiner',
|
|
high_noise_frac: 0.8,
|
|
output_format: 'webp',
|
|
output_quality: 90,
|
|
};
|
|
|
|
if (sourceImageBase64 && strength !== null) {
|
|
input.image = sourceImageBase64;
|
|
input.prompt_strength = strength;
|
|
console.log('Added img2img params for SDXL, prompt_strength:', input.prompt_strength);
|
|
}
|
|
|
|
if (seed) input.seed = seed;
|
|
}
|
|
// SeeDream 4 - Uses size preset and aspect_ratio
|
|
else if (model_id.includes('seedream-4')) {
|
|
let sizePreset = '2K';
|
|
if (finalWidth >= 4096 || finalHeight >= 4096) {
|
|
sizePreset = '4K';
|
|
} else if (finalWidth <= 1024 && finalHeight <= 1024) {
|
|
sizePreset = '1K';
|
|
}
|
|
|
|
input = {
|
|
prompt,
|
|
size: sizePreset,
|
|
width: finalWidth,
|
|
height: finalHeight,
|
|
max_images: 1,
|
|
aspect_ratio: simplifiedRatio,
|
|
};
|
|
|
|
if (sourceImageBase64 && strength !== null) {
|
|
input.image_input = [sourceImageBase64];
|
|
console.log('Added img2img params for SeeDream 4');
|
|
}
|
|
}
|
|
// SeeDream 3 - Standard dimensions
|
|
else if (model_id.includes('seedream-3') || model_id.includes('seedream')) {
|
|
input = {
|
|
prompt,
|
|
width: finalWidth,
|
|
height: finalHeight,
|
|
num_inference_steps,
|
|
guidance_scale,
|
|
};
|
|
if (seed) input.seed = seed;
|
|
}
|
|
// FLUX 1.1 Pro - Uses aspect_ratio
|
|
else if (model_id.includes('flux-1.1-pro')) {
|
|
input = {
|
|
prompt,
|
|
aspect_ratio: simplifiedRatio,
|
|
output_format: 'webp',
|
|
output_quality: 90,
|
|
safety_tolerance: 2,
|
|
};
|
|
if (seed) input.seed = seed;
|
|
}
|
|
// Recraft V3 SVG - Vector output
|
|
else if (model_id.includes('recraft-v3-svg')) {
|
|
input = {
|
|
prompt,
|
|
width: finalWidth,
|
|
height: finalHeight,
|
|
output_format: 'svg',
|
|
style: 'vector_illustration',
|
|
};
|
|
if (seed) input.seed = seed;
|
|
}
|
|
// Recraft V3 - Uses size parameter
|
|
else if (model_id.includes('recraft-v3') || model_id.includes('recraft')) {
|
|
input = {
|
|
prompt,
|
|
size: `${finalWidth}x${finalHeight}`,
|
|
style: 'realistic_image',
|
|
};
|
|
}
|
|
// Stable Diffusion 3.5 Large
|
|
else if (model_id.includes('stable-diffusion-3.5') || model_id.includes('sd-3-5')) {
|
|
input = {
|
|
prompt,
|
|
aspect_ratio: simplifiedRatio,
|
|
cfg: guidance_scale,
|
|
steps: num_inference_steps,
|
|
output_format: 'webp',
|
|
output_quality: 90,
|
|
};
|
|
if (seed) input.seed = seed;
|
|
}
|
|
// Qwen Image - Specific parameter requirements
|
|
else if (model_id.includes('qwen-image') || model_id.includes('qwen')) {
|
|
input = {
|
|
prompt,
|
|
aspect_ratio: simplifiedRatio,
|
|
num_inference_steps,
|
|
guidance: guidance_scale,
|
|
go_fast: true,
|
|
image_size: 'optimize_for_quality',
|
|
output_format: 'webp',
|
|
output_quality: 90,
|
|
enhance_prompt: false,
|
|
disable_safety_checker: false
|
|
};
|
|
if (seed) input.seed = seed;
|
|
}
|
|
// Default/fallback for unknown models
|
|
else {
|
|
input = {
|
|
prompt,
|
|
width: finalWidth,
|
|
height: finalHeight,
|
|
num_inference_steps,
|
|
guidance_scale,
|
|
};
|
|
if (seed) input.seed = seed;
|
|
}
|
|
|
|
return { input, finalWidth, finalHeight };
|
|
}
|
|
|
|
/**
|
|
* Determine output format from model ID and output URL
|
|
*/
|
|
function determineOutputFormat(modelId: string, outputUrl: string): { format: string; contentType: string } {
|
|
if (modelId.includes('recraft-v3-svg')) {
|
|
return { format: 'svg', contentType: 'image/svg+xml' };
|
|
}
|
|
if (modelId.includes('imagen-4')) {
|
|
return { format: 'png', contentType: 'image/png' };
|
|
}
|
|
if (outputUrl.includes('.png')) {
|
|
return { format: 'png', contentType: 'image/png' };
|
|
}
|
|
if (outputUrl.includes('.jpg') || outputUrl.includes('.jpeg')) {
|
|
return { format: 'jpeg', contentType: 'image/jpeg' };
|
|
}
|
|
// Default to webp
|
|
return { format: 'webp', contentType: 'image/webp' };
|
|
}
|
|
|
|
/**
|
|
* Main function: Process image generation via Replicate API
|
|
*
|
|
* @param params - Generation parameters
|
|
* @param replicateApiToken - Replicate API token
|
|
* @returns Generation result with output URL or error
|
|
*/
|
|
export async function processGeneration(
|
|
params: GenerationParams,
|
|
replicateApiToken: string
|
|
): Promise<GenerationResult> {
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
console.log('=== PROCESS GENERATION START ===');
|
|
console.log('Model:', params.model_id);
|
|
console.log('Prompt:', params.prompt.substring(0, 100) + '...');
|
|
|
|
// Handle image-to-image conversion if needed
|
|
let sourceImageBase64: string | null = null;
|
|
if (params.source_image_url && params.strength !== null) {
|
|
console.log('Image-to-image mode detected');
|
|
sourceImageBase64 = await convertImageToBase64(params.source_image_url);
|
|
}
|
|
|
|
// Build model-specific input
|
|
const { input, finalWidth, finalHeight } = buildModelInput(params, sourceImageBase64);
|
|
|
|
console.log('Replicate API input:', JSON.stringify(input, null, 2));
|
|
|
|
// Prepare Replicate API request
|
|
const requestBody: any = { input };
|
|
|
|
if (params.model_version) {
|
|
requestBody.version = params.model_version;
|
|
console.log('Using version hash:', params.model_version);
|
|
} else {
|
|
requestBody.model = params.model_id;
|
|
console.log('Using model ID (official model):', params.model_id);
|
|
}
|
|
|
|
// Call Replicate API to start prediction
|
|
console.log('Calling Replicate API...');
|
|
const replicateResponse = await fetch('https://api.replicate.com/v1/predictions', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Token ${replicateApiToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(requestBody)
|
|
});
|
|
|
|
if (!replicateResponse.ok) {
|
|
const errorText = await replicateResponse.text();
|
|
console.error('Replicate API error:', errorText);
|
|
console.error('Response status:', replicateResponse.status);
|
|
throw new Error(`Replicate API error (${replicateResponse.status}): ${errorText}`);
|
|
}
|
|
|
|
const prediction = await replicateResponse.json();
|
|
console.log('Prediction created:', prediction.id, 'Status:', prediction.status);
|
|
|
|
// Poll for completion
|
|
const maxAttempts = 120; // 10 minutes max (5 second intervals)
|
|
let attempts = 0;
|
|
|
|
while (attempts < maxAttempts) {
|
|
await new Promise(resolve => setTimeout(resolve, 5000)); // Poll every 5 seconds
|
|
attempts++;
|
|
|
|
const statusResponse = await fetch(`https://api.replicate.com/v1/predictions/${prediction.id}`, {
|
|
headers: {
|
|
'Authorization': `Token ${replicateApiToken}`,
|
|
},
|
|
});
|
|
|
|
if (!statusResponse.ok) {
|
|
console.error('Failed to get prediction status');
|
|
continue; // Retry
|
|
}
|
|
|
|
const result = await statusResponse.json();
|
|
console.log(`Poll ${attempts}: ${result.status}`);
|
|
|
|
// Success - Extract output URL
|
|
if (result.status === 'succeeded' && result.output) {
|
|
let outputUrl: string;
|
|
|
|
// Different models return output in different formats
|
|
if (Array.isArray(result.output)) {
|
|
outputUrl = result.output[0];
|
|
} else if (typeof result.output === 'string') {
|
|
outputUrl = result.output;
|
|
} else if (result.output.url) {
|
|
outputUrl = result.output.url;
|
|
} else {
|
|
console.error('Unexpected output format:', result.output);
|
|
throw new Error('Unexpected output format from model');
|
|
}
|
|
|
|
console.log('Generation succeeded! Output URL:', outputUrl);
|
|
|
|
const { format, contentType } = determineOutputFormat(params.model_id, outputUrl);
|
|
const generationTime = Math.floor((Date.now() - startTime) / 1000);
|
|
|
|
console.log('=== PROCESS GENERATION COMPLETE ===');
|
|
console.log('Time taken:', generationTime, 'seconds');
|
|
|
|
return {
|
|
success: true,
|
|
output_url: outputUrl,
|
|
format,
|
|
width: finalWidth,
|
|
height: finalHeight,
|
|
generation_time_seconds: generationTime
|
|
};
|
|
}
|
|
|
|
// Failed or canceled
|
|
if (result.status === 'failed' || result.status === 'canceled') {
|
|
const errorMsg = result.error || `Generation ${result.status}`;
|
|
console.error('Generation failed:', errorMsg);
|
|
throw new Error(errorMsg);
|
|
}
|
|
}
|
|
|
|
// Timeout after max attempts
|
|
throw new Error('Generation timeout after 10 minutes');
|
|
|
|
} catch (error: any) {
|
|
console.error('Error in processGeneration:', error.message);
|
|
console.error('Stack:', error.stack);
|
|
|
|
return {
|
|
success: false,
|
|
error: error.message || 'Unknown error during generation'
|
|
};
|
|
}
|
|
}
|