Compare commits

...
45 changed files with 1928 additions and 267 deletions
+27 -2
View File
@@ -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
+65 -12
View File
@@ -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
+8 -2
View File
@@ -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),
+113 -8
View File
@@ -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"}
+11
View File
@@ -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(),
}
+9
View File
@@ -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.
+2 -1
View File
@@ -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.
+63 -1
View File
@@ -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;
+4
View File
@@ -1684,6 +1684,10 @@
<label class="settings-label">Model</label>
<select id="set-imgModelSelect" class="settings-select"><option value="">Auto-detect</option></select>
</div>
<div style="display:flex;align-items:center;gap:0.75rem;">
<label class="settings-label" for="set-imgEditModelSelect">Image edit model</label>
<select id="set-imgEditModelSelect" class="settings-select"><option value="">Same as generation</option></select>
</div>
<div style="display:flex;align-items:center;gap:0.75rem;">
<label class="settings-label">Quality</label>
<select id="set-imgQualitySelect" class="settings-select">
+25 -6
View File
@@ -41,6 +41,7 @@
* }} deps
*/
import { state } from './state.js';
import { imageResultGeometry } from './image-result-geometry.js';
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
export function wireInpaintButtons({
@@ -184,17 +185,30 @@ export function wireInpaintButtons({
if (!state.editorOpen) return; // user closed mid-decode
try {
saveState('Inpaint result');
// OpenAI returns at one of its allowed sizes (1024²,
// 1024×1536, 1536×1024) which often differs from our
// canvas. Scale to canvas size with smoothing so the
// inpaint blends in regardless of source dims.
// Whole-image edits keep the provider's native frame; only masked
// edits need registration with the source document's coordinates.
const shortPrompt = (prompt || '').trim().replace(/\s+/g, ' ').slice(0, 40);
const layerName = shortPrompt ? `Inpaint: ${shortPrompt}` : 'Inpaint Result';
const resultLayer = createLayer(layerName, state.imgWidth, state.imgHeight);
const fullFrame = data.full_frame === true;
const geometry = imageResultGeometry(resultImg.naturalWidth, resultImg.naturalHeight, state.imgWidth, state.imgHeight);
const resultLayer = createLayer(layerName,
fullFrame ? geometry.width : state.imgWidth,
fullFrame ? geometry.height : state.imgHeight);
resultLayer.ctx.imageSmoothingEnabled = true;
resultLayer.ctx.imageSmoothingQuality = 'high';
resultLayer.ctx.drawImage(resultImg, 0, 0, state.imgWidth, state.imgHeight);
if (fullFrame) {
resultLayer.ctx.drawImage(resultImg, geometry.drawX, geometry.drawY, geometry.drawWidth, geometry.drawHeight);
state.layerOffsets.set(resultLayer.id, {
x: geometry.offsetX,
y: geometry.offsetY,
});
} else {
const scale = Math.min(state.imgWidth / resultImg.naturalWidth, state.imgHeight / resultImg.naturalHeight);
const width = resultImg.naturalWidth * scale, height = resultImg.naturalHeight * scale;
resultLayer.ctx.drawImage(resultImg, (state.imgWidth - width) / 2, (state.imgHeight - height) / 2, width, height);
}
// Snapshot the AI result + hard mask used for this run.
if (!fullFrame) {
const aiSnap = document.createElement('canvas');
aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight;
aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0);
@@ -205,6 +219,7 @@ export function wireInpaintButtons({
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
// Apply initial alpha = hard mask (no feather, no edge shift).
applyInpaintFeather(resultLayer, 0, 0);
}
state.layers.push(resultLayer);
state.activeLayerId = resultLayer.id;
state.lastInpaintLayerId = resultLayer.id;
@@ -220,6 +235,10 @@ export function wireInpaintButtons({
}
composite();
renderLayerPanel();
if (fullFrame) {
if (uiModule) uiModule.showToast('Image edit complete', 4500);
return;
}
// Reveal post-generation Feather + Edge Stroke sliders.
// Cap Edge Stroke at ±padPx so the slider can't ask for
// more AI buffer than we generated.
+14 -65
View File
@@ -54,16 +54,16 @@ export function resolveQuickEditModel() {
label: ((opt && opt.textContent) || '').trim(),
};
}
const inp = document.getElementById('ge-ai-inpaint');
const opt = resolveInpaintModel(inp);
if (!opt) return { endpoint: '', model: '', label: '' };
const raw = opt.value || '';
const idx = raw.indexOf('::');
return {
endpoint: idx < 0 ? raw : raw.slice(0, idx),
model: idx < 0 ? '' : raw.slice(idx + 2),
label: ((opt.textContent || '').trim().replace(/^Auto\s*\((.*)\)$/, '$1')),
};
const generation = document.getElementById('ge-ai-model');
if (generation?.value && generation.value !== '__serve_cookbook__') {
const index = generation.value.indexOf('::');
const model = index < 0 ? '' : generation.value.slice(index + 2);
if (model && modelCaps(model, '', '').inpaint) {
return { endpoint: generation.value.slice(0, index), model,
label: generation.options[generation.selectedIndex]?.textContent?.trim() || model };
}
}
return { endpoint: '', model: '', label: 'Auto (saved settings)' };
}
// Resolve the Quick Edit AGENT (planner) model — the vision-language model
@@ -140,25 +140,7 @@ export function modelCaps(modelId, endpointName, endpointType) {
return { gen: false, inpaint: false };
}
// Global listeners resolve live controls instead of retaining editor instances.
function syncCommandModel(value) {
const select = document.getElementById('ge-ai-command-model');
if (!select || !select._modelsReady) return;
if (value && ![...select.options].some(o => !o.disabled && o.value === value && value !== '__serve_cookbook__')) {
if (localStorage.getItem('ge-ai-command-model') === value) localStorage.removeItem('ge-ai-command-model');
value = '';
}
if (select.value !== value) {
select.value = value;
if (select.value !== value) select.value = '';
updateFooterEditModel();
}
}
window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || ''));
window.addEventListener('storage', e => {
if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || '');
});
window.addEventListener('ge:model-endpoints-updated', e => {
globalThis.window?.addEventListener('ge:model-endpoints-updated', e => {
document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || '');
});
@@ -256,9 +238,9 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
const data = await res.json();
if (listeners.signal.aborted || !root.isConnected) return;
const modelsList = data.models || [];
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">Auto-detect</option>';
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>';
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Same as generation</option>';
const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model'));
for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>';
let firstGen = null;
@@ -346,45 +328,11 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
// Sentinel changes are handled by the component's delegated listener.
if (aiCommandSelect) {
aiCommandSelect._modelsReady = true;
try {
const rawSaved = localStorage.getItem('ge-ai-command-model') || '';
let saved = rawSaved;
if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) {
const modelPart = saved.includes('::') ? saved.split('::').pop() : '';
const matching = loaded.filter(l => l.model === modelPart);
if (matching.length === 1) {
saved = matching[0].value;
} else {
saved = '';
}
try {
if (saved) localStorage.setItem('ge-ai-command-model', saved);
else localStorage.removeItem('ge-ai-command-model');
} catch {}
}
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) {
aiCommandSelect.value = saved;
} else {
aiCommandSelect.value = '';
}
if (aiCommandSelect.value !== rawSaved) {
if (aiCommandSelect.value) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
else localStorage.removeItem('ge-ai-command-model');
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: aiCommandSelect.value } }));
}
} catch {}
if (aiCommandSelect._commandWired !== listeners) {
aiCommandSelect._commandWired = listeners;
listen(aiCommandSelect, 'change', () => {
if (aiCommandSelect.value === '__serve_cookbook__') return;
const val = aiCommandSelect.value || '';
try {
if (val) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
else localStorage.removeItem('ge-ai-command-model');
} catch {}
updateFooterEditModel();
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
});
}
}
@@ -409,6 +357,7 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
});
}
_aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() };
container.dispatchEvent(new Event('ge-image-models-ready'));
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
} catch (e) {
if (listeners.signal.aborted || !root.isConnected) return;
+5 -30
View File
@@ -2,11 +2,10 @@
* Background Remove (rembg) + Sharpen wiring + the live edge-cleanup
* tuner that runs on the most-recent rembg cutout.
*
* rembg-run button: flatten + POST to /api/image/remove-bg with an
* rembg-run button: active layer + POST to /api/image/remove-bg with an
* optional hint_mask if the user has a wand/lasso selection
* active. After the new layer lands, hides every previously-
* visible layer so the cutout reads cleanly, and binds the
* live-tuner to the new layer.
* active. Updates only that layer and binds its live edge tuner;
* other layers and their visibility remain unchanged.
*
* Live edge-cleanup tuner: snapshots the pristine cutout the
* moment it lands; subsequent feather/grow slider tweaks
@@ -65,37 +64,13 @@ export function wireRembgAndSharpen({
// NB: edge_feather / edge_grow are applied CLIENT-side so the
// sliders can re-tune the cutout without re-running the model.
const btn = document.getElementById('ge-rembg-run');
const before = state.layers.length;
// Snapshot which layers were visible BEFORE the run so we know
// which to hide after a successful cutout.
const prevVisible = state.layers.filter(l => l.visible).map(l => l.id);
await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
// applyImageTool resolves after the returned image is decoded and the
// new layer is inserted. Keep a short poll for defensive compatibility
// with alternate runners that may still commit on the next frame.
let frames = 0;
while (state.layers.length <= before && frames < 60) {
await new Promise(r => requestAnimationFrame(r));
frames++;
}
if (state.layers.length > before) {
const newLayer = state.layers[state.layers.length - 1];
bindRembgLiveTuner(newLayer);
// Auto-hide underlying layers so the user sees just the
// cutout — the eye toggles back on if they re-enable manually.
for (const layer of state.layers) {
if (prevVisible.includes(layer.id) && layer.id !== newLayer.id) {
layer.visible = false;
}
}
composite();
renderLayerPanel();
}
const editedLayer = await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
// Reset sliders so the new cutout starts clean.
const f = document.getElementById('ge-rembg-feather');
const g = document.getElementById('ge-rembg-grow');
if (f) { f.value = 0; document.getElementById('ge-rembg-feather-label').textContent = '0px'; syncRembgFeather(0); }
if (g) { g.value = 0; document.getElementById('ge-rembg-grow-label').textContent = '0px'; syncRembgGrow(0); }
if (editedLayer) bindRembgLiveTuner(editedLayer);
});
// ── Live edge-cleanup tuner ──
+61 -3
View File
@@ -5,6 +5,9 @@
* "Double-check AI edits" toggle. Opened from the Quick Edit gear button and
* from the editor setup checklist; keeps the Quick Edit box itself uncluttered.
*/
import { readImagePreferences, saveImagePreferences } from '../settings/imagePreferences.js';
import { updateFooterEditModel } from './ai-models.js';
export function createAiSettings({ container }) {
const el = document.createElement('div');
el.className = 'ge-ai-settings';
@@ -19,11 +22,14 @@ export function createAiSettings({ container }) {
<button type="button" class="ge-ai-settings-close" data-ai-settings-close aria-label="Close">×</button>
</div>
<div class="ge-ai-settings-body">
<label class="ge-ai-settings-label" for="ge-ai-model">Image generation model</label>
<select class="ge-ai-command-model" id="ge-ai-model" title="Image generation model" aria-label="Image generation model"></select>
<label class="ge-ai-settings-label" for="ge-ai-command-model">Image edit model</label>
<select class="ge-ai-command-model" id="ge-ai-command-model" title="Image edit model" aria-label="Image edit model"></select>
<label class="ge-ai-settings-label" for="ge-ai-command-agent-model">AI agent (vision) model</label>
<select class="ge-ai-command-model" id="ge-ai-command-agent-model" title="Quick Edit agent model" aria-label="Quick Edit agent model"></select>
<p class="ge-ai-settings-note">Recommended, not required — the vision agent plans edits and can double-check results.</p>
<p class="ge-ai-settings-note" id="ge-ai-settings-save-status" role="status"></p>
<label class="ge-ai-settings-label" for="ge-look-strength">Look strength</label>
<div class="ge-ai-settings-strength">
<input type="range" id="ge-look-strength" min="0.5" max="2" step="0.1" value="1.4" aria-label="Look grade strength" />
@@ -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();
}
+23 -1
View File
@@ -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);
+5 -5
View File
@@ -55,6 +55,11 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<label>Color</label>
<input type="color" class="ge-color-picker" value="${color}" />
</div>
<div class="ge-control-row ge-eraser-row" id="ge-brush-softness-row">
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
<input type="range" id="ge-brush-softness" min="0" max="1200" value="100" title="Brush edge softness and feathered falloff" />
</div>
<div class="ge-control-row ge-brush-size-row">
<label><span class="ge-size-label">${brushSize}px</span></label>
<input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" />
@@ -94,11 +99,6 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
<label>Flow <span id="ge-brush-flow-label">100%</span></label>
<input type="range" id="ge-brush-flow" min="5" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row" id="ge-brush-softness-row">
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
<input type="range" id="ge-brush-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha for a feathered fade at the perimeter." />
</div>
</div>
<div class="ge-brush-stroke-group" data-stroke-tool="eraser">
<div class="ge-section-title">Eraser</div>
+35 -5
View File
@@ -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;
+5
View File
@@ -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) || '',
+1 -1
View File
@@ -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;
+2 -1
View File
@@ -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;
+71
View File
@@ -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);
}
};
}
+11
View File
@@ -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 };
}
+12 -5
View File
@@ -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.
+4
View File
@@ -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();
}
},
});
+8 -1
View File
@@ -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);
+13
View File
@@ -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';
}
}
+32 -10
View File
@@ -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';
+5 -2
View File
@@ -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%)`;
}
});
}
+18 -4
View File
@@ -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);
}
});
}
+2
View File
@@ -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';
+6 -1
View File
@@ -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();
}
+121 -57
View File
@@ -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 += `
<div class="ge-filter-row ge-eraser-row">
<label>${p.label}${isColor ? '' : ` <span class="ge-filter-row-value" data-val-for="${p.key}">${p.value}${p.suffix || ''}</span>`}</label>
${isColor
<label>${p.label}${isColor || isSelect ? '' : ` <span class="ge-filter-row-value" data-val-for="${p.key}">${p.value}${p.suffix || ''}</span>`}</label>
${isSelect
? `<select data-key="${p.key}" class="ge-ctl-select" aria-label="${p.label}">${p.options.map(option => `<option value="${option.value}"${String(option.value) === String(p.value) ? ' selected' : ''}>${option.label}</option>`).join('')}</select>`
: isColor
? `<input type="color" data-key="${p.key}" value="${p.value}" aria-label="${p.label}" />`
: `<input type="range" data-key="${p.key}" min="${p.min}" max="${p.max}" step="${p.step || 1}" value="${p.value}" />`}
</div>
@@ -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; }
+24 -2
View File
@@ -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(); });
}
+26
View File
@@ -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;
}
+5 -1
View File
@@ -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',
+4 -2
View File
@@ -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.
@@ -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():
@@ -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'}
+2 -3
View File
@@ -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():
@@ -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='<html><body></body></html>', 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='<div id="toast"></div><div id="gallery-editor-container"></div>';
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='<div id="toast"></div><div id="gallery-editor-container" style="height:680px"></div>';
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='<div id="ge-layers-list"></div>';
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='<div id="area"><canvas width="100" height="100"></canvas><button>Chrome</button></div>';
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='<div id="area"><canvas width="100" height="100" style="position:absolute;left:100px;top:100px;width:100px;height:100px"></canvas></div>';
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='<div id="gallery-modal" class="modal"><div id="gallery-editor-container"><div class="ge-topbar"><div class="ge-save-wrap"><button id="ge-save-menu-btn">Save</button><div id="ge-save-menu" class="ge-save-menu" hidden><button id="ge-save">Save image</button><button id="ge-export-png">Save as PNG</button></div></div></div></div></div>';
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 = '<div id="menu"><button>First</button><button>Second</button>'
+ '<button class="ge-menu-parent">Blur &gt;</button><div class="ge-submenu">Gaussian</div></div>';
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 = '<div class="ge-submenu" style="display:block;left:8px;top:8px;">'
+ '<button class="dropdown-item-compact"><span>Plain</span></button>'
+ '<button class="dropdown-item-compact"><svg width="24" height="24"><circle cx="12" cy="12" r="8"/></svg><span>Icon</span></button>'
+ '<button class="dropdown-item-compact"><span>Longer label</span><svg width="10" height="10"></svg></button></div>';
}""")
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 = '<select id="set-imgModelSelect"><option value="">Auto</option></select>'
+ '<select id="set-imgEditModelSelect"><option value="">Same</option></select>'
+ '<select id="set-imgQualitySelect"><option value="medium">Medium</option></select>'
+ '<input type="checkbox" id="set-imgEnabledToggle"><span id="set-imgSettingsMsg"></span>';
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:()=>'<div></div>'});
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 = '<div id="toast"></div><div class="modal" id="gallery-modal"><div class="modal-content"><div id="gallery-editor-container"></div></div></div>';
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']}
+2 -2
View File
@@ -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
+5 -6
View File
@@ -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():
+36 -1
View File
@@ -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():
+7 -6
View File
@@ -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 = '<option value=\"\">Auto (default)</option>';" 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 = '<option value=\"\">Same as generation</option>';" 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():
+73 -8
View File
@@ -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)
+67 -10
View File
@@ -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):