import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; import { SettingsService } from '../settings/settings.service'; const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; export interface GenerarRenderResultado { imagen: string | null; error?: string; debug: Record; } @Injectable() export class ImageGeneratorService { private readonly logger = new Logger(ImageGeneratorService.name); constructor( private readonly config: ConfigService, private readonly settings: SettingsService, ) {} // Usado por el pipeline real: devuelve la imagen o lanza. async generarRender(prompt: string, fotoAntesDataUri: string, opts?: { model?: string }): Promise { const r = await this.generarConDebug(prompt, fotoAntesDataUri, opts); if (!r.imagen) throw new Error(r.error || 'No se pudo extraer imagen de la respuesta'); return r.imagen; } // Usado por el sandbox: nunca lanza, devuelve imagen|null + info de depuración (sin volcar base64). async generarConDebug( prompt: string, fotoAntesDataUri: string, opts?: { model?: string }, ): Promise { const apiKey = this.config.get('OPENROUTER_API_KEY'); const model = opts?.model?.trim() || this.settings.getModeloImagen(); try { const response = await axios.post( OPENROUTER_URL, { model, // Necesario para que OpenRouter devuelva imagen además de texto. modalities: ['image', 'text'], messages: [ { role: 'user', content: [ { type: 'text', text: prompt }, { type: 'image_url', image_url: { url: fotoAntesDataUri } }, ], }, ], }, { headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://reformix.es', 'X-Title': 'Reformix Image Worker', }, timeout: 90000, }, ); const message = response.data?.choices?.[0]?.message; const imagen = this.extraerImagen(message); return { imagen, error: imagen ? undefined : 'No se encontró imagen en la respuesta de OpenRouter', debug: this.resumenDebug(response.data, model, imagen), }; } catch (err: any) { const status = err.response?.status; const msg = err.response?.data?.error?.message || err.message; this.logger.error(`Error generando imagen (${status ?? 'sin status'}): ${msg}`); return { imagen: null, error: msg, debug: { model, status, error: msg }, }; } } private extraerImagen(message: any): string | null { if (!message) return null; // 1) Forma de OpenRouter para image-gen: message.images[].image_url.url if (Array.isArray(message.images)) { for (const img of message.images) { const url = img?.image_url?.url ?? img?.url ?? (typeof img === 'string' ? img : null); if (typeof url === 'string' && url) return url; } } const content = message.content; if (typeof content === 'string') { if (content.startsWith('data:image')) return content; const dataUri = content.match(/data:image\/[a-zA-Z+]+;base64,[A-Za-z0-9+/=]+/); if (dataUri) return dataUri[0]; const url = content.match(/https?:\/\/[^\s"'()]+\.(?:png|jpg|jpeg|webp)/i); if (url) return url[0]; } else if (Array.isArray(content)) { for (const part of content) { const url = part?.image_url?.url; if (typeof url === 'string' && url) return url; } } return null; } // Resumen compacto para depurar el formato real sin meter el base64 (enorme) en la respuesta. private resumenDebug(data: any, model: string, imagen: string | null): Record { const msg = data?.choices?.[0]?.message; return { model, modelDevuelto: data?.model, finishReason: data?.choices?.[0]?.finish_reason, messageKeys: msg ? Object.keys(msg) : [], imagesCount: Array.isArray(msg?.images) ? msg.images.length : 0, contentType: Array.isArray(msg?.content) ? 'array' : typeof msg?.content, contentPreview: typeof msg?.content === 'string' ? msg.content.slice(0, 200) : undefined, imagenEncontrada: !!imagen, imagenTipo: imagen ? (imagen.startsWith('data:') ? 'data-uri' : 'url') : null, usage: data?.usage, }; } }