From dfe8765f10a522e0b4763cc9f51b27d1a28aa01d Mon Sep 17 00:00:00 2001 From: Anil-matcha Date: Sat, 29 Aug 2026 11:16:47 +0530 Subject: [PATCH] feat(design): add opt-in MuAPI logo provider --- .claude/skills/design/SKILL.md | 16 +- .../skills/design/references/logo-design.md | 17 +- .../skills/design/scripts/logo/generate.py | 186 ++++++++++++++++-- .../scripts/logo/tests/test_generate.py | 115 +++++++++++ cli/assets/skills/design/SKILL.md | 16 +- .../skills/design/references/logo-design.md | 17 +- .../skills/design/scripts/logo/generate.py | 186 ++++++++++++++++-- .../scripts/logo/tests/test_generate.py | 115 +++++++++++ 8 files changed, 620 insertions(+), 48 deletions(-) diff --git a/.claude/skills/design/SKILL.md b/.claude/skills/design/SKILL.md index fadccf4..86309e3 100644 --- a/.claude/skills/design/SKILL.md +++ b/.claude/skills/design/SKILL.md @@ -1,6 +1,6 @@ --- name: design -description: "Comprehensive design skill: brand identity, design tokens, UI styling, logo generation (55 styles, Gemini or Atlas Cloud AI), corporate identity program (50 deliverables, CIP mockups), HTML presentations (Chart.js), banner design (22 styles, social/ads/web/print), icon design (15 styles, SVG, Gemini 3.1 Pro), social photos (HTML→screenshot, multi-platform). Actions: design logo, create CIP, generate mockups, build slides, design banner, generate icon, create social photos, social media images, brand identity, design system. Platforms: Facebook, Twitter, LinkedIn, YouTube, Instagram, Pinterest, TikTok, Threads, Google Ads." +description: "Comprehensive design skill: brand identity, design tokens, UI styling, logo generation (55 styles, Gemini or MuAPI AI), corporate identity program (50 deliverables, CIP mockups), HTML presentations (Chart.js), banner design (22 styles, social/ads/web/print), icon design (15 styles, SVG, Gemini 3.1 Pro), social photos (HTML→screenshot, multi-platform). Actions: design logo, create CIP, generate mockups, build slides, design banner, generate icon, create social photos, social media images, brand identity, design system. Platforms: Facebook, Twitter, LinkedIn, YouTube, Instagram, Pinterest, TikTok, Threads, Google Ads." argument-hint: "[design-type] [context]" license: MIT metadata: @@ -39,7 +39,8 @@ Unified design skill: brand, tokens, UI, logo, CIP, slides, banners, social phot ## Logo Design (Built-in) -55+ styles, 30 color palettes, 25 industry guides. Gemini Nano Banana models. +55+ styles, 30 color palettes, 25 industry guides. Gemini Nano Banana and +MuAPI image generation. ### Logo: Generate Design Brief @@ -63,6 +64,8 @@ python3 ~/.claude/skills/design/scripts/logo/search.py "healthcare medical" --do python3 ~/.claude/skills/design/scripts/logo/generate.py --brand "TechFlow" --style minimalist --industry tech python3 ~/.claude/skills/design/scripts/logo/generate.py --prompt "coffee shop vintage badge" --style vintage python3 ~/.claude/skills/design/scripts/logo/generate.py --brand "TechFlow" --provider atlas +python3 ~/.claude/skills/design/scripts/logo/generate.py --brand "TechFlow" --provider muapi +python3 ~/.claude/skills/design/scripts/logo/generate.py --brand "TechFlow" --provider muapi --muapi-model nano-banana-pro ``` **IMPORTANT:** When scripts fail, try to fix them directly. @@ -304,8 +307,17 @@ python3 --version || python --version ```bash export GEMINI_API_KEY="your-key" # https://aistudio.google.com/apikey pip install google-genai pillow + +# Optional MuAPI provider (no extra Python package required) +export MUAPI_API_KEY="your-key" ``` +MuAPI uses the asynchronous model endpoint and prediction result API. See the +[MuAPI API reference](https://muapi.ai/docs/api-reference) for authentication +and current model contracts. The logo generator currently supports the +`nano-banana` and `nano-banana-pro` model slugs, and sends only the shared +`prompt` and `aspect_ratio` fields. + > **Note for Windows:** Use `python` instead of `pip` where needed (e.g., `python -m pip install ...`). ## Integration diff --git a/.claude/skills/design/references/logo-design.md b/.claude/skills/design/references/logo-design.md index 3fae93b..497197f 100644 --- a/.claude/skills/design/references/logo-design.md +++ b/.claude/skills/design/references/logo-design.md @@ -1,13 +1,13 @@ # Logo Design Reference -AI-powered logo design with 55+ styles, 30 color palettes, 25 industry guides. Gemini Nano Banana is the default provider; Atlas Cloud is also available as an explicit opt-in. +AI-powered logo design with 55+ styles, 30 color palettes, 25 industry guides. Gemini Nano Banana is the default provider; MuAPI is also available as an explicit opt-in provider. ## Scripts | Script | Purpose | |--------|---------| | `scripts/logo/search.py` | Search styles, colors, industries; generate design briefs | -| `scripts/logo/generate.py` | Generate logos with Gemini Nano Banana or Atlas Cloud | +| `scripts/logo/generate.py` | Generate logos with Gemini Nano Banana or MuAPI | | `scripts/logo/core.py` | BM25 search engine for logo data | ## Commands @@ -39,9 +39,11 @@ python3 ~/.claude/skills/design/scripts/logo/search.py "healthcare medical" --do python3 ~/.claude/skills/design/scripts/logo/generate.py --brand "TechFlow" --style minimalist --industry tech python3 ~/.claude/skills/design/scripts/logo/generate.py --prompt "coffee shop vintage badge" --style vintage python3 ~/.claude/skills/design/scripts/logo/generate.py --brand "TechFlow" --provider atlas +python3 ~/.claude/skills/design/scripts/logo/generate.py --brand "TechFlow" --provider muapi +python3 ~/.claude/skills/design/scripts/logo/generate.py --brand "TechFlow" --provider muapi --muapi-model nano-banana-pro ``` -Options: `--style`, `--industry`, `--prompt`, `--provider`, `--atlas-model` +Options: `--style`, `--industry`, `--prompt`, `--provider`, `--atlas-model`, `--muapi-model` ## Available Styles @@ -93,4 +95,13 @@ pip install google-genai # Optional Atlas Cloud provider (no extra Python package required) export ATLASCLOUD_API_KEY="your-key" + +# Optional MuAPI provider (no extra Python package required) +export MUAPI_API_KEY="your-key" ``` + +MuAPI uses the asynchronous model endpoint and prediction result API. See the +[MuAPI API reference](https://muapi.ai/docs/api-reference) for authentication +and current model contracts. The logo generator currently supports the +`nano-banana` and `nano-banana-pro` model slugs, and sends only the shared +`prompt` and `aspect_ratio` fields. diff --git a/.claude/skills/design/scripts/logo/generate.py b/.claude/skills/design/scripts/logo/generate.py index 7de5b70..d402b73 100644 --- a/.claude/skills/design/scripts/logo/generate.py +++ b/.claude/skills/design/scripts/logo/generate.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -"""Logo generation with Gemini or Atlas Cloud. +"""Logo generation with Gemini or MuAPI. -Gemini remains the default provider. Atlas Cloud is opt-in with -``--provider atlas`` and uses its asynchronous image generation API. +Gemini remains the default provider. MuAPI is opt-in with +``--provider muapi`` and uses its asynchronous image generation API with the +selected model's prompt/aspect-ratio contract. Models: - Nano Banana (default): gemini-2.5-flash-image - fast, high-volume, low-latency @@ -14,6 +15,8 @@ Usage: python generate.py --brand "TechFlow" --industry tech --style minimalist python generate.py --brand "TechFlow" --pro # Use Nano Banana Pro model python generate.py --brand "TechFlow" --provider atlas + python generate.py --brand "TechFlow" --provider muapi + python generate.py --brand "TechFlow" --provider muapi --muapi-model nano-banana-pro Batch mode (generates multiple variants): python generate.py --brand "Unikorn" --batch 9 --output-dir ./logos --pro @@ -57,6 +60,7 @@ load_env() # ============ CONFIGURATION ============ GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY") ATLASCLOUD_API_KEY = os.environ.get("ATLASCLOUD_API_KEY") +MUAPI_API_KEY = os.environ.get("MUAPI_API_KEY") # Gemini "Nano Banana" model configurations for image generation GEMINI_FLASH = "gemini-2.5-flash-image" # Nano Banana: fast, high-volume, low-latency @@ -65,9 +69,14 @@ GEMINI_PRO = "gemini-3-pro-image-preview" # Nano Banana Pro: professional quali # Atlas Cloud model validated against the live model catalog and schema. ATLAS_MODEL = "google/nano-banana-2-lite/text-to-image" ATLAS_API_BASE = "https://api.atlascloud.ai/api/v1" -HTTP_USER_AGENT = "ui-ux-pro-max/2.5 (Atlas Cloud logo provider)" +MUAPI_MODEL = "nano-banana" +MUAPI_MODELS = ("nano-banana", "nano-banana-pro") +MUAPI_API_BASE = "https://api.muapi.ai/api/v1" +HTTP_USER_AGENT = "ui-ux-pro-max/2.5 (logo generation)" ATLAS_POLL_INTERVAL = 2 ATLAS_MAX_POLLS = 90 +MUAPI_POLL_INTERVAL = 2 +MUAPI_MAX_POLLS = 90 # Supported aspect ratios ASPECT_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4"] @@ -156,13 +165,13 @@ def _validate_public_https_url(url): or parsed.username or parsed.password ): - raise ValueError("Atlas Cloud returned an invalid media URL") + raise ValueError("Provider returned an invalid media URL") hostname = parsed.hostname.lower().rstrip(".") if hostname == "localhost" or hostname.endswith( (".localhost", ".local", ".internal") ): - raise ValueError("Atlas Cloud media URL used a local hostname") + raise ValueError("Provider media URL used a local hostname") try: ip = ipaddress.ip_address(hostname) @@ -170,17 +179,26 @@ def _validate_public_https_url(url): return else: if not ip.is_global: - raise ValueError("Atlas Cloud media URL used a non-public address") + raise ValueError("Provider media URL used a non-public address") -def _json_request(url, api_key, method="GET", payload=None): +def _json_request( + url, api_key, method="GET", payload=None, api_key_header="Authorization" +): + if api_key_header == "Authorization": + auth_value = f"Bearer {api_key}" + elif api_key_header == "x-api-key": + auth_value = api_key + else: + raise ValueError("Unsupported API key header") + body = json.dumps(payload).encode("utf-8") if payload is not None else None request = Request( url, data=body, method=method, headers={ - "Authorization": f"Bearer {api_key}", + api_key_header: auth_value, "Accept": "application/json", "User-Agent": HTTP_USER_AGENT, **({"Content-Type": "application/json"} if body is not None else {}), @@ -192,10 +210,10 @@ def _json_request(url, api_key, method="GET", payload=None): except HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace") raise RuntimeError( - f"Atlas Cloud request failed ({exc.code}): {detail[:300]}" + f"Provider request failed ({exc.code}): {detail[:300]}" ) from exc except (URLError, TimeoutError, json.JSONDecodeError) as exc: - raise RuntimeError(f"Atlas Cloud request failed: {exc}") from exc + raise RuntimeError(f"Provider request failed: {exc}") from exc def _atlas_prediction_data(response): @@ -210,6 +228,10 @@ def _atlas_prediction_data(response): def _download_atlas_image(url, output_path): + _download_image(url, output_path, "image provider") + + +def _download_image(url, output_path, provider_name): _validate_public_https_url(url) request = Request( url, @@ -222,14 +244,14 @@ def _download_atlas_image(url, output_path): content_type = response.headers.get_content_type() if not content_type.startswith("image/"): raise RuntimeError( - f"Atlas Cloud output is not an image ({content_type})" + f"{provider_name} output is not an image ({content_type})" ) image_data = response.read() except (HTTPError, URLError, TimeoutError) as exc: - raise RuntimeError(f"Unable to download Atlas Cloud image: {exc}") from exc + raise RuntimeError(f"Unable to download {provider_name} image: {exc}") from exc if not image_data: - raise RuntimeError("Atlas Cloud returned an empty image") + raise RuntimeError(f"{provider_name} returned an empty image") with open(output_path, "wb") as output_file: output_file.write(image_data) @@ -281,6 +303,106 @@ def _generate_with_atlas(prompt, output_path, aspect_ratio, api_key, model): raise RuntimeError("Atlas Cloud prediction timed out while polling") +def _muapi_response_objects(response): + """Return the response and common MuAPI envelopes without guessing fields.""" + if not isinstance(response, dict): + raise TypeError("MuAPI returned an invalid response") + + objects = [response] + for key in ("data", "output", "result"): + value = response.get(key) + if isinstance(value, dict) and value not in objects: + objects.append(value) + return objects + + +def _muapi_response_value(response, keys): + for item in _muapi_response_objects(response): + for key in keys: + value = item.get(key) + if value not in (None, ""): + return value + return None + + +def _muapi_error(response): + value = _muapi_response_value(response, ("error", "message", "detail")) + if isinstance(value, str): + return value[:300] + return "MuAPI request failed" + + +def _muapi_output_url(response): + for item in _muapi_response_objects(response): + outputs = item.get("outputs") + if isinstance(outputs, list): + for output in outputs: + if isinstance(output, str) and output.startswith("https://"): + return output + if isinstance(output, dict): + for key in ("url", "image_url"): + value = output.get(key) + if isinstance(value, str) and value.startswith("https://"): + return value + raise RuntimeError("MuAPI completed without an HTTPS image URL") + + +def _download_muapi_image(url, output_path): + _download_image(url, output_path, "MuAPI") + + +def _generate_with_muapi(prompt, output_path, aspect_ratio, api_key, model): + if not api_key: + raise RuntimeError("MUAPI_API_KEY not set") + if model not in MUAPI_MODELS: + raise RuntimeError( + f"Unsupported MuAPI logo model: {model}. " + f"Choose one of: {', '.join(MUAPI_MODELS)}" + ) + + payload = { + "prompt": prompt, + "aspect_ratio": aspect_ratio, + } + response = _json_request( + f"{MUAPI_API_BASE}/{model}", + api_key, + method="POST", + payload=payload, + api_key_header="x-api-key", + ) + request_id = _muapi_response_value(response, ("request_id", "id")) + if not isinstance(request_id, str) or not request_id: + raise RuntimeError("MuAPI did not return a request ID") + + data = response + for poll_number in range(MUAPI_MAX_POLLS + 1): + status = _muapi_response_value(data, ("status",)) + normalized_status = str(status or "").lower() + if normalized_status in {"completed", "succeeded", "success"}: + _download_muapi_image(_muapi_output_url(data), output_path) + return + if normalized_status in { + "failed", + "error", + "timeout", + "canceled", + "cancelled", + }: + raise RuntimeError(f"MuAPI generation {normalized_status}: {_muapi_error(data)}") + if poll_number == MUAPI_MAX_POLLS: + break + + time.sleep(MUAPI_POLL_INTERVAL) + data = _json_request( + f"{MUAPI_API_BASE}/predictions/{request_id}/result", + api_key, + api_key_header="x-api-key", + ) + + raise RuntimeError("MuAPI prediction timed out while polling") + + def _generate_with_gemini(prompt, output_path, aspect_ratio, use_pro): if not GEMINI_API_KEY: raise RuntimeError("GEMINI_API_KEY not set") @@ -344,8 +466,9 @@ def generate_logo( aspect_ratio=None, provider="gemini", atlas_model=ATLAS_MODEL, + muapi_model=MUAPI_MODEL, ): - """Generate a logo using Gemini or Atlas Cloud image generation. + """Generate a logo using Gemini or MuAPI image generation. Args: aspect_ratio: Image aspect ratio. Options: "1:1", "16:9", "9:16", "4:3", "3:4" @@ -365,6 +488,8 @@ def generate_logo( if provider == "atlas": model_label = f"Atlas Cloud ({atlas_model})" + elif provider == "muapi": + model_label = f"MuAPI ({muapi_model})" else: model_label = ( "Nano Banana Pro (gemini-3-pro-image-preview)" @@ -386,6 +511,14 @@ def generate_logo( ATLASCLOUD_API_KEY, atlas_model, ) + elif provider == "muapi": + _generate_with_muapi( + full_prompt, + output_path, + ratio, + MUAPI_API_KEY, + muapi_model, + ) else: _generate_with_gemini(full_prompt, output_path, ratio, use_pro) @@ -407,6 +540,7 @@ def generate_batch( aspect_ratio=None, provider="gemini", atlas_model=ATLAS_MODEL, + muapi_model=MUAPI_MODEL, ): """Generate multiple logo variants with different styles""" @@ -430,6 +564,8 @@ def generate_batch( model_label = ( f"Atlas Cloud ({atlas_model})" if provider == "atlas" + else f"MuAPI ({muapi_model})" + if provider == "muapi" else f"Nano Banana {'Pro' if use_pro else 'Flash'}" ) ratio = aspect_ratio if aspect_ratio in ASPECT_RATIOS else DEFAULT_ASPECT_RATIO @@ -466,6 +602,7 @@ def generate_batch( aspect_ratio=aspect_ratio, provider=provider, atlas_model=atlas_model, + muapi_model=muapi_model, ) if result: @@ -487,7 +624,7 @@ def generate_batch( def main(): parser = argparse.ArgumentParser( - description="Generate logos using Gemini or Atlas Cloud" + description="Generate logos using Gemini or MuAPI" ) parser.add_argument("--prompt", "-p", type=str, help="Logo description prompt") parser.add_argument("--brand", "-b", type=str, help="Brand name") @@ -514,7 +651,7 @@ def main(): ) parser.add_argument( "--provider", - choices=["gemini", "atlas"], + choices=["gemini", "atlas", "muapi"], default="gemini", help="Image provider (default: gemini)", ) @@ -523,6 +660,12 @@ def main(): default=ATLAS_MODEL, help=f"Atlas Cloud image model (default: {ATLAS_MODEL})", ) + parser.add_argument( + "--muapi-model", + choices=MUAPI_MODELS, + default=MUAPI_MODEL, + help=f"MuAPI image model (default: {MUAPI_MODEL})", + ) parser.add_argument( "--aspect-ratio", "-r", @@ -539,8 +682,11 @@ def main(): args = parser.parse_args() - if args.provider == "atlas" and args.pro: - parser.error("--pro is only available with --provider gemini") + if args.provider != "gemini" and args.pro: + parser.error( + "--pro is only available with --provider gemini; " + "use --muapi-model nano-banana-pro for MuAPI" + ) if args.list_styles: print("Available styles:") @@ -574,6 +720,7 @@ def main(): aspect_ratio=args.aspect_ratio, provider=args.provider, atlas_model=args.atlas_model, + muapi_model=args.muapi_model, ) else: generate_logo( @@ -586,6 +733,7 @@ def main(): aspect_ratio=args.aspect_ratio, provider=args.provider, atlas_model=args.atlas_model, + muapi_model=args.muapi_model, ) diff --git a/.claude/skills/design/scripts/logo/tests/test_generate.py b/.claude/skills/design/scripts/logo/tests/test_generate.py index 8420d90..11faa7d 100644 --- a/.claude/skills/design/scripts/logo/tests/test_generate.py +++ b/.claude/skills/design/scripts/logo/tests/test_generate.py @@ -128,5 +128,120 @@ class AtlasGenerationTests(unittest.TestCase): logo_generate._validate_public_https_url("https://assets.local/logo.png") +class MuapiGenerationTests(unittest.TestCase): + @patch.object(logo_generate, "_download_muapi_image") + @patch.object(logo_generate.time, "sleep") + @patch.object(logo_generate, "_json_request") + def test_muapi_submits_once_and_polls_until_completed( + self, json_request, sleep, download + ): + json_request.side_effect = [ + {"request_id": "req-123"}, + {"request_id": "req-123", "status": "processing"}, + { + "request_id": "req-123", + "status": "completed", + "outputs": ["https://media.example.com/logo.png"], + }, + ] + + logo_generate._generate_with_muapi( + "logo prompt", "logo.png", "1:1", "muapi-key", "nano-banana" + ) + + self.assertEqual(json_request.call_count, 3) + self.assertEqual( + json_request.call_args_list[0], + call( + f"{logo_generate.MUAPI_API_BASE}/nano-banana", + "muapi-key", + method="POST", + payload={"prompt": "logo prompt", "aspect_ratio": "1:1"}, + api_key_header="x-api-key", + ), + ) + self.assertEqual( + json_request.call_args_list[1:], + [ + call( + f"{logo_generate.MUAPI_API_BASE}/predictions/req-123/result", + "muapi-key", + api_key_header="x-api-key", + ), + call( + f"{logo_generate.MUAPI_API_BASE}/predictions/req-123/result", + "muapi-key", + api_key_header="x-api-key", + ), + ], + ) + self.assertEqual(sleep.call_count, 2) + download.assert_called_once_with( + "https://media.example.com/logo.png", "logo.png" + ) + + @patch.object(logo_generate, "_json_request") + def test_muapi_does_not_retry_generation_post(self, json_request): + json_request.side_effect = RuntimeError("network error") + + with self.assertRaisesRegex(RuntimeError, "network error"): + logo_generate._generate_with_muapi( + "logo prompt", "logo.png", "1:1", "muapi-key", "nano-banana" + ) + + json_request.assert_called_once() + + def test_muapi_requires_key_and_known_model(self): + with self.assertRaisesRegex(RuntimeError, "MUAPI_API_KEY not set"): + logo_generate._generate_with_muapi( + "logo prompt", "logo.png", "1:1", None, "nano-banana" + ) + + with self.assertRaisesRegex(RuntimeError, "Unsupported MuAPI logo model"): + logo_generate._generate_with_muapi( + "logo prompt", "logo.png", "1:1", "muapi-key", "unknown-model" + ) + + @patch.object(logo_generate, "build_opener") + def test_muapi_uses_x_api_key_header(self, build_opener): + class Response: + def __enter__(self): + return self + + def __exit__(self, *args): + return None + + @staticmethod + def read(): + return b"{}" + + build_opener.return_value.open.return_value = Response() + + logo_generate._json_request( + "https://api.muapi.ai/api/v1/nano-banana", + "muapi-key", + method="POST", + payload={"prompt": "logo"}, + api_key_header="x-api-key", + ) + + request = build_opener.return_value.open.call_args.args[0] + headers = {key.lower(): value for key, value in request.header_items()} + self.assertEqual(headers["x-api-key"], "muapi-key") + self.assertNotIn("authorization", headers) + + @patch.object(logo_generate, "_json_request") + def test_muapi_reports_failed_prediction(self, json_request): + json_request.side_effect = [ + {"request_id": "req-123"}, + {"status": "failed", "error": "invalid prompt"}, + ] + + with self.assertRaisesRegex(RuntimeError, "invalid prompt"): + logo_generate._generate_with_muapi( + "logo prompt", "logo.png", "1:1", "muapi-key", "nano-banana" + ) + + if __name__ == "__main__": unittest.main() diff --git a/cli/assets/skills/design/SKILL.md b/cli/assets/skills/design/SKILL.md index fadccf4..86309e3 100644 --- a/cli/assets/skills/design/SKILL.md +++ b/cli/assets/skills/design/SKILL.md @@ -1,6 +1,6 @@ --- name: design -description: "Comprehensive design skill: brand identity, design tokens, UI styling, logo generation (55 styles, Gemini or Atlas Cloud AI), corporate identity program (50 deliverables, CIP mockups), HTML presentations (Chart.js), banner design (22 styles, social/ads/web/print), icon design (15 styles, SVG, Gemini 3.1 Pro), social photos (HTML→screenshot, multi-platform). Actions: design logo, create CIP, generate mockups, build slides, design banner, generate icon, create social photos, social media images, brand identity, design system. Platforms: Facebook, Twitter, LinkedIn, YouTube, Instagram, Pinterest, TikTok, Threads, Google Ads." +description: "Comprehensive design skill: brand identity, design tokens, UI styling, logo generation (55 styles, Gemini or MuAPI AI), corporate identity program (50 deliverables, CIP mockups), HTML presentations (Chart.js), banner design (22 styles, social/ads/web/print), icon design (15 styles, SVG, Gemini 3.1 Pro), social photos (HTML→screenshot, multi-platform). Actions: design logo, create CIP, generate mockups, build slides, design banner, generate icon, create social photos, social media images, brand identity, design system. Platforms: Facebook, Twitter, LinkedIn, YouTube, Instagram, Pinterest, TikTok, Threads, Google Ads." argument-hint: "[design-type] [context]" license: MIT metadata: @@ -39,7 +39,8 @@ Unified design skill: brand, tokens, UI, logo, CIP, slides, banners, social phot ## Logo Design (Built-in) -55+ styles, 30 color palettes, 25 industry guides. Gemini Nano Banana models. +55+ styles, 30 color palettes, 25 industry guides. Gemini Nano Banana and +MuAPI image generation. ### Logo: Generate Design Brief @@ -63,6 +64,8 @@ python3 ~/.claude/skills/design/scripts/logo/search.py "healthcare medical" --do python3 ~/.claude/skills/design/scripts/logo/generate.py --brand "TechFlow" --style minimalist --industry tech python3 ~/.claude/skills/design/scripts/logo/generate.py --prompt "coffee shop vintage badge" --style vintage python3 ~/.claude/skills/design/scripts/logo/generate.py --brand "TechFlow" --provider atlas +python3 ~/.claude/skills/design/scripts/logo/generate.py --brand "TechFlow" --provider muapi +python3 ~/.claude/skills/design/scripts/logo/generate.py --brand "TechFlow" --provider muapi --muapi-model nano-banana-pro ``` **IMPORTANT:** When scripts fail, try to fix them directly. @@ -304,8 +307,17 @@ python3 --version || python --version ```bash export GEMINI_API_KEY="your-key" # https://aistudio.google.com/apikey pip install google-genai pillow + +# Optional MuAPI provider (no extra Python package required) +export MUAPI_API_KEY="your-key" ``` +MuAPI uses the asynchronous model endpoint and prediction result API. See the +[MuAPI API reference](https://muapi.ai/docs/api-reference) for authentication +and current model contracts. The logo generator currently supports the +`nano-banana` and `nano-banana-pro` model slugs, and sends only the shared +`prompt` and `aspect_ratio` fields. + > **Note for Windows:** Use `python` instead of `pip` where needed (e.g., `python -m pip install ...`). ## Integration diff --git a/cli/assets/skills/design/references/logo-design.md b/cli/assets/skills/design/references/logo-design.md index 3fae93b..497197f 100644 --- a/cli/assets/skills/design/references/logo-design.md +++ b/cli/assets/skills/design/references/logo-design.md @@ -1,13 +1,13 @@ # Logo Design Reference -AI-powered logo design with 55+ styles, 30 color palettes, 25 industry guides. Gemini Nano Banana is the default provider; Atlas Cloud is also available as an explicit opt-in. +AI-powered logo design with 55+ styles, 30 color palettes, 25 industry guides. Gemini Nano Banana is the default provider; MuAPI is also available as an explicit opt-in provider. ## Scripts | Script | Purpose | |--------|---------| | `scripts/logo/search.py` | Search styles, colors, industries; generate design briefs | -| `scripts/logo/generate.py` | Generate logos with Gemini Nano Banana or Atlas Cloud | +| `scripts/logo/generate.py` | Generate logos with Gemini Nano Banana or MuAPI | | `scripts/logo/core.py` | BM25 search engine for logo data | ## Commands @@ -39,9 +39,11 @@ python3 ~/.claude/skills/design/scripts/logo/search.py "healthcare medical" --do python3 ~/.claude/skills/design/scripts/logo/generate.py --brand "TechFlow" --style minimalist --industry tech python3 ~/.claude/skills/design/scripts/logo/generate.py --prompt "coffee shop vintage badge" --style vintage python3 ~/.claude/skills/design/scripts/logo/generate.py --brand "TechFlow" --provider atlas +python3 ~/.claude/skills/design/scripts/logo/generate.py --brand "TechFlow" --provider muapi +python3 ~/.claude/skills/design/scripts/logo/generate.py --brand "TechFlow" --provider muapi --muapi-model nano-banana-pro ``` -Options: `--style`, `--industry`, `--prompt`, `--provider`, `--atlas-model` +Options: `--style`, `--industry`, `--prompt`, `--provider`, `--atlas-model`, `--muapi-model` ## Available Styles @@ -93,4 +95,13 @@ pip install google-genai # Optional Atlas Cloud provider (no extra Python package required) export ATLASCLOUD_API_KEY="your-key" + +# Optional MuAPI provider (no extra Python package required) +export MUAPI_API_KEY="your-key" ``` + +MuAPI uses the asynchronous model endpoint and prediction result API. See the +[MuAPI API reference](https://muapi.ai/docs/api-reference) for authentication +and current model contracts. The logo generator currently supports the +`nano-banana` and `nano-banana-pro` model slugs, and sends only the shared +`prompt` and `aspect_ratio` fields. diff --git a/cli/assets/skills/design/scripts/logo/generate.py b/cli/assets/skills/design/scripts/logo/generate.py index 7de5b70..d402b73 100644 --- a/cli/assets/skills/design/scripts/logo/generate.py +++ b/cli/assets/skills/design/scripts/logo/generate.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -"""Logo generation with Gemini or Atlas Cloud. +"""Logo generation with Gemini or MuAPI. -Gemini remains the default provider. Atlas Cloud is opt-in with -``--provider atlas`` and uses its asynchronous image generation API. +Gemini remains the default provider. MuAPI is opt-in with +``--provider muapi`` and uses its asynchronous image generation API with the +selected model's prompt/aspect-ratio contract. Models: - Nano Banana (default): gemini-2.5-flash-image - fast, high-volume, low-latency @@ -14,6 +15,8 @@ Usage: python generate.py --brand "TechFlow" --industry tech --style minimalist python generate.py --brand "TechFlow" --pro # Use Nano Banana Pro model python generate.py --brand "TechFlow" --provider atlas + python generate.py --brand "TechFlow" --provider muapi + python generate.py --brand "TechFlow" --provider muapi --muapi-model nano-banana-pro Batch mode (generates multiple variants): python generate.py --brand "Unikorn" --batch 9 --output-dir ./logos --pro @@ -57,6 +60,7 @@ load_env() # ============ CONFIGURATION ============ GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY") ATLASCLOUD_API_KEY = os.environ.get("ATLASCLOUD_API_KEY") +MUAPI_API_KEY = os.environ.get("MUAPI_API_KEY") # Gemini "Nano Banana" model configurations for image generation GEMINI_FLASH = "gemini-2.5-flash-image" # Nano Banana: fast, high-volume, low-latency @@ -65,9 +69,14 @@ GEMINI_PRO = "gemini-3-pro-image-preview" # Nano Banana Pro: professional quali # Atlas Cloud model validated against the live model catalog and schema. ATLAS_MODEL = "google/nano-banana-2-lite/text-to-image" ATLAS_API_BASE = "https://api.atlascloud.ai/api/v1" -HTTP_USER_AGENT = "ui-ux-pro-max/2.5 (Atlas Cloud logo provider)" +MUAPI_MODEL = "nano-banana" +MUAPI_MODELS = ("nano-banana", "nano-banana-pro") +MUAPI_API_BASE = "https://api.muapi.ai/api/v1" +HTTP_USER_AGENT = "ui-ux-pro-max/2.5 (logo generation)" ATLAS_POLL_INTERVAL = 2 ATLAS_MAX_POLLS = 90 +MUAPI_POLL_INTERVAL = 2 +MUAPI_MAX_POLLS = 90 # Supported aspect ratios ASPECT_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4"] @@ -156,13 +165,13 @@ def _validate_public_https_url(url): or parsed.username or parsed.password ): - raise ValueError("Atlas Cloud returned an invalid media URL") + raise ValueError("Provider returned an invalid media URL") hostname = parsed.hostname.lower().rstrip(".") if hostname == "localhost" or hostname.endswith( (".localhost", ".local", ".internal") ): - raise ValueError("Atlas Cloud media URL used a local hostname") + raise ValueError("Provider media URL used a local hostname") try: ip = ipaddress.ip_address(hostname) @@ -170,17 +179,26 @@ def _validate_public_https_url(url): return else: if not ip.is_global: - raise ValueError("Atlas Cloud media URL used a non-public address") + raise ValueError("Provider media URL used a non-public address") -def _json_request(url, api_key, method="GET", payload=None): +def _json_request( + url, api_key, method="GET", payload=None, api_key_header="Authorization" +): + if api_key_header == "Authorization": + auth_value = f"Bearer {api_key}" + elif api_key_header == "x-api-key": + auth_value = api_key + else: + raise ValueError("Unsupported API key header") + body = json.dumps(payload).encode("utf-8") if payload is not None else None request = Request( url, data=body, method=method, headers={ - "Authorization": f"Bearer {api_key}", + api_key_header: auth_value, "Accept": "application/json", "User-Agent": HTTP_USER_AGENT, **({"Content-Type": "application/json"} if body is not None else {}), @@ -192,10 +210,10 @@ def _json_request(url, api_key, method="GET", payload=None): except HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace") raise RuntimeError( - f"Atlas Cloud request failed ({exc.code}): {detail[:300]}" + f"Provider request failed ({exc.code}): {detail[:300]}" ) from exc except (URLError, TimeoutError, json.JSONDecodeError) as exc: - raise RuntimeError(f"Atlas Cloud request failed: {exc}") from exc + raise RuntimeError(f"Provider request failed: {exc}") from exc def _atlas_prediction_data(response): @@ -210,6 +228,10 @@ def _atlas_prediction_data(response): def _download_atlas_image(url, output_path): + _download_image(url, output_path, "image provider") + + +def _download_image(url, output_path, provider_name): _validate_public_https_url(url) request = Request( url, @@ -222,14 +244,14 @@ def _download_atlas_image(url, output_path): content_type = response.headers.get_content_type() if not content_type.startswith("image/"): raise RuntimeError( - f"Atlas Cloud output is not an image ({content_type})" + f"{provider_name} output is not an image ({content_type})" ) image_data = response.read() except (HTTPError, URLError, TimeoutError) as exc: - raise RuntimeError(f"Unable to download Atlas Cloud image: {exc}") from exc + raise RuntimeError(f"Unable to download {provider_name} image: {exc}") from exc if not image_data: - raise RuntimeError("Atlas Cloud returned an empty image") + raise RuntimeError(f"{provider_name} returned an empty image") with open(output_path, "wb") as output_file: output_file.write(image_data) @@ -281,6 +303,106 @@ def _generate_with_atlas(prompt, output_path, aspect_ratio, api_key, model): raise RuntimeError("Atlas Cloud prediction timed out while polling") +def _muapi_response_objects(response): + """Return the response and common MuAPI envelopes without guessing fields.""" + if not isinstance(response, dict): + raise TypeError("MuAPI returned an invalid response") + + objects = [response] + for key in ("data", "output", "result"): + value = response.get(key) + if isinstance(value, dict) and value not in objects: + objects.append(value) + return objects + + +def _muapi_response_value(response, keys): + for item in _muapi_response_objects(response): + for key in keys: + value = item.get(key) + if value not in (None, ""): + return value + return None + + +def _muapi_error(response): + value = _muapi_response_value(response, ("error", "message", "detail")) + if isinstance(value, str): + return value[:300] + return "MuAPI request failed" + + +def _muapi_output_url(response): + for item in _muapi_response_objects(response): + outputs = item.get("outputs") + if isinstance(outputs, list): + for output in outputs: + if isinstance(output, str) and output.startswith("https://"): + return output + if isinstance(output, dict): + for key in ("url", "image_url"): + value = output.get(key) + if isinstance(value, str) and value.startswith("https://"): + return value + raise RuntimeError("MuAPI completed without an HTTPS image URL") + + +def _download_muapi_image(url, output_path): + _download_image(url, output_path, "MuAPI") + + +def _generate_with_muapi(prompt, output_path, aspect_ratio, api_key, model): + if not api_key: + raise RuntimeError("MUAPI_API_KEY not set") + if model not in MUAPI_MODELS: + raise RuntimeError( + f"Unsupported MuAPI logo model: {model}. " + f"Choose one of: {', '.join(MUAPI_MODELS)}" + ) + + payload = { + "prompt": prompt, + "aspect_ratio": aspect_ratio, + } + response = _json_request( + f"{MUAPI_API_BASE}/{model}", + api_key, + method="POST", + payload=payload, + api_key_header="x-api-key", + ) + request_id = _muapi_response_value(response, ("request_id", "id")) + if not isinstance(request_id, str) or not request_id: + raise RuntimeError("MuAPI did not return a request ID") + + data = response + for poll_number in range(MUAPI_MAX_POLLS + 1): + status = _muapi_response_value(data, ("status",)) + normalized_status = str(status or "").lower() + if normalized_status in {"completed", "succeeded", "success"}: + _download_muapi_image(_muapi_output_url(data), output_path) + return + if normalized_status in { + "failed", + "error", + "timeout", + "canceled", + "cancelled", + }: + raise RuntimeError(f"MuAPI generation {normalized_status}: {_muapi_error(data)}") + if poll_number == MUAPI_MAX_POLLS: + break + + time.sleep(MUAPI_POLL_INTERVAL) + data = _json_request( + f"{MUAPI_API_BASE}/predictions/{request_id}/result", + api_key, + api_key_header="x-api-key", + ) + + raise RuntimeError("MuAPI prediction timed out while polling") + + def _generate_with_gemini(prompt, output_path, aspect_ratio, use_pro): if not GEMINI_API_KEY: raise RuntimeError("GEMINI_API_KEY not set") @@ -344,8 +466,9 @@ def generate_logo( aspect_ratio=None, provider="gemini", atlas_model=ATLAS_MODEL, + muapi_model=MUAPI_MODEL, ): - """Generate a logo using Gemini or Atlas Cloud image generation. + """Generate a logo using Gemini or MuAPI image generation. Args: aspect_ratio: Image aspect ratio. Options: "1:1", "16:9", "9:16", "4:3", "3:4" @@ -365,6 +488,8 @@ def generate_logo( if provider == "atlas": model_label = f"Atlas Cloud ({atlas_model})" + elif provider == "muapi": + model_label = f"MuAPI ({muapi_model})" else: model_label = ( "Nano Banana Pro (gemini-3-pro-image-preview)" @@ -386,6 +511,14 @@ def generate_logo( ATLASCLOUD_API_KEY, atlas_model, ) + elif provider == "muapi": + _generate_with_muapi( + full_prompt, + output_path, + ratio, + MUAPI_API_KEY, + muapi_model, + ) else: _generate_with_gemini(full_prompt, output_path, ratio, use_pro) @@ -407,6 +540,7 @@ def generate_batch( aspect_ratio=None, provider="gemini", atlas_model=ATLAS_MODEL, + muapi_model=MUAPI_MODEL, ): """Generate multiple logo variants with different styles""" @@ -430,6 +564,8 @@ def generate_batch( model_label = ( f"Atlas Cloud ({atlas_model})" if provider == "atlas" + else f"MuAPI ({muapi_model})" + if provider == "muapi" else f"Nano Banana {'Pro' if use_pro else 'Flash'}" ) ratio = aspect_ratio if aspect_ratio in ASPECT_RATIOS else DEFAULT_ASPECT_RATIO @@ -466,6 +602,7 @@ def generate_batch( aspect_ratio=aspect_ratio, provider=provider, atlas_model=atlas_model, + muapi_model=muapi_model, ) if result: @@ -487,7 +624,7 @@ def generate_batch( def main(): parser = argparse.ArgumentParser( - description="Generate logos using Gemini or Atlas Cloud" + description="Generate logos using Gemini or MuAPI" ) parser.add_argument("--prompt", "-p", type=str, help="Logo description prompt") parser.add_argument("--brand", "-b", type=str, help="Brand name") @@ -514,7 +651,7 @@ def main(): ) parser.add_argument( "--provider", - choices=["gemini", "atlas"], + choices=["gemini", "atlas", "muapi"], default="gemini", help="Image provider (default: gemini)", ) @@ -523,6 +660,12 @@ def main(): default=ATLAS_MODEL, help=f"Atlas Cloud image model (default: {ATLAS_MODEL})", ) + parser.add_argument( + "--muapi-model", + choices=MUAPI_MODELS, + default=MUAPI_MODEL, + help=f"MuAPI image model (default: {MUAPI_MODEL})", + ) parser.add_argument( "--aspect-ratio", "-r", @@ -539,8 +682,11 @@ def main(): args = parser.parse_args() - if args.provider == "atlas" and args.pro: - parser.error("--pro is only available with --provider gemini") + if args.provider != "gemini" and args.pro: + parser.error( + "--pro is only available with --provider gemini; " + "use --muapi-model nano-banana-pro for MuAPI" + ) if args.list_styles: print("Available styles:") @@ -574,6 +720,7 @@ def main(): aspect_ratio=args.aspect_ratio, provider=args.provider, atlas_model=args.atlas_model, + muapi_model=args.muapi_model, ) else: generate_logo( @@ -586,6 +733,7 @@ def main(): aspect_ratio=args.aspect_ratio, provider=args.provider, atlas_model=args.atlas_model, + muapi_model=args.muapi_model, ) diff --git a/cli/assets/skills/design/scripts/logo/tests/test_generate.py b/cli/assets/skills/design/scripts/logo/tests/test_generate.py index 8420d90..11faa7d 100644 --- a/cli/assets/skills/design/scripts/logo/tests/test_generate.py +++ b/cli/assets/skills/design/scripts/logo/tests/test_generate.py @@ -128,5 +128,120 @@ class AtlasGenerationTests(unittest.TestCase): logo_generate._validate_public_https_url("https://assets.local/logo.png") +class MuapiGenerationTests(unittest.TestCase): + @patch.object(logo_generate, "_download_muapi_image") + @patch.object(logo_generate.time, "sleep") + @patch.object(logo_generate, "_json_request") + def test_muapi_submits_once_and_polls_until_completed( + self, json_request, sleep, download + ): + json_request.side_effect = [ + {"request_id": "req-123"}, + {"request_id": "req-123", "status": "processing"}, + { + "request_id": "req-123", + "status": "completed", + "outputs": ["https://media.example.com/logo.png"], + }, + ] + + logo_generate._generate_with_muapi( + "logo prompt", "logo.png", "1:1", "muapi-key", "nano-banana" + ) + + self.assertEqual(json_request.call_count, 3) + self.assertEqual( + json_request.call_args_list[0], + call( + f"{logo_generate.MUAPI_API_BASE}/nano-banana", + "muapi-key", + method="POST", + payload={"prompt": "logo prompt", "aspect_ratio": "1:1"}, + api_key_header="x-api-key", + ), + ) + self.assertEqual( + json_request.call_args_list[1:], + [ + call( + f"{logo_generate.MUAPI_API_BASE}/predictions/req-123/result", + "muapi-key", + api_key_header="x-api-key", + ), + call( + f"{logo_generate.MUAPI_API_BASE}/predictions/req-123/result", + "muapi-key", + api_key_header="x-api-key", + ), + ], + ) + self.assertEqual(sleep.call_count, 2) + download.assert_called_once_with( + "https://media.example.com/logo.png", "logo.png" + ) + + @patch.object(logo_generate, "_json_request") + def test_muapi_does_not_retry_generation_post(self, json_request): + json_request.side_effect = RuntimeError("network error") + + with self.assertRaisesRegex(RuntimeError, "network error"): + logo_generate._generate_with_muapi( + "logo prompt", "logo.png", "1:1", "muapi-key", "nano-banana" + ) + + json_request.assert_called_once() + + def test_muapi_requires_key_and_known_model(self): + with self.assertRaisesRegex(RuntimeError, "MUAPI_API_KEY not set"): + logo_generate._generate_with_muapi( + "logo prompt", "logo.png", "1:1", None, "nano-banana" + ) + + with self.assertRaisesRegex(RuntimeError, "Unsupported MuAPI logo model"): + logo_generate._generate_with_muapi( + "logo prompt", "logo.png", "1:1", "muapi-key", "unknown-model" + ) + + @patch.object(logo_generate, "build_opener") + def test_muapi_uses_x_api_key_header(self, build_opener): + class Response: + def __enter__(self): + return self + + def __exit__(self, *args): + return None + + @staticmethod + def read(): + return b"{}" + + build_opener.return_value.open.return_value = Response() + + logo_generate._json_request( + "https://api.muapi.ai/api/v1/nano-banana", + "muapi-key", + method="POST", + payload={"prompt": "logo"}, + api_key_header="x-api-key", + ) + + request = build_opener.return_value.open.call_args.args[0] + headers = {key.lower(): value for key, value in request.header_items()} + self.assertEqual(headers["x-api-key"], "muapi-key") + self.assertNotIn("authorization", headers) + + @patch.object(logo_generate, "_json_request") + def test_muapi_reports_failed_prediction(self, json_request): + json_request.side_effect = [ + {"request_id": "req-123"}, + {"status": "failed", "error": "invalid prompt"}, + ] + + with self.assertRaisesRegex(RuntimeError, "invalid prompt"): + logo_generate._generate_with_muapi( + "logo prompt", "logo.png", "1:1", "muapi-key", "nano-banana" + ) + + if __name__ == "__main__": unittest.main()