diff --git a/paintplus/.env.example b/paintplus/.env.example index 836db6f..93e4b13 100644 --- a/paintplus/.env.example +++ b/paintplus/.env.example @@ -82,6 +82,11 @@ REPLICATE_API_KEY=r8_PASTE_YOUR_KEY_HERE # ─────────────────────────────────────────────────────────────────────────── #OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx #OPENAI_MODEL=dall-e-3 +# Model for inpaint/img2img/outpaint (the /v1/images/edits endpoint). +# dall-e-3 does NOT support edits at all — keep this on gpt-image-1 for +# ChatGPT-comparable edit quality, or set dall-e-2 for the older/cheaper option. +# Note: gpt-image-1 may require completing org verification at platform.openai.com. +#OPENAI_EDIT_MODEL=gpt-image-1 # ─────────────────────────────────────────────────────────────────────────── # INVOKEAI (self-hosted, best for Flux/SDXL) diff --git a/paintplus/backend/app/config.py b/paintplus/backend/app/config.py index 55b6d46..017bc8e 100644 --- a/paintplus/backend/app/config.py +++ b/paintplus/backend/app/config.py @@ -27,7 +27,8 @@ class Settings(BaseSettings): # Provider API Keys openai_api_key: str = "" - openai_model: str = "dall-e-3" + openai_model: str = "dall-e-3" # text-to-image (generations endpoint) + openai_edit_model: str = "gpt-image-1" # inpaint/img2img/outpaint (edits endpoint — dall-e-3 isn't supported there) stability_api_key: str = "" replicate_api_key: str = "" diff --git a/paintplus/backend/app/services/remote_provider.py b/paintplus/backend/app/services/remote_provider.py index 5fcb603..4d6f43e 100644 --- a/paintplus/backend/app/services/remote_provider.py +++ b/paintplus/backend/app/services/remote_provider.py @@ -30,38 +30,44 @@ class RemoteAIProvider(ABC): class OpenAIRemoteProvider(RemoteAIProvider): """OpenAI image API — gpt-image-1 / dall-e-3.""" - def __init__(self, api_key: str, model: str = "dall-e-3"): + def __init__(self, api_key: str, model: str = "dall-e-3", edit_model: str = "gpt-image-1"): self.api_key = api_key self.model = model + # dall-e-3 has no edits/inpaint support at all — edits need a model of their own. + self.edit_model = edit_model self.base_url = "https://api.openai.com/v1" def _headers(self): return {"Authorization": f"Bearer {self.api_key}"} + async def _fetch_result(self, client: httpx.AsyncClient, item: dict) -> bytes: + # gpt-image-1 only ever returns b64_json; dall-e-2/dall-e-3 default to a url. + if item.get("b64_json"): + return base64.b64decode(item["b64_json"]) + img_r = await client.get(item["url"]) + img_r.raise_for_status() + return img_r.content + async def inpaint(self, image_bytes: bytes, mask_bytes: bytes, prompt: str, params: dict) -> bytes: + model = (params or {}).get("model") or self.edit_model async with httpx.AsyncClient(timeout=120.0) as client: files = { "image": ("image.png", image_bytes, "image/png"), "mask": ("mask.png", mask_bytes, "image/png"), } - data = {"prompt": prompt, "n": "1", "size": "1024x1024"} + data = {"model": model, "prompt": prompt, "n": "1", "size": "1024x1024"} r = await client.post(f"{self.base_url}/images/edits", files=files, data=data, headers=self._headers()) r.raise_for_status() - url = r.json()["data"][0]["url"] - img_r = await client.get(url) - img_r.raise_for_status() - return img_r.content + return await self._fetch_result(client, r.json()["data"][0]) async def txt2img(self, prompt: str, width: int, height: int, params: dict) -> bytes: size = f"{width}x{height}" if f"{width}x{height}" in {"256x256", "512x512", "1024x1024"} else "1024x1024" + model = (params or {}).get("model") or self.model async with httpx.AsyncClient(timeout=120.0) as client: - data = {"model": self.model, "prompt": prompt, "n": 1, "size": size} + data = {"model": model, "prompt": prompt, "n": 1, "size": size} r = await client.post(f"{self.base_url}/images/generations", json=data, headers=self._headers()) r.raise_for_status() - url = r.json()["data"][0]["url"] - img_r = await client.get(url) - img_r.raise_for_status() - return img_r.content + return await self._fetch_result(client, r.json()["data"][0]) async def img2img(self, image_bytes: bytes, prompt: str, strength: float, params: dict) -> bytes: # OpenAI doesn't have img2img natively — use edits with blank mask @@ -416,7 +422,7 @@ def _build_provider(name: str) -> Optional[RemoteAIProvider]: if name == "openai": if not settings.openai_api_key: return None - return OpenAIRemoteProvider(settings.openai_api_key, settings.openai_model) + return OpenAIRemoteProvider(settings.openai_api_key, settings.openai_model, settings.openai_edit_model) if name == "invokeai": if not settings.invokeai_url: diff --git a/paintplus/docker-compose.gpu.yml b/paintplus/docker-compose.gpu.yml index 0e50903..4d0ea7b 100644 --- a/paintplus/docker-compose.gpu.yml +++ b/paintplus/docker-compose.gpu.yml @@ -97,6 +97,7 @@ services: # ── Remote/cloud providers (all optional) ──────────────────────────────── - OPENAI_API_KEY=${OPENAI_API_KEY:-} - OPENAI_MODEL=${OPENAI_MODEL:-dall-e-3} + - OPENAI_EDIT_MODEL=${OPENAI_EDIT_MODEL:-gpt-image-1} - REPLICATE_API_KEY=${REPLICATE_API_KEY:-} - STABILITY_API_KEY=${STABILITY_API_KEY:-} diff --git a/paintplus/docker-compose.yml b/paintplus/docker-compose.yml index 81d2e97..3e6c6ab 100644 --- a/paintplus/docker-compose.yml +++ b/paintplus/docker-compose.yml @@ -14,6 +14,8 @@ services: - SECRET_KEY=${SECRET_KEY:-change-this-secret-key-in-production} - AI_PROVIDER=${AI_PROVIDER:-mock} - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - OPENAI_MODEL=${OPENAI_MODEL:-dall-e-3} + - OPENAI_EDIT_MODEL=${OPENAI_EDIT_MODEL:-gpt-image-1} - STABILITY_API_KEY=${STABILITY_API_KEY:-} - REPLICATE_API_KEY=${REPLICATE_API_KEY:-} - INVOKEAI_URL=${INVOKEAI_URL:-}