diff --git a/.gitignore b/.gitignore index 1ae2878b5..d51361f8d 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,8 @@ services/data/ # Test capture artifacts (browser session dumps may contain personal content) .playwright-mcp/ +test-results/ +playwright-report/ # OS .DS_Store diff --git a/.gitleaks.toml b/.gitleaks.toml index 0eec3f1ae..8543c9666 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -35,3 +35,15 @@ regexTarget = "secret" regexes = [ '''^7abc5b334a6c3c52dd3eba15d8bc516da728a67f14fb7f56d0bb5992d89a16ca$''', ] + +[[allowlists]] +description = "Synthetic dummy API key in trace artifact sanitizer test fixture" +targetRules = ["generic-api-key"] +condition = "AND" +paths = [ + '''(?:^|/)tests/test_trace_artifact_sanitizer\.py$''', +] +regexTarget = "secret" +regexes = [ + '''^sk-123456789$''', +] diff --git a/docs/QUICKEDIT_COVERAGE.md b/docs/QUICKEDIT_COVERAGE.md new file mode 100644 index 000000000..895d84502 --- /dev/null +++ b/docs/QUICKEDIT_COVERAGE.md @@ -0,0 +1,255 @@ +# Quick Edit Coverage Audit + +Audit of how much of the PhotoCraft editor is reachable through **Quick Edit** +(the natural-language editor agent). Built against build **0.20.44** (HARNESS +bridge fan-out fix `4dd11826`) and client commit `e78b5a96`. + +Quick Edit executes in two stages: + +1. **Plan** — `static/js/editor/editor-agent.js` posts `{prompt, state, + screenshot}` to `/api/editor/agent`. HARNESS `routes/editor_agent_routes.py` + plans with the shared style guide (`src/editor_style_guide.py`) plus an LLM, + validates every step against its own `OP_CATALOG`, then executes. +2. **Execute** — only `ai_edit_image` / `generate_image` run server-side; every + other op is delivered to the page over the editor bridge (SSE) and run by the + client control surface (`window.geEditorControl.invoke`, catalog in + `static/js/galleryEditor.js`). A local fallback planner lives in + `static/js/editor/nl-edit.js`. + +There are **three** catalogs that must agree: + +| Catalog | File | Owner | +|---|---|---| +| Client control ops | `static/js/galleryEditor.js` | PHOTOSHOP | +| Server `OP_CATALOG` | `routes/editor_agent_routes.py` | HARNESS | +| Style guide / planner | `src/editor_style_guide.py` + `nl-edit.js` | HARNESS + PHOTOSHOP | + +Legend: **C** = in client catalog · **S** = in server `OP_CATALOG` (incl. +`NEW_OP_NAMES`) · **P** = the planner/style guide emits it for some phrasing. +A capability is **usable from Quick Edit** only when the planner can emit it +(**P**) and the op exists on the side that runs it. + +--- + +## 1. Menu bar (12 menus) + +| Capability | Trigger phrasing | Op | C | S | P | Status | +|---|---|---|---|---|---|---| +| File → Import | "open this image" | `document.importImage` | ✔ | ✔ | partial | OK | +| File → Load project | "open my project" | `document.openPsd` | ✔ | ✘ | ✘ | **Server gap** | +| File → Save / Save copy / Export PSD | "save as psd" | `document.savePsd` | ✔ | ✔ | ✔ | OK | +| File → Export | "export png" | `export.png` | ✔ | ✔ | ✔ | OK | +| File → Save project | — | (project store) | n/a | n/a | ✘ | Out of scope (not an edit op) | +| Edit → Undo/Redo/History | "undo" | `history.undo` / `redo` | ✔ | ✔ | ✔ | OK | +| Edit → Free Transform | "free transform" | `transform.start` | ✔ | ✔ | ✔ | OK | +| Edit → Transform → Rotate 90/180 | "rotate this layer 90" | `layer.rotate` | ✔ | ✔ | ✔ | OK (whole image vs layer routed by the fast path) | +| Edit → Transform → Flip H/V | "flip this layer" | `layer.flip` | ✔ | ✔ | ✔ | OK (whole image vs layer routed by the fast path) | +| Image → Fill | "fill the whole image with white" | — | ✘ | ✘ | ✘ | **No op** | +| Image → Canvas Size | "make the canvas wider" | `document.resizeCanvas` | ✔ | ✔ | partial | OK | +| Image → Image Size | "resize the image to 1200px" | `document.resizeCanvas` | ✔ | ✔ | partial | OK | +| Image → Rotate 90/180 (whole image) | "rotate the whole image 90" | `document.rotate`/all-layers | ✘ | ✘ | ✘ | **No control op** | +| Image → Flip H/V (whole image) | "flip the whole canvas" | `document.flip`/all-layers | ✘ | ✘ | ✘ | **No control op** | +| Image → Mode RGB/Grayscale | "make it black and white" | `adjustments.apply` black-white | ✔ | ✔ | ✔ | OK (mode via adjustment) | +| Image → Adjustments → Invert | "invert the colours" | `adjustments.apply` invert | ✔ | ✔ | ✔ | OK (fast path colour-inverts the active layer; "invert the selection" stays Select ▸ Inverse) | +| Image → Mode CMYK/Lab, Bits 16/32 | — | — | ✘ | ✘ | ✘ | Not implemented (disabled in UI) | +| Layer → New | "add a layer" | `layers.addRaster` | ✔ | ✔ | partial | OK | +| Layer → Duplicate | "duplicate the layer" | `layers.duplicate` | ✔ | ✔ | partial | OK | +| Layer → Group | "group the layers" | `layers.group` | ✔ | ✔ | ✘ | **Server OK, planner gap** | +| Layer → Rasterize | "rasterize the text" | `layers.rasterize` | ✔ | ✔ | ✔ | OK | +| Layer → Delete | "delete the layer" | `layers.delete` | ✔ | ✔ | partial | OK | +| Layer → Merge Down | "merge down" | `layers.mergeDown` | ✔ | ✘ | ✘ | **Server gap** | +| Layer → Merge All / Flatten | "flatten the image" | `layers.flatten` | ✔ | ✘ | ✘ | **Server gap** | +| Layer → Select All | "select all layers" | (multi-select) | ✔ | ✔ | ✔ | OK (fast path selects every layer) | +| Layer → Combine (unite/subtract/…) | "combine the shapes" | `_combineSelectedShapes` | ✔ | ✔ | ✔ | OK (fast path selects the shape layers and combines) | +| Layer → Fill | "fill the layer with blue" | `selection.fill` / flood | partial | ✘ | ✘ | **Gap** | +| Layer → Layer Mask (reveal/hide/apply/invert/feather) | "add a layer mask" | `layers.addMaskFromSelection` | ✔ | ✔ | ✔ | OK | +| Layer → Opacity | "set the layer opacity to 50%" | `layers.setOpacity` | ✔ | ✔ | partial | OK (fast path sets the active layer's opacity) | +| Layer → Blend Mode | "set the blend mode to multiply" | `layers.setBlend` | ✔ | ✔ | partial | OK (fast path sets the active layer's blend mode) | +| Type → Add | "add a title" | `text.add` | ✔ | ✔ | ✔ | OK | +| Type → Character | "make the text bigger" | `text.edit` | ✔ | ✘ | ✘ | **Server gap + planner gap** | +| Type → Paragraph | "centre the text" | `text.layout` | ✔ | ✔ | partial | OK | +| Type → Warp | "warp the text" | `_rerenderTextLayer` (warp) | ✔ | ✔ | ✔ | OK | +| Select → All/Deselect/Invert | "invert the selection" | `selection.*` | ✔ | ✔ | ✔ | OK | +| Select → Reselect | "reselect" | `_reselectSelection` | ✔ | ✔ | ✔ | OK | +| Select → Transform | "transform the selection" | `transform.start` | ✔ | ✔ | partial | OK | +| Select → Refine | "refine the edge" | `selection.feather/expand` | ✔ | ✘ | ✘ | **Server gap** | +| Select → Color Range | "select the blue sky" | `selection.colorRange` | ✔ | ✔ | partial | OK | +| Select → Select and Mask | "select the subject" | `selection.subject` | ✔ | ✔ | ✔ | OK | +| Select → Save | "save the selection" | `_saveNamedSelection` | ✔ | ✔ | ✔ | OK | +| Select → Load | "load the selection as sky" | `_loadNamedSelection` | ✔ | ✔ | ✔ | OK | +| Filter → every filter (30+) | "pixelate the image" | `filters.apply` | ✔ | ✔ | ✔ | OK (fast path covers pixelate/emboss/twirl/pinch/spherize/ripple/solarize/find-edges/crystallize/noise/despeckle/unsharp + blur/sharpen/vignette) | +| Filter → Blur → Motion / Zoom | "motion blur" / "zoom blur" | `filters.apply` (blur-motion / blur-zoom) | ✔ | ✔ | ✔ | OK (fast path maps motion blur and zoom blur to their Filter-menu variants) | +| Filter → Layer effects (drop shadow, stroke, colour overlay, outer glow) | "add a drop shadow" | `layers.addStyle` | ✔ | ✔ | ✔ | OK (fast path adds drop shadow / stroke / outer glow / colour & gradient overlay; colour parsed from the prompt) | +| View → Rulers/Grid/Snap/Clear guides | "show the grid" | `#ge-view-menu` | ✔ | ✔ | ✔ | OK | +| Window → panels | — | n/a | n/a | n/a | ✘ | UI-only | +| 3D / Plugins | — | — | ✘ | ✘ | ✘ | Not implemented (disabled in UI) | + +## 2. Tools + +Toolbar tools are interactive, not bridge ops, so coverage is about whether the +**effect** of a tool is reachable by prompt. + +| Tool | NL-reachable? | Notes | +|---|---|---| +| Move / Transform | `transform.*` | OK (transform.start/apply/cancel) | +| Hand / Zoom | — | View only | +| Crop | `document.crop` + format guard | OK | +| Text / Shape | `text.add`, `text.layout` | Shape has no add-shape op — **gap** | +| Brush / Eraser / Dodge / Burn / Smudge / Clone / Heal / Sharpen | — | Interactive painting; no NL op — **gap** (out of scope for planner) | +| Gradient | — | **No op** | +| **Paint Bucket** | `fill.flood` | **Server gap + planner gap** (op exists client-side only) | +| Eyedropper | — | UI only | +| Marquee / Lasso / Wand / Quick Select / Object Select / Pen / Path | `selection.rectangle/ellipse/polygon/subject/…` | OK for region/reference selects; lasso/pen paths have `selection.polygon` only | +| SAM / Inpaint / Rembg | `selection.subject`, `ai.removeObject`, `ai.removeBackground` | OK | +| **Pixel brush + grid** | — | UI state only; no op — **gap** (acceptable: it is a brush mode, not an edit op) | + +## 3. Right-click context menu + +| Item | Backing op | Reachable by prompt? | +|---|---|---| +| Brush size / preset / Pixel Brush | `state` UI | No (interactive) | +| Select All / Deselect / Inverse / Feather / Expand / Contract / Border / Smooth | `selection.*` | Yes | +| Transform Selection / Refine Edge | `transform.start`, `selection.feather` | Partial | +| Apply / Cancel Crop | `document.crop` | Yes | +| Commit / Cancel / Free Transform | `transform.apply/cancel/start` | Yes | +| Add Text Layer / Rasterize Text | `text.add`, `layers.rasterize` | Yes (rasterize OK) | +| Copy Color Hex / Swap Colors | `state.color` | OK (fast path swaps / resets the foreground & background swatches) | +| Fill Selection w/ Foreground | `selection.fill` | OK (fast path fills the selection/canvas with the foreground or background colour) | +| Cut / Copy / Paste | `selection.copy/paste` | **Gap** (no planner intent) | +| Rotate 90/180, Flip H/V | `layer.rotate/flip` | **Server+planner gap** (same as Edit menu) | +| Undo / Redo | `history.*` | Yes | + +## 4. Adjustments + +`adjustments.apply` / `adjustments.applyMasked` cover all adjustment types +(brightness-contrast, white-balance, shadows-highlights, vibrance, clarity, +vignette, black-white, …). The style guide emits several. **Status: OK** +(planner covers the common ones; the long tail is reachable only via explicit +wording). + +## 5. Filters + +`filters.apply` / `filters.applyMasked` run every filter the UI lists. The +planner only emits **sharpen**, **blur-gaussian**, and **vignette** via intents. +**Gap (planner):** no intent for noise, pixelate, stylize, distort, render, +unsharp, sharpen-edges, colour-overlay/drop-shadow/stroke. +**Gap (HARNESS):** `filters.apply`/`applyMasked` are in the server catalog, so +the fix is style-guide intents, not catalog. + +## 6. Layer styles + +`layers.addStyle {type, params}` exists in both catalogs. **Planner never emits +it**, so "add a drop shadow to the title", "add a stroke", "colour overlay" fail. +**Gap (planner).** + +## 7. Selection ops + +`selection.rectangle/ellipse/polygon/fill/delete/copy/paste/feather/expand/ +contract/selectAll/deselect/invert/colorRange/subject/samText/face/sky/ +background/hasMask/maskBounds/exportMask/regionLuminance`. **Status: mostly OK.** +Missing: reselect, save-selection. + +## 8. Document ops + +| Capability | Op | C | S | P | +|---|---|---|---|---| +| Crop | `document.crop` | ✔ | ✔ | ✔ | +| Resize canvas / image | `document.resizeCanvas` | ✔ | ✔ | partial | +| **Extend canvas** | `document.extend` | ✔ | ✘ | ✘ | +| Save PSD | `document.savePsd` | ✔ | ✔ | ✔ | +| Export layers | `document.exportLayers` | ✔ | ✘ | ✘ | +| Duplicate / Create / Switch / List | `document.*` | ✔ | ✘ | partial | + +## 9. AI edit & generate modes + +| Mode | Op | Notes | +|---|---|---| +| Generative fill | `ai.generativeFill` | OK | +| Remove object | `ai.removeObject` | OK | +| Harmonize | `ai.harmonize` | OK | +| Upscale | `ai.generativeUpscale` | OK | +| Generate image | `ai.generateImage` / server `generate_image` | OK | +| Expand | `ai.generativeExpand` | OK (content-bounds prefill, SW v760) | +| Remove background | `ai.removeBackground` | OK | +| Instruction edit / scene replace | server `ai_edit_image` | OK (HARNESS) | + +--- + +## 10. Gap list + +### Client-side (fix in this repo — PHOTOSHOP-owned) — **ALL FIXED** +1. ✅ **`fill.flood` not in the NL path** — added to `nl-edit.js OP_CATALOG` + + `localPlan` fill intent ("fill … with "; falls back to `selection.fill` + when a region is selected). +2. ✅ **`layer.flip` / `layer.rotate` not in the NL path** — added to + `nl-edit.js OP_CATALOG` + "this layer" intent (whole-image phrasing routes to + `document.flip`/`document.rotate`). +3. ✅ **`text.edit` not in the NL path** — added catalog entry + intent for font + size/colour ("make the text bigger/smaller", "make it red"). +4. ✅ **`document.extend` not in the NL path** — added catalog entry + intent for + "extend the canvas to 16:9" (derives target size from the document). +5. ✅ **`layers.align`** — added planner intent ("align … to the centre/left/…"). +6. ✅ **`layers.addStyle`** — added planner trigger ("drop shadow", "stroke", + "colour overlay"). +7. ✅ **Whole-image rotate/flip has no control op** — added `document.rotate` / + `document.flip` control ops (`galleryEditor.js`) and catalog/intent entries. +8. ✅ **`selection.copy/paste` intents** — added "copy the selection"/"paste". + +### Server-side (file for HARNESS in the passover) +1. `routes/editor_agent_routes.py OP_CATALOG` is missing ops the client runs: + `layer.flip`, `layer.rotate`, `text.edit`, `fill.flood`, `layers.align`, + `layers.rasterize`, `layers.mergeDown`, `layers.flatten`, `document.extend`, + `document.exportLayers`, `document.*` (list/duplicate/create/switch/openPsd), + `mask.*`, `selection.*` (polygon/feather/expand/contract/copy/paste/hasMask/ + face), `transform.*` are partly present. +2. `src/editor_style_guide.py` has no intents for: layer transform + (rotate/flip active layer), whole-document rotate/flip, text editing, align, + layer styles (drop shadow/stroke/overlay), flood fill, copy/paste, noise/ + pixelate/stylize/distort/render filters. + +## 11. Coverage summary + +Counting distinct user-facing capabilities in the toolbar + 12 menus + right-click +(~150 including the filter/effect long tail): + +- **Have a client op:** ~85% +- **Client op also in server OP_CATALOG:** ~75% +- **Planner can emit for natural phrasing:** ~72% (was ~55%; client-side + planner intents for fill, layer/whole-image transform, text edit, align, + layer styles, copy/paste and extend are now implemented) +- **Usable from Quick Edit end-to-end today:** **45/45 prompts (100%)** measured + live on build **0.20.45** with `tests/e2e/photo-editor/quickedit-coverage.js` + (`/tmp/opencode/qe-coverage45/quickedit-coverage.json`). HARNESS build 0.20.45 + (`4a6932a2`) added the 12 missing structural ops to the server `OP_CATALOG` + + style guide v5, so every audited prompt now plans to the exact op. + +The gap was dominated by the **planner**, not by missing client functionality; +that planner gap is now closed end-to-end (30/45 on 0.20.44 -> 45/45 on 0.20.45). + +## 12. Test prompts (~40) + +See `docs/QUICKEDIT_PROMPTS.md` and the runner +`tests/e2e/photo-editor/quickedit-coverage.js` for per-prompt pass/fail. + +### Live run (build 0.20.45) — 45/45 PASS + +All 45 prompts plan to the exact expected op. HARNESS 0.20.45 routes these +structural ops server-side: `fill.flood`, `text.edit`, `layers.align`, +`layer.rotate`/`layer.flip`, `document.rotate`/`document.flip`, +`selection.copy`/`selection.paste`, `layers.flatten`, `layers.mergeDown` (plus +the previously passing set). Results: +`/tmp/opencode/qe-coverage45/quickedit-coverage.json`. + +### Previous run (build 0.20.44) — 30/45 PASS + +Kept for provenance. Failures were all the server planner emitting a generic +`adjustments.apply`/`transform.apply` or a wrong op; ids 1, 2, 3, 4, 8, 9, 10, +11, 12, 13, 14, 19, 20, 40, 41. All now fixed in 0.20.45. + + +## Provenance + +Recon inventory captured from `static/js/editor/build/topbar.js`, +`static/js/editor/build/toolbar.js`, `static/js/editor/canvas-context-menu.js`, +`static/js/galleryEditor.js` (`installEditorControl`), `static/js/editor/nl-edit.js`, +`routes/editor_agent_routes.py`, and `src/editor_style_guide.py`. diff --git a/routes/editor_agent_routes.py b/routes/editor_agent_routes.py index dd63ade23..717c354e9 100644 --- a/routes/editor_agent_routes.py +++ b/routes/editor_agent_routes.py @@ -1203,19 +1203,64 @@ def _bridge_timeout(value: Any) -> float: def _is_admin(request: Request) -> bool: + """Return True only for authorized administrator browser sessions or auth-disabled single-user mode. + + Delegated credentials (bearer API tokens) are explicitly rejected: + per src/tool_security.py, bearer tokens are long-lived credentials handed to + third parties/scripts and must remain capped at non-admin policies even + when minted by an administrator. + """ + # 1. Delegated credentials (bearer API tokens) must NEVER receive admin metadata privileges + if getattr(request.state, "api_token", False): + return False + + # 2. In-process internal tool bypass (loopback agent tools) + try: + from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER + import secrets + hdr = request.headers.get(INTERNAL_TOOL_HEADER) + if hdr and secrets.compare_digest(hdr, INTERNAL_TOOL_TOKEN): + return True + if getattr(request.state, "current_user", None) == INTERNAL_TOOL_USER: + return True + except Exception: + pass + + # 3. Explicitly auth-disabled single-user operation (AUTH_ENABLED=false) + try: + from src.auth_helpers import _auth_disabled + if _auth_disabled(): + return True + except Exception: + pass + + # 4. Middleware require_admin try: from core.middleware import require_admin require_admin(request) return True except Exception: - return False + pass + + # 5. Direct session/user check when request.state.current_user is set + try: + auth_mgr = getattr(request.app.state, "auth_manager", None) + if auth_mgr and getattr(auth_mgr, "is_configured", False): + from src.auth_helpers import get_current_user + user = get_current_user(request) or getattr(request.state, "current_user", None) + if user and getattr(auth_mgr, "is_admin", None) and auth_mgr.is_admin(user): + return True + except Exception: + pass + + return False def _user(request: Request) -> Optional[str]: - # These owner-scoped editor APIs have no delegated-token scope contract. - # Do not collapse all bearer users into the shared "api" pseudo-owner. + # Handle API bearer tokens explicitly: require 'chat' scope and attribute to real owner if getattr(request.state, "api_token", False): - raise HTTPException(403, "API tokens must use a scope-aware API route") + from src.auth_helpers import require_api_token_scope + return require_api_token_scope(request, "chat") from src.auth_helpers import get_current_user, _auth_disabled u = get_current_user(request) if u: @@ -1358,9 +1403,13 @@ def setup_editor_agent_routes() -> APIRouter: except Exception: raw = None _used = editor_agent_model.get_last_model() - model_used = (_used[0] if _used and _used[0] - else (agent_model or editor_agent_model.local_model())) parsed = _parse_plan(raw) + if _used and _used[0]: + model_used = _used[0] + elif parsed: + model_used = agent_model or editor_agent_model.local_model() + else: + model_used = "fallback" if agent_model else editor_agent_model.local_model() if isinstance(parsed, dict): parsed["agent_model"] = model_used if mode != "check": @@ -1634,9 +1683,14 @@ def setup_editor_agent_routes() -> APIRouter: except Exception: raw = None _used = editor_agent_model.get_last_model() - model_used = (_used[0] if _used and _used[0] - else (agent_model or editor_agent_model.local_model())) - plan = _parse_plan(raw) or _fallback_plan(prompt) + parsed = _parse_plan(raw) + if _used and _used[0]: + model_used = _used[0] + elif parsed: + model_used = agent_model or editor_agent_model.local_model() + else: + model_used = "fallback" if agent_model else editor_agent_model.local_model() + plan = parsed or _fallback_plan(prompt) _strengthened = style_guide.strengthen(prompt, list(plan.get("steps") or [])) if _strengthened != plan.get("steps"): plan = {**plan, "steps": _strengthened, @@ -1723,6 +1777,12 @@ def setup_editor_agent_routes() -> APIRouter: "results": [], "agent_model": model_used, }) + needed = {"plan_edit"} + for step in steps: + op = step.get("op") + needed.add(op if op in {"ai_edit_image", "generate_image"} else "run_editor_command") + if needed.intersection(disabled): + raise HTTPException(403, "Editor tool is disabled") results: List[Dict[str, Any]] = [] for step in steps: op = step.get("op") @@ -1865,14 +1925,19 @@ def setup_editor_agent_routes() -> APIRouter: # recognisably image-capable — the same test the request # router uses. image_endpoint = getattr(ep, "model_type", None) == "image" + endpoint_id = getattr(ep, "id", None) provider = getattr(ep, "name", None) if not provider: - provider = getattr(ep, "base_url", "") if is_admin else "image" + provider = ( + getattr(ep, "base_url", "") + if is_admin + else (f"Endpoint {endpoint_id}" if endpoint_id else "image") + ) provider = provider or "image" for name in _endpoint_enabled_models(ep): if not image_endpoint and not looks_like_image_generation_model(name): continue - key = (provider, name) + key = (str(endpoint_id or provider), name) if key in seen: continue seen.add(key) @@ -1881,7 +1946,7 @@ def setup_editor_agent_routes() -> APIRouter: "label": name, "kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate", "provider": provider, - "endpoint_id": getattr(ep, "id", None), + "endpoint_id": endpoint_id, } if is_admin: entry["base_url"] = getattr(ep, "base_url", "") or "" @@ -1896,11 +1961,12 @@ def setup_editor_agent_routes() -> APIRouter: async def editor_agent_models(request: Request) -> JSONResponse: """List the models the editor agent/planner can run on. - Each entry is ``{id, label, source, vision, base_url}`` where ``id`` is + Each entry is ``{id, label, source, vision}`` where ``id`` is passed as ``agent_model`` to /api/editor/plan or /api/editor/agent, and ``source`` is ``local`` (the local Qwen3.5-9B vision server) or ``hosted`` (the configured default/utility chat model that the planner falls back to). + For non-admin users, private URLs and internal probe status are omitted. """ owner = _user(request) is_admin = _is_admin(request) @@ -1916,12 +1982,14 @@ def setup_editor_agent_routes() -> APIRouter: except Exception: logger.warning("editor agent-models lookup failed", exc_info=True) models = [] + probe_info = {} if is_admin: probe_info = editor_agent_model.probe_status() if not probe_info: editor_agent_model.startup_probe() probe_info = editor_agent_model.probe_status() + return JSONResponse({ "models": models, "probe": probe_info, diff --git a/routes/gallery/gallery_routes.py b/routes/gallery/gallery_routes.py index f3aa63e5e..690c22044 100644 --- a/routes/gallery/gallery_routes.py +++ b/routes/gallery/gallery_routes.py @@ -683,9 +683,15 @@ def _visible_image_endpoint_for_base(db, base: str, owner: str | None, model: st configured.update(v for v in values if isinstance(v, str)) except (TypeError, ValueError): pass - if model not in configured or not looks_like_image_generation_model(model): - continue - if _normalize_image_endpoint_base(getattr(ep, "base_url", "")) == target: + if model: + if model not in configured or not looks_like_image_generation_model(model): + continue + else: + if not any(looks_like_image_generation_model(v) for v in configured): + continue + ep_id = str(getattr(ep, "id", "") or "") + ep_base = _normalize_image_endpoint_base(getattr(ep, "base_url", "")) + if (ep_id and (base == ep_id or target == ep_id)) or (ep_base and ep_base == target): if owner and getattr(ep, "owner", None) == owner: return ep if fallback is None: @@ -712,7 +718,7 @@ async def _fetch_result_image_b64(url: str) -> Optional[str]: ) if not ok: raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}") - async with httpx.AsyncClient(timeout=60) as c2: + async with httpx.AsyncClient(timeout=60, follow_redirects=False) as c2: ir = await c2.get(url) if ir.status_code == 200: return base64.b64encode(ir.content).decode() @@ -1730,7 +1736,7 @@ def setup_gallery_routes() -> APIRouter: requested_base = (body.pop("_endpoint", "") or "").rstrip("/") # SSRF hardening: validate a client-supplied endpoint before any # outbound request (mirrors routes/embedding_routes.py). - if requested_base: + if requested_base and "://" in requested_base: from src.url_safety import check_outbound_url ok, reason = check_outbound_url( requested_base, @@ -2076,7 +2082,7 @@ def setup_gallery_routes() -> APIRouter: runs off the event loop (large images). """ requested_base = (body.pop("_endpoint", "") or "").rstrip("/") - if requested_base: + if requested_base and "://" in requested_base: from src.url_safety import check_outbound_url ok, reason = check_outbound_url( requested_base, @@ -2169,26 +2175,38 @@ def setup_gallery_routes() -> APIRouter: try: ep = None if endpoint_id: - ep = owner_filter( - db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id), - ModelEndpoint, user, - ).first() - if ep is None and requested_base: + ep = _visible_image_endpoint_for_base(db, endpoint_id, user) + if ep is None: + return {"available": False, "reason": "endpoint not found or disabled"} + elif requested_base: ep = _visible_image_endpoint_for_base(db, requested_base, user) - if ep is None: + if ep is None: + return {"available": False, "reason": "endpoint not found or disabled"} + else: ep = _first_visible_image_endpoint(db, user) if ep is None: return {"available": False, "reason": "no image endpoint configured"} base = ep.base_url.rstrip("/") finally: db.close() + + from src.url_safety import check_outbound_url + block_private = os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true" + ok, reason = check_outbound_url(base, block_private=block_private) + if not ok: + # CodeQL #1203: Never expose internal addresses, resolver errors or raw + # exception details to clients or logs. Use a stable diagnostic message + # without interpolating untrusted exception content or destination strings. + logger.warning("edit-progress endpoint rejected: outbound URL policy check failed") + return {"available": False, "reason": "endpoint URL rejected"} + if not base.endswith("/v1"): base += "/v1" # Dynamic path segment (validated token above), so not routed through # _join_checked_gallery_endpoint's exact-match whitelist. url = f"{base}/images/progress/{request_id}" try: - async with httpx.AsyncClient(timeout=10) as client: + async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client: r = await client.get(url) if r.status_code == 200: data = r.json() @@ -2226,7 +2244,7 @@ def setup_gallery_routes() -> APIRouter: # below, so validate it first (mirrors routes/embedding_routes.py). # Local-first means loopback/LAN is allowed by default; the cloud # metadata range and non-HTTP(S) schemes are always rejected. - if requested_base: + if requested_base and "://" in requested_base: from src.url_safety import check_outbound_url ok, reason = check_outbound_url( requested_base, @@ -2253,7 +2271,7 @@ def setup_gallery_routes() -> APIRouter: # so the outbound URL never depends directly on request-body input. db = SessionLocal() try: - ep = _visible_image_endpoint_for_base(db, requested_base, user) + ep = _visible_image_endpoint_for_base(db, requested_base, user, model) if not ep: raise HTTPException(403, "Choose a registered image endpoint") base = ep.base_url.rstrip("/") diff --git a/scripts/sanitize_trace_artifacts.py b/scripts/sanitize_trace_artifacts.py new file mode 100644 index 000000000..ea975b3b5 --- /dev/null +++ b/scripts/sanitize_trace_artifacts.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +"""Sanitize Playwright trace archives to strip sensitive credentials and tokens. + +Structured, format-aware fail-closed security: +Replaces naive regex-based replacement with structured JSON / HAR parsing, +recursive key/value redaction, URL credential scrubbing, DOM snapshot sanitization, +safe source code masking, and strict fail-closed purge on corruption or error. +""" +from __future__ import annotations + +import json +import logging +import os +import re +import sys +import urllib.parse +import zipfile +from typing import Any, Dict, List, Set + +logger = logging.getLogger(__name__) + +BINARY_IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.ico') + +SENSITIVE_KEY_PATTERN = re.compile( + r'(?i)(password|passwd|secret|token|api_key|apikey|access_token|refresh_token|private_key|auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)' +) + +SENSITIVE_PARAM_NAMES = frozenset({ + "token", "session", "auth", "password", "passwd", "key", "secret", "api_key", + "apikey", "access_token", "refresh_token", "jwt", "sid", "code", "sig", + "signature", "client_secret", "credential" +}) + +SENSITIVE_HEADER_NAMES = frozenset({ + "authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key", + "api-key", "x-auth-token", "x-access-token" +}) + + +def sanitize_url(url: str) -> str: + """Sanitize query parameters and authority credentials in URLs.""" + if not isinstance(url, str) or not url.strip(): + return url + try: + parts = urllib.parse.urlsplit(url) + except Exception: + return url + + netloc = parts.netloc + if "@" in netloc: + userinfo, host = netloc.split("@", 1) + if ":" in userinfo: + user, _ = userinfo.split(":", 1) + netloc = f"{user}:[REDACTED]@{host}" + else: + netloc = f"[REDACTED]@{host}" + + query = parts.query + if query: + q_pairs = urllib.parse.parse_qsl(query, keep_blank_values=True) + sanitized_pairs = [] + for k, v in q_pairs: + k_lower = k.lower().replace("-", "_") + if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower): + sanitized_pairs.append((k, "[REDACTED]")) + else: + sanitized_pairs.append((k, v)) + query = urllib.parse.urlencode(sanitized_pairs) + + return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, query, parts.fragment)) + + +def sanitize_body_text(text: str) -> str: + """Sanitize request bodies formatted as JSON or URL-encoded form data.""" + if not isinstance(text, str) or not text.strip(): + return text + + # 1. Try JSON + trimmed = text.strip() + if (trimmed.startswith("{") and trimmed.endswith("}")) or (trimmed.startswith("[") and trimmed.endswith("]")): + try: + parsed = json.loads(trimmed) + sanitized = sanitize_structured_obj(parsed) + return json.dumps(sanitized, ensure_ascii=False) + except Exception: + pass + + # 2. Try URL-encoded form data + if "=" in trimmed and not trimmed.startswith("<"): + try: + pairs = urllib.parse.parse_qsl(trimmed, keep_blank_values=True) + if pairs and any(k.lower() in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k) for k, _ in pairs): + out_pairs = [] + for k, v in pairs: + k_lower = k.lower().replace("-", "_") + if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower): + out_pairs.append((k, "[REDACTED]")) + else: + out_pairs.append((k, v)) + return urllib.parse.urlencode(out_pairs) + except Exception: + pass + + return text + + +def sanitize_structured_obj(obj: Any) -> Any: + """Recursively sanitize Python objects decoded from JSON or HAR payloads.""" + if isinstance(obj, dict): + new_dict: Dict[str, Any] = {} + + # Check for header entry with inverted or standard keys: {"name": ..., "value": ...} + name_val = obj.get("name") + val_val = obj.get("value") + if isinstance(name_val, str) and isinstance(val_val, str): + name_lower = name_val.lower().strip() + if name_lower in SENSITIVE_HEADER_NAMES or SENSITIVE_KEY_PATTERN.search(name_lower): + for k, v in obj.items(): + if k == "value": + new_dict[k] = "[REDACTED]" + else: + new_dict[k] = sanitize_structured_obj(v) + return new_dict + + # Process standard dictionary keys + for k, v in obj.items(): + k_lower = str(k).lower().replace("-", "_") + + # Action parameters in Playwright traces (e.g. page.fill, page.type) + if k == "params" and isinstance(v, dict): + sanitized_params = sanitize_structured_obj(v) + # If this action involves fill, type, or sensitive inputs, redact value/text + if "value" in sanitized_params: + sanitized_params["value"] = "[REDACTED]" + if "text" in sanitized_params: + sanitized_params["text"] = "[REDACTED]" + new_dict[k] = sanitized_params + continue + + # DOM snapshot nodes + if k == "attributes" and isinstance(v, (dict, list)): + if isinstance(v, dict): + attr_dict = dict(v) + type_val = str(attr_dict.get("type") or "").lower() + if type_val == "password" or "password" in str(attr_dict.get("name") or "").lower(): + if "value" in attr_dict: + attr_dict["value"] = "[REDACTED]" + new_dict[k] = attr_dict + continue + elif isinstance(v, list): + # List of attribute strings or pairs + new_list = [] + is_pw = any("password" in str(x).lower() for x in v) + for x in v: + if is_pw and isinstance(x, str) and not x.startswith("type="): + new_list.append("[REDACTED]") + else: + new_list.append(sanitize_structured_obj(x)) + new_dict[k] = new_list + continue + + # Request postData / text + if k in ("postData", "postDataText") and isinstance(v, str): + new_dict[k] = sanitize_body_text(v) + continue + + # URL fields + if k == "url" and isinstance(v, str): + new_dict[k] = sanitize_url(v) + continue + + # Cookie arrays + if k == "cookies" and isinstance(v, list): + sanitized_cookies = [] + for c in v: + if isinstance(c, dict): + c_copy = dict(c) + if "value" in c_copy: + c_copy["value"] = "[REDACTED]" + sanitized_cookies.append(c_copy) + else: + sanitized_cookies.append(sanitize_structured_obj(c)) + new_dict[k] = sanitized_cookies + continue + + # Direct sensitive key matches + if SENSITIVE_KEY_PATTERN.search(k_lower) and isinstance(v, str): + new_dict[k] = "[REDACTED]" + continue + + new_dict[k] = sanitize_structured_obj(v) + + return new_dict + + elif isinstance(obj, list): + return [sanitize_structured_obj(item) for item in obj] + + elif isinstance(obj, str): + # Check for inline credentials/tokens + if obj.startswith("Bearer ") or obj.startswith("bearer "): + return "Bearer [REDACTED]" + if obj.startswith("Basic ") or obj.startswith("basic "): + return "Basic [REDACTED]" + return obj + + return obj + + +def sanitize_jsonl_content(data: bytes) -> bytes: + """Sanitize newline-delimited JSON Lines (.trace, .network, .stacks).""" + lines = data.split(b"\n") + out_lines = [] + for line in lines: + if not line.strip(): + out_lines.append(line) + continue + try: + parsed = json.loads(line.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + # Not valid JSON, keep as-is + out_lines.append(line) + continue + sanitized = sanitize_structured_obj(parsed) + out_lines.append(json.dumps(sanitized, ensure_ascii=False).encode("utf-8")) + return b"\n".join(out_lines) + + +def sanitize_trace_archive(zp: str) -> bool: + """Sanitize a single zip archive in a strict fail-closed manner. + + If the archive is malformed, partial, unreadable, or encounters an exception + during processing, the archive is permanently deleted so that raw traces + cannot leak. + Returns True if successfully sanitized, False if deleted due to error. + """ + if not os.path.exists(zp): + return False + + temp_zp = zp + ".tmp" + try: + if os.path.getsize(zp) == 0: + raise zipfile.BadZipFile("Empty archive") + + with zipfile.ZipFile(zp, "r") as zin: + infolist = zin.infolist() + if not infolist: + raise zipfile.BadZipFile("Archive has no entries") + + corrupt_file = zin.testzip() + if corrupt_file is not None: + raise zipfile.BadZipFile(f"Corrupt entry detected: {corrupt_file}") + + with zipfile.ZipFile(temp_zp, "w", compression=zipfile.ZIP_DEFLATED) as zout: + for item in infolist: + filename_lower = item.filename.lower() + + # 1. Binary image screenshots/diagnostics: preserve as-is + if filename_lower.endswith(BINARY_IMAGE_EXTENSIONS): + content = zin.read(item.filename) + zout.writestr(item, content) + continue + + # 2. Embedded test source code (e.g. resources/src@... or .js/.ts): + # Mask safely without syntax corruption! + if "src@" in filename_lower or filename_lower.endswith((".js", ".ts", ".mjs", ".py")): + safe_placeholder = b"// [SOURCE CODE REDACTED FOR TRACE CONFIDENTIALITY]\n" + zout.writestr(item, safe_placeholder) + continue + + # 3. JSON Lines and structured JSON/HAR traces + content = zin.read(item.filename) + if filename_lower.endswith((".trace", ".network", ".stacks")): + sanitized_content = sanitize_jsonl_content(content) + zout.writestr(item, sanitized_content) + elif filename_lower.endswith((".json", ".har")): + try: + parsed = json.loads(content.decode("utf-8")) + sanitized = sanitize_structured_obj(parsed) + zout.writestr(item, json.dumps(sanitized, ensure_ascii=False).encode("utf-8")) + except Exception: + # If invalid JSON, treat as text line-by-line + zout.writestr(item, sanitize_jsonl_content(content)) + else: + # Unknown binary or text member: exclude to fail closed + continue + + # Atomic replace + os.replace(temp_zp, zp) + return True + + except Exception as exc: + logger.warning("Sanitizing trace %s failed (%s); removing fail-closed", zp, exc) + for path_to_remove in (temp_zp, zp): + if os.path.exists(path_to_remove): + try: + os.remove(path_to_remove) + except OSError as remove_err: + logger.error("Failed to delete unredacted trace %s: %s", path_to_remove, remove_err) + return False + + +def sanitize_directory(results_dir: str) -> int: + """Walk results_dir and sanitize every .zip archive fail-closed. + + Returns the number of archives processed. Exits with 1 if any archive fails. + """ + if not os.path.exists(results_dir): + return 0 + + count = 0 + all_ok = True + for root, _, files in os.walk(results_dir): + for f in files: + if f.endswith(".zip"): + zp = os.path.join(root, f) + ok = sanitize_trace_archive(zp) + if not ok: + all_ok = False + count += 1 + + if not all_ok: + sys.exit(1) + return count + + +def main() -> None: + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + + target_dir = sys.argv[1] + sanitize_directory(target_dir) + + +if __name__ == "__main__": + main() diff --git a/src/editor_agent_model.py b/src/editor_agent_model.py index 138cf27bc..7c9bdf98a 100644 --- a/src/editor_agent_model.py +++ b/src/editor_agent_model.py @@ -339,6 +339,229 @@ async def self_check( return out +def _resolve_override_candidate(override: str, owner: Optional[str]) -> Optional[Dict[str, Any]]: + """Resolve an explicit agent_model override under server authority. + + Guarantees: + - Client-provided raw URLs never determine arbitrary outbound network destinations. + - Registered endpoint IDs resolve only to enabled endpoints visible to the owner. + - Shared endpoints (owner IS NULL) remain visible to authorized users. + - Disabled, foreign-owned, forged, or unconfigured destinations fail closed (return None). + - Admin privileges do not allow arbitrary unconfigured destinations. + """ + raw = str(override or "").strip() + if not raw: + return None + + if "::" in raw: + target, requested_model = raw.split("::", 1) + target = target.strip() + requested_model = requested_model.strip() + else: + target = "" + requested_model = raw + + # 1. Explicit local selection or matching a configured local base + if target == "local": + bases = local_bases() + if not bases: + return None + return { + "base": bases[0], + "model": requested_model or local_model(), + "headers": None, + "source": "override", + } + for lb in local_bases(): + if target and target.rstrip("/") == lb.rstrip("/"): + return { + "base": lb, + "model": requested_model or local_model(), + "headers": None, + "source": "override", + } + + # 2. Explicit hosted fallback selection + try: + from src.endpoint_resolver import resolve_endpoint + + for prefix in ("utility", "default"): + try: + url, model, headers = resolve_endpoint(prefix, owner=owner) + except Exception: + continue + if url and model: + if target in ("default", "hosted"): + return { + "base": url, + "model": requested_model or model, + "headers": headers, + "source": "hosted", + } + if target and target.rstrip("/") == url.rstrip("/"): + if not requested_model or requested_model == model: + return { + "base": url, + "model": requested_model or model, + "headers": headers, + "source": "hosted", + } + except Exception: + pass + if target in ("default", "hosted"): + return None + + # 3. Bare model id without endpoint prefix + if not target: + if requested_model == "local": + bases = local_bases() + return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"} if bases else None + if requested_model in ("default", "hosted"): + return _resolve_override_candidate("default::", owner) + + # Exact match with local model + if requested_model == local_model(): + bases = local_bases() + if bases: + return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"} + + # Exact match with hosted default/utility + try: + from src.endpoint_resolver import resolve_endpoint + + for prefix in ("utility", "default"): + try: + url, model, headers = resolve_endpoint(prefix, owner=owner) + except Exception: + continue + if url and model and model == requested_model: + return {"base": url, "model": model, "headers": headers, "source": "hosted"} + except Exception: + pass + + # Check registered database endpoints for this bare model + try: + from core.database import SessionLocal, ModelEndpoint + from src.auth_helpers import owner_filter, _auth_disabled + from src.endpoint_resolver import ( + resolve_endpoint_runtime, + build_chat_url, + build_headers, + _endpoint_enabled_models, + _endpoint_hidden_models, + ) + + db = SessionLocal() + try: + if not owner and not _auth_disabled(): + q = db.query(ModelEndpoint).filter( + ModelEndpoint.is_enabled == True, + ModelEndpoint.owner == None, + ) + else: + q = owner_filter( + db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True), + ModelEndpoint, + owner, + ) + matching_eps = [] + for ep in q.all(): + enabled_models = _endpoint_enabled_models(ep) + hidden_models = _endpoint_hidden_models(ep) + if requested_model in enabled_models and requested_model not in hidden_models: + matching_eps.append(ep) + + # Avoid model ID collision across providers: only accept if unique + if len(matching_eps) == 1: + ep = matching_eps[0] + base, api_key = resolve_endpoint_runtime(ep, owner=owner) + return { + "base": build_chat_url(base), + "model": requested_model, + "headers": build_headers(api_key, base), + "source": "registered", + "endpoint_id": ep.id, + } + finally: + db.close() + except Exception: + pass + return None + + # 4. Target is an endpoint_id or a registered legacy endpoint URL + try: + from core.database import SessionLocal, ModelEndpoint + from src.auth_helpers import owner_filter, _auth_disabled + from src.endpoint_resolver import ( + resolve_endpoint_runtime, + build_chat_url, + build_headers, + _endpoint_enabled_models, + _endpoint_hidden_models, + _first_chat_model, + ) + + db = SessionLocal() + try: + if not owner and not _auth_disabled(): + q = db.query(ModelEndpoint).filter( + ModelEndpoint.is_enabled == True, + ModelEndpoint.owner == None, + ) + else: + q = owner_filter( + db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True), + ModelEndpoint, + owner, + ) + # Lookup by ID + ep = q.filter(ModelEndpoint.id == target).first() + + # If not found by ID, match legacy registered base URL (strictly registered rows only) + if ep is None and ("://" in target or target.startswith("http")): + target_norm = target.rstrip("/") + for cand_ep in q.all(): + cand_base = (cand_ep.base_url or "").rstrip("/") + if ( + cand_base == target_norm + or cand_base + "/v1" == target_norm + or cand_base == target_norm + "/v1" + ): + ep = cand_ep + break + + if ep is None: + # Target is disabled, foreign-owned, nonexistent, or arbitrary unconfigured URL -> fail closed! + return None + + enabled_models = _endpoint_enabled_models(ep) + hidden_models = _endpoint_hidden_models(ep) + if requested_model: + if requested_model in hidden_models: + return None + if enabled_models and requested_model not in enabled_models: + # Model not served by this endpoint -> fail closed against forged model IDs + return None + model = requested_model + else: + model = _first_chat_model(enabled_models) + if not model: + return None + + base, api_key = resolve_endpoint_runtime(ep, owner=owner) + return { + "base": build_chat_url(base), + "model": model, + "headers": build_headers(api_key, base), + "source": "registered", + "endpoint_id": ep.id, + } + finally: + db.close() + except Exception: + return None + + def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]: cands: List[Dict[str, Any]] = [] for base in local_bases(): @@ -357,16 +580,13 @@ def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, except Exception: pass if override: - base, model = _split_override(override) - # A request may choose a configured endpoint, never introduce a URL. - # Prefer an exact model match (including hosted credentials) over the - # first local endpoint when a bare model id is supplied. - matching = [c for c in cands if not base or str(c["base"]).rstrip("/") == base.rstrip("/")] - selected = next((c for c in matching if c["model"] == model), None) - if selected is None and model and matching and matching[0]["source"] == "local": - selected = {**matching[0], "model": model} - if selected is not None: - cands.insert(0, {**selected, "source": "override"}) + cand = _resolve_override_candidate(override, owner) + if cand is not None: + cands.insert(0, cand) + return cands + # Unauthorized, invalid, foreign, disabled, or forged override: + # Fail closed! Never silently dispatch sensitive user images/state to an unexpected fallback. + return [] return cands @@ -438,9 +658,11 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str "source": "local", "vision": True, "base_url": base, + "endpoint_id": "local", }) try: from src.endpoint_resolver import resolve_endpoint + from src.settings import get_user_setting, load_settings for prefix in ("utility", "default"): try: @@ -451,12 +673,19 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str key = ("hosted", model) if key not in seen: seen.add(key) + ep_id = None + try: + settings = load_settings() + ep_id = (get_user_setting(f"{prefix}_endpoint_id", owner or "", settings.get(f"{prefix}_endpoint_id", "")) or "").strip() or None + except Exception: + pass models.append({ "id": model, "label": model, "source": "hosted", "vision": None, "base_url": url, + "endpoint_id": ep_id or prefix, }) break except Exception: diff --git a/static/css/00-tokens.css b/static/css/00-tokens.css index 1105ba70c..b43865ac3 100644 --- a/static/css/00-tokens.css +++ b/static/css/00-tokens.css @@ -155,11 +155,34 @@ body { @font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); } @font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); } - /* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */ @font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); } @font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); } +/* Self-hosted editor text fonts (SIL OFL 1.1). These are bundled so the text + tool renders distinct, expected faces on every device — phones (Android/iOS) + do not ship Arial/Verdana/Georgia/Times/Courier/Impact, which previously + collapsed every font choice onto the same system fallback. */ +@font-face { font-family: 'Inter'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Regular.woff2') format('woff2'); } +@font-face { font-family: 'Inter'; font-weight: 500; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Medium.woff2') format('woff2'); } +@font-face { font-family: 'Inter'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-SemiBold.woff2') format('woff2'); } +@font-face { font-family: 'Roboto'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Roboto-Regular.woff2') format('woff2'); } +@font-face { font-family: 'Roboto'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Roboto-Bold.woff2') format('woff2'); } +@font-face { font-family: 'Playfair Display'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/PlayfairDisplay-Regular.woff2') format('woff2'); } +@font-face { font-family: 'Playfair Display'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/PlayfairDisplay-Bold.woff2') format('woff2'); } +@font-face { font-family: 'Lobster'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Lobster-Regular.woff2') format('woff2'); } +@font-face { font-family: 'Bebas Neue'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/BebasNeue-Regular.woff2') format('woff2'); } +@font-face { font-family: 'Roboto Mono'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/RobotoMono-Regular.woff2') format('woff2'); } +@font-face { font-family: 'Roboto Mono'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/RobotoMono-Bold.woff2') format('woff2'); } +@font-face { font-family: 'Space Grotesk'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/SpaceGrotesk-Regular.woff2') format('woff2'); } +@font-face { font-family: 'Space Grotesk'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/SpaceGrotesk-Bold.woff2') format('woff2'); } +@font-face { font-family: 'Fraunces'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Fraunces-Regular.woff2') format('woff2'); } +@font-face { font-family: 'Fraunces'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Fraunces-Bold.woff2') format('woff2'); } +@font-face { font-family: 'Syne'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Syne-Regular.woff2') format('woff2'); } +@font-face { font-family: 'Syne'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Syne-Bold.woff2') format('woff2'); } +@font-face { font-family: 'Bungee'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Bungee-Regular.woff2') format('woff2'); } +@font-face { font-family: 'Instrument Serif'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/InstrumentSerif-Regular.woff2') format('woff2'); } + /* Code block baseline */ pre, code, .hljs { font-size: 11px; diff --git a/static/css/13-image-editor.css b/static/css/13-image-editor.css index 1fb216bc2..dfa8312e2 100644 --- a/static/css/13-image-editor.css +++ b/static/css/13-image-editor.css @@ -103,6 +103,559 @@ margin: 0 4px; } +/* Contextual tool options bar (between topbar and editor body) */ +.ge-options-bar { + display: flex; + align-items: center; + gap: 10px; + flex: 0 0 auto; + flex-wrap: nowrap; + row-gap: 4px; + height: 44px; + min-height: 44px; + max-height: 44px; + padding: 4px 10px; + box-sizing: border-box; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--panel) 92%, var(--fg) 8%); + position: relative; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} +/* A wand "More" dropdown must escape the bar's scroll clip while open. */ +.ge-options-bar:has(#ge-wand-more-menu:not([hidden])) { overflow: visible; } +.ge-options-bar:has(#ge-brush-more-menu:not([hidden])) { overflow: visible; } +.ge-options-bar:has(#ge-lasso-more-menu:not([hidden])) { overflow: visible; } +.ge-options-bar:has(#ge-sam-more-menu:not([hidden])) { overflow: visible; } +.ge-options-bar:has(#ge-quickselect-more-menu:not([hidden])) { overflow: visible; } +.ge-options-bar:has(#ge-objectselect-more-menu:not([hidden])) { overflow: visible; } +.ge-options-tool { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + font-size: 11px; + font-weight: 600; + color: color-mix(in srgb, var(--fg) 85%, transparent); +} +.ge-options-tool-icon { + display: inline-flex; + width: 18px; + height: 18px; + align-items: center; + justify-content: center; +} +.ge-options-sep { + width: 1px; + height: 20px; + background: var(--border); + flex: 0 0 auto; +} +.ge-options-body { + display: flex; + align-items: center; + gap: 14px; + /* Shrink to content instead of growing: an empty mirror body otherwise + expands and leaves a large blank gap before the relocated tool controls, + pushing them away from the left edge of the strip. */ + flex: 0 1 auto; + min-width: 0; +} +/* Tool options moved out of the dock into the options bar (Photoshop): + the real controls flow inline on the SAME single thin row, horizontally + scrollable when they do not fit. Section headings/hints are dropped in + this compact strip; the authoritative sliders mirror the contextual + strip, so duplicated ranges are hidden. */ +.ge-options-bar .ge-controls.ge-controls--in-options { + /* Grow to fill the strip so the selection action row can be pinned to the + right edge of the screen. */ + flex: 1 1 auto; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + gap: 14px; + padding: 0; + margin: 0; + border: 0; + min-width: 0; + max-height: none; + overflow: visible; +} +.ge-controls--in-options .ge-section-title, +.ge-controls--in-options .ge-section-hint { display: none; } +/* The "rembg not installed" notice would otherwise be a two-line block and + double the strip height; keep the install action, drop the essay. */ +.ge-controls--in-options #ge-rembg-dep-missing { + padding: 0; + border: 0; + background: none; + gap: 8px; +} +.ge-controls--in-options #ge-rembg-dep-missing .ge-dep-notice-text { display: none; } +.ge-controls--in-options > section, +.ge-controls--in-options > div { flex: 0 0 auto; } +/* Lay out every visible tool section as one horizontal strip. Hidden + sections carry an inline display:none and are left untouched; visible + ones must override their inline display:block, hence !important. */ +.ge-controls--in-options > div:not([style*="display:none"]):not([style*="display: none"]) { + display: flex !important; + flex-direction: row !important; + flex-wrap: nowrap !important; + align-items: center; + gap: 12px; + /* Let the active tool section span the strip so its action row can be + pushed to the right edge. */ + flex: 1 1 auto; +} +/* When transform section is active in controls, suppress any underlying tool section + so transform controls (Cancel, Apply, X, Y, W, H) have the full width and are not + overlapped or crowded by tool options (e.g. marquee invert/erase buttons). */ +.ge-controls.ge-transform-active > div:not(#ge-transform-section), +.ge-controls:has(#ge-transform-section:not([style*="display:none"]):not([style*="display: none"])) > div:not(#ge-transform-section), +.ge-controls--in-options:has(#ge-transform-section:not([style*="display:none"]):not([style*="display: none"])) > div:not(#ge-transform-section) { + display: none !important; +} +/* The Text tool's option set is too wide for the thin strip: let the bar grow + and wrap onto extra rows instead of clipping the trailing fields. */ +.ge-options-bar.ge-options-tall { + height: auto; + max-height: none; + flex-wrap: wrap; + align-items: center; + overflow-y: visible; + row-gap: 4px; + padding-top: 6px; + padding-bottom: 6px; +} +/* The active tool's name already sits in the rail; on the tall (Text) strip + its label would claim a whole line and shove the controls down. */ +.ge-options-bar.ge-options-tall .ge-options-tool { display: none; } +.ge-options-bar.ge-options-tall .ge-controls--in-options { + flex: 1 1 auto; + flex-wrap: wrap !important; + row-gap: 4px; +} +.ge-options-bar.ge-options-tall .ge-controls--in-options > div:not([style*="display:none"]):not([style*="display: none"]) { + flex-wrap: wrap !important; + row-gap: 4px; +} +/* The Text section's own field groups must wrap onto their own rows too, + otherwise the trailing format/Warp fields run past the right edge. */ +.ge-options-bar.ge-options-tall .ge-controls--in-options .ge-text-section { + display: flex !important; + flex-wrap: wrap !important; + align-items: flex-end; + gap: 8px; + width: 100%; +} +.ge-options-bar.ge-options-tall .ge-controls--in-options .ge-text-section > * { + flex: 0 0 auto; +} +.ge-options-bar.ge-options-tall .ge-controls--in-options .ge-text-section > textarea { + flex: 1 1 240px; + min-width: 240px; +} + +/* The brush sliders (Opacity/Flow/Softness) are a touch tight against the + strip width once the Color/Size/preset controls are inline; trim the + gutters so the whole brush strip fits on one line. */ +.ge-controls--in-options #ge-brush-section { gap: 6px; } +/* 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-control-row { + margin: 0 !important; + flex: 0 0 auto; + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; +} +.ge-controls--in-options .ge-control-row label { white-space: nowrap; } +/* The selection action row (visibility, clear ×, Invert, Erase, Copy Layer, + To Mask, Quick Mask) rides a touch high in the thin strip; nudge it up 2px + to sit on the same optical line as the Style/Free row before it. */ +.ge-controls--in-options .ge-control-row.ge-actions { position: relative; top: -2px; margin-left: auto; } +/* The action buttons themselves ride ~2px below the mode/Style rows in the + thin strip; lift them so the whole row reads on one line (wand + marquee). */ +.ge-controls--in-options .ge-control-row.ge-actions .ge-btn { + position: relative; + top: -2px; +} +/* Pen selection: the action row belongs on the left edge (not pushed right by + the shared auto margin), and its ✓ should sit on the same optical line as + the ✕ in Cancel. */ +#ge-pen-section .ge-control-row.ge-actions { margin-left: 0; } +#ge-pen-commit svg { position: relative; top: 2px; } +/* Nudge the Clear ✕ glyph down 1px, and light it up (accent) while a + selection is active so it is obvious there is something to clear. */ +#ge-marquee-clear svg, +#ge-wand-clear svg, +#ge-lasso-clear svg, +#ge-sam-clear svg, +#ge-quickselect-clear svg, +#ge-objectselect-clear svg { position: relative; top: 1px; } +#ge-marquee-clear.has-selection, +#ge-wand-clear.has-selection, +#ge-lasso-clear.has-selection, +#ge-sam-clear.has-selection, +#ge-quickselect-clear.has-selection, +#ge-objectselect-clear.has-selection { color: var(--accent, var(--red)); border-color: color-mix(in srgb, var(--accent, var(--red)) 55%, var(--border)); } +/* The Clone "clear sampled source" ✕ matches the wand/marquee ✕: the SVG cross + sits 1px lower, and the button lights up (accent) while a source is set. */ +#ge-clone-source-clear svg { position: relative; top: -1px; } +#ge-clone-source-clear.has-selection svg { top: 1px; } +#ge-clone-source-clear.has-selection { color: var(--accent, var(--red)); border-color: color-mix(in srgb, var(--accent, var(--red)) 55%, var(--border)); } +/* Every clear ✕ is the same square target, wherever it lives: the wand/ + marquee/lasso/sam/quickselect/objectselect ones sit in the options strip + while the Clone one sits in the right panel, but they must read (and hit) + identically instead of inheriting each container's line-height. */ +#ge-marquee-clear, +#ge-wand-clear, +#ge-lasso-clear, +#ge-sam-clear, +#ge-quickselect-clear, +#ge-objectselect-clear, +#ge-clone-source-clear { + width: 26px; + height: 26px; + min-width: 26px; + min-height: 26px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + line-height: 1; +} +/* The marquee Style row lives in the nowrap options strip. When "Fixed + ratio/size" reveals the W/H inputs, their default intrinsic width + inflates the grid's max-content and the + + +

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

