mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-27 00:02:21 +01:00
1109 lines
49 KiB
Python
1109 lines
49 KiB
Python
"""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 <colour>' 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 <colour>'.",
|
|
},
|
|
{
|
|
"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 <colour>' — 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 <style>" (e.g. "turn this into an anime") is a restyle, not
|
|
# a rotation.
|
|
rotate_deg = None
|
|
rm = re.search(r"\b(?:rotate|turn)\b[^\d]*?\b(90|180|270)\b", p)
|
|
if rm:
|
|
rotate_deg = int(rm.group(1))
|
|
elif re.search(r"\brotate\b", p):
|
|
rotate_deg = 90
|
|
if rotate_deg is not None:
|
|
degrees = rotate_deg
|
|
if whole:
|
|
steps.append({"op": "document.rotate", "params": {"degrees": degrees}})
|
|
labels.append("rotated the whole image %d\u00b0" % degrees)
|
|
else:
|
|
steps.append({"op": "layer.rotate", "params": {"degrees": degrees}})
|
|
labels.append("rotated the layer %d\u00b0" % degrees)
|
|
|
|
if re.search(r"\bcopy\b", p) and re.search(r"\bselection\b|\bselected\b", p):
|
|
steps.append({"op": "selection.copy", "params": {}})
|
|
labels.append("copied the selection")
|
|
if re.search(r"\bpaste\b", p) and not re.search(r"\bflatten\b|\bmerge\b", p):
|
|
steps.append({"op": "selection.paste", "params": {}})
|
|
labels.append("pasted as a new layer")
|
|
|
|
if re.search(r"\bflatten\b", p):
|
|
steps.append({"op": "layers.flatten", "params": {}})
|
|
labels.append("flattened the image")
|
|
elif re.search(r"\bmerge\b", p) and re.search(r"\bdown\b|\blayer\b", p):
|
|
steps.append({"op": "layers.mergeDown", "params": {}})
|
|
labels.append("merged the layer down")
|
|
|
|
if not steps:
|
|
return None
|
|
return {
|
|
"reply": "Done \u2014 I " + ", ".join(labels) + ".",
|
|
"planner": "deterministic",
|
|
"steps": steps,
|
|
}
|
|
|
|
|
|
def _is_global_lift(step: Any) -> bool:
|
|
if not isinstance(step, dict):
|
|
return False
|
|
op = step.get("op")
|
|
params = step.get("params") or {}
|
|
if op == "photo.autoTone":
|
|
return True
|
|
if op != "adjustments.apply":
|
|
return False
|
|
kind = params.get("type")
|
|
if kind not in GLOBAL_LIFT_TYPES:
|
|
return False
|
|
inner = params.get("params") or {}
|
|
if kind == "brightness-contrast":
|
|
return float(inner.get("brightness") or 0) > 0
|
|
if kind == "exposure":
|
|
return float(inner.get("exposure") or 0) > 0
|
|
return True
|
|
|
|
|
|
def _is_shadows_highlights(step: Any) -> bool:
|
|
if not isinstance(step, dict) or step.get("op") != "adjustments.apply":
|
|
return False
|
|
params = step.get("params") or {}
|
|
return params.get("type") == "shadows-highlights"
|
|
|
|
|
|
def _is_masked_recolor(step: Any) -> bool:
|
|
"""True for a masked hue-saturation recolour step.
|
|
|
|
Such a step already targets the object's own colour, so no whole-image
|
|
global adjustment may follow it (it would double-process the photo and
|
|
undo the "only this object" intent).
|
|
"""
|
|
if not isinstance(step, dict) or step.get("op") != "adjustments.applyMasked":
|
|
return False
|
|
params = step.get("params") or {}
|
|
if params.get("mode") == "hue-saturation":
|
|
return True
|
|
hue = params.get("hue")
|
|
return hue not in (None, 0, 0.0)
|
|
|
|
|
|
def _is_whole_image_adjustment(step: Any) -> bool:
|
|
"""True for a whole-image tone/colour adjustment (never selection-scoped).
|
|
|
|
``adjustments.apply`` acts on the entire layer and ``photo.autoTone`` on the
|
|
whole photo, so after a masked recolour either one re-grades everything and
|
|
undoes the "only this object" intent — drop both regardless of type
|
|
(a non-lift grade such as vibrance/saturation would otherwise survive the
|
|
low-key guard).
|
|
"""
|
|
if not isinstance(step, dict):
|
|
return False
|
|
return step.get("op") in ("photo.autoTone", "adjustments.apply")
|
|
|
|
|
|
def collapse_global_lifts(steps: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
"""Enforce the low-key rule: at most one global lift, never a stack.
|
|
|
|
A single auto-tone pass (``photo.autoTone``) is the preferred global lift;
|
|
it may be paired with ONE shadow-lifting, highlight-protecting adjustment.
|
|
Any further global lift (a second auto tone, a brightness/exposure lift, or
|
|
an extra shadows-highlights) is dropped. When there is no auto tone, two or
|
|
more global lifts collapse to a single safe shadows-highlights step and a
|
|
lone brightness/exposure lift is clamped so it cannot blow out highlights.
|
|
"""
|
|
if not steps:
|
|
return steps
|
|
lifts = [i for i, step in enumerate(steps) if _is_global_lift(step)]
|
|
auto = [i for i in lifts if steps[i].get("op") == "photo.autoTone"]
|
|
if auto:
|
|
other = [i for i in lifts if i not in auto]
|
|
keep_other = other[0] if len(other) == 1 and _is_shadows_highlights(steps[other[0]]) else None
|
|
drop = set(auto[1:]) | (set(other) - ({keep_other} if keep_other is not None else set()))
|
|
if drop:
|
|
return [step for i, step in enumerate(steps) if i not in drop]
|
|
return steps
|
|
if len(lifts) >= 2:
|
|
drop = set(lifts)
|
|
out = [step for i, step in enumerate(steps) if i not in drop]
|
|
insert_at = min(lifts[0], len(out))
|
|
out.insert(insert_at, json.loads(json.dumps(_SAFE_LOWKEY_LIFT)))
|
|
return out
|
|
if len(lifts) == 1:
|
|
step = steps[lifts[0]]
|
|
params = step.get("params") or {}
|
|
if step.get("op") == "adjustments.apply":
|
|
inner = params.get("params") or {}
|
|
if params.get("type") == "brightness-contrast":
|
|
inner["brightness"] = min(float(inner.get("brightness") or 0), 25.0)
|
|
params["params"] = inner
|
|
step["params"] = params
|
|
elif params.get("type") == "exposure":
|
|
inner["exposure"] = min(float(inner.get("exposure") or 0), 30.0)
|
|
params["params"] = inner
|
|
step["params"] = params
|
|
return steps
|
|
|
|
|
|
def _clone(step: Any) -> Any:
|
|
return json.loads(json.dumps(step))
|
|
|
|
|
|
def enforce_guards(steps: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
"""Apply every planner-side safety rule so the client rarely has to undo.
|
|
|
|
* at most one global lift, never a stack (``collapse_global_lifts``);
|
|
* never a global brightness lift AFTER ``retouch.skin`` (the client's
|
|
"never brighten skin" guard), which also removes a trailing autoTone.
|
|
"""
|
|
steps = collapse_global_lifts(steps)
|
|
out: List[Dict[str, Any]] = []
|
|
skin_seen = False
|
|
for step in steps:
|
|
op = step.get("op") if isinstance(step, dict) else None
|
|
if op == "retouch.skin":
|
|
skin_seen = True
|
|
out.append(step)
|
|
continue
|
|
if skin_seen and _is_global_lift(step):
|
|
continue
|
|
out.append(step)
|
|
# Never append a global adjustment after a masked recolour: the recolour is
|
|
# already scoped to the object, so a following whole-image lift would undo
|
|
# the intent and double-process the image.
|
|
recolored = False
|
|
guarded: List[Dict[str, Any]] = []
|
|
for step in out:
|
|
if _is_masked_recolor(step):
|
|
recolored = True
|
|
guarded.append(step)
|
|
continue
|
|
if recolored and (_is_global_lift(step) or _is_whole_image_adjustment(step)):
|
|
continue
|
|
guarded.append(step)
|
|
return guarded
|
|
|
|
|
|
def strengthen(prompt: str, steps: Optional[List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
|
|
"""Promote a timid model plan to the guide recipe for the matched intent.
|
|
|
|
The sampling LLM frequently returns a single mild ``adjustments.apply`` for
|
|
generic intents (make it pretty / pop / professional / film look) and for
|
|
skin/restore, which grade as imperceptible. When the plan is shorter than
|
|
the recipe's ``min_steps`` the deterministic recipe is used instead.
|
|
"""
|
|
steps = list(steps or [])
|
|
route = match(prompt)
|
|
if not route:
|
|
return steps
|
|
recipe = route.get("steps") or []
|
|
try:
|
|
min_steps = int(route.get("min_steps") or len(recipe))
|
|
except (TypeError, ValueError):
|
|
min_steps = len(recipe)
|
|
# The guide is authoritative for a matched intent: keep the model plan only
|
|
# when it already contains every recipe op (with multiplicity) and is at
|
|
# least ``min_steps`` long. Otherwise the model substituted weaker/different
|
|
# ops (e.g. three global adjustments with no photo.autoTone/white-balance for
|
|
# "restore"), so the calibrated recipe is used. The signature distinguishes
|
|
# adjustments by type and filters by id, so a plain brightness-contrast does
|
|
# not masquerade as the recipe's white-balance.
|
|
recipe_ops = Counter(_step_signature(step) for step in recipe)
|
|
plan_ops = Counter(_step_signature(step) for step in steps)
|
|
if len(steps) < min_steps or (plan_ops & recipe_ops) != recipe_ops:
|
|
return [_clone(step) for step in recipe]
|
|
return steps
|
|
|
|
|
|
def _step_signature(step: Any) -> str:
|
|
if not isinstance(step, dict):
|
|
return ""
|
|
op = str(step.get("op") or "")
|
|
params = step.get("params") or {}
|
|
if not isinstance(params, dict):
|
|
params = {}
|
|
if op in ("adjustments.apply", "adjustments.applyMasked"):
|
|
return op + ":" + str(params.get("type") or "")
|
|
if op in ("filters.apply", "filters.applyMasked"):
|
|
return op + ":" + str(params.get("id") or "")
|
|
return op
|
|
|
|
|
|
def guidance() -> str:
|
|
"""The routing/style block injected into every planner system prompt."""
|
|
ops = "; ".join(
|
|
"{op} {params}".format(op=o["op"], params=json.dumps(o["params"]))
|
|
for o in NEW_OPS
|
|
)
|
|
return (
|
|
"Style guide v" + str(STYLE_GUIDE_VERSION) + " (single source of truth; "
|
|
"the browser fallback reads the same rules). New ops with canonical params: "
|
|
+ ops + ". Clarity and vignette are filters.apply ids "
|
|
"(filters.apply {id:'clarity',amount,radius} and {id:'vignette',amount,feather}). "
|
|
"Generic 'make it look good' ('make it pretty', 'make it pop', 'make it look "
|
|
"professional', 'film look', 'instagram ready'): a decisive 3-4 step grade, "
|
|
"never one mild adjustment: photo.autoTone -> adjustments.apply vibrance "
|
|
"{vibrance:20-30} -> filters.apply {id:'clarity'} -> optional filters.apply "
|
|
"{id:'vignette'}. "
|
|
"Portrait / look-better ('fix the skin', 'make me look better', 'fix my "
|
|
"teeth'): do NOT lead with photo.autoTone — a whole-image auto tone washes out "
|
|
"the face. Go straight to selection.face (or selection.subject "
|
|
"{label:'person'}) -> retouch.skin {smooth<=14, opacity<=0.45} -> "
|
|
"selection.deselect (teeth: selection.face -> retouch.teeth). Keep the retouch "
|
|
"gentle and never brighten the face; only add a global photo.autoTone when the "
|
|
"photo is genuinely under- or over-exposed, and NEVER a global brightness lift "
|
|
"AFTER retouch.skin. "
|
|
"'restore this old photo': photo.autoTone -> brightness-contrast "
|
|
"{brightness:0,contrast:~18} -> white-balance/tint -> vibrance {~18} -> "
|
|
"sharpen; recover contrast + colour from the faded baseline and NEVER apply a "
|
|
"large exposure lift. "
|
|
"Low-key / 'fix the lighting' on a dark image: photo.autoTone PLUS at most "
|
|
"ONE adjustments.apply shadows-highlights {shadows:30,highlights:-15} that "
|
|
"protects highlights; NEVER stack a second autoTone/brightness/exposure/"
|
|
"levels/shadows-highlights, and keep highlights <= 0. "
|
|
"Temperature sign: moody/cooler/cinematic/teal -> white-balance temperature "
|
|
"NEGATIVE; warmer/golden/summer/sunset -> POSITIVE. "
|
|
"Background softer / 'blur the background' -> selection.background then "
|
|
"filters.applyMasked {id:'blur-gaussian'}; never a plain global blur. "
|
|
"GENERATIVE CONTENT: Qwen-Image-Edit is an instruction model, so most "
|
|
"generative edits use ai_edit_image mode='instruction' on the WHOLE image "
|
|
"with NO mask and a prompt that NAMES what must stay unchanged (e.g. "
|
|
"'replace the background with a sunny beach, keep the woman exactly the "
|
|
"same'). Use instruction mode for: replacing/changing the background or "
|
|
"scene ('put her on a beach', 'change the background to night'), changing "
|
|
"the season or weather ('make it snowy', 'make it autumn', 'make it rainy', "
|
|
"'add fog'), adding an object or element to an existing photo ('add a full "
|
|
"moon in the sky', 'add wildflowers', 'add sunglasses to her', 'add a "
|
|
"rainbow', 'add clouds'), an explicit 'generative fill' ('generative fill "
|
|
"the sky'), and removing diffuse distractions ('remove the "
|
|
"distractions'). These are CONTENT changes, never adjustments.apply/filters. "
|
|
"Use 8 sampler steps for these generative content edits. Use ai_edit_image "
|
|
"mode='masked' ONLY for an explicit "
|
|
"regional removal of a clearly identified object ('remove the person on "
|
|
"the left') — select that object first so the mask is tight, then inpaint; "
|
|
"if the mask comes back tiny or empty, fall back to instruction mode. "
|
|
"generate_image is ONLY for creating a brand-new image ('create a new "
|
|
"image of ...', 'make a logo') — NEVER use it to add content to an "
|
|
"existing photo, and never place its output as an opaque full layer over "
|
|
"a photo. "
|
|
"Canvas format ('instagram post', '1080x1350', '4:5', 'square', '9:16', '16:9'): "
|
|
"document.crop {x,y,width,height} to the target aspect, then document.resizeCanvas "
|
|
"{width,height,anchor:'center'}; never just export/resize and claim success. To "
|
|
"EXTEND ('extend the canvas to 16:9', 'make it wider'): ai.generativeExpand "
|
|
"{width,height,anchor:'center',prompt,mode:'instruction',feather:true} when an "
|
|
"image provider is configured — pad the canvas, keep the ORIGINAL pixels opaque on "
|
|
"top, inpaint ONLY the new border with a prompt that continues the scene ('extend "
|
|
"the sky and clouds'), and feather the seam; else document.resizeCanvas "
|
|
"{width,height,anchor:'center'} and say honestly that the new border was extended "
|
|
"with edge/transparent pixels. "
|
|
"Text ('add a title TOKYO', 'add a caption', 'with a drop shadow'): text.add "
|
|
"{text,x,y,color,size} plus layers.addStyle {type:'drop-shadow'}; text and layer "
|
|
"styles are NOT generative and must never return the needs-provider message. "
|
|
"'save as PSD' / 'layered PSD': document.savePsd. "
|
|
"Structural ops (use the exact op, never a generic adjustment/transform): "
|
|
"'fill ... with <colour>' -> selection.fill {color} when a selection is named, "
|
|
"else fill.flood {color,x:0,y:0}; 'make the text bigger/smaller' or 'make the "
|
|
"title <colour>' -> text.edit {size,color}; a 'colour overlay' -> layers.addStyle "
|
|
"{type:'color-overlay'}; 'align ... left/centre/right/top/middle/bottom' -> "
|
|
"layers.align {mode}; 'rotate/flip this layer' -> layer.rotate {degrees} / "
|
|
"layer.flip {axis}, but 'rotate/flip the whole image/canvas/photo' -> "
|
|
"document.rotate {degrees} / document.flip {axis}; 'copy the selection' -> "
|
|
"selection.copy, 'paste' -> selection.paste; 'flatten the image' -> "
|
|
"layers.flatten; 'merge the layer down' -> layers.mergeDown. "
|
|
"Targeted recolour ('make only the red car blue', 'recolour the red rock to "
|
|
"teal', 'change the yellow car to red'): select the OBJECT by description with "
|
|
"selection.samText {text:'red car'} (carry fallbackColor {source hex} so the "
|
|
"client falls back to a colour-range selection only if the mask is empty), then "
|
|
"recolour the masked region with adjustments.applyMasked {hue:<source->target "
|
|
"hue delta in degrees>, saturation:0, mode:'hue-saturation'} so shading, "
|
|
"highlights and reflections are preserved. NEVER selection.fill (a flat fill "
|
|
"destroys shading) and NEVER a whole-image adjustments.apply tint. If the "
|
|
"target cannot be reached by a hue rotation (white/black/grey) or no source "
|
|
"colour is named ('make the flowers bright pink'), select the object the same "
|
|
"way and recolour the masked region with ai_edit_image mode='masked'. "
|
|
"Named reference look ('make it look like a Wes Anderson film', 'film noir', "
|
|
"'vintage Kodachrome'): apply the matching look recipe — Wes Anderson = pastel "
|
|
"+ warm highlights + soft contrast; film noir = high-contrast black and white "
|
|
"with deep shadows + a vignette; Kodachrome = warm saturated colour + contrast "
|
|
"+ a light vignette. Strong styles ('cyberpunk', 'Studio Ghibli') use "
|
|
"ai_edit_image mode='instruction' with a prompt that restyles the scene and "
|
|
"keeps the subject; never a generic single adjustment. "
|
|
"A vague prompt with no concrete defect ('clean this up') -> return "
|
|
"{\"reply\": <question>, \"steps\": [], \"ask_user\": {\"question\": <q>, "
|
|
"\"options\": [{\"label\":..,\"description\":..}, ...]}} with 2-3 concrete "
|
|
"options (auto tone / brighten / cool) instead of guessing."
|
|
)
|
|
|
|
|
|
def as_json() -> Dict[str, Any]:
|
|
"""The serialisable guide served to the browser client."""
|
|
return {
|
|
"version": STYLE_GUIDE_VERSION,
|
|
"new_ops": NEW_OPS,
|
|
"intents": [
|
|
{"id": r["id"], "keywords": r["keywords"], "steps": r["steps"],
|
|
"reply": r["reply"]}
|
|
for r in INTENT_ROUTES
|
|
],
|
|
"looks": [
|
|
{"id": lk["id"], "keywords": lk["keywords"],
|
|
"mode": lk.get("mode"), "reply": lk["reply"],
|
|
**({"steps": lk["steps"]} if lk.get("steps") else {}),
|
|
**({"prompt": lk["prompt"]} if lk.get("prompt") else {})}
|
|
for lk in LOOKS
|
|
],
|
|
"ambiguous_options": [dict(o) for o in _AMBIGUOUS_OPTIONS],
|
|
"rules": {
|
|
"low_key": "At most one global lift; prefer shadows-highlights; "
|
|
"never stack; protect highlights.",
|
|
"temperature_sign": "cool/moody/cinematic -> negative; "
|
|
"warm/golden/summer -> positive.",
|
|
"portrait": "No leading photo.autoTone (only if the photo is actually "
|
|
"under/over-exposed): selection.face -> retouch.skin "
|
|
"{smooth<=14, opacity<=0.45} -> selection.deselect; teeth: "
|
|
"selection.face -> retouch.teeth; never brighten the face, "
|
|
"never a global brightness lift AFTER retouch.skin.",
|
|
"generic_look": "make it pretty/pop/professional/film -> decisive 3-4 "
|
|
"steps: photo.autoTone -> vibrance 20-30 -> clarity -> "
|
|
"optional vignette.",
|
|
"restore": "old/faded photo -> photo.autoTone + contrast (brightness 0) "
|
|
"+ white-balance/tint + vibrance + slight sharpen; never a "
|
|
"large exposure lift.",
|
|
"format": "aspect request -> document.crop to the aspect then "
|
|
"document.resizeCanvas; extend -> pad canvas, keep the "
|
|
"original opaque on top, ai.generativeExpand (mode "
|
|
"instruction, feather the seam) inpaints ONLY the new border "
|
|
"when a provider exists else document.resizeCanvas (honest "
|
|
"edge fill).",
|
|
"generative_content": "Most generative edits use ai_edit_image "
|
|
"mode='instruction' on the WHOLE image (no mask, 8 steps) with a "
|
|
"prompt naming what stays unchanged: background/scene replace, "
|
|
"season/weather change (snowy/autumn/rain/fog), adding an "
|
|
"object/element (wildflowers, sunglasses, rainbow, clouds), an "
|
|
"explicit 'generative fill', removing diffuse distractions. "
|
|
"mode='masked' ONLY for explicit regional "
|
|
"removal of a clearly identified object ('remove the person on "
|
|
"the left') — select it first, tight mask, fall back to "
|
|
"instruction if the mask is tiny/empty. generate_image ONLY for "
|
|
"creating a brand-new image; never use it to add content to a "
|
|
"photo or place it as an opaque full layer.",
|
|
"text": "text.add (+ layers.addStyle drop-shadow); never generative, never "
|
|
"the needs-provider message.",
|
|
"psd": "'save as PSD' -> document.savePsd.",
|
|
"structural": "Exact ops, never a generic adjustment: fill -> selection.fill "
|
|
"(selection) or fill.flood {color,x,y}; existing text size/colour -> "
|
|
"text.edit; colour overlay -> layers.addStyle {type:'color-overlay'}; "
|
|
"align -> layers.align {mode}; rotate/flip the active layer -> "
|
|
"layer.rotate/layer.flip, the whole image/canvas -> document.rotate/"
|
|
"document.flip; copy/paste the selection -> selection.copy/paste; "
|
|
"flatten -> layers.flatten; merge down -> layers.mergeDown.",
|
|
"recolor": "targeted recolour -> selection.samText {text:<object>, "
|
|
"fallbackColor:<source hex>} then adjustments.applyMasked "
|
|
"{hue:<source->target hue delta>, saturation:0, "
|
|
"mode:'hue-saturation'} (preserves shading; colour-range fallback "
|
|
"only if the mask is empty); white/black/grey or no source colour "
|
|
"-> ai_edit_image mode:'masked'. Never selection.fill; never a "
|
|
"whole-image adjustments.apply tint.",
|
|
"looks": "named look -> recipe (Wes Anderson pastel/warm/soft; film noir "
|
|
"high-contrast B&W + vignette; Kodachrome warm saturated + vignette) "
|
|
"or ai_edit_image instruction (cyberpunk, Studio Ghibli); never a "
|
|
"generic single adjustment.",
|
|
"ambiguous": "ask_user with 2-3 concrete options.",
|
|
},
|
|
"guidance": guidance(),
|
|
}
|