diff --git a/routes/editor_agent_routes.py b/routes/editor_agent_routes.py index 717c354e9..aebe39ad1 100644 --- a/routes/editor_agent_routes.py +++ b/routes/editor_agent_routes.py @@ -855,6 +855,11 @@ def _plan_special( left to the model. Returns (steps, reply, note) or None. These never need an image provider except generative border fill, used only when one exists. """ + if re.fullmatch( + r"\s*(?:please\s+)?(?:remove|erase|delete|clear|strip)\s+(?:the\s+)?(?:bg|background|backdrop)(?:\s+(?:from|of)\s+(?:the\s+)?(?:active|current|selected)\s+layer)?[.!\s]*", + prompt, re.I, + ): + return [{"op": "ai.removeBackground", "params": {}}], "Removing the active layer's background.", "" # Targeted recolour is fully deterministic (object mask + masked hue shift) # and must never be left to the model, which sometimes substitutes a # generative placeImage. Highest priority so it can never vary. @@ -1647,6 +1652,13 @@ def setup_editor_agent_routes() -> APIRouter: screenshot = body.get("screenshot") # Optional user-picked image model (from the editor model selector). model_choice = str(body.get("model") or body.get("image_model") or "").strip() + generation_model_choice = str(body.get("generation_model") or "").strip() + use_image_settings = body.get("_use_image_settings") is True + if use_image_settings: + from src.editor_image_settings import editor_image_models + image_models = editor_image_models() + model_choice = image_models["edit"] + generation_model_choice = image_models["generation"] # Optional user-picked PLANNER model (the editor agent/planner model). agent_model = str(body.get("agent_model") or "").strip() tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled] @@ -1797,9 +1809,22 @@ def setup_editor_agent_routes() -> APIRouter: # Planner output is untrusted: it cannot redirect this turn to # another editor session or override an explicit model choice. tool_payload["session"] = session + if use_image_settings: + tool_payload["_use_image_settings"] = True + if op == "generate_image" and state.get("width") and state.get("height"): + try: + width, height = int(state["width"]), int(state["height"]) + if width > 0 and height > 0: + tool_payload["size"] = f"{width}x{height}" + except (TypeError, ValueError): + pass # Route to the user-picked model when one is selected. - if model_choice: - tool_payload["model"] = model_choice + selected_model = generation_model_choice if op == "generate_image" else model_choice + if selected_model: + tool_payload["model"] = selected_model + elif op == "generate_image" or use_image_settings: + # Let server settings resolve Auto, not a planner-invented model. + tool_payload.pop("model", None) # Hand the AI step the canvas image the client already captured # for this request. Without it the tool falls back to a separate # bridge screenshot, which is not reliably answered right after a diff --git a/routes/gallery/gallery_routes.py b/routes/gallery/gallery_routes.py index 690c22044..356af2a9f 100644 --- a/routes/gallery/gallery_routes.py +++ b/routes/gallery/gallery_routes.py @@ -37,6 +37,24 @@ _SAM_STATE: Dict[str, Any] = {} _GROUNDING_STATE: Dict[str, Any] = {} +def _compose_cloud_image_edit(source, generated, mask, *, instruction=False): + from PIL import Image, ImageChops, ImageOps + + full_frame = instruction or mask.getextrema() == (255, 255) + if full_frame: + return generated, True + # Partial edits must share the source coordinate system, but resizing + # width/height independently distorts the subject when the provider changes ratio. + fitted = ImageOps.contain(generated, source.size, Image.Resampling.LANCZOS) + offset = ((source.width - fitted.width) // 2, (source.height - fitted.height) // 2) + aligned = Image.new("RGBA", source.size) + aligned.paste(fitted, offset) + coverage = Image.new("L", source.size) + coverage.paste(255, (offset[0], offset[1], offset[0] + fitted.width, offset[1] + fitted.height)) + effective_mask = ImageChops.multiply(mask, coverage) + return Image.composite(aligned, source, effective_mask), False + + def _rss_mb() -> float: """Current process RSS in MB (from /proc; -1.0 when unavailable).""" try: @@ -568,6 +586,7 @@ def _is_openai_api_base(url: str) -> bool: _GALLERY_ENDPOINT_PATHS = frozenset({ + "/chat/completions", "/images", "/images/edits", "/images/generations", @@ -1745,6 +1764,17 @@ def setup_gallery_routes() -> APIRouter: if not ok: raise HTTPException(400, f"Rejected endpoint URL: {reason}") chosen_model = (body.pop("_model", "") or "").strip() + if body.pop("_use_image_settings", False) is True: + from src.editor_image_settings import editor_image_models + from src.ai_interaction import _resolve_model + chosen_model = editor_image_models()["edit"] + if not chosen_model: + raise HTTPException(400, "Choose an image edit model in AI settings") + try: + edit_url, chosen_model, _ = await asyncio.to_thread(_resolve_model, chosen_model, owner=user) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + requested_base = edit_url.removesuffix("/chat/completions").rstrip("/") # Optional client-supplied id so GET /api/image/edit-progress can poll the # provider's step progress for this op. Ignored by providers that do not # implement the progress contract. @@ -1862,14 +1892,30 @@ def setup_gallery_routes() -> APIRouter: mask_png.save(reference_mask, format="PNG") references = [src_buf.getvalue(), reference_mask.getvalue()] payload = { - "model": oa_model, "size": size, "n": 1, + "model": oa_model, "n": 1, "output_format": "png", "prompt": "Edit the first image. The second image is a mask: white marks the region to edit, black marks the region to preserve. Keep composition and framing unchanged. Requested edit: " + str(body.get("prompt", "")), "input_references": [{"type": "image_url", "image_url": { "url": "data:image/png;base64," + base64.b64encode(value).decode(), }} for value in references], } - r = await client.post(_join_checked_gallery_endpoint(base, "/images"), headers=headers, json=payload) + from src.image_model_ids import image_aspect_ratio + payload["aspect_ratio"] = image_aspect_ratio(source_png.width, source_png.height) + from src.ai_interaction import _uses_openrouter_chat_images + if _uses_openrouter_chat_images(oa_model): + payload = { + "model": oa_model, + "messages": [{"role": "user", "content": [ + {"type": "text", "text": payload["prompt"]}, + *[{"type": "image_url", "image_url": ref["image_url"]} + for ref in payload["input_references"]], + ]}], + "modalities": ["image", "text"], "stream": False, + "image_config": {"aspect_ratio": payload["aspect_ratio"]}, + } + r = await client.post(_join_checked_gallery_endpoint(base, "/chat/completions"), headers=headers, json=payload) + else: + r = await client.post(_join_checked_gallery_endpoint(base, "/images"), headers=headers, json=payload) else: r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files) if r.status_code != 200: @@ -1885,6 +1931,12 @@ def setup_gallery_routes() -> APIRouter: pass raise HTTPException(r.status_code, f"{_detail} (provider HTTP {r.status_code})") result = r.json() + from src.ai_interaction import _chat_image_results, _image_response_error + provider_error = _image_response_error(result, oa_model) + if provider_error: + raise HTTPException(400 if "deprecated" in provider_error.lower() else 502, provider_error) + if is_openrouter and _uses_openrouter_chat_images(oa_model): + result = {"data": _chat_image_results(result)} raw_b64 = None if result.get("data"): item = result["data"][0] @@ -1904,17 +1956,13 @@ def setup_gallery_routes() -> APIRouter: # the masked region actually changes. try: generated = Image.open(io.BytesIO(base64.b64decode(raw_b64))).convert("RGBA") - # Match the generated image to the source dims. - if generated.size != source_png.size: - generated = generated.resize(source_png.size, Image.LANCZOS) - # mask_png: white = regenerate (use generated), - # black = keep (use source). - # Composite: result = source * (1 - mask_norm) + generated * mask_norm - # Image.composite does exactly that with `mask`. - blended = Image.composite(generated, source_png, mask_png) + blended, full_frame = _compose_cloud_image_edit( + source_png, generated, mask_png, + instruction=body.get("mode") == "instruction", + ) out_buf = io.BytesIO() blended.save(out_buf, format="PNG") - return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model} + return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model, "full_frame": full_frame} except Exception as comp_err: # Never return a full-image edit when masking fails. logger.warning(f"Inpaint compose failed: {comp_err}") @@ -2574,7 +2622,9 @@ def setup_gallery_routes() -> APIRouter: height = int((body or {}).get("height") or 0) except (TypeError, ValueError): width = height = 0 - if height > width: + if width > 0 and height > 0: + size = f"{width}x{height}" + elif height > width: size = "1024x1536" elif width > height: size = "1536x1024" @@ -2587,6 +2637,9 @@ def setup_gallery_routes() -> APIRouter: # The editor's model picker may choose a specific (cloud) image model; # forward it so settings defaults only apply when none is selected. model_choice = str((body or {}).get("model") or "").strip() + if body.get("_use_image_settings") is True: + from src.editor_image_settings import editor_image_models + model_choice = editor_image_models()["generation"] payload = {"prompt": prompt, "size": size} if model_choice: payload["model"] = model_choice diff --git a/src/agent_tools/editor_tools.py b/src/agent_tools/editor_tools.py index aec99a56a..9e1badf7a 100644 --- a/src/agent_tools/editor_tools.py +++ b/src/agent_tools/editor_tools.py @@ -191,7 +191,7 @@ def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[ db.close() -async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None) -> bool: +async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None, native_size: bool = False) -> bool: params: Dict[str, Any] = { "dataUrl": data_url, "name": name, @@ -199,6 +199,8 @@ async def _place_layer(data_url: str, name: str, owner: Optional[str], session: "blend": blend or "normal", "opacity": opacity, } + if native_size: + params["nativeSize"] = True # Forward the edit mode so the client can build a difference mask for # instruction-mode results: the whole image was regenerated under an # instruction prompt, so untouched pixels must stay pixel-identical (the @@ -506,6 +508,8 @@ class AiEditImageTool: } _t["image"] = time.perf_counter() payload: Dict[str, Any] = {"image": image} + if args.get("_use_image_settings") is True: + payload["_use_image_settings"] = True model_choice = str(args.get("model") or "").strip() mask_used = False place_mode: Optional[str] = None @@ -609,10 +613,11 @@ class AiEditImageTool: f"AI {op}", owner, session, - masked=mask_used, + masked=mask_used and data.get("full_frame") is not True, blend=str(args.get("blend") or "normal"), opacity=float(args.get("opacity") or 100), mode=place_mode, + native_size=data.get("full_frame") is True, ) info["placed"] = placed if placed: @@ -687,6 +692,7 @@ class GenerateImageLayerTool: str(args.get("name") or "Generated image"), owner, session, + native_size=True, masked=bool(args.get("mask")), blend=str(args.get("blend") or "normal"), opacity=float(args.get("opacity") or 100), diff --git a/src/ai_interaction.py b/src/ai_interaction.py index dd772eff1..ad8df6676 100644 --- a/src/ai_interaction.py +++ b/src/ai_interaction.py @@ -179,8 +179,13 @@ def _resolve_model(spec: str, owner: Optional[str] = None, model_type: Optional[ # served internal id. Include pinned/cached ids in the match set # so chat sessions using the HF repo id still resolve. Do not use # stale cached aliases when the endpoint itself is unreachable. - if model_type == "image" and endpoint_reachable: - for extra in _json_list(getattr(ep, "pinned_models", None)) + _json_list(getattr(ep, "cached_models", None)): + if endpoint_reachable: + # Gateway image models may live in a dedicated catalogue, + # so explicitly pinned IDs are valid on mixed endpoints too. + extras = _json_list(getattr(ep, "pinned_models", None)) + if model_type == "image": + extras += _json_list(getattr(ep, "cached_models", None)) + for extra in extras: if extra not in model_ids: model_ids.append(extra) @@ -1200,6 +1205,39 @@ def image_editing_provider_available(owner: Optional[str] = None) -> bool: return False +def _uses_openrouter_chat_images(model_id: str) -> bool: + # GPT-5 Image is an LLM wrapper, unlike native gpt-image-* image models. + leaf = model_id_leaf(model_id).lower() + return leaf.startswith("gpt-") and "-image" in leaf and not leaf.startswith("gpt-image-") + + +def _chat_image_results(data: dict) -> list: + images = [] + for choice in data.get("choices") or []: + message = choice.get("message") or {} + for item in message.get("images") or []: + url = (item.get("image_url") or {}).get("url") + if not isinstance(url, str): + continue + if url.startswith("data:image/") and ";base64," in url: + images.append({"b64_json": url.split(",", 1)[1]}) + elif url.startswith(("https://", "http://")): + images.append({"url": url}) + return images + + +def _image_response_error(data: dict, model_id: str) -> Optional[str]: + errors = [data.get("error")] + errors.extend(choice.get("error") for choice in data.get("choices") or []) + for error in errors: + if not error: + continue + message = str(error.get("message") or "Image provider failed") if isinstance(error, dict) else str(error) + hint = " Choose a currently supported image model in AI settings." if "deprecated" in message.lower() else "" + return f"Image provider failed for '{model_id}': {message}{hint}" + return None + + async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict: """Generate an image using an image-capable model (e.g. gpt-image-1). @@ -1342,8 +1380,19 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/") images_url = base_url + "/images/generations" from src.model_capability_readers.base import detect_vendor + chat_images = detect_vendor(url) == "openrouter" and _uses_openrouter_chat_images(model_id) if detect_vendor(url) == "openrouter": images_url = base_url + "/images" + if chat_images: + images_url = base_url + "/chat/completions" + + # Capture the requested geometry before older OpenAI size restrictions. + from src.image_model_ids import image_aspect_ratio + try: + requested_width, requested_height = (int(value) for value in size.lower().split("x")) + requested_ratio = image_aspect_ratio(requested_width, requested_height) + except (TypeError, ValueError): + requested_ratio = "1:1" # Validate size for cloud image models (local diffusion accepts any WxH) valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"} @@ -1359,6 +1408,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne "n": 1, "size": size, } + if detect_vendor(url) == "openrouter": + payload.pop("size", None) + payload["aspect_ratio"] = requested_ratio # GPT image models and local diffusion support quality; DALL-E does not if is_gpt_image or is_local_diffusion: @@ -1372,7 +1424,16 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne try: # GPT image models can take 30-120s+ depending on quality async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)) as client: - resp = await client.post(images_url, json=payload, headers=headers) + request_payload = payload + if chat_images: + request_payload = { + "model": model_id, + "messages": [{"role": "user", "content": prompt}], + "modalities": ["image", "text"], + "stream": False, + "image_config": {"aspect_ratio": requested_ratio}, + } + resp = await client.post(images_url, json=request_payload, headers=headers) if resp.status_code != 200: error_text = resp.text[:500] @@ -1387,9 +1448,15 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne } data = resp.json() - images = data.get("data", []) + provider_error = _image_response_error(data, model_id) + if provider_error: + return {"error": provider_error, "untrusted_content": True} + images = _chat_image_results(data) if chat_images else data.get("data", []) if not images: - return {"error": "No images returned from API"} + return {"error": ( + f"Image model '{model_id}' returned no image. " + "Choose another image generation model in AI settings." + )} img = images[0] image_url = None @@ -1508,7 +1575,7 @@ async def do_edit_image( _settings = {} if not model_spec: - model_spec = _settings.get("image_model", "") + model_spec = _settings.get("image_edit_model") or _settings.get("image_model", "") if quality == "medium" and _settings.get("image_quality"): quality = _settings["image_quality"] if not model_spec: @@ -1517,6 +1584,10 @@ async def do_edit_image( try: try: def _call(): + try: + return _resolve_model(model_spec, owner=owner) + except ValueError: + pass try: return _resolve_model(model_spec, owner=owner, model_type="image") except TypeError as exc: @@ -1529,6 +1600,7 @@ async def do_edit_image( except ValueError: return {"error": f"No endpoint found with image model '{model_spec}'."} + requested_edit_size = size if not size or size == "auto": from PIL import Image from src.image_model_ids import image_edit_size @@ -1704,7 +1776,35 @@ async def do_edit_image( "image_url": {"url": f"data:{mime};base64,{image_b64}"}, }], } - resp = await client.post(base_url + "/images", json=edit_payload, headers=headers) + from src.image_model_ids import image_aspect_ratio + from PIL import Image as ReferenceImage + with ReferenceImage.open(path) as reference: + reference_width, reference_height = reference.size + if reference.getexif().get(274) in {5, 6, 7, 8}: + reference_width, reference_height = reference_height, reference_width + if requested_edit_size and requested_edit_size != "auto": + try: + reference_width, reference_height = (int(value) for value in requested_edit_size.lower().split("x")) + except (TypeError, ValueError): + pass + edit_payload.pop("size", None) + edit_payload["aspect_ratio"] = image_aspect_ratio(reference_width, reference_height) + if _uses_openrouter_chat_images(model_id): + edit_payload = { + "model": model_id, + "messages": [{"role": "user", "content": [ + {"type": "text", "text": prompt}, + {"type": "image_url", "image_url": { + "url": f"data:{mime};base64,{image_b64}", + }}, + ]}], + "modalities": ["image", "text"], + "stream": False, + "image_config": {"aspect_ratio": edit_payload["aspect_ratio"]}, + } + resp = await client.post(base_url + "/chat/completions", json=edit_payload, headers=headers) + else: + resp = await client.post(base_url + "/images", json=edit_payload, headers=headers) else: with path.open("rb") as f: files = {"image": (path.name, f, mime)} @@ -1747,7 +1847,12 @@ async def do_edit_image( } data = resp.json() - images = data.get("data", []) + provider_error = _image_response_error(data, model_id) + if provider_error: + return {"error": provider_error, "untrusted_content": True} + images = (_chat_image_results(data) + if is_openrouter and _uses_openrouter_chat_images(model_id) + else data.get("data", [])) if not images: return {"error": "No image returned from edit API"} diff --git a/src/editor_image_settings.py b/src/editor_image_settings.py new file mode 100644 index 000000000..8eaadbdc5 --- /dev/null +++ b/src/editor_image_settings.py @@ -0,0 +1,11 @@ +"""The shared model defaults used by Quick Edit's image operations.""" +from src.settings import load_settings + + +def editor_image_models(): + settings = load_settings() + generation = str(settings.get("image_model") or "").strip() + return { + "generation": generation, + "edit": str(settings.get("image_edit_model") or generation).strip(), + } diff --git a/src/image_model_ids.py b/src/image_model_ids.py index d7ec72c2f..9f2c87022 100644 --- a/src/image_model_ids.py +++ b/src/image_model_ids.py @@ -40,6 +40,15 @@ def image_edit_size(model_id: str, width: int, height: int) -> str: return f"{width}x{height}" +def image_aspect_ratio(width: int, height: int) -> str: + """Choose the nearest common Image API ratio, without distorting pixels.""" + if width <= 0 or height <= 0: + raise ValueError("Image dimensions must be positive") + ratios = ((1, 1), (16, 9), (9, 16), (3, 2), (2, 3), (4, 3), (3, 4), (21, 9)) + w, h = min(ratios, key=lambda ratio: abs(math.log((ratio[0] / ratio[1]) / (width / height)))) + return f"{w}:{h}" + + def looks_like_image_generation_model(model_id: str) -> bool: """Return True when a model id should use image generation routes. diff --git a/src/settings.py b/src/settings.py index 411cb6500..53c4c523a 100644 --- a/src/settings.py +++ b/src/settings.py @@ -46,6 +46,7 @@ DEFAULT_SETTINGS = { "agent_email_confirm": True, "image_gen_enabled": False, "image_model": "", + "image_edit_model": "", "image_quality": "medium", # Optional editor planner/caption endpoints. Empty uses configured chat # fallback without probing a developer-specific host. @@ -294,7 +295,7 @@ def is_setting_overridden(key: str) -> bool: # resolved by FastAPI deps; an empty/None owner falls through to the global. _PER_USER_KEYS = { "vision_model", "vision_enabled", "vision_model_fallbacks", - "image_model", "image_gen_enabled", "image_quality", + "image_model", "image_edit_model", "image_gen_enabled", "image_quality", # Default chat endpoint / model — without per-user resolution every new # account inherited whatever the most-recent admin picked, which then # got injected into the chat composer on first open. diff --git a/static/css/13-image-editor.css b/static/css/13-image-editor.css index dfa8312e2..7ddc795e6 100644 --- a/static/css/13-image-editor.css +++ b/static/css/13-image-editor.css @@ -264,7 +264,42 @@ /* The brush controls (Color/Size/preset/caret) hug their content; only the trailing slider section grows, so the Opacity/Flow/Softness group sits right after the caret instead of leaving a wide gap. */ -.ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; } +.ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; flex-direction: row; align-items: center; gap: 8px; } +.ge-controls--in-options #ge-brush-softness-row input[type="range"] { width: 88px; flex: 0 0 88px; } +#ge-brush-softness-row { + display: grid !important; + grid-template-columns: 14px max-content 88px; + column-gap: 8px; + align-items: center; +} +#ge-brush-softness-row:has(> .ge-slider-value) { + grid-template-columns: 14px max-content 88px 6ch; +} +#ge-brush-softness-row label { + flex: 0 0 auto; + min-width: max-content; + gap: 6px; +} +#ge-brush-softness-row #ge-brush-softness-label { + position: static; + min-width: 6ch; + margin: 0; + padding: 0; + white-space: nowrap; + text-align: right; + font-variant-numeric: tabular-nums; +} +.ge-controls--in-options #ge-brush-controls .ge-brush-size-row { + display: grid; + grid-template-columns: minmax(6ch, max-content) 96px auto; + column-gap: 8px; +} +.ge-controls--in-options .ge-brush-size-row label, +.ge-controls--in-options .ge-brush-size-row .ge-size-label { + min-width: 6ch; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} .ge-controls--in-options .ge-control-row { margin: 0 !important; flex: 0 0 auto; @@ -4607,6 +4642,8 @@ body.ge-editor-open textarea.ge-direct-text-editor { pointer-events: auto; } .ge-filter-modal { + max-height: calc(100dvh - 40px); + overflow-y: auto; width: 240px; background: color-mix(in srgb, var(--panel) 95%, transparent); border: 1px solid var(--border); @@ -4631,6 +4668,9 @@ body.ge-editor-open textarea.ge-direct-text-editor { gap: 2px; } .ge-filter-row label { + flex: 0 0 auto; + min-width: 0; + width: 100%; font-size: 10px; opacity: 0.7; display: flex; @@ -9732,6 +9772,28 @@ body.ge-editor-open .tour-hint { margin-left: auto; color: var(--color-muted); } +.ge-submenu .dropdown-item-compact { + box-sizing: border-box; + display: flex; + align-items: center; + height: 28px; + min-height: 28px; + max-height: 28px; + padding: 0 10px; + line-height: 1.2; + white-space: nowrap; +} +.ge-submenu .dropdown-item-compact svg { + flex-shrink: 0; + max-height: 16px; +} +@media (max-width: 820px) { + .ge-submenu .dropdown-item-compact { + height: 32px; + min-height: 32px; + max-height: 32px; + } +} .ge-submenu { display: none; position: absolute; diff --git a/static/index.html b/static/index.html index aafacc2e1..7db4cfede 100644 --- a/static/index.html +++ b/static/index.html @@ -1684,6 +1684,10 @@ +
+ + +

Recommended, not required — the vision agent plans edits and can double-check results.

+

@@ -41,6 +47,7 @@ export function createAiSettings({ container }) { function open(focusId) { el.hidden = false; + syncModels(); if (focusId) { const target = document.getElementById(focusId); if (target) { @@ -56,10 +63,59 @@ export function createAiSettings({ container }) { close(); } }); - const onKey = (e) => { if (e.key === 'Escape' && !el.hidden) close(); }; - document.addEventListener('keydown', onKey); + const onKey = (e) => { + if (e.key !== 'Escape' || el.hidden) return; + e.preventDefault(); + e.stopImmediatePropagation(); + close(); + }; + window.addEventListener('keydown', onKey, true); container.appendChild(el); + const status = el.querySelector('#ge-ai-settings-save-status'); + const modelFields = [['ge-ai-model', 'image_model'], ['ge-ai-command-model', 'image_edit_model']]; + function applyModels(settings) { + for (const [id, key] of modelFields) { + if (!(key in settings)) continue; + const select = el.querySelector(`#${id}`); + const model = settings[key] || ''; + let option = [...select.options].find(option => { + const value = option.value; + return model ? value.slice(value.indexOf('::') + 2) === model : value === ''; + }); + if (!option && model && select.options.length) { + option = document.createElement('option'); + option.value = `::${model}`; + option.textContent = `${model} (unavailable)`; + option.disabled = true; + select.append(option); + } + if (option) select.value = option.value; + } + updateFooterEditModel(); + } + async function syncModels() { + try { applyModels(await readImagePreferences()); } + catch (error) { status.textContent = error.message; } + } + for (const [id, key] of modelFields) { + el.querySelector(`#${id}`).addEventListener('change', async (event) => { + const raw = event.target.value; + if (raw === '__serve_cookbook__') return; + const model = raw.includes('::') ? raw.slice(raw.indexOf('::') + 2) : ''; + try { + status.textContent = 'Saving…'; + await saveImagePreferences({ [key]: model }); + status.textContent = 'Saved'; + } catch (error) { + await syncModels(); + status.textContent = error.message; + } + }); + } + const onPreferencesChanged = event => applyModels(event.detail); + window.addEventListener('image-preferences-changed', onPreferencesChanged); + container.addEventListener('ge-image-models-ready', syncModels); const strengthInput = el.querySelector('#ge-look-strength'); const strengthVal = el.querySelector('#ge-look-strength-val'); @@ -77,7 +133,9 @@ export function createAiSettings({ container }) { } function destroy() { - document.removeEventListener('keydown', onKey); + window.removeEventListener('keydown', onKey, true); + window.removeEventListener('image-preferences-changed', onPreferencesChanged); + container.removeEventListener('ge-image-models-ready', syncModels); el.remove(); } diff --git a/static/js/editor/ai-tool-runner.js b/static/js/editor/ai-tool-runner.js index 5ea51de1f..d9e438869 100644 --- a/static/js/editor/ai-tool-runner.js +++ b/static/js/editor/ai-tool-runner.js @@ -76,7 +76,12 @@ export function createApplyImageTool({ if (sel.endpoint) extraPayload._endpoint = sel.endpoint; if (sel.model && !extraPayload._model) extraPayload._model = sel.model; } - const flatCanvas = flatten(); + const removeBackground = endpoint === '/api/image/remove-bg'; + const targetLayer = removeBackground ? state.layers.find(layer => layer.id === state.activeLayerId) : null; + if (removeBackground && (!targetLayer?.canvas || targetLayer.kind === 'adjustment' || targetLayer.locks?.pixels)) { + throw new Error('Select an unlocked image layer first'); + } + const flatCanvas = removeBackground ? targetLayer.canvas : flatten(); const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1]; const body = { image: imageB64, ...extraPayload }; const res = await fetch(endpoint, { @@ -96,6 +101,23 @@ export function createApplyImageTool({ const img = await decodeAIImage(data.image, operation.signal); operation.signal.throwIfAborted(); if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4) + if (removeBackground) { + if (!state.layers.includes(targetLayer)) return; + saveState('Remove background'); + // The returned pixels replace the editable source, not just its preview. + targetLayer.kind = 'raster'; + targetLayer.placed = null; + targetLayer.text = null; + targetLayer.shape = null; + targetLayer.ctx.clearRect(0, 0, targetLayer.canvas.width, targetLayer.canvas.height); + targetLayer.ctx.drawImage(img, 0, 0, targetLayer.canvas.width, targetLayer.canvas.height); + targetLayer._adjCacheKey = null; + targetLayer._adjFinalKey = null; + composite(); + renderLayerPanel(); + uiModule?.showToast('Background removed'); + return targetLayer; + } { saveState(); const layer = createLayer(layerName, state.imgWidth, state.imgHeight); diff --git a/static/js/editor/build/controls.js b/static/js/editor/build/controls.js index fafa4a0ac..1e55574a1 100644 --- a/static/js/editor/build/controls.js +++ b/static/js/editor/build/controls.js @@ -55,6 +55,11 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
+
+ + + +
@@ -94,11 +99,6 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
-
- - - -
Eraser
diff --git a/static/js/editor/canvas-events.js b/static/js/editor/canvas-events.js index d6b4747ed..1a087921b 100644 --- a/static/js/editor/canvas-events.js +++ b/static/js/editor/canvas-events.js @@ -78,6 +78,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, const UI_CHROME_SELECTOR = '.ge-topbar, .ge-toolbar, .ge-controls, .ge-right-panel, ' + '.ge-dock, .ge-options-bar, .ge-canvas-toolbar, button, input, select, textarea'; const isUiChrome = e => !!e.target?.closest?.(UI_CHROME_SELECTOR); + const canStartOutside = () => ['marquee', 'lasso', 'crop'].includes(state.tool); // Only in-progress selection/crop drags are affected by the chrome // guard. A committed cropRect/selection is a resting state — clicking // its Apply button must not be treated as "strayed onto chrome". @@ -143,15 +144,16 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, }; state.mainCanvas.addEventListener('pointerup', endPen); state.mainCanvas.addEventListener('pointercancel', endPen); - // Lasso and Crop can start OUTSIDE the canvas — fallback mousedown on the + // Selection and Crop can start OUTSIDE the canvas — fallback mousedown on the // surrounding canvas-area so the user can begin a path/rect in the empty // space around the image. Other tools stay canvas-only. canvasArea.addEventListener('mousedown', (e) => { - if (state.tool !== 'lasso' && state.tool !== 'crop') return; + if (!canStartOutside() || Date.now() < suppressMouseUntil) return; + if (e.button !== 0 || isDirectPanIntent(state.tool, state.spacePanActive, e.button)) return; if (e.target === state.mainCanvas) return; // already handled // Ignore presses on editor chrome inside the area (the crop Apply panel, // popups, buttons) so clicking Apply does not start a fresh crop. - if (e.target.closest?.('button, input, select, textarea, .ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return; + if (isUiChrome(e) || e.target.closest?.('.ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-fx-menu')) return; beginDraw(e); }); state.mainCanvas.addEventListener('mouseenter', (e) => { @@ -305,6 +307,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, let panning = false; let pid = null; let transformPointerId = null; + let outsideDrawPointerId = null; let startX = 0, startY = 0; let startPanX = 0, startPanY = 0; const getOffset = () => ({ x: state.panX || 0, y: state.panY || 0 }); @@ -315,10 +318,23 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, }; canvasArea.addEventListener('pointerdown', (e) => { const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button); - // With the crop (or lasso) tool, an empty-area press draws a rect/path + // With a selection or crop tool, an empty-area press draws a rect/path // rather than panning — so the crop can be pulled out past the image. // Space-held / middle-mouse (directPan) still pans. - if ((state.tool === 'lasso' || state.tool === 'crop') && !directPan) return; + if (canStartOutside() && !directPan) { + if (e.target === state.mainCanvas || isUiChrome(e) || + e.target.closest?.('.ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-fx-menu')) return; + // Mouse starts through mousedown; pen/touch need their own captured path. + if (e.pointerType === 'pen' || e.pointerType === 'touch') { + if (outsideDrawPointerId !== null || e.button !== 0) return; + outsideDrawPointerId = e.pointerId; + suppressMouseUntil = Date.now() + 500; + try { canvasArea.setPointerCapture(e.pointerId); } catch {} + beginDraw(e); + e.preventDefault(); + } + return; + } if (e.target.closest('button, input, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return; const onImage = e.target === state.mainCanvas || e.target === state.transformOverlay; if (onImage && !directPan) return; @@ -357,6 +373,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, e.preventDefault(); }); canvasArea.addEventListener('pointermove', (e) => { + if (outsideDrawPointerId !== null && e.pointerId === outsideDrawPointerId) { + suppressMouseUntil = Date.now() + 500; + if (!isUiChrome(e)) continueDraw(e); + e.preventDefault(); + return; + } if (transformPointerId !== null && e.pointerId === transformPointerId) { continueDraw(e); return; @@ -370,6 +392,14 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, applyOffset(next.x, next.y); }); const endPan = (e) => { + if (outsideDrawPointerId !== null && (!e || e.pointerId === outsideDrawPointerId)) { + const capturedId = outsideDrawPointerId; + outsideDrawPointerId = null; + suppressMouseUntil = Date.now() + 500; + if (e?.type === 'pointercancel') cancelDraw?.(); + else if (e) endDraw(e); + try { canvasArea.releasePointerCapture(capturedId); } catch {} + } if (transformPointerId !== null && (!e || e.pointerId === transformPointerId)) { const capturedId = transformPointerId; transformPointerId = null; diff --git a/static/js/editor/editor-agent.js b/static/js/editor/editor-agent.js index a6ceff122..34d470d63 100644 --- a/static/js/editor/editor-agent.js +++ b/static/js/editor/editor-agent.js @@ -21,6 +21,7 @@ export const EDITOR_AGENT_VERSION = '1.0'; export const DEFAULT_AGENT_ENDPOINT = '/api/editor/agent'; import { formatCropRect, isGenericIntent } from './nl-edit.js'; +import { waitForImagePreferences } from '../settings/imagePreferences.js'; export function createEditorAgent({ control, @@ -28,6 +29,7 @@ export function createEditorAgent({ fetchImpl = null, getSession = null, getModel = null, + getGenerationModel = null, getAgentModel = null, onStep = null, } = {}) { @@ -145,6 +147,7 @@ export function createEditorAgent({ activeStep = report; executed = []; running = true; + await waitForImagePreferences(); // Honest before-image (captured before any client pre-step) for the // imperceptible-edit check, then the history mark scoping the undo group. @@ -194,6 +197,8 @@ export function createEditorAgent({ signal: signal || undefined, body: JSON.stringify({ prompt, session, editor_session: session, state, screenshot, + _use_image_settings: true, + generation_model: getGenerationModel ? getGenerationModel() : '', model: (modelInfo && modelInfo.model) || '', endpoint: (modelInfo && modelInfo.endpoint) || '', model_label: (modelInfo && modelInfo.label) || '', diff --git a/static/js/editor/effects-worker.js b/static/js/editor/effects-worker.js index 72d71b773..42fbc0f11 100644 --- a/static/js/editor/effects-worker.js +++ b/static/js/editor/effects-worker.js @@ -132,7 +132,7 @@ function renderEffects(source, effects, masks) { const contribution = maskedContribution(next, masks[index]); const blended = copyCanvas(current); const blendCtx = blended.getContext('2d'); - if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over'; + if (effect.type === 'drop-shadow' && !effect.params.inside) blendCtx.globalCompositeOperation = 'destination-over'; blendCtx.globalAlpha = effect.opacity; blendCtx.drawImage(contribution, 0, 0); blendCtx.globalAlpha = 1; diff --git a/static/js/editor/effects.js b/static/js/editor/effects.js index 83dfa5a77..9fb39fbdc 100644 --- a/static/js/editor/effects.js +++ b/static/js/editor/effects.js @@ -81,6 +81,7 @@ export function normalizeEffect(effect) { params.blendMode = typeof params.blendMode === 'string' ? params.blendMode : defaults.blendMode; } if (type === 'drop-shadow') { + params.inside = params.inside === true || params.inside === 1; params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color; for (const key of ['opacity', 'blur', 'x', 'y']) params[key] = Number(params[key]) || 0; params.opacity = Math.max(0, Math.min(1, params.opacity)); @@ -291,7 +292,7 @@ export function renderEffects(source, effects = [], shouldContinue = () => true) : next; const blended = copyCanvas(current); const blendCtx = blended.getContext('2d'); - if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over'; + if (effect.type === 'drop-shadow' && !effect.params.inside) blendCtx.globalCompositeOperation = 'destination-over'; blendCtx.globalAlpha = effect.opacity; blendCtx.drawImage(contribution, 0, 0); blendCtx.globalAlpha = 1; diff --git a/static/js/editor/image-generation.js b/static/js/editor/image-generation.js new file mode 100644 index 000000000..d7c443959 --- /dev/null +++ b/static/js/editor/image-generation.js @@ -0,0 +1,71 @@ +import { decodeAIImage } from './ai-operation.js'; +import { waitForImagePreferences } from '../settings/imagePreferences.js'; + +export function createImageGenerationAction({ + getDocument, placeResult, getSignal = () => null, onProgress = () => {}, + formatError = info => info.detail || 'Image generation failed', + fetchImpl = (...args) => fetch(...args), timeoutMs = 330000, +}) { + return async function generateImage(params = {}) { + const document = getDocument(); + if (!document.key) throw new Error('Open a document before generating an image'); + const controller = new AbortController(); + const parentSignal = getSignal(); + const cancel = () => controller.abort(); + if (parentSignal?.aborted) cancel(); + parentSignal?.addEventListener('abort', cancel, { once: true }); + let timedOut = false; + const deadline = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs); + const started = Date.now(); + const progress = setInterval(() => onProgress(Math.floor((Date.now() - started) / 1000)), 1000); + // A selection can change while the provider works; preserve this request's mask. + let mask = null; + try { + if (params.masked && document.mask) { + mask = globalThis.document.createElement('canvas'); + mask.width = document.mask.width; mask.height = document.mask.height; + mask.getContext('2d').drawImage(document.mask, 0, 0); + } + await waitForImagePreferences(); + controller.signal.throwIfAborted(); + let response; + try { response = await fetchImpl(window.geGenerateImageEndpoint || '/api/image/generate', { + method: 'POST', credentials: 'same-origin', signal: controller.signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ _use_image_settings: true, prompt: params.prompt || '', + width: Math.max(1, Math.round(Number(params.width) || document.width)), + height: Math.max(1, Math.round(Number(params.height) || document.height)) }), + }); } catch (error) { + if (controller.signal.aborted) throw error; + throw new Error(formatError({ network: true, detail: error.message })); + } + let data; + try { data = await response.json(); } + catch { throw new Error(response.ok ? 'Image server returned an invalid response' : `Image server failed (${response.status})`); } + if (!response.ok || data.error) throw new Error(formatError({ status: response.status, + detail: data.detail || data.error || `Image server failed (${response.status})` })); + const image = data.image || data.images?.[0]; + if (!image) throw new Error('Image generation returned no image'); + const decoded = await decodeAIImage(image, controller.signal); + controller.signal.throwIfAborted(); + const current = getDocument(); + if (current.key !== document.key || current.width !== document.width || current.height !== document.height) { + throw new Error('The document changed during generation. The result was not placed in another document.'); + } + const canvas = globalThis.document.createElement('canvas'); + canvas.width = decoded.naturalWidth; canvas.height = decoded.naturalHeight; + const context = canvas.getContext('2d'); + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = 'high'; + context.drawImage(decoded, 0, 0); + return placeResult(canvas, 'Generated', mask); + } catch (error) { + if (timedOut) throw new Error('Image generation timed out. Try again or choose a faster image model.'); + throw error; + } finally { + clearTimeout(deadline); + clearInterval(progress); + parentSignal?.removeEventListener('abort', cancel); + } + }; +} diff --git a/static/js/editor/image-result-geometry.js b/static/js/editor/image-result-geometry.js new file mode 100644 index 000000000..1a2be3ee5 --- /dev/null +++ b/static/js/editor/image-result-geometry.js @@ -0,0 +1,11 @@ +export function imageResultGeometry(width, height, canvasWidth, canvasHeight) { + const matchingRatio = Math.abs(width * canvasHeight - height * canvasWidth) <= Math.max(canvasWidth, canvasHeight); + if (matchingRatio) { + const scale = Math.max(canvasWidth / width, canvasHeight / height); + const drawWidth = width * scale, drawHeight = height * scale; + return { width: canvasWidth, height: canvasHeight, offsetX: 0, offsetY: 0, + drawX: (canvasWidth - drawWidth) / 2, drawY: (canvasHeight - drawHeight) / 2, drawWidth, drawHeight }; + } + return { width, height, offsetX: Math.round((canvasWidth - width) / 2), offsetY: Math.round((canvasHeight - height) / 2), + drawX: 0, drawY: 0, drawWidth: width, drawHeight: height }; +} diff --git a/static/js/editor/keyboard-shortcuts.js b/static/js/editor/keyboard-shortcuts.js index 912a6cff7..646fb66c5 100644 --- a/static/js/editor/keyboard-shortcuts.js +++ b/static/js/editor/keyboard-shortcuts.js @@ -67,7 +67,7 @@ export function wireKeyboardShortcuts(deps) { toolbar, toolKeyMap, composite, saveState, undo, redo, toggleShortcuts, confirmTransform, cancelTransform, startTransform, nudgeTransform, - resizeCustomPrompt, addEmptyLayer, brushSizeSync, + resizeCustomPrompt, addEmptyLayer, brushSizeSync, openLayerAdjustment, invertSelection, wandDeleteSelection, wandCopyToNewLayer, copyPixelsToClipboard, lassoDeleteSelection, lassoCopyToLayer, @@ -181,6 +181,12 @@ export function wireKeyboardShortcuts(deps) { return; } // Save shortcuts — match the hints shown in the Save dropdown. + if (e.shiftKey && !e.altKey && e.code === 'KeyU') { + e.preventDefault(); + e.stopPropagation(); + openLayerAdjustment?.('hue-saturation'); + return; + } if ((e.key === 's' || e.key === 'S') && !e.altKey) { e.preventDefault(); document.getElementById(e.shiftKey ? 'ge-export-gallery' : 'ge-save')?.click(); @@ -221,11 +227,12 @@ export function wireKeyboardShortcuts(deps) { addEmptyLayer(); return; } - // Ctrl+Alt+L — Levels adjustment. The Image menu owns the row and its - // dialog, so click it instead of duplicating the adjustment here. + // Ctrl+Alt+L edits the current layer, leaving Levels controls open. if (e.altKey && e.code === 'KeyL') { - const row = document.querySelector('[data-image-action="adj-levels"]'); - if (row) { e.preventDefault(); e.stopPropagation(); row.click(); return; } + e.preventDefault(); + e.stopPropagation(); + openLayerAdjustment?.('levels'); + return; } // Ctrl/Cmd+J duplicates the active layer through the layer panel's // existing implementation, which preserves masks and effects. diff --git a/static/js/editor/layer-panel.js b/static/js/editor/layer-panel.js index 4d943d13b..f31b94e05 100644 --- a/static/js/editor/layer-panel.js +++ b/static/js/editor/layer-panel.js @@ -2233,6 +2233,7 @@ export function createLayerPanelRenderer(deps) { if (dragSortModule) { dragSortModule.enable('ge-layers-list', '.ge-layer-item', { instanceKey: 'ge-layers', + excludeSelector: '.ge-adj-sub-item', threshold: 4, ignoreSelector: 'button, input, select, textarea, a, [contenteditable="true"], .ge-adj-sub-item', onReorder: (orderedItems) => { @@ -2258,6 +2259,9 @@ export function createLayerPanelRenderer(deps) { state.layers = newLayers; normalizeLayerClipping(state); composite(); + // Effects, adjustments and masks are sibling rows, not children + // of the dragged row. Rebuild them under their reordered owner. + render(); } }, }); diff --git a/static/js/editor/layer-styles.js b/static/js/editor/layer-styles.js index fb424cba1..30661bf71 100644 --- a/static/js/editor/layer-styles.js +++ b/static/js/editor/layer-styles.js @@ -6,7 +6,7 @@ const size = () => number('size', 'Size', 8, 0, 100); const angle = () => number('angle', 'Angle', 120, -180, 180, 'deg'); export const LAYER_STYLES = { 'bevel-emboss': { label: 'Bevel & Emboss', controls: [size(), angle(), number('depth', 'Depth', 100, 0, 300, '%'), color('highlight', 'Highlight', '#ffffff'), color('color', 'Shadow', '#000000'), opacity()] }, - 'inner-shadow': { label: 'Inner Shadow', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 6, 0, 100)] }, + 'inner-shadow': { label: 'Inner Shadow', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 6, 0, 300)] }, 'inner-glow': { label: 'Inner Glow', controls: [color('color', 'Color', '#ffffff'), opacity(), size()] }, satin: { label: 'Satin', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 12, 0, 100)] }, 'gradient-overlay': { label: 'Gradient Overlay', controls: [color('color', 'Start', '#e06c75'), color('endColor', 'End', '#ffffff'), opacity(), angle(), number('scale', 'Scale', 100, 10, 300, '%')] }, @@ -32,6 +32,13 @@ function surface(width, height) { } export function renderDropShadow(source, params) { + if (params.inside) { + return renderLayerStyle(source, 'inner-shadow', { + color: params.color, opacity: params.opacity * 100, size: params.blur / 2, + distance: Math.hypot(params.x, params.y), + angle: Math.atan2(-params.y, params.x) * 180 / Math.PI, + }); + } // Tint alpha first so RGB content cannot leak into the shadow. Draw only // this silhouette; compositing the source again thickens translucent edges. const silhouette = surface(source.width, source.height); diff --git a/static/js/editor/quick-edit-job.js b/static/js/editor/quick-edit-job.js new file mode 100644 index 000000000..2d847b025 --- /dev/null +++ b/static/js/editor/quick-edit-job.js @@ -0,0 +1,13 @@ +export function finishQuickEditJob(job, result) { + if (job.cancelled || result?.cancelled) { + job.cancelled = true; + job.status = 'cancelled'; + job.detail = 'Cancelled'; + } else if (job.status === 'error' || result?.ok === false) { + job.status = 'error'; + job.detail = result?.error || result?.message || (job.detail !== 'Working…' && job.detail) || 'Edit failed'; + } else { + job.status = 'done'; + job.detail = result?.message || 'Done'; + } +} diff --git a/static/js/editor/stroke-pipeline.js b/static/js/editor/stroke-pipeline.js index 32275b0aa..4151fde85 100644 --- a/static/js/editor/stroke-pipeline.js +++ b/static/js/editor/stroke-pipeline.js @@ -14,19 +14,26 @@ function toolSettings(tool) { let _lastColor = null; let _lastTransparentColor = null; -function transparentColor(color) { - if (color === _lastColor && _lastTransparentColor) return _lastTransparentColor; - _lastColor = color; +function transparentColor(color, alpha = 0) { + const key = `${color}|${alpha}`; + if (key === _lastColor && _lastTransparentColor) return _lastTransparentColor; + _lastColor = key; + _lastTransparentColor = colorWithAlpha(color, alpha); + return _lastTransparentColor; +} + +function colorWithAlpha(color, alpha) { if (color.startsWith('#')) { const raw = color.slice(1); const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw; const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16)); - _lastTransparentColor = `rgba(${rgb.join(',')},0)`; - return _lastTransparentColor; + return `rgba(${rgb.join(',')},${alpha})`; } const match = color.match(/rgba?\(([^)]+)\)/); - _lastTransparentColor = match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)'; - return _lastTransparentColor; + if (!match) return `rgba(0,0,0,${alpha})`; + const components = match[1].split(','); + const originalAlpha = components.length > 3 ? Number(components[3]) : 1; + return `rgba(${components.slice(0, 3).join(',')},${alpha * originalAlpha})`; } function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) { @@ -38,8 +45,19 @@ function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) { ctx.fillStyle = color; } else { const gradient = ctx.createRadialGradient(x, y, radius * hard, x, y, radius); - gradient.addColorStop(0, color); - gradient.addColorStop(1, transparentColor(color)); + if (softness > 1) { + // Above the old maximum, remove the firm core with a progressively + // gentler alpha profile while keeping the selected brush diameter. + ctx.fillStyle = color; + const normalized = ctx.fillStyle; + for (let step = 0; step <= 16; step++) { + const t = step / 16; + gradient.addColorStop(t, transparentColor(normalized, (1 - t) ** softness)); + } + } else { + gradient.addColorStop(0, color); + gradient.addColorStop(1, transparentColor(color)); + } ctx.fillStyle = gradient; } ctx.beginPath(); @@ -274,6 +292,10 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit if (!PAINT_TOOLS.has(tool)) return; const layer = activeLayer(); if (!layer) return; + // Pixel writes invalidate adjusted previews even when adjustment settings + // themselves have not changed (otherwise retouch strokes appear late). + layer._adjFinalKey = null; + layer._adjCacheKey = null; if (tool === 'clone') return cloneStamp(sample, layer); if (tool === 'heal') { // A healing stroke without a picked source uses the convenient local @@ -294,7 +316,7 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit let operation = 'source-over'; let color = state.color; let alpha = opacity * flow; - let softness = Math.max(0, Math.min(1, settings.softness / 300)); + let softness = Math.max(0, Math.min(4, settings.softness / 300)); if (tool === 'eraser') { operation = 'destination-out'; color = 'rgba(0,0,0,1)'; } else if (tool === 'inpaint') { operation = state.inpaintEraseStroke ? 'destination-out' : 'source-over'; diff --git a/static/js/editor/stroke-tool-sliders.js b/static/js/editor/stroke-tool-sliders.js index 3b68da07d..b67ebba62 100644 --- a/static/js/editor/stroke-tool-sliders.js +++ b/static/js/editor/stroke-tool-sliders.js @@ -57,8 +57,11 @@ function wireToolSliders(prefix, fields) { // softness rises (the CSS already sets the radial gradient — we // just tween the inner solid radius to communicate the falloff). if (softPrev) { - const innerStop = Math.max(0, 60 - state[fields.softness] * 0.55); - softPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`; + const amount = state[fields.softness]; + const innerStop = Math.max(0, 60 - amount * 0.55); + softPrev.style.background = amount > 300 + ? `radial-gradient(circle, var(--fg), color-mix(in srgb, var(--fg) ${Math.round(100 * 0.5 ** (amount / 300))}%, transparent) 50%, transparent 100%)` + : `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`; } }); } diff --git a/static/js/editor/submenu-toggles.js b/static/js/editor/submenu-toggles.js index a6c98cc25..483833cbc 100644 --- a/static/js/editor/submenu-toggles.js +++ b/static/js/editor/submenu-toggles.js @@ -20,6 +20,23 @@ export function wireSubmenuToggles(menu) { menu.classList.remove('ge-flyout-open'); } }); + function positionPanel(parent) { + const panel = parent.nextElementSibling; + if (!panel?.classList.contains('ge-submenu')) return; + const row = parent.getBoundingClientRect(); + const origin = (panel.offsetParent || menu).getBoundingClientRect(); + const top = Math.max(8, Math.min(row.top, window.innerHeight - panel.offsetHeight - 8)); + const left = row.right + panel.offsetWidth > window.innerWidth - 8 + ? Math.max(8, row.left - panel.offsetWidth) : row.right - 2; + panel.style.top = `${top - origin.top + (panel.offsetParent?.scrollTop || 0)}px`; + panel.style.left = `${left - origin.left + (panel.offsetParent?.scrollLeft || 0)}px`; + } + for (const eventName of ['pointerover', 'focusin']) { + menu.addEventListener(eventName, (event) => { + const parent = event.target.closest('.ge-menu-parent'); + if (parent) positionPanel(parent); + }); + } menu.addEventListener('click', (e) => { const parent = e.target.closest('.ge-menu-parent'); if (!parent) return; @@ -34,10 +51,7 @@ export function wireSubmenuToggles(menu) { parent.setAttribute('aria-expanded', String(willOpen)); if (willOpen) { // Align the flyout with the clicked row rather than the menu top. - const panel = parent.nextElementSibling; - if (panel && panel.classList.contains('ge-submenu')) { - panel.style.top = parent.offsetTop + 'px'; - } + positionPanel(parent); } }); } diff --git a/static/js/editor/wire-topbar.js b/static/js/editor/wire-topbar.js index 0357e2063..179f37a0c 100644 --- a/static/js/editor/wire-topbar.js +++ b/static/js/editor/wire-topbar.js @@ -37,6 +37,7 @@ * }} deps */ import { state } from './state.js'; +import { topPortalZ } from '../toolWindowZOrder.js'; import { isLayerPixelLocked, isLayerTransparencyLocked } from './layer-groups.js'; const TOPBAR_MENU_IDS = ['ge-file-menu', 'ge-edit-menu', 'ge-view-menu', 'ge-image-menu', 'ge-layer-menu', 'ge-type-menu', 'ge-selection-menu', 'ge-filter-menu', 'ge-window-menu', 'ge-help-menu', 'ge-resize-menu', 'ge-save-menu']; @@ -101,6 +102,7 @@ export function wireTopbar(deps) { }; const positionSaveMenu = () => { const r = saveBtn.getBoundingClientRect(); + saveMenu.style.zIndex = String(topPortalZ()); saveMenu.style.top = `${r.bottom + 2}px`; saveMenu.style.right = `${Math.max(8, window.innerWidth - r.right)}px`; saveMenu.style.left = 'auto'; diff --git a/static/js/gallery.js b/static/js/gallery.js index 8804cb15d..d00964d39 100644 --- a/static/js/gallery.js +++ b/static/js/gallery.js @@ -1321,7 +1321,12 @@ function _renderEditorLanding() { // openEditor() now returns a Promise — it's async because the size // prompt is a styled modal. Await it before checking whether the // editor actually opened (the user may have cancelled). - await openEditor(null, null, null, 'New canvas'); + try { + await openEditor(null, null, null, 'New canvas'); + } catch (error) { + // openEditor reports the initialization error and tears down its shell. + console.error('[gallery] New canvas failed', error); + } if (!document.querySelector('#gallery-editor-container .gallery-editor')) { _renderEditorLanding(); } diff --git a/static/js/galleryEditor.js b/static/js/galleryEditor.js index c60b8e8d6..2c49b032c 100644 --- a/static/js/galleryEditor.js +++ b/static/js/galleryEditor.js @@ -277,8 +277,11 @@ import { alphaBounds as _maskAlphaBounds, featherMaskCanvas as _featherMaskCanva import { analyzeAutoTone as _analyzeAutoTone, applyAutoTone as _applyAutoTone, boxBlurRGB as _boxBlurRGB, skinMaskFromData as _skinMaskFromData, frequencySeparationSmooth as _frequencySeparationSmooth, blemishRemoval as _blemishRemoval, teethWhitening as _teethWhitening, featureExclusionMask as _featureExclusionMask, dilateAlpha as _dilateAlpha, subtractMask as _subtractMask, guidedClarity as _guidedClarity } from './editor/retouch.js'; import { wireClipboardAndDrop } from './editor/clipboard-and-drop.js'; import { wireAIModelSelectors, resolveInpaintModel, resolveQuickEditModel, resolveQuickEditAgentModel } from './editor/ai-models.js'; -import { createPreflight } from './editor/preflight.js'; import { createAiSettings } from './editor/ai-settings.js'; +import { waitForImagePreferences } from './settings/imagePreferences.js'; +import { createImageGenerationAction } from './editor/image-generation.js'; +import { finishQuickEditJob } from './editor/quick-edit-job.js'; +import { imageResultGeometry } from './editor/image-result-geometry.js'; import { lookRecipeSteps } from './editor/look-recipes.js'; import { wireInpaintButtons } from './editor/ai-inpaint.js?v=20260708match1'; import { wireAIToolsMisc } from './editor/ai-tools-misc.js'; @@ -356,7 +359,6 @@ function _computeTransformSnap(frame, center) { const _INPAINT_DEFAULT_BRUSH = 100; let _samAbortController = null; let _precisionGuides = null; -let _preflight = null; let _aiSettings = null; let _selectionAnimationFrame = null; let _selectionAnimationLast = 0; @@ -394,6 +396,12 @@ if (!window.__galleryEditEscHardGuardInstalled) { } if (e.target?.closest?.('#styled-confirm-overlay')) return; if (e.key === 'Escape') { + const aiSettings = document.querySelector('.ge-ai-settings:not([hidden])'); + if (aiSettings) { + e.preventDefault(); e.stopImmediatePropagation(); + aiSettings.querySelector('[data-ai-settings-close]')?.click(); + return; + } // Floating dialogs/popups that live outside the editor modal must close on // Escape reliably (their document-capture handlers can miss when focus is // outside them). Close the top-most one and stop here. @@ -838,6 +846,7 @@ function _setCanvasAiBusy(busy) { } let _aiProgressTimer = null; +let _aiProgressEpoch = 0; function _setAiProgress(frac, label) { const wrap = document.getElementById('ge-ai-command-progress'); if (!wrap) return; @@ -857,12 +866,17 @@ function _startAiProgress(requestId) { const endpointId = arguments[1] || ''; _stopAiProgress(); if (!requestId) return; + const epoch = _aiProgressEpoch; + let pending = false; const tick = async () => { + if (pending || epoch !== _aiProgressEpoch) return; + pending = true; try { const epQuery = endpointId ? `&endpoint_id=${encodeURIComponent(endpointId)}` : ''; const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}` + epQuery, { credentials: 'same-origin' }); if (!r.ok) return; const p = await r.json(); + if (epoch !== _aiProgressEpoch) return; const st = p.stage || ''; if (st === 'done' || st === 'error') { _setAiProgress(1, st === 'done' ? 'Done' : 'Failed'); return; } const frac = typeof p.progress === 'number' ? p.progress : 0; @@ -871,12 +885,13 @@ function _startAiProgress(requestId) { const verb = st === 'editing' ? 'Generating' : (st ? st[0].toUpperCase() + st.slice(1) : 'Working'); _setAiProgress(frac, `${verb}…${stepTxt}${eta}`); _setAiCommandStatus(`${verb}…${stepTxt}${eta}`, 'running'); - } catch (_) {} + } catch (_) {} finally { pending = false; } }; _aiProgressTimer = setInterval(tick, 1000); tick(); } function _stopAiProgress() { + _aiProgressEpoch++; if (_aiProgressTimer) { clearInterval(_aiProgressTimer); _aiProgressTimer = null; } _setAiProgress(null); } @@ -1074,6 +1089,7 @@ const _QUICK_EDIT_TOOL_SUGGESTIONS = Object.entries(_TOOLS).map(([id, t]) => ({ kind: 'Tool', icon: t.icon || '', toolId: id, + runPrompt: id === 'rembg' ? 'remove background' : null, aliases: [id], })); const _QUICK_EDIT_CATALOG = [..._QUICK_EDIT_TOOL_SUGGESTIONS, ..._AI_COMMAND_SUGGESTIONS]; @@ -1281,8 +1297,13 @@ function _wireAiCommandBox() { if (item.toolId) { _clickToolButton(item.toolId); _setAiCommandStatus(`${item.label} tool`, 'done'); + if (item.runPrompt) { + input.value = item.runPrompt; + syncClearButton(); + } hideSuggestions(); input.focus(); + if (run && item.runPrompt) form.requestSubmit(); return true; } input.value = item.insert; @@ -1471,6 +1492,24 @@ function _wireAiCommandBox() { // model (window.geEditorAI) just like the server would route it. const _executeQuickEditLocal = async (prompt) => { const p = prompt.toLowerCase(); + // Background-removal intent (shared with the routing guard so the regex + // can never drift between the two call sites). + if (_looksBackgroundRemoval(p)) { + await _runQuickControlOp('ai.removeBackground', {}); + // Chain a tone step when the prompt also asks for one + // ("remove the background and make it brighter", "...and warm it up"). + if (/\b(bright(?:er|en)?|lighten|exposure|well[-\s]*lit)\b/.test(p)) { + await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: 16, contrast: 4 } }); + } else if (/\b(dark(?:er|en)?|dimmer)\b/.test(p)) { + await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: -14, contrast: 4 } }); + } else if (/\b(warm(?:er)?)\b/.test(p)) { + await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: 18 } }); + } else if (/\b(cool(?:er)?)\b/.test(p)) { + await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: -18 } }); + } + _setAiCommandStatus('Background removal finished.', 'done'); + return; + } // A targeted recolour ("make only the red car blue", "recolour the rock to // teal") needs the model to repaint a specific object — never fake it with a // global colour tweak from the deterministic fast path. @@ -1933,24 +1972,6 @@ function _wireAiCommandBox() { return; } } - // Background-removal intent (shared with the routing guard so the regex - // can never drift between the two call sites). - if (_looksBackgroundRemoval(p)) { - await _runQuickControlOp('ai.removeBackground', {}); - // Chain a tone step when the prompt also asks for one - // ("remove the background and make it brighter", "...and warm it up"). - if (/\b(bright(?:er|en)?|lighten|exposure|well[-\s]*lit)\b/.test(p)) { - await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: 16, contrast: 4 } }); - } else if (/\b(dark(?:er|en)?|dimmer)\b/.test(p)) { - await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: -14, contrast: 4 } }); - } else if (/\b(warm(?:er)?)\b/.test(p)) { - await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: 18 } }); - } else if (/\b(cool(?:er)?)\b/.test(p)) { - await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: -18 } }); - } - _setAiCommandStatus('Background removal finished.', 'done'); - return; - } if (!/\b(text|title|caption|font|heading|subtitle)\b/.test(p) && /\b(upscale|higher\s*res|increase\s*resolution|bigger|2x|4x)\b/.test(p)) { // Use the real upscale op (Real-ESRGAN, basic-upscale fallback). The @@ -2814,8 +2835,8 @@ function _wireAiCommandBox() { if (_looksBackgroundRemoval(prompt)) { _setAiCommandStatus('Removing the background…', 'running'); try { - const done = await _executeQuickEditLocal(prompt); - if (done !== false) return { ok: true }; + await _executeQuickEditLocal(prompt); + return { ok: true }; } catch (err) { if (signal && signal.aborted) { _setAiCommandStatus('', ''); return { ok: false, cancelled: true }; } const msg = err && err.message ? err.message : String(err); @@ -2841,6 +2862,7 @@ function _wireAiCommandBox() { _setAiCommandStatus('Generated an image.', 'done'); return { ok: true, image: res }; } catch (err) { + if (signal?.aborted) return { ok: false, cancelled: true }; const msg = err && err.message ? err.message : String(err); _setAiCommandStatus(_isGenOfflineMsg(msg) ? msg : `Could not generate an image: ${msg}`, 'error'); return { ok: false, error: msg }; @@ -3083,19 +3105,22 @@ function _wireAiCommandBox() { job.controller = controller; renderJobStack(); _quickEditActiveJob = job; + let result; try { - await executeQuickEdit(job.prompt, { forceAi: !!job.forceAi, signal: controller ? controller.signal : null }); + result = await executeQuickEdit(job.prompt, { forceAi: !!job.forceAi, signal: controller ? controller.signal : null }); } catch (err) { if (!job.cancelled) { job.status = 'error'; job.detail = err?.message || 'AI edit failed'; } } finally { + _stopAiProgress(); + finishQuickEditJob(job, result); + _setAiCommandStatus(job.cancelled ? '' : job.detail, job.cancelled ? '' : job.status); if (_quickEditActiveJob === job) _quickEditActiveJob = null; job.onChange = null; job.controller = null; if (job.cancelled) continue; - if (job.status === 'running') job.status = 'done'; renderJobStack(); setTimeout(() => dismissJob(job), job.status === 'error' ? 3200 : 1600); } @@ -5307,6 +5332,9 @@ function _restoreState(snap) { } function undo() { + // End sampling before restoring pixels; pointer-up must not replay the + // unfinished stroke into the restored layer without a new history entry. + if (state.drawing) _strokeTool.tryEnd(); if (state.undoStack.length === 0) return; const target = state.undoStack.pop(); const cur = _snapshotState(); @@ -6522,6 +6550,11 @@ const _nudgeTransform = _transformSession.nudgeTransform; const _confirmTransform = _transformSession.confirmTransform; const _cancelTransform = _transformSession.cancelTransform; window.__galleryEditorHandleEscape = () => { + const aiSettings = document.querySelector('.ge-ai-settings:not([hidden])'); + if (aiSettings) { + aiSettings.querySelector('[data-ai-settings-close]')?.click(); + return true; + } const sizePrompt = document.getElementById('ge-canvas-size-overlay'); if (sizePrompt && getComputedStyle(sizePrompt).display !== 'none') { sizePrompt._cancelCanvasSize?.(); @@ -8051,6 +8084,7 @@ async function _canMutateLayerPixels(layer, action = 'editing pixels') { return false; } if (!['placed', 'text', 'shape'].includes(layer.kind)) return true; + if (action === 'cutting pixels') return _rasterizeLayerNow(layer); return _confirmRasterizeLayer(layer); } @@ -8249,10 +8283,13 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) { // standard slim red-thumb styling instead of the bare browser // default. Value chip on the same line as the label. const isColor = p.type === 'color'; + const isSelect = p.type === 'select'; rows += `
- - ${isColor + + ${isSelect + ? `` + : isColor ? `` : ``}
@@ -8273,7 +8310,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) { for (const p of params) values[p.key] = p.value; // Initial preview render. try { onPreview(values); } catch {} - overlay.querySelectorAll('input[data-key]').forEach(inp => { + overlay.querySelectorAll('input[data-key], select[data-key]').forEach(inp => { inp.addEventListener('input', (e) => { if (!isCurrent()) { cleanup(null); return; } const k = e.target.dataset.key; @@ -8304,7 +8341,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) { e.preventDefault(); cleanup(e.target?.dataset.action === 'cancel' ? null : values); } else if (e.key === 'Tab') { - const fields = [...overlay.querySelectorAll('input, button')]; + const fields = [...overlay.querySelectorAll('input, select, button')]; const index = fields.indexOf(document.activeElement); e.preventDefault(); fields[(index + (e.shiftKey ? -1 : 1) + fields.length) % fields.length]?.focus(); @@ -8316,7 +8353,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) { if (!overlay.isConnected || !isCurrent()) cleanup(null); }); observer.observe(state.container, { childList: true, subtree: true }); - overlay.querySelector('input, button')?.focus({ preventScroll: true }); + overlay.querySelector('input, select, button')?.focus({ preventScroll: true }); overlay.querySelector('[data-action="apply"]').addEventListener('click', () => cleanup(values)); overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => cleanup(null)); // Click outside the modal (on the dim backdrop) = cancel. @@ -8482,6 +8519,7 @@ function _shadowOffsets(values = {}) { return { x: Math.round(Math.cos(radians) * distance), y: Math.round(-Math.sin(radians) * distance), + inside: Number(values.inside) === 1, }; } @@ -8543,6 +8581,7 @@ async function _addRetainedEffect(type, presetName = null) { { key: 'color', label: 'Color', type: 'color', value: '#000000' }, { key: 'opacity', label: 'Opacity', min: 0, max: 100, step: 1, value: 45, suffix: '%' }, { key: 'blur', label: 'Blur', min: 0, max: 100, step: 1, value: 12, suffix: 'px' }, + { key: 'inside', label: 'Position', type: 'select', value: 0, options: [{ value: 0, label: 'Outside' }, { value: 1, label: 'Inside' }] }, { key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).distance, suffix: 'px' }, { key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).angle, suffix: '\u00b0' }, ]; @@ -8756,6 +8795,7 @@ async function _editRetainedEffect(layer, effect) { { key: 'color', label: 'Color', type: 'color', value: p.color || '#000000' }, { key: 'opacity', label: 'Opacity', min: 0, max: 100, step: 1, value: Math.round((Number(p.opacity) || 0) * 100), suffix: '%' }, { key: 'blur', label: 'Blur', min: 0, max: 100, step: 1, value: Number(p.blur) || 0, suffix: 'px' }, + { key: 'inside', label: 'Position', type: 'select', value: p.inside ? 1 : 0, options: [{ value: 0, label: 'Outside' }, { value: 1, label: 'Inside' }] }, { key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance(p).distance, suffix: 'px' }, { key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance(p).angle, suffix: '\u00b0' }, ]; @@ -9980,9 +10020,8 @@ function _buildEditor(container) { if (colorRow) colorRow.style.display = ['eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'liquify'].includes(toolId) ? 'none' : ''; const colorLabel = colorRow?.querySelector('label'); if (colorLabel) colorLabel.textContent = 'Color'; - // Per-tool stroke modifiers all live in the shared brush "More" menu so - // every brush-family tool has the same compact strip as the Brush. Only - // the group for the active tool is shown; the rest stay hidden. + // Secondary modifiers stay in More; brush softness remains in the main + // strip. Only the active tool's secondary group is shown. const strokeTool = (toolId === 'brush' || toolId === 'pixel' || toolId === 'dodge' || toolId === 'burn') ? 'brush' : toolId === 'eraser' ? 'eraser' : (toolId === 'clone' || toolId === 'heal') ? 'clone' @@ -9992,6 +10031,8 @@ function _buildEditor(container) { row.style.display = row.dataset.strokeTool === strokeTool ? '' : 'none'; }); const dodgeBurnRow = document.getElementById('ge-dodgeburn-intensity-row'); + const brushSoftnessRow = document.getElementById('ge-brush-softness-row'); + if (brushSoftnessRow) brushSoftnessRow.style.display = ['brush', 'dodge', 'burn'].includes(toolId) ? '' : 'none'; // Intensity sits in the strip next to Size, so it must show for Dodge and // Burn only (and never for the other brush-family tools). if (dodgeBurnRow) dodgeBurnRow.style.display = (toolId === 'dodge' || toolId === 'burn') ? '' : 'none'; @@ -10444,7 +10485,6 @@ function _buildEditor(container) { canvasArea.appendChild(state.transformOverlay); canvasArea.appendChild(_buildQuickMaskBar()); canvasArea.appendChild(_buildAiCommandBox()); - _preflight = createPreflight({ canvasArea }); _aiSettings = createAiSettings({ container }); try { window.geEditorAiSettings = _aiSettings; } catch (_) {} // On phones the rulers eat scarce canvas width; default them off. @@ -10637,19 +10677,10 @@ function _buildEditor(container) { const initialStatusTool = document.getElementById('ge-status-tool'); if (initialStatusTool) initialStatusTool.textContent = _TOOLS[state.tool]?.label || state.tool || ''; } - // Preflight checklist: greet the user on open (unless dismissed) and - // expose a footer button to re-open it. + // Keep model configuration in the AI settings dialog. try { const footer = container.querySelector('.ge-editor-footer'); if (footer) { - const setupBtn = document.createElement('button'); - setupBtn.type = 'button'; - setupBtn.id = 'ge-setup-btn'; - setupBtn.className = 'ge-setup-btn'; - setupBtn.title = 'Editor setup checklist'; - setupBtn.textContent = 'Setup'; - setupBtn.addEventListener('click', () => _preflight?.open()); - footer.appendChild(setupBtn); const aiBtn = document.createElement('button'); aiBtn.type = 'button'; aiBtn.id = 'ge-ai-settings-btn'; @@ -10659,7 +10690,6 @@ function _buildEditor(container) { aiBtn.addEventListener('click', () => _aiSettings?.open()); footer.appendChild(aiBtn); } - _preflight?.maybeOpen(); } catch (_) {} try { _initControlKit(container); } catch (_) {} let _lastLayoutDesktop = null; @@ -11779,6 +11809,14 @@ function _buildEditor(container) { // Keyboard shortcuts — full implementation in // editor/keyboard-shortcuts.js. wireKeyboardShortcuts({ + openLayerAdjustment: (type) => { + const layer = activeLayer(); + if (!layer || !layer.canvas || layer.kind === 'adjustment') { + uiModule?.showToast('Select an image layer first'); + return; + } + _openAdjPopup(layer, type, document.getElementById('ge-image-menu-btn')); + }, fillSelection: _doFillSelection, copyPixelsToClipboard: _copyPixelsToClipboard, toolbar, toolKeyMap: _toolKeyMap, @@ -12143,7 +12181,9 @@ function _buildEditor(container) { out.getContext('2d').drawImage(layer.canvas, off.x, off.y); return out; }; - const _aiEditCanvas = async (endpoint, payload = {}, { keepNaturalSize = false } = {}) => { + const _aiEditCanvas = async (endpoint, payload = {}, { keepNaturalSize = true } = {}) => { + await waitForImagePreferences(); + if (endpoint === '/api/image/inpaint') payload = { ...payload, _use_image_settings: true }; const flat = _aiEditSourceCanvas(); const image = flat.toDataURL('image/png').split(',')[1]; const requestId = 'qe-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); @@ -12154,7 +12194,7 @@ function _buildEditor(container) { } else { _startAiProgress(requestId); } - const body = JSON.stringify({ image, request_id: requestId, ...payload }); + const body = JSON.stringify({ image, width: flat.width, height: flat.height, request_id: requestId, ...payload }); // The image servers occasionally reject a request transiently (503 while a // model swaps, 502/504 gateways, dropped connections). Retry those a couple // of times with backoff so a hiccup doesn't silently leave the canvas @@ -12212,7 +12252,7 @@ function _buildEditor(container) { c.height = state.imgHeight; } c.getContext('2d').drawImage(img, 0, 0, c.width, c.height); - return { canvas: c, dataUrl: c.toDataURL('image/png') }; + return { canvas: c, dataUrl: c.toDataURL('image/png'), fullFrame: data.full_frame === true }; } finally { if (_timer) clearTimeout(_timer); if (_aiEditAbort === _ctl) _aiEditAbort = null; @@ -12221,12 +12261,17 @@ function _buildEditor(container) { }; const _placeResultLayer = (canvas, name, maskCanvas) => { _saveState(`AI: ${name}`); - const layer = createLayer(name, state.imgWidth, state.imgHeight); - layer.ctx.drawImage(canvas, 0, 0); + const geometry = imageResultGeometry(canvas.width, canvas.height, state.imgWidth, state.imgHeight); + const layer = createLayer(name, geometry.width, geometry.height); + const offset = { x: geometry.offsetX, y: geometry.offsetY }; + state.layerOffsets.set(layer.id, offset); + layer.ctx.imageSmoothingEnabled = true; + layer.ctx.imageSmoothingQuality = 'high'; + layer.ctx.drawImage(canvas, geometry.drawX, geometry.drawY, geometry.drawWidth, geometry.drawHeight); if (maskCanvas) { layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; - layer.ctx.drawImage(maskCanvas, 0, 0); + layer.ctx.drawImage(maskCanvas, -offset.x, -offset.y); layer.ctx.restore(); } state.layers.push(layer); @@ -12236,13 +12281,24 @@ function _buildEditor(container) { _renderLayerPanel(); return layer.id; }; + const _generateImage = createImageGenerationAction({ + formatError: _generationErrorMessage, + getDocument: () => ({ + key: state.editorOpen ? `${state.editorSessionToken}:${state.documentId}` : null, + width: state.imgWidth, height: state.imgHeight, mask: state.wandMask, + }), + getSignal: () => _quickEditActiveJob?.controller?.signal, + onProgress: seconds => _setAiCommandStatus(`Generating an image… ${seconds}s`, 'running'), + placeResult: _placeResultLayer, + }); const _aiInpaintSelection = async (prompt) => { if (!state.wandMask) throw new Error('make a selection first'); const layer = activeLayer() || _activeParentLayer(); if (!layer) throw new Error('no layer'); const mask = state.wandMask.toDataURL('image/png').split(',')[1]; - const { canvas } = await _aiEditCanvas('/api/image/inpaint', { mask, prompt: prompt || '', steps: 4 }); - return _placeResultLayer(canvas, 'Generative fill', state.wandMask); + const selection = state.wandMask; + const { canvas, fullFrame } = await _aiEditCanvas('/api/image/inpaint', { mask, prompt: prompt || '', steps: 4 }); + return _placeResultLayer(canvas, 'Generative fill', fullFrame ? null : selection); }; // Replace the background/scene while keeping the subject: the server derives // the subject mask, repaints the inverse (new background), harmonises colour @@ -12535,7 +12591,7 @@ function _buildEditor(container) { 'layers.delete': { description: 'Delete layers (defaults to selection).', run: (p) => { if (p && (p.id || p.index || p.name)) _controlSelect(_controlFindLayer(p)); _layerPanelRenderer.deleteSelectedLayers?.(); return true; } }, 'layers.setOpacity': { description: 'Set a layer opacity (0-1).', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState(`Change opacity of ${l.name}`); l.opacity = Math.max(0, Math.min(1, Number(p.opacity))); composite(); _renderLayerPanel(); return l.id; } }, 'layers.setBlend': { description: 'Set a layer blend mode.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState(`Change blend mode of ${l.name}`); l.blendMode = p.blendMode || p.mode || 'source-over'; composite(); _renderLayerPanel(); return l.id; } }, - 'layers.placeImage': { description: 'Place an image (data URL) as a NEW layer, masked to the current selection when one exists; never overwrites the original. Instruction mode (mode:"instruction" or differenceMask:true) keeps untouched areas pixel-identical via a difference map. {dataUrl,name,masked,blend,opacity,x,y,w,h,mode,differenceMask,diffThreshold,diffGrow,diffFeather}.', run: (p) => new Promise((resolve, reject) => { if (!p || !p.dataUrl) { reject(new Error('dataUrl required')); return; } const img = new Image(); img.onload = () => { try { _saveState(`Place ${p.name || 'image'}`); const dw = state.imgWidth, dh = state.imgHeight; const useDiff = (p.differenceMask === true) || String(p.mode || '').toLowerCase() === 'instruction'; const beforeSnap = useDiff ? flatten() : null; const layer = createLayer(p.name || 'Placed image', dw, dh); let tw = Number(p.w), th = Number(p.h); if (!(tw > 0) || !(th > 0)) { if (useDiff) { tw = dw; th = dh; } else { const scale = Math.min(dw / img.naturalWidth, dh / img.naturalHeight); tw = Math.max(1, Math.round(img.naturalWidth * scale)); th = Math.max(1, Math.round(img.naturalHeight * scale)); } } let tx = Number(p.x), ty = Number(p.y); if (!Number.isFinite(tx)) tx = useDiff ? 0 : Math.round((dw - tw) / 2); if (!Number.isFinite(ty)) ty = useDiff ? 0 : Math.round((dh - th) / 2); layer.ctx.imageSmoothingEnabled = true; layer.ctx.imageSmoothingQuality = 'high'; layer.ctx.drawImage(img, tx, ty, tw, th); if (p.masked !== false && state.wandMask) { layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(state.wandMask, 0, 0); layer.ctx.restore(); } else if (useDiff && beforeSnap) { const diff = _differenceMaskCanvas(beforeSnap, layer.canvas, { threshold: p.diffThreshold, grow: p.diffGrow, feather: p.diffFeather, edgeExcludePx: 0 }); const explicitEdge = p.edgeExcludePx != null ? Number(p.edgeExcludePx) : null; const sceneReplace = diff.changedFraction > 0.5; const edgeExclude = sceneReplace ? 0 : (explicitEdge != null ? explicitEdge : 3); if (edgeExclude > 0) _clearMaskEdgeBand(diff.mask, edgeExclude); layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(diff.mask, 0, 0); layer.ctx.restore(); if (sceneReplace) _edgeFillFromInterior(layer.ctx, dw, dh, 3); } if (p.blend) layer.blendMode = p.blend; if (p.opacity != null) layer.opacity = Math.max(0, Math.min(1, Number(p.opacity))); state.layers.push(layer); state.activeLayerId = layer.id; state.selectedLayerIds = [layer.id]; _renderLayerPanel(); composite(); resolve(layer.id); } catch (e) { reject(e); } }; img.onerror = () => reject(new Error('image load failed')); img.src = p.dataUrl; }) }, + 'layers.placeImage': { description: 'Place an image (data URL) as a NEW layer, masked to the current selection when one exists; never overwrites the original. Instruction mode (mode:"instruction" or differenceMask:true) keeps untouched areas pixel-identical via a difference map. {dataUrl,name,masked,blend,opacity,x,y,w,h,mode,differenceMask,diffThreshold,diffGrow,diffFeather}.', run: (p) => new Promise((resolve, reject) => { if (!p || !p.dataUrl) { reject(new Error('dataUrl required')); return; } const img = new Image(); img.onload = () => { try { _saveState(`Place ${p.name || 'image'}`); const dw = state.imgWidth, dh = state.imgHeight; const useDiff = p.nativeSize !== true && ((p.differenceMask === true) || String(p.mode || '').toLowerCase() === 'instruction'); const beforeSnap = useDiff ? flatten() : null; const nativeSize = p.nativeSize === true && !useDiff; const geometry = imageResultGeometry(img.naturalWidth, img.naturalHeight, dw, dh); const layer = createLayer(p.name || 'Placed image', nativeSize ? geometry.width : dw, nativeSize ? geometry.height : dh); const nativeOffset = { x: geometry.offsetX, y: geometry.offsetY }; if (nativeSize) state.layerOffsets.set(layer.id, nativeOffset); let tw = Number(p.w), th = Number(p.h); if (!(tw > 0) || !(th > 0)) { if (nativeSize) { tw = geometry.drawWidth; th = geometry.drawHeight; } else if (useDiff) { tw = dw; th = dh; } else { const scale = Math.min(dw / img.naturalWidth, dh / img.naturalHeight); tw = Math.max(1, Math.round(img.naturalWidth * scale)); th = Math.max(1, Math.round(img.naturalHeight * scale)); } } let tx = Number(p.x), ty = Number(p.y); if (!Number.isFinite(tx)) tx = nativeSize ? geometry.drawX : useDiff ? 0 : Math.round((dw - tw) / 2); if (!Number.isFinite(ty)) ty = nativeSize ? geometry.drawY : useDiff ? 0 : Math.round((dh - th) / 2); layer.ctx.imageSmoothingEnabled = true; layer.ctx.imageSmoothingQuality = 'high'; layer.ctx.drawImage(img, tx, ty, tw, th); if (p.masked !== false && state.wandMask) { layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(state.wandMask, nativeSize ? -nativeOffset.x : 0, nativeSize ? -nativeOffset.y : 0); layer.ctx.restore(); } else if (useDiff && beforeSnap) { const diff = _differenceMaskCanvas(beforeSnap, layer.canvas, { threshold: p.diffThreshold, grow: p.diffGrow, feather: p.diffFeather, edgeExcludePx: 0 }); const explicitEdge = p.edgeExcludePx != null ? Number(p.edgeExcludePx) : null; const sceneReplace = diff.changedFraction > 0.5; const edgeExclude = sceneReplace ? 0 : (explicitEdge != null ? explicitEdge : 3); if (edgeExclude > 0) _clearMaskEdgeBand(diff.mask, edgeExclude); layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(diff.mask, 0, 0); layer.ctx.restore(); if (sceneReplace) _edgeFillFromInterior(layer.ctx, dw, dh, 3); } if (p.blend) layer.blendMode = p.blend; if (p.opacity != null) layer.opacity = Math.max(0, Math.min(1, Number(p.opacity))); state.layers.push(layer); state.activeLayerId = layer.id; state.selectedLayerIds = [layer.id]; _renderLayerPanel(); composite(); resolve(layer.id); } catch (e) { reject(e); } }; img.onerror = () => reject(new Error('image load failed')); img.src = p.dataUrl; }) }, 'layers.addMaskFromSelection': { description: 'Add a layer mask from the current selection (reveals the selection). {invert}.', run: (p) => { const parent = _activeParentLayer(); if (!parent) throw new Error('no layer'); _saveState('Add layer mask'); const mask = _ensureActiveMaskLayer(); if (!mask) throw new Error('could not create mask'); const w = mask.canvas.width, h = mask.canvas.height; const mctx = mask.ctx; const off = state.layerOffsets.get(parent.id) || { x: 0, y: 0 }; const ox = -(off.x || 0), oy = -(off.y || 0); mctx.globalCompositeOperation = 'source-over'; mctx.clearRect(0, 0, w, h); if (state.wandMask) { if (p && p.invert) { mctx.fillStyle = '#fff'; mctx.fillRect(0, 0, w, h); mctx.globalCompositeOperation = 'destination-out'; mctx.drawImage(state.wandMask, ox, oy); mctx.globalCompositeOperation = 'source-over'; } else { mctx.drawImage(state.wandMask, ox, oy); } } else { mctx.fillStyle = (p && p.invert) ? '#000' : '#fff'; mctx.fillRect(0, 0, w, h); } composite(); _renderLayerPanel(); return mask.id; } }, 'layers.removeMask': { description: 'Remove the active layer mask (does not affect pixels).', run: () => { const parent = _activeParentLayer(); if (!parent || !parent.masks || !parent.masks.length) throw new Error('no mask'); _saveState('Remove layer mask'); parent.masks = parent.masks.filter((m) => m.id !== parent.activeMaskId); parent.activeMaskId = parent.masks[0]?.id || null; composite(); _renderLayerPanel(); return true; } }, 'layers.applyMask': { description: 'Bake the active layer mask into the layer alpha and remove the mask.', run: () => { const parent = _activeParentLayer(); const mask = _getActiveMaskLayer(); if (!parent || !parent.ctx || !mask) throw new Error('no mask to apply'); _saveState('Apply layer mask'); _bakeMaskIntoLayer(parent.ctx, mask.canvas, 0, 0); parent.masks = parent.masks.filter((m) => m.id !== mask.id); parent.activeMaskId = parent.masks[0]?.id || null; composite(); _renderLayerPanel(); return true; } }, @@ -12551,7 +12607,7 @@ function _buildEditor(container) { 'ai.removeObject': { description: 'Remove the selected object and fill naturally. {prompt}.', run: (p) => _aiInpaintSelection((p && p.prompt) || 'remove the object, fill the area naturally') }, 'ai.harmonize': { description: 'Harmonize the layer/selection lighting with the rest of the image; result is a NEW layer. {strength}.', run: async (p) => { const { canvas } = await _aiEditCanvas('/api/image/harmonize', { strength: Number(p && p.strength) || 0.4 }); return _placeResultLayer(canvas, 'Harmonized', state.wandMask || null); } }, 'ai.generativeUpscale': { description: 'Upscale the image 2x/4x; result is a NEW layer. Falls back to a high-quality canvas upscale labelled "basic upscale" when no Real-ESRGAN model is served. {scale}.', run: async (p) => { const scale = Number(p && p.scale) === 4 ? 4 : 2; try { const { canvas } = await _aiEditCanvas('/api/image/upscale-local', { scale }); return _placeResultLayer(canvas, `Upscaled ${scale}x`, null); } catch (e) { const src = flatten(); const c = document.createElement('canvas'); c.width = Math.max(1, Math.round(src.width * scale)); c.height = Math.max(1, Math.round(src.height * scale)); const cx = c.getContext('2d'); cx.imageSmoothingEnabled = true; cx.imageSmoothingQuality = 'high'; cx.drawImage(src, 0, 0, c.width, c.height); _saveState('Upscale (basic)'); _resizeCanvasDocument(state, c.width, c.height, { anchorX: 0, anchorY: 0 }); const layerId = _placeResultLayer(c, `Upscaled ${scale}x (basic upscale)`, null); return { layerId, scale, method: 'basic', reason: String(e && e.message || e) }; } } }, - 'ai.generateImage': { description: 'Generate an image from a prompt (current canvas as reference) and place it as a NEW layer. {prompt,width,height}.', run: async (p) => { const endpoint = (typeof window !== 'undefined' && window.geGenerateImageEndpoint) || '/api/image/generate'; const reference = flatten().toDataURL('image/png').split(',')[1]; const _pickedModel = (() => { try { const v = localStorage.getItem('ge-ai-command-model') || ''; return v.includes('::') ? v.split('::').pop() : ''; } catch (_) { return ''; } })(); const body = { prompt: (p && p.prompt) || '', width: Math.max(1, Math.round(Number(p && p.width) || state.imgWidth)), height: Math.max(1, Math.round(Number(p && p.height) || state.imgHeight)), reference, ...(_pickedModel ? { model: _pickedModel } : {}) }; let _gres = null; try { _gres = await fetch(endpoint, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } catch (_e) { throw new Error(_generationErrorMessage({ network: true, detail: (_e && _e.message) || 'network error' })); } const res = _gres; if (!res.ok) { let _gd = res.statusText; try { const e = await res.json(); _gd = e.detail || e.error || _gd; } catch (_) {} throw new Error(_generationErrorMessage({ status: res.status, detail: _gd })); } const data = await res.json(); if (data.error) throw new Error(_generationErrorMessage({ detail: data.error })); const b64 = data.image || (Array.isArray(data.images) && data.images[0]); if (!b64) throw new Error('No image returned'); try { window.__geGenerationDisabled = 0; } catch (_) {} const img = await _decodeAIImage(b64, _aiSignal); const c = document.createElement('canvas'); c.width = state.imgWidth; c.height = state.imgHeight; c.getContext('2d').drawImage(img, 0, 0, state.imgWidth, state.imgHeight); return _placeResultLayer(c, 'Generated', state.wandMask || null); } }, + 'ai.generateImage': { description: 'Generate an image with the saved generation model and fit it proportionally as a new layer. {prompt,width,height}.', run: (p) => _generateImage(p || {}) }, 'ai.generativeExpand': { description: 'Generatively expand the canvas to width×height; only the new border is inpainted, the original pixels are kept pixel-identical, and the seam is feathered. {width,height,anchor,prompt}.', run: async (p) => { const tw = Math.max(1, Math.round(Number(p && p.width) || state.imgWidth)); const th = Math.max(1, Math.round(Number(p && p.height) || state.imgHeight)); if (tw <= state.imgWidth && th <= state.imgHeight) throw new Error('target must be larger than the current canvas'); const anchor = (p && p.anchor) || 'center'; const anchorX = anchor === 'left' ? 0 : anchor === 'right' ? 1 : 0.5; const anchorY = anchor === 'top' ? 0 : anchor === 'bottom' ? 1 : 0.5; _saveState('Generative expand'); const steps = Math.max(1, Math.round(Number(p && p.steps) || 4)); const sw = state.imgWidth; const sh = state.imgHeight; _resizeCanvasDocument(state, tw, th, { anchorX, anchorY }); const flat = flatten(); const b = _contentBounds(); const mask = document.createElement('canvas'); mask.width = tw; mask.height = th; const mctx = mask.getContext('2d'); mctx.fillStyle = '#ffffff'; mctx.fillRect(0, 0, tw, th); const prefill = document.createElement('canvas'); prefill.width = tw; prefill.height = th; const pctx = prefill.getContext('2d'); pctx.imageSmoothingEnabled = true; pctx.drawImage(flat, 0, 0, tw, th); if (b) { mctx.clearRect(b.x, b.y, b.w, b.h); const bx = b.x, by = b.y, bw = b.w, bh = b.h, rx = bx + bw, by2 = by + bh; if (bx > 0) pctx.drawImage(flat, bx, by, 1, bh, 0, by, bx, bh); if (rx < tw) pctx.drawImage(flat, rx - 1, by, 1, bh, rx, by, tw - rx, bh); if (by > 0) pctx.drawImage(flat, bx, by, bw, 1, bx, 0, bw, by); if (by2 < th) pctx.drawImage(flat, bx, by2 - 1, bw, 1, bx, by2, bw, th - by2); if (bx > 0 && by > 0) pctx.drawImage(flat, bx, by, 1, 1, 0, 0, bx, by); if (rx < tw && by > 0) pctx.drawImage(flat, rx - 1, by, 1, 1, rx, 0, tw - rx, by); if (bx > 0 && by2 < th) pctx.drawImage(flat, bx, by2 - 1, 1, 1, 0, by2, bx, th - by2); if (rx < tw && by2 < th) pctx.drawImage(flat, rx - 1, by2 - 1, 1, 1, rx, by2, tw - rx, th - by2); } else { const tx = Math.round((tw - sw) * anchorX); const ty = Math.round((th - sh) * anchorY); mctx.clearRect(tx, ty, sw, sh); } const image = prefill.toDataURL('image/png').split(',')[1]; const maskB64 = mask.toDataURL('image/png').split(',')[1]; const res = await fetch('/api/image/inpaint', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image, mask: maskB64, prompt: (p && p.prompt) || 'extend the scene seamlessly, continuing the surrounding content and lighting naturally', steps }) }); if (!res.ok) { let err = res.statusText; try { const e = await res.json(); err = e.detail || e.error || err; } catch (_) {} throw new Error(err); } const data = await res.json(); if (data.error) throw new Error(data.error); if (!data.image) throw new Error('No image returned'); const img = await _decodeAIImage(data.image, _aiSignal); const seam = Math.max(12, Math.round(Math.min(tw, th) * 0.02)); let blendMask = _growMaskCanvas(mask, seam, true); blendMask = _featherMaskCanvas((cw, ch) => { const c = document.createElement('canvas'); c.width = cw; c.height = ch; return c; }, blendMask, Math.round(seam * 1.4)); const layer = createLayer('Generative expand', tw, th); layer.ctx.imageSmoothingEnabled = true; layer.ctx.imageSmoothingQuality = 'high'; layer.ctx.drawImage(img, 0, 0, tw, th); if (b) { const edge = _edgeBandAverage(flat, b); if (edge) { layer.ctx.save(); layer.ctx.globalAlpha = 0.14; layer.ctx.fillStyle = `rgb(${edge[0]}, ${edge[1]}, ${edge[2]})`; layer.ctx.fillRect(0, 0, tw, th); layer.ctx.restore(); } } _bakeMaskIntoLayer(layer.ctx, blendMask); state.layers.push(layer); state.activeLayerId = layer.id; state.selectedLayerIds = [layer.id]; composite(); _renderLayerPanel(); return { width: tw, height: th, layerId: layer.id, seam, steps }; } }, 'layers.rename': { description: 'Rename a layer {id|index|name, name|newName}.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); const next = String(p.name || p.newName || '').trim(); if (!next) throw new Error('name required'); _saveState('Rename layer'); l.name = next; _renderLayerPanel(); return l.id; } }, 'layers.setVisibility': { description: 'Show/hide a layer {id|index|name, visible}.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState('Toggle layer visibility'); l.visible = (p.visible === undefined) ? !l.visible : !!p.visible; composite(); _renderLayerPanel(); return l.visible; } }, @@ -12796,6 +12852,10 @@ function _buildEditor(container) { endpoint: window.geEditorAgentEndpoint || '/api/editor/agent', getSession: () => agentBridgeSession, getModel: () => _getSelectedQuickEditModel(), + getGenerationModel: () => { + const value = document.getElementById('ge-ai-model')?.value || ''; + return value.includes('::') ? value.slice(value.indexOf('::') + 2) : ''; + }, getAgentModel: () => _getSelectedQuickEditAgentModel(), }); } catch (e) { console.error('[ge-editor] agent init failed', e); } @@ -13895,6 +13955,12 @@ export function openEditor(imageUrl, imageId, presetSize, displayName, draftId) return; } _finishBlank(size.w, size.h); + }).catch(error => { + console.error('[openEditor] New project failed:', error); + closeEditor({ force: true }); + window.dispatchEvent(new CustomEvent('gallery-editor-setup-cancelled')); + uiModule.showError(`Could not create project: ${error?.message || 'Canvas initialization failed'}`); + throw error; }); } @@ -14049,8 +14115,6 @@ export function closeEditor(options = {}) { _selectionNudgeTimer = null; _selectionNudgeHistorySaved = false; _precisionGuides = null; - try { _preflight?.destroy(); } catch {} - _preflight = null; try { _aiSettings?.destroy(); } catch {} _aiSettings = null; try { delete window.geEditorAiSettings; } catch (_) { window.geEditorAiSettings = null; } diff --git a/static/js/settings/imageModels.js b/static/js/settings/imageModels.js index 34bd8d614..551293b53 100644 --- a/static/js/settings/imageModels.js +++ b/static/js/settings/imageModels.js @@ -4,12 +4,14 @@ // them from the same places, so load order and init sequence are untouched. import { byId as el } from './dom.js'; -import { postSettings as _postSettings } from './api.js'; +import { saveImagePreferences as _postSettings } from './imagePreferences.js'; import { modelCaps } from '../editor/ai-models.js'; import { sortModelIds } from '../modelSort.js'; +let preferencesListener = null; export async function initImageSettings() { const modelSel = el('set-imgModelSelect'); + const editModelSel = el('set-imgEditModelSelect'); const qualSel = el('set-imgQualitySelect'); const msg = el('set-imgSettingsMsg'); const enabledToggle = el('set-imgEnabledToggle'); @@ -18,23 +20,36 @@ export async function initImageSettings() { const endpointsRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' }); const endpoints = await endpointsRes.json(); const imageModels = new Set(); + const editModels = new Set(); (Array.isArray(endpoints) ? endpoints : []).forEach(endpoint => { if (!endpoint.is_enabled || !endpoint.online) return; (Array.isArray(endpoint.models) ? endpoint.models : []).forEach(modelId => { if (modelId && modelCaps(modelId, '', endpoint.model_type).gen) imageModels.add(String(modelId)); + if (modelId && modelCaps(modelId, '', endpoint.model_type).inpaint) editModels.add(String(modelId)); }); }); + modelSel.length = 1; sortModelIds(Array.from(imageModels)).forEach(mid => { const opt = document.createElement('option'); opt.value = mid; opt.textContent = mid; modelSel.appendChild(opt); }); + if (editModelSel) { + editModelSel.length = 1; + for (const mid of sortModelIds(Array.from(editModels))) { + const option = document.createElement('option'); + option.value = mid; + option.textContent = mid; + editModelSel.appendChild(option); + } + } } catch (e) { console.warn('Failed to load models for image settings', e); } try { const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' }); const settings = await settingsRes.json(); if (settings.image_model) modelSel.value = settings.image_model; + if (editModelSel) editModelSel.value = settings.image_edit_model || ''; if (settings.image_quality) qualSel.value = settings.image_quality; if (enabledToggle) enabledToggle.checked = settings.image_gen_enabled === true; } catch (e) { console.warn('Failed to load settings', e); } @@ -49,12 +64,19 @@ export async function initImageSettings() { async function saveSettings() { try { - const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value }); + const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_edit_model: editModelSel?.value || '', image_quality: qualSel.value }); if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`)); msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000); } catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; } } modelSel.addEventListener('change', saveSettings); + editModelSel?.addEventListener('change', saveSettings); + if (preferencesListener) window.removeEventListener('image-preferences-changed', preferencesListener); + preferencesListener = event => { + if ('image_model' in event.detail) modelSel.value = event.detail.image_model || ''; + if (editModelSel && 'image_edit_model' in event.detail) editModelSel.value = event.detail.image_edit_model || ''; + }; + window.addEventListener('image-preferences-changed', preferencesListener); qualSel.addEventListener('change', saveSettings); if (enabledToggle) enabledToggle.addEventListener('change', function() { syncImgDisabled(); saveSettings(); }); } diff --git a/static/js/settings/imagePreferences.js b/static/js/settings/imagePreferences.js new file mode 100644 index 000000000..f27fc0782 --- /dev/null +++ b/static/js/settings/imagePreferences.js @@ -0,0 +1,26 @@ +import { invalidateSettings } from '../appConfig.js'; + +export async function readImagePreferences() { + const response = await fetch('/api/auth/settings', { credentials: 'same-origin' }); + if (!response.ok) throw new Error('Could not load image settings'); + return response.json(); +} + +let saveQueue = Promise.resolve(); +export function waitForImagePreferences() { return saveQueue; } +export function saveImagePreferences(patch) { + const save = async () => { + const response = await fetch('/api/auth/settings', { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), + }); + if (!response.ok) throw new Error(response.status === 403 + ? 'Admin access is required to change image settings.' : 'Could not save image settings'); + invalidateSettings(); + window.dispatchEvent(new CustomEvent('image-preferences-changed', { detail: patch })); + return response; + }; + const result = saveQueue.then(save); + saveQueue = result.catch(() => {}); + return result; +} diff --git a/static/sw.js b/static/sw.js index de964dd14..6dc2226cf 100644 --- a/static/sw.js +++ b/static/sw.js @@ -7,7 +7,7 @@ // - Other static assets (images/fonts/libs): cache-first with bg refresh. // - API / non-GET: never cached. // Bump CACHE_NAME whenever the precache list or SW logic changes. -const CACHE_NAME = 'odysseus-v1114-pr40-editor-precache'; +const CACHE_NAME = 'odysseus-v1133-pr40-editor-precache'; // KaTeX resolves these from its own stylesheet, so caching the CSS without them // gives offline math fallback glyphs instead of proper typesetting. @@ -236,6 +236,10 @@ const PANEL_PRECACHE = [ '/static/js/editor/aspect-fit.js', '/static/js/editor/preflight.js', '/static/js/editor/ai-settings.js', + '/static/js/editor/image-generation.js', + '/static/js/editor/image-result-geometry.js', + '/static/js/editor/quick-edit-job.js', + '/static/js/settings/imagePreferences.js', '/static/js/editor/look-recipes.js', '/static/js/editor/editor-control.js', '/static/js/editor/editor-bridge.js', diff --git a/tests/test_editor_brush_more_menu.py b/tests/test_editor_brush_more_menu.py index 5e5eb979b..699762c2f 100644 --- a/tests/test_editor_brush_more_menu.py +++ b/tests/test_editor_brush_more_menu.py @@ -29,8 +29,10 @@ def test_secondary_brush_controls_live_after_the_menu_open(): block = CONTROLS[i:j] for marker in ('id="ge-brush-spacing"', 'id="ge-brush-smoothing"', 'id="ge-brush-blend"', 'ge-brush-pressure-row', - 'id="ge-brush-flow-row"', 'id="ge-brush-softness-row"'): + 'id="ge-brush-flow-row"'): assert marker in block, marker + assert 'id="ge-brush-softness-row"' not in block + assert CONTROLS.index('id="ge-brush-softness-row"') < i def test_brush_size_label_drops_the_redundant_word(): @@ -98,7 +100,7 @@ def test_brush_strip_fits_and_smudge_row_respects_inline_hide(): # The Opacity/Flow/Softness sliders are wound tighter so the strip fits. assert ".ge-controls--in-options #ge-brush-section { gap: 6px; }" in CSS # The brush controls hug their content so the sliders sit next to the caret. - assert ".ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }" in CSS + assert ".ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; flex-direction: row;" in CSS # The smudge Strength row is hidden with an inline display:none on other # tools; the `.ge-eraser-row { display:flex !important }` rule must not # override that. diff --git a/tests/test_editor_generation_error_messages.py b/tests/test_editor_generation_error_messages.py index 2dc73d9e6..11441a53b 100644 --- a/tests/test_editor_generation_error_messages.py +++ b/tests/test_editor_generation_error_messages.py @@ -44,9 +44,11 @@ def test_quick_edit_shows_the_generation_message_verbatim(): assert "_isGenOfflineMsg" in EDITOR assert "_setAiCommandStatus(_isGenOfflineMsg(_genErr) ? _genErr : `Could not generate that edit: ${_genErr}`, 'error');" in EDITOR assert "_setAiCommandStatus(_isGenOfflineMsg(msg) ? msg : `Could not generate an image: ${msg}`, 'error');" in EDITOR - # the generateImage op maps its own fetch/HTTP failures through the classifier - assert "_generationErrorMessage({ network: true" in EDITOR - assert "_generationErrorMessage({ status: res.status, detail: _gd })" in EDITOR + # The extracted generation action keeps the shared failure classifier. + generation = (ROOT / 'static/js/editor/image-generation.js').read_text() + assert "formatError: _generationErrorMessage" in EDITOR + assert "formatError({ network: true" in generation + assert "formatError({ status: response.status" in generation def test_sw_cache_is_bumped(): diff --git a/tests/test_editor_generation_model_routing.py b/tests/test_editor_generation_model_routing.py new file mode 100644 index 000000000..4618067fc --- /dev/null +++ b/tests/test_editor_generation_model_routing.py @@ -0,0 +1,121 @@ +import json + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from routes import editor_agent_routes as routes +from src.agent_tools import editor_tools + + +@pytest.fixture(autouse=True) +def allow_image_generation_privilege(monkeypatch): + monkeypatch.setattr('src.auth_helpers.require_privilege', lambda *args: 'test-user') + + +@pytest.mark.parametrize("chosen", ["openai/gpt-5-image", ""]) +def test_quick_edit_generation_uses_generation_selection_not_planner_model(monkeypatch, chosen): + seen = [] + + async def execute(self, content, ctx): + seen.append(json.loads(content)) + return "provider stub", {"exit_code": 1, "error": "test stops before canvas verification"} + + monkeypatch.setattr(editor_tools.GenerateImageLayerTool, "execute", execute) + monkeypatch.setattr(routes, "_user", lambda request: None) + monkeypatch.setattr(routes.editor_agent_model, "startup_probe", lambda: None) + monkeypatch.setattr(routes.style_guide, "strengthen", lambda prompt, steps: steps) + monkeypatch.setattr(routes.style_guide, "enforce_guards", lambda steps: steps) + monkeypatch.setattr(routes, "_image_provider_available", lambda owner: True) + monkeypatch.setattr(routes, "_plan_special", lambda *args: None) + monkeypatch.setattr(routes, "_ensure_generative", lambda prompt, steps: steps) + monkeypatch.setattr(routes, "_unsure_plan", lambda prompt, plan: plan) + routes.set_editor_planner(lambda messages, owner: json.dumps({ + "reply": "Generate", "steps": [{"op": "generate_image", "params": { + "prompt": "a cow", "model": "gpt-image-1", + }}], + })) + app = FastAPI() + app.include_router(routes.setup_editor_agent_routes()) + try: + with TestClient(app) as client: + response = client.post("/api/editor/agent", json={ + "prompt": "create a new image of a cow", + "model": "edit-only-model", "generation_model": chosen, + }) + assert response.status_code == 200, response.text + assert len(seen) == 1, response.text + assert seen[0].get("model", "") == chosen + finally: + routes.set_editor_planner(None) + + +@pytest.mark.parametrize('op,expected', [('generate_image','saved-generation'),('ai_edit_image','saved-editor')]) +def test_quick_edit_agent_uses_saved_models_over_browser_and_planner(monkeypatch, op, expected): + seen = [] + + async def execute(self, content, ctx): + seen.append(json.loads(content)) + return 'stub', {'exit_code':1, 'error':'stop before verification'} + + monkeypatch.setattr('src.editor_image_settings.load_settings', lambda: { + 'image_model':'saved-generation', 'image_edit_model':'saved-editor'}) + monkeypatch.setattr(editor_tools.GenerateImageLayerTool, 'execute', execute) + monkeypatch.setattr(editor_tools.AiEditImageTool, 'execute', execute) + monkeypatch.setattr(routes, '_user', lambda request: None) + monkeypatch.setattr(routes.editor_agent_model, 'startup_probe', lambda: None) + monkeypatch.setattr(routes.style_guide, 'strengthen', lambda prompt, steps: steps) + monkeypatch.setattr(routes.style_guide, 'enforce_guards', lambda steps: steps) + monkeypatch.setattr(routes, '_image_provider_available', lambda owner: True) + monkeypatch.setattr(routes, '_plan_special', lambda *args: None) + monkeypatch.setattr(routes, '_ensure_generative', lambda prompt, steps: steps) + monkeypatch.setattr(routes, '_unsure_plan', lambda prompt, plan: plan) + routes.set_editor_planner(lambda messages, owner: json.dumps({ + 'reply':'Edit', 'steps':[{'op':op,'params':{'prompt':'a cow','model':'planner-model','size':'1024x1024'}}]})) + app = FastAPI() + app.include_router(routes.setup_editor_agent_routes()) + try: + with TestClient(app) as client: + response = client.post('/api/editor/agent', json={ + 'prompt':'create an image of a cow','_use_image_settings':True, + 'model':'stale-editor','generation_model':'stale-generator','state':{'width':1280,'height':720}}) + assert response.status_code == 200, response.text + assert len(seen) == 1, response.text + assert seen[0]['model'] == expected + assert seen[0]['_use_image_settings'] is True + if op == 'generate_image': + assert seen[0]['size'] == '1280x720' + finally: + routes.set_editor_planner(None) + + +def test_quick_generation_route_uses_saved_setting(monkeypatch): + import asyncio + from routes.gallery import gallery_routes as gallery + from src import ai_interaction + + seen = [] + + async def generate(content, **kwargs): + seen.append(json.loads(content)) + return {'image_url':'data:image/png;base64,dGVzdA=='} + + monkeypatch.setattr(gallery, 'require_privilege', lambda *args: 'alice') + monkeypatch.setattr(ai_interaction, 'do_generate_image', generate) + monkeypatch.setattr('src.editor_image_settings.load_settings', lambda: {'image_model':'saved-generation'}) + + class Request: + async def json(self): + return {'prompt':'a cow','model':'stale-generator','_use_image_settings':True,'width':1280,'height':720} + + endpoint = next(route.endpoint for route in gallery.setup_gallery_routes().routes if route.path == '/api/image/generate') + result = asyncio.run(endpoint(Request())) + assert result['image'] == 'dGVzdA==' + assert seen[0]['model'] == 'saved-generation' + assert seen[0]['size'] == '1280x720' + + +def test_edit_default_uses_generation_only_when_edit_setting_is_empty(monkeypatch): + from src import editor_image_settings + monkeypatch.setattr(editor_image_settings, 'load_settings', lambda: {'image_model':'generator','image_edit_model':''}) + assert editor_image_settings.editor_image_models() == {'generation':'generator','edit':'generator'} diff --git a/tests/test_editor_keyboard_shortcuts.py b/tests/test_editor_keyboard_shortcuts.py index 12ce80f56..d1d7dc903 100644 --- a/tests/test_editor_keyboard_shortcuts.py +++ b/tests/test_editor_keyboard_shortcuts.py @@ -49,10 +49,9 @@ def test_ctrl_zoom_shortcuts_drive_the_toolbar_and_override_the_browser(): def test_ctrl_alt_l_opens_the_levels_adjustment(): keyboard = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8") - # Photoshop-style chord: reuse the Image menu row so the dialog and the - # adjustment-layer plumbing stay in one place. + # Open layer-local controls without creating/committing a new adjustment layer. assert "e.altKey && e.code === 'KeyL'" in keyboard - assert "document.querySelector('[data-image-action=\"adj-levels\"]')" in keyboard + assert "openLayerAdjustment?.('levels');" in keyboard def test_ctrl_alt_shift_e_stamps_the_visible_layers(): diff --git a/tests/test_editor_layer_scoped_actions_browser.py b/tests/test_editor_layer_scoped_actions_browser.py new file mode 100644 index 000000000..a8e019b09 --- /dev/null +++ b/tests/test_editor_layer_scoped_actions_browser.py @@ -0,0 +1,770 @@ +from pathlib import Path + +import pytest +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def page(): + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1000, "height": 700}) + + def serve(route): + relative = route.request.url.split('http://editor.test/', 1)[1].split('?', 1)[0] + if not relative: + route.fulfill(body='', content_type='text/html') + else: + route.fulfill(path=str(ROOT / relative)) + + page.route('http://editor.test/**', serve) + page.goto('http://editor.test/') + yield page + browser.close() + + +def test_remove_background_sends_only_active_layer_and_preserves_others(page): + result = page.evaluate("""async () => { + const {state} = await import('/static/js/editor/state.js'); + const {createApplyImageTool} = await import('/static/js/editor/ai-tool-runner.js'); + function layer(id, color) { + const canvas = document.createElement('canvas'); canvas.width = canvas.height = 4; + const ctx = canvas.getContext('2d'); ctx.fillStyle = color; ctx.fillRect(0,0,4,4); + return {id, canvas, ctx, visible:true, locks:{}}; + } + const red = layer('red','red'), blue = layer('blue','blue'); + red.kind = 'placed'; red.placed = {sourceName:'original'}; + state.layers = [red, blue]; state.activeLayerId = red.id; state.editorOpen = true; + const beforeBlue = blue.canvas.toDataURL(); const expected = red.canvas.toDataURL().split(',')[1]; + let sent, snapshots = 0; + const clear = document.createElement('canvas'); clear.width = clear.height = 4; + window.fetch = async (url, opts) => { + sent = JSON.parse(opts.body); + state.activeLayerId = blue.id; // Changing selection during the request must not change its target. + return {ok:true, json:async () => ({image:clear.toDataURL()})}; + }; + const run = createApplyImageTool({ + flatten: () => {throw Error('Must not flatten');}, saveState: () => snapshots++, + createLayer: () => {throw Error('Must not create a merged layer');}, + composite: () => {}, renderLayerPanel: () => {}, deriveBusyLabel: () => 'Removing', + getSelectedAIEndpoint: () => ({}), spinnerModule: {}, uiModule: {showToast(){}}, + }); + await run('/api/image/remove-bg', {}, 'BG Removed', document.createElement('button')); + return {correctInput:sent.image === expected, blueUnchanged:blue.canvas.toDataURL() === beforeBlue, + count:state.layers.length, visible:state.layers.every(l => l.visible), snapshots, + alpha:red.ctx.getImageData(0,0,1,1).data[3], kind:red.kind, placed:red.placed}; + }""") + assert result == dict(correctInput=True, blueUnchanged=True, count=2, visible=True, snapshots=1, alpha=0, kind='raster', placed=None) + + +def test_levels_shortcut_opens_controls_without_menu_commit(page): + result = page.evaluate("""async () => { + const {state} = await import('/static/js/editor/state.js'); + const {wireKeyboardShortcuts} = await import('/static/js/editor/keyboard-shortcuts.js'); + state.editorOpen = true; + let selected; + wireKeyboardShortcuts({toolbar:document.createElement('div'), toolKeyMap:{}, + openLayerAdjustment:type => selected = type}); + const event = new KeyboardEvent('keydown', {key:'l', code:'KeyL', ctrlKey:true, altKey:true, + bubbles:true, cancelable:true}); + document.dispatchEvent(event); + return {selected, prevented:event.defaultPrevented}; + }""") + assert result == dict(selected='levels', prevented=True) + + +def test_hue_saturation_shortcut_opens_adjustment(page): + result = page.evaluate("""async () => { + const {state} = await import('/static/js/editor/state.js'); + const {wireKeyboardShortcuts} = await import('/static/js/editor/keyboard-shortcuts.js'); + state.editorOpen = true; + let selected; + wireKeyboardShortcuts({toolbar:document.createElement('div'), toolKeyMap:{}, + openLayerAdjustment:type => selected = type}); + const event = new KeyboardEvent('keydown', {key:'U', code:'KeyU', ctrlKey:true, shiftKey:true, + bubbles:true, cancelable:true}); + document.dispatchEvent(event); + return {selected, prevented:event.defaultPrevented}; + }""") + assert result == dict(selected='hue-saturation', prevented=True) + + +def test_inner_shadow_clips_inside_and_reverses_direction(page): + result = page.evaluate("""async () => { + const {renderEffects} = await import('/static/js/editor/effects.js'); + const source = document.createElement('canvas'); source.width=source.height=60; + const ctx=source.getContext('2d');ctx.fillStyle='white';ctx.fillRect(15,15,30,30); + function sample(inside,x) { + const out=renderEffects(source,[{type:'drop-shadow',params:{inside,x,y:0,blur:0,opacity:1,color:'#000000'}}]); + const c=out.getContext('2d'); + return {left:[...c.getImageData(16,30,1,1).data],right:[...c.getImageData(43,30,1,1).data], + outside:[...c.getImageData(47,30,1,1).data]}; + } + return {right:sample(true,6),left:sample(true,-6),outer:sample(false,6)}; + }""") + assert result['right']['left'] == [0,0,0,255] + assert result['right']['right'] == [255,255,255,255] + assert result['left']['right'] == [0,0,0,255] + assert result['right']['outside'][3] == 0 + assert result['outer']['outside'] == [0,0,0,255] + + +def test_cut_placed_image_rasterizes_once_without_confirmation(page): + page.route('http://editor.test/api/**', lambda route: route.fulfill(json={})) + page.evaluate("""async () => { + document.body.innerHTML='
'; + const editor=await import('/static/js/galleryEditor.js'); + const {state}=await import('/static/js/editor/state.js'); + editor.openEditor(null,null,{w:32,h:32},'Cut test'); + const layer=state.layers.at(-1); + layer.ctx.fillStyle='red';layer.ctx.fillRect(0,0,32,32); + layer.kind='placed';layer.placed={sourceName:'Original image'}; + document.activeElement?.blur(); + document.dispatchEvent(new KeyboardEvent('keydown',{key:'x',code:'KeyX',ctrlKey:true,bubbles:true,cancelable:true})); + }""") + page.wait_for_function("""async () => { + const {state}=await import('/static/js/editor/state.js'); + return !!state.internalClipboard && state.layers.at(-1).ctx.getImageData(0,0,1,1).data[3]===0; + }""") + result=page.evaluate("""async () => { + const {state}=await import('/static/js/editor/state.js'); + return {kind:state.layers.at(-1).kind,placed:state.layers.at(-1).placed, + copied:[...state.internalClipboard.getContext('2d').getImageData(0,0,1,1).data], + prompted:document.querySelector('#styled-confirm-modal')?.textContent?.includes('Rasterize layer')||false}; + }""") + assert result==dict(kind='raster',placed=None,copied=[255,0,0,255],prompted=False) + + +def test_drop_shadow_dialog_inside_and_direction_apply(page): + page.route('http://editor.test/api/**', lambda route: route.fulfill(json={})) + page.add_style_tag(content=(ROOT/'static/css/13-image-editor.css').read_text()) + page.add_style_tag(content=':root{--panel:#242426;--bg:#171719;--fg:#eee;--border:#454549;--red:#d47965;}body{background:var(--bg);color:var(--fg);font-family:Arial,sans-serif;}') + page.evaluate("""async () => { + document.body.innerHTML='
'; + const editor=await import('/static/js/galleryEditor.js'); + editor.openEditor(null,null,{w:64,h:64},'Shadow test'); + document.querySelector('[data-filter-action="effect-drop-shadow"]').click(); + }""") + page.locator('.ge-filter-overlay select[data-key="inside"]').select_option('1') + page.locator('.ge-filter-overlay input[data-key="angle"]').evaluate("el=>{el.value='90';el.dispatchEvent(new Event('input',{bubbles:true}));}") + page.screenshot(path='/tmp/editor-shadow-inside-direction.png') + page.locator('.ge-filter-overlay [data-action="apply"]').click() + page.wait_for_function("""async () => { + const {state}=await import('/static/js/editor/state.js'); + return state.layers.at(-1).effects.length===1; + }""") + result=page.evaluate("""async () => { + const {state}=await import('/static/js/editor/state.js'); + const {inside,x,y}=state.layers.at(-1).effects[0].params; + return {inside,x,y}; + }""") + assert result==dict(inside=True,x=0,y=-7) + + +def test_reorder_rebuilds_effect_rows_under_their_parent(page): + result=page.evaluate("""async () => { + const {state}=await import('/static/js/editor/state.js'); + const {createLayerPanelRenderer}=await import('/static/js/editor/layer-panel.js'); + document.body.innerHTML='
'; + state.container=document.body;state.editorOpen=true; + function layer(id,effects=[]) { + const canvas=document.createElement('canvas');canvas.width=canvas.height=16; + return {id,name:id,kind:'raster',canvas,ctx:canvas.getContext('2d'),visible:true, + opacity:1,locks:{},effects,masks:[],adjLayers:[]}; + } + const a=layer('a',[{id:'shadow-a',type:'drop-shadow',params:{}}]), b=layer('b'); + state.layers=[a,b];state.activeLayerId=a.id;state.layerOffsets=new Map();state.layerGroups=[]; + let options; + const renderer=createLayerPanelRenderer({composite(){},saveState(){}, + dragSortModule:{enable:(id,selector,config)=>{options=config;}},uiModule:{showToast(){}}}); + renderer.render(); + const list=document.getElementById('ge-layers-list'); + const row=id=>list.querySelector(`.ge-layer-item[data-layer-id="${id}"]`); + list.insertBefore(row('a'),row('b')); + options.onReorder([...list.querySelectorAll('.ge-layer-item[data-layer-id]')]); + return {order:state.layers.map(layer=>layer.id),excluded:options.excludeSelector, + rows:[...list.children].map(el=>el.dataset.layerId||el.dataset.effectId)}; + }""") + assert result==dict(order=['b','a'],excluded='.ge-adj-sub-item',rows=['a','shadow-a','b']) + + +@pytest.mark.parametrize('tool', ['marquee','lasso']) +@pytest.mark.parametrize('pointer_type', ['mouse','pen','touch']) +@pytest.mark.parametrize('pan', [False,True]) +def test_selections_start_outside_canvas_unless_explicitly_panning(page,tool,pointer_type,pan): + result=page.evaluate("""async ({tool,pointerType,pan}) => { + const {state}=await import('/static/js/editor/state.js'); + const {wireCanvasEvents}=await import('/static/js/editor/canvas-events.js'); + document.body.innerHTML='
'; + const area=document.getElementById('area'); + state.mainCanvas=area.querySelector('canvas');state.tool=tool; + state.editorOpen=true;state.spacePanActive=pan;state.panX=state.panY=0; + let starts=0,moves=0,ends=0; + wireCanvasEvents({canvasArea:area,beginDraw:()=>starts++,continueDraw:()=>moves++, + endDraw:()=>ends++,updateBrushCursor(){},syncZoomControls(){}}); + function pointer(type,target=area) { + target.dispatchEvent(new PointerEvent(type,{pointerId:5,pointerType,button:0, + clientX:type==='pointerdown'?10:30,clientY:type==='pointerdown'?10:30,bubbles:true,cancelable:true})); + } + pointer('pointerdown'); + if(pointerType==='mouse')area.dispatchEvent(new MouseEvent('mousedown',{button:0,bubbles:true})); + pointer('pointermove'); + if(pointerType==='mouse')window.dispatchEvent(new MouseEvent('mousemove')); + pointer('pointerup'); + if(pointerType==='mouse')window.dispatchEvent(new MouseEvent('mouseup')); + const after={starts,moves,ends,panX:state.panX}; + pointer('pointerdown',area.querySelector('button')); + return {...after,chromeStarted:starts!==after.starts}; + }""",dict(tool=tool,pointerType=pointer_type,pan=pan)) + assert result['starts']==(0 if pan else 1) + assert result['panX']==(20 if pan else 0) + assert not result['chromeStarted'] + if not pan: + assert result['moves']==1 + assert result['ends']==1 + + +def test_marquee_drag_from_workspace_creates_clipped_selection(page): + result=page.evaluate("""async () => { + const {state}=await import('/static/js/editor/state.js'); + const {wireCanvasEvents}=await import('/static/js/editor/canvas-events.js'); + const {createMarqueeTool}=await import('/static/js/editor/tools/marquee.js'); + document.body.innerHTML='
'; + const area=document.getElementById('area');state.mainCanvas=area.querySelector('canvas'); + state.tool='marquee';state.imgWidth=state.imgHeight=100;state.editorOpen=true; + state.spacePanActive=false;state.panX=state.panY=0;state.wandMask=null; + const layer={id:'a'};state.layers=[layer];state.activeLayerId='a';state.layerOffsets=new Map(); + const tool=createMarqueeTool({activeLayer:()=>layer,saveState(){},composite(){},drawOverlay(){},syncSelectionUi(){}}); + wireCanvasEvents({canvasArea:area,beginDraw:e=>tool.begin(e),continueDraw:e=>tool.drag(e), + endDraw:e=>tool.end(e),updateBrushCursor(){},syncZoomControls(){}}); + area.dispatchEvent(new PointerEvent('pointerdown',{pointerType:'mouse',pointerId:1,button:0,clientX:80,clientY:80,bubbles:true})); + area.dispatchEvent(new MouseEvent('mousedown',{button:0,clientX:80,clientY:80,bubbles:true})); + window.dispatchEvent(new MouseEvent('mousemove',{clientX:150,clientY:150})); + window.dispatchEvent(new MouseEvent('mouseup',{clientX:150,clientY:150})); + const ctx=state.wandMask.getContext('2d'); + return {inside:ctx.getImageData(0,0,1,1).data[3],outside:ctx.getImageData(70,70,1,1).data[3],pan:state.panX}; + }""") + assert result==dict(inside=255,outside=0,pan=0) + + +def test_save_menu_remains_clickable_after_png_export_and_window_raise(page): + page.add_style_tag(content=''' + .modal {position:fixed;inset:0;z-index:20000;background:#222;} + #ge-save-menu-btn {position:absolute;right:20px;top:20px;} + .ge-save-menu {position:fixed;z-index:10005;width:200px;background:#333;} + .ge-save-menu[hidden] {display:none;} + .ge-save-menu button {display:block;width:100%;height:30px;} + ''') + page.evaluate("""async () => { + const {wireTopbar}=await import('/static/js/editor/wire-topbar.js'); + document.body.innerHTML=''; + window.pngExports=0;window.saves=0; + document.getElementById('ge-save').addEventListener('click',()=>window.saves++); + wireTopbar({registerDocClickAway(){},exportPNGFile:()=>window.pngExports++}); + }""") + page.locator('#ge-save-menu-btn').click() + page.locator('#ge-export-png').click(timeout=2000) + page.evaluate("document.getElementById('gallery-modal').style.zIndex='50000'") + page.locator('#ge-save-menu-btn').click() + page.locator('#ge-save').click(timeout=2000) + assert page.evaluate('({png:window.pngExports,saves:window.saves})') == dict(png=1,saves=1) + + +def test_submenu_hover_aligns_to_row_and_flips_at_right_edge(page): + page.add_style_tag(content=''' + #menu {position:absolute; left:780px; top:100px; width:180px;} + button {display:block; height:35px; width:180px;} + .ge-submenu {display:none; position:absolute; width:160px; height:120px; top:0; left:100%;} + .ge-menu-parent:hover + .ge-submenu {display:block;} + ''') + page.evaluate("""async () => { + document.body.innerHTML = ''; + const {wireSubmenuToggles} = await import('/static/js/editor/submenu-toggles.js'); + wireSubmenuToggles(document.getElementById('menu')); + }""") + page.locator('.ge-menu-parent').hover() + row = page.locator('.ge-menu-parent').bounding_box() + panel = page.locator('.ge-submenu').bounding_box() + assert abs(panel['y'] - row['y']) <= 1 + assert panel['x'] + panel['width'] <= row['x'] + 1 + page.screenshot(path='/tmp/editor-submenu-alignment.png') + + +@pytest.mark.parametrize('prompt', ['remove bg', 'remove background', 'remove the background from the selected layer']) +def test_quick_edit_planner_uses_background_tool(prompt): + from routes.editor_agent_routes import _plan_special + steps, _, _ = _plan_special(prompt, {}, True) + assert steps == [{'op': 'ai.removeBackground', 'params': {}}] + + +def test_quick_edit_background_removal_precedes_creative_routing(): + source = (ROOT / 'static/js/galleryEditor.js').read_text() + local = source.split('const _executeQuickEditLocal = async (prompt) => {', 1)[1] + assert local.index('if (_looksBackgroundRemoval(p))') < local.index('if (_looksRecolor(p))') + + +@pytest.mark.parametrize('run', [False, True]) +def test_background_tool_suggestion_prepares_command_and_enter_submits(page, run): + source = (ROOT / 'static/js/galleryEditor.js').read_text() + picker = 'const pickSuggestion = ' + source.split('const pickSuggestion = ', 1)[1].split(" wrap.addEventListener('pointerdown'", 1)[0] + assert "runPrompt: id === 'rembg' ? 'remove background' : null" in source + result = page.evaluate("""({picker, run}) => { + const suggestionItems = [{toolId:'rembg', label:'Bg Remove', runPrompt:'remove background'}]; + const input = document.createElement('input'); input.value = 'bg'; + let submitted = 0, selected = null, clearSynced = false; + const form = {requestSubmit: () => submitted++}; + const _clickToolButton = id => selected = id; + const _setAiCommandStatus = () => {}; + const hideSuggestions = () => {}; + const syncClearButton = () => clearSynced = true; + eval(picker + '; pickSuggestion(0, run);'); + return {prompt:input.value, submitted, selected, clearSynced}; + }""", {'picker':picker, 'run':run}) + assert result == dict(prompt='remove background', submitted=int(run), selected='rembg', clearSynced=True) + + +@pytest.mark.parametrize('tool', ['dodge', 'burn']) +def test_retouch_invalidates_adjustment_preview_and_stops_before_undo(page, tool): + result = page.evaluate("""async tool => { + const {state} = await import('/static/js/editor/state.js'); + const {createStrokePipeline} = await import('/static/js/editor/stroke-pipeline.js'); + const {renderLayerWithAdjLayers} = await import('/static/js/editor/fx/pixel-pass.js'); + const canvas = document.createElement('canvas'); canvas.width = canvas.height = 30; + const ctx = canvas.getContext('2d'); ctx.fillStyle = 'rgb(100,100,100)'; ctx.fillRect(0,0,30,30); + const layer = {id:'target', canvas, ctx, adjLayers:[{id:'adj', type:'brightness-contrast', + params:{brightness:1.1,contrast:1}, visible:true, opacity:1}]}; + state.layers = [layer]; state.layerOffsets = new Map(); state.tool = tool; + Object.assign(state,{brushSize:12, brushOpacity:100, brushFlow:100, brushSoftness:0, + brushSpacing:20, brushSmoothing:0, dodgeBurnIntensity:50}); + const original = ctx.getImageData(0,0,30,30); + const pixel = () => renderLayerWithAdjLayers(layer).getContext('2d').getImageData(15,15,1,1).data[0]; + const before = pixel(); + const pipeline = createStrokePipeline({activeLayer:()=>layer, getActiveMaskLayer:()=>null, composite:()=>{}}); + pipeline.beginStroke({x:15,y:15,pressure:1},tool); + const during = pixel(); + pipeline.endStroke(null); // Finish before Undo restores the saved pixels. + ctx.putImageData(original,0,0); layer._adjFinalKey = null; layer._adjCacheKey = null; + pipeline.endStroke({x:28,y:28,pressure:1}); // A late pointer-up cannot replay this stroke. + return {before,during,after:pixel(),corner:ctx.getImageData(28,28,1,1).data[0]}; + }""", tool) + assert result['during'] > result['before'] if tool == 'dodge' else result['during'] < result['before'] + assert result['after'] == result['before'] + assert result['corner'] == 100 + + +def test_undo_finishes_active_stroke_before_restoring_history(): + source = (ROOT / 'static/js/galleryEditor.js').read_text() + undo = source.split('function undo() {', 1)[1].split('function redo()', 1)[0] + assert undo.index('if (state.drawing) _strokeTool.tryEnd();') < undo.index('state.undoStack.pop()') + + +@pytest.mark.parametrize('width,height', [(1000,700), (390,844)]) +def test_filter_submenu_rows_have_equal_heights_with_and_without_icons(page, width, height): + page.set_viewport_size({'width':width,'height':height}) + page.add_style_tag(content=(ROOT / 'static/css/13-image-editor.css').read_text()) + page.evaluate("""() => { + document.body.innerHTML = '
' + + '' + + '' + + '
'; + }""") + heights = page.locator('.dropdown-item-compact').evaluate_all('(rows)=>rows.map(row=>row.getBoundingClientRect().height)') + assert heights == ([28]*3 if width > 820 else [32]*3) + page.screenshot(path=f'/tmp/editor-filter-row-heights-{width}.png') + + +def test_ai_settings_escape_closes_only_the_dialog(page): + page.route('**/api/auth/settings', lambda route: route.fulfill(json={'image_model':'', 'image_edit_model':''})) + result = page.evaluate("""async () => { + const {createAiSettings} = await import('/static/js/editor/ai-settings.js'); + const container = document.createElement('div'); document.body.append(container); + const settings = createAiSettings({container}); settings.open(); + let reachedEditor = false; + document.addEventListener('keydown', () => reachedEditor = true, {capture:true, once:true}); + document.dispatchEvent(new KeyboardEvent('keydown', {key:'Escape', bubbles:true, cancelable:true})); + const result = {closed:settings.element.hidden, reachedEditor}; settings.destroy(); + return result; + }""") + assert result == dict(closed=True, reachedEditor=False) + + +def test_image_model_settings_sync_both_directions_and_survive_reopening(page): + saved = {'image_model':'openai/gpt-5-image', 'image_edit_model':'', 'image_quality':'medium', 'image_gen_enabled':True} + writes = [] + + def settings_api(route): + if route.request.method == 'POST': + patch = route.request.post_data_json + writes.append(patch) + saved.update(patch) + route.fulfill(json=dict(saved)) + + page.route('**/api/auth/settings', settings_api) + page.route('**/api/model-endpoints', lambda route: route.fulfill(json=[{ + 'is_enabled':True, 'online':True, 'model_type':'llm', + 'models':['openai/gpt-5-image','openai/gpt-image-2'], + }])) + page.evaluate("""async () => { + document.body.innerHTML = '' + + '' + + '' + + ''; + const {initImageSettings} = await import('/static/js/settings/imageModels.js'); + await initImageSettings(); + const {createAiSettings} = await import('/static/js/editor/ai-settings.js'); + window.dialog = createAiSettings({container:document.body}); + for (const id of ['ge-ai-model','ge-ai-command-model']) { + const select = document.getElementById(id); + for (const model of ['', 'openai/gpt-5-image','openai/gpt-image-2']) { + const option = document.createElement('option'); + option.value = model ? 'https://openrouter.ai/api/v1::'+model : ''; + option.textContent = model || 'Auto'; select.append(option); + } + } + dialog.open(); + }""") + page.wait_for_function("document.getElementById('ge-ai-model').value.endsWith('::openai/gpt-5-image')") + page.select_option('#ge-ai-model', 'https://openrouter.ai/api/v1::openai/gpt-image-2') + page.wait_for_function("document.getElementById('set-imgModelSelect').value === 'openai/gpt-image-2'") + assert saved['image_model'] == 'openai/gpt-image-2' + page.select_option('#set-imgEditModelSelect', 'openai/gpt-image-2') + page.wait_for_function("document.getElementById('ge-ai-command-model').value.endsWith('::openai/gpt-image-2')") + page.evaluate('dialog.close(); dialog.open();') + page.wait_for_function("document.getElementById('ge-ai-command-model').value.endsWith('::openai/gpt-image-2')") + assert saved['image_edit_model'] == 'openai/gpt-image-2' + assert len(writes) == 2 + + +def test_generation_places_result_without_stretching_or_refresh(page): + result = page.evaluate("""async () => { + const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js'); + const image = document.createElement('canvas'); image.width = 80; image.height = 20; + const ctx = image.getContext('2d'); ctx.fillStyle = 'red'; ctx.fillRect(0,0,80,20); + const selection = document.createElement('canvas'); selection.width = selection.height = 40; + selection.getContext('2d').fillRect(0,0,40,40); + let placed = null, body = null; + const run = createImageGenerationAction({ + getDocument:()=>({key:'one',width:40,height:40,mask:selection}), + fetchImpl:async (url,opts) => { + body = JSON.parse(opts.body); + selection.getContext('2d').clearRect(0,0,40,40); + return {ok:true,json:async()=>({image:image.toDataURL()})}; + }, + placeResult:(canvas,name,mask)=>{ + const data = canvas.getContext('2d'); + placed = {width:canvas.width,height:canvas.height,left:data.getImageData(0,10,1,1).data[0], + right:data.getImageData(79,10,1,1).data[0], mask:mask.getContext('2d').getImageData(20,20,1,1).data[3]}; + return 'new-layer'; + }, + }); + const id = await run({prompt:'a cow',masked:true}); + return {id,placed,body}; + }""") + assert result['id'] == 'new-layer' + assert result['placed'] == dict(width=80,height=20,left=255,right=255,mask=255) + assert result['body'] == dict(_use_image_settings=True,prompt='a cow',width=40,height=40) + + +@pytest.mark.parametrize('action', ['cancel','timeout','invalid-image','document-switch']) +def test_generation_finishes_cleanly_on_failure_without_late_placement(page, action): + result = page.evaluate("""async action => { + const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js'); + let key = 'original', placements = 0; + const controller = new AbortController(); + const run = createImageGenerationAction({ + getDocument:()=>({key,width:40,height:40}), getSignal:()=>controller.signal, + timeoutMs:action === 'timeout' ? 20 : 2000, + fetchImpl:async (url,opts)=>{ + if (action === 'timeout' || action === 'cancel') { + if (action === 'cancel') setTimeout(()=>controller.abort(),10); + return new Promise((resolve,reject)=>opts.signal.addEventListener('abort', + ()=>reject(new DOMException('Aborted','AbortError')), {once:true})); + } + if (action === 'invalid-image') return {ok:true,json:async()=>({image:'bad-image-data'})}; + const image = document.createElement('canvas'); image.width = image.height = 4; + key = 'different'; + return {ok:true,json:async()=>({image:image.toDataURL()})}; + }, + placeResult:()=>placements++, + }); + let error = ''; + try {await run({prompt:'a cow'});} catch (e) {error = e.message;} + return {error,placements}; + }""", action) + assert result['placements'] == 0 + assert result['error'] + if action == 'timeout': + assert 'timed out' in result['error'] + if action == 'document-switch': + assert 'document changed' in result['error'] + + +def test_generation_provider_error_is_not_retried_and_next_request_can_succeed(page): + result = page.evaluate("""async () => { + const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js'); + let calls = 0, placements = 0; + const image = document.createElement('canvas'); image.width = image.height = 4; + const run = createImageGenerationAction({ + getDocument:()=>({key:'one',width:40,height:40}), + formatError:({status})=>status === 503 ? 'Generation is disabled' : 'error', + fetchImpl:async ()=> ++calls === 1 + ? {ok:false,status:503,json:async()=>({detail:'Disabled'})} + : {ok:true,json:async()=>({image:image.toDataURL()})}, + placeResult:()=>++placements, + }); + let error; + try {await run({prompt:'one'});} catch (e) {error = e.message;} + const callsAfterFailure = calls; + await run({prompt:'two'}); + return {error,callsAfterFailure,calls,placements}; + }""") + assert result == dict(error='Generation is disabled',callsAfterFailure=1,calls=2,placements=1) + + +def test_late_progress_response_cannot_restore_working_after_completion(page): + source = (ROOT / 'static/js/galleryEditor.js').read_text() + functions = source.split('function _startAiProgress(requestId) {',1)[1].split('function _setAiCommandStatus(',1)[0] + functions = 'function _startAiProgress(requestId) {' + functions + result = page.evaluate("""async functions => { + let _aiProgressTimer = null, _aiProgressEpoch = 0, status = 'Working'; + let resolveRequest; + const fetch = () => new Promise(resolve => resolveRequest = resolve); + const _setAiProgress = () => {}; + const _setAiCommandStatus = text => status = text; + const api = eval(functions + '; ({start:_startAiProgress,stop:_stopAiProgress});'); + api.start('old-request'); + api.stop(); status = 'Done'; + resolveRequest({ok:true,json:async()=>({stage:'editing',progress:0.5})}); + await new Promise(resolve=>setTimeout(resolve,20)); + return status; + }""", functions) + assert result == 'Done' + + +@pytest.mark.parametrize('success', [True,False]) +def test_quick_edit_queue_clears_working_on_completion(page, success): + source = (ROOT / 'static/js/galleryEditor.js').read_text() + pump = 'const pumpQuickEdit = ' + source.split('const pumpQuickEdit = ',1)[1].split(' const enqueueQuickEditJob',1)[0] + result = page.evaluate("""async ({pump,success}) => { + const {finishQuickEditJob} = await import('/static/js/editor/quick-edit-job.js'); + const jobs = [{id:'one',prompt:'fill',status:'queued'}]; + let pumping = false, _quickEditActiveJob = null, stopped = 0, terminal; + const renderJobStack = () => {}; + const dismissJob = () => {}; + const _stopAiProgress = () => stopped++; + const _setAiCommandStatus = (text,kind) => terminal = {text,kind}; + const executeQuickEdit = async () => success ? {ok:true} : {ok:false,error:'Provider failed'}; + await eval(pump + '; pumpQuickEdit();'); + return {status:jobs[0].status,detail:jobs[0].detail,terminal,stopped,pumping}; + }""", {'pump':pump,'success':success}) + expected = 'done' if success else 'error' + message = 'Done' if success else 'Provider failed' + assert result == dict(status=expected,detail=message,terminal=dict(text=message,kind=expected),stopped=1,pumping=False) + + +@pytest.mark.parametrize('bridge', [False,True]) +@pytest.mark.parametrize('instruction', [False,True]) +@pytest.mark.parametrize('matching_ratio', [False,True]) +def test_generated_layer_can_keep_pixels_outside_document_bounds(page, bridge, instruction, matching_ratio): + source = (ROOT / 'static/js/galleryEditor.js').read_text() + placement = 'const _placeResultLayer = ' + source.split('const _placeResultLayer = ',1)[1].split(' const _generateImage',1)[0] + bridge_line = next(line for line in source.splitlines() if "'layers.placeImage':" in line) + result = page.evaluate("""async ({placement,bridgeLine,bridge,instruction,matchingRatio}) => { + const {imageResultGeometry} = await import('/static/js/editor/image-result-geometry.js'); + const state = {imgWidth:matchingRatio ? 1280 : 40,imgHeight:matchingRatio ? 720 : 40,layers:[],layerOffsets:new Map()}; + let saved = 0; + const _saveState = ()=>saved++; + const composite = ()=>{}; + const _renderLayerPanel = ()=>{}; + const createLayer = (name,w,h)=>{ + const canvas = document.createElement('canvas'); canvas.width=w;canvas.height=h; + return {id:'new',name,canvas,ctx:canvas.getContext('2d')}; + }; + const image = document.createElement('canvas'); image.width=matchingRatio ? 1536 : 80;image.height=matchingRatio ? 864 : 20; + image.getContext('2d').fillRect(0,0,image.width,image.height); + if (bridge) { + const run = eval('({' + bridgeLine + '})')["layers.placeImage"].run; + await run({dataUrl:image.toDataURL(),nativeSize:true,masked:false, + mode:instruction ? 'instruction' : undefined,differenceMask:instruction}); + } else { + eval(placement + '; _placeResultLayer(image,"Generated",null);'); + } + const layer = state.layers[0]; + return {width:layer.canvas.width,height:layer.canvas.height,offset:state.layerOffsets.get(layer.id), + outsideAlpha:layer.ctx.getImageData(79,10,1,1).data[3],saved}; + }""", {'placement':placement,'bridgeLine':bridge_line,'bridge':bridge,'instruction':instruction,'matchingRatio':matching_ratio}) + expected = dict(width=1280,height=720,offset=dict(x=0,y=0),outsideAlpha=255,saved=1) if matching_ratio else dict(width=80,height=20,offset=dict(x=-20,y=10),outsideAlpha=255,saved=1) + assert result == expected + + +def test_whole_image_edit_does_not_clip_native_result_to_select_all(page): + source = (ROOT / 'static/js/galleryEditor.js').read_text() + operation = 'const _aiInpaintSelection = ' + source.split('const _aiInpaintSelection = ',1)[1].split(' // Replace the background/scene',1)[0] + result = page.evaluate("""async operation => { + const mask = document.createElement('canvas'); mask.width=40;mask.height=40; + const canvas = document.createElement('canvas'); canvas.width=80;canvas.height=20; + const state = {wandMask:mask}; + const activeLayer = ()=>({id:'one'}); + const _aiEditCanvas = async ()=>({canvas,fullFrame:true}); + const _placeResultLayer = (result,name,resultMask)=>({width:result.width,height:result.height,masked:!!resultMask}); + return await eval(operation + '; _aiInpaintSelection("change the sky");'); + }""", operation) + assert result == dict(width=80,height=20,masked=False) + + +@pytest.mark.parametrize('matching_ratio', [False,True]) +def test_inpaint_panel_preserves_native_whole_edit_dimensions(page, matching_ratio): + source = (ROOT / 'static/js/editor/ai-inpaint.js').read_text() + body = 'const fullFrame = ' + source.split('const fullFrame = ',1)[1].split(' // Snapshot the AI result',1)[0] + result = page.evaluate("""async ({body,matchingRatio}) => { + const {imageResultGeometry} = await import('/static/js/editor/image-result-geometry.js'); + const state = {imgWidth:matchingRatio ? 1280 : 40,imgHeight:matchingRatio ? 720 : 40,layerOffsets:new Map()}; + const data = {full_frame:true}, layerName = 'Edit'; + const createLayer = (name,w,h)=>{ + const canvas=document.createElement('canvas');canvas.width=w;canvas.height=h; + return {id:'result',canvas,ctx:canvas.getContext('2d')}; + }; + const source = document.createElement('canvas'); source.width=matchingRatio ? 1536 : 80;source.height=matchingRatio ? 864 : 20; + source.getContext('2d').fillRect(0,0,source.width,source.height); + const resultImg = new Image(); resultImg.src=source.toDataURL(); await resultImg.decode(); + return eval(body + '; ({width:resultLayer.canvas.width,height:resultLayer.canvas.height,offset:state.layerOffsets.get(resultLayer.id)});'); + }""", {'body':body,'matchingRatio':matching_ratio}) + assert result == (dict(width=1280,height=720,offset=dict(x=0,y=0)) if matching_ratio else dict(width=80,height=20,offset=dict(x=-20,y=10))) + + +def test_brush_softness_is_in_main_bar_and_saves_extended_value(page): + page.add_style_tag(content=(ROOT / 'static/css/13-image-editor.css').read_text()) + result = page.evaluate("""async () => { + const {controlsHTML} = await import('/static/js/editor/build/controls.js'); + const {wireStrokeToolSliders} = await import('/static/js/editor/stroke-tool-sliders.js'); + const {state} = await import('/static/js/editor/state.js'); + const bar = document.createElement('div');bar.className='ge-options-bar'; + const controls = document.createElement('div');controls.className='ge-controls ge-controls--in-options'; + controls.innerHTML=controlsHTML({color:'#000000',brushSize:40,wandTolerance:20}); + for(const child of controls.children) if(child.id !== 'ge-brush-controls') child.style.display='none'; + bar.append(controls);document.body.append(bar); + wireStrokeToolSliders(); + const slider=document.getElementById('ge-brush-softness'); + slider.value='1200';slider.dispatchEvent(new Event('input',{bubbles:true})); + return {inMore:!!slider.closest('#ge-brush-more-menu'),max:slider.max,value:state.brushSoftness, + visible:slider.getBoundingClientRect().height>0,label:document.getElementById('ge-brush-softness-label').textContent}; + }""") + assert result == dict(inMore=False,max='1200',value=1200,visible=True,label='1200%') + page.screenshot(path='/tmp/editor-brush-softness-main-bar.png') + + +def test_extended_softness_produces_softer_pixels_not_just_larger_slider_values(page): + result = page.evaluate("""async () => { + const {state}=await import('/static/js/editor/state.js'); + const {createStrokePipeline}=await import('/static/js/editor/stroke-pipeline.js'); + const results=[]; + for(const softness of [300,1200]) { + const canvas=document.createElement('canvas');canvas.width=canvas.height=100; + const layer={id:'brush',canvas,ctx:canvas.getContext('2d')}; + Object.assign(state,{tool:'brush',brushSize:60,brushSoftness:softness,brushOpacity:100,brushFlow:100, + brushBlendMode:'source-over',color:'#000000',brushSpacing:15,brushSmoothing:0, + brushPixel:false,pressureSize:false,pressureOpacity:false,pressureFlow:false,layerOffsets:new Map()}); + const pipeline=createStrokePipeline({activeLayer:()=>layer,getActiveMaskLayer:()=>null,composite:()=>{}}); + pipeline.beginStroke({x:50,y:50,pressure:1},'brush');pipeline.endStroke(null); + const alpha=x=>layer.ctx.getImageData(x,50,1,1).data[3]; + results.push({middle:alpha(65),center:alpha(50),outside:alpha(85)}); + document.body.append(canvas); + } + return results; + }""") + assert result[1]['middle'] < result[0]['middle'] / 3 + assert result[1]['center'] > 150 + assert result[0]['outside'] == result[1]['outside'] == 0 + page.screenshot(path='/tmp/editor-brush-softness-falloff.png') + + +@pytest.mark.parametrize('width', [1000,840]) +def test_brush_numbers_do_not_overlap_after_real_panel_normalization(page,width): + page.set_viewport_size({'width':width,'height':700}) + for stylesheet in ['static/css/13-image-editor.css','static/css/documents-gallery-editor.css']: + page.add_style_tag(content=(ROOT / stylesheet).read_text()) + page.add_style_tag(content=':root{--bg:#171719;--panel:#242426;--fg:#eee;--red:#d47965;--border:#454549;}body{background:var(--bg);}') + result = page.evaluate("""async () => { + const {state}=await import('/static/js/editor/state.js'); + const {controlsHTML}=await import('/static/js/editor/build/controls.js'); + const {buildRightPanel}=await import('/static/js/editor/build/right-panel.js'); + const {wireStrokeToolSliders}=await import('/static/js/editor/stroke-tool-sliders.js'); + state.container=document.createElement('div');document.body.append(state.container); + state.brushSize=800; + const {controls}=buildRightPanel({controlsHTML,layerPanelHTML:()=>'
'}); + controls.classList.add('ge-controls--in-options'); + const bar=document.createElement('div');bar.className='ge-options-bar';bar.append(controls);state.container.append(bar); + for(const child of controls.children) if(child.id !== 'ge-brush-controls') child.style.display='none'; + wireStrokeToolSliders(); + const slider=document.getElementById('ge-brush-softness'); + const elements=[document.querySelector('#ge-brush-softness-row label'),slider, + document.getElementById('ge-brush-softness-label'),document.querySelector('.ge-brush-size-row label'), + document.querySelector('.ge-brush-size-row input')]; + const bounds=()=>elements.map(element=>{ + const r=element.getBoundingClientRect();return {x:r.x,right:r.right,width:r.width}; + }); + slider.value='1';slider.dispatchEvent(new Event('input'));const small=bounds(); + slider.value='1200';slider.dispatchEvent(new Event('input'));const large=bounds(); + return {small,large,value:document.getElementById('ge-brush-softness-label').textContent}; + }""") + assert result['value'] == '1200%' + assert all(bounds['width'] > 0 for bounds in result['large']), result + for left,right in zip(result['large'],result['large'][1:]): + assert left['right'] <= right['x'] + 1, result + for small,large in zip(result['small'],result['large']): + assert abs(small['x']-large['x']) <= 1, result + page.screenshot(path=f'/tmp/editor-brush-number-layout-{width}.png') + + +@pytest.mark.parametrize('fail_once', [False, True]) +def test_new_project_create_initializes_canvas(page, fail_once): + errors = [] + page.on('pageerror', lambda error: errors.append(str(error))) + page.route('http://editor.test/api/**', lambda route: route.fulfill(json={})) + page.add_style_tag(content=''' + .modal {position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:250;pointer-events:none;} + .modal-content {pointer-events:auto;background:#333;padding:20px;} + #gallery-modal {z-index:700;} + #gallery-modal > .modal-content {width:90vw;height:90vh;} + ''') + page.evaluate("""async () => { + document.body.innerHTML = '
'; + const editor = await import('/static/js/galleryEditor.js'); + window.newProject = editor.openEditor(null, null, null, 'New canvas'); + window.newProject.catch(error => { window.createError = error.stack; }); + }""") + if fail_once: + page.evaluate("""() => { + const fill = CanvasRenderingContext2D.prototype.fillRect; + CanvasRenderingContext2D.prototype.fillRect = function(...args) { + if (this.canvas.width === 1024 && this.canvas.height === 1024) { + CanvasRenderingContext2D.prototype.fillRect = fill; + throw Error('Canvas allocation test failure'); + } + return fill.apply(this, args); + }; + }""") + page.locator('#ge-canvas-prompt-ok').click(timeout=3000) + if fail_once: + page.wait_for_function("window.createError") + assert 'Canvas allocation test failure' in page.evaluate('window.createError') + assert 'Could not create project' in page.locator('body').inner_text() + page.evaluate("""async () => { + window.createError = null; + const editor = await import('/static/js/galleryEditor.js'); + window.newProject = editor.openEditor(null, null, null, 'New canvas'); + window.newProject.catch(error => { window.createError = error.stack; }); + }""") + page.locator('#ge-canvas-prompt-ok').click(timeout=3000) + page.wait_for_timeout(500) + result = page.evaluate("""async () => { + const {state} = await import('/static/js/editor/state.js'); + return {error:window.createError, width:state.mainCanvas?.width, + height:state.mainCanvas?.height, layers:state.layers.map(layer=>layer.name)}; + }""") + assert not errors, errors + assert not result.get('error'), result + assert result == {'error':None,'width':1024,'height':1024,'layers':['Background','Edit']} diff --git a/tests/test_editor_polish_batch2.py b/tests/test_editor_polish_batch2.py index 7fa48ed91..1a97608e0 100644 --- a/tests/test_editor_polish_batch2.py +++ b/tests/test_editor_polish_batch2.py @@ -143,8 +143,8 @@ def test_crop_can_start_outside_the_canvas_without_panning(): # image (so it can be pulled out past the edge) instead of the canvas-area # pan grabbing the pointer and switching to hand mode. CE = (ROOT / "static/js/editor/canvas-events.js").read_text(encoding="utf-8") - assert "if (state.tool !== 'lasso' && state.tool !== 'crop') return;" in CE - assert "(state.tool === 'lasso' || state.tool === 'crop') && !directPan" in CE + assert "['marquee', 'lasso', 'crop'].includes(state.tool)" in CE + assert "if (canStartOutside() && !directPan)" in CE # Pressing editor chrome inside the area (e.g. the crop Apply panel) must # not start a fresh crop. assert ".ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup" in CE diff --git a/tests/test_editor_preflight.py b/tests/test_editor_preflight.py index 52a1d48ae..a19f65304 100644 --- a/tests/test_editor_preflight.py +++ b/tests/test_editor_preflight.py @@ -32,13 +32,12 @@ def test_preflight_module_has_the_four_setup_steps(): assert "required ready" in src -def test_preflight_is_wired_into_the_editor(): +def test_editor_uses_ai_settings_without_redundant_setup(): editor = _read("static/js/galleryEditor.js") - assert "import { createPreflight } from './editor/preflight.js';" in editor - assert "createPreflight({ canvasArea })" in editor - assert "_preflight?.maybeOpen();" in editor - assert "setupBtn.id = 'ge-setup-btn';" in editor - assert "_preflight?.destroy();" in editor + assert "createPreflight" not in editor + assert "setupBtn.id = 'ge-setup-btn';" not in editor + assert "createAiSettings({ container })" in editor + assert "aiBtn.addEventListener('click', () => _aiSettings?.open());" in editor def test_ai_models_exposes_status_for_the_checklist(): diff --git a/tests/test_editor_quick_edit_image_mode.py b/tests/test_editor_quick_edit_image_mode.py index 920c70122..653318342 100644 --- a/tests/test_editor_quick_edit_image_mode.py +++ b/tests/test_editor_quick_edit_image_mode.py @@ -49,7 +49,42 @@ def test_generate_route_forwards_the_picked_model(): assert 'model_choice = str((body or {}).get("model") or "").strip()' in route assert 'payload["model"] = model_choice' in route assert "content = _json.dumps(payload)" in route - assert "localStorage.getItem('ge-ai-command-model')" in JS + operation = JS.split("'ai.generateImage':", 1)[1].split("\n", 1)[0] + generation = (ROOT / 'static/js/editor/image-generation.js').read_text() + assert '_generateImage(p || {})' in operation + assert "_use_image_settings: true" in generation + assert "document.getElementById('ge-ai-model')" not in operation + assert "_getSelectedQuickEditModel()" not in operation + assert "localStorage.getItem" not in operation + + +def test_ai_settings_separates_generation_from_editing_without_setup(): + settings = (ROOT / "static/js/editor/ai-settings.js").read_text() + assert 'id="ge-ai-model"' in settings + assert 'id="ge-ai-command-model"' in settings + assert "Image generation model" in settings + assert "createPreflight" not in JS + assert "ge-setup-btn';" not in JS.split("const aiBtn", 1)[0] + + +def test_generate_uses_live_picker_and_auto_edit_fallback(): + import subprocess + + module = (ROOT / "static/js/editor/ai-models.js").as_uri() + script = """ +import assert from 'node:assert/strict'; +const {resolveQuickEditModel} = await import(MODULE); +const selected = 'https://openrouter.ai/api/v1::openai/gpt-5-image'; +const option = {value: selected, textContent: 'GPT-5 Image'}; +const picker = {value: selected, selectedIndex: 0, options: [option]}; +const fallback = {value: '', options: [option]}; +globalThis.document = {getElementById: id => id === 'ge-ai-command-model' ? picker : fallback}; +globalThis.localStorage = {getItem: () => {throw new Error('must not read stale storage');}}; +assert.equal(resolveQuickEditModel().model, 'openai/gpt-5-image'); +picker.value = ''; +assert.equal(resolveQuickEditModel().model, ''); +""".replace("MODULE", repr(module)) + subprocess.run(["node", "--input-type=module", "-e", script], check=True) def test_cloud_image_model_is_resolved_before_the_local_fallback(): diff --git a/tests/test_editor_quick_edit_static.py b/tests/test_editor_quick_edit_static.py index 90208a984..44c72eca6 100644 --- a/tests/test_editor_quick_edit_static.py +++ b/tests/test_editor_quick_edit_static.py @@ -52,7 +52,7 @@ def test_quick_edit_flip_and_rotate_accept_numeric_degrees(): def test_quick_edit_transforms_do_not_add_a_second_undo_snapshot(): start = EDITOR.index("if (/\\brotate\\b.*\\b180\\b") - end = EDITOR.index("if (_looksBackgroundRemoval(p)) {", start) + end = EDITOR.index("if (!/\\b(text|title|caption|font|heading|subtitle)", start) transform_block = EDITOR[start:end] assert "_saveState('Rotate" not in transform_block assert "_saveState('Flip" not in transform_block @@ -372,9 +372,10 @@ def test_quick_edit_has_a_model_picker(): assert "function _getSelectedQuickEditModel()" in EDITOR assert "getModel: () => _getSelectedQuickEditModel()," in EDITOR # Populated from the provider list and persisted per user. - assert "aiCommandSelect.innerHTML = '';" in models - assert "localStorage.setItem('ge-ai-command-model', aiCommandSelect.value)" in models - assert "localStorage.getItem('ge-ai-command-model')" in models + assert "aiCommandSelect.innerHTML = '';" in models + assert "localStorage.setItem('ge-ai-command-model'" not in models + assert "localStorage.getItem('ge-ai-command-model')" not in models + assert 'saveImagePreferences({ [key]: model })' in AI_SETTINGS # Styled. assert ".ge-ai-settings-card" in STYLE assert ".ge-ai-command-model" in STYLE @@ -698,7 +699,7 @@ def test_quick_edit_shows_a_real_progress_bar(): assert "fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}`" in EDITOR # The edit request carries the request_id the server keys progress on. assert "const requestId = 'qe-' + Date.now().toString(36)" in EDITOR - assert "const body = JSON.stringify({ image, request_id: requestId, ...payload });" in EDITOR + assert "const body = JSON.stringify({ image, width: flat.width, height: flat.height, request_id: requestId, ...payload });" in EDITOR assert "_startAiProgress(requestId);" in EDITOR assert "_stopAiProgress();" in EDITOR # Styling exists. @@ -718,7 +719,7 @@ def test_generation_503_is_handled_gracefully(): # (returns 503). Surface a clear message and do not retry immediately. assert "if (status === 503) return _GEN_DISABLED_MSG;" in EDITOR assert "_GEN_DISABLED_MSG = 'Image generation is switched off on the server at the moment.'" in EDITOR - assert "window.__geGenerationDisabled" in EDITOR + assert "formatError: _generationErrorMessage" in EDITOR def test_sending_a_prompt_returns_focus_to_the_document(): diff --git a/tests/test_gallery_cloud_inpaint.py b/tests/test_gallery_cloud_inpaint.py index af0e635b2..8e50c2162 100644 --- a/tests/test_gallery_cloud_inpaint.py +++ b/tests/test_gallery_cloud_inpaint.py @@ -5,6 +5,7 @@ import json from types import SimpleNamespace import httpx +import pytest from PIL import Image import routes.gallery_routes as gallery @@ -16,7 +17,10 @@ def png(image): return base64.b64encode(output.getvalue()).decode() -def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog): +@pytest.mark.parametrize('model', ['openai/gpt-5-image', 'openai/gpt-image-2']) +@pytest.mark.parametrize('saved_settings', [False, True]) +@pytest.mark.parametrize('full_frame', [False, True]) +def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog, model, saved_settings, full_frame): import logging caplog.set_level(logging.DEBUG) endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', api_key='test') @@ -29,36 +33,97 @@ def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog): monkeypatch.setattr(gallery, 'require_privilege', lambda *args: 'alice') monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *args: endpoint) monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *args, **kwargs: (True, '')) + monkeypatch.setattr('src.editor_image_settings.load_settings', lambda: {'image_model':'wrong-generation-model', 'image_edit_model':model}) + def resolve_model(selected, **kwargs): + assert selected == model + assert kwargs['owner'] == 'alice' + return endpoint.base_url + '/chat/completions', model, {} + monkeypatch.setattr('src.ai_interaction._resolve_model', resolve_model) requests = [] def respond(request): requests.append(request) - assert str(request.url) == endpoint.base_url + '/images' + assert str(request.url) == endpoint.base_url + ('/chat/completions' if model == 'openai/gpt-5-image' else '/images') payload = json.loads(request.content) - assert payload['model'] == 'openai/gpt-5-image' - assert len(payload['input_references']) == 2 + assert payload['model'] == model + if model == 'openai/gpt-5-image': + assert len(payload['messages'][0]['content']) == 3 + else: + assert len(payload['input_references']) == 2 assert request.headers['authorization'] == 'Bearer test' - return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (2, 1), 'blue'))}]}) + image = png(Image.new('RGB', (4, 4) if full_frame else (2, 1), 'blue')) + if model == 'openai/gpt-5-image': + return httpx.Response(200, json={'choices':[{'message':{'images':[{'image_url':{'url':'data:image/png;base64,'+image}}]}}]}) + return httpx.Response(200, json={'data': [{'b64_json': image}]}) client = httpx.AsyncClient monkeypatch.setattr(httpx, 'AsyncClient', lambda **kwargs: client(transport=httpx.MockTransport(respond), **kwargs)) mask = Image.new('L', (2, 1)) + if full_frame: + mask.paste(255, (0,0,2,1)) mask.putpixel((1, 0), 255) class Request: async def json(self): - return {'_endpoint': endpoint.base_url, '_model': 'openai/gpt-5-image', + return {'_endpoint': endpoint.base_url, '_model': 'stale-model' if saved_settings else model, + '_use_image_settings':saved_settings, 'image': png(Image.new('RGB', (2, 1), 'red')), 'mask': png(mask), 'prompt': 'private-cloud-prompt'} route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint') result = asyncio.run(route.endpoint(Request())) output = Image.open(io.BytesIO(base64.b64decode(result['image']))) - assert output.getpixel((0, 0)) == (255, 0, 0, 255) - assert output.getpixel((1, 0)) == (0, 0, 255, 255) + if full_frame: + assert output.size == (4,4) + assert result['full_frame'] is True + else: + assert output.getpixel((0, 0)) == (255, 0, 0, 255) + assert output.getpixel((1, 0)) == (0, 0, 255, 255) assert len(requests) == 1 assert 'private-cloud-prompt' not in caplog.text +def test_partial_image_edit_preserves_ratio_and_unmasked_pixels(): + source = Image.new('RGBA',(4,4),'red') + generated = Image.new('RGBA',(4,2),'blue') + mask = Image.new('L',(4,4),255) + mask.putpixel((0,0),0) + result, full = gallery._compose_cloud_image_edit(source,generated,mask) + assert full is False + assert result.size == (4,4) + assert result.getpixel((2,0)) == source.getpixel((2,0)) + assert result.getpixel((2,3)) == source.getpixel((2,3)) + assert result.getpixel((2,1)) == (0,0,255,255) + + +def test_quick_edit_inpaint_requests_source_ratio_without_width_height_fields(monkeypatch): + endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1',api_key='test') + monkeypatch.setattr(gallery,'SessionLocal',lambda:SimpleNamespace(close=lambda:None)) + monkeypatch.setattr(gallery,'require_privilege',lambda *args:'alice') + monkeypatch.setattr(gallery,'_visible_image_endpoint_for_base',lambda *args:endpoint) + monkeypatch.setattr('src.url_safety.check_outbound_url',lambda *args,**kwargs:(True,'')) + sent = [] + original = httpx.AsyncClient + + def respond(request): + payload = json.loads(request.content) + sent.append(payload) + return httpx.Response(200,json={'data':[{'b64_json':png(Image.new('RGB',(1536,864),'blue'))}]}) + + monkeypatch.setattr(httpx,'AsyncClient',lambda **kwargs:original(transport=httpx.MockTransport(respond),**kwargs)) + + class Request: + async def json(self): + return {'_endpoint':endpoint.base_url,'_model':'openai/gpt-image-2','prompt':'change the sky', + 'image':png(Image.new('RGB',(1280,720),'red')),'mask':png(Image.new('L',(1280,720),255))} + + route = next(route for route in gallery.setup_gallery_routes().routes if route.path == '/api/image/inpaint') + result = asyncio.run(route.endpoint(Request())) + assert sent[0]['aspect_ratio'] == '16:9' + assert 'size' not in sent[0] + image = Image.open(io.BytesIO(base64.b64decode(result['image']))) + assert image.size == (1536,864) + + def test_mixed_endpoint_requires_configured_image_model(monkeypatch): endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', owner='alice', model_type='llm', cached_models='["openai/gpt-5-image", "text-model"]', pinned_models=None) diff --git a/tests/test_image_provider_transport.py b/tests/test_image_provider_transport.py index 46d67598f..bf5ff9776 100644 --- a/tests/test_image_provider_transport.py +++ b/tests/test_image_provider_transport.py @@ -23,7 +23,7 @@ def test_native_generation_uses_configured_model_and_multiline_prompt(image_tran json.dumps({'prompt': prompt}), session_id='fixture', owner='alice')) assert 'error' not in result assert seen == [('openai/gpt-5-image', 'alice')] - assert json.loads(requests[0].content)['prompt'] == prompt + assert json.loads(requests[0].content)['messages'][0]['content'] == prompt assert result['image_id'] @@ -87,13 +87,17 @@ def image_transport(monkeypatch, tmp_path): original_client = httpx.AsyncClient requests = [] - def configure(base, status=200, body=None): + def configure(base, status=200, body=None, model='openai/gpt-5-image'): monkeypatch.setattr(ai_interaction, '_resolve_model', lambda *args, **kwargs: ( - base + '/chat/completions', 'openai/gpt-5-image', {'Authorization': 'Bearer test'}, + base + '/chat/completions', model, {'Authorization': 'Bearer test'}, )) def handle(request): requests.append(request) + if body is None and 'openrouter.ai' in base and request.url.path.endswith('/chat/completions'): + return httpx.Response(status, json={'choices': [{'message': {'images': [{ + 'image_url': {'url': 'data:image/png;base64,' + base64.b64encode(source.read_bytes()).decode()}, + }]}}]}) return httpx.Response(status, json=body if body is not None else { 'data': [{'b64_json': base64.b64encode(source.read_bytes()).decode()}], }) @@ -130,12 +134,13 @@ def test_image_provider_protocol(image_transport, editing, base): assert request.headers['authorization'] == 'Bearer test' if 'openrouter.ai' in base: assert len(requests) == 1 # No unsupported local progress/fallback probes. - assert str(request.url) == base + '/images' + assert str(request.url) == base + '/chat/completions' payload = json.loads(request.content) - assert payload['n'] == 1 + assert payload['model'] == 'openai/gpt-5-image' + assert payload['modalities'] == ['image', 'text'] + assert 'prompt' not in payload if editing: - assert payload['size'] == '1536x1024' - reference = payload['input_references'][0]['image_url']['url'] + reference = payload['messages'][0]['content'][1]['image_url']['url'] assert reference.startswith('data:image/png;base64,') assert base64.b64decode(reference.split(',', 1)[1]) == source.read_bytes() assert 'request_id' not in payload @@ -181,13 +186,65 @@ def test_edit_dimensions_respect_exif_and_explicit_size(image_transport, explici exif = Image.Exif() exif[274] = 6 Image.new('RGB', (120, 80)).save(source, exif=exif) - configure('https://openrouter.ai/api/v1') + configure('https://openrouter.ai/api/v1', model='openai/gpt-image-1') result = asyncio.run(ai_interaction.do_edit_image( - 'Edit', str(source), model_spec='openai/gpt-5-image', + 'Edit', str(source), model_spec='openai/gpt-image-1', size='1024x1024' if explicit else 'auto', )) assert 'error' not in result - assert json.loads(requests[0].content)['size'] == ('1024x1024' if explicit else '1024x1536') + assert json.loads(requests[0].content)['aspect_ratio'] == ('1:1' if explicit else '2:3') + + +def test_native_openrouter_image_model_keeps_dedicated_image_api(image_transport): + configure, source, requests, rows = image_transport + configure('https://openrouter.ai/api/v1', model='openai/gpt-image-1') + result = asyncio.run(ai_interaction.do_generate_image('A cow\nopenai/gpt-image-1', incognito=True)) + assert 'error' not in result + assert str(requests[0].url).endswith('/api/v1/images') + assert json.loads(requests[0].content)['model'] == 'openai/gpt-image-1' + + +@pytest.mark.parametrize('editing', [False, True]) +def test_http_200_provider_deprecation_is_not_reported_as_empty_image(image_transport, editing): + configure, source, requests, rows = image_transport + configure('https://openrouter.ai/api/v1', body={'choices': [{ + 'finish_reason': 'error', 'message': {'content': None}, + 'error': {'code': 502, 'message': 'The model gpt-image-1 has been deprecated'}, + }]}) + if editing: + result = asyncio.run(ai_interaction.do_edit_image('Edit', str(source), model_spec='openai/gpt-5-image')) + else: + result = asyncio.run(ai_interaction.do_generate_image('A cow\nopenai/gpt-5-image')) + assert 'deprecated' in result['error'] + assert 'openai/gpt-5-image' in result['error'] + assert 'AI settings' in result['error'] + assert len(requests) == 1 + assert not rows + + +def test_empty_image_response_identifies_the_selected_model(image_transport): + configure, source, requests, rows = image_transport + configure('https://openrouter.ai/api/v1', body={'choices': [{'message': {'content': 'No image.'}}]}) + result = asyncio.run(ai_interaction.do_generate_image('A cow\nopenai/gpt-5-image')) + assert "'openai/gpt-5-image' returned no image" in result['error'] + assert 'AI settings' in result['error'] + assert len(requests) == 1 + + +@pytest.mark.parametrize('editing', [False,True]) +def test_openrouter_1280_by_720_requests_16_by_9_not_square(image_transport, editing): + configure, source, requests, rows = image_transport + Image.new('RGB',(1280,720),'red').save(source) + configure('https://openrouter.ai/api/v1',model='openai/gpt-image-2') + if editing: + result = asyncio.run(ai_interaction.do_edit_image('Change the sky',str(source),model_spec='openai/gpt-image-2',incognito=True)) + else: + result = asyncio.run(ai_interaction.do_generate_image(json.dumps({ + 'prompt':'a landscape','model':'openai/gpt-image-2','size':'1280x720'}),incognito=True)) + assert 'error' not in result + body = json.loads(requests[0].content) + assert body['aspect_ratio'] == '16:9' + assert 'size' not in body def test_unreadable_image_does_not_call_provider(image_transport):