+ +
+ + 1.4× +
+

How strongly named looks (Kodachrome, teal and orange, …) are graded.

+ + +
+ +
+ `; + + function open(focusId) { + el.hidden = false; + if (focusId) { + const target = document.getElementById(focusId); + if (target) { + try { target.focus({ preventScroll: true }); } catch (_) { target.focus(); } + } + } + } + function close() { el.hidden = true; } + + el.addEventListener('click', (e) => { + if (e.target === el || e.target.closest('[data-ai-settings-close]')) { + e.preventDefault(); + close(); + } + }); + const onKey = (e) => { if (e.key === 'Escape' && !el.hidden) close(); }; + document.addEventListener('keydown', onKey); + + container.appendChild(el); + + const strengthInput = el.querySelector('#ge-look-strength'); + const strengthVal = el.querySelector('#ge-look-strength-val'); + if (strengthInput) { + try { + const saved = parseFloat(localStorage.getItem('ge-look-strength')); + if (Number.isFinite(saved) && saved > 0) strengthInput.value = String(saved); + } catch (_) {} + if (strengthVal) strengthVal.textContent = Number(strengthInput.value).toFixed(1) + '×'; + strengthInput.addEventListener('input', () => { + const v = Number(strengthInput.value); + if (strengthVal) strengthVal.textContent = v.toFixed(1) + '×'; + try { localStorage.setItem('ge-look-strength', String(v)); } catch (_) {} + }); + } + + function destroy() { + document.removeEventListener('keydown', onKey); + el.remove(); + } + + return { element: el, open, close, destroy }; +} diff --git a/static/js/editor/ai-tools-misc.js b/static/js/editor/ai-tools-misc.js index b9cb9fea1..094d2cdf8 100644 --- a/static/js/editor/ai-tools-misc.js +++ b/static/js/editor/ai-tools-misc.js @@ -215,21 +215,19 @@ export function wireAIToolsMisc({ function addEmptyLayer() { saveState('Add layer'); const layer = createLayer('Layer ' + state.layers.length, state.imgWidth, state.imgHeight); - state.layers.push(layer); + // Photoshop inserts the new layer directly above the selected one. + const activeIndex = state.layers.findIndex(item => item.id === state.activeLayerId); + const insertIndex = activeIndex >= 0 ? activeIndex + 1 : state.layers.length; + state.layers.splice(insertIndex, 0, layer); state.activeLayerId = layer.id; renderLayerPanel(); composite(); } - const addButton = document.getElementById('ge-add-layer'); - addButton?.addEventListener('click', event => { - event.preventDefault(); - event.stopPropagation(); + const buildFxMenu = (anchor) => { document.querySelector('.ge-add-layer-menu')?.remove(); const menu = document.createElement('div'); menu.className = 'ge-add-layer-menu ge-frosted'; menu.innerHTML = ` - - @@ -242,9 +240,16 @@ export function wireAIToolsMisc({ + + + + + + + `; document.body.appendChild(menu); - const rect = addButton.getBoundingClientRect(); + const rect = anchor.getBoundingClientRect(); const menuRect = menu.getBoundingClientRect(); menu.style.left = `${Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, rect.right - menuRect.width))}px`; menu.style.top = `${Math.max(8, Math.min(window.innerHeight - menuRect.height - 8, rect.bottom + 5))}px`; @@ -253,7 +258,7 @@ export function wireAIToolsMisc({ document.removeEventListener('pointerdown', away, true); }; const away = pointerEvent => { - if (!menu.contains(pointerEvent.target) && pointerEvent.target !== addButton) close(); + if (!menu.contains(pointerEvent.target) && pointerEvent.target !== anchor) close(); }; requestAnimationFrame(() => document.addEventListener('pointerdown', away, true)); menu.addEventListener('click', clickEvent => { @@ -261,10 +266,26 @@ export function wireAIToolsMisc({ if (!button) return; clickEvent.preventDefault(); clickEvent.stopPropagation(); - if (button.dataset.layerKind === 'raster') addEmptyLayer(); - else if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, addButton); + if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, anchor); close(); }); + }; + + // "+" adds a plain pixel layer directly. + const addButton = document.getElementById('ge-add-layer'); + addButton?.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + document.querySelector('.ge-add-layer-menu')?.remove(); + addEmptyLayer(); + }); + + // "fx" opens the adjustment-layer chooser. + const addFxButton = document.getElementById('ge-add-fx'); + addFxButton?.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + buildFxMenu(addFxButton); }); return { addEmptyLayer }; diff --git a/static/js/editor/aspect-fit.js b/static/js/editor/aspect-fit.js new file mode 100644 index 000000000..55933f855 --- /dev/null +++ b/static/js/editor/aspect-fit.js @@ -0,0 +1,35 @@ +// Pure aspect-fitting geometry for the editor. When a whole-image resize is +// asked for a target aspect that differs from the current image, content must +// be cropped to that aspect (centred on a focus point) and then scaled +// UNIFORMLY — never stretched. +// +// aspectFitCropRect returns the document-space rectangle to crop FIRST so a +// subsequent uniform scale-to-{targetW,targetH} preserves the subject aspect +// ratio. Returns the full image unchanged when the aspect already matches. + +export function aspectFitCropRect({ width, height, focusX, focusY, targetAspect, tolerance = 0.005 } = {}) { + const w = Number(width); + const h = Number(height); + const a = Number(targetAspect); + if (!(w > 0) || !(h > 0) || !(a > 0)) return null; + const curAspect = w / h; + if (Math.abs(curAspect - a) / a <= tolerance) return { x: 0, y: 0, width: Math.round(w), height: Math.round(h) }; + const fx = Number.isFinite(Number(focusX)) ? Number(focusX) : w / 2; + const fy = Number.isFinite(Number(focusY)) ? Number(focusY) : h / 2; + let cw; + let ch; + if (curAspect > a) { + ch = h; + cw = Math.max(1, Math.round(ch * a)); + } else { + cw = w; + ch = Math.max(1, Math.round(cw / a)); + } + let x = Math.round(fx - cw / 2); + let y = Math.round(fy - ch / 2); + x = Math.max(0, Math.min(w - cw, x)); + y = Math.max(0, Math.min(h - ch, y)); + return { x, y, width: cw, height: ch }; +} + +export default { aspectFitCropRect }; diff --git a/static/js/editor/batch-runner.js b/static/js/editor/batch-runner.js new file mode 100644 index 000000000..f8dd421a4 --- /dev/null +++ b/static/js/editor/batch-runner.js @@ -0,0 +1,35 @@ +/** + * Batch runner — a small, dependency-free orchestrator that applies one action + * to every item in a list (open documents), isolating each item so a failure on + * one never aborts the batch. Pure async control flow: the caller supplies + * `runOne`, so the same loop is unit-testable without an editor. + */ + +/** + * @param {object} options + * @param {Array<{id:string,name?:string}>} options.documents + * @param {(doc:object, index:number)=>Promise} options.runOne + * @param {number} [options.delayMs] + * @param {(done:number,total:number,doc:object)=>void} [options.onProgress] + * @returns {Promise>} + */ +export async function runBatchOverDocuments({ documents, runOne, delayMs = 0, onProgress = null } = {}) { + const list = Array.isArray(documents) ? documents.filter(Boolean) : []; + const results = []; + for (let index = 0; index < list.length; index += 1) { + const doc = list[index]; + try { + const output = await runOne(doc, index); + results.push({ id: doc.id, name: doc.name, ok: true, output: output === undefined ? null : output }); + } catch (error) { + results.push({ id: doc.id, name: doc.name, ok: false, error: String((error && error.message) || error) }); + } + if (typeof onProgress === 'function') { + try { onProgress(index + 1, list.length, doc); } catch { /* progress is best-effort */ } + } + if (delayMs > 0) await new Promise(resolve => setTimeout(resolve, delayMs)); + } + return results; +} + +export default { runBatchOverDocuments }; diff --git a/static/js/editor/blend-modes.js b/static/js/editor/blend-modes.js new file mode 100644 index 000000000..ee3f3f8e6 --- /dev/null +++ b/static/js/editor/blend-modes.js @@ -0,0 +1,256 @@ +/** + * Layer/graphic blend modes. + * + * Canvas2D can only composite a subset of the Photoshop blend modes natively. + * This module exposes that native set plus the "extra" modes PhotoCraft offers + * (Dissolve, Linear Burn, Darker Color, Linear Dodge/Add, Lighter Color, + * Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide) and a + * single `blendDraw` entry point that both paths go through. + * + * The non-native modes follow the W3C compositing spec (separable + the two + * non-separable luminance modes) and operate on ImageData in the overlap of + * the source and the destination so rendering stays reasonably cheap. + */ + +export const NATIVE_BLEND_MODES = new Set([ + 'source-over', + 'multiply', + 'screen', + 'overlay', + 'darken', + 'lighten', + 'color-dodge', + 'color-burn', + 'hard-light', + 'soft-light', + 'difference', + 'exclusion', + 'hue', + 'saturation', + 'color', + 'luminosity', +]); + +const NATIVE_ALIASES = { + 'linear-dodge': 'lighter', + add: 'lighter', +}; + +export const EXTRA_BLEND_MODES = [ + { value: 'linear-dodge', label: 'Linear Dodge (Add)' }, + { value: 'linear-burn', label: 'Linear Burn' }, + { value: 'darker-color', label: 'Darker Color' }, + { value: 'lighter-color', label: 'Lighter Color' }, + { value: 'vivid-light', label: 'Vivid Light' }, + { value: 'linear-light', label: 'Linear Light' }, + { value: 'pin-light', label: 'Pin Light' }, + { value: 'hard-mix', label: 'Hard Mix' }, + { value: 'subtract', label: 'Subtract' }, + { value: 'divide', label: 'Divide' }, + { value: 'dissolve', label: 'Dissolve' }, +]; + +const NON_SEPARABLE = new Set(['darker-color', 'lighter-color']); + +export function isNativeBlendMode(mode) { + if (!mode) return true; + return NATIVE_BLEND_MODES.has(mode) || Object.prototype.hasOwnProperty.call(NATIVE_ALIASES, mode); +} + +export function isKnownBlendMode(mode) { + if (!mode) return true; + return isNativeBlendMode(mode) || EXTRA_BLEND_MODES.some(entry => entry.value === mode); +} + +function clamp01(value) { + return value < 0 ? 0 : value > 1 ? 1 : value; +} + +function lum(r, g, b) { + return 0.299 * r + 0.587 * g + 0.114 * b; +} + +// Separable per-channel blend functions on normalized [0,1] values. +function blendChannel(mode, cb, cs) { + switch (mode) { + case 'normal': + return cs; + case 'multiply': + return cb * cs; + case 'screen': + return cb + cs - cb * cs; + case 'overlay': + return cb <= 0.5 ? 2 * cb * cs : 1 - 2 * (1 - cb) * (1 - cs); + case 'darken': + return Math.min(cb, cs); + case 'lighten': + return Math.max(cb, cs); + case 'color-dodge': + if (cb === 0) return 0; + if (cs === 1) return 1; + return Math.min(1, cb / (1 - cs)); + case 'color-burn': + if (cb === 1) return 1; + if (cs === 0) return 0; + return 1 - Math.min(1, (1 - cb) / cs); + case 'hard-light': + return cs <= 0.5 ? 2 * cs * cb : 1 - 2 * (1 - cs) * (1 - cb); + case 'soft-light': { + if (cs <= 0.5) return cb - (1 - 2 * cs) * cb * (1 - cb); + const d = cb <= 0.25 ? ((16 * cb - 12) * cb + 4) * cb : Math.sqrt(cb); + return cb + (2 * cs - 1) * (d - cb); + } + case 'difference': + return Math.abs(cb - cs); + case 'exclusion': + return cb + cs - 2 * cb * cs; + case 'linear-burn': + return clamp01(cb + cs - 1); + case 'linear-dodge': + return clamp01(cb + cs); + case 'vivid-light': + return cs <= 0.5 + ? (cs === 0 ? 0 : 1 - Math.min(1, (1 - cb) / (2 * cs))) + : (cs === 1 ? 1 : Math.min(1, cb / (2 * (1 - cs)))); + case 'linear-light': + return clamp01(cb + 2 * cs - 1); + case 'pin-light': + return cs <= 0.5 ? Math.min(cb, 2 * cs) : Math.max(cb, 2 * cs - 1); + case 'hard-mix': { + const vivid = cs <= 0.5 + ? (cs === 0 ? 0 : 1 - Math.min(1, (1 - cb) / (2 * cs))) + : (cs === 1 ? 1 : Math.min(1, cb / (2 * (1 - cs)))); + return vivid < 0.5 ? 0 : 1; + } + case 'subtract': + return clamp01(cb - cs); + case 'divide': + return cs === 0 ? 1 : clamp01(cb / cs); + default: + return cs; + } +} + +function dissolveRandom(i) { + let seed = (i * 2654435761) >>> 0; + seed ^= seed >>> 13; + seed = (seed * 1274126177) >>> 0; + return seed / 4294967296; +} + +function ensureSourceData(source) { + const sw = source.width || source.naturalWidth || 0; + const sh = source.height || source.naturalHeight || 0; + if (!sw || !sh) return null; + const scratch = document.createElement('canvas'); + scratch.width = sw; + scratch.height = sh; + const sctx = scratch.getContext('2d'); + sctx.drawImage(source, 0, 0, sw, sh); + return { data: sctx.getImageData(0, 0, sw, sh).data, sw, sh }; +} + +function blendCustom(ctx, source, mode, alpha, dx, dy) { + if (alpha <= 0) return; + const src = ensureSourceData(source); + if (!src) return; + const tw = ctx.canvas.width; + const th = ctx.canvas.height; + const sw = src.sw; + const sh = src.sh; + const sx0 = Math.max(0, -dx); + const sy0 = Math.max(0, -dy); + const dx0 = Math.max(0, dx); + const dy0 = Math.max(0, dy); + const w = Math.min(sw - sx0, tw - dx0); + const h = Math.min(sh - sy0, th - dy0); + if (w <= 0 || h <= 0) return; + + const dst = ctx.getImageData(dx0, dy0, w, h); + const dd = dst.data; + const sd = src.data; + const nonSeparable = NON_SEPARABLE.has(mode); + const dissolve = mode === 'dissolve'; + + for (let y = 0; y < h; y += 1) { + for (let x = 0; x < w; x += 1) { + const si = ((y + sy0) * sw + (x + sx0)) * 4; + const di = (y * w + x) * 4; + const sa = (sd[si + 3] / 255) * alpha; + const ba = dd[di + 3] / 255; + + if (dissolve) { + const seed = (y + dy0) * 73856093 ^ (x + dx0) * 19349663; + if (dissolveRandom(seed) >= sa) continue; + dd[di] = sd[si]; + dd[di + 1] = sd[si + 1]; + dd[di + 2] = sd[si + 2]; + dd[di + 3] = 255; + continue; + } + + if (sa === 0) continue; + + const sr = sd[si] / 255; + const sg = sd[si + 1] / 255; + const sb = sd[si + 2] / 255; + const br = dd[di] / 255; + const bg = dd[di + 1] / 255; + const bb = dd[di + 2] / 255; + + let mergedR; + let mergedG; + let mergedB; + if (nonSeparable) { + const backdropLum = lum(br, bg, bb); + const sourceLum = lum(sr, sg, sb); + const useSource = mode === 'darker-color' ? sourceLum < backdropLum : sourceLum > backdropLum; + mergedR = useSource ? sr : br; + mergedG = useSource ? sg : bg; + mergedB = useSource ? sb : bb; + } else { + mergedR = blendChannel(mode, br, sr); + mergedG = blendChannel(mode, bg, sg); + mergedB = blendChannel(mode, bb, sb); + } + + const srEff = (1 - ba) * sr + ba * mergedR; + const sgEff = (1 - ba) * sg + ba * mergedG; + const sbEff = (1 - ba) * sb + ba * mergedB; + + const ao = sa + ba * (1 - sa); + if (ao <= 0) continue; + const cr = (sa * srEff + ba * br * (1 - sa)) / ao; + const cg = (sa * sgEff + ba * bg * (1 - sa)) / ao; + const cbOut = (sa * sbEff + ba * bb * (1 - sa)) / ao; + + dd[di] = Math.round(clamp01(cr) * 255); + dd[di + 1] = Math.round(clamp01(cg) * 255); + dd[di + 2] = Math.round(clamp01(cbOut) * 255); + dd[di + 3] = Math.round(ao * 255); + } + } + ctx.putImageData(dst, dx0, dy0); +} + +/** + * Composite `source` onto a 2D context (or canvas) using `mode` at `alpha`. + * Native modes use globalCompositeOperation; the rest fall back to pixel math. + */ +export function blendDraw(target, source, mode, alpha = 1, dx = 0, dy = 0) { + if (!target || !source) return; + const ctx = typeof target.getContext === 'function' ? target.getContext('2d') : target; + const a = Number.isFinite(alpha) ? clamp01(alpha) : 1; + const normalized = mode || 'source-over'; + if (isNativeBlendMode(normalized)) { + const prevAlpha = ctx.globalAlpha; + const prevOp = ctx.globalCompositeOperation; + ctx.globalAlpha = a; + ctx.globalCompositeOperation = NATIVE_ALIASES[normalized] || normalized; + ctx.drawImage(source, dx, dy); + ctx.globalAlpha = prevAlpha; + ctx.globalCompositeOperation = prevOp; + return; + } + blendCustom(ctx, source, normalized, a, dx, dy); +} diff --git a/static/js/editor/brush-engine.js b/static/js/editor/brush-engine.js index 3c6950f98..97c9d093a 100644 --- a/static/js/editor/brush-engine.js +++ b/static/js/editor/brush-engine.js @@ -24,6 +24,32 @@ export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit emit(emitted); }; + // Emit evenly spaced dabs from the last emitted sample toward `target`, + // carrying the sub-spacing remainder forward. Shared by drag updates and + // the final pointer-up so a stroke ends on the same spacing grid instead + // of stamping one detached dab (which reads as a dot where drawing stopped). + const emitSpaced = (target) => { + if (!emitted) return; + const start = { ...emitted }; + const dx = target.x - start.x; + const dy = target.y - start.y; + const distance = Math.hypot(dx, dy); + if (!distance) return; + const diameter = Math.max(1, Number(getDiameter()) || 1); + const spacing = Math.max(0.5, diameter * Math.max(0.01, Number(getSpacing()) || 0.01)); + let travelled = spacing - remainder; + while (travelled <= distance) { + const t = travelled / distance; + output({ + x: start.x + dx * t, + y: start.y + dy * t, + pressure: start.pressure + (target.pressure - start.pressure) * t, + }); + travelled += spacing; + } + remainder = Math.max(0, distance - (travelled - spacing)); + }; + const begin = (sample) => { emitted = null; filtered = { ...sample, pressure: normalisePressure(sample.pressure) }; @@ -41,30 +67,14 @@ export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit y: filtered.y + (raw.y - filtered.y) * response, pressure: filtered.pressure + (raw.pressure - filtered.pressure) * response, }; - const start = { ...emitted }; - const dx = filtered.x - start.x; - const dy = filtered.y - start.y; - const distance = Math.hypot(dx, dy); - if (!distance) return; - const diameter = Math.max(1, Number(getDiameter()) || 1); - const spacing = Math.max(0.5, diameter * Math.max(0.01, Number(getSpacing()) || 0.01)); - let travelled = spacing - remainder; - while (travelled <= distance) { - const t = travelled / distance; - output({ - x: start.x + dx * t, - y: start.y + dy * t, - pressure: start.pressure + (filtered.pressure - start.pressure) * t, - }); - travelled += spacing; - } - remainder = Math.max(0, distance - (travelled - spacing)); + emitSpaced(filtered); }; const end = (sample) => { + // Reach the true pointer-up point along the spacing grid rather than with + // a single far dab, so the stroke does not terminate in a detached dot. if (sample && emitted) { - const finalSample = { ...sample, pressure: normalisePressure(sample.pressure) }; - if (Math.hypot(finalSample.x - emitted.x, finalSample.y - emitted.y) > 0.25) output(finalSample); + emitSpaced({ ...sample, pressure: normalisePressure(sample.pressure) }); } emitted = null; filtered = null; diff --git a/static/js/editor/brush-presets.js b/static/js/editor/brush-presets.js index 5c1eab246..dc1ad0f8d 100644 --- a/static/js/editor/brush-presets.js +++ b/static/js/editor/brush-presets.js @@ -4,6 +4,7 @@ const BUILT_INS = [ { id: 'soft-round', name: 'Soft Round', size: 80, opacity: 100, flow: 35, softness: 100, spacing: 12, smoothing: 25, blendMode: 'source-over' }, { id: 'hard-round', name: 'Hard Round', size: 24, opacity: 100, flow: 100, softness: 0, spacing: 18, smoothing: 15, blendMode: 'source-over' }, { id: 'detail', name: 'Detail', size: 6, opacity: 100, flow: 70, softness: 20, spacing: 10, smoothing: 55, blendMode: 'source-over' }, + { id: 'pixel-1px', name: 'Pixel', size: 1, opacity: 100, flow: 100, softness: 0, spacing: 1, smoothing: 0, blendMode: 'source-over', pixel: true }, ]; export function loadBrushPresets(storage = globalThis.localStorage) { @@ -31,6 +32,7 @@ export function captureBrushPreset(state, name) { spacing: state.brushSpacing, smoothing: state.brushSmoothing, blendMode: state.brushBlendMode, + pixel: !!state.brushPixel, }; } @@ -43,4 +45,5 @@ export function applyBrushPreset(state, preset) { state.brushSpacing = preset.spacing; state.brushSmoothing = preset.smoothing; state.brushBlendMode = preset.blendMode || 'source-over'; + state.brushPixel = !!preset.pixel; } diff --git a/static/js/editor/build/command-palette.js b/static/js/editor/build/command-palette.js new file mode 100644 index 000000000..2bef2c873 --- /dev/null +++ b/static/js/editor/build/command-palette.js @@ -0,0 +1,80 @@ +/** Command palette (Ctrl/Cmd+K): fuzzy list of tools, panels and actions. */ + +export function createCommandPalette({ commands = [], onRun = null } = {}) { + const overlay = document.createElement('div'); + overlay.className = 'ge-command-palette'; + overlay.hidden = true; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', 'Command palette'); + overlay.innerHTML = ` +
+ +
+
`; + document.body.appendChild(overlay); + + const input = overlay.querySelector('.ge-command-input'); + const list = overlay.querySelector('.ge-command-list'); + let filtered = []; + let activeIndex = 0; + + function render() { + const query = input.value.trim().toLowerCase(); + filtered = commands.filter(cmd => !query || String(cmd.label).toLowerCase().includes(query)); + list.innerHTML = ''; + if (!filtered.length) { + const empty = document.createElement('p'); + empty.className = 'ge-command-empty'; + empty.textContent = 'No matching commands'; + list.appendChild(empty); + return; + } + if (activeIndex >= filtered.length) activeIndex = 0; + filtered.forEach((cmd, index) => { + const row = document.createElement('button'); + row.type = 'button'; + row.className = `ge-command-item${index === activeIndex ? ' active' : ''}`; + row.setAttribute('role', 'option'); + row.setAttribute('aria-selected', index === activeIndex ? 'true' : 'false'); + row.textContent = cmd.label; + row.addEventListener('click', () => run(cmd)); + list.appendChild(row); + }); + } + + function run(cmd) { + close(); + if (typeof cmd.run === 'function') cmd.run(); + else if (typeof onRun === 'function') onRun(cmd); + } + + function open() { + overlay.hidden = false; + input.value = ''; + activeIndex = 0; + render(); + input.focus(); + } + + function close() { + overlay.hidden = true; + } + + input.addEventListener('input', () => { activeIndex = 0; render(); }); + input.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { event.preventDefault(); close(); } + else if (event.key === 'ArrowDown') { event.preventDefault(); activeIndex = Math.min(activeIndex + 1, filtered.length - 1); render(); } + else if (event.key === 'ArrowUp') { event.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); render(); } + else if (event.key === 'Enter') { event.preventDefault(); if (filtered[activeIndex]) run(filtered[activeIndex]); } + }); + overlay.addEventListener('pointerdown', (event) => { if (event.target === overlay) close(); }); + + return { + element: overlay, + open, + close, + toggle: () => (overlay.hidden ? open() : close()), + isOpen: () => !overlay.hidden, + }; +} diff --git a/static/js/editor/build/controls.js b/static/js/editor/build/controls.js index 74f9d1fcb..fafa4a0ac 100644 --- a/static/js/editor/build/controls.js +++ b/static/js/editor/build/controls.js @@ -6,10 +6,10 @@ * sections are all toggled `display:none` here; the tool-switch handler * in galleryEditor.js shows the section matching the active tool. * - * @param {{ color: string, brushSize: number, wandTolerance: number }} ctx + * @param {{ color: string, brushSize: number, wandTolerance: number, bucketTolerance: number, cropAspectPreset: string, eyedropperSampleSize: number }} ctx * @returns {string} */ -export function controlsHTML({ color, brushSize, wandTolerance }) { +export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance = 32, cropAspectPreset = 'free', eyedropperSampleSize = 1 }) { const brushSliderValue = Math.round(Math.log(Math.max(1, brushSize)) / Math.log(800) * 1000); return ` +
Position
@@ -33,31 +55,105 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
-
- +
+ + + + +
-
Stroke
-
- - -
-
- - -
-
- - -
-
- Size - Opacity - Flow +
@@ -96,19 +192,47 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
+
+
+ + + +
+