
Image Tryon
- 1.6k installs
- 18 repo stars
- Updated July 27, 2026
- starchild-ai-agent/official-skills
image-tryon is an AI-powered virtual try-on skill that renders two images (person + garment/item) into a single photorealistic preview showing how the item appears on the person.
About
Virtual try-on skill that synthesizes person and item photos to preview how garments and accessories appear on users. Supports eight try-on categories (clothing, accessory, hairstyle, makeup, glasses, hat, shoes, watch) via two models: nanopro (~25s, good quality) and gpt (~150s, best quality). Requires both person and garment/item images as local paths or URLs. Downloads results locally for reliable delivery; handles photo requirements, prompt engineering, and error cases. Best for fashion e-commerce, styling apps, and personal fashion exploration.
- Eight dedicated try-on categories with auto-intent recognition from user queries
- Dual-model support: nanopro (25s default) and gpt (150s for premium quality)
- Always downloads output to local workspace; never relies on CSP-restricted fal.media URLs
- Virtual try-on for clothing, accessories, hairstyles, makeup, glasses, hats, shoes, watches
- Virtual try-on for clothing, accessories, hairstyles, makeup, glasses, hats, shoes, watches
Image Tryon by the numbers
- 1,642 all-time installs (skills.sh)
- +62 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #178 of 1,340 Generative Media skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
image-tryon capabilities & compatibility
Per-call FAL API pricing (nanopro ~$0.05-0.10 estimated, gpt ~$0.20-0.30 estimated)
- Capabilities
- clothing tryon · accessory preview · hairstyle simulation · makeup preview · glasses fitting · hat preview · shoes tryon · watch preview
- Use cases
- ui design · image generation
- Runs
- Remote server
- Pricing
- Bring your own API key
What image-tryon says it does
Virtual try-on: clothing, accessories, hairstyles, makeup, glasses, hats, shoes, watches.
npx skills add https://github.com/starchild-ai-agent/official-skills --skill image-tryonAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.6k |
|---|---|
| repo stars | ★ 18 |
| Last updated | July 27, 2026 |
| Repository | starchild-ai-agent/official-skills ↗ |
What it does
Virtual try-on for clothing, accessories, hairstyles, makeup, glasses, hats, shoes, watches
Who is it for?
Fashion e-commerce platforms, personal styling apps, retail virtual try-on, accessory preview, hairstyle/makeup consultation, watch/glasses fitting
Skip if: Single-image editing (use image-edit), portrait generation from one reference (use image-portrait), text-to-image synthesis (use image-create), fashion model generation from scratch (use image-create)
When should I use this skill?
User requests to visualize how clothing, accessories, hairstyles, makeup, glasses, hats, shoes, or watches look on them — e.g. 'try on this dress', 'put these glasses on me', 'show me with this hairstyle'
What you get
User receives realistic photorealistic preview of the item worn by them, enabling faster purchase decisions and style exploration.
- Local PNG/JPG image file in output/images/ directory
- Result object with success status and local_path
- Workspace-viewable file panel display
By the numbers
- Eight try-on categories: clothing, accessory, hairstyle, makeup, glasses, hat, shoes, watch
- nanopro model: ~25 seconds execution time
- gpt model: ~150 seconds execution time
Files
image-tryon
Use this skill for all virtual try-on requests on Starchild.
Covers: clothing try-on, accessory try-on, hairstyle preview, makeup preview, glasses try-on, hat try-on, shoes try-on, watch try-on.
Core principle: call the provided script. Do not re-implement proxy/billing plumbing.
Key difference from image-edit: try-on always requires two images — a person photo and a garment/item photo.
---
1. Quick start — clothing try-on (most common)
exec(open('skills/image-tryon/try_on.py').read())
result = try_on(
person_path="uploads/person.jpg",
garment_path="uploads/dress.jpg",
category="clothing",
)
# result -> {"success": True, "images": [{"local_path": "output/images/..."}], ...}The script reads both local files, base64-encodes them, and sends them to fal.ai as data URIs — no manual URL publishing needed.
2. Quick start — URL inputs
exec(open('skills/image-tryon/try_on.py').read())
result = try_on(
person_url="https://example.com/person.jpg",
garment_url="https://example.com/jacket.jpg",
category="clothing",
)3. Quick start — glasses try-on
exec(open('skills/image-tryon/try_on.py').read())
result = try_on(
person_path="uploads/face.jpg",
garment_path="uploads/sunglasses.jpg",
category="glasses",
)Delivering the result to the user — IMPORTANT
Never hand the user the raw fal.media URL. fal serves files with restrictive CSP headers. The only reliable delivery path is the already-downloaded local file:
1. Use each image's local_path (e.g. output/images/xxx.png) — the script always downloads on success. 2. Tell the user the files are saved to output/images/ and viewable in the workspace file panel. 3. On Web channel, embed inline so the user can preview in chat:
4. On Telegram / WeChat: send via send_to_telegram(file_path="output/images/...", message_type="image") or send_to_wechat(file_path="output/images/...", message_type="image").
---
4. Parameters
| Parameter | Required | Default | Description |
|---|---|---|---|
person_path | yes* | — | Local workspace file path to the person's photo |
person_url | yes* | — | Public HTTPS URL of the person's photo |
garment_path | yes* | — | Local workspace file path to the garment/item photo |
garment_url | yes* | — | Public HTTPS URL of the garment/item photo |
category | no | "clothing" | Try-on category key (see §5) |
prompt | no | None | Custom prompt — overrides category default when set |
model | no | "nanopro" | Model: "nanopro" (fast ~25s) or "gpt" (best quality ~150s) |
aspect_ratio | no | "3:4" | Output ratio: 1:1, 3:4, 4:3, 9:16, 16:9 |
Image input rules:
- Person image: provide
person_pathORperson_url(one is required). - Garment/item image: provide
garment_pathORgarment_url(one is required). - If both path and URL are given for the same image, path takes priority.
- Both images are required — try-on cannot work with only one image.
Prompt priority: prompt (full override) > category default prompt.
---
5. Try-on categories
Intent recognition — what the user says → which category to use
| User says | Category | Key |
|---|---|---|
| "try on this dress/shirt/jacket/outfit" | Clothing | clothing |
| "put this necklace/scarf/bag on me" | Accessory | accessory |
| "show me with this hairstyle/hair color" | Hairstyle | hairstyle |
| "apply this makeup/lipstick look" | Makeup | makeup |
| "try on these glasses/sunglasses" | Glasses | glasses |
| "put this hat/cap/beanie on me" | Hat | hat |
| "try on these shoes/sneakers/boots" | Shoes | shoes |
| "put this watch on my wrist" | Watch | watch |
Category details
| Category | Key | Best for | Photo requirements |
|---|---|---|---|
| Clothing | clothing | Shirts, dresses, jackets, pants, coats, full outfits | Full body or upper body person photo |
| Accessory | accessory | Scarves, bags, belts, jewelry, necklaces, earrings | Relevant body area visible |
| Hairstyle | hairstyle | Haircuts, hair colors, styling changes | Clear face/head photo |
| Makeup | makeup | Lipstick, eyeshadow, foundation, blush, full looks | Clear face close-up |
| Glasses | glasses | Prescription glasses, sunglasses, reading glasses | Clear face photo, front-facing |
| Hat | hat | Caps, beanies, fedoras, sun hats, helmets | Head and shoulders visible |
| Shoes | shoes | Sneakers, heels, boots, sandals, loafers | Full body or lower body photo |
| Watch | watch | Analog, smartwatches, luxury watches | Wrist/arm visible |
---
6. Model selection guide
| Model | Key | Speed | Quality | Best for |
|---|---|---|---|---|
| NanoPro | nanopro | ~25s | Good | Default for all requests. Fast iteration. |
| GPT Image 2 | gpt | ~150s | Best | When user explicitly asks for "highest quality" or "best quality". |
Decision rules: 1. Default: always use nanopro unless the user explicitly requests higher quality. 2. Use `gpt` when: user says "highest quality", "best quality", "premium", or the result needs to be photorealistic for professional use. 3. Use `nanopro` when: user wants fast results, is trying multiple items, or iterating on looks.
# Default (fast)
result = try_on(person_path="me.jpg", garment_path="dress.jpg", category="clothing")
# High quality (user requested)
result = try_on(person_path="me.jpg", garment_path="dress.jpg", category="clothing", model="gpt")---
7. Usage examples by category
Clothing try-on
exec(open('skills/image-tryon/try_on.py').read())
result = try_on(
person_path="uploads/person_fullbody.jpg",
garment_path="uploads/summer_dress.jpg",
category="clothing",
)Accessory try-on
exec(open('skills/image-tryon/try_on.py').read())
result = try_on(
person_path="uploads/portrait.jpg",
garment_path="uploads/gold_necklace.jpg",
category="accessory",
)Hairstyle preview
exec(open('skills/image-tryon/try_on.py').read())
result = try_on(
person_path="uploads/face.jpg",
garment_path="uploads/bob_hairstyle.jpg",
category="hairstyle",
)Makeup preview
exec(open('skills/image-tryon/try_on.py').read())
result = try_on(
person_path="uploads/face_closeup.jpg",
garment_path="uploads/evening_makeup.jpg",
category="makeup",
)Glasses try-on
exec(open('skills/image-tryon/try_on.py').read())
result = try_on(
person_path="uploads/face_front.jpg",
garment_path="uploads/aviator_sunglasses.jpg",
category="glasses",
)Hat try-on
exec(open('skills/image-tryon/try_on.py').read())
result = try_on(
person_path="uploads/head_shoulders.jpg",
garment_path="uploads/fedora_hat.jpg",
category="hat",
)Shoes try-on
exec(open('skills/image-tryon/try_on.py').read())
result = try_on(
person_path="uploads/person_fullbody.jpg",
garment_path="uploads/white_sneakers.jpg",
category="shoes",
)Watch try-on
exec(open('skills/image-tryon/try_on.py').read())
result = try_on(
person_path="uploads/wrist_photo.jpg",
garment_path="uploads/luxury_watch.jpg",
category="watch",
)Custom prompt (override default)
exec(open('skills/image-tryon/try_on.py').read())
result = try_on(
person_path="uploads/person.jpg",
garment_path="uploads/vintage_jacket.jpg",
category="clothing",
prompt="The person is wearing the vintage leather jacket from the second image, styled with a casual street fashion look. Keep the person's face and body exactly the same. Add realistic leather texture and natural draping.",
)Different aspect ratio
exec(open('skills/image-tryon/try_on.py').read())
result = try_on(
person_path="uploads/person.jpg",
garment_path="uploads/outfit.jpg",
category="clothing",
aspect_ratio="9:16", # Full-length portrait
)---
8. Photo requirements — best practices
Person photo guidelines
| Category | Recommended photo type | Tips |
|---|---|---|
| Clothing | Full body, front-facing | Arms slightly away from body, neutral pose |
| Accessory | Relevant body area visible | Good lighting on the area where accessory goes |
| Hairstyle | Clear head/face, front or 3/4 view | Hair pulled back or current style clearly visible |
| Makeup | Face close-up, front-facing | Clean face, good even lighting, no heavy makeup |
| Glasses | Face front-facing, eyes visible | No existing glasses, clear eye area |
| Hat | Head and shoulders, front-facing | No existing hat, hair visible |
| Shoes | Full body or legs/feet visible | Standing pose, current shoes visible |
| Watch | Wrist/forearm visible | Bare wrist or current watch visible |
General photo quality rules
1. Lighting: well-lit, even lighting works best. Avoid harsh shadows on the face/body. 2. Resolution: 1024×1024 or higher recommended. Low-res photos produce poor results. 3. Angle: front-facing photos work best for most categories. 4. Background: any background works, but clean backgrounds produce cleaner results. 5. Pose: natural, relaxed poses. Avoid extreme angles or heavy cropping.
Garment/item photo guidelines
1. Product shots work best: official product images on white/neutral backgrounds. 2. Clear visibility: the item should be the main focus, not obscured. 3. Multiple angles: front view is most important for clothing. 4. Color accuracy: ensure the photo shows true colors (no heavy filters). 5. High resolution: detailed product images produce better try-on results.
---
9. Prompt engineering for custom try-on
When the default category prompt doesn't produce the desired result, use a custom prompt. Follow these guidelines:
The 5-element try-on prompt structure
[person preservation] + [item description] + [fit/positioning] + [style/mood] + [quality anchors]Key principles
1. Always preserve identity: "Keep the person's face, body shape, and pose exactly the same." 2. Describe the item clearly: "wearing the red leather jacket from the second image" 3. Specify fit and positioning: "natural draping, proper shoulder fit, realistic wrinkles" 4. Add style context: "casual street style look", "formal business attire" 5. Quality anchors: "professional fashion photography", "editorial quality", "realistic shadows"
Example custom prompts
Formal outfit:
The person is wearing the navy blue suit from the second image. Keep the person's face, body, and pose exactly the same. The suit should fit perfectly with proper tailoring — clean shoulder line, correct sleeve length, natural lapel lay. Professional fashion photography quality with studio lighting.Casual street style:
The person is wearing the oversized hoodie from the second image in a relaxed street style. Keep the person's identity and pose the same. The hoodie should drape naturally with realistic fabric weight and casual fit. Urban photography style.Jewelry combination:
The person is wearing the diamond pendant necklace from the second image. Keep everything about the person the same. The necklace should sit naturally on the collarbone with realistic sparkle and light reflections. The chain length and pendant size should be proportional to the person's frame.---
10. Error handling
| Error | Cause | Solution |
|---|---|---|
| "Person image error: Either person_path or person_url must be provided" | Missing person photo | Ask user for their photo |
| "Garment/item image error: Either garment_path or garment_url must be provided" | Missing item photo | Ask user for the item photo |
| "File not found" | Invalid file path | Check the file path and try again |
| "Unsupported image format" | Non-image file | Use JPG, PNG, or WebP |
| "Image too large" | File > 10 MB | Resize or compress the image |
| "Unknown category" | Invalid category key | Use one of the 8 valid categories |
| Low quality result | Poor input photos | Use higher resolution, well-lit photos |
| Wrong item placement | Unclear body positioning | Use front-facing photos with target area visible |
---
11. When NOT to use this skill
- Single image editing (no garment/item reference) → use
image-editskill - Portrait generation (styled photos from one reference) → use
image-portraitskill - Text-to-image (no reference photos at all) → use
image-createskill - Fashion model generation (creating models from scratch) → use
image-createskill
"""Cost tracking helper for skill subprocesses.
Skills that call sc-proxy via plain `requests` need to:
1. Tag every paid call with a SC-CALLER-ID that ties it back to the user
turn that triggered the skill (so the agent's per-turn cost summary
shows the cost in the right cost card).
2. After each call, parse the sc-proxy response headers
(`X-Credits-Used`, `X-Credits-Api-Type`) and write a row to the cost
ledger that the agent reads back when it builds the SSE
`cost_summary` event.
This file is intentionally zero-dependency (stdlib only) so it can be
dropped into any skill folder without coupling to starchild-clawd internals.
Env vars consumed (set by the agent before dispatching the bash subprocess):
- STARCHILD_TOOL_CALLER_ID — opaque tag for the current tool call
- STARCHILD_USER_TURN_ID — uuid of the current user turn
- STARCHILD_COST_LEDGER_DIR — optional override for ledger directory
When env vars are absent (e.g. running the script outside an agent), the
helpers degrade gracefully: caller-id falls back to a synthetic string so
the call still goes through, and ledger writes still happen for audit but
the user-turn reader will skip them.
"""
from __future__ import annotations
import fcntl
import json
import os
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional
from urllib.parse import urlparse
_DEFAULT_LEDGER_DIR = "/data/.starchild/cost_ledger"
# Allowlisted request payload keys we forward into the ledger row's
# `details` field. MUST stay in sync with starchild-clawd's
# core/http_client._record_cost_to_ledger allowlist — anything not in
# that allowlist won't be picked up by the agent and won't render in
# the frontend cost card.
_PAYLOAD_ALLOWLIST = (
# Identity
"model", "provider",
# Image geometry
"aspect_ratio", "quality", "resolution", "image_size", "size",
# Video / motion
"duration", "duration_s", "fps", "motion_strength",
# Quantity
"n", "count",
# Generation knobs
"seed", "steps", "guidance_scale", "cfg_scale", "strength",
"scheduler", "sampler",
# Reference / mode hints
"image_to_image", "image_to_video", "use_reference", "reference_count",
)
def caller_headers(extra: Optional[Dict[str, str]] = None,
tool_default: str = "skill") -> Dict[str, str]:
"""Return an HTTP-headers dict with SC-CALLER-ID filled in.
Resolution order:
1. `extra["SC-CALLER-ID"]` (case-insensitive) — caller wins.
2. STARCHILD_TOOL_CALLER_ID env (set by the agent)
3. Synthetic `f"{tool_default}:{int(time.time())}"` — tags the call so
charges are attributable to *some* identifier even when the agent
didn't inject one (standalone CLI runs, tests, cron).
"""
merged: Dict[str, str] = dict(extra or {})
has_caller = any(k.lower() == "sc-caller-id" for k in merged)
if not has_caller:
cid = os.environ.get("STARCHILD_TOOL_CALLER_ID") \
or f"{tool_default}:{int(time.time())}"
merged["SC-CALLER-ID"] = cid
return merged
def record_response(response,
request_url: str,
request_payload: Optional[Dict[str, Any]] = None,
api_type_hint: Optional[str] = None) -> None:
"""Inspect a sc-proxy response and append a ledger row when paid.
Best-effort. Silently no-ops when:
- response carries no X-Credits-Used / X-Credits-Api-Type
- cost is 0 or unparseable
- file write fails
Never raises — must not break a real request flow.
"""
try:
headers = getattr(response, "headers", None) or {}
used = headers.get("X-Credits-Used") or headers.get("x-credits-used")
api_type = (headers.get("X-Credits-Api-Type")
or headers.get("x-credits-api-type")
or api_type_hint)
if not used or not api_type:
return
try:
cost_f = float(used)
except (TypeError, ValueError):
return
if cost_f <= 0:
return
turn_id = os.environ.get("STARCHILD_USER_TURN_ID") or ""
caller_id = os.environ.get("STARCHILD_TOOL_CALLER_ID") or ""
host = ""
try:
host = urlparse(request_url).netloc or ""
except Exception:
pass
details: Dict[str, Any] = {}
if isinstance(request_payload, dict):
for k in _PAYLOAD_ALLOWLIST:
v = request_payload.get(k)
if v not in (None, "", []):
details[k] = v
# fal.ai puts the model in the URL path, not the body.
if "model" not in details and api_type == "falai":
try:
path = urlparse(request_url).path or ""
model_path = path.lstrip("/")
if "/requests/" in model_path:
model_path = model_path.split("/requests/", 1)[0]
if model_path and not model_path.startswith("requests/"):
details["model"] = model_path
details["provider"] = "fal"
except Exception:
pass
_append_ledger(
turn_id=turn_id,
caller_id=caller_id,
api_type=api_type,
cost_usd=cost_f,
url_host=host,
details=details or None,
)
except Exception:
# Never let cost tracking break the actual request.
pass
def _ledger_dir() -> Path:
base = os.environ.get("STARCHILD_COST_LEDGER_DIR") or _DEFAULT_LEDGER_DIR
p = Path(base)
try:
p.mkdir(parents=True, exist_ok=True)
except OSError:
p = Path("/tmp/starchild_cost_ledger")
p.mkdir(parents=True, exist_ok=True)
return p
def _today_path() -> Path:
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
return _ledger_dir() / f"{today}.jsonl"
def _derive_tool(caller_id: str, api_type: str) -> str:
"""Match starchild-clawd's _derive_tool_from_caller fallback."""
if not caller_id:
return api_type or "unknown"
# chat:{sid}/tool:{name} → name
if "/tool:" in caller_id:
return caller_id.rsplit("/tool:", 1)[-1] or api_type
# skill:{name} | job:{id} | video:{ts}
head = caller_id.split(":", 1)[0]
return head or api_type or "unknown"
def _append_ledger(*, turn_id: str, caller_id: str, api_type: str,
cost_usd: float, url_host: str,
details: Optional[Dict[str, Any]]) -> None:
row = {
"ts": round(time.time(), 3),
"turn_id": turn_id,
"caller_id": caller_id,
"tool": _derive_tool(caller_id, api_type),
"api_type": api_type or "unknown",
"cost_usd": round(cost_usd, 8),
"url_host": url_host or "",
}
if details:
row["details"] = details
line = json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n"
path = _today_path()
try:
with open(path, "ab") as f:
try:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
except OSError:
pass
try:
f.write(line.encode("utf-8"))
f.flush()
try:
os.fsync(f.fileno())
except OSError:
pass
finally:
try:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
except OSError:
pass
except OSError:
pass
"""
Image Try-On skill exports — script-mode skill.
Usage from a bash block:
python3 - <<'EOF'
import sys
sys.path.insert(0, "/data/workspace/skills/image-tryon")
from exports import try_on, CATEGORIES, CATEGORY_PROMPTS, MODELS
result = try_on(
person_path="uploads/person.jpg",
garment_path="uploads/dress.jpg",
category="clothing",
)
print(result)
EOF
"""
import os
import sys
# Ensure the skill directory is importable regardless of cwd.
_SKILL_DIR = os.path.dirname(os.path.abspath(__file__))
if _SKILL_DIR not in sys.path:
sys.path.insert(0, _SKILL_DIR)
from try_on import ( # noqa: E402
try_on,
CATEGORIES,
CATEGORY_PROMPTS,
VALID_ASPECT_RATIOS,
MODELS,
)
__all__ = [
"try_on",
"CATEGORIES",
"CATEGORY_PROMPTS",
"VALID_ASPECT_RATIOS",
"MODELS",
]
#!/usr/bin/env python3
"""Virtual try-on script — visualize clothing, accessories, hairstyles, and more on a person.
Supports three models:
- nano2 (fal-ai/gemini-3.1-flash-image-preview/edit) — fastest ~15s, good for drafts
- nanopro (fal-ai/gemini-3-pro-image-preview/edit) — balanced ~25s, good quality (default)
- gpt (openai/gpt-image-2/edit) — best quality, slow ~150s
Requires two images:
- Person photo (the subject to dress/style)
- Garment/item photo (the clothing, accessory, or style reference)
Both images are base64-encoded as data URIs and sent via the /edit endpoint.
Flow: resolve both images → build category prompt → submit to fal queue → poll → download.
Cost tracking: uses _cost_track.py to record per-call costs via sc-proxy
headers so the agent's per-turn cost_summary picks up this skill's cost.
Local testing: set FAL_KEY env var to call fal.ai directly (no sc-proxy).
"""
import requests
import json
import time
import os
import sys
import base64
import mimetypes
from datetime import datetime
from pathlib import Path
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Make _cost_track importable when this script is invoked from any CWD.
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from _cost_track import caller_headers, record_response # noqa: E402
# Local testing: when FAL_KEY env var is set, call fal.ai directly
# (no sc-proxy). In production, sc-proxy injects the real key.
_FAL_KEY = os.environ.get("FAL_KEY")
_LOCAL_MODE = bool(_FAL_KEY)
PROXY_URL = 'http://sc-proxy.internal:8080'
PROXIES = {} if _LOCAL_MODE else {'http': PROXY_URL, 'https': PROXY_URL}
# ── Model configuration ──────────────────────────────────────────────
MODELS = {
"nano2": {
"edit": "fal-ai/gemini-3.1-flash-image-preview/edit",
"timeout": 90,
"poll_interval": 2,
},
"nanopro": {
"edit": "fal-ai/gemini-3-pro-image-preview/edit",
"timeout": 120,
"poll_interval": 3,
},
"gpt": {
"edit": "openai/gpt-image-2/edit",
"timeout": 600,
"poll_interval": 5,
},
}
DEFAULT_MODEL = "nanopro"
# Supported image extensions
SUPPORTED_IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.bmp'}
MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10 MB
# ── Category definitions ──────────────────────────────────────────────
CATEGORIES = {
"clothing": "Clothing try-on — shirts, dresses, jackets, pants, coats, full outfits",
"accessory": "Accessory try-on — scarves, bags, belts, jewelry, necklaces, earrings",
"hairstyle": "Hairstyle preview — haircuts, hair colors, styling changes",
"makeup": "Makeup preview — lipstick, eyeshadow, foundation, blush, full looks",
"glasses": "Eyewear try-on — prescription glasses, sunglasses, reading glasses",
"hat": "Hat try-on — caps, beanies, fedoras, sun hats, helmets",
"shoes": "Shoes try-on — sneakers, heels, boots, sandals, loafers",
"watch": "Watch try-on — analog watches, smartwatches, luxury watches, bracelets",
}
# ── Category prompt templates ─────────────────────────────────────────
# Each template instructs the model to combine the person image with the
# garment/item image for a realistic virtual try-on result.
CATEGORY_PROMPTS = {
"clothing": (
"The person in the first image is wearing the clothing from the second image. "
"Keep the person's face, body shape, and pose exactly the same. "
"Only change the outfit to match the garment shown in the second image. "
"Natural fit, proper draping, realistic wrinkles and shadows. "
"The clothing should follow the body contours naturally. "
"Maintain the original background and lighting."
),
"accessory": (
"The person in the first image is wearing the accessory from the second image. "
"Keep everything about the person the same — face, body, pose, clothing. "
"Add the accessory naturally with proper positioning, scale, and perspective. "
"Ensure realistic shadows and reflections where the accessory meets the body. "
"The accessory should look like it belongs in the original photo."
),
"hairstyle": (
"Apply the hairstyle from the second image to the person in the first image. "
"Keep the person's face, facial features, skin tone, and expression exactly the same. "
"Only change the hair — style, length, volume, and color should match the reference. "
"Ensure natural hairline transition and realistic hair texture. "
"The hairstyle should suit the person's face shape and look natural."
),
"makeup": (
"Apply the makeup style from the second image to the person in the first image. "
"Keep the person's facial features, face shape, and expression the same. "
"Apply similar makeup colors, techniques, and intensity — including foundation, "
"eye makeup, lip color, blush, and contouring as shown in the reference. "
"The makeup should look professionally applied and natural on the person's skin tone."
),
"glasses": (
"The person in the first image is wearing the glasses from the second image. "
"Keep everything about the person the same — face, expression, hair, clothing. "
"Only add the glasses with proper fit and positioning on the face. "
"The glasses should sit naturally on the nose bridge and ears. "
"Add realistic lens reflections and shadows. "
"Ensure the frame size is proportional to the person's face."
),
"hat": (
"The person in the first image is wearing the hat from the second image. "
"Keep everything about the person the same — face, expression, clothing. "
"Only add the hat with natural positioning on the head. "
"The hat should sit at the correct angle and depth. "
"Adjust hair visibility naturally around the hat. "
"Add realistic shadows cast by the hat on the face and shoulders."
),
"shoes": (
"The person in the first image is wearing the shoes from the second image. "
"Show a full-body or lower-body view with the new shoes naturally fitted. "
"Keep the person's body, pose, and clothing exactly the same. "
"Only change the footwear to match the shoes in the reference. "
"Ensure proper scale, perspective, and ground contact. "
"Add realistic shadows beneath the shoes."
),
"watch": (
"The person in the first image is wearing the watch from the second image. "
"Keep everything about the person the same — face, body, clothing. "
"Only add the watch on the wrist with proper fit and positioning. "
"The watch should wrap naturally around the wrist with correct proportions. "
"Show realistic metal/leather reflections and shadows. "
"Ensure the watch face is visible and properly oriented."
),
}
# ── Constants ─────────────────────────────────────────────────────────
MAX_COUNT = 4 # fal.ai API supports up to 4 images per call
DEFAULT_COUNT = 1
VALID_ASPECT_RATIOS = {
"1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9",
}
DEFAULT_ASPECT_RATIO = "3:4" # Portrait orientation for try-on
VALID_OUTPUT_FORMATS = {"jpeg", "png", "webp"}
DEFAULT_OUTPUT_FORMAT = "png"
OUTPUT_DIR = "output/images"
def _get_auth_key():
"""Return the appropriate fal API key."""
return _FAL_KEY if _LOCAL_MODE else 'fake-falai-key-12345'
def _get_model_config(model_key):
"""Return model config dict for the given key."""
return MODELS.get(model_key, MODELS[DEFAULT_MODEL])
def _resolve_image(image_path=None, image_url=None, label="image"):
"""Resolve an image input to a URL for the fal API.
Accepts either a local file path or a public URL.
Local files are base64-encoded as data URIs.
Returns (url_string, error_string).
"""
if not image_path and not image_url:
return None, f"Either {label}_path or {label}_url must be provided."
if image_path:
p = Path(image_path)
if not p.exists():
return None, f"File not found: {image_path}"
if not p.is_file():
return None, f"Not a file: {image_path}"
ext = p.suffix.lower()
if ext not in SUPPORTED_IMAGE_EXTS:
return None, (
f"Unsupported image format: {ext}. "
f"Supported: {', '.join(sorted(SUPPORTED_IMAGE_EXTS))}"
)
size = p.stat().st_size
if size > MAX_IMAGE_BYTES:
return None, (
f"Image too large: {size / 1024 / 1024:.1f} MB "
f"(max {MAX_IMAGE_BYTES / 1024 / 1024:.0f} MB)"
)
mime_type = mimetypes.guess_type(str(p))[0] or "image/jpeg"
with open(p, 'rb') as f:
b64 = base64.b64encode(f.read()).decode('ascii')
return f"data:{mime_type};base64,{b64}", None
# URL input
if not image_url.startswith(("http://", "https://")):
return None, (
f"{label}_url must be a public HTTP(S) URL. "
"For local files, use the path parameter instead."
)
return image_url, None
def _build_tryon_prompt(prompt=None, category="clothing"):
"""Construct the try-on prompt from category template and optional override.
Priority:
1. prompt provided → use as-is (full override)
2. no prompt → use category default prompt
Returns the final prompt string.
"""
if prompt:
return prompt
return CATEGORY_PROMPTS.get(category, CATEGORY_PROMPTS["clothing"])
def _aspect_ratio_to_size(aspect_ratio):
"""Convert aspect ratio string to fal image_size dict.
Sizes aligned with image_generate tool capabilities
(core/image_models.py _STD_ASPECTS / _NANO2_ASPECTS).
"""
mapping = {
"1:1": {"width": 1024, "height": 1024},
"2:3": {"width": 680, "height": 1024},
"3:2": {"width": 1024, "height": 680},
"3:4": {"width": 768, "height": 1024},
"4:3": {"width": 1024, "height": 768},
"4:5": {"width": 816, "height": 1024},
"5:4": {"width": 1024, "height": 816},
"9:16": {"width": 576, "height": 1024},
"16:9": {"width": 1024, "height": 576},
"21:9": {"width": 1024, "height": 440},
}
return mapping.get(aspect_ratio, mapping["3:4"])
def _build_request_body(prompt, person_url, garment_url, aspect_ratio="3:4",
model_key="nanopro", count=1, output_format="png"):
"""Build the request body for the fal edit API with two images.
Both images are passed via the image_urls array. The person image is
the primary image, and the garment image is the style reference.
"""
body = {
"prompt": prompt,
"num_images": count,
"seed": int(time.time() * 1000) % (2**32),
"output_format": output_format,
}
# Both images passed via image_urls array
body["image_urls"] = [person_url, garment_url]
# nano2/nanopro use aspect_ratio string; gpt uses image_size object
if aspect_ratio and aspect_ratio in VALID_ASPECT_RATIOS:
if model_key != "gpt":
body["aspect_ratio"] = aspect_ratio
else:
body["image_size"] = _aspect_ratio_to_size(aspect_ratio)
body["quality"] = "high"
return body
def _submit_request(prompt, person_url, garment_url, model_key, headers,
aspect_ratio="3:4", count=1, output_format="png"):
"""Submit a try-on request to the fal queue."""
cfg = _get_model_config(model_key)
model_id = cfg["edit"]
submit_url = f"https://queue.fal.run/{model_id}"
body = _build_request_body(prompt, person_url, garment_url, aspect_ratio,
model_key, count=count, output_format=output_format)
resp = requests.post(
submit_url, headers=headers, json=body,
proxies=PROXIES, verify=False, timeout=90,
)
record_response(resp, request_url=submit_url, request_payload=body)
if resp.status_code != 200:
return None, f"Submit failed: {resp.status_code} - {resp.text[:300]}"
data = resp.json()
cost = float(resp.headers.get('X-Credits-Used', 0))
data['_cost'] = cost
return data, None
def _poll_until_done(status_url, request_id, model_key):
"""Poll the fal queue until the request completes or fails."""
cfg = _get_model_config(model_key)
headers = {'Authorization': f'Key {_get_auth_key()}'}
deadline = time.time() + cfg["timeout"]
poll_interval = cfg["poll_interval"]
while time.time() < deadline:
try:
poll_resp = requests.get(
status_url, headers=headers,
proxies=PROXIES, verify=False, timeout=60,
)
status_data = poll_resp.json()
status = status_data.get('status')
if status == 'COMPLETED':
return "COMPLETED", None
elif status in ('FAILED', 'CANCELLED'):
return status, f"Try-on {status}"
except requests.RequestException:
pass
time.sleep(poll_interval)
return "TIMEOUT", f"Try-on timed out after {cfg['timeout'] // 60} minutes"
def _extract_image_urls(result_json):
"""Extract image URLs from fal response across model variants."""
if not isinstance(result_json, dict):
return []
urls = []
for key in ("images", "output", "outputs", "data"):
arr = result_json.get(key)
if isinstance(arr, list):
for item in arr:
if isinstance(item, dict) and isinstance(item.get("url"), str):
urls.append(item["url"])
elif isinstance(item, dict) and isinstance(item.get("b64_json"), str):
urls.append(f"data:image/png;base64,{item['b64_json']}")
elif isinstance(item, str) and item.startswith("http"):
urls.append(item)
if not urls:
for key in ("image", "output_image"):
node = result_json.get(key)
if isinstance(node, dict) and isinstance(node.get("url"), str):
urls.append(node["url"])
elif isinstance(node, str) and node.startswith("http"):
urls.append(node)
return urls
def _download_image(url, index, label, timestamp):
"""Download a single image from fal CDN to the output directory."""
os.makedirs(OUTPUT_DIR, exist_ok=True)
if url.startswith("data:"):
ext = ".png"
filename = f"{timestamp}_{label}_{index}{ext}"
local_path = os.path.join(OUTPUT_DIR, filename)
b64_data = url.split(",", 1)[1]
img_bytes = base64.b64decode(b64_data)
with open(local_path, 'wb') as f:
f.write(img_bytes)
return local_path, len(img_bytes)
ext = ".png"
if ".jpg" in url or ".jpeg" in url:
ext = ".jpg"
elif ".webp" in url:
ext = ".webp"
filename = f"{timestamp}_{label}_{index}{ext}"
local_path = os.path.join(OUTPUT_DIR, filename)
resp = requests.get(url, timeout=120)
resp.raise_for_status()
with open(local_path, 'wb') as f:
f.write(resp.content)
return local_path, len(resp.content)
def try_on(
person_path=None,
person_url=None,
garment_path=None,
garment_url=None,
category="clothing",
prompt=None,
model="nanopro",
count=None,
aspect_ratio="3:4",
output_format=None,
):
"""Virtual try-on — visualize an item on a person.
Requires two images: a person photo and a garment/item photo.
The model composites the item onto the person realistically.
Args:
person_path: Local workspace file path to the person's photo.
person_url: Public HTTPS URL of the person's photo.
garment_path: Local workspace file path to the garment/item photo.
garment_url: Public HTTPS URL of the garment/item photo.
category: Try-on category — one of:
clothing, accessory, hairstyle, makeup, glasses, hat, shoes, watch.
prompt: Custom prompt — overrides the category default when set.
model: Model key — "nanopro" (default, fast ~25s) or
"gpt" (best quality ~150s).
count: Number of output images to generate (1-4, default 1).
Uses fal.ai native num_images for efficient batch generation.
aspect_ratio: Output aspect ratio (1:1, 3:4, 4:3, 9:16, 16:9).
Default "3:4" (portrait orientation).
output_format: Output image format — "png" (default), "jpeg", or "webp".
Returns:
dict with success status, try-on result image paths, and metadata.
"""
# Validate category
if category not in CATEGORIES:
return {
"success": False,
"error": (
f"Unknown category: '{category}'. "
f"Valid categories: {', '.join(sorted(CATEGORIES.keys()))}"
),
}
# Resolve person image
person_resolved, err = _resolve_image(person_path, person_url, label="person")
if err:
return {"success": False, "error": f"Person image error: {err}"}
# Resolve garment/item image
garment_resolved, err = _resolve_image(garment_path, garment_url, label="garment")
if err:
return {"success": False, "error": f"Garment/item image error: {err}"}
# Validate and normalize parameters
model_key = model if model in MODELS else DEFAULT_MODEL
count = min(max(int(count or DEFAULT_COUNT), 1), MAX_COUNT)
fmt = output_format if output_format in VALID_OUTPUT_FORMATS else DEFAULT_OUTPUT_FORMAT
if aspect_ratio and aspect_ratio not in VALID_ASPECT_RATIOS:
aspect_ratio = DEFAULT_ASPECT_RATIO
# Build the try-on prompt
final_prompt = _build_tryon_prompt(prompt=prompt, category=category)
# Build a label for filenames
label = f"tryon_{category}"
headers = caller_headers({
'Authorization': f'Key {_get_auth_key()}',
'Content-Type': 'application/json',
}, tool_default='image-tryon')
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
# Submit the try-on request
submit_data, err = _submit_request(
final_prompt, person_resolved, garment_resolved,
model_key, headers, aspect_ratio,
count=count, output_format=fmt,
)
if err:
return {"success": False, "error": err}
request_id = submit_data.get('request_id')
status_url = submit_data.get('status_url')
result_url = submit_data.get('response_url') or submit_data.get('result_url')
cost = submit_data.get('_cost', 0)
print(f"Submitted: {request_id} (category={category}, model={model_key}, "
f"count={count}, cost=${cost:.2f})")
# Poll for completion
status, poll_err = _poll_until_done(status_url, request_id, model_key)
if status != "COMPLETED":
return {
"success": False,
"request_id": request_id,
"error": poll_err,
}
# Fetch result
try:
result_resp = requests.get(
result_url,
headers={'Authorization': f'Key {_get_auth_key()}'},
proxies=PROXIES, verify=False, timeout=90,
)
result_json = result_resp.json()
except Exception as e:
return {
"success": False,
"request_id": request_id,
"error": f"Failed to fetch result: {e}",
}
# Handle fal error responses
if result_resp.status_code != 200:
detail = result_json.get("detail", result_resp.text[:300])
return {
"success": False,
"request_id": request_id,
"error": f"fal error ({result_resp.status_code}): {detail}",
}
# Extract and download images
image_urls = _extract_image_urls(result_json)
if not image_urls:
detail = result_json.get("detail")
if detail:
err_msg = f"fal error: {detail}"
else:
err_msg = (
f"No image URL found in response. "
f"Keys: {list(result_json.keys())}"
)
return {
"success": False,
"request_id": request_id,
"error": err_msg,
}
results = []
errors = []
for img_url in image_urls:
try:
local_path, size_bytes = _download_image(
img_url, len(results), label, timestamp,
)
results.append({
"url": img_url if not img_url.startswith("data:") else "(base64)",
"local_path": local_path,
"size_bytes": size_bytes,
"request_id": request_id,
})
except Exception as e:
errors.append({
"request_id": request_id,
"error": f"Download failed: {e}",
})
if not results:
return {
"success": False,
"error": "All download attempts failed",
"errors": errors,
}
return {
"success": True,
"model": model_key,
"category": category,
"prompt": final_prompt,
"aspect_ratio": aspect_ratio,
"output_format": fmt,
"count_requested": count,
"count_generated": len(results),
"total_cost": round(cost, 4),
"images": results,
"errors": errors if errors else None,
}
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python try_on.py <person_image> <garment_image> [category] [model]")
print(f"\nCategories: {', '.join(sorted(CATEGORIES.keys()))}")
print(f"\nModels: {', '.join(MODELS.keys())}")
print("\nSet FAL_KEY env var for local testing (direct fal.ai access).")
sys.exit(1)
person_arg = sys.argv[1]
garment_arg = sys.argv[2]
category_arg = sys.argv[3] if len(sys.argv) > 3 else "clothing"
model_arg = sys.argv[4] if len(sys.argv) > 4 else "nanopro"
if _LOCAL_MODE:
print("Local mode: using FAL_KEY directly (no sc-proxy)")
# Determine if inputs are URLs or file paths
person_kw = {}
if person_arg.startswith(("http://", "https://")):
person_kw["person_url"] = person_arg
else:
person_kw["person_path"] = person_arg
garment_kw = {}
if garment_arg.startswith(("http://", "https://")):
garment_kw["garment_url"] = garment_arg
else:
garment_kw["garment_path"] = garment_arg
result = try_on(
**person_kw,
**garment_kw,
category=category_arg,
model=model_arg,
)
print(json.dumps(result, indent=2, ensure_ascii=False))
Related skills
FAQ
Can I use just one image or do I need both person and item?
Both images are required — try-on cannot work with only one image. You must provide a person photo and a garment/item photo.
What is the difference between nanopro and gpt models?
nanopro is the default model (~25s, good quality) best for fast iteration. gpt is slower (~150s, best quality) and should be used only when the user explicitly requests 'highest quality' or 'best quality'.
How do I deliver the result to the user?
Never use the raw fal.media URL (restrictive CSP headers). Always use the downloaded local_path from output/images/. Embed inline as markdown  or send via platform-specific methods (send_to_telegram, send_to_wechat).