Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 7 additions & 19 deletions src/ecommerce_agent/services/gemini_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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),
Expand Down Expand Up @@ -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
21 changes: 18 additions & 3 deletions src/ecommerce_agent/services/openai_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,16 +223,31 @@ 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:
await client.aclose()
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,
*,
Expand Down
14 changes: 12 additions & 2 deletions src/ecommerce_agent/web/design_studio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}


Expand All @@ -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 "—",
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/ecommerce_agent/web/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
14 changes: 14 additions & 0 deletions src/ecommerce_agent/web/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions src/ecommerce_agent/web/static/b1.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────── */
Expand Down Expand Up @@ -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; }
Expand Down
4 changes: 2 additions & 2 deletions src/ecommerce_agent/web/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Spline+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/b1.css">
<link rel="stylesheet" href="/static/b1.css?v={{ static_version }}">
<script src="/static/htmx.min.js" defer></script>
<script src="/static/app.js" defer></script>
<script src="/static/app.js?v={{ static_version }}" defer></script>
</head>
<body>
<div class="ws-app">
Expand Down
8 changes: 5 additions & 3 deletions src/ecommerce_agent/web/templates/design_studio.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<button type="button" class="ws-icon-btn" title="Discard &amp; close" data-composer-close>{{ icon("x", 14) }}</button>
</div>
</div>
<form class="ws-composer-body" method="post" action="/design-studio/generate" enctype="multipart/form-data" data-aspect-form>
<form class="ws-composer-body" method="post" action="/design-studio/generate" enctype="multipart/form-data" data-aspect-form data-generation-form>
<input type="hidden" name="model" value="{{ image_model }}" data-image-model>
{% if parent_generation_id %}<input type="hidden" name="parent_generation_id" value="{{ parent_generation_id }}">{% endif %}
<div>{{ field_label("Title") }}<input class="ws-input" name="title" value="{{ prefill_title }}" placeholder="Untitled design"></div>
Expand Down Expand Up @@ -51,8 +51,9 @@

<div class="ws-composer-foot">
<button type="button" class="ws-btn ws-ghost">{{ icon("eye", 14) }} Preview</button>
<button type="submit" class="ws-btn ws-primary ws-grow">{{ icon("bolt", 14, 1.9) }} Generate v1</button>
<button type="submit" class="ws-btn ws-primary ws-grow" data-generate-button>{{ icon("bolt", 14, 1.9) }} Generate v1</button>
</div>
<div class="ws-generating" data-generating role="status" hidden><span class="ws-spinner"></span><span><strong>Generating image…</strong><small>This can take a minute.</small></span></div>
</form>
</section>

Expand All @@ -64,7 +65,7 @@
</div>
<div class="ws-lib-head-r">
<div class="ws-pseudo-input">{{ icon("search", 13) }} filter lines</div>
{{ chip("all succeeded", "ok") }}
{{ chip(library_status, library_status_tone) }}
</div>
</div>
<div class="ws-lib-scroll">
Expand Down Expand Up @@ -97,6 +98,7 @@
</div>
{% endfor %}
</div>
{% if line.error %}<div class="ws-generation-error">{{ line.error }}</div>{% endif %}
<div class="ws-libscore">
<div class="ws-libscore-row">
<span class="ws-mono-label">Workshop score</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<div class="ws-selected-body">
<div class="ws-thumb is-big ws-selected-preview">
{% if focus.thumb %}<img class="ws-thumb-img" src="{{ focus.thumb }}" alt="">{% else %}<div class="ws-thumb-hatch"></div>{% endif %}
<div class="ws-thumb-sub">1024 × 1024</div>
<div class="ws-thumb-sub">{{ focus.size }}</div>
<div class="ws-thumb-label">{{ focus.version }}</div>
</div>

Expand All @@ -35,6 +35,7 @@
</div>

<p class="ws-prose ws-clamp4">{{ focus.prompt }}</p>
{% if focus.error %}<div class="ws-generation-error">{{ focus.error }}</div>{% endif %}

<div class="ws-meta-chips">
{% for m in focus.meta %}<span class="ws-meta-chip">{{ m }}</span>{% endfor %}
Expand Down
5 changes: 3 additions & 2 deletions tests/smoke/test_api_and_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
16 changes: 16 additions & 0 deletions tests/unit/test_gemini_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions tests/unit/test_openai_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
[
Expand Down
3 changes: 3 additions & 0 deletions tests/web/test_design_studio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading