_cost_track.py
"""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
edit_image.py
#!/usr/bin/env python3
"""Image editing script — edit, enhance, and transform existing images.
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
Covers: general editing, background replacement, upscaling, restoration,
colorization, inpainting, retouching, beauty enhancement, filters,
car customization, before/after comparison, outpainting, and more.
Flow: resolve image → build 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
# ── Action definitions ────────────────────────────────────────────────
# Each action maps to an optimized prompt template that wraps the user's
# intent into a high-quality editing instruction.
ACTIONS = {
# === F: Multi-image / general editing ===
"edit": "General edit — modify the image according to the prompt",
"blend": "Image blending — place a person into a new background/scene",
"extend": "Outpainting — extend the image beyond its current boundaries",
"local_edit": "Local edit — modify only a specific region of the image",
"restructure": "Structural redesign — change layout, grid/column count, or rearrange elements",
"text_render": "Text rendering — add or modify text within the image",
"multi_angle": "Multi-angle — generate different viewing angles from one photo",
"before_after": "Before/after comparison — generate a side-by-side comparison",
# === G: Professional editing ===
"replace_bg": "Background replacement — swap the background while keeping the subject",
"upscale": "Super-resolution — upscale and enhance image resolution",
"restore": "Photo restoration — repair scratches, tears, fading in old photos",
"colorize": "Colorization — add realistic colors to black-and-white photos",
"remove_person": "Person removal — remove a specific person from the photo",
# === V: Retouching / beauty ===
"retouch": "Portrait retouching — skin smoothing, blemish removal, teeth whitening",
"slim": "Slimming — adjust facial and body proportions subtly",
"enhance": "Enhancement — color correction, lighting improvement, quality boost",
"filter": "Artistic filter — apply a specific artistic style or filter effect",
# === W: Medical / fitness ===
"comparison": "Comparison — before/after for medical, fitness, or transformation",
# === X: Automotive ===
"car_color": "Car recolor — change the color of a vehicle",
"car_wrap": "Car wrap preview — visualize a wrap or film on a vehicle",
}
# ── Action prompt templates ───────────────────────────────────────────
# These templates wrap the user's prompt to produce optimal results.
# {prompt} is replaced with the user's specific instruction.
ACTION_PROMPTS = {
"edit": (
"Edit this image: {prompt}. "
"Maintain the overall composition and quality of the original image, "
"UNLESS the instruction explicitly asks to change the layout or structure — "
"in that case the instruction takes precedence over preserving composition. "
"Apply the requested changes precisely while preserving unaffected areas."
),
"restructure": (
"Redesign the layout of this image: {prompt}. "
"This is a STRUCTURAL change — you MUST alter the composition as instructed "
"(e.g. change grid dimensions, number of rows/columns, element arrangement, "
"or overall layout). Do NOT preserve the original layout. "
"Keep the visual style, color palette, and content theme of the original, "
"but rebuild the structure exactly as described."
),
"blend": (
"Seamlessly blend the subject from this image into the described scene: {prompt}. "
"Match lighting, perspective, and color temperature between the subject and "
"the new environment. Ensure natural shadows and reflections. "
"The result should look like a real photograph, not a composite."
),
"extend": (
"Extend this image beyond its current boundaries: {prompt}. "
"Generate new content that seamlessly continues the existing scene. "
"Match the style, lighting, perspective, and color palette of the original. "
"Ensure no visible seams or discontinuities at the boundary."
),
"local_edit": (
"Make a local edit to this image: {prompt}. "
"Only modify the specified region. Keep everything else exactly as in the original. "
"Ensure the edited area blends naturally with the surrounding content."
),
"text_render": (
"Add or modify text in this image: {prompt}. "
"Render the text clearly and legibly. Match the visual style of the image. "
"Ensure proper font weight, color contrast, and placement. "
"The text should look naturally integrated, not pasted on."
),
"multi_angle": (
"Generate a different viewing angle of the subject in this image: {prompt}. "
"Maintain the subject's identity, proportions, and details. "
"Adjust perspective, lighting, and shadows consistently for the new angle. "
"The result should look like a real photo taken from the described viewpoint."
),
"before_after": (
"Create a before/after comparison: {prompt}. "
"Generate a side-by-side image showing the transformation. "
"Left side shows the original state, right side shows the result. "
"Add a clean dividing line between the two halves. "
"Ensure both halves have consistent framing and scale."
),
"replace_bg": (
"Replace the background of this image: {prompt}. "
"Keep the foreground subject perfectly intact with clean edges. "
"Match the lighting direction and color temperature of the new background "
"to the subject. Add appropriate shadows and reflections. "
"The result should look like the subject was photographed in the new setting."
),
"upscale": (
"Upscale and enhance this image to higher resolution: {prompt}. "
"Increase detail and sharpness while preserving the original content. "
"Enhance textures, reduce noise and compression artifacts. "
"Maintain natural appearance without over-sharpening or hallucinating details."
),
"restore": (
"Restore this old or damaged photograph: {prompt}. "
"Repair scratches, tears, creases, stains, and fading. "
"Reconstruct missing or damaged areas based on surrounding context. "
"Enhance clarity while preserving the authentic character of the original photo. "
"Fix color shifts and restore proper tonal range."
),
"colorize": (
"Colorize this black-and-white photograph with realistic, natural colors: {prompt}. "
"Apply historically and contextually appropriate colors. "
"Use realistic skin tones for people, natural colors for landscapes and objects. "
"Maintain the original detail and tonal range. "
"The result should look like a naturally colored photograph, not artificially tinted."
),
"remove_person": (
"Remove the specified person from this photo: {prompt}. "
"Fill the area where the person was with content that matches the surrounding "
"background seamlessly. Reconstruct any occluded background elements. "
"Ensure no ghosting, artifacts, or visible editing traces remain."
),
"retouch": (
"Professionally retouch this portrait: {prompt}. "
"Apply natural skin smoothing that preserves texture and pores. "
"Remove blemishes, acne, and skin imperfections. "
"Subtly whiten teeth and brighten eyes if visible. "
"Enhance skin tone evenness while maintaining a realistic, non-plastic look. "
"Keep the person's natural features and character."
),
"slim": (
"Subtly adjust proportions in this portrait: {prompt}. "
"Apply natural-looking slimming to the specified areas. "
"Maintain realistic body proportions and avoid distortion. "
"Ensure the background and surrounding elements are not warped. "
"The result should look natural and unedited."
),
"enhance": (
"Enhance this image: {prompt}. "
"Improve color vibrancy, contrast, and tonal balance. "
"Optimize lighting and exposure. Reduce noise while preserving detail. "
"Apply professional-grade color grading for a polished look. "
"The result should look like a professionally edited photograph."
),
"filter": (
"Apply an artistic filter to this image: {prompt}. "
"Transform the visual style while preserving the composition and subject. "
"Ensure the filter effect is applied consistently across the entire image. "
"Maintain recognizable content while achieving the desired artistic effect."
),
"comparison": (
"Create a transformation comparison image: {prompt}. "
"Generate a professional before/after layout showing the change. "
"Use clean framing with consistent scale and alignment. "
"Add subtle labels or a dividing element if appropriate. "
"The comparison should clearly communicate the transformation."
),
"car_color": (
"Change the color of the vehicle in this image: {prompt}. "
"Apply the new color realistically with proper metallic/matte finish. "
"Maintain reflections, highlights, and shadows appropriate for the new color. "
"Keep all other elements (wheels, trim, background) unchanged. "
"The result should look like a factory paint job, not a digital overlay."
),
"car_wrap": (
"Apply a vehicle wrap or film to the car in this image: {prompt}. "
"Render the wrap material realistically following the car's body contours. "
"Show proper material properties (matte, gloss, satin, chrome, carbon fiber). "
"Maintain reflections and lighting consistent with the wrap material. "
"Keep wheels, windows, and trim unaffected."
),
}
# ── Default prompts when user provides no specific instruction ─────────
ACTION_DEFAULT_PROMPTS = {
"edit": "Enhance and improve this image while maintaining its original character",
"blend": "Place the subject into a professional studio setting with soft lighting",
"extend": "Extend the image naturally in all directions, continuing the scene",
"local_edit": "Clean up and improve the central area of the image",
"text_render": "Add elegant text overlay that complements the image",
"multi_angle": "Show this subject from a three-quarter view angle",
"before_after": "Show a before and after comparison of image enhancement",
"replace_bg": "Replace the background with a clean, professional studio backdrop",
"upscale": "Upscale to maximum quality with enhanced detail and sharpness",
"restore": "Restore this photo by repairing all visible damage and improving clarity",
"colorize": "Add natural, realistic colors appropriate to the era and content",
"remove_person": "Remove the indicated person and fill with matching background",
"retouch": "Apply professional portrait retouching with natural skin smoothing",
"slim": "Apply subtle, natural-looking facial slimming",
"enhance": "Enhance colors, lighting, contrast, and overall image quality",
"filter": "Apply a cinematic color grading filter with warm tones",
"comparison": "Create a professional before/after transformation comparison",
"car_color": "Change the car color to a deep metallic blue",
"car_wrap": "Apply a matte black wrap to the vehicle",
}
# ── 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 = None # None = preserve original image ratio
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):
"""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, "Either image_path or image_url must be provided for editing."
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, (
"image_url must be a public HTTP(S) URL. "
"For local files, use the image_path parameter instead."
)
return image_url, None
def _build_edit_prompt(prompt=None, action="edit"):
"""Construct the editing prompt from action template and user instruction.
Priority:
1. prompt provided → wrap with action template
2. no prompt → use action default prompt with template
Returns the final prompt string.
"""
user_prompt = prompt if prompt else ACTION_DEFAULT_PROMPTS.get(action, "")
template = ACTION_PROMPTS.get(action, ACTION_PROMPTS["edit"])
return template.format(prompt=user_prompt)
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["1:1"])
def _build_request_body(prompt, image_urls, aspect_ratio=None, model_key="nanopro",
count=1, output_format="png"):
"""Build the request body for the fal edit API."""
body = {
"prompt": prompt,
"num_images": count,
"seed": int(time.time() * 1000) % (2**32),
"output_format": output_format,
}
# Pass all images via image_urls array (supports 1-3+ images)
body["image_urls"] = image_urls
# Only set output dimensions if aspect_ratio is explicitly provided
# (otherwise the model preserves the original image dimensions)
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, image_urls, model_key, headers, aspect_ratio=None,
count=1, output_format="png"):
"""Submit an edit 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, image_urls, 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"Edit {status}"
except requests.RequestException:
pass
time.sleep(poll_interval)
return "TIMEOUT", f"Edit 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 edit_image(
image_path=None,
image_url=None,
image2_path=None,
image2_url=None,
image3_path=None,
image3_url=None,
prompt="",
action="edit",
model=None,
count=None,
aspect_ratio=None,
output_format=None,
):
"""Edit an existing image using AI models.
This is the primary function for all image editing operations.
Requires at least one image input (local path or URL).
Supports up to 3 images for multi-image scenarios (blend, face swap,
style transfer, group photo composition).
Args:
image_path: Local workspace file path to the source image.
image_url: Public HTTPS URL of the source image.
image2_path: Local path to a second image (for blend, face swap,
style transfer, etc.).
image2_url: Public URL of a second image.
image3_path: Local path to a third image (for group photos, etc.).
image3_url: Public URL of a third image.
prompt: Editing instruction describing the desired changes.
action: Operation type — one of the ACTIONS keys:
edit, blend, extend, local_edit, text_render, multi_angle,
before_after, replace_bg, upscale, restore, colorize,
remove_person, retouch, slim, enhance, filter,
comparison, car_color, car_wrap.
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).
None = preserve original image dimensions.
output_format: Output image format — "png" (default), "jpeg", or "webp".
Returns:
dict with success status, edited image paths, and metadata.
"""
# Validate action
if action not in ACTIONS:
return {
"success": False,
"error": (
f"Unknown action: '{action}'. "
f"Valid actions: {', '.join(sorted(ACTIONS.keys()))}"
),
}
# Resolve source image (required)
src_url, err = _resolve_image(image_path, image_url)
if err:
return {"success": False, "error": err}
# Build image_urls list (supports 1-3 images)
all_image_urls = [src_url]
# Resolve optional second image
if image2_path or image2_url:
img2_url, err = _resolve_image(image2_path, image2_url)
if err:
return {"success": False, "error": f"Second image error: {err}"}
all_image_urls.append(img2_url)
# Resolve optional third image
if image3_path or image3_url:
img3_url, err = _resolve_image(image3_path, image3_url)
if err:
return {"success": False, "error": f"Third image error: {err}"}
all_image_urls.append(img3_url)
# 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
# Validate aspect_ratio if provided
if aspect_ratio and aspect_ratio not in VALID_ASPECT_RATIOS:
aspect_ratio = None # Fall back to preserving original
# Build the editing prompt
final_prompt = _build_edit_prompt(prompt=prompt, action=action)
# Build a label for filenames
label = f"edit_{action}"
headers = caller_headers({
'Authorization': f'Key {_get_auth_key()}',
'Content-Type': 'application/json',
}, tool_default='image-edit')
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
# Submit the edit request with all images and count
submit_data, err = _submit_request(
final_prompt, all_image_urls, 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} (action={action}, model={model_key}, "
f"images={len(all_image_urls)}, 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,
"action": action,
"prompt": final_prompt,
"aspect_ratio": aspect_ratio,
"output_format": fmt,
"input_image_count": len(all_image_urls),
"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) < 2:
print("Usage: python edit_image.py <image_path_or_url> [prompt] [action] [model]")
print(f"\nActions: {', '.join(sorted(ACTIONS.keys()))}")
print(f"\nModels: {', '.join(MODELS.keys())}")
print("\nSet FAL_KEY env var for local testing (direct fal.ai access).")
sys.exit(1)
img_arg = sys.argv[1]
prompt_arg = sys.argv[2] if len(sys.argv) > 2 else ""
action_arg = sys.argv[3] if len(sys.argv) > 3 else "edit"
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 input is a URL or file path
if img_arg.startswith(("http://", "https://")):
result = edit_image(
image_url=img_arg,
prompt=prompt_arg,
action=action_arg,
model=model_arg,
)
else:
result = edit_image(
image_path=img_arg,
prompt=prompt_arg,
action=action_arg,
model=model_arg,
)
print(json.dumps(result, indent=2, ensure_ascii=False))
SKILL.md
---
name: image-edit
version: 1.0.5
description: |
Image editing and enhancement of an existing image. Covers background replacement, super-resolution upscaling, old photo restoration, colorization, person removal, portrait retouching (skin smoothing, blemish removal), slimming, color grading, artistic filters, image blending, outpainting, local editing, text rendering, multi-angle generation, before/after comparison, car recoloring, car wrap preview.
Use when editing, enhancing, or transforming an existing image (e.g. remove background, upscale photo, restore old photo, retouch portrait, change car color, apply filter, extend image).
metadata:
starchild:
emoji: "✏️"
skillKey: image-edit
user-invocable: true
disable-model-invocation: false
---
# image-edit
Use this skill for **all image editing and enhancement requests** on Starchild.
Covers: general editing, background replacement, super-resolution, old photo restoration, colorization, person removal, portrait retouching (skin smoothing, blemish removal, teeth whitening), slimming, color grading, artistic filters, image blending, outpainting, local editing, text rendering, multi-angle generation, before/after comparison, car recoloring, car wrap preview, and fitness/medical transformation comparisons.
**Core principle:** call the provided script. Do not re-implement proxy/billing plumbing.
**When to use image-edit vs other image skills:**
- **image-edit** → user wants to EDIT, ENHANCE, or TRANSFORM an existing image
- **image-portrait** → user wants a portrait with their face/identity preserved from a reference photo
- **image-create** → user wants to CREATE something from a text description (no source image)
---
## 1. Quick start — basic edit (most common)
> **⚠️ Execution context — read this first.**
> The code blocks below are **Python**, not shell commands. Starchild's `bash` tool
> runs `/bin/bash -c`, which cannot parse `exec(open(...))` — pasting them directly
> into a bash command will fail with `syntax error near unexpected token 'open'`.
> Also, `exec(open(...))` inside `python3 -c` fails with `NameError: __file__`
> because the script uses `__file__` for path resolution.
>
> **Use `python3 - <<'EOF'` with `from exports import` when calling via the bash tool:**
>
> ```bash
> python3 - <<'EOF'
> import sys
> sys.path.insert(0, "skills/image-edit")
> from exports import edit_image
> result = edit_image(
> image_path="uploads/photo.jpg",
> prompt="make the sky more dramatic with golden sunset colors",
> action="enhance",
> )
> print(result)
> EOF
> ```
>
> The heredoc (`<<'EOF'`) preserves all quotes and newlines — no escaping needed.
```python
exec(open('skills/image-edit/edit_image.py').read())
result = edit_image(
image_path="uploads/photo.jpg",
prompt="make the sky more dramatic with golden sunset colors",
action="enhance",
)
# result -> {"success": True, "images": [{"local_path": "output/images/..."}], ...}
```
The script reads the local file, base64-encodes it, and sends it to fal.ai as a data URI — no manual URL publishing needed.
## 2. Quick start — public URL
```python
exec(open('skills/image-edit/edit_image.py').read())
result = edit_image(
image_url="https://example.com/photo.jpg",
prompt="replace the background with a tropical beach",
action="replace_bg",
)
```
### 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:
```markdown

```
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")`.
---
## 3. Parameters
| Parameter | Required | Default | Description |
|-----------|----------|---------|-------------|
| `image_path` | yes* | — | Local workspace file path to the source image |
| `image_url` | yes* | — | Public HTTPS URL of the source image |
| `prompt` | no | auto | Editing instruction (what to change) |
| `action` | no | `"edit"` | Operation type (see §4) |
| `model` | no | `"nanopro"` | Model: `"nanopro"` (fast ~25s) or `"gpt"` (best quality ~150s) |
| `aspect_ratio` | no | `None` | Output ratio: `1:1`, `3:4`, `4:3`, `9:16`, `16:9`. `None` = preserve original. |
*At least one of `image_path` or `image_url` must be provided. If both are given, `image_path` takes priority.
---
## 4. Actions — operation types
### F: Multi-image / general editing
| Action | Key | Description |
|--------|-----|-------------|
| General edit | `edit` | Modify the image according to the prompt |
| Image blending | `blend` | Place a person/subject into a new background or scene |
| Outpainting | `extend` | Extend the image beyond its current boundaries |
| Local edit | `local_edit` | Modify only a specific region of the image |
| Structural redesign | `restructure` | Change layout/grid/column count or rearrange elements — overrides "preserve composition" |
| Text rendering | `text_render` | Add or modify text within the image |
| Multi-angle | `multi_angle` | Generate different viewing angles from one photo |
| Before/after | `before_after` | Generate a side-by-side comparison image |
### G: Professional editing
| Action | Key | Description |
|--------|-----|-------------|
| Background replacement | `replace_bg` | Swap the background while keeping the subject |
| Super-resolution | `upscale` | Upscale and enhance image resolution |
| Photo restoration | `restore` | Repair scratches, tears, fading in old photos |
| Colorization | `colorize` | Add realistic colors to black-and-white photos |
| Person removal | `remove_person` | Remove a specific person from the photo |
### V: Retouching / beauty
| Action | Key | Description |
|--------|-----|-------------|
| Portrait retouching | `retouch` | Skin smoothing, blemish removal, teeth whitening |
| Slimming | `slim` | Adjust facial and body proportions subtly |
| Enhancement | `enhance` | Color correction, lighting improvement, quality boost |
| Artistic filter | `filter` | Apply a specific artistic style or filter effect |
### W: Medical / fitness comparison
| Action | Key | Description |
|--------|-----|-------------|
| Transformation comparison | `comparison` | Before/after for medical, fitness, or transformation |
### X: Automotive
| Action | Key | Description |
|--------|-----|-------------|
| Car recolor | `car_color` | Change the color of a vehicle |
| Car wrap preview | `car_wrap` | Visualize a wrap or film on a vehicle |
---
## 5. 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". Complex edits. |
**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 edit requires very precise detail preservation (e.g., complex text rendering, fine inpainting).
3. **Use `nanopro` when:** user wants fast results, is iterating on edits, or the edit is straightforward.
```python
# Default (fast)
result = edit_image(image_path="photo.jpg", prompt="remove background", action="replace_bg")
# High quality (user requested)
result = edit_image(image_path="photo.jpg", prompt="remove background", action="replace_bg", model="gpt")
```
---
## 6. Intent recognition guide
Use this table to map user requests to the correct action:
### General editing
| User says | Action | Prompt hint |
|-----------|--------|-------------|
| "edit this photo", "modify this image" | `edit` | Pass user's instruction as prompt |
| "put me on a beach", "change the scene" | `blend` | Describe the target scene |
| "extend the image", "make it wider", "outpaint" | `extend` | Describe what to add |
| "change just the shirt color", "edit only the sky" | `local_edit` | Specify the region and change |
| "fewer columns", "simplify the grid", "rearrange the layout", "make it 7 columns max" | `restructure` | State the target structure explicitly (rows/columns/arrangement) |
| "add text", "write 'Hello' on the image" | `text_render` | Specify text content and placement |
| "show from the side", "different angle" | `multi_angle` | Describe the desired angle |
| "before and after", "show the difference" | `before_after` | Describe the transformation |
### Professional editing
| User says | Action | Prompt hint |
|-----------|--------|-------------|
| "remove background", "change background", "换背景" | `replace_bg` | Describe the new background |
| "upscale", "make it higher resolution", "enhance quality" | `upscale` | Optionally specify target quality |
| "restore old photo", "fix this damaged photo", "修复老照片" | `restore` | Describe specific damage to fix |
| "colorize", "add color to B&W photo", "上色" | `colorize` | Optionally describe expected colors |
| "remove this person", "P掉某人" | `remove_person` | Describe which person to remove |
### Retouching / beauty
| User says | Action | Prompt hint |
|-----------|--------|-------------|
| "retouch", "smooth skin", "remove blemishes", "磨皮美白" | `retouch` | Specify retouching level |
| "make me thinner", "slim face", "瘦脸" | `slim` | Specify areas to adjust |
| "enhance colors", "improve lighting", "调色" | `enhance` | Describe desired look |
| "apply filter", "make it look vintage", "滤镜" | `filter` | Describe the filter style |
### Medical / fitness
| User says | Action | Prompt hint |
|-----------|--------|-------------|
| "before and after surgery", "fitness transformation" | `comparison` | Describe the transformation context |
### Automotive
| User says | Action | Prompt hint |
|-----------|--------|-------------|
| "change car color", "make it red", "汽车改色" | `car_color` | Specify the target color and finish |
| "car wrap", "vinyl wrap preview", "贴膜预览" | `car_wrap` | Describe wrap material and color |
---
## 7. Prompt engineering best practices
### The prompt template system
Every action has a built-in prompt template that wraps the user's instruction for optimal results. You only need to pass the user's specific intent — the template adds the technical quality instructions automatically.
For example, if the user says "make the background a sunset beach":
```python
result = edit_image(
image_path="photo.jpg",
prompt="a beautiful sunset beach with palm trees and golden light",
action="replace_bg",
)
# The script wraps this into: "Replace the background of this image: a beautiful
# sunset beach with palm trees and golden light. Keep the foreground subject
# perfectly intact with clean edges. Match the lighting direction..."
```
### Key principles (from reference skills)
1. **Be specific about the change** — vague prompts produce poor results:
- ❌ "make it better"
- ✅ "increase contrast, add warm golden tones, sharpen details"
2. **Describe what to preserve** — especially for local edits:
- ❌ "change the shirt"
- ✅ "change the shirt color to navy blue, keep the same fabric texture and wrinkles"
3. **Specify materials and finishes** — for car and product edits:
- ❌ "make it blue"
- ✅ "deep metallic blue with a glossy clear coat finish"
4. **Reference real-world styles** — for filters and artistic effects:
- ❌ "make it artistic"
- ✅ "apply a warm cinematic color grade like Wes Anderson films"
5. **Describe the era for restoration/colorization**:
- ❌ "colorize this"
- ✅ "colorize this 1940s family portrait with period-appropriate clothing colors"
6. **For retouching, specify the level**:
- Light: "subtle skin smoothing, keep natural texture"
- Medium: "professional retouching, remove blemishes, even skin tone"
- Heavy: "full beauty retouching, smooth skin, brighten eyes, whiten teeth"
---
## 8. Usage examples by scenario
### Background replacement
```python
exec(open('skills/image-edit/edit_image.py').read())
# Simple background swap
result = edit_image(
image_path="uploads/portrait.jpg",
prompt="a modern office with floor-to-ceiling windows and city skyline view",
action="replace_bg",
)
# Studio background
result = edit_image(
image_path="uploads/product.jpg",
prompt="clean white studio background with soft shadow",
action="replace_bg",
)
```
### Old photo restoration
```python
# Repair damaged photo
result = edit_image(
image_path="uploads/old_family_photo.jpg",
prompt="repair all scratches, tears, and stains; restore faded colors; enhance clarity",
action="restore",
)
# Colorize black-and-white photo
result = edit_image(
image_path="uploads/grandpa_1945.jpg",
prompt="colorize with historically accurate colors for 1940s era, natural skin tones, period-appropriate clothing",
action="colorize",
)
```
### Portrait retouching
```python
# Professional retouching
result = edit_image(
image_path="uploads/selfie.jpg",
prompt="professional portrait retouching: smooth skin while keeping natural texture, remove blemishes, subtle teeth whitening, brighten eyes",
action="retouch",
)
# Slimming
result = edit_image(
image_path="uploads/photo.jpg",
prompt="subtle facial slimming, slightly more defined jawline, natural proportions",
action="slim",
)
```
### Image enhancement
```python
# Color grading
result = edit_image(
image_path="uploads/landscape.jpg",
prompt="cinematic color grading with warm golden tones, enhanced contrast, vibrant but natural colors",
action="enhance",
)
# Artistic filter
result = edit_image(
image_path="uploads/photo.jpg",
prompt="oil painting style with visible brushstrokes, rich warm palette, impressionist feel",
action="filter",
)
```
### Super-resolution upscaling
```python
result = edit_image(
image_path="uploads/low_res.jpg",
prompt="upscale to maximum quality, enhance fine details, reduce noise and compression artifacts",
action="upscale",
)
```
### Person removal
```python
result = edit_image(
image_path="uploads/group_photo.jpg",
prompt="remove the person on the far right, fill with the park background seamlessly",
action="remove_person",
)
```
### Outpainting (image extension)
```python
result = edit_image(
image_path="uploads/cropped.jpg",
prompt="extend the image to the left and right, continuing the mountain landscape naturally",
action="extend",
aspect_ratio="16:9",
)
```
### Car customization
```python
# Car recolor
result = edit_image(
image_path="uploads/my_car.jpg",
prompt="change to a deep cherry red metallic paint with glossy clear coat",
action="car_color",
)
# Car wrap preview
result = edit_image(
image_path="uploads/my_car.jpg",
prompt="matte black vinyl wrap with carbon fiber accents on the hood and mirrors",
action="car_wrap",
)
```
### Before/after comparison
```python
# Fitness transformation
result = edit_image(
image_path="uploads/fitness_photo.jpg",
prompt="create a fitness transformation comparison showing a more toned and fit version",
action="comparison",
)
```
### Local editing
```python
# Change specific element
result = edit_image(
image_path="uploads/outfit.jpg",
prompt="change only the dress color from red to emerald green, keep the same fabric texture",
action="local_edit",
)
```
### Text rendering
```python
result = edit_image(
image_path="uploads/poster_bg.jpg",
prompt="add the text 'SUMMER SALE' in bold white letters centered at the top, with a subtle drop shadow",
action="text_render",
)
```
### High quality edit
```python
# Use GPT model for best quality
result = edit_image(
image_path="uploads/important_photo.jpg",
prompt="professional color correction and enhancement for print publication",
action="enhance",
model="gpt",
)
```
---
## 9. Provided scripts
| File | Purpose |
|------|---------|
| `edit_image.py` | Core script: resolve image → build prompt → submit → poll → download. Handles local files (base64) and URLs, all actions, two models. |
| `exports.py` | Re-exports `edit_image`, `ACTIONS`, `ACTION_PROMPTS`, `MODELS` for programmatic use by other skills. |
| `_cost_track.py` | Cost tracking helper — records per-call costs via sc-proxy headers. |
---
## 10. Local testing
Set `FAL_KEY` env var to call fal.ai directly (bypasses sc-proxy):
```bash
# Basic edit
FAL_KEY=your-fal-key python3 skills/image-edit/edit_image.py photo.jpg "make it brighter" enhance nanopro
# Args: <image_path_or_url> [prompt] [action] [model]
```
---
## 11. Troubleshooting
| Problem | Fix |
|---------|-----|
| `File not found: ...` | Check the workspace path; the file must exist |
| `Unsupported image format` | Use `.jpg`, `.jpeg`, `.png`, `.webp`, or `.bmp` |
| `Image too large` | Resize to under 10 MB before uploading |
| `image_url must be a public HTTP(S) URL` | Use `image_path` for local files, or provide a valid `https://` URL |
| `Unknown action` | Check valid actions in §4 |
| `HTTP 402 insufficient_credits` | Top up balance; cost is pre-charged on submit |
| `HTTP 403 endpoint_not_allowed` | sc-proxy only allows approved fal endpoints; contact admin |
| Edit `FAILED` upstream | Simplify prompt, ensure source image is clear, retry |
| Job stuck `IN_PROGRESS` >10 min | Save `request_id`, retry later |
| Poor edit quality | Try `model="gpt"` for higher quality; be more specific in prompt |
| Layout/grid/column count won't change no matter how many times you iterate | Prefer `action="restructure"` for structural changes — its template mandates the layout change. Plain `edit` now has a precedence fallback (explicit structural instructions override composition preservation), but treat it only as a compatibility net, not the primary path |
| Background not fully removed | Use `replace_bg` action with explicit background description |
| Retouching looks unnatural | Add "keep natural texture" or "subtle" to prompt |
---
## 12. Infrastructure (reference)
- Caller → `sc-proxy` → `queue.fal.run/{model}` → fal model providers
- All requests must include `Authorization: Key fake-falai-key-12345` (proxy injects the real `FAL_KEY`)
- Pre-charge happens at submit. Poll/result calls are free.
- Local files are base64-encoded as data URIs — no separate upload step needed.
- Final images live at `https://*.fal.media/...` — public CDN, no auth needed for download.
- Cost tracking via `_cost_track.py` — records `X-Credits-Used` from sc-proxy response headers.
### Model endpoints
| Model | Edit endpoint |
|-------|--------------|
| nanopro | `fal-ai/nano-banana-pro/edit` |
| gpt | `openai/gpt-image-2/edit` |
---