"""Single source of truth for photo-editor intent routing. Both the server-side planner (``routes/editor_agent_routes.py``) and the browser fallback planner (``static/js/editor/nl-edit.js``) read this guide so their behaviour cannot drift. It is served verbatim to the client at ``GET /api/editor/style-guide``. Calibrated strengths live here; this file is the only place to tune routing. It is intentionally dependency-free so the route module can import it cheaply. """ from __future__ import annotations import json import re from collections import Counter from typing import Any, Dict, List, Optional STYLE_GUIDE_VERSION = 8 # New editor ops (implemented client-side, static) with canonical params. The # server op catalog must include these or a valid plan would be filtered out. NEW_OPS: List[Dict[str, Any]] = [ { "op": "photo.autoTone", "params": {}, "help": "Camera-Raw-style auto tone/colour (per-channel levels, auto white " "balance, exposure, contrast). The single preferred global lift.", }, { "op": "retouch.skin", "params": {"smooth": 8, "opacity": 0.35, "blemish": True, "radius": 12, "threshold": 16}, "help": "Gentle frequency-separation skin smoothing that keeps texture, over " "the selection intersected with skin tones. Keep it subtle: smooth " "<=14, opacity <=0.45; it never brightens the face.", }, { "op": "retouch.blemish", "params": {"radius": 4, "threshold": 16}, "help": "Remove small blemishes inside the selection or skin region.", }, { "op": "retouch.teeth", "params": {"desat": 0.6, "bright": 16}, "help": "Whiten teeth within the current selection (desaturate yellow, " "lift brightness with a small feather).", }, { "op": "selection.face", "params": {"mode": "replace"}, "help": "Select a face (SAM text 'face', upper-centre ellipse fallback).", }, { "op": "selection.background", "params": {"mode": "replace", "subject": "person", "fuzziness": 30}, "help": "Select the background (SAM subject inverted, corner-colour " "fallback). Subject-aware; prefer over a plain global blur.", }, { "op": "fill.flood", "params": {"color": "#ff0000", "x": 0, "y": 0, "tolerance": 32}, "help": "Paint-bucket flood-fill a pixel layer from a point " "{x,y,color,tolerance}. Use for 'fill ... with ' when there " "is no active selection.", }, { "op": "selection.fill", "params": {"color": "#ff0000"}, "help": "Fill the current selection with a colour on the active pixel layer " "{color}. Use for 'fill the selection with '.", }, { "op": "text.edit", "params": {"content": "New text", "size": 96, "color": "#ff0000"}, "help": "Edit an EXISTING text layer {id|index|name, content, font, size, " "color, align, tracking, lineHeight}. Use for 'make the text " "bigger/smaller' and 'make the title ' — never text.add.", }, { "op": "layers.align", "params": {"mode": "center"}, "help": "Align/distribute the selected layers to the canvas {mode:left|center|" "right|top|middle|bottom|distribute-h|distribute-v}.", }, { "op": "layer.rotate", "params": {"degrees": 90}, "help": "Rotate the ACTIVE LAYER by 90/180/270 {degrees, id|index|name}. Use " "unless the prompt names the whole image/canvas/photo.", }, { "op": "layer.flip", "params": {"axis": "h"}, "help": "Flip the ACTIVE LAYER in place {axis:h|v, id|index|name}.", }, { "op": "document.rotate", "params": {"degrees": 90}, "help": "Rotate the WHOLE document (all layers + canvas) by 90/180/270 " "{degrees} — for 'rotate the whole image/canvas/photo'.", }, { "op": "document.flip", "params": {"axis": "h"}, "help": "Flip the WHOLE document {axis:h|v} — for 'flip the canvas/whole " "image'.", }, { "op": "selection.copy", "params": {}, "help": "Copy (or cut, {cut:true}) the selection pixels to the internal " "clipboard.", }, { "op": "selection.paste", "params": {}, "help": "Paste the internal clipboard as a new layer {name}.", }, { "op": "layers.flatten", "params": {}, "help": "Flatten all layers into a single layer.", }, { "op": "layers.mergeDown", "params": {}, "help": "Merge the active layer down into the layer below.", }, ] NEW_OP_NAMES: List[str] = [op["op"] for op in NEW_OPS] def _adj(kind: str, params: Dict[str, Any]) -> Dict[str, Any]: return {"op": "adjustments.apply", "params": {"type": kind, "params": params}} def _filter(fid: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: return {"op": "filters.apply", "params": {"id": fid, **(params or {})}} # Ordered intent routes. The first route whose keyword appears wins. INTENT_ROUTES: List[Dict[str, Any]] = [ { "id": "teeth", "keywords": ["teeth", "whiten my smile", "whiter smile", "smile looks"], "steps": [ {"op": "selection.face", "params": {"mode": "replace"}}, {"op": "retouch.teeth", "params": {"desat": 0.6, "bright": 16}}, ], "reply": "Selecting the face and whitening teeth.", }, { "id": "portrait_retouch", "keywords": ["skin", "complexion", "blemish", "acne", "pores", "retouch", "look better", "look my best", "make me look", "beautify", "flawless", "smooth my face", "clear up", "clean up my face", "portrait", "selfie"], # Do NOT lead with a whole-image photo.autoTone: lightening the whole frame # washes out the face (the client suppresses autoTone for portrait intents # unless the histogram shows real under/over-exposure). Go straight to a # gentle skin retouch; keep it subtle and never brighten the face. "min_steps": 3, "steps": [ {"op": "selection.face", "params": {"mode": "replace"}}, {"op": "retouch.skin", "params": {"smooth": 8, "opacity": 0.35, "blemish": True, "radius": 12, "threshold": 16}}, {"op": "selection.deselect", "params": {}}, ], "reply": "Selecting the face and gently retouching the skin.", }, { "id": "restore", "keywords": ["restore", "restoration", "old photo", "old photograph", "old picture", "faded photo", "faded picture", "faded", "recover this photo", "bring back this photo", "photo restoration"], # Never a large exposure lift: recover contrast + colour from a faded # baseline, then a slight sharpen. "min_steps": 4, "steps": [ {"op": "photo.autoTone", "params": {}}, _adj("brightness-contrast", {"brightness": 0, "contrast": 18}), _adj("white-balance", {"temperature": 0, "tint": 5}), _adj("vibrance", {"vibrance": 18}), _filter("sharpen"), ], "reply": "Restoring contrast and colour, then a light sharpen.", }, { "id": "background", "keywords": ["background"], "steps": [ {"op": "selection.background", "params": {"mode": "replace", "subject": "person", "fuzziness": 30}}, {"op": "filters.applyMasked", "params": {"id": "blur-gaussian"}}, ], "reply": "Selecting the background and softening it.", }, { "id": "lighting", # Low-key lighting: one auto-tone pass (the preferred global lift) plus # at most one shadow-lifting, highlight-protecting adjustment. Both are # sanctioned together; never a stack of brightness/exposure lifts. "keywords": ["lighting", "exposure", "too dark", "underexposed", "underexpose", "backlit", "back-lit", "brighten", "brighter", "lift the shadows", "dark photo", "dark image"], "min_steps": 2, "steps": [ {"op": "photo.autoTone", "params": {}}, {"op": "adjustments.apply", "params": {"type": "shadows-highlights", "params": {"shadows": 30, "highlights": -15}}}, ], "reply": "Auto tone (levels and white balance), then one shadow lift " "that protects the highlights.", }, { "id": "cool", "keywords": ["moodier", "moody", "cooler", "cool it", "cinematic", "teal", "blue tone", "cold", "night mood", "colder"], "steps": [_adj("white-balance", {"temperature": -25})], "reply": "Cooling the white balance (negative temperature).", }, { "id": "warm", "keywords": ["warmer", "warm it", "golden", "summer", "sunset", "golden hour", "warmth"], "steps": [_adj("white-balance", {"temperature": 25})], "reply": "Warming the white balance (positive temperature).", }, { "id": "black_white", "keywords": ["black and white", "black & white", "grayscale", "greyscale", "monochrome", "b&w"], "steps": [_adj("black-white", {})], "reply": "Converting to black and white.", }, { "id": "polish", # Generic "make it look good" intents: a decisive multi-step grade with # visible strength (a single timid adjustment scores as imperceptible). "keywords": ["pretty", "beautiful", "make it look good", "look good", "professional", "professionally", "editorial", "magazine", "glam", "glamour", "pop", "punchy", "striking", "vibrant", "film", "film look", "cinematic look", "instagram", "social media", "elevate", "polish"], "min_steps": 3, "steps": [ {"op": "photo.autoTone", "params": {}}, _adj("vibrance", {"vibrance": 25}), _filter("clarity", {"amount": 22, "radius": 30}), _filter("vignette", {"amount": 22, "feather": 0.6}), ], "reply": "Balancing tone, adding vibrance and local contrast, with a light vignette.", }, { "id": "vivid", "keywords": ["vivid", "dramatic", "saturate", "saturation", "colourful", "colorful"], "steps": [_adj("vibrance", {"vibrance": 35})], "reply": "Boosting vibrance.", }, { "id": "sharpen", "keywords": ["sharpen", "crisper", "crisp", "more detail", "sharper"], "steps": [_filter("sharpen")], "reply": "Sharpening.", }, ] # A prompt that only asks for a vague "improvement" with no concrete defect and # no matching intent cannot be planned deterministically -> ask the user. _AMBIGUOUS_PATTERNS = [ "clean this up", "clean it up", "fix it", "fix this", "make it better", "make this better", "improve", "touch up", "touch it up", "do something", "enhance", "make it nice", "polish", ] _AMBIGUOUS_OPTIONS = [ {"label": "Auto tone & color", "description": "photo.autoTone: balanced levels, white balance, contrast."}, {"label": "Brighten (lift shadows)", "description": "shadows-highlights: brighten without blowing highlights."}, {"label": "Cooler / cinematic", "description": "white-balance negative: cooler, moodier look."}, ] _DEFECT_HINTS = [ "skin", "teeth", "background", "light", "dark", "colour", "color", "warm", "cool", "blur", "sharpen", "contrast", "bright", "shadow", "face", "blemish", "noise", "grain", "crop", "vibrance", "saturat", "black and white", "grayscale", "portrait", "moody", "exposure", "tone", ] # Low-key safety: at most ONE global lift; prefer shadows/highlights and keep # highlights protected. Global-lift adjustment types are counted together. GLOBAL_LIFT_TYPES = ("autoTone", "brightness-contrast", "exposure", "levels", "shadows-highlights") _SAFE_LOWKEY_LIFT = { "op": "adjustments.apply", "params": {"type": "shadows-highlights", "params": {"shadows": 30, "highlights": -15}}, } def match(prompt: str) -> Optional[Dict[str, Any]]: """Return the first matching intent route for a prompt, or None.""" text = str(prompt or "").lower() for route in INTENT_ROUTES: if any(keyword in text for keyword in route["keywords"]): return route return None def is_ambiguous(prompt: str) -> bool: text = str(prompt or "").lower() if match(text): return False if any(hint in text for hint in _DEFECT_HINTS): return False return any(pattern in text for pattern in _AMBIGUOUS_PATTERNS) def ask_user_plan() -> Dict[str, Any]: question = "What would you like me to do with this photo?" return { "reply": question, "planner": "fallback", "steps": [], "ask_user": { "question": question, "options": [dict(o) for o in _AMBIGUOUS_OPTIONS], "multi": False, }, } def has_concrete_intent(prompt: str) -> bool: """True when a prompt maps to a concrete edit (a recipe, look, recolour, structural op) or names a concrete defect. Used to decide whether a plan made only of generic whole-image tone ops should be trusted or replaced by an ask_user prompt. """ text = str(prompt or "").lower() if match(text) or look_plan(text) or recolor_plan(text) or structural_plan(text): return True return any(hint in text for hint in _DEFECT_HINTS) def fallback_plan(prompt: str) -> Optional[Dict[str, Any]]: """Deterministic plan from the guide, or None when nothing matches.""" route = match(prompt) if route: return { "reply": route["reply"], "planner": "fallback", "steps": enforce_guards([dict(step) for step in route["steps"]]), } if is_ambiguous(prompt): return ask_user_plan() return None # ── Structural op routing ────────────────────────────────────────────── # Deterministic plans for structural editor ops the sampling model reliably # substitutes with a generic adjustments/transform step. Mirrors the browser # fallback (static/js/editor/nl-edit.js localPlan) so the server and client # agree; the server path must emit these or a wrong model plan suppresses the # browser fallback. _NAMED_COLORS = { "red": "#ff0000", "blue": "#0000ff", "green": "#00a000", "yellow": "#ffff00", "orange": "#ff8000", "purple": "#8000ff", "pink": "#ff69b4", "black": "#000000", "white": "#ffffff", "grey": "#808080", "gray": "#808080", "cyan": "#00ffff", "magenta": "#ff00ff", "brown": "#8b4513", "teal": "#008080", } _COLOR_TOKEN = (r"(red|blue|green|yellow|orange|purple|pink|black|white|grey|" r"gray|cyan|magenta|brown|teal|#[0-9a-fA-F]{3,6})") def color_for_name(name: str) -> Optional[str]: n = str(name or "").strip().lower() if re.match(r"^#[0-9a-f]{3,6}$", n): return n return _NAMED_COLORS.get(n) # ── Named reference looks + targeted recolour ────────────────────────── # Server-only deterministic plans (reached via routes._plan_special -> # structural_plan). Deliberately NOT added to INTENT_ROUTES: the browser # client consumes `intents` and cannot run an ai_edit_image step, so strong # looks must stay on the server path. _LOOK_KEEP_CLAUSE = " Keep the main subject and composition exactly the same." LOOKS = [ { "id": "wes_anderson", "keywords": ["wes anderson"], "mode": "recipe", "steps": [ {"op": "photo.autoTone", "params": {}}, _adj("white-balance", {"temperature": 10, "tint": 6}), _adj("vibrance", {"vibrance": 18}), _adj("brightness-contrast", {"brightness": 6, "contrast": -10}), ], "reply": "Gave it a Wes Anderson look: pastel palette, warm highlights and soft contrast.", }, { "id": "film_noir", "keywords": ["film noir"], "mode": "recipe", "steps": [ _adj("black-white", {}), _adj("brightness-contrast", {"contrast": 38}), _adj("shadows-highlights", {"shadows": -22, "highlights": 18}), _filter("vignette", {"amount": 45, "feather": 0.5}), ], "reply": "Gave it a film-noir look: high-contrast black and white with deep shadows.", }, { "id": "vintage_kodachrome", "keywords": ["kodachrome", "vintage film"], "mode": "recipe", "steps": [ {"op": "photo.autoTone", "params": {}}, _adj("white-balance", {"temperature": 12, "tint": 8}), _adj("vibrance", {"vibrance": 22}), _adj("brightness-contrast", {"contrast": 14}), _filter("vignette", {"amount": 12, "feather": 0.7}), ], "reply": "Gave it a vintage Kodachrome look: warm saturated colour and lifted contrast.", }, { "id": "cyberpunk", "keywords": ["cyberpunk"], "mode": "instruction", "prompt": "Restyle the photo as a cyberpunk scene: vivid neon magenta and cyan " "lighting, wet reflective surfaces, deep shadows and a futuristic " "atmosphere." + _LOOK_KEEP_CLAUSE, "reply": "Restyling it as a cyberpunk scene with the image model.", }, { "id": "studio_ghibli", "keywords": ["ghibli", "studio ghibli"], "mode": "instruction", "prompt": "Restyle the photo as a soft hand-painted anime scene in a " "Studio-Ghibli-inspired pastel style: luminous pastel palette, " "gentle painterly shading, clean edges and warm sunlight." + _LOOK_KEEP_CLAUSE, "reply": "Restyling it as a Studio-Ghibli-inspired pastel scene with the image model.", }, ] def look_plan(prompt: str) -> Optional[Dict[str, Any]]: """Deterministic plan for a named reference look, or None. Recipe looks (Wes Anderson, film noir, vintage Kodachrome) are pure adjustment/filter stacks; strong looks (cyberpunk, Studio Ghibli) use ai_edit_image instruction mode. """ p = str(prompt or "").lower() for look in LOOKS: if any(k in p for k in look["keywords"]): if look["mode"] == "instruction": steps = [{"op": "ai_edit_image", "params": {"op": "generative", "mode": "instruction", "prompt": look["prompt"], "steps": 8}}] else: steps = [dict(s) for s in look["steps"]] return {"reply": look["reply"], "planner": "deterministic", "steps": steps} return None _RECOLOR_ACTION_RE = re.compile( r"\brecolou?r\b" r"|\bmake\s+only\b" r"|\bmake\s+the\b[^.]{0,30}?\b" + _COLOR_TOKEN + r"\b" r"|\b(?:change|turn|convert|paint|colou?r)\b[^.]{0,40}?\b" + _COLOR_TOKEN + r"\b", re.I, ) _RECOLOR_EXCLUDE_RE = re.compile( r"\b(?:background|backdrop|blur|black\s+and\s+white|grayscale|greyscale|" r"monochrome|b&w|text|title|font|caption|label|logo|watermark|drop\s+shadow|" r"generative)\b", re.I, ) _RECOLOR_TRAILING_ADJ = re.compile( r"\s+\b(?:bright|brighter|vivid|vibrant|dark|darker|light|lighter|deep|" r"pale|more|less|very|really|slightly|totally|completely)\b\s*$", re.I, ) def _hex_rgb01(hex_color: str): h = str(hex_color or "").lstrip("#") if len(h) == 3: h = "".join(ch * 2 for ch in h) try: return tuple(int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4)) except Exception: return None def _hue_degrees(hex_color: str) -> Optional[float]: rgb = _hex_rgb01(hex_color) if rgb is None: return None r, g, b = rgb mx, mn = max(rgb), min(rgb) if mx == mn: return 0.0 d = mx - mn if mx == r: h = ((g - b) / d) % 6 elif mx == g: h = (b - r) / d + 2 else: h = (r - g) / d + 4 return h * 60.0 def _hue_delta(src_hex: str, tgt_hex: str) -> int: sh = _hue_degrees(src_hex) th = _hue_degrees(tgt_hex) if sh is None or th is None: return 0 return int(round((th - sh + 180.0) % 360.0 - 180.0)) def _is_chromatic(hex_color: str) -> bool: """True when a pure hue rotation can plausibly reach this colour. White / black / grey targets have (near) zero saturation and cannot be reached by rotating hue, so they go through the image model instead. """ rgb = _hex_rgb01(hex_color) if rgb is None: return False mx, mn = max(rgb), min(rgb) return (mx - mn) >= 0.15 and mx >= 0.12 def _recolor_object_phrase(prompt: str, target_word: str) -> str: """Best-effort object description for the mask ("red car" in 'the red car').""" m = re.search( r"\b(?:make|recolou?r|change|turn|convert|paint|colou?r)\b\s+(?:only\s+)?(.+)", prompt, re.I, ) phrase = (m.group(1) if m else prompt).strip() if target_word: phrase = re.sub(r"\b" + re.escape(target_word) + r"\b", " ", phrase, count=1, flags=re.I) words = " ".join(phrase.split()).strip(" .,!?").split() if len(words) > 1 and words[0].casefold() in {"to", "into", "the", "a", "an"}: words.pop(0) if len(words) > 1 and words[-1].casefold() in {"to", "into"}: words.pop() phrase = " ".join(words) while phrase and _RECOLOR_TRAILING_ADJ.search(phrase): phrase = _RECOLOR_TRAILING_ADJ.sub("", phrase).strip() return phrase or "the subject" def recolor_plan(prompt: str) -> Optional[Dict[str, Any]]: """Deterministic plan for a targeted recolour, or None. 'make only the red car blue' -> select the OBJECT by description (``selection.samText`` "red car"; falling back to a colour-range selection via ``fallbackColor`` when the mask is empty) and then shift the MASKED HUE toward the target with ``adjustments.applyMasked`` (preserving shading, highlights and reflections). Never a flat ``selection.fill`` and never a whole-image ``adjustments.apply`` tint. When the target cannot be reached by a hue rotation (white/black/grey) or no source colour is known, the masked region is recoloured with ``ai_edit_image`` instruction mode instead. """ p = str(prompt or "") if not _RECOLOR_ACTION_RE.search(p): return None if _RECOLOR_EXCLUDE_RE.search(p): return None colors = [m.group(1).lower() for m in re.finditer(r"\b" + _COLOR_TOKEN + r"\b", p)] colors = [c for c in colors if color_for_name(c)] if not colors: return None target = colors[-1] tgt_hex = color_for_name(target) source = colors[0] if len(colors) >= 2 and colors[0] != target else None src_hex = color_for_name(source) if source else None phrase = _recolor_object_phrase(p, target) select = { "op": "selection.samText", "params": {"text": phrase}, } if src_hex: select["params"]["fallbackColor"] = src_hex select["params"]["fallbackFuzziness"] = 40 if source and src_hex and _is_chromatic(tgt_hex): delta = _hue_delta(src_hex, tgt_hex) src_hue = int(round(_hue_degrees(src_hex) or 0)) return { "reply": "Selected the %s and shifted its hue toward %s " "(shading kept)." % (phrase, target), "planner": "deterministic", "steps": [ select, {"op": "adjustments.applyMasked", "params": {"hue": delta, "saturation": 0, "mode": "hue-saturation", "sourceHue": src_hue, "source": src_hex, "target": tgt_hex}}, ], } instruction = ("Recolour the %s to %s, preserving its shading, highlights " "and reflections." % (phrase, target)) return { "reply": "Recolouring the %s with the image model." % phrase, "planner": "deterministic", "steps": [ select, {"op": "ai_edit_image", "params": {"op": "inpaint", "mode": "masked", "prompt": instruction, "steps": 8}}, ], } def structural_plan(prompt: str) -> Optional[Dict[str, Any]]: """Deterministic plan for structural editor ops, or None. Covers fill/flood, text.edit, colour-overlay layer style, layer align, layer/document rotate+flip, selection copy/paste, flatten and merge-down. Also dispatches named reference looks and targeted recolours. Returns ``{"reply", "planner": "deterministic", "steps": [...]}``. """ look = look_plan(prompt) if look is not None: return look recolor = recolor_plan(prompt) if recolor is not None: return recolor p = str(prompt or "").lower() steps: List[Dict[str, Any]] = [] labels: List[str] = [] fm = (re.search(r"\bfill\b[^.]*?\bwith\s+" + _COLOR_TOKEN + r"\b", p) or re.search(r"\bfill\b[^.]*?\b" + _COLOR_TOKEN + r"\b", p)) if fm: color = color_for_name(fm.group(1)) if color: if re.search(r"\bselection\b|\bselected\b", p): steps.append({"op": "selection.fill", "params": {"color": color}}) labels.append("filled the selection") else: steps.append({"op": "fill.flood", "params": {"color": color, "x": 0, "y": 0}}) labels.append("flood-filled the layer") if re.search(r"\b(text|title|caption|font|label)\b", p) and not re.search( r"\b(add|insert|create|write|put|place)\b", p): tparams: Dict[str, Any] = {} if re.search(r"\b(bigger|larger|increase)\b", p): tparams["size"] = 96 elif re.search(r"\b(smaller|reduce|decrease)\b", p): tparams["size"] = 28 cm = re.search(r"\b" + _COLOR_TOKEN + r"\b", p) if cm: c = color_for_name(cm.group(1)) if c: tparams["color"] = c if tparams: steps.append({"op": "text.edit", "params": tparams}) labels.append("updated the text layer") if re.search(r"\bcolo?ur?\s*overlay\b", p): steps.append({"op": "layers.addStyle", "params": {"type": "color-overlay", "params": {}}}) labels.append("added a colour-overlay layer style") am = re.search( r"\balign\b[^.]*?\b(centre|center|left|right|top|middle|bottom)\b", p) if am: mode = "center" if am.group(1) in ("centre", "center") else am.group(1) steps.append({"op": "layers.align", "params": {"mode": mode}}) labels.append("aligned to the %s" % mode) whole = bool(re.search( r"\b(?:whole|entire|full)\s+(?:image|canvas|photo|picture|document)\b", p)) \ or bool(re.search(r"\b(?:canvas|document)\b", p) and re.search(r"\b(?:rotate|flip|mirror|turn)\b", p)) if re.search(r"\b(?:flip|mirror)\b", p): axis = "v" if re.search(r"\b(?:vertical(?:ly)?|v)\b", p) else "h" if whole: steps.append({"op": "document.flip", "params": {"axis": axis}}) labels.append("flipped the whole image") else: steps.append({"op": "layer.flip", "params": {"axis": axis}}) labels.append("flipped the layer") # Rotation needs an explicit angle/quarter-turn or the verb "rotate"; a bare # "turn ... into