Files
admin 46af290db3
Build and Push Docker Images / build-and-push (push) Failing after 3m33s
initial commit
2026-11-07 16:39:43 +03:00

204 lines
8.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import base64
import logging
import re
from pathlib import Path
from typing import Optional
import httpx
from config.settings import settings
from fastapi import HTTPException
from services.project_service import project_service
logger = logging.getLogger(__name__)
class ImageGenerator:
def __init__(self):
self.api_key = settings.OPENROUTER_API_KEY
self.base_url = getattr(
settings, "OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"
)
self.default_model = "qwen/qwen-image-2.1-turbo"
def _get_images_dir(self, project_id: str, user_id: str) -> Path:
"""Получить или создать директорию для изображений проекта"""
project_dir = project_service.get_project_dir(project_id, user_id)
images_dir = project_dir / "images"
images_dir.mkdir(exist_ok=True)
return images_dir
async def generate_image(
self,
prompt: str,
project_id: str,
user_id: str,
document_id: str,
document_type: str, # 'book' или 'chapter'
model: Optional[str] = None,
size: str = "1024x1024",
) -> dict:
"""
Генерирует изображение по промпту и сохраняет его в папку проекта.
Имя файла определяется по конвенции:
- document_type='book' -> cover.png (обложка книги)
- document_type='chapter' -> {document_id}.png (иллюстрация главы)
Возвращает информацию о сохранённом файле.
"""
if not self.api_key or self.api_key == "your_api_key_here":
raise ValueError("OpenRouter API key not configured")
model = model or self.default_model
# Генерация изображений использует OpenAI Images API endpoint,
# а не Chat Completions. См. официальную документацию модели.
payload = {
"model": model,
"prompt": prompt,
"n": 1,
"response_format": "b64_json",
}
# Мапим size в aspect_ratio для моделей, которые его поддерживают
aspect_ratio_map = {
"1024x1024": "1:1",
"1024x1792": "9:16",
"1792x1024": "16:9",
"512x512": "1:1",
}
if size in aspect_ratio_map:
payload["size"] = size
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "http://localhost:3000",
"X-Title": "Writer IDE",
}
try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{self.base_url}/images/generations",
json=payload,
headers=headers,
)
response.raise_for_status()
data = response.json()
# Проверяем наличие ошибки в ответе (некоторые провайдеры
# возвращают 200 с полем error вместо HTTP error status)
if "error" in data:
error_info = data["error"]
error_msg = (
error_info.get("message", str(error_info))
if isinstance(error_info, dict)
else str(error_info)
)
raise ValueError(f"API returned error: {error_msg}")
# OpenAI Images API формат: {"data": [{"b64_json": "..."}]}
images_data = data.get("data", [])
if not images_data:
raise ValueError(
f"No image data in response. Keys: {list(data.keys())}"
)
img_data = images_data[0]
image_url = None
image_base64 = None
if isinstance(img_data, dict):
image_base64 = img_data.get("b64_json")
image_url = img_data.get("url")
elif isinstance(img_data, str):
# Некоторые провайдеры могут вернуть URL напрямую в data
image_url = img_data if img_data.startswith("http") else None
# Фоллбэк: пробуем извлечь из text content (если провайдер
# использует chat/completions формат поверх images/generations)
if not image_url and not image_base64:
content = (
data.get("choices", [{}])[0].get("message", {}).get("content", "")
)
if content:
base64_match = re.search(
r"data:image/[^;]+;base64,([A-Za-z0-9+/=]+)", content
)
if base64_match:
image_base64 = base64_match.group(1)
if not image_url and not image_base64:
logger.warning(
"Could not extract image. Response keys: %s",
list(data.keys()),
)
raise ValueError(
f"Could not extract image from response. Available keys: {list(data.keys())}"
)
# Сохраняем изображение
images_dir = self._get_images_dir(project_id, user_id)
# Определяем имя файла по конвенции
if document_type == "book":
filename = "cover.png" # Обложка книги
else: # chapter
filename = f"{document_id}.png" # Изображение главы
filepath = images_dir / filename
if image_url:
async with httpx.AsyncClient(timeout=60.0) as client:
img_response = await client.get(image_url)
img_response.raise_for_status()
image_bytes = img_response.content
filepath.write_bytes(image_bytes)
data_url = f"data:image/png;base64,{base64.b64encode(image_bytes).decode('ascii')}"
elif image_base64:
image_bytes = base64.b64decode(image_base64)
filepath.write_bytes(image_bytes)
# Определяем MIME тип по первым байтам изображения
mime = "image/png" # default
if image_bytes[:2] == b"\xff\xd8":
mime = "image/jpeg"
elif image_bytes[:8] == b"\x89PNG\r\n\x1a\n":
mime = "image/png"
elif image_bytes[:6] in (b"GIF87a", b"GIF89a"):
mime = "image/gif"
elif image_bytes[:4] == b"RIFF" and image_bytes[8:12] == b"WEBP":
mime = "image/webp"
data_url = f"data:{mime};base64,{image_base64}"
else:
# Должно быть обработано выше, но на всякий случай
raise ValueError("No image data available to encode")
return {
"image_id": document_id,
"filename": filename,
"path": f"/api/images/{project_id}/{filename}",
"prompt": prompt,
"model": model,
"data_url": data_url,
"document_id": document_id,
"document_type": document_type,
}
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=502,
detail=f"Image generation API error: {e.response.status_code} - {e.response.text[:200]}",
)
except httpx.TimeoutException:
raise HTTPException(status_code=504, detail="Image generation timed out")
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Image generation failed: {str(e)}"
)
# Singleton
image_generator = ImageGenerator()