diff --git a/src/ecommerce_agent/services/gemini_images.py b/src/ecommerce_agent/services/gemini_images.py index c363506..231f116 100644 --- a/src/ecommerce_agent/services/gemini_images.py +++ b/src/ecommerce_agent/services/gemini_images.py @@ -72,12 +72,7 @@ async def generate(self, request: ImageGenerationRequest) -> GeneratedAsset: json=_request_payload(request), ) image_bytes, usage, mime_type = _parse_response(response) - image_bytes = _convert_image( - image_bytes, - expected_width=request.width, - expected_height=request.height, - output_format=request.output_format, - ) + image_bytes, width, height = _convert_image(image_bytes, request.output_format) safe_id = re.sub(r"[^A-Za-z0-9_.-]", "-", request.product_concept_id) extension = "jpg" if request.output_format == "jpeg" else request.output_format key = f"image-generations/{safe_id}/revision-{request.revision}.{extension}" @@ -89,13 +84,14 @@ async def generate(self, request: ImageGenerationRequest) -> GeneratedAsset: return GeneratedAsset( uri=uri, checksum=checksum, - width=request.width, - height=request.height, + width=width, + height=height, provider="gemini", model=model, metadata={ "aspect_ratio": size.aspect_ratio, "image_size": size.image_size, + "requested_size": f"{request.width}x{request.height}", "output_format": request.output_format, "background_removal_friendly": request.background_removal_friendly, "reference_count": len(request.references), @@ -184,25 +180,17 @@ def _parse_response(response: httpx.Response) -> tuple[bytes, dict[str, Any], st def _convert_image( image_bytes: bytes, - *, - expected_width: int, - expected_height: int, output_format: str, -) -> bytes: +) -> tuple[bytes, int, int]: try: with Image.open(io.BytesIO(image_bytes)) as image: image.load() - if image.size != (expected_width, expected_height): - raise OpenAIImageError( - f"Gemini returned an unexpected image size: {image.size[0]}x{image.size[1]}." - ) + width, height = image.size output_image = image.convert("RGB") if output_format == "jpeg" else image output = io.BytesIO() output_image.save( output, format={"png": "PNG", "jpeg": "JPEG", "webp": "WEBP"}[output_format] ) - return output.getvalue() - except OpenAIImageError: - raise + return output.getvalue(), width, height except (UnidentifiedImageError, OSError) as error: raise OpenAIImageError("Gemini returned invalid image data.") from error diff --git a/src/ecommerce_agent/services/openai_images.py b/src/ecommerce_agent/services/openai_images.py index 397de4c..6d9e787 100644 --- a/src/ecommerce_agent/services/openai_images.py +++ b/src/ecommerce_agent/services/openai_images.py @@ -223,9 +223,7 @@ async def _post_with_safe_retries( await self._sleep(self._retry_delay_seconds * (2 ** (attempt - 1))) continue if response.is_error: - raise OpenAIImageError( - f"OpenAI image generation failed with HTTP {response.status_code}." - ) + raise OpenAIImageError(_error_message(response)) return response finally: if owns_client: @@ -233,6 +231,23 @@ async def _post_with_safe_retries( raise OpenAIImageError("OpenAI image generation failed after safe retries.") +def _error_message(response: httpx.Response) -> str: + try: + payload = response.json() + error = payload.get("error", {}) if isinstance(payload, dict) else {} + except ValueError: + error = {} + code = error.get("code") if isinstance(error, dict) else None + if code == "billing_hard_limit_reached": + return ( + "OpenAI billing hard limit reached. Increase the API billing limit or use Nano Banana." + ) + message = error.get("message") if isinstance(error, dict) else None + if isinstance(message, str) and message: + return f"OpenAI image generation failed: {message}" + return f"OpenAI image generation failed with HTTP {response.status_code}." + + def _parse_response( response: httpx.Response, *, diff --git a/src/ecommerce_agent/web/design_studio.py b/src/ecommerce_agent/web/design_studio.py index ab5e2b2..03f16cc 100644 --- a/src/ecommerce_agent/web/design_studio.py +++ b/src/ecommerce_agent/web/design_studio.py @@ -54,6 +54,8 @@ def _line_view(path: list[dict[str, Any]]) -> dict[str, Any]: "updated": _fmt_date(leaf.get("created_at")), "lineage": branch_label(path).replace("->", "→"), "thumbs": _thumbs(path), + "status": leaf.get("status"), + "error": leaf.get("error_message"), } @@ -69,8 +71,10 @@ def _focus_view(path: list[dict[str, Any]], version_index: int) -> dict[str, Any "active_index": version_index, "lineage": branch_label(path).replace("->", "→"), "thumb": asset_url(gen.get("asset_uri")), + "size": f"{gen.get('width', '?')} × {gen.get('height', '?')}", "prompt": gen.get("prompt") or "", "status": gen.get("status"), + "error": gen.get("error_message"), "meta": [ str(gen.get("id", ""))[:12], gen.get("model") or "—", @@ -106,6 +110,7 @@ async def design_studio( ) -> HTMLResponse: paths = await _all_paths(session) lines = [_line_view(p) for p in paths] + problem_count = sum(line["status"] != "succeeded" for line in lines) # Slate layout: nothing is selected by default (library spans full width). A # design is shown in the right panel only when explicitly requested via `sel`. selected_path = next((p for p in paths if p[-1]["id"] == sel), None) if sel else None @@ -126,6 +131,8 @@ async def design_studio( subtitle=SUBTITLE, primary={"label": "New design", "icon": "plus", "attrs": "data-studio-new"}, lines=lines, + library_status=(f"{problem_count} need attention" if problem_count else "all succeeded"), + library_status_tone="warn" if problem_count else "ok", focus=focus, selected_line=focus["line_id"] if focus else None, line_count=len(paths), @@ -163,14 +170,17 @@ async def generate(request: Request, session: SessionDep) -> RedirectResponse: try: parent_id = str((await request.form()).get("parent_generation_id") or "") if parent_id: - await edit_image_generation( + result = await edit_image_generation( generation_id=uuid.UUID(parent_id), request=request, session=session, services=services, ) else: - await create_image_generation(request=request, session=session, services=services) + result = await create_image_generation( + request=request, session=session, services=services + ) + return RedirectResponse(url=f"/design-studio?sel={result['id']}", status_code=303) except Exception: pass return RedirectResponse(url="/design-studio", status_code=303) diff --git a/src/ecommerce_agent/web/routes.py b/src/ecommerce_agent/web/routes.py index 3b1b5e1..b2a24ae 100644 --- a/src/ecommerce_agent/web/routes.py +++ b/src/ecommerce_agent/web/routes.py @@ -16,6 +16,9 @@ _TEMPLATES_DIR = Path(__file__).parent / "templates" templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) +templates.env.globals["static_version"] = max( + path.stat().st_mtime_ns for path in (_TEMPLATES_DIR.parent / "static").iterdir() +) router = APIRouter(include_in_schema=False) diff --git a/src/ecommerce_agent/web/static/app.js b/src/ecommerce_agent/web/static/app.js index ceea1b9..df18a36 100644 --- a/src/ecommerce_agent/web/static/app.js +++ b/src/ecommerce_agent/web/static/app.js @@ -164,6 +164,20 @@ if (h) h.value = radio.getAttribute("data-h"); }); + // ── Image generation progress ──────────────────────────────────────────── + document.addEventListener("submit", function (e) { + var form = e.target.closest("[data-generation-form]"); + if (!form) return; + var selectedModel = document.querySelector('[data-dd="image"] .ws-opt.is-on'); + var modelInput = form.querySelector("[data-image-model]"); + if (selectedModel && modelInput) modelInput.value = selectedModel.getAttribute("data-value"); + form.setAttribute("aria-busy", "true"); + var status = form.querySelector("[data-generating]"); + var button = form.querySelector("[data-generate-button]"); + if (status) status.hidden = false; + if (button) { button.disabled = true; button.textContent = "Generating…"; } + }); + // ── Removable tags editor ───────────────────────────────────────────────── function updateTagCount(form) { var count = form.querySelectorAll("[data-tag-list] .ws-tag:not(.ws-tag-add)").length; diff --git a/src/ecommerce_agent/web/static/b1.css b/src/ecommerce_agent/web/static/b1.css index 9976e80..c72368c 100644 --- a/src/ecommerce_agent/web/static/b1.css +++ b/src/ecommerce_agent/web/static/b1.css @@ -415,6 +415,13 @@ textarea.ws-input { line-height: 1.5; font-size: 13px; resize: vertical; } .ws-disclosure summary { list-style: none; cursor: pointer; } .ws-disclosure summary::-webkit-details-marker { display: none; } .ws-form { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; } +.ws-generating { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: var(--border); border-radius: var(--radius); color: var(--ink); } +.ws-generating[hidden] { display: none; } +.ws-generating span:last-child { display: flex; flex-direction: column; gap: 2px; } +.ws-generating small { color: var(--faint); } +.ws-spinner { width: 16px; height: 16px; border: 2px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: ws-spin .8s linear infinite; } +.ws-generation-error { margin: 0 12px 12px; padding: 8px 10px; border: 1px solid var(--warn); border-radius: var(--radius); color: var(--warn); font-size: 12px; line-height: 1.4; } +@keyframes ws-spin { to { transform: rotate(360deg); } } .ws-faint-mono { font-family: var(--mono); font-size: 11px; color: var(--faint); } /* ── Report list card ────────────────────────────────────────────────────── */ @@ -564,6 +571,7 @@ textarea.ws-input { line-height: 1.5; font-size: 13px; resize: vertical; } .ws-libcard-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } .ws-libcard-thumbs { display: flex; gap: 6px; } .ws-libcard-thumbs .ws-thumb { aspect-ratio: 1; flex: 1; } +.ws-libcard .ws-thumb-img, .ws-selected-preview .ws-thumb-img { object-fit: contain; } .ws-libscore-row { display: flex; justify-content: space-between; margin-bottom: 5px; } .ws-lineage { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 11px; color: var(--faint); } .ws-lineage-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/src/ecommerce_agent/web/templates/base.html b/src/ecommerce_agent/web/templates/base.html index 0bec012..2a26b91 100644 --- a/src/ecommerce_agent/web/templates/base.html +++ b/src/ecommerce_agent/web/templates/base.html @@ -16,9 +16,9 @@ - + - +
diff --git a/src/ecommerce_agent/web/templates/design_studio.html b/src/ecommerce_agent/web/templates/design_studio.html index 8e0707e..1f87510 100644 --- a/src/ecommerce_agent/web/templates/design_studio.html +++ b/src/ecommerce_agent/web/templates/design_studio.html @@ -9,7 +9,7 @@
-
+ {% if parent_generation_id %}{% endif %}
{{ field_label("Title") }}
@@ -51,8 +51,9 @@
- +
+
@@ -64,7 +65,7 @@
{{ icon("search", 13) }} filter lines
- {{ chip("all succeeded", "ok") }} + {{ chip(library_status, library_status_tone) }}
@@ -97,6 +98,7 @@
{% endfor %} + {% if line.error %}
{{ line.error }}
{% endif %}
Workshop score diff --git a/src/ecommerce_agent/web/templates/partials/_studio_focus.html b/src/ecommerce_agent/web/templates/partials/_studio_focus.html index 9602b62..553f86f 100644 --- a/src/ecommerce_agent/web/templates/partials/_studio_focus.html +++ b/src/ecommerce_agent/web/templates/partials/_studio_focus.html @@ -12,7 +12,7 @@
{% if focus.thumb %}{% else %}
{% endif %} -
1024 × 1024
+
{{ focus.size }}
{{ focus.version }}
@@ -35,6 +35,7 @@

{{ focus.prompt }}

+ {% if focus.error %}
{{ focus.error }}
{% endif %}
{% for m in focus.meta %}{{ m }}{% endfor %} diff --git a/tests/smoke/test_api_and_dashboard.py b/tests/smoke/test_api_and_dashboard.py index 0f3597a..7d4f234 100644 --- a/tests/smoke/test_api_and_dashboard.py +++ b/tests/smoke/test_api_and_dashboard.py @@ -909,8 +909,9 @@ async def _get_session(): assert await process_one(db_sessions, services) is True drafts = (await client.get("/api/printful/drafts")).json() - assert drafts[0]["status"] == "succeeded" - assert drafts[0]["request_payload"]["sync_variants"][0]["files"][0]["options"] == [ + submitted = next(row for row in drafts if row["id"] == draft["id"]) + assert submitted["status"] == "succeeded" + assert submitted["request_payload"]["sync_variants"][0]["files"][0]["options"] == [ {"id": "full_color", "value": True} ] assert (await client.get("/api/printful/templates")).json() diff --git a/tests/unit/test_gemini_images.py b/tests/unit/test_gemini_images.py index 96b5aa5..79688c4 100644 --- a/tests/unit/test_gemini_images.py +++ b/tests/unit/test_gemini_images.py @@ -113,6 +113,22 @@ def handler(request: httpx.Request) -> httpx.Response: assert "generationConfig" not in captured["payload"] +async def test_accepts_gemini_native_image_dimensions(tmp_path) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return success_response(request, width=1408, height=768) + + result = await provider(tmp_path, handler).generate( + ImageGenerationRequest( + prompt="Poster", + product_concept_id="native-size", + model="gemini-3.1-flash-image", + ) + ) + + assert (result.width, result.height) == (1408, 768) + assert result.metadata["requested_size"] == "1024x1024" + + async def test_unsupported_size_is_rejected_before_network_call(tmp_path) -> None: calls = 0 diff --git a/tests/unit/test_openai_images.py b/tests/unit/test_openai_images.py index 51de8a4..fd39004 100644 --- a/tests/unit/test_openai_images.py +++ b/tests/unit/test_openai_images.py @@ -202,6 +202,25 @@ def handler(request: httpx.Request) -> httpx.Response: assert calls == 1 +async def test_billing_limit_error_is_actionable(tmp_path) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 400, + json={ + "error": { + "message": "Billing hard limit has been reached.", + "code": "billing_hard_limit_reached", + } + }, + request=request, + ) + + with pytest.raises(OpenAIImageError, match="billing hard limit.*Nano Banana"): + await provider(tmp_path, handler).generate( + ImageGenerationRequest(prompt="Billing", product_concept_id="id") + ) + + @pytest.mark.parametrize( ("payload", "output_format"), [ diff --git a/tests/web/test_design_studio.py b/tests/web/test_design_studio.py index 7268d3b..5481e86 100644 --- a/tests/web/test_design_studio.py +++ b/tests/web/test_design_studio.py @@ -88,3 +88,6 @@ async def test_image_model_picker_controls_generation_form(web) -> None: assert 'data-value="gemini-3-pro-image"' in resp.text assert "Nano Banana Pro" in resp.text assert 'data-value="gemini-2.5-flash-image"' in resp.text + assert "data-generation-form" in resp.text + assert "Generating image…" in resp.text + assert 'data-dd="image"' in resp.text