diff --git a/.github/workflows/block-cursor-coauthor.yml b/.github/workflows/block-cursor-coauthor.yml index 1fe66009..ea255cbb 100644 --- a/.github/workflows/block-cursor-coauthor.yml +++ b/.github/workflows/block-cursor-coauthor.yml @@ -1,5 +1,7 @@ # Reject PR commits that include Cursor as Co-authored-by. # GitHub free repos cannot use ruleset commit_message_pattern; this check is the substitute. +# Use Windows PowerShell — Git Bash mangles ACTIONS_TEMP paths (C:\ → C:), +# and this runner may not have PowerShell 7 (pwsh) installed. name: Block Cursor co-author on: @@ -16,7 +18,7 @@ jobs: runs-on: [self-hosted, Windows, ci] defaults: run: - shell: bash + shell: powershell steps: - name: Checkout uses: actions/checkout@v4 @@ -28,11 +30,21 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - set -euo pipefail - bad="$(git log --format=%B "${BASE_SHA}..${HEAD_SHA}" | grep -F 'Co-authored-by: Cursor' || true)" - if [ -n "$bad" ]; then - echo "::error::Commits must not include 'Co-authored-by: Cursor'. Amend/rebase without that trailer, then push." - git log --format='== %h%n%B' "${BASE_SHA}..${HEAD_SHA}" | grep -n -F 'Co-authored-by: Cursor' -B5 || true + $base = $env:BASE_SHA + $head = $env:HEAD_SHA + if (-not $base -or -not $head) { + Write-Error "Missing BASE_SHA or HEAD_SHA" exit 1 - fi - echo "OK: no Cursor Co-authored-by trailer in PR commits." + } + $bodies = git log --format=%B "${base}..${head}" 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error "git log failed: $bodies" + exit 1 + } + $hit = $bodies | Select-String -SimpleMatch 'Co-authored-by: Cursor' + if ($hit) { + Write-Host "::error::Commits must not include 'Co-authored-by: Cursor'. Amend/rebase without that trailer, then push." + git log --format='== %h%n%B' "${base}..${head}" | Select-String -SimpleMatch 'Co-authored-by: Cursor' -Context 5,0 + exit 1 + } + Write-Host 'OK: no Cursor Co-authored-by trailer in PR commits.' diff --git a/apps/api/.env.example b/apps/api/.env.example index 6ed545c6..1b3c5859 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -49,6 +49,8 @@ S3_ENABLED=false # VISION_EDIT_ELEMENTS_PROVIDER=seedream # VISION_SEEDREAM_LAYER_MODEL=doubao-seedream-5-0-pro-260628 # VISION_SEEDREAM_TIMEOUT_SEC=180 +# When local MinIO (localhost:9000), remote Seedream needs a public CDN base: +# VISION_PUBLIC_BASE_URL=https://files.recombyn.com/recombyn # DASHSCOPE_API_KEY= # BYTEPLUS_API_KEY= # TOPAZ_API_KEY= diff --git a/apps/api/app/api/routes/image_process_jobs.py b/apps/api/app/api/routes/image_process_jobs.py index bb6db7a2..4b837187 100644 --- a/apps/api/app/api/routes/image_process_jobs.py +++ b/apps/api/app/api/routes/image_process_jobs.py @@ -13,7 +13,12 @@ from app.api.deps import CurrentUser from app.api.routes.chat_job_sse import streaming_media_job_events -from app.api.routes.image_tools import ImageProcessIn, _charge, credit_cost_for_kind +from app.api.routes.image_tools import ( + ImageProcessIn, + _charge, + _require_free_vision_quota, + credit_cost_for_kind, +) from app.services.i18n.errors import http_error from app.services.i18n.locale import LocaleDep from app.services.job_store import get_job, normalize_trace_id, save_job, update_job @@ -167,6 +172,7 @@ async def create_image_process_job( raise http_error(400, "image_required", locale) cost = credit_cost_for_kind(tool_kind, body.model, user_id=current_user.id) + _require_free_vision_quota(current_user.id, cost=cost, locale=locale) _charge(current_user.id, cost, f"AI image tool: {tool_kind}", locale=locale) job_id = uuid.uuid4().hex diff --git a/apps/api/app/api/routes/image_tools.py b/apps/api/app/api/routes/image_tools.py index b54c2c3f..0e586b86 100644 --- a/apps/api/app/api/routes/image_tools.py +++ b/apps/api/app/api/routes/image_tools.py @@ -18,13 +18,22 @@ uses_llm_for_kind, ) from app.services.wallet.billing import DEFAULT_IMAGE_CREDITS, image_model_credit_cost -from app.services.wallet.db import is_wallet_billing_enabled, spend_credits +from app.services.wallet.db import ( + consume_free_vision_quota, + free_vision_remaining, + get_user_plan, + is_wallet_billing_enabled, + spend_credits, +) router = APIRouter(prefix="/image", tags=["image-tools"]) -# Wallet 积分 for LLM image tools only (Seedream i2i). MediaKit / WaveSpeed / layered → 0. +# Wallet 积分 for paid vision / LLM image paths. +# MediaKit tools (removeBg / expand / …) stay 0 — free users use freeVision quota. _KIND_CREDIT_COST: dict[str, int] = { "replaceText": 30, + # Seedream layer_decomposition / WaveSpeed qwen-image/layered + "editElements": 30, } @@ -49,28 +58,46 @@ def _charge(user_id: str, amount: int, detail: str, *, locale: str | None = None raise http_error(400, "request_failed", locale) from err +def _require_free_vision_quota( + user_id: str, + *, + cost: int, + locale: str | None = None, +) -> None: + """Free plan: lifetime cap on zero-credit vision toolbar tools.""" + if cost > 0 or not is_wallet_billing_enabled(): + return + if str(get_user_plan(user_id) or "free").strip().lower() != "free": + return + if consume_free_vision_quota(user_id): + return + raise http_error(402, "free_vision_exhausted", locale) + + def credit_cost_for_kind( kind: str, model: str | None = None, *, user_id: str | None = None, ) -> int: - """Platform credits only when an LLM image path runs on the platform key.""" + """Platform credits for paid image tools (LLM i2i + Seedream/WaveSpeed layered).""" k = (kind or "").strip() - if not uses_llm_for_kind(k): - return 0 if not is_wallet_billing_enabled(): return 0 if is_byok_model_ref(model) or (user_id and uses_user_platform_byok(user_id, model)): return 0 + if k in _KIND_CREDIT_COST: + return int(_KIND_CREDIT_COST[k]) + if not uses_llm_for_kind(k): + return 0 mid = (model or "").strip() if mid: return image_model_credit_cost(mid) - return int(_KIND_CREDIT_COST.get(k, DEFAULT_IMAGE_CREDITS)) + return int(DEFAULT_IMAGE_CREDITS) @router.get("/tools") -def list_image_tools() -> dict[str, Any]: +def list_image_tools(current_user: CurrentUser) -> dict[str, Any]: from app.core.config import settings from app.services.llm.image_tools import ( mediakit_supports, @@ -82,6 +109,7 @@ def list_image_tools() -> dict[str, Any]: seedream_enabled, wavespeed_enabled, ) + from app.services.wallet.db import FREE_VISION_LIMIT kinds = sorted(IMAGE_PROCESS_KINDS) costs = {k: credit_cost_for_kind(k) for k in kinds} @@ -91,6 +119,10 @@ def list_image_tools() -> dict[str, Any]: dash_on = bool(str(settings.dashscope_api_key or "").strip()) byte_on = bool(str(settings.byteplus_api_key or "").strip()) topaz_on = bool(str(settings.topaz_api_key or "").strip()) + plan = str(get_user_plan(current_user.id) or "free").strip().lower() + vision_remaining = None + if is_wallet_billing_enabled() and plan == "free": + vision_remaining = free_vision_remaining(current_user.id) return { "kinds": kinds, "credits": costs, @@ -119,6 +151,10 @@ def list_image_tools() -> dict[str, Any]: }, # FE mockup is client-side only (no server bake API). "mockup": {"enabled": True, "templates": []}, + "freeVision": { + "limit": FREE_VISION_LIMIT, + "remaining": vision_remaining, + }, } @@ -130,6 +166,7 @@ async def post_image_process( ) -> dict[str, Any]: kind = body.kind.strip() cost = credit_cost_for_kind(kind, body.model, user_id=current_user.id) + _require_free_vision_quota(current_user.id, cost=cost, locale=locale) _charge(current_user.id, cost, f"AI image tool: {kind}", locale=locale) try: diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py index 33169d50..47320e28 100644 --- a/apps/api/app/core/config.py +++ b/apps/api/app/core/config.py @@ -156,28 +156,6 @@ class Settings(BaseSettings): agent_profile_id: str = "design.canvas" - # Design agent floors (ADR 0017). Always BasicLocal in-process — no remote Intelligence service. - intelligence_provider: str = Field( - default="local", - validation_alias="RECOMBYN_INTELLIGENCE_MODE", - ) - intelligence_remote_url: str = Field( - default="", - validation_alias="RECOMBYN_INTELLIGENCE_URL", - ) - intelligence_remote_api_key: str = Field( - default="", - validation_alias="RECOMBYN_INTELLIGENCE_API_KEY", - ) - intelligence_remote_timeout_sec: float = Field( - default=30.0, - validation_alias="RECOMBYN_INTELLIGENCE_TIMEOUT_SEC", - ) - intelligence_circuit_sec: float = Field( - default=30.0, - validation_alias="RECOMBYN_INTELLIGENCE_CIRCUIT_SEC", - ) - # Volcengine AI MediaKit (OSS: removeBg, expand, editText, eraser, upscale, translateImage, productScene). mediakit_api_key: str = Field( default="", @@ -221,6 +199,12 @@ class Settings(BaseSettings): default=180.0, validation_alias="VISION_SEEDREAM_TIMEOUT_SEC", ) + # When S3_PUBLIC_BASE_URL is localhost/MinIO, remote APIs (Seedream/WaveSpeed) + # need a reachable CDN base (prod: https://files.recombyn.com/recombyn). + vision_public_base_url: str = Field( + default="", + validation_alias="VISION_PUBLIC_BASE_URL", + ) # Reserved for later vision vendors (keys only — no layered clients yet). dashscope_api_key: str = Field(default="", validation_alias="DASHSCOPE_API_KEY") diff --git a/apps/api/app/services/design/intelligence_runtime.py b/apps/api/app/services/design/intelligence_runtime.py index 64463dbc..ebee9c6f 100644 --- a/apps/api/app/services/design/intelligence_runtime.py +++ b/apps/api/app/services/design/intelligence_runtime.py @@ -444,57 +444,22 @@ def reset_design_intelligence_client() -> None: _client = None def remote_billing_base_url() -> str: - """No remote Intelligence host — billing quotes stay local.""" + """No remote Intelligence billing host in the open tree.""" return "" -def _remote_billing_headers() -> dict[str, str]: - key = str(getattr(settings, "intelligence_remote_api_key", "") or "").strip() - headers = {"Content-Type": "application/json"} - if key: - headers["Authorization"] = f"Bearer {key}" - return headers - - def call_remote_billing( method: str, path: str, *, json_body: dict[str, Any] | None = None, ) -> dict[str, Any] | None: - """HTTP call to optional host ``/billing/quote``. Returns None if unavailable.""" - import urllib.error - import urllib.request - - base = remote_billing_base_url() - if not base: - return None - url = f"{base}{path}" - timeout = float(getattr(settings, "intelligence_remote_timeout_sec", 30.0) or 30.0) - data = None - headers = _remote_billing_headers() - if json_body is not None or method.upper() in ("POST", "PUT"): - import json as _json - - raw = _json.dumps(json_body or {}).encode("utf-8") - data = raw - req = urllib.request.Request( - url, - data=data, - headers=headers, - method=method.upper(), - ) - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - import json as _json - - body = _json.loads(resp.read().decode("utf-8")) - return body if isinstance(body, dict) else None - except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError) as e: - _log.warning("remote billing %s %s failed: %s", method, path, e) - return None + """Optional remote billing — always unavailable (BasicLocal only).""" + _ = method, path, json_body + return None def quote_remote_task_credits(body: dict[str, Any]) -> dict[str, Any] | None: - """Optional host credit quote — wire returns credits only.""" - return call_remote_billing("POST", "/billing/quote", json_body=body) + """Optional host credit quote — not wired in the open tree.""" + _ = body + return None diff --git a/apps/api/app/services/design/runtime/graph/nodes/intent.py b/apps/api/app/services/design/runtime/graph/nodes/intent.py index 11692619..69988df0 100644 --- a/apps/api/app/services/design/runtime/graph/nodes/intent.py +++ b/apps/api/app/services/design/runtime/graph/nodes/intent.py @@ -39,6 +39,19 @@ def _pending_proposal_flag(rt: AgentRuntime) -> dict[str, Any] | None: return raw +def _recent_dialogue_lines(rt: AgentRuntime, *, limit: int = 4) -> str: + lines: list[str] = [] + for turn in list(getattr(rt, "mem_short", None) or [])[-limit:]: + if not isinstance(turn, dict): + continue + text = str(turn.get("text") or "").strip() + if not text: + continue + role = "User" if str(turn.get("role") or "") == "user" else "Assistant" + lines.append(f"{role}: {text[:280]}") + return "\n".join(lines) + + def _release_ambient_focus_for_new_design(rt: AgentRuntime) -> None: """Drop ambient/memory FOCUS so Host can open a shimmer sibling and bind it. @@ -93,9 +106,6 @@ async def _vision_chat_reply(rt: AgentRuntime) -> str: ] if not images: return "" - from app.services.design.runtime.models_route import ( - resolve_model_for_skill, - ) try: model, _reason = resolve_model_for_skill( @@ -109,10 +119,13 @@ async def _vision_chat_reply(rt: AgentRuntime) -> str: canvas_node_count=len(rt.scene_nodes or []), route_lane=str(rt.flags.get("route_lane") or "").strip() or None, ) - except Exception: - return "" - if not model: - return "" + except Exception as err: + raise RuntimeError(f"vision_chat_failed: model resolve failed: {err}") from err + if not str(model or "").strip(): + raise RuntimeError( + "vision_chat_failed: no vision model — lock a multimodal model " + "or configure precheck.vision_model" + ) system = ( "You are a helpful design-canvas assistant. The user attached image(s) " "from the canvas or as references. Look at every image carefully and " @@ -120,9 +133,9 @@ async def _vision_chat_reply(rt: AgentRuntime) -> str: "puzzle, solve it and give the answer. Reply in the user's language. " "Be concise — do not claim you cannot see the image." ) - user = (rt.prompt or "").strip()[:4000] - if not user: - user = "Look at the attached image(s) and answer any question they contain." + user = (rt.prompt or "").strip()[:4000] or ( + "Look at the attached image(s) and answer any question they contain." + ) try: _family, content, _used, events, _thinking = await _stream_llm_text( model_family=model, @@ -136,11 +149,40 @@ async def _vision_chat_reply(rt: AgentRuntime) -> str: for ev in events: if isinstance(ev, dict) and ev.get("phase") == "model_switch": _emit({"type": "activity", "kind": "model", **ev}) - return str(content or "").strip() + reply = str(content or "").strip() + if not reply: + raise RuntimeError("vision_chat_failed: empty vision reply") + return reply + except RuntimeError: + raise except Exception as err: raise RuntimeError(f"vision_chat_failed: {err}") from err +def _intent_summary( + *, + intent: str, + paint_lane: str, + action: str, + session_action: str, + rationale: str, +) -> str: + parts = [f"intent={intent}"] + if paint_lane: + parts.append(f"/{paint_lane}") + if action: + parts.append(f" · proposal={action}") + if session_action: + parts.append(f" · session={session_action}") + if rationale: + parts.append(f" · {rationale[:80]}") + return "".join(parts) + + +def _should_log_reply(*, intent: str, action: str, session_action: str) -> bool: + return intent == "chat" or action == "dismiss" or bool(session_action) + + async def _node_intent_classify(state: GraphState) -> Command: """Cheap intent gate: chat → end; canvas_op → paint; design → decide; animation → animation_decide. @@ -149,14 +191,6 @@ async def _node_intent_classify(state: GraphState) -> Command: rt = state["rt"] st = rt.run pending = _pending_proposal_flag(rt) - dial_lines: list[str] = [] - for t in list(getattr(rt, "mem_short", None) or [])[-4:]: - if not isinstance(t, dict): - continue - role = "User" if str(t.get("role") or "") == "user" else "Assistant" - text = str(t.get("text") or "").strip() - if text: - dial_lines.append(f"{role}: {text[:280]}") t_intent = time.perf_counter() route_lane = str(rt.flags.get("route_lane") or st.task_tier or "").strip() or None try: @@ -187,7 +221,7 @@ async def _node_intent_classify(state: GraphState) -> Command: scene_nodes=rt.scene_nodes, interaction_mode=str(rt.flags.get("mode") or rt.mode or ""), pending_proposal=pending, - recent_dialogue="\n".join(dial_lines), + recent_dialogue=_recent_dialogue_lines(rt), model=intent_model, user_selected_model=rt.user_selected_model, route_lane=route_lane, @@ -232,9 +266,10 @@ async def _node_intent_classify(state: GraphState) -> Command: str(getattr(decision, "output_locale", "") or "").strip() or None, default="zh-CN", ) - st.intent = ( - paint_ops_intent(intent, paint_lane) if intent != "chat" else "chat" - ) + if intent == "chat": + st.intent = "chat" + else: + st.intent = paint_ops_intent(intent, paint_lane) rt.flags["gate_intent"] = intent plan = build_design_plan( prompt=rt.prompt, @@ -243,10 +278,7 @@ async def _node_intent_classify(state: GraphState) -> Command: focus_frame_id=rt.focus_id, scene_nodes=rt.scene_nodes, ) - if plan is not None: - rt.design_plan = plan.model_dump() - else: - rt.design_plan = None + rt.design_plan = plan.model_dump() if plan is not None else None if session_action: rt.flags["session_action"] = session_action from app.services.design.runtime.session_log import log_stage_decision @@ -260,6 +292,9 @@ async def _node_intent_classify(state: GraphState) -> Command: session_action=session_action or None, ) + log_reply = _should_log_reply( + intent=intent, action=action, session_action=session_action + ) st.push_log( phase="intent_classify", intent=intent, @@ -269,17 +304,13 @@ async def _node_intent_classify(state: GraphState) -> Command: model=intent_model, model_reason=intent_model_reason, needs_clarification=needs_clarification or None, - reply=( - reply[:500] - if intent == "chat" or action == "dismiss" or session_action - else None - ), - summary=( - f"intent={intent}" - + (f"/{paint_lane}" if paint_lane else "") - + (f" · proposal={action}" if action else "") - + (f" · session={session_action}" if session_action else "") - + (f" · {(decision.rationale or '')[:80]}" if decision.rationale else "") + reply=reply[:500] if log_reply else None, + summary=_intent_summary( + intent=intent, + paint_lane=paint_lane, + action=action, + session_action=session_action, + rationale=str(decision.rationale or ""), ), duration_ms=intent_ms, llm_raw=_clip_llm_raw( @@ -291,13 +322,11 @@ async def _node_intent_classify(state: GraphState) -> Command: "session_action": session_action, "needs_clarification": needs_clarification, "clarification": clarification[:240] if needs_clarification else "", - "clarification_options": clarification_options - if needs_clarification - else [], + "clarification_options": ( + clarification_options if needs_clarification else [] + ), "rationale": (decision.rationale or "")[:400], - "reply": reply[:400] - if intent == "chat" or action == "dismiss" or session_action - else "", + "reply": reply[:400] if log_reply else "", }, ensure_ascii=False, ), @@ -323,11 +352,11 @@ async def _node_intent_classify(state: GraphState) -> Command: return _goto_cmd(rt, frm="intent_classify", to="apply_confirm") if action == "dismiss" and pending: - if not reply: - _emit_ux_tip(rt, "ask_dismissed") - else: + if reply: st.reply = reply _emit({"type": "token", "text": reply}) + else: + _emit_ux_tip(rt, "ask_dismissed") _clear_ask_proposal_meta(str(pending.get("task_id") or "")) _drop_pending(rt) _emit_chat_ui_done(rt) @@ -347,30 +376,21 @@ async def _node_intent_classify(state: GraphState) -> Command: if intent == "chat": vision_streamed = False - if rt.images and not session_action: + if rt.images: # Classifier never saw pixels — regenerate with vision before settle. - vision_reply = await _vision_chat_reply(rt) - if vision_reply: - reply = vision_reply - vision_streamed = True - st.reply = reply - rt.classified_reply = reply - st.push_log( - phase="intent_vision_chat", - intent="chat", - summary=f"vision_chat images={len(rt.images)}", - reply=reply[:500], - ) - elif not reply: - raise IntentClassifyError( - "intent_classify: chat with images but vision reply empty" - ) - if reply and not vision_streamed: - st.reply = reply - _emit({"type": "token", "text": reply}) - elif reply and vision_streamed: + reply = await _vision_chat_reply(rt) + vision_streamed = True + rt.classified_reply = reply + st.push_log( + phase="intent_vision_chat", + intent="chat", + summary=f"vision_chat images={len(rt.images)}", + reply=reply[:500], + ) + if reply: st.reply = reply - # Reply is on screen — free UI before settle (episode / KG / memory). + if not vision_streamed: + _emit({"type": "token", "text": reply}) _emit_chat_ui_done(rt) return _goto_cmd(rt, frm="intent_classify", to="__settle__") diff --git a/apps/api/app/services/design/runtime/llm_step.py b/apps/api/app/services/design/runtime/llm_step.py index f2d3ca38..61591cc4 100644 --- a/apps/api/app/services/design/runtime/llm_step.py +++ b/apps/api/app/services/design/runtime/llm_step.py @@ -293,6 +293,12 @@ async def complete_skill_step( raw_images = [ u.strip() for u in (images or []) if isinstance(u, str) and u.strip() ] + if raw_images: + from app.services.vision.rehost import ensure_remote_fetchable_image_refs + + raw_images = await ensure_remote_fetchable_image_refs(raw_images) + if safe_images: + safe_images = await ensure_remote_fetchable_image_refs(safe_images) tokens = _resolve_max_tokens(max_tokens) endpoint, llm = _build_step_llm( family=family, @@ -412,6 +418,12 @@ async def stream_skill_step( raw_images = [ u.strip() for u in (images or []) if isinstance(u, str) and u.strip() ] + if raw_images: + from app.services.vision.rehost import ensure_remote_fetchable_image_refs + + raw_images = await ensure_remote_fetchable_image_refs(raw_images) + if safe_images: + safe_images = await ensure_remote_fetchable_image_refs(safe_images) tokens = _resolve_max_tokens(max_tokens) endpoint, llm = _build_step_llm( family=family, diff --git a/apps/api/app/services/design/runtime/pipeline_support.py b/apps/api/app/services/design/runtime/pipeline_support.py index 36dd9cd8..2ab577b6 100644 --- a/apps/api/app/services/design/runtime/pipeline_support.py +++ b/apps/api/app/services/design/runtime/pipeline_support.py @@ -154,6 +154,18 @@ def _run_error_code(err: BaseException | str) -> str: if "structured_output_failed" in low: return "structured_output_failed" if "vision_chat_failed" in low: + if ( + "too small" in low + or "minimum allowed dimension" in low + or "dimensions are too small" in low + ): + return "vision_image_too_small" + if ( + "error while downloading" in low + or "connection refused" in low + or "dial tcp" in low + ): + return "vision_image_unreachable" return "vision_chat_failed" if "review_agent_llm_failed" in low or "review_lanes_unavailable" in low: return "review_failed" diff --git a/apps/api/app/services/i18n/catalog/errors.json b/apps/api/app/services/i18n/catalog/errors.json index e031ebf6..9d4cb2ec 100644 --- a/apps/api/app/services/i18n/catalog/errors.json +++ b/apps/api/app/services/i18n/catalog/errors.json @@ -5,6 +5,12 @@ "en": "Daily free run used up. Try again tomorrow or upgrade.", "ja": "本日の無料実行回数を使い切りました。明日再度お試しいただくか、プランをアップグレードしてください。" }, + "free_vision_exhausted": { + "zh-CN": "免费用户抠图等视觉工具最多使用 3 次,次数已用完,请升级会员后继续使用。", + "zh-TW": "免費用戶去背等視覺工具最多使用 3 次,次數已用完,請升級會員後繼續使用。", + "en": "Free plan includes 3 vision tool uses (remove background, etc.). Limit reached — upgrade to continue.", + "ja": "無料プランの画像ツール(背景除去など)は 3 回までです。上限に達しました。続行するにはプランをアップグレードしてください。" + }, "insufficient_credits": { "zh-CN": "积分不足,请充值后重试。", "zh-TW": "積分不足,請充值後重試。", @@ -30,10 +36,22 @@ "ja": "構造化出力に失敗しました。再試行するか、別の言い方でお試しください。" }, "vision_chat_failed": { - "zh-CN": "看图回复失败,请检查 Admin 看图模型配置后重试。", - "zh-TW": "看圖回覆失敗,請檢查 Admin 看圖模型設定後重試。", - "en": "Vision reply failed. Check the vision model in Admin and retry.", - "ja": "画像付き返信に失敗しました。Admin の vision モデルを確認して再試行してください。" + "zh-CN": "看图回复失败,请稍后重试;若反复失败请检查看图模型与附图是否可访问。", + "zh-TW": "看圖回覆失敗,請稍後重試;若反覆失敗請檢查看圖模型與附圖是否可存取。", + "en": "Vision reply failed. Retry; if it keeps failing, check the vision model and that the image is reachable.", + "ja": "画像付き返信に失敗しました。再試行し、続く場合は vision モデルと画像の取得可否を確認してください。" + }, + "vision_image_too_small": { + "zh-CN": "附图过小(看图模型要求边长至少约 14px),请换一张更大的图或框选更大区域后再试。", + "zh-TW": "附圖過小(看圖模型要求邊長至少約 14px),請換一張更大的圖或框選更大區域後再試。", + "en": "Attached image is too small (vision models need roughly 14px on each side). Use a larger image or a bigger selection.", + "ja": "添付画像が小さすぎます(vision モデルは各辺おおよそ 14px 以上)。より大きな画像か選択範囲でもう一度お試しください。" + }, + "vision_image_unreachable": { + "zh-CN": "附图地址无法被看图模型访问(本机 MinIO / 内网链接)。请重试;若仍失败请检查本地对象存储是否在跑。", + "zh-TW": "附圖網址無法被看圖模型存取(本機 MinIO / 內網連結)。請重試;若仍失敗請檢查本機物件儲存是否在運作。", + "en": "The image URL is not reachable by the vision model (local MinIO / private link). Retry; if it still fails, check that local object storage is running.", + "ja": "画像 URL を vision モデルが取得できません(ローカル MinIO / プライベートリンク)。再試行し、だめならローカルストレージの稼働を確認してください。" }, "review_failed": { "zh-CN": "设计评审失败,请重试。", @@ -672,10 +690,10 @@ "ja": "DLQ エントリに project_id/user_id がありません — 再生できません。" }, "mockup_unavailable": { - "zh-CN": "样机渲染需要接入 Recombyn Intelligence(设置 RECOMBYN_INTELLIGENCE_URL)", - "zh-TW": "樣機渲染需要接入 Recombyn Intelligence(設定 RECOMBYN_INTELLIGENCE_URL)", - "en": "Mockup rendering requires Recombyn Intelligence (set RECOMBYN_INTELLIGENCE_URL).", - "ja": "モックアップ描画には Recombyn Intelligence が必要です(RECOMBYN_INTELLIGENCE_URL を設定してください)。" + "zh-CN": "样机预览失败,请稍后重试。", + "zh-TW": "樣機預覽失敗,請稍後再試。", + "en": "Mockup preview failed. Please try again.", + "ja": "モックアップのプレビューに失敗しました。もう一度お試しください。" }, "card_key_misconfigured": { "zh-CN": "卡密系统未正确配置,请联系管理员", diff --git a/apps/api/app/services/i18n/errors.py b/apps/api/app/services/i18n/errors.py index a62fe490..b3b6bdde 100644 --- a/apps/api/app/services/i18n/errors.py +++ b/apps/api/app/services/i18n/errors.py @@ -211,6 +211,20 @@ def rule_msg(key: str, code: str) -> str: if "structured_output_failed" in low or "decide_structured" in low: return rule_msg("error.structured_output_failed", "structured_output_failed") if "vision_chat_failed" in low: + if ( + "too small" in low + or "minimum allowed dimension" in low + or "dimensions are too small" in low + ): + return rule_msg("error.vision_image_too_small", "vision_image_too_small") + if ( + "error while downloading" in low + or "connection refused" in low + or "dial tcp" in low + ): + return rule_msg( + "error.vision_image_unreachable", "vision_image_unreachable" + ) return rule_msg("error.vision_chat_failed", "vision_chat_failed") if "review_agent_llm_failed" in low or "review_lanes_unavailable" in low: return rule_msg("error.review_failed", "review_failed") diff --git a/apps/api/app/services/vision/edit_text.py b/apps/api/app/services/vision/edit_text.py index 6ec8ca08..660d3899 100644 --- a/apps/api/app/services/vision/edit_text.py +++ b/apps/api/app/services/vision/edit_text.py @@ -44,7 +44,7 @@ async def decompose_edit_text( m = meta or {} min_conf = float(getattr(settings, "ocr_text_min_confidence", 0.72) or 0.72) ocr_meta = { - "tool_version": str(m.get("toolVersion") or m.get("tool_version") or "max"), + "tool_version": str(m.get("toolVersion") or "max"), } ocr = await image_ocr(image, meta=ocr_meta) blocks = list(ocr.get("blocks") or []) @@ -60,9 +60,9 @@ async def decompose_edit_text( erase = await erase_image( image, meta={ - "standard_scene": "full_screen_text_erase", - "output_format": "png", - "tool_version": "standard", + "standardScene": "full_screen_text_erase", + "outputFormat": "png", + "toolVersion": "standard", }, ) bg_bytes = erase["image_bytes"] diff --git a/apps/api/app/services/vision/mediakit_client.py b/apps/api/app/services/vision/mediakit_client.py index 21ea0fa8..9fe0264c 100644 --- a/apps/api/app/services/vision/mediakit_client.py +++ b/apps/api/app/services/vision/mediakit_client.py @@ -14,14 +14,17 @@ from __future__ import annotations import base64 +import io import logging import re from typing import Any from urllib.parse import unquote, urlparse import httpx +from PIL import Image from app.core.config import settings +from app.services.vision.rehost import is_public_http_url logger = logging.getLogger(__name__) @@ -233,24 +236,95 @@ async def _load_image_bytes(image_ref: str) -> tuple[bytes, str]: raise ValueError("unsupported image reference") -def _is_public_http_url(ref: str) -> bool: - s = (ref or "").strip() - if not (s.startswith("http://") or s.startswith("https://")): - return False - try: - host = (urlparse(s).hostname or "").lower() - except Exception: - return False - if not host: - return False - if host in {"localhost", "127.0.0.1", "0.0.0.0", "::1"}: - return False - if host.endswith(".local") or host.endswith(".internal"): - return False - if host.startswith("10.") or host.startswith("192.168.") or host.startswith("172."): - # Private LAN — MediaKit cannot fetch. - return False - return True +_MEDIAKIT_ALIGN_PX = 8 +_MEDIAKIT_MAX_SIDE = 4096 +_MEDIAKIT_MIN_SIDE = 64 + + +def _align_mediakit_dim(n: int, *, align: int = _MEDIAKIT_ALIGN_PX) -> int: + size = max(1, int(n)) + rounded = int(round(size / float(align)) * align) + return max(align, rounded) + + +def mediakit_target_size( + width: int, + height: int, + *, + align: int = _MEDIAKIT_ALIGN_PX, + max_side: int = _MEDIAKIT_MAX_SIDE, + min_side: int = _MEDIAKIT_MIN_SIDE, +) -> tuple[int, int]: + """ + MediaKit erase/remove-bg reject odd / non-aligned sizes (errcode 800012). + + Fit into [min_side, max_side] and snap both edges to ``align`` multiples. + """ + w = max(1, int(width)) + h = max(1, int(height)) + long_side = max(w, h) + scale = 1.0 + if long_side > max_side: + scale = min(scale, max_side / float(long_side)) + short_side = min(w, h) + if short_side * scale < min_side: + scale = max(scale, min_side / float(short_side)) + tw = _align_mediakit_dim(max(1, round(w * scale)), align=align) + th = _align_mediakit_dim(max(1, round(h * scale)), align=align) + tw = min(max(align, tw), _align_mediakit_dim(max_side, align=align)) + th = min(max(align, th), _align_mediakit_dim(max_side, align=align)) + return tw, th + + +def fit_raster_for_mediakit( + data: bytes, + *, + align: int = _MEDIAKIT_ALIGN_PX, + max_side: int = _MEDIAKIT_MAX_SIDE, + min_side: int = _MEDIAKIT_MIN_SIDE, + nearest: bool = False, + target: tuple[int, int] | None = None, +) -> tuple[bytes, tuple[int, int], tuple[int, int]]: + """ + Return ``(png_bytes, (orig_w, orig_h), (out_w, out_h))``. + + ``nearest=True`` for masks so brush edges stay crisp. + """ + img = Image.open(io.BytesIO(data)) + orig = (int(img.width), int(img.height)) + if target is not None: + out_w, out_h = int(target[0]), int(target[1]) + else: + out_w, out_h = mediakit_target_size( + orig[0], orig[1], align=align, max_side=max_side, min_side=min_side + ) + if (out_w, out_h) == orig and img.format == "PNG": + return data, orig, orig + if (out_w, out_h) != orig: + resample = Image.Resampling.NEAREST if nearest else Image.Resampling.LANCZOS + img = img.resize((out_w, out_h), resample) + return _image_to_png_bytes(img), orig, (out_w, out_h) + + +def _image_to_png_bytes(img: Image.Image) -> bytes: + if img.mode not in ("RGB", "RGBA"): + if "A" in img.getbands(): + img = img.convert("RGBA") + else: + img = img.convert("RGB") + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + +def _restore_raster_size(data: bytes, size: tuple[int, int]) -> bytes: + w, h = int(size[0]), int(size[1]) + if w < 1 or h < 1: + return data + img = Image.open(io.BytesIO(data)) + if img.size == (w, h): + return data + return _image_to_png_bytes(img.resize((w, h), Image.Resampling.LANCZOS)) def _parse_upload_headers(raw: Any) -> dict[str, str]: @@ -345,7 +419,7 @@ async def _resolve_image_url_for_tool( ref = (image_ref or "").strip() if ref.startswith(("mediakit://", "tos://", "vod://")): return ref - if _is_public_http_url(ref) and not _object_key_from_image_ref(ref): + if is_public_http_url(ref) and not _object_key_from_image_ref(ref): # Public CDN URL MediaKit can fetch directly. return ref @@ -375,7 +449,7 @@ def _parse_json_response(resp: httpx.Response) -> dict[str, Any]: def _scene_from_meta(meta: dict[str, Any] | None) -> str: m = meta or {} - raw = str(m.get("scene") or m.get("cutoutScene") or "general").strip().lower() + raw = str(m.get("scene") or "general").strip().lower() if raw == "portrait": raw = "human" if raw not in _SCENES: @@ -384,7 +458,7 @@ def _scene_from_meta(meta: dict[str, Any] | None) -> str: def _output_format_from_meta(meta: dict[str, Any] | None) -> str: - raw = str((meta or {}).get("outputFormat") or (meta or {}).get("output_format") or "png") + raw = str((meta or {}).get("outputFormat") or "png") raw = raw.strip().lower() if raw not in _OUTPUT_FORMATS: return "png" @@ -425,16 +499,16 @@ async def remove_image_background( "scene": scene, "output_format": _output_format_from_meta(m), } - need_contour = _optional_bool(m, "needContour", "need_contour") + need_contour = _optional_bool(m, "needContour") if need_contour is not None: body["need_contour"] = need_contour - need_crop = _optional_bool(m, "needCropBackground", "need_crop_background") + need_crop = _optional_bool(m, "needCropBackground") if need_crop is not None: body["need_crop_background"] = need_crop - contour_color = str(m.get("contourColor") or m.get("contour_color") or "").strip() + contour_color = str(m.get("contourColor") or "").strip() if contour_color: body["contour_color"] = contour_color - contour_size = m.get("contourSize", m.get("contour_size")) + contour_size = m.get("contourSize") if contour_size is not None: try: body["contour_size"] = max(1, min(100, int(contour_size))) @@ -509,10 +583,10 @@ def expand_ratios_from_meta(meta: dict[str, Any] | None) -> tuple[float, float, """ m = meta or {} direct = ( - _meta_float(m, "expandLeft", "expand_left"), - _meta_float(m, "expandRight", "expand_right"), - _meta_float(m, "expandTop", "expand_top"), - _meta_float(m, "expandBottom", "expand_bottom"), + _meta_float(m, "expandLeft"), + _meta_float(m, "expandRight"), + _meta_float(m, "expandTop"), + _meta_float(m, "expandBottom"), ) if any(v is not None for v in direct): return ( @@ -522,12 +596,12 @@ def expand_ratios_from_meta(meta: dict[str, Any] | None) -> tuple[float, float, max(0.0, float(direct[3] or 0.0)), ) - pad_l = max(0, _meta_int(m, "padLeft", "pad_left")) - pad_r = max(0, _meta_int(m, "padRight", "pad_right")) - pad_t = max(0, _meta_int(m, "padTop", "pad_top")) - pad_b = max(0, _meta_int(m, "padBottom", "pad_bottom")) - tw = _meta_int(m, "targetWidth", "target_width", default=0) - th = _meta_int(m, "targetHeight", "target_height", default=0) + pad_l = max(0, _meta_int(m, "padLeft")) + pad_r = max(0, _meta_int(m, "padRight")) + pad_t = max(0, _meta_int(m, "padTop")) + pad_b = max(0, _meta_int(m, "padBottom")) + tw = _meta_int(m, "targetWidth", default=0) + th = _meta_int(m, "targetHeight", default=0) ow = tw - pad_l - pad_r if tw > 0 else 0 oh = th - pad_t - pad_b if th > 0 else 0 if ow > 0 and oh > 0 and (pad_l or pad_r or pad_t or pad_b): @@ -712,14 +786,14 @@ async def image_ocr( """ _require_enabled() m = meta or {} - tool_version = str(m.get("toolVersion") or m.get("tool_version") or "max").strip().lower() + tool_version = str(m.get("toolVersion") or "max").strip().lower() if tool_version not in {"standard", "max"}: tool_version = "max" body: dict[str, Any] = {"tool_version": tool_version} - task_type = str(m.get("taskType") or m.get("task_type") or "").strip().lower() + task_type = str(m.get("taskType") or "").strip().lower() if task_type: body["task_type"] = task_type - keywords = m.get("maxKeywords") if "maxKeywords" in m else m.get("max_keywords") + keywords = m.get("maxKeywords") if isinstance(keywords, list) and keywords: body["max_keywords"] = [str(k).strip() for k in keywords if str(k).strip()] @@ -767,12 +841,116 @@ async def image_ocr( } ) +_SELECTED_AREA_KEYS = ( + ("top_left_x", "top_left_x"), + ("topLeftX", "top_left_x"), + ("top_left_y", "top_left_y"), + ("topLeftY", "top_left_y"), + ("bottom_right_x", "bottom_right_x"), + ("bottomRightX", "bottom_right_x"), + ("bottom_right_y", "bottom_right_y"), + ("bottomRightY", "bottom_right_y"), +) + + +def _erase_scene(meta: dict[str, Any]) -> str: + scene = str(meta.get("standardScene") or "full_screen_text_erase").strip() + if scene in _ERASE_SCENES: + return scene + return "full_screen_text_erase" + + +def _selected_area_from_meta(meta: dict[str, Any]) -> dict[str, float] | None: + raw = meta.get("selectedArea") + if not isinstance(raw, dict): + return None + area: dict[str, float] = {} + for src_key, dst_key in _SELECTED_AREA_KEYS: + if src_key not in raw: + continue + try: + area[dst_key] = float(raw[src_key]) + except (TypeError, ValueError): + continue + if len(area) != 4: + return None + return area + + +def _scale_selected_area( + area: dict[str, float], + *, + orig_size: tuple[int, int], + fitted_size: tuple[int, int], +) -> dict[str, float]: + if fitted_size == orig_size or orig_size[0] < 1 or orig_size[1] < 1: + return area + sx = fitted_size[0] / float(orig_size[0]) + sy = fitted_size[1] / float(orig_size[1]) + return { + "top_left_x": float(area["top_left_x"]) * sx, + "top_left_y": float(area["top_left_y"]) * sy, + "bottom_right_x": float(area["bottom_right_x"]) * sx, + "bottom_right_y": float(area["bottom_right_y"]) * sy, + } + + +def _build_erase_body(meta: dict[str, Any]) -> tuple[dict[str, Any], str, str]: + tool_version = str(meta.get("toolVersion") or "standard").strip().lower() + if tool_version != "standard": + tool_version = "standard" + scene = _erase_scene(meta) + output_format = str(meta.get("outputFormat") or "png").strip().lower() + if output_format not in _OUTPUT_FORMATS: + output_format = "png" + + body: dict[str, Any] = { + "tool_version": tool_version, + "standard_scene": scene, + "output_format": output_format, + } + erase_text = str(meta.get("standardEraseText") or "").strip() + if erase_text and scene == "full_screen_text_erase": + body["standard_erase_text"] = erase_text + if scene == "selected_area_erase": + area = _selected_area_from_meta(meta) + if area is not None: + body["selected_area"] = area + return body, scene, output_format + + +async def _attach_erase_mask( + client: httpx.AsyncClient, + body: dict[str, Any], + *, + scene: str, + meta: dict[str, Any], + mask_bytes: bytes | None, + fitted_size: tuple[int, int], +) -> None: + if scene != "selected_area_erase": + return + mask_url = str(meta.get("maskUrl") or "").strip() + if mask_bytes: + mask_fitted, _, _ = fit_raster_for_mediakit( + mask_bytes, nearest=True, target=fitted_size + ) + mask_url = await _upload_bytes_as_mediakit_uri( + client, + mask_fitted, + filename="erase-mask.png", + tool_name=_ERASE_TOOL, + ) + if mask_url: + body["mask_url"] = mask_url + if "mask_url" not in body and "selected_area" not in body: + raise ValueError("selected_area_erase requires mask_url or selected_area") + async def erase_image( image_ref: str, *, meta: dict[str, Any] | None = None, - resolved_url: str | None = None, mask_bytes: bytes | None = None, ) -> dict[str, Any]: """ @@ -786,67 +964,29 @@ async def erase_image( """ _require_enabled() m = meta or {} - tool_version = str(m.get("toolVersion") or m.get("tool_version") or "standard").strip().lower() - if tool_version not in {"standard"}: - tool_version = "standard" - scene = str( - m.get("standardScene") or m.get("standard_scene") or "full_screen_text_erase" - ).strip() - if scene not in _ERASE_SCENES: - scene = "full_screen_text_erase" - output_format = str(m.get("outputFormat") or m.get("output_format") or "png").strip().lower() - if output_format not in _OUTPUT_FORMATS: - output_format = "png" - - body: dict[str, Any] = { - "tool_version": tool_version, - "standard_scene": scene, - "output_format": output_format, - } - erase_text = str(m.get("standardEraseText") or m.get("standard_erase_text") or "").strip() - if erase_text and scene == "full_screen_text_erase": - body["standard_erase_text"] = erase_text - - selected_area = m.get("selectedArea") if "selectedArea" in m else m.get("selected_area") - if isinstance(selected_area, dict) and scene == "selected_area_erase": - area: dict[str, float] = {} - for src_key, dst_key in ( - ("top_left_x", "top_left_x"), - ("topLeftX", "top_left_x"), - ("top_left_y", "top_left_y"), - ("topLeftY", "top_left_y"), - ("bottom_right_x", "bottom_right_x"), - ("bottomRightX", "bottom_right_x"), - ("bottom_right_y", "bottom_right_y"), - ("bottomRightY", "bottom_right_y"), - ): - if src_key not in selected_area: - continue - try: - area[dst_key] = float(selected_area[src_key]) - except (TypeError, ValueError): - continue - if len(area) == 4: - body["selected_area"] = area + body, scene, output_format = _build_erase_body(m) + tool_version = str(body["tool_version"]) async with httpx.AsyncClient(timeout=_timeout()) as client: - body["image_url"] = resolved_url or await _resolve_image_url_for_tool( - client, image_ref, tool_name=_ERASE_TOOL + src_bytes, _src_name = await _load_image_bytes(image_ref) + fitted, orig_size, fitted_size = fit_raster_for_mediakit(src_bytes) + body["image_url"] = await _upload_bytes_as_mediakit_uri( + client, fitted, filename="erase.png", tool_name=_ERASE_TOOL ) - - mask_url = str(m.get("maskUrl") or m.get("mask_url") or "").strip() - if mask_bytes and scene == "selected_area_erase": - mask_url = await _upload_bytes_as_mediakit_uri( - client, - mask_bytes, - filename="erase-mask.png", - tool_name=_ERASE_TOOL, + await _attach_erase_mask( + client, + body, + scene=scene, + meta=m, + mask_bytes=mask_bytes, + fitted_size=fitted_size, + ) + if "selected_area" in body: + body["selected_area"] = _scale_selected_area( + body["selected_area"], + orig_size=orig_size, + fitted_size=fitted_size, ) - if mask_url and scene == "selected_area_erase": - body["mask_url"] = mask_url - - if scene == "selected_area_erase" and "mask_url" not in body and "selected_area" not in body: - raise ValueError("selected_area_erase requires mask_url or selected_area") resp = await client.post( f"{_base_url()}/api/v1/tools-sync/erase-image", @@ -864,16 +1004,19 @@ async def erase_image( if dl.status_code >= 400: raise RuntimeError(f"failed to download MediaKit erase ({dl.status_code})") raw = dl.content + if fitted_size != orig_size: + raw = _restore_raster_size(raw, orig_size) return { "image_bytes": raw, "image_url": out_url, - "width": int(result.get("image_width") or 0) or None, - "height": int(result.get("image_height") or 0) or None, - "format": str(result.get("image_format") or output_format).strip().lower(), + "width": orig_size[0], + "height": orig_size[1], + "format": output_format, "task_id": str(payload.get("task_id") or "").strip() or None, "request_id": str(payload.get("request_id") or "").strip() or None, "scene": scene, + "tool_version": tool_version, } @@ -886,26 +1029,24 @@ def enhance_params_from_meta( m = meta or {} body: dict[str, Any] = {} - version = str(m.get("toolVersion") or m.get("tool_version") or "professional").strip().lower() + version = str(m.get("toolVersion") or "professional").strip().lower() if version not in _ENHANCE_VERSIONS: version = "professional" body["tool_version"] = version - mode = str( - m.get("generativeEnhanceMode") or m.get("generative_enhance_mode") or "" - ).strip().lower() + mode = str(m.get("generativeEnhanceMode") or "").strip().lower() if not mode and version in {"professional", "max"}: mode = "fidelity_first" if mode in _ENHANCE_MODES: body["generative_enhance_mode"] = mode - multiple = _meta_float(m, "multiple", "scale") + multiple = _meta_float(m, "multiple") if multiple is not None and multiple >= 1: body["multiple"] = round(float(multiple), 2) return body - tw = _meta_int(m, "targetWidth", "target_width", default=0) - th = _meta_int(m, "targetHeight", "target_height", default=0) + tw = _meta_int(m, "targetWidth", default=0) + th = _meta_int(m, "targetHeight", default=0) if tw > 0: body["target_width"] = tw if th > 0: @@ -1006,23 +1147,17 @@ def translate_params_from_meta(meta: dict[str, Any] | None) -> dict[str, Any]: m = meta or {} body: dict[str, Any] = {} - version = str( - m.get("toolVersion") or m.get("tool_version") or "seed-translation" - ).strip().lower() + version = str(m.get("toolVersion") or "seed-translation").strip().lower() if version not in _TRANSLATE_VERSIONS: version = "seed-translation" body["tool_version"] = version - target = _normalize_translate_lang( - m.get("targetLang") if "targetLang" in m else m.get("target_lang") - ) + target = _normalize_translate_lang(m.get("targetLang")) if not target: target = "zh" body["target_lang"] = target - source = _normalize_translate_lang( - m.get("sourceLang") if "sourceLang" in m else m.get("source_lang") - ) + source = _normalize_translate_lang(m.get("sourceLang")) if source: body["source_lang"] = source return body @@ -1088,18 +1223,16 @@ def _clamp_product_dim(raw: Any, default: int = 600) -> int: def product_scene_params_from_meta(meta: dict[str, Any] | None) -> dict[str, Any]: """Build MediaKit generate-product-scene-image fields from FE meta.""" m = meta or {} - version = str( - m.get("toolVersion") or m.get("tool_version") or "standard" - ).strip().lower() + version = str(m.get("toolVersion") or "standard").strip().lower() if version not in _PRODUCT_SCENE_VERSIONS: version = "standard" body: dict[str, Any] = {"tool_version": version} - batch = _meta_int(m, "batchCount", "batch_count", default=1) + batch = _meta_int(m, "batchCount", default=1) body["batch_count"] = max(1, min(4, batch if batch > 0 else 1)) - ow = _meta_int(m, "outputWidth", "output_width", default=0) - oh = _meta_int(m, "outputHeight", "output_height", default=0) + ow = _meta_int(m, "outputWidth", default=0) + oh = _meta_int(m, "outputHeight", default=0) if ow > 0: body["output_width"] = _clamp_product_dim(ow) if oh > 0: @@ -1108,18 +1241,16 @@ def product_scene_params_from_meta(meta: dict[str, Any] | None) -> dict[str, Any body["output_width"] = 600 body["output_height"] = 600 - prompt = str(m.get("prompt") or m.get("positivePrompt") or "").strip() + prompt = str(m.get("prompt") or "").strip() if prompt: body["prompt"] = prompt - product_ratio = _meta_float(m, "productRatio", "product_ratio") + product_ratio = _meta_float(m, "productRatio") if product_ratio is not None: body["product_ratio"] = max(0.0, min(1.0, float(product_ratio))) if version == "standard": - scene = str( - m.get("standardScene") or m.get("standard_scene") or "exhibit_home" - ).strip().lower() + scene = str(m.get("standardScene") or "exhibit_home").strip().lower() if scene not in _PRODUCT_STANDARD_SCENES: scene = "exhibit_home" body["standard_scene"] = scene @@ -1130,42 +1261,23 @@ def product_scene_params_from_meta(meta: dict[str, Any] | None) -> dict[str, Any if version == "professional": if not prompt: raise ValueError("professional product scene requires meta.prompt") - ref = str( - m.get("professionalReferenceImageUrl") - or m.get("professional_reference_image_url") - or m.get("referenceImageUrl") - or m.get("reference_image_url") - or "" - ).strip() + ref = str(m.get("professionalReferenceImageUrl") or "").strip() if not ref: raise ValueError( "professional product scene requires meta.professionalReferenceImageUrl" ) body["professional_reference_image_url"] = ref - adapt = _meta_float( - m, - "professionalReferenceImageAdaptScale", - "professional_reference_image_adapt_scale", - "referenceAdaptScale", - ) + adapt = _meta_float(m, "professionalReferenceImageAdaptScale") if adapt is None: adapt = 0.9 body["professional_reference_image_adapt_scale"] = max(0.0, min(1.0, float(adapt))) return body # industry - scene_ref = str( - m.get("industrySceneImageUrl") - or m.get("industry_scene_image_url") - or "" - ).strip() + scene_ref = str(m.get("industrySceneImageUrl") or "").strip() if scene_ref: body["industry_scene_image_url"] = scene_ref - detail_ref = str( - m.get("industryDetailImageUrl") - or m.get("industry_detail_image_url") - or "" - ).strip() + detail_ref = str(m.get("industryDetailImageUrl") or "").strip() if detail_ref: body["industry_detail_image_url"] = detail_ref return body diff --git a/apps/api/app/services/vision/product_scene.py b/apps/api/app/services/vision/product_scene.py index 3a56d062..4daa5e7d 100644 --- a/apps/api/app/services/vision/product_scene.py +++ b/apps/api/app/services/vision/product_scene.py @@ -2,7 +2,6 @@ from __future__ import annotations -import base64 import io import logging from typing import Any @@ -13,7 +12,7 @@ generate_product_scene_image, mediakit_enabled, ) -from app.services.vision.rehost import rehost_image_bytes +from app.services.vision.rehost import encode_or_rehost_image, raster_filename_and_type logger = logging.getLogger(__name__) @@ -30,22 +29,22 @@ def _encode_or_rehost( user_id: str | None, index: int, ) -> str: + filename, content_type = raster_filename_and_type( + fmt, stem="product-scene", index=index + ) if user_id: - filename = f"product-scene-{index}.png" if fmt == "png" else f"product-scene-{index}.jpg" - content_type = "image/png" if fmt == "png" else "image/jpeg" - return rehost_image_bytes( - user_id, raw, filename=filename, content_type=content_type + return encode_or_rehost_image( + raw, user_id=user_id, filename=filename, content_type=content_type ) img = Image.open(io.BytesIO(raw)) buf = io.BytesIO() - if fmt == "png": + if content_type == "image/png": img.convert("RGBA" if "A" in img.getbands() else "RGB").save(buf, format="PNG") - ctype = "image/png" else: img.convert("RGB").save(buf, format="JPEG", quality=92) - ctype = "image/jpeg" - b64 = base64.b64encode(buf.getvalue()).decode("ascii") - return f"data:{ctype};base64,{b64}" + return encode_or_rehost_image( + buf.getvalue(), user_id=None, filename=filename, content_type=content_type + ) async def product_scene( @@ -78,12 +77,14 @@ async def product_scene( raw = row["image_bytes"] fmt = str(row.get("format") or "png").lower() urls.append(_encode_or_rehost(raw, fmt=fmt, user_id=user_id, index=i)) - if not width: - width = int(row.get("width") or 0) - height = int(row.get("height") or 0) - if not width or not height: - img = Image.open(io.BytesIO(raw)) - width, height = img.width, img.height + if width and height: + continue + width = int(row.get("width") or 0) + height = int(row.get("height") or 0) + if width and height: + continue + img = Image.open(io.BytesIO(raw)) + width, height = img.width, img.height version = str(out.get("tool_version") or "standard") return { diff --git a/apps/api/app/services/vision/providers/seedream.py b/apps/api/app/services/vision/providers/seedream.py index cc364997..5ebf3081 100644 --- a/apps/api/app/services/vision/providers/seedream.py +++ b/apps/api/app/services/vision/providers/seedream.py @@ -7,18 +7,18 @@ import base64 import io -import logging import re from typing import Any -from urllib.parse import urlparse import httpx from PIL import Image from app.core.config import settings from app.services.vision.providers.base import ProgressCb - -logger = logging.getLogger(__name__) +from app.services.vision.rehost import ( + ensure_remote_fetchable_image_ref, + ipv4_loopback_url, +) _DATA_URL_RE = re.compile(r"^data:([^;,]+)?(?:;base64)?,(.+)$", re.DOTALL) _DEFAULT_MODEL = "doubao-seedream-5-0-pro-260628" @@ -28,6 +28,7 @@ "见 https://console.volcengine.com/ark)" ) _TIMEOUT_SEC = 180.0 +_SIZE_PRESETS = frozenset({"1K", "1.5K", "2K"}) def seedream_enabled() -> bool: @@ -52,22 +53,12 @@ def _require_enabled() -> None: raise RuntimeError(_SEEDREAM_REQUIRED_MSG) -def _is_http_url(ref: str) -> bool: - try: - parsed = urlparse(ref) - except Exception: - return False - return parsed.scheme in ("http", "https") and bool(parsed.netloc) - - -async def _ensure_image_ref(image: str, *, user_id: str | None) -> str: - """Ark accepts public URL or data URL; rehost private refs when needed.""" - ref = (image or "").strip() - if not ref: - raise ValueError("image is required") - if _is_http_url(ref) or ref.startswith("data:"): - return ref - raise ValueError("image must be a data URL or http(s) URL") +def _normalize_size(raw: Any) -> str: + s = str(raw or "auto").strip() or "auto" + upper = s.upper() + if upper in _SIZE_PRESETS: + return upper + return s async def _download(url_or_data: str) -> bytes: @@ -80,22 +71,29 @@ async def _download(url_or_data: str) -> bytes: raise RuntimeError("invalid data URL from Seedream") return base64.b64decode(match.group(2)) async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=20.0)) as client: - resp = await client.get(ref) + resp = await client.get(ipv4_loopback_url(ref)) resp.raise_for_status() return resp.content +async def _ensure_image_ref(image: str, *, user_id: str | None) -> str: + """Give Ark a public URL or data URL — never localhost / LAN.""" + _ = user_id + return await ensure_remote_fetchable_image_ref(image) + + def _parse_size_wh(raw: Any) -> tuple[int, int]: s = str(raw or "").strip().lower() - if "x" in s: - parts = s.split("x", 1) - try: - w, h = int(parts[0]), int(parts[1]) - if w > 0 and h > 0: - return w, h - except ValueError: - pass - return 0, 0 + if "x" not in s: + return 0, 0 + left, right = s.split("x", 1) + try: + w, h = int(left), int(right) + except ValueError: + return 0, 0 + if w <= 0 or h <= 0: + return 0, 0 + return w, h def _bbox_xywh(item: dict[str, Any]) -> tuple[float, float, float, float] | None: @@ -106,12 +104,7 @@ def _bbox_xywh(item: dict[str, Any]) -> tuple[float, float, float, float] | None if not isinstance(absolute, (list, tuple)) or len(absolute) < 4: return None try: - left, top, right, bottom = ( - float(absolute[0]), - float(absolute[1]), - float(absolute[2]), - float(absolute[3]), - ) + left, top, right, bottom = (float(absolute[i]) for i in range(4)) except (TypeError, ValueError): return None w = max(0.0, right - left) @@ -131,6 +124,15 @@ def _item_url(item: dict[str, Any]) -> str: return "" +def _layer_display_name(raw: dict[str, Any], z: int) -> str: + name = str(raw.get("name") or "").strip() + if name: + return name + if z == 0: + return "Background" + return f"Layer {z}" + + def map_seedream_data_to_layers( data_items: list[dict[str, Any]], ) -> list[dict[str, Any]]: @@ -140,20 +142,18 @@ def map_seedream_data_to_layers( if not isinstance(raw, dict): continue try: - z = int(raw.get("z_index") if raw.get("z_index") is not None else i) + z_raw = raw.get("z_index") + z = int(z_raw) if z_raw is not None else i except (TypeError, ValueError): z = i url = _item_url(raw) if not url: continue - name = str(raw.get("name") or "").strip() or ( - "Background" if z == 0 else f"Layer {z}" - ) rows.append( { "z_index": z, "url": url, - "name": name, + "name": _layer_display_name(raw, z), "description": str(raw.get("description") or "").strip(), "size": raw.get("size"), "bounding_box": raw.get("bounding_box"), @@ -163,6 +163,38 @@ def map_seedream_data_to_layers( return rows +def _raster_wh(raw: bytes) -> tuple[int, int]: + with Image.open(io.BytesIO(raw)) as img: + return int(img.width), int(img.height) + + +def _layer_geometry( + row: dict[str, Any], + raw_bytes: bytes, + *, + canvas_w: int, + canvas_h: int, +) -> tuple[float, float, float, float, int, int]: + """Return (x, y, w, h, canvas_w, canvas_h) for one Seedream layer.""" + z = int(row["z_index"]) + size_w, size_h = _parse_size_wh(row.get("size")) + xywh = _bbox_xywh(row) + + if z == 0: + if size_w > 0 and size_h > 0: + cw, ch = size_w, size_h + else: + cw, ch = _raster_wh(raw_bytes) + return 0.0, 0.0, float(cw), float(ch), cw, ch + + if xywh: + x, y, w, h = xywh + return x, y, w, h, canvas_w, canvas_h + + nw, nh = _raster_wh(raw_bytes) + return 0.0, 0.0, float(nw), float(nh), canvas_w, canvas_h + + async def run_layered( image: str, *, @@ -180,9 +212,7 @@ async def run_layered( m = meta or {} image_ref = await _ensure_image_ref(image, user_id=user_id) - size = str(m.get("size") or m.get("resolution") or "auto").strip() or "auto" - if size.upper() in ("1K", "1.5K", "2K"): - size = size.upper() if size.upper() != "1.5K" else "1.5K" + size = _normalize_size(m.get("size") or m.get("resolution")) prompt = str(m.get("prompt") or "").strip() model = layer_model_id() @@ -224,9 +254,7 @@ async def run_layered( if not isinstance(data, list) or not data: raise RuntimeError("Seedream layer_decomposition returned no data") - mapped = map_seedream_data_to_layers( - [x for x in data if isinstance(x, dict)] - ) + mapped = map_seedream_data_to_layers([x for x in data if isinstance(x, dict)]) if not mapped: raise RuntimeError("Seedream layer_decomposition returned empty layers") @@ -237,22 +265,9 @@ async def run_layered( layers_out: list[dict[str, Any]] = [] for row in mapped: raw_bytes = await _download(str(row["url"])) - z = int(row["z_index"]) - xywh = _bbox_xywh(row) - size_w, size_h = _parse_size_wh(row.get("size")) - if z == 0: - if size_w > 0 and size_h > 0: - canvas_w, canvas_h = size_w, size_h - else: - with Image.open(io.BytesIO(raw_bytes)) as img: - canvas_w, canvas_h = int(img.width), int(img.height) - x, y, w, h = 0.0, 0.0, float(canvas_w), float(canvas_h) - elif xywh: - x, y, w, h = xywh - else: - with Image.open(io.BytesIO(raw_bytes)) as img: - nw, nh = int(img.width), int(img.height) - x, y, w, h = 0.0, 0.0, float(nw), float(nh) + x, y, w, h, canvas_w, canvas_h = _layer_geometry( + row, raw_bytes, canvas_w=canvas_w, canvas_h=canvas_h + ) layers_out.append( { "bytes": raw_bytes, @@ -261,7 +276,7 @@ async def run_layered( "width": w, "height": h, "name": str(row["name"]), - "z_index": z, + "z_index": int(row["z_index"]), "description": str(row.get("description") or ""), } ) diff --git a/apps/api/app/services/vision/providers/wavespeed.py b/apps/api/app/services/vision/providers/wavespeed.py index d193aa6d..805e20de 100644 --- a/apps/api/app/services/vision/providers/wavespeed.py +++ b/apps/api/app/services/vision/providers/wavespeed.py @@ -10,20 +10,22 @@ import asyncio import base64 import io -import logging import re import time from typing import Any -from urllib.parse import urlparse import httpx from PIL import Image from app.core.config import settings from app.services.vision.providers.base import ProgressCb -from app.services.vision.rehost import rehost_image_bytes - -logger = logging.getLogger(__name__) +from app.services.vision.rehost import ( + bytes_to_data_url, + ipv4_loopback_url, + is_http_url, + is_public_http_url, + rehost_image_bytes, +) _DATA_URL_RE = re.compile(r"^data:([^;,]+)?(?:;base64)?,(.+)$", re.DOTALL) _TERMINAL_FAIL = frozenset({"failed", "cancelled", "timeout", "deleted"}) @@ -76,12 +78,11 @@ def _unwrap_data(payload: Any) -> dict[str, Any]: return payload -def _is_http_url(ref: str) -> bool: - try: - parsed = urlparse(ref) - except Exception: - return False - return parsed.scheme in ("http", "https") and bool(parsed.netloc) +def _outputs_list(task: dict[str, Any]) -> list[str] | None: + outputs = task.get("outputs") + if not isinstance(outputs, list) or not outputs: + return None + return [str(x) for x in outputs if str(x).strip()] async def _load_image_bytes(image_ref: str) -> tuple[bytes, str]: @@ -93,15 +94,15 @@ async def _load_image_bytes(image_ref: str) -> tuple[bytes, str]: if not match: raise ValueError("invalid data URL") return base64.b64decode(match.group(2)), "image/png" - if _is_http_url(ref): - async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=20.0)) as client: - resp = await client.get(ref) - resp.raise_for_status() - ctype = (resp.headers.get("content-type") or "image/png").split(";")[0].strip() - if not ctype.startswith("image/"): - ctype = "image/png" - return resp.content, ctype - raise ValueError("image must be a data URL or http(s) URL") + if not is_http_url(ref): + raise ValueError("image must be a data URL or http(s) URL") + async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=20.0)) as client: + resp = await client.get(ipv4_loopback_url(ref)) + resp.raise_for_status() + ctype = (resp.headers.get("content-type") or "image/png").split(";")[0].strip() + if not ctype.startswith("image/"): + ctype = "image/png" + return resp.content, ctype async def ensure_public_image_url( @@ -110,25 +111,23 @@ async def ensure_public_image_url( user_id: str | None, filename: str = "wavespeed-input.png", ) -> str: - """WaveSpeed fetches remote URLs; rehost data / private refs when needed.""" + """WaveSpeed fetches remote URLs; inline private / loopback refs as data URLs.""" ref = (image or "").strip() if not ref: raise ValueError("image is required") - if _is_http_url(ref): + if is_http_url(ref) and is_public_http_url(ref): return ref raw, ctype = await _load_image_bytes(ref) uid = str(user_id or "").strip() - if not uid: - # Fallback: WaveSpeed accepts some data URLs; prefer rehost in production. - b64 = base64.b64encode(raw).decode("ascii") - return f"data:{ctype};base64,{b64}" - ext = "png" if "png" in ctype else "jpg" - return rehost_image_bytes( - uid, - raw, - filename=filename if filename.endswith(ext) else f"wavespeed-input.{ext}", - content_type=ctype or "image/png", - ) + if uid: + ext = "png" if "png" in ctype else "jpg" + name = filename if filename.endswith(ext) else f"wavespeed-input.{ext}" + hosted = rehost_image_bytes( + uid, raw, filename=name, content_type=ctype or "image/png" + ) + if is_public_http_url(hosted): + return hosted + return bytes_to_data_url(raw, content_type=ctype) async def _download_output(url_or_b64: str) -> bytes: @@ -141,7 +140,7 @@ async def _download_output(url_or_b64: str) -> bytes: raise RuntimeError("invalid WaveSpeed data URL output") return base64.b64decode(match.group(2)) # Naked base64 (enable_base64_output) - if not _is_http_url(ref) and len(ref) > 64 and re.fullmatch(r"[A-Za-z0-9+/=\s]+", ref or ""): + if not is_http_url(ref) and len(ref) > 64 and re.fullmatch(r"[A-Za-z0-9+/=\s]+", ref): try: return base64.b64decode(ref) except Exception: @@ -178,17 +177,15 @@ async def submit_and_wait( task = _unwrap_data(submit_resp.json()) prediction_id = str(task.get("id") or "").strip() if not prediction_id: - # Sync mode may return outputs immediately. - outputs = task.get("outputs") - if isinstance(outputs, list) and outputs: - return [str(x) for x in outputs if str(x).strip()] + sync_out = _outputs_list(task) + if sync_out: + return sync_out raise RuntimeError("WaveSpeed submit response missing prediction id") - status = str(task.get("status") or "").strip().lower() - if status == "completed": - outputs = task.get("outputs") - if isinstance(outputs, list) and outputs: - return [str(x) for x in outputs if str(x).strip()] + if str(task.get("status") or "").strip().lower() == "completed": + done = _outputs_list(task) + if done: + return done result_url = f"{_base_url()}/api/v3/predictions/{prediction_id}/result" poll_n = 0 @@ -198,8 +195,7 @@ async def submit_and_wait( await asyncio.sleep(_POLL_SEC) poll_n += 1 if on_progress: - pct = min(90, 10 + poll_n * 5) - on_progress(pct, "wavespeed:poll") + on_progress(min(90, 10 + poll_n * 5), "wavespeed:poll") poll_resp = await client.get(result_url, headers=headers) if poll_resp.status_code >= 400: detail = (poll_resp.text or "")[:400] @@ -207,10 +203,10 @@ async def submit_and_wait( result = _unwrap_data(poll_resp.json()) status = str(result.get("status") or "").strip().lower() if status == "completed": - outputs = result.get("outputs") - if not isinstance(outputs, list) or not outputs: + done = _outputs_list(result) + if not done: raise RuntimeError("WaveSpeed completed with empty outputs") - return [str(x) for x in outputs if str(x).strip()] + return done if status in _TERMINAL_FAIL: err = str(result.get("error") or status) raise RuntimeError(f"WaveSpeed prediction {status}: {err}") diff --git a/apps/api/app/services/vision/rehost.py b/apps/api/app/services/vision/rehost.py index f764875a..521fe232 100644 --- a/apps/api/app/services/vision/rehost.py +++ b/apps/api/app/services/vision/rehost.py @@ -1,7 +1,121 @@ -"""Rehost vision tool outputs to our uploads store (avoid giant data-URL round-trips).""" +"""Vision image hosting + URL helpers for remote APIs (Seedream / WaveSpeed / MediaKit).""" from __future__ import annotations +import base64 +from urllib.parse import urlparse + +from app.core.config import settings + +_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "0.0.0.0", "::1"}) + + +def is_http_url(ref: str) -> bool: + try: + parsed = urlparse(ref) + except Exception: + return False + return parsed.scheme in ("http", "https") and bool(parsed.netloc) + + +def _host_is_private_lan(host: str) -> bool: + if host.startswith("10.") or host.startswith("192.168."): + return True + if not host.startswith("172."): + return False + parts = host.split(".") + if len(parts) < 2: + return False + try: + second = int(parts[1]) + except ValueError: + return False + return 16 <= second <= 31 + + +def is_public_http_url(ref: str) -> bool: + """True when a remote cloud API can fetch the URL itself.""" + if not is_http_url(ref): + return False + try: + host = (urlparse(ref).hostname or "").lower() + except Exception: + return False + if not host or host in _LOOPBACK_HOSTS: + return False + if host.endswith(".local") or host.endswith(".internal"): + return False + if _host_is_private_lan(host): + return False + return True + + +def ipv4_loopback_url(ref: str) -> str: + """Rewrite localhost / ::1 → 127.0.0.1 so local MinIO fetches work on Windows.""" + try: + parsed = urlparse(ref) + host = (parsed.hostname or "").lower() + if host not in {"localhost", "::1"}: + return ref + port = f":{parsed.port}" if parsed.port else "" + out = f"{parsed.scheme}://127.0.0.1{port}{parsed.path or ''}" + if parsed.query: + return f"{out}?{parsed.query}" + return out + except Exception: + return ref + + +def bytes_to_data_url(raw: bytes, *, content_type: str = "image/png") -> str: + ctype = (content_type or "image/png").split(";")[0].strip() or "image/png" + if not ctype.startswith("image/"): + ctype = "image/png" + return f"data:{ctype};base64,{base64.b64encode(raw).decode('ascii')}" + + +def storage_object_key(ref: str, *, local_base: str = "") -> str: + """Extract object key from a storage display URL.""" + src = (ref or "").strip() + base = (local_base or "").strip().rstrip("/") + if base and src.startswith(base + "/"): + return src[len(base) + 1 :].split("?", 1)[0].lstrip("/") + try: + path = urlparse(src).path or "" + except Exception: + return "" + # path-style MinIO: /{bucket}/{key…} + parts = [p for p in path.split("/") if p] + if len(parts) < 2: + return "" + return "/".join(parts[1:]) + + +def rewrite_private_storage_url(ref: str) -> str | None: + """ + Map local MinIO URL → public CDN (VISION_PUBLIC_BASE_URL). + + Example: + http://localhost:9000/recombyn/uploads/a.png + → https://files.recombyn.com/recombyn/uploads/a.png + """ + src = (ref or "").strip() + if not src or not is_http_url(src): + return None + if is_public_http_url(src): + return src + + local_base = str(settings.s3_public_base_url or "").strip().rstrip("/") + public_base = str(settings.vision_public_base_url or "").strip().rstrip("/") + if not public_base and local_base and is_public_http_url(local_base): + public_base = local_base + if not public_base or not is_public_http_url(public_base): + return None + + key = storage_object_key(src, local_base=local_base) + if not key: + return None + return f"{public_base}/{key}" + def rehost_image_bytes( user_id: str | None, @@ -28,3 +142,79 @@ def rehost_image_bytes( if not url: raise RuntimeError(f"rehost failed for {filename}") return url + + +def encode_or_rehost_image( + raw: bytes, + *, + user_id: str | None, + filename: str, + content_type: str = "image/png", +) -> str: + """Upload when ``user_id`` is set; otherwise return a data URL.""" + if str(user_id or "").strip(): + return rehost_image_bytes( + user_id, raw, filename=filename, content_type=content_type + ) + return bytes_to_data_url(raw, content_type=content_type) + + +def raster_filename_and_type( + fmt: str, + *, + stem: str, + index: int | None = None, +) -> tuple[str, str]: + """``(filename, content_type)`` for png/jpeg vision outputs.""" + use_png = str(fmt or "png").strip().lower() == "png" + suffix = f"-{index}" if index is not None else "" + if use_png: + return f"{stem}{suffix}.png", "image/png" + return f"{stem}{suffix}.jpg", "image/jpeg" + + +async def _download_image_bytes(ref: str) -> bytes: + """Fetch image bytes; rewrite localhost → 127.0.0.1 for Windows MinIO.""" + import httpx + + url = ipv4_loopback_url((ref or "").strip()) + async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=20.0)) as client: + resp = await client.get(url) + resp.raise_for_status() + return resp.content + + +async def ensure_remote_fetchable_image_ref(image: str) -> str: + """Public URL, CDN rewrite, or data URL — never localhost/LAN for cloud APIs. + + Same contract as Seedream layering: prefer ``VISION_PUBLIC_BASE_URL`` rewrite, + else download from local MinIO and inline as data URL. + """ + ref = (image or "").strip() + if not ref: + raise ValueError("image is required") + if ref.startswith("data:"): + return ref + if not is_http_url(ref): + raise ValueError("image must be a data URL or http(s) URL") + if is_public_http_url(ref): + return ref + rewritten = rewrite_private_storage_url(ref) + if rewritten: + return rewritten + return bytes_to_data_url(await _download_image_bytes(ref)) + + +async def ensure_remote_fetchable_image_refs( + images: list[str] | None, +) -> list[str]: + """Apply ``ensure_remote_fetchable_image_ref`` to each non-empty image URL.""" + out: list[str] = [] + for raw in images or []: + if not isinstance(raw, str): + continue + s = raw.strip() + if not s: + continue + out.append(await ensure_remote_fetchable_image_ref(s)) + return out diff --git a/apps/api/app/services/vision/remove_bg.py b/apps/api/app/services/vision/remove_bg.py index 1bde1c0e..62da0049 100644 --- a/apps/api/app/services/vision/remove_bg.py +++ b/apps/api/app/services/vision/remove_bg.py @@ -29,18 +29,13 @@ async def remove_background( Cut out the main subject via MediaKit ``remove-image-background``. Keeps whatever canvas MediaKit returns (PNG with transparency by default). - Brush hint masks from older ILP flows are ignored — MediaKit has no mask input. Returns ``{ image, kind, engine, model, mode, width, height, scene? }``. """ if not mediakit_enabled(): raise RuntimeError(_MEDIAKIT_REQUIRED_MSG) - m = meta or {} - if m.get("includeMask") or m.get("excludeMask"): - logger.info("removeBg: MediaKit ignores includeMask/excludeMask brush hints") - - out = await remove_image_background(image, meta=m) + out = await remove_image_background(image, meta=meta or {}) png_bytes = out["image_bytes"] rgba = Image.open(io.BytesIO(png_bytes)).convert("RGBA") image_out = rehost_image_bytes(user_id, png_bytes, filename="removeBg.png") diff --git a/apps/api/app/services/vision/smart_erase.py b/apps/api/app/services/vision/smart_erase.py index 73845631..54014a40 100644 --- a/apps/api/app/services/vision/smart_erase.py +++ b/apps/api/app/services/vision/smart_erase.py @@ -11,7 +11,7 @@ from PIL import Image from app.services.vision.mediakit_client import erase_image, mediakit_enabled -from app.services.vision.rehost import rehost_image_bytes +from app.services.vision.rehost import encode_or_rehost_image, raster_filename_and_type logger = logging.getLogger(__name__) @@ -22,11 +22,6 @@ _DATA_URL_RE = re.compile(r"^data:[^;,]+(?:;base64)?,(.+)$", re.DOTALL) -def _png_data_url_from_bytes(png_bytes: bytes) -> str: - b64 = base64.b64encode(png_bytes).decode("ascii") - return f"data:image/png;base64,{b64}" - - def _decode_data_url_mask(raw: str) -> bytes: ref = (raw or "").strip() if not ref: @@ -57,6 +52,15 @@ def mask_to_mediakit_bw_png(mask_bytes: bytes) -> bytes: return buf.getvalue() +def _png_bytes_for_canvas(raw: bytes, img: Image.Image, fmt: str) -> bytes: + """Prefer PNG for canvas round-trip when no upload user.""" + if img.mode == "RGBA" or str(fmt or "").lower() == "png": + return raw + buf = io.BytesIO() + img.convert("RGB").save(buf, format="PNG") + return buf.getvalue() + + async def smart_erase( image: str, *, @@ -66,7 +70,7 @@ async def smart_erase( """ Erase painted regions via MediaKit ``selected_area_erase`` + mask. - Also accepts auto scenes when meta sets ``standard_scene`` without a brush mask + Also accepts auto scenes when meta sets ``standardScene`` without a brush mask (e.g. full_screen_text_erase / full_screen_icon_erase). Returns ``{ image, kind, engine, mode, width, height }``. @@ -75,38 +79,32 @@ async def smart_erase( raise RuntimeError(_MEDIAKIT_REQUIRED_MSG) m = dict(meta or {}) - scene = str(m.get("standardScene") or m.get("standard_scene") or "").strip() - mask_raw = str(m.get("eraseMask") or m.get("excludeMask") or "").strip() + scene = str(m.get("standardScene") or "").strip() + mask_raw = str(m.get("eraseMask") or "").strip() mask_png: bytes | None = None if mask_raw: mask_png = mask_to_mediakit_bw_png(_decode_data_url_mask(mask_raw)) scene = "selected_area_erase" - m["standard_scene"] = scene + m["standardScene"] = scene elif not scene: raise ValueError("请先在图片上涂抹要擦除的区域") - m.setdefault("output_format", "png") + m.setdefault("outputFormat", "png") out = await erase_image(image, meta=m, mask_bytes=mask_png) raw = out["image_bytes"] img = Image.open(io.BytesIO(raw)) width = int(out.get("width") or img.width) height = int(out.get("height") or img.height) - - if user_id: - fmt = str(out.get("format") or "png").lower() - filename = "eraser.png" if fmt == "png" else "eraser.jpg" - content_type = "image/png" if fmt == "png" else "image/jpeg" - image_out = rehost_image_bytes( - user_id, raw, filename=filename, content_type=content_type - ) - else: - # Prefer PNG data URL for canvas round-trip when no upload user. - if img.mode != "RGBA" and str(out.get("format") or "").lower() != "png": - buf = io.BytesIO() - img.convert("RGB").save(buf, format="PNG") - raw = buf.getvalue() - image_out = _png_data_url_from_bytes(raw) + fmt = str(out.get("format") or "png").lower() + + if not user_id: + raw = _png_bytes_for_canvas(raw, img, fmt) + fmt = "png" + filename, content_type = raster_filename_and_type(fmt, stem="eraser") + image_out = encode_or_rehost_image( + raw, user_id=user_id, filename=filename, content_type=content_type + ) return { "image": image_out, diff --git a/apps/api/app/services/vision/translate_image.py b/apps/api/app/services/vision/translate_image.py index 378386f0..56884a62 100644 --- a/apps/api/app/services/vision/translate_image.py +++ b/apps/api/app/services/vision/translate_image.py @@ -2,7 +2,6 @@ from __future__ import annotations -import base64 import io import logging from typing import Any @@ -10,7 +9,7 @@ from PIL import Image from app.services.vision.mediakit_client import mediakit_enabled, translate_image_text -from app.services.vision.rehost import rehost_image_bytes +from app.services.vision.rehost import encode_or_rehost_image, raster_filename_and_type logger = logging.getLogger(__name__) @@ -20,6 +19,20 @@ ) +def _payload_for_output(raw: bytes, *, fmt: str, user_id: str | None) -> tuple[bytes, str, str]: + """Return ``(bytes, filename, content_type)``.""" + filename, content_type = raster_filename_and_type(fmt, stem="translate") + if user_id: + return raw, filename, content_type + img = Image.open(io.BytesIO(raw)) + buf = io.BytesIO() + if content_type == "image/png": + img.convert("RGBA" if "A" in img.getbands() else "RGB").save(buf, format="PNG") + else: + img.convert("RGB").save(buf, format="JPEG", quality=92) + return buf.getvalue(), filename, content_type + + async def translate_image( image: str, *, @@ -46,22 +59,10 @@ async def translate_image( version = str(out.get("tool_version") or "seed-translation") target = str(out.get("target_lang") or "zh") - if user_id: - filename = "translate.png" if fmt == "png" else "translate.jpg" - content_type = "image/png" if fmt == "png" else "image/jpeg" - image_out = rehost_image_bytes( - user_id, raw, filename=filename, content_type=content_type - ) - else: - buf = io.BytesIO() - if fmt == "png": - img.convert("RGBA" if "A" in img.getbands() else "RGB").save(buf, format="PNG") - ctype = "image/png" - else: - img.convert("RGB").save(buf, format="JPEG", quality=92) - ctype = "image/jpeg" - b64 = base64.b64encode(buf.getvalue()).decode("ascii") - image_out = f"data:{ctype};base64,{b64}" + payload, filename, content_type = _payload_for_output(raw, fmt=fmt, user_id=user_id) + image_out = encode_or_rehost_image( + payload, user_id=user_id, filename=filename, content_type=content_type + ) return { "image": image_out, diff --git a/apps/api/app/services/vision/upscale.py b/apps/api/app/services/vision/upscale.py index 414992b9..c515fa46 100644 --- a/apps/api/app/services/vision/upscale.py +++ b/apps/api/app/services/vision/upscale.py @@ -9,7 +9,7 @@ from PIL import Image from app.services.vision.mediakit_client import enhance_image, mediakit_enabled -from app.services.vision.rehost import rehost_image_bytes +from app.services.vision.rehost import encode_or_rehost_image, raster_filename_and_type logger = logging.getLogger(__name__) @@ -48,25 +48,21 @@ async def upscale_image( height = int(out.get("height") or img.height) fmt = str(out.get("format") or "png").lower() version = str(out.get("tool_version") or "professional") + filename, content_type = raster_filename_and_type(fmt, stem="upscale") if user_id: - filename = "upscale.png" if fmt == "png" else "upscale.jpg" - content_type = "image/png" if fmt == "png" else "image/jpeg" - image_out = rehost_image_bytes( - user_id, raw, filename=filename, content_type=content_type - ) + payload = raw else: buf = io.BytesIO() - if fmt == "png": + if content_type == "image/png": img.convert("RGBA" if "A" in img.getbands() else "RGB").save(buf, format="PNG") - ctype = "image/png" else: img.convert("RGB").save(buf, format="JPEG", quality=92) - ctype = "image/jpeg" - import base64 + payload = buf.getvalue() - b64 = base64.b64encode(buf.getvalue()).decode("ascii") - image_out = f"data:{ctype};base64,{b64}" + image_out = encode_or_rehost_image( + payload, user_id=user_id, filename=filename, content_type=content_type + ) return { "image": image_out, diff --git a/apps/api/app/services/wallet/db.py b/apps/api/app/services/wallet/db.py index f2b1c780..45cfa3dc 100644 --- a/apps/api/app/services/wallet/db.py +++ b/apps/api/app/services/wallet/db.py @@ -67,6 +67,9 @@ def ensure_credits_scale_x10_migration() -> None: "FREE_DAILY_LIMIT", "free_daily_remaining", "consume_free_daily_quota", + "FREE_VISION_LIMIT", + "free_vision_remaining", + "consume_free_vision_quota", ] @@ -330,6 +333,80 @@ def consume_free_daily_quota(user_id: str, *, limit: int = FREE_DAILY_LIMIT) -> return True +# Free users: lifetime cap on zero-credit vision toolbar tools (抠图 / 放大 / …). +FREE_VISION_LIMIT = 3 +_FREE_VISION_PREFIX = "free_vision:" + + +def free_vision_remaining(user_id: str, *, limit: int = FREE_VISION_LIMIT) -> int: + """How many free vision tool uses remain (does not consume).""" + from sqlmodel import Session + + from app import crud + from app.core.db import engine + + init_schema() + uid = (user_id or "").strip() + lim = max(0, int(limit if limit is not None else FREE_VISION_LIMIT)) + if not uid or lim <= 0: + return 0 + with Session(engine) as session: + used = crud.count_wallet_ledger_detail_prefix( + session=session, user_id=uid, detail_prefix=_FREE_VISION_PREFIX + ) + return max(0, lim - used) + + +def consume_free_vision_quota(user_id: str, *, limit: int = FREE_VISION_LIMIT) -> bool: + """ + Atomically reserve one free vision tool use for plan=free. + Returns True if reserved; False if the lifetime quota is already used. + Writes a zero-amount ledger marker (does not change balance). + """ + from sqlmodel import Session + + from app import crud + from app.core.db import engine + from app.models import UserBalance + + init_schema() + uid = (user_id or "").strip() + lim = max(0, int(limit if limit is not None else FREE_VISION_LIMIT)) + if not uid or lim <= 0: + return False + import time + + now = time.time() + with Session(engine) as session: + used = crud.count_wallet_ledger_detail_prefix( + session=session, user_id=uid, detail_prefix=_FREE_VISION_PREFIX + ) + if used >= lim: + return False + bal = crud.get_user_balance(session=session, user_id=uid) + if not bal: + bal = UserBalance( + user_id=uid, + credits=0, + plan_id="free", + plan_expires_at=None, + updated_at=now, + ) + session.add(bal) + session.flush() + crud.add_wallet_ledger( + session=session, + user_id=uid, + kind="spend", + amount=0, + balance_after=int(bal.credits or 0), + detail=f"{_FREE_VISION_PREFIX}use", + commit=False, + ) + session.commit() + return True + + def spend_credits( user_id: str, amount: int, diff --git a/apps/api/tests/design_engine/test_intelligence_client.py b/apps/api/tests/design_engine/test_intelligence_client.py index 451f7ad5..835e6af1 100644 --- a/apps/api/tests/design_engine/test_intelligence_client.py +++ b/apps/api/tests/design_engine/test_intelligence_client.py @@ -47,22 +47,20 @@ def test_basic_local_is_intelligence_provider(): assert isinstance(BasicLocalProvider(), IntelligenceProvider) -def test_factory_local_uses_basic_local(monkeypatch): +def test_factory_local_uses_basic_local(): from app.services.design import intelligence_runtime as ir - monkeypatch.setattr(ir.settings, "intelligence_provider", "local") ir.reset_design_intelligence_client() client = ir.build_design_intelligence_client() assert isinstance(client, DesignIntelligenceClient) assert isinstance(client.provider, BasicLocalProvider) -def test_client_research_via_basic_local(monkeypatch): +def test_client_research_via_basic_local(): import asyncio from app.services.design import intelligence_runtime as ir - monkeypatch.setattr(ir.settings, "intelligence_provider", "local") ir.reset_design_intelligence_client() client = ir.build_design_intelligence_client() rt = _rt() @@ -95,12 +93,11 @@ def test_remote_empty_dict_not_usable(): assert remote_result_usable("research", {"summary": "ok", "provider": "x"}) -def test_stable_client_surface_uses_canonical_methods(monkeypatch): +def test_stable_client_surface_uses_canonical_methods(): import asyncio from app.services.design import intelligence_runtime as ir - monkeypatch.setattr(ir.settings, "intelligence_provider", "local") ir.reset_design_intelligence_client() client = ir.build_design_intelligence_client() rt = _rt() @@ -115,259 +112,3 @@ async def _run(): assert isinstance(gov, dict) assert gov.get("status") in ("pass", "fail") _ = proposed - - -def test_remote_provider_falls_back_on_empty_post(monkeypatch): - import asyncio - - from app.services.design.intelligence_runtime import ( - BasicLocalProvider, - RemoteIntelligenceProvider, - ) - - remote = RemoteIntelligenceProvider(base_url="http://example.invalid", fallback=BasicLocalProvider()) - - async def _empty(_method: str, _rt: object): - return {} - - monkeypatch.setattr(remote, "_post", _empty) - rt = _rt() - - async def _run(): - return await remote.research(rt) - - result = asyncio.run(_run()) - assert result is not None or getattr(rt, "design_research", None) is not None - - -def test_remote_provider_applies_usable_research(monkeypatch): - import asyncio - - from app.services.design.intelligence_runtime import ( - BasicLocalProvider, - RemoteIntelligenceProvider, - apply_intelligence_result, - ) - - remote = RemoteIntelligenceProvider( - base_url="http://example.invalid", - fallback=BasicLocalProvider(), - apply_result=apply_intelligence_result, - ) - payload = { - "category": "ai_landing", - "common_patterns": ["purple-blue gradient"], - "avoid": ["purple gradient"], - "anti_category_strategy": [ - "avoid: purple gradient", - "adopt: editorial typography", - ], - "why_effective": ["Category clichés erase differentiation."], - "summary": "category=ai_landing", - "provider": "private-research", - } - - async def _ok(_method: str, _rt: object): - return payload - - monkeypatch.setattr(remote, "_post", _ok) - rt = _rt() - - async def _run(): - return await remote.research(rt) - - result = asyncio.run(_run()) - assert result is not None - assert rt.design_research is not None - assert rt.design_research.get("category") == "ai_landing" - assert rt.design_research.get("provider") == "private-research" - assert "tool_ops" not in (rt.design_research or {}) - - -def test_remote_applies_advanced_hooks(monkeypatch): - import asyncio - - from app.services.design.intelligence_runtime import ( - BasicLocalProvider, - RemoteIntelligenceProvider, - apply_intelligence_result, - ) - - remote = RemoteIntelligenceProvider( - base_url="http://example.invalid", - fallback=BasicLocalProvider(), - apply_result=apply_intelligence_result, - ) - - async def _post(method: str, _rt: object): - if method == "retrieve_memory": - return { - "notes": ["preference:premium_restraint"], - "summary": "memory", - "provider": "private-memory", - } - if method == "review": - return { - "status": "pass", - "score": 90, - "issues": [], - "summary": "ok", - "provider": "private-review", - } - if method == "optimize": - return { - "actions": ["raise CTA"], - "applied": False, - "summary": "opt", - "provider": "private-optimize", - } - if method == "write_principle": - return { - "principles": ["thesis:x"], - "written": True, - "summary": "wrote", - "provider": "private-principle", - } - return {} - - monkeypatch.setattr(remote, "_post", _post) - rt = _rt() - - async def _run(): - await remote.retrieve_memory(rt) - await remote.review(rt) - await remote.optimize(rt) - await remote.write_principle(rt) - - asyncio.run(_run()) - assert "preference:premium_restraint" in (rt.flags.get("memory_notes") or []) - assert (rt.judge_verdict or {}).get("score") == 90 - assert (rt.optimization or {}).get("actions") - assert rt.flags.get("knowledge_written") is True - - -class _StubResearch: - async def research(self, _rt: object): - return {"summary": "stub-local", "provider": "stub"} - - -def test_remote_builds_request_once_per_call(monkeypatch): - import asyncio - - from app.services.design.intelligence_runtime import RemoteIntelligenceProvider - - builds: list[str] = [] - remote = RemoteIntelligenceProvider(base_url="http://example.invalid") - - def _count(method: str, rt: object): - builds.append(method) - from recombyn_runtime.intelligence import build_intelligence_request as real - - return real(method, rt) - - monkeypatch.setattr( - "recombyn_intelligence_client.remote.build_intelligence_request", - _count, - ) - - async def _ok(_method: str, _payload: object): - return {"summary": "ok", "provider": "remote"} - - monkeypatch.setattr(remote, "_post", _ok) - - async def _run(): - return await remote.research(_rt()) - - result = asyncio.run(_run()) - assert result is not None - assert builds == ["research"] - - -def test_circuit_skips_http_after_timeout(monkeypatch): - import asyncio - import httpx - - from app.services.design.intelligence_runtime import RemoteIntelligenceProvider - - posts: list[str] = [] - - class _FailClient: - is_closed = False - - async def post(self, url: str, json=None, headers=None): - posts.append(url) - raise httpx.TimeoutException("timeout") - - fail_client = _FailClient() - remote = RemoteIntelligenceProvider( - base_url="http://example.invalid", - timeout_sec=8.0, - circuit_sec=30.0, - fallback=_StubResearch(), - ) - - async def _http(): - return fail_client - - monkeypatch.setattr(remote, "_http_client", _http) - - async def _run(): - first = await remote.research(_rt()) - second = await remote.research(_rt()) - return first, second - - first, second = asyncio.run(_run()) - assert first == {"summary": "stub-local", "provider": "stub"} - assert second == {"summary": "stub-local", "provider": "stub"} - assert len(posts) == 1 - assert remote._circuit_open() is True - - -def test_hop_cache_skips_http_on_resume(monkeypatch): - import asyncio - - from app.services.design.intelligence_runtime import RemoteIntelligenceProvider - - store: dict[tuple[str, str, str], dict] = {} - posts: list[str] = [] - - def hop_get(key: tuple[str, str, str]): - return store.get(key) - - def hop_put(key: tuple[str, str, str], payload: dict): - store[key] = payload - - async def _ok(method: str, _payload: object): - posts.append(method) - return {"summary": "cached", "provider": "remote"} - - first = RemoteIntelligenceProvider( - base_url="http://example.invalid", - hop_get=hop_get, - hop_put=hop_put, - ) - monkeypatch.setattr(first, "_post", _ok) - - async def _run_first(): - return await first.research(_rt()) - - assert asyncio.run(_run_first())["summary"] == "cached" - assert posts == ["research"] - - resumed = RemoteIntelligenceProvider( - base_url="http://example.invalid", - hop_get=hop_get, - hop_put=hop_put, - ) - - async def _forbidden(_method: str, _payload: object): - posts.append("should-not-post") - return {"summary": "fresh", "provider": "remote"} - - monkeypatch.setattr(resumed, "_post", _forbidden) - - async def _run_resume(): - return await resumed.research(_rt()) - - assert asyncio.run(_run_resume())["summary"] == "cached" - assert posts == ["research"] diff --git a/apps/api/tests/integration_tests/test_design_canvas_crud.py b/apps/api/tests/integration_tests/test_design_canvas_crud.py index e7986a73..694584df 100644 --- a/apps/api/tests/integration_tests/test_design_canvas_crud.py +++ b/apps/api/tests/integration_tests/test_design_canvas_crud.py @@ -98,10 +98,6 @@ def _wallet_and_fast_observe(monkeypatch): "app.services.design.runtime.graph.nodes.observe._SCENE_WAIT_SEC", 0.05, ) - monkeypatch.setattr( - "app.core.config.settings.intelligence_provider", - "local", - ) monkeypatch.setattr( "app.services.design.runtime.graph.nodes.decide.intelligence_task_profile", lambda _rt: IntelligenceTaskProfile("direct", (), (), False, False), diff --git a/apps/api/tests/integration_tests/test_design_golden_paths.py b/apps/api/tests/integration_tests/test_design_golden_paths.py index 5b87aecd..205275c4 100644 --- a/apps/api/tests/integration_tests/test_design_golden_paths.py +++ b/apps/api/tests/integration_tests/test_design_golden_paths.py @@ -55,10 +55,6 @@ def _wallet(monkeypatch): "app.services.design.runtime.orchestrator._refund_hold", lambda *_a, **_k: None, ) - monkeypatch.setattr( - "app.core.config.settings.intelligence_provider", - "local", - ) monkeypatch.setattr( "app.services.design.runtime.graph.nodes.decide.intelligence_task_profile", lambda _rt: IntelligenceTaskProfile("direct", (), (), False, False), diff --git a/apps/api/tests/unit_tests/test_edit_text_mediakit.py b/apps/api/tests/unit_tests/test_edit_text_mediakit.py index 9d8fca50..c5c6056a 100644 --- a/apps/api/tests/unit_tests/test_edit_text_mediakit.py +++ b/apps/api/tests/unit_tests/test_edit_text_mediakit.py @@ -97,7 +97,7 @@ def fake_client(*args, **kwargs): monkeypatch.setattr(mk.httpx, "AsyncClient", fake_client) data_url = f"data:image/png;base64,{base64.b64encode(png).decode('ascii')}" - out = asyncio.run(mk.image_ocr(data_url, meta={"tool_version": "max"})) + out = asyncio.run(mk.image_ocr(data_url, meta={"toolVersion": "max"})) assert len(out["blocks"]) == 1 assert out["blocks"][0]["text"] == "AI" assert out["tool_version"] == "max" @@ -127,7 +127,7 @@ async def fake_ocr(_image, *, meta=None, resolved_url=None): "request_id": "r1", } - async def fake_erase(_image, *, meta=None, resolved_url=None): + async def fake_erase(_image, *, meta=None, mask_bytes=None): return { "image_bytes": png, "image_url": "https://output.test/bg.png", diff --git a/apps/api/tests/unit_tests/test_free_vision_quota.py b/apps/api/tests/unit_tests/test_free_vision_quota.py new file mode 100644 index 00000000..a544e157 --- /dev/null +++ b/apps/api/tests/unit_tests/test_free_vision_quota.py @@ -0,0 +1,63 @@ +"""Free-plan lifetime vision toolbar quota (3 uses).""" + +from __future__ import annotations + +import pytest +from fastapi import HTTPException + + +def test_require_free_vision_allows_when_cost_positive(monkeypatch): + from app.api.routes import image_tools as mod + + monkeypatch.setattr(mod, "is_wallet_billing_enabled", lambda: True) + monkeypatch.setattr(mod, "get_user_plan", lambda _uid: "free") + called = {"n": 0} + + def _consume(_uid: str) -> bool: + called["n"] += 1 + return True + + monkeypatch.setattr(mod, "consume_free_vision_quota", _consume) + mod._require_free_vision_quota("u1", cost=30, locale="en") + assert called["n"] == 0 + + +def test_require_free_vision_skips_paid_plan(monkeypatch): + from app.api.routes import image_tools as mod + + monkeypatch.setattr(mod, "is_wallet_billing_enabled", lambda: True) + monkeypatch.setattr(mod, "get_user_plan", lambda _uid: "pro") + called = {"n": 0} + + def _consume(_uid: str) -> bool: + called["n"] += 1 + return True + + monkeypatch.setattr(mod, "consume_free_vision_quota", _consume) + mod._require_free_vision_quota("u1", cost=0, locale="en") + assert called["n"] == 0 + + +def test_require_free_vision_exhausted_raises_402(monkeypatch): + from app.api.routes import image_tools as mod + + monkeypatch.setattr(mod, "is_wallet_billing_enabled", lambda: True) + monkeypatch.setattr(mod, "get_user_plan", lambda _uid: "free") + monkeypatch.setattr(mod, "consume_free_vision_quota", lambda _uid: False) + + with pytest.raises(HTTPException) as ei: + mod._require_free_vision_quota("u1", cost=0, locale="zh-CN") + assert ei.value.status_code == 402 + detail = ei.value.detail + assert isinstance(detail, dict) + assert detail.get("code") == "free_vision_exhausted" + assert "3" in str(detail.get("message") or "") + + +def test_require_free_vision_consumes_when_available(monkeypatch): + from app.api.routes import image_tools as mod + + monkeypatch.setattr(mod, "is_wallet_billing_enabled", lambda: True) + monkeypatch.setattr(mod, "get_user_plan", lambda _uid: "free") + monkeypatch.setattr(mod, "consume_free_vision_quota", lambda _uid: True) + mod._require_free_vision_quota("u1", cost=0, locale="en") diff --git a/apps/api/tests/unit_tests/test_mediakit_eraser.py b/apps/api/tests/unit_tests/test_mediakit_eraser.py index 6618272e..881fcaf8 100644 --- a/apps/api/tests/unit_tests/test_mediakit_eraser.py +++ b/apps/api/tests/unit_tests/test_mediakit_eraser.py @@ -49,8 +49,8 @@ def test_smart_erase_selected_area(monkeypatch): monkeypatch.setattr("app.services.vision.smart_erase.mediakit_enabled", lambda: True) png = _rgb_png_bytes() - async def fake_erase(image, *, meta=None, resolved_url=None, mask_bytes=None): - assert meta["standard_scene"] == "selected_area_erase" + async def fake_erase(image, *, meta=None, mask_bytes=None): + assert meta["standardScene"] == "selected_area_erase" assert mask_bytes and len(mask_bytes) > 8 return { "image_bytes": png, @@ -65,8 +65,8 @@ async def fake_erase(image, *, meta=None, resolved_url=None, mask_bytes=None): monkeypatch.setattr("app.services.vision.smart_erase.erase_image", fake_erase) monkeypatch.setattr( - "app.services.vision.smart_erase.rehost_image_bytes", - lambda _uid, data, **kwargs: "https://cdn.example/eraser.png", + "app.services.vision.smart_erase.encode_or_rehost_image", + lambda data, **kwargs: "https://cdn.example/eraser.png", ) result = asyncio.run( smart_erase( @@ -81,22 +81,42 @@ async def fake_erase(image, *, meta=None, resolved_url=None, mask_bytes=None): assert result["image"] == "https://cdn.example/eraser.png" +def test_mediakit_target_size_snaps_odd_dims(): + # User failure: 1097x1463 → MediaKit 800012 resolution not supported. + tw, th = mk.mediakit_target_size(1097, 1463) + assert tw % 8 == 0 and th % 8 == 0 + assert tw == 1096 and th == 1464 + + +def test_fit_raster_for_mediakit_odd_png(): + buf = io.BytesIO() + Image.new("RGB", (1097, 1463), color=(1, 2, 3)).save(buf, format="PNG") + fitted, orig, out = mk.fit_raster_for_mediakit(buf.getvalue()) + assert orig == (1097, 1463) + assert out == (1096, 1464) + img = Image.open(io.BytesIO(fitted)) + assert img.size == (1096, 1464) + + def test_erase_image_selected_area_uploads_mask(monkeypatch): monkeypatch.setattr(mk.settings, "mediakit_api_key", "amk-test-key") monkeypatch.setattr(mk.settings, "mediakit_base_url", "https://mediakit.test") png = _rgb_png_bytes() mask = mask_to_mediakit_bw_png(base64.b64decode(_mask_data_url().split(",", 1)[1])) bodies: list[bytes] = [] + uploads = 0 def handler(request: httpx.Request) -> httpx.Response: + nonlocal uploads path = request.url.path if path.endswith("/request-media-upload-url"): + uploads += 1 return httpx.Response( 200, json={ "success": True, "result": { - "file_id": "mediakit://up-1", + "file_id": f"mediakit://up-{uploads}", "method": "PUT", "upload_url": "https://upload.test/put", "upload_headers": [], @@ -135,10 +155,12 @@ def fake_client(*args, **kwargs): out = asyncio.run( mk.erase_image( data_url, - meta={"standard_scene": "selected_area_erase", "output_format": "png"}, + meta={"standardScene": "selected_area_erase", "outputFormat": "png"}, mask_bytes=mask, ) ) assert out["width"] == 8 + assert out["height"] == 8 assert out["scene"] == "selected_area_erase" assert any(b"selected_area_erase" in b and b"mask_url" in b for b in bodies) + assert uploads >= 2 # source + mask diff --git a/apps/api/tests/unit_tests/test_mediakit_expand.py b/apps/api/tests/unit_tests/test_mediakit_expand.py index d5d5b5c3..0789597c 100644 --- a/apps/api/tests/unit_tests/test_mediakit_expand.py +++ b/apps/api/tests/unit_tests/test_mediakit_expand.py @@ -40,7 +40,7 @@ def test_expand_ratios_from_pads(): def test_expand_ratios_direct(): left, right, top, bottom = mk.expand_ratios_from_meta( - {"expand_left": 0.15, "expand_right": 0.1, "expand_top": 0, "expand_bottom": 0.05} + {"expandLeft": 0.15, "expandRight": 0.1, "expandTop": 0, "expandBottom": 0.05} ) assert (left, right, top, bottom) == (0.15, 0.1, 0.0, 0.05) @@ -159,7 +159,7 @@ async def fake_expand(_image, *, meta=None): lambda _uid, data, **kwargs: "https://cdn.example/expand.jpg", ) result = asyncio.run( - expand_canvas("data:image/png;base64,xx", meta={"expand_left": 0.1}, user_id="u1") + expand_canvas("data:image/png;base64,xx", meta={"expandLeft": 0.1}, user_id="u1") ) assert result["kind"] == "expand" assert result["engine"] == "mediakit:expand-image-canvas" diff --git a/apps/api/tests/unit_tests/test_mediakit_product_scene.py b/apps/api/tests/unit_tests/test_mediakit_product_scene.py index c3f0ed58..1e67f0cc 100644 --- a/apps/api/tests/unit_tests/test_mediakit_product_scene.py +++ b/apps/api/tests/unit_tests/test_mediakit_product_scene.py @@ -55,7 +55,7 @@ def test_product_scene_params_professional(): def test_product_scene_params_professional_requires_ref(): with pytest.raises(ValueError, match="professionalReferenceImageUrl"): mk.product_scene_params_from_meta( - {"tool_version": "professional", "prompt": "x"} + {"toolVersion": "professional", "prompt": "x"} ) @@ -103,13 +103,12 @@ async def fake_gen(image, *, meta=None): ) calls: list[str] = [] - def fake_rehost(_uid, data, **kwargs): - name = str(kwargs.get("filename") or "") - calls.append(name) - return f"https://cdn.example/{name}" + def fake_rehost(data, *, user_id=None, filename="", content_type="image/png"): + calls.append(str(filename or "")) + return f"https://cdn.example/{filename}" monkeypatch.setattr( - "app.services.vision.product_scene.rehost_image_bytes", fake_rehost + "app.services.vision.product_scene.encode_or_rehost_image", fake_rehost ) result = asyncio.run( product_scene( diff --git a/apps/api/tests/unit_tests/test_mediakit_remove_bg.py b/apps/api/tests/unit_tests/test_mediakit_remove_bg.py index dcd9e948..ad9f928f 100644 --- a/apps/api/tests/unit_tests/test_mediakit_remove_bg.py +++ b/apps/api/tests/unit_tests/test_mediakit_remove_bg.py @@ -41,7 +41,7 @@ def test_mediakit_supports_when_key_set(monkeypatch): def test_scene_from_meta_maps_portrait_to_human(): assert mk._scene_from_meta({"scene": "portrait"}) == "human" - assert mk._scene_from_meta({"cutoutScene": "product"}) == "product" + assert mk._scene_from_meta({"scene": "product"}) == "product" assert mk._scene_from_meta({"scene": "nope"}) == "general" diff --git a/apps/api/tests/unit_tests/test_mediakit_translate.py b/apps/api/tests/unit_tests/test_mediakit_translate.py index 00147e01..5930d2d9 100644 --- a/apps/api/tests/unit_tests/test_mediakit_translate.py +++ b/apps/api/tests/unit_tests/test_mediakit_translate.py @@ -42,7 +42,7 @@ def test_translate_params_aliases(): def test_translate_params_invalid_version_falls_back(): - body = mk.translate_params_from_meta({"tool_version": "nope", "target_lang": "ja"}) + body = mk.translate_params_from_meta({"toolVersion": "nope", "targetLang": "ja"}) assert body["tool_version"] == "seed-translation" assert body["target_lang"] == "ja" @@ -80,8 +80,10 @@ async def fake_translate(image, *, meta=None): "app.services.vision.translate_image.translate_image_text", fake_translate ) monkeypatch.setattr( - "app.services.vision.translate_image.rehost_image_bytes", - lambda _uid, data, **kwargs: "https://cdn.example/translate.png", + "app.services.vision.translate_image.encode_or_rehost_image", + lambda data, *, user_id=None, filename="", content_type="image/png": ( + "https://cdn.example/translate.png" + ), ) result = asyncio.run( translate_image( diff --git a/apps/api/tests/unit_tests/test_mediakit_upscale.py b/apps/api/tests/unit_tests/test_mediakit_upscale.py index 6979d34e..ca1d314c 100644 --- a/apps/api/tests/unit_tests/test_mediakit_upscale.py +++ b/apps/api/tests/unit_tests/test_mediakit_upscale.py @@ -39,7 +39,7 @@ def test_enhance_params_multiple_wins_over_targets(): def test_enhance_params_target_width_only(): - body = mk.enhance_params_from_meta({"target_width": 1920, "tool_version": "standard"}) + body = mk.enhance_params_from_meta({"targetWidth": 1920, "toolVersion": "standard"}) assert body["tool_version"] == "standard" assert body["target_width"] == 1920 assert "generative_enhance_mode" not in body @@ -70,8 +70,10 @@ async def fake_enhance(image, *, meta=None, resolution=None): monkeypatch.setattr("app.services.vision.upscale.enhance_image", fake_enhance) monkeypatch.setattr( - "app.services.vision.upscale.rehost_image_bytes", - lambda _uid, data, **kwargs: "https://cdn.example/upscale.png", + "app.services.vision.upscale.encode_or_rehost_image", + lambda data, *, user_id=None, filename="", content_type="image/png": ( + "https://cdn.example/upscale.png" + ), ) result = asyncio.run( upscale_image( diff --git a/apps/api/tests/unit_tests/test_remove_bg_ilp.py b/apps/api/tests/unit_tests/test_remove_bg_ilp.py index 93afeaa9..88cff9d3 100644 --- a/apps/api/tests/unit_tests/test_remove_bg_ilp.py +++ b/apps/api/tests/unit_tests/test_remove_bg_ilp.py @@ -87,7 +87,7 @@ async def fake_mk(_image, *, meta=None): result = asyncio.run( remove_background( _tiny_png(), - meta={"scene": "product", "includeMask": "data:image/png;base64,xx"}, + meta={"scene": "product"}, user_id="u1", ) ) diff --git a/apps/api/tests/unit_tests/test_vision_image_refs.py b/apps/api/tests/unit_tests/test_vision_image_refs.py new file mode 100644 index 00000000..c3f5a865 --- /dev/null +++ b/apps/api/tests/unit_tests/test_vision_image_refs.py @@ -0,0 +1,121 @@ +"""Seedream / WaveSpeed image refs must not send localhost to remote APIs.""" + +from __future__ import annotations + +import asyncio +import base64 + +import pytest + + +def test_seedream_public_url_passed_through(): + from app.services.vision.providers.seedream import _ensure_image_ref + + out = asyncio.run( + _ensure_image_ref("https://cdn.example.com/a.png", user_id="u1") + ) + assert out == "https://cdn.example.com/a.png" + + +def test_seedream_rewrites_localhost_to_vision_public_base(monkeypatch): + from app.services.vision.providers import seedream as mod + + monkeypatch.setattr( + "app.core.config.settings.s3_public_base_url", + "http://localhost:9000/recombyn", + ) + monkeypatch.setattr( + "app.core.config.settings.vision_public_base_url", + "https://files.recombyn.com/recombyn", + ) + out = asyncio.run( + mod._ensure_image_ref( + "http://localhost:9000/recombyn/uploads/user/a.png", + user_id="u1", + ) + ) + assert out == "https://files.recombyn.com/recombyn/uploads/user/a.png" + + +def test_seedream_localhost_inlined_when_no_public_base(monkeypatch): + from app.services.vision.providers import seedream as mod + from app.services.vision import rehost as rehost_mod + + png = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + ) + + async def fake_download(_url: str) -> bytes: + return png + + monkeypatch.setattr( + "app.core.config.settings.s3_public_base_url", + "http://localhost:9000/recombyn", + ) + monkeypatch.setattr("app.core.config.settings.vision_public_base_url", "") + monkeypatch.setattr(rehost_mod, "_download_image_bytes", fake_download) + out = asyncio.run( + mod._ensure_image_ref( + "http://localhost:9000/recombyn/uploads/x.png", + user_id="u1", + ) + ) + assert out.startswith("data:image/png;base64,") + assert base64.b64decode(out.split(",", 1)[1]) == png + + +def test_llm_refs_inline_localhost_like_seedream(monkeypatch): + from app.services.vision import rehost as rehost_mod + + png = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + ) + + async def fake_download(_url: str) -> bytes: + return png + + monkeypatch.setattr( + "app.core.config.settings.s3_public_base_url", + "http://localhost:9000/recombyn", + ) + monkeypatch.setattr("app.core.config.settings.vision_public_base_url", "") + monkeypatch.setattr(rehost_mod, "_download_image_bytes", fake_download) + out = asyncio.run( + rehost_mod.ensure_remote_fetchable_image_refs( + ["http://localhost:9000/recombyn/uploads/x.png"] + ) + ) + assert len(out) == 1 + assert out[0].startswith("data:image/png;base64,") + + +def test_wavespeed_localhost_inlined_when_rehost_still_private(monkeypatch): + from app.services.vision.providers import wavespeed as mod + + png = b"\x89PNG\r\n\x1a\n" + b"x" * 16 + + async def fake_load(_ref: str) -> tuple[bytes, str]: + return png, "image/png" + + monkeypatch.setattr(mod, "_load_image_bytes", fake_load) + monkeypatch.setattr( + mod, + "rehost_image_bytes", + lambda *_a, **_k: "http://127.0.0.1:9000/bucket/x.png", + ) + out = asyncio.run( + mod.ensure_public_image_url( + "http://localhost:9000/recombyn/uploads/x.png", + user_id="u1", + ) + ) + assert out.startswith("data:image/png;base64,") + + +def test_wavespeed_keeps_public_http(): + from app.services.vision.providers.wavespeed import ensure_public_image_url + + out = asyncio.run( + ensure_public_image_url("https://cdn.example.com/a.png", user_id="u1") + ) + assert out == "https://cdn.example.com/a.png" diff --git a/apps/api/tests/unit_tests/test_wavespeed_vision.py b/apps/api/tests/unit_tests/test_wavespeed_vision.py index f544ef1f..feae6013 100644 --- a/apps/api/tests/unit_tests/test_wavespeed_vision.py +++ b/apps/api/tests/unit_tests/test_wavespeed_vision.py @@ -64,7 +64,7 @@ def test_angle_mapping(): assert ws.num_layers_from_meta({"num_layers": 1}) == 2 -def test_credit_cost_vision_kinds_zero(): +def test_credit_cost_vision_kinds(): from app.api.routes.image_tools import credit_cost_for_kind assert credit_cost_for_kind("upscale") == 0 @@ -72,7 +72,7 @@ def test_credit_cost_vision_kinds_zero(): assert credit_cost_for_kind("removeBg") == 0 assert credit_cost_for_kind("translateImage") == 0 assert credit_cost_for_kind("productScene") == 0 - assert credit_cost_for_kind("editElements") == 0 + assert credit_cost_for_kind("editElements") == 30 assert credit_cost_for_kind("multiAngle") == 0 assert credit_cost_for_kind("replaceText") == 30 diff --git a/apps/web/src/assets/editor/product-scenes/exhibit_bathroom.png b/apps/web/src/assets/editor/product-scenes/exhibit_bathroom.png new file mode 100644 index 00000000..f4f3c33e Binary files /dev/null and b/apps/web/src/assets/editor/product-scenes/exhibit_bathroom.png differ diff --git a/apps/web/src/assets/editor/product-scenes/exhibit_floor.png b/apps/web/src/assets/editor/product-scenes/exhibit_floor.png new file mode 100644 index 00000000..db857ac9 Binary files /dev/null and b/apps/web/src/assets/editor/product-scenes/exhibit_floor.png differ diff --git a/apps/web/src/assets/editor/product-scenes/exhibit_forest.png b/apps/web/src/assets/editor/product-scenes/exhibit_forest.png new file mode 100644 index 00000000..ef7faf6f Binary files /dev/null and b/apps/web/src/assets/editor/product-scenes/exhibit_forest.png differ diff --git a/apps/web/src/assets/editor/product-scenes/exhibit_home.png b/apps/web/src/assets/editor/product-scenes/exhibit_home.png new file mode 100644 index 00000000..d5f5a785 Binary files /dev/null and b/apps/web/src/assets/editor/product-scenes/exhibit_home.png differ diff --git a/apps/web/src/assets/editor/product-scenes/exhibit_kitchen.png b/apps/web/src/assets/editor/product-scenes/exhibit_kitchen.png new file mode 100644 index 00000000..cc84939f Binary files /dev/null and b/apps/web/src/assets/editor/product-scenes/exhibit_kitchen.png differ diff --git a/apps/web/src/assets/editor/product-scenes/exhibit_light.png b/apps/web/src/assets/editor/product-scenes/exhibit_light.png new file mode 100644 index 00000000..0d094e07 Binary files /dev/null and b/apps/web/src/assets/editor/product-scenes/exhibit_light.png differ diff --git a/apps/web/src/assets/editor/product-scenes/exhibit_luxury.png b/apps/web/src/assets/editor/product-scenes/exhibit_luxury.png new file mode 100644 index 00000000..6eb821aa Binary files /dev/null and b/apps/web/src/assets/editor/product-scenes/exhibit_luxury.png differ diff --git a/apps/web/src/assets/editor/product-scenes/exhibit_modern.png b/apps/web/src/assets/editor/product-scenes/exhibit_modern.png new file mode 100644 index 00000000..e67808fd Binary files /dev/null and b/apps/web/src/assets/editor/product-scenes/exhibit_modern.png differ diff --git a/apps/web/src/assets/editor/product-scenes/exhibit_simple.png b/apps/web/src/assets/editor/product-scenes/exhibit_simple.png new file mode 100644 index 00000000..1262105a Binary files /dev/null and b/apps/web/src/assets/editor/product-scenes/exhibit_simple.png differ diff --git a/apps/web/src/assets/editor/product-scenes/exhibit_stone.png b/apps/web/src/assets/editor/product-scenes/exhibit_stone.png new file mode 100644 index 00000000..89a1dd9f Binary files /dev/null and b/apps/web/src/assets/editor/product-scenes/exhibit_stone.png differ diff --git a/apps/web/src/assets/editor/product-scenes/natural_pasture.png b/apps/web/src/assets/editor/product-scenes/natural_pasture.png new file mode 100644 index 00000000..79b9e555 Binary files /dev/null and b/apps/web/src/assets/editor/product-scenes/natural_pasture.png differ diff --git a/apps/web/src/assets/editor/product-scenes/water_reflect.png b/apps/web/src/assets/editor/product-scenes/water_reflect.png new file mode 100644 index 00000000..fe1f8f3a Binary files /dev/null and b/apps/web/src/assets/editor/product-scenes/water_reflect.png differ diff --git a/apps/web/src/components/editor/canvas/__tests__/ctxMenuGuards.test.ts b/apps/web/src/components/editor/canvas/__tests__/ctxMenuGuards.test.ts index 92ddd07b..32025357 100644 --- a/apps/web/src/components/editor/canvas/__tests__/ctxMenuGuards.test.ts +++ b/apps/web/src/components/editor/canvas/__tests__/ctxMenuGuards.test.ts @@ -43,8 +43,8 @@ describe('ctxMenuGuards', () => { ).toBe(false); }); - it('blocks mutations while a frame is processing', () => { - expect(selectionMutationBlocked(doc, [], ['frame-running'])).toBe(true); + it('does not block mutations on frame processStatus alone', () => { + expect(selectionMutationBlocked(doc, [], ['frame-running'])).toBe(false); }); it('allows deleting any processing target', () => { diff --git a/apps/web/src/components/editor/canvas/attachPick.ts b/apps/web/src/components/editor/canvas/attachPick.ts index a86fdb4a..f015bc2d 100644 --- a/apps/web/src/components/editor/canvas/attachPick.ts +++ b/apps/web/src/components/editor/canvas/attachPick.ts @@ -5,55 +5,14 @@ import { expandSelectionWithGroups, readNodeGroupId } from '@/components/rcb/scene/document/sceneGroups'; -import { nodeLeftTop } from '@/components/rcb/scene/paint/sceneToSvg'; +import { frameForFullBleedPlate as frameForFullBleedPlateId } from '@/components/rcb/scene/document/sceneHitBridge'; import type { SceneDocument } from '@/components/rcb/sceneNode'; /** Near-full-bleed plate covering an artboard — treat click as frame select. * Rect / path / image backgrounds all count (vector artboards often use a path fill). */ export function frameForFullBleedPlate(doc: SceneDocument, nodeId: string): { id: string } | null { - const node = doc?.deltaSetLike?.[nodeId]; - if (!node) return null; - const key = String(node.key || ''); - if (key === 'shape') { - const shapeType = String(node.attrs?.shapeType || 'rect'); - // Open strokes are not plates. - if (shapeType === 'line' || shapeType === 'arrow' || shapeType === 'pencil') return null; - if (shapeType === 'pen' || shapeType === 'path') { - const closed = node.attrs?.closed; - if (closed === false || closed === 'false' || closed === 0 || closed === '0') return null; - } - } else if (key !== 'image' && key !== 'rect') { - return null; - } - const frames = Array.isArray(doc?.frames) ? doc.frames : []; - if (!frames.length) return null; - const { left, top } = nodeLeftTop(doc, node); - const w = Math.max(1, Number(node.width) || 1); - const h = Math.max(1, Number(node.height) || 1); - const area = w * h; - // Prefer the frame this node is bound to, then any overlapping artboard. - const boundId = String(node.attrs?.frameId || '').trim(); - const ordered = boundId - ? [ - ...frames.filter((f) => String(f?.id) === boundId), - ...frames.filter((f) => String(f?.id) !== boundId), - ] - : frames; - for (const f of ordered) { - if (!f?.id) continue; - const fx = Number(f.x) || 0; - const fy = Number(f.y) || 0; - const fw = Math.max(1, Number(f.width) || 1); - const fh = Math.max(1, Number(f.height) || 1); - const frameArea = fw * fh; - const ow = Math.max(0, Math.min(left + w, fx + fw) - Math.max(left, fx)); - const oh = Math.max(0, Math.min(top + h, fy + fh) - Math.max(top, fy)); - const overlap = ow * oh; - if (overlap >= frameArea * 0.9 && area >= frameArea * 0.85) { - return { id: String(f.id) }; - } - } - return null; + const id = frameForFullBleedPlateId(doc, nodeId); + return id ? { id } : null; } /** Drop generator plates + process-shimmer (+ videos when images-only) from attach targets. */ diff --git a/apps/web/src/components/editor/nodes/ImageNode/ImageProcessWatcher.tsx b/apps/web/src/components/editor/nodes/ImageNode/ImageProcessWatcher.tsx index b44173ce..650bf1dc 100644 --- a/apps/web/src/components/editor/nodes/ImageNode/ImageProcessWatcher.tsx +++ b/apps/web/src/components/editor/nodes/ImageNode/ImageProcessWatcher.tsx @@ -1,355 +1,394 @@ -import { useEffect, useRef, memo } from 'react'; -import { useSelector } from '@/store'; -import { useEditorDocument } from '@/store/editorSelectors'; -import i18n from '@/i18n'; -import { message } from '@/components/base'; -import { - formatProcessProgressLabel, - processJobAttrPatch, - readProcessJobIds, - stripProcessProgressLabel, -} from '@/components/rcb/scene/document/processJobAttrs'; -import { - AI_IMAGE_PROCESS_KINDS, - processImageToolAsync, - useImageToolCapabilities, - type ImageProcessResult, -} from '@/service/imageTools'; -import { isUploadAbortError, uploadImageFromSrc } from '@/utils/uploadImage'; -import { getHttpErrorMessage } from '@/service/client'; -import { refreshWalletAfterSpend } from '@/service/wallet'; -import { - failImageProcess, - finishImageProcess, - patchDocumentNode, -} from '@/store/modules/editor'; -import type { SceneNodeInput } from '@/components/rcb/sceneNode'; - -const DECOMPOSE_KINDS = new Set(['editText', 'editElements']); - -function tt(key: string, opts?: Record): string { - return String(i18n.t(key, opts)); -} - -function parseMeta(raw: unknown): Record { - if (!raw) return {}; - if (typeof raw === 'object') return raw as Record; - try { - return JSON.parse(String(raw)) as Record; - } catch { - return {}; - } -} - -function aspectFromBox(w: number, h: number): string { - const rw = Math.max(1, Math.round(w)); - const rh = Math.max(1, Math.round(h)); - const g = (a: number, b: number): number => (b === 0 ? a : g(b, a % b)); - const d = g(rw, rh) || 1; - return `${Math.round(rw / d)}:${Math.round(rh / d)}`; -} - -function resolutionFor(kind: string, node: SceneNodeInput): string | undefined { - if (kind !== 'upscale') return undefined; - const meta = parseMeta(node?.attrs?.processMeta); - const fromMeta = String(meta.resolution || '') - .trim() - .toUpperCase(); - if (fromMeta === '2K' || fromMeta === '4K') return fromMeta; - const tw = Number(node?.attrs?.processTargetWidth) || 0; - if (tw >= 3500) return '4K'; - return '2K'; -} - -/** Persist tool output on our file server. */ -async function persistProcessedSrc(src: string, filename: string): Promise { - const raw = String(src || '').trim(); - if (!raw) throw new Error('empty processed image'); - const uploaded = await uploadImageFromSrc(raw, filename); - const url = String(uploaded.url || '').trim(); - if (!url) throw new Error('upload returned no url'); - return url; -} - -async function refreshWallet() { - refreshWalletAfterSpend(); -} - -/** Prefer backend ``message``; FE i18n only for client-only / empty fallbacks. */ -function processFailMessage(err: unknown): string { - const msg = getHttpErrorMessage(err, ''); - if (msg.trim()) return msg; - if ( - /timeout/i.test(String((err as Error)?.message || '')) || - (err as { code?: string })?.code === 'ECONNABORTED' - ) { - return tt('editor.imageToolbar.processTimeout'); - } - return tt('editor.imageToolbar.processFailed'); -} - -function buildFinishAttrsForKind( - kind: string, - opts: { - sourceGenPrompt?: string; - replacedCopy?: string; - } -): Record | undefined { - if (kind === 'removeBg' || kind === 'eraser') { - return { - cutout: 'true', - name: - kind === 'eraser' - ? tt('editor.imageToolbar.nameEraser') - : tt('editor.imageToolbar.nameCutout'), - }; - } - if (kind === 'replaceText' && opts.replacedCopy) { - return { - letteringText: opts.replacedCopy, - genPrompt: [String(opts.sourceGenPrompt || '').trim(), `Text replaced to: ${opts.replacedCopy}`] - .filter(Boolean) - .join('\n'), - }; - } - return undefined; -} - -async function finishDecomposeResult( - pendingId: string, - kind: string, - res: ImageProcessResult, - cancelled: () => boolean -) { - const layers = Array.isArray(res?.layers) ? res.layers : []; - if (!layers.length || !DECOMPOSE_KINDS.has(kind)) return false; - - const persisted = await Promise.all( - layers.map(async (layer: any, i: number) => { - const src = String(layer?.src || '').trim(); - if (!src || String(layer?.type) === 'text' || /^https?:\/\//i.test(src)) return layer; - return { ...layer, src: await persistProcessedSrc(src, `${kind}-layer-${i + 1}.png`) }; - }) - ); - if (cancelled()) return true; - - finishImageProcess({ - nodeId: pendingId, - layers: persisted, - sourceWidth: Number(res.width) || undefined, - sourceHeight: Number(res.height) || undefined, - }); - const warn = Array.isArray(res.warnings) ? res.warnings.filter(Boolean) : []; - if (warn.length) { - message.warning(warn.slice(0, 3).join(';')); - } else { - const textCount = layers.filter((l: any) => String(l?.type) === 'text').length; - const rasterCount = layers.filter((l: any) => l?.letteringText).length; - if (kind === 'editElements') { - message.success(tt('editor.imageToolbar.doneEditElementsHint')); - } else if (rasterCount > 0 && textCount > 0) { - message.success( - tt('editor.imageToolbar.doneOcrMixed', { textCount, rasterCount }) - ); - } else if (textCount > 0) { - message.success(tt('editor.imageToolbar.doneOcrEditable', { count: textCount })); - } else { - message.success(tt('editor.imageToolbar.doneOcr')); - } - } - await refreshWallet(); - return true; -} - -/** - * Completes spawned image process jobs via async backend jobs + SSE progress. - * Results are uploaded to our file server when still inline data URLs. - */ -function ImageProcessWatcher() { useImageToolCapabilities(); - const pendingId = useSelector((s: any) => s.editor.pendingImageProcessId as string | null); - const document = useEditorDocument(); - const documentRef = useRef(document); - documentRef.current = document; - - useEffect(() => { - if (!pendingId) return undefined; - const doc = documentRef.current; - const node = doc?.deltaSetLike?.[pendingId]; - const kind = String(node?.attrs?.processKind || ''); - if (kind === 'import' || kind === 'upload') return undefined; - - let cancelled = false; - const ac = new AbortController(); - const isCancelled = () => cancelled; - - const fail = (msg: string) => { - if (cancelled) return; - message.error(msg); - failImageProcess({ nodeId: pendingId }); - }; - - const run = async () => { - if (!AI_IMAGE_PROCESS_KINDS.has(kind)) { - fail(`unsupported image process kind: ${kind || 'unknown'}`); - return; - } - - const latest = documentRef.current; - const liveNode = latest?.deltaSetLike?.[pendingId] || node; - const sourceId = String(liveNode?.attrs?.processSourceId || ''); - const sourceNode = sourceId ? latest?.deltaSetLike?.[sourceId] : null; - const image = String(sourceNode?.attrs?.src || liveNode?.attrs?.src || ''); - if (!image) { - fail(tt('editor.imageToolbar.imageNotFound')); - return; - } - - const w = Number(liveNode?.width) || Number(sourceNode?.width) || 1024; - const h = Number(liveNode?.height) || Number(sourceNode?.height) || 1024; - const meta = parseMeta(liveNode?.attrs?.processMeta); - const labelBase = stripProcessProgressLabel(String(liveNode?.attrs?.processLabel || '')); - - try { - const processBody: { - kind: string; - image: string; - meta?: Record; - aspect_ratio?: string; - quality?: string; - resolution?: string; - } = { - kind, - image, - quality: 'high', - }; - if (meta) processBody.meta = meta; - const aspect = aspectFromBox(w, h); - if (aspect) processBody.aspect_ratio = aspect; - const resolution = resolutionFor(kind, liveNode); - if (resolution) processBody.resolution = resolution; - - const existingJobIds = readProcessJobIds(liveNode); - const res = await processImageToolAsync(processBody, { - signal: ac.signal, - jobId: existingJobIds[0], - onProgress: (pct) => { - if (cancelled) return; - patchDocumentNode({ - nodeId: pendingId, - skipHistory: true, - patch: { - attrs: { - processLabel: formatProcessProgressLabel( - labelBase, - pct, - labelBase || '处理中' - ), - }, - }, - }); - }, - onJobCreated: (jobId) => { - if (cancelled) return; - patchDocumentNode({ - nodeId: pendingId, - skipHistory: true, - patch: { attrs: processJobAttrPatch([jobId]) }, - }); - }, - }); - if (cancelled) return; - - if (await finishDecomposeResult(pendingId, kind, res, isCancelled)) return; - - const svgMarkup = String(res?.svg || '').trim(); - if (kind === 'vector' || svgMarkup) { - if (!svgMarkup) { - fail(tt('editor.imageToolbar.processNoResult')); - return; - } - finishImageProcess({ - nodeId: pendingId, - svg: svgMarkup, - attrs: { name: tt('editor.imageToolbar.nameVector') }, - }); - message.success(tt('editor.imageToolbar.doneVector')); - await refreshWallet(); - return; - } - - if (!res?.image) { - fail(tt('editor.imageToolbar.processNoResult')); - return; - } - const storedUrl = await persistProcessedSrc(res.image, `${kind}.png`); - if (cancelled) return; - - const batchUrls = Array.isArray(res.images) - ? res.images.map((u) => String(u || '').trim()).filter(Boolean) - : []; - let variantAttr: string | undefined; - if (batchUrls.length > 1) { - const persisted: string[] = []; - for (let i = 0; i < batchUrls.length; i += 1) { - const u = batchUrls[i]; - if (u === res.image) { - persisted.push(storedUrl); - continue; - } - const nextUrl = await persistProcessedSrc(u, `${kind}-${i}.png`); - if (cancelled) return; - persisted.push(nextUrl); - } - const withMain = persisted.includes(storedUrl) - ? persisted - : [storedUrl, ...persisted]; - variantAttr = JSON.stringify(withMain); - } - - const replaceMeta = kind === 'replaceText' ? parseMeta(liveNode?.attrs?.processMeta) : {}; - const replacedCopy = String(replaceMeta.newText || '').trim(); - const finishAttrs = buildFinishAttrsForKind(kind, { - sourceGenPrompt: String(sourceNode?.attrs?.genPrompt || ''), - replacedCopy, - }); - finishImageProcess({ - nodeId: pendingId, - src: storedUrl, - attrs: { - ...(finishAttrs || {}), - ...(variantAttr ? { imageVariants: variantAttr } : {}), - }, - }); - const labels: Record = { - removeBg: tt('editor.imageToolbar.doneRemoveBg'), - eraser: tt('editor.imageToolbar.doneEraser'), - upscale: tt('editor.imageToolbar.doneUpscale'), - multiAngle: tt('editor.imageToolbar.doneMultiAngle'), - expand: tt('editor.imageToolbar.doneExpand'), - editText: tt('editor.imageToolbar.doneEditText'), - editElements: tt('editor.imageToolbar.doneEditElements'), - replaceText: tt('editor.imageToolbar.doneReplaceText'), - translateImage: tt('editor.imageToolbar.doneTranslateImage'), - productScene: tt('editor.imageToolbar.doneProductScene'), - vector: tt('editor.imageToolbar.doneVector'), - adjust: tt('editor.imageToolbar.doneAdjust'), - }; - message.success(labels[kind] || tt('editor.imageToolbar.doneGeneric')); - await refreshWallet(); - } catch (err: any) { - if (cancelled || isUploadAbortError(err)) return; - fail(processFailMessage(err)); - } - }; - - run(); - return () => { - cancelled = true; - ac.abort(); - }; - }, [pendingId]); - - return null; -} - -export default memo(ImageProcessWatcher); +import { useEffect, useRef, memo } from 'react'; +import { useSelector } from '@/store'; +import { useEditorDocument } from '@/store/editorSelectors'; +import i18n from '@/i18n'; +import { message } from '@/components/base'; +import { + formatProcessProgressLabel, + processJobAttrPatch, + readProcessJobIds, + stripProcessProgressLabel, +} from '@/components/rcb/scene/document/processJobAttrs'; +import { + AI_IMAGE_PROCESS_KINDS, + processImageToolAsync, + useImageToolCapabilities, + type ImageProcessResult, +} from '@/service/imageTools'; +import { isUploadAbortError, uploadImageFromSrc } from '@/utils/uploadImage'; +import { getHttpErrorMessage, getHttpStatus } from '@/service/client'; +import { refreshWalletAfterSpend } from '@/service/wallet'; +import { + failImageProcess, + finishImageProcess, + patchDocumentNode, +} from '@/store/modules/editor'; +import type { SceneNodeInput } from '@/components/rcb/sceneNode'; + +const DECOMPOSE_KINDS = new Set(['editText', 'editElements']); + +function tt(key: string, opts?: Record): string { + return String(i18n.t(key, opts)); +} + +function resolveHttpStatus(err: unknown): number | undefined { + const fromClient = getHttpStatus(err); + if (fromClient != null) return fromClient; + if (!err || typeof err !== 'object' || !('status' in err)) return undefined; + const s = Number((err as { status?: unknown }).status); + if (!Number.isFinite(s)) return undefined; + return s; +} + +function isQuotaWarnError(err: unknown): boolean { + if (resolveHttpStatus(err) === 402) return true; + if (!err || typeof err !== 'object' || !('code' in err)) return false; + const code = String((err as { code?: unknown }).code || '').trim(); + return code === 'free_vision_exhausted' || code === 'insufficient_credits'; +} + +function parseMeta(raw: unknown): Record { + if (!raw) return {}; + if (typeof raw === 'object') return raw as Record; + try { + return JSON.parse(String(raw)) as Record; + } catch { + return {}; + } +} + +function aspectFromBox(w: number, h: number): string { + const rw = Math.max(1, Math.round(w)); + const rh = Math.max(1, Math.round(h)); + const g = (a: number, b: number): number => (b === 0 ? a : g(b, a % b)); + const d = g(rw, rh) || 1; + return `${Math.round(rw / d)}:${Math.round(rh / d)}`; +} + +function resolutionFor(kind: string, node: SceneNodeInput): string | undefined { + if (kind !== 'upscale') return undefined; + const meta = parseMeta(node?.attrs?.processMeta); + const fromMeta = String(meta.resolution || '') + .trim() + .toUpperCase(); + if (fromMeta === '2K' || fromMeta === '4K') return fromMeta; + const tw = Number(node?.attrs?.processTargetWidth) || 0; + if (tw >= 3500) return '4K'; + return '2K'; +} + +/** Persist tool output on our file server. */ +async function persistProcessedSrc(src: string, filename: string): Promise { + const raw = String(src || '').trim(); + if (!raw) throw new Error('empty processed image'); + const uploaded = await uploadImageFromSrc(raw, filename); + const url = String(uploaded.url || '').trim(); + if (!url) throw new Error('upload returned no url'); + return url; +} + +/** Prefer backend ``message``; FE i18n only for client-only / empty fallbacks. */ +function processFailMessage(err: unknown): string { + const msg = getHttpErrorMessage(err, ''); + if (msg.trim()) return msg; + if ( + /timeout/i.test(String((err as Error)?.message || '')) || + (err as { code?: string })?.code === 'ECONNABORTED' + ) { + return tt('editor.imageToolbar.processTimeout'); + } + return tt('editor.imageToolbar.processFailed'); +} + +function buildFinishAttrsForKind( + kind: string, + opts: { + sourceGenPrompt?: string; + replacedCopy?: string; + } +): Record | undefined { + if (kind === 'removeBg' || kind === 'eraser') { + return { + cutout: 'true', + name: + kind === 'eraser' + ? tt('editor.imageToolbar.nameEraser') + : tt('editor.imageToolbar.nameCutout'), + }; + } + if (kind === 'replaceText' && opts.replacedCopy) { + return { + letteringText: opts.replacedCopy, + genPrompt: [String(opts.sourceGenPrompt || '').trim(), `Text replaced to: ${opts.replacedCopy}`] + .filter(Boolean) + .join('\n'), + }; + } + return undefined; +} + +function decomposeSuccessMessage(kind: string, layers: unknown[]): string { + if (kind === 'editElements') { + return tt('editor.imageToolbar.doneEditElementsHint'); + } + const textCount = layers.filter((l: any) => String(l?.type) === 'text').length; + const rasterCount = layers.filter((l: any) => l?.letteringText).length; + if (rasterCount > 0 && textCount > 0) { + return tt('editor.imageToolbar.doneOcrMixed', { textCount, rasterCount }); + } + if (textCount > 0) { + return tt('editor.imageToolbar.doneOcrEditable', { count: textCount }); + } + return tt('editor.imageToolbar.doneOcr'); +} + +const PROCESS_DONE_LABELS: Record = { + removeBg: 'editor.imageToolbar.doneRemoveBg', + eraser: 'editor.imageToolbar.doneEraser', + upscale: 'editor.imageToolbar.doneUpscale', + multiAngle: 'editor.imageToolbar.doneMultiAngle', + expand: 'editor.imageToolbar.doneExpand', + editText: 'editor.imageToolbar.doneEditText', + editElements: 'editor.imageToolbar.doneEditElements', + replaceText: 'editor.imageToolbar.doneReplaceText', + translateImage: 'editor.imageToolbar.doneTranslateImage', + productScene: 'editor.imageToolbar.doneProductScene', + vector: 'editor.imageToolbar.doneVector', + adjust: 'editor.imageToolbar.doneAdjust', +}; + +function rasterDoneMessage(kind: string): string { + const key = PROCESS_DONE_LABELS[kind]; + if (key) return tt(key); + return tt('editor.imageToolbar.doneGeneric'); +} + +async function persistBatchVariantUrls( + mainSrc: string, + storedUrl: string, + batchUrls: string[], + kind: string, + cancelled: () => boolean +): Promise { + if (batchUrls.length <= 1) return undefined; + const persisted: string[] = []; + for (let i = 0; i < batchUrls.length; i += 1) { + const u = batchUrls[i]; + if (u === mainSrc) { + persisted.push(storedUrl); + continue; + } + const nextUrl = await persistProcessedSrc(u, `${kind}-${i}.png`); + if (cancelled()) return undefined; + persisted.push(nextUrl); + } + const withMain = persisted.includes(storedUrl) + ? persisted + : [storedUrl, ...persisted]; + return JSON.stringify(withMain); +} + +async function finishDecomposeResult( + pendingId: string, + kind: string, + res: ImageProcessResult, + cancelled: () => boolean +) { + const layers = Array.isArray(res?.layers) ? res.layers : []; + if (!layers.length || !DECOMPOSE_KINDS.has(kind)) return false; + + const persisted = await Promise.all( + layers.map(async (layer: any, i: number) => { + const src = String(layer?.src || '').trim(); + if (!src || String(layer?.type) === 'text' || /^https?:\/\//i.test(src)) return layer; + return { ...layer, src: await persistProcessedSrc(src, `${kind}-layer-${i + 1}.png`) }; + }) + ); + if (cancelled()) return true; + + finishImageProcess({ + nodeId: pendingId, + layers: persisted, + sourceWidth: Number(res.width) || undefined, + sourceHeight: Number(res.height) || undefined, + }); + const warn = Array.isArray(res.warnings) ? res.warnings.filter(Boolean) : []; + if (warn.length) { + message.warning(warn.slice(0, 3).join(';')); + } else { + message.success(decomposeSuccessMessage(kind, layers)); + } + refreshWalletAfterSpend(); + return true; +} + +/** + * Completes spawned image process jobs via async backend jobs + SSE progress. + * Results are uploaded to our file server when still inline data URLs. + */ +function ImageProcessWatcher() { + useImageToolCapabilities(); + const pendingId = useSelector((s: any) => s.editor.pendingImageProcessId as string | null); + const document = useEditorDocument(); + const documentRef = useRef(document); + documentRef.current = document; + + useEffect(() => { + if (!pendingId) return undefined; + const doc = documentRef.current; + const node = doc?.deltaSetLike?.[pendingId]; + const kind = String(node?.attrs?.processKind || ''); + if (kind === 'import' || kind === 'upload') return undefined; + + let cancelled = false; + const ac = new AbortController(); + const isCancelled = () => cancelled; + + const fail = (msg: string, err?: unknown) => { + if (cancelled) return; + if (err && isQuotaWarnError(err)) message.warning(msg); + else message.error(msg); + failImageProcess({ nodeId: pendingId }); + }; + + const run = async () => { + if (!AI_IMAGE_PROCESS_KINDS.has(kind)) { + fail(`unsupported image process kind: ${kind || 'unknown'}`); + return; + } + + const latest = documentRef.current; + const liveNode = latest?.deltaSetLike?.[pendingId] || node; + const sourceId = String(liveNode?.attrs?.processSourceId || ''); + const sourceNode = sourceId ? latest?.deltaSetLike?.[sourceId] : null; + const image = String(sourceNode?.attrs?.src || liveNode?.attrs?.src || ''); + if (!image) { + fail(tt('editor.imageToolbar.imageNotFound')); + return; + } + + const w = Number(liveNode?.width) || Number(sourceNode?.width) || 1024; + const h = Number(liveNode?.height) || Number(sourceNode?.height) || 1024; + const meta = parseMeta(liveNode?.attrs?.processMeta); + const labelBase = stripProcessProgressLabel(String(liveNode?.attrs?.processLabel || '')); + + try { + const processBody: { + kind: string; + image: string; + meta?: Record; + aspect_ratio?: string; + quality?: string; + resolution?: string; + } = { + kind, + image, + quality: 'high', + }; + if (meta) processBody.meta = meta; + const aspect = aspectFromBox(w, h); + if (aspect) processBody.aspect_ratio = aspect; + const resolution = resolutionFor(kind, liveNode); + if (resolution) processBody.resolution = resolution; + + const existingJobIds = readProcessJobIds(liveNode); + const res = await processImageToolAsync(processBody, { + signal: ac.signal, + jobId: existingJobIds[0], + onProgress: (pct) => { + if (cancelled) return; + patchDocumentNode({ + nodeId: pendingId, + skipHistory: true, + patch: { + attrs: { + processLabel: formatProcessProgressLabel( + labelBase, + pct, + labelBase || '处理中' + ), + }, + }, + }); + }, + onJobCreated: (jobId) => { + if (cancelled) return; + patchDocumentNode({ + nodeId: pendingId, + skipHistory: true, + patch: { attrs: processJobAttrPatch([jobId]) }, + }); + }, + }); + if (cancelled) return; + + if (await finishDecomposeResult(pendingId, kind, res, isCancelled)) return; + + const svgMarkup = String(res?.svg || '').trim(); + if (kind === 'vector' || svgMarkup) { + if (!svgMarkup) { + fail(tt('editor.imageToolbar.processNoResult')); + return; + } + finishImageProcess({ + nodeId: pendingId, + svg: svgMarkup, + attrs: { name: tt('editor.imageToolbar.nameVector') }, + }); + message.success(tt('editor.imageToolbar.doneVector')); + refreshWalletAfterSpend(); + return; + } + + if (!res?.image) { + fail(tt('editor.imageToolbar.processNoResult')); + return; + } + const storedUrl = await persistProcessedSrc(res.image, `${kind}.png`); + if (cancelled) return; + + const batchUrls = Array.isArray(res.images) + ? res.images.map((u) => String(u || '').trim()).filter(Boolean) + : []; + const variantAttr = await persistBatchVariantUrls( + res.image, + storedUrl, + batchUrls, + kind, + isCancelled + ); + if (cancelled) return; + + const replaceMeta = kind === 'replaceText' ? parseMeta(liveNode?.attrs?.processMeta) : {}; + const replacedCopy = String(replaceMeta.newText || '').trim(); + const finishAttrs = buildFinishAttrsForKind(kind, { + sourceGenPrompt: String(sourceNode?.attrs?.genPrompt || ''), + replacedCopy, + }); + finishImageProcess({ + nodeId: pendingId, + src: storedUrl, + attrs: { + ...(finishAttrs || {}), + ...(variantAttr ? { imageVariants: variantAttr } : {}), + }, + }); + message.success(rasterDoneMessage(kind)); + refreshWalletAfterSpend(); + } catch (err: any) { + if (cancelled || isUploadAbortError(err)) return; + fail(processFailMessage(err), err); + } + }; + + run(); + return () => { + cancelled = true; + ac.abort(); + }; + }, [pendingId]); + + return null; +} + +export default memo(ImageProcessWatcher); diff --git a/apps/web/src/components/editor/nodes/ImageNode/ImageQuickEditComposer.tsx b/apps/web/src/components/editor/nodes/ImageNode/ImageQuickEditComposer.tsx index 35e75623..82bdd563 100644 --- a/apps/web/src/components/editor/nodes/ImageNode/ImageQuickEditComposer.tsx +++ b/apps/web/src/components/editor/nodes/ImageNode/ImageQuickEditComposer.tsx @@ -593,7 +593,7 @@ function ImageQuickEditComposer({ value={prompt} onChange={setPrompt} onSubmit={() => { - if (canSendGen) void onGenerate(); + if (canSendGen) onGenerate(); }} // canSendGen only gates the send button — empty prompt must stay editable. disabled={sending} @@ -720,7 +720,9 @@ function ImageQuickEditComposer({ {sceneMenuOpen ? ( - + {PRODUCT_SCENE_PRESETS.map((p) => ( { setSceneCode(p.code); setSceneMenuOpen(false); }} > - {t(`editor.imageToolbar.productScenePreset.${p.labelKey}`, { - defaultValue: p.code, - })} + + + {t(`editor.imageToolbar.productScenePreset.${p.labelKey}`, { + defaultValue: p.code, + })} + ))} @@ -200,7 +215,7 @@ function ProductSceneSessionHost({ type="button" className={cn( imageToolBtn, - 'min-w-[4.5rem] justify-between gap-2 px-3 font-medium tabular-nums', + 'gap-1 px-2 font-medium tabular-nums', batchMenuOpen && 'bg-[var(--accent-soft)]' )} onClick={() => { @@ -235,7 +250,7 @@ function ProductSceneSessionHost({