mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-15 19:32:21 +02:00
Server-side photo editor: natural-language editor agent route
(/api/editor/agent), photo_editor tool family + style guide, image-op
provider proxy with progress, email server hardening, gallery/hwfit
routes, and graceful shutdown.
Source: vendor/photocraft (ad1cff44) editor-server files, merged onto
upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/B_conflicts.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
643 lines
26 KiB
Python
643 lines
26 KiB
Python
"""Unit tests for the shared photo-editor style guide and its server wiring.
|
|
|
|
The guide (``src/editor_style_guide.py``) is the single source of truth for
|
|
intent -> ops routing, calibrated strengths and the low-key / temperature /
|
|
portrait / ambiguity rules. It feeds both the server planner and (via
|
|
``GET /api/editor/style-guide``) the browser fallback.
|
|
"""
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
import routes.editor_agent_routes as eaar
|
|
from src import editor_style_guide as guide
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_planner():
|
|
eaar.set_editor_planner(None)
|
|
yield
|
|
eaar.set_editor_planner(None)
|
|
|
|
|
|
@pytest.fixture()
|
|
def client():
|
|
app = FastAPI()
|
|
app.include_router(eaar.setup_editor_agent_routes())
|
|
return TestClient(app)
|
|
|
|
|
|
def _ops(steps):
|
|
return [s["op"] for s in steps]
|
|
|
|
|
|
def test_new_ops_are_in_the_server_op_catalog():
|
|
# If a new retouch/selection op is missing from OP_CATALOG, _parse_plan
|
|
# filters a valid plan step out and the edit silently disappears.
|
|
for name in guide.NEW_OP_NAMES:
|
|
assert name in eaar.OP_CATALOG
|
|
for name in ("photo.autoTone", "retouch.skin", "retouch.teeth",
|
|
"selection.face", "selection.background"):
|
|
assert name in eaar._OP_SET
|
|
|
|
|
|
def test_portrait_skin_intent_is_a_multi_step_plan():
|
|
plan = guide.fallback_plan("can you fix the skin a bit, look my best")
|
|
assert plan is not None
|
|
# No leading whole-image auto tone: it washes out the face. Go straight to a
|
|
# gentle face retouch, then clear the selection.
|
|
assert _ops(plan["steps"]) == [
|
|
"selection.face", "retouch.skin", "selection.deselect",
|
|
]
|
|
skin = plan["steps"][1]["params"]
|
|
assert 0 < skin["smooth"] <= 14
|
|
assert 0 < skin["opacity"] <= 0.45
|
|
# No global lift anywhere in the portrait chain (client never brightens skin).
|
|
assert not any("autoTone" in op or "brightness" in op or "exposure" in op
|
|
for op in _ops(plan["steps"]))
|
|
|
|
|
|
def test_portrait_intent_does_not_lead_with_auto_tone():
|
|
for prompt in ("make me look better", "fix the skin", "fix my teeth",
|
|
"smooth my complexion", "portrait touch up"):
|
|
plan = guide.fallback_plan(prompt)
|
|
assert plan is not None, prompt
|
|
assert "photo.autoTone" not in _ops(plan["steps"]), prompt
|
|
|
|
|
|
def test_teeth_intent_selects_face_then_whitens():
|
|
plan = guide.fallback_plan("fix my teeth")
|
|
assert _ops(plan["steps"]) == ["selection.face", "retouch.teeth"]
|
|
assert plan["steps"][1]["params"]["desat"] > 0
|
|
|
|
|
|
def test_background_intent_is_selection_then_masked_blur():
|
|
plan = guide.fallback_plan("blur the background / make the background nicer")
|
|
ops = _ops(plan["steps"])
|
|
assert ops == ["selection.background", "filters.applyMasked"]
|
|
assert plan["steps"][1]["params"]["id"] == "blur-gaussian"
|
|
|
|
|
|
def test_temperature_sign_is_cool_negative_warm_positive():
|
|
cool = guide.fallback_plan("make it moodier")
|
|
assert cool["steps"][0]["params"] == {
|
|
"type": "white-balance", "params": {"temperature": -25}}
|
|
warm = guide.fallback_plan("make it warmer, golden summer feel")
|
|
assert warm["steps"][0]["params"]["params"]["temperature"] == 25
|
|
|
|
|
|
def test_lighting_intent_uses_auto_tone_then_single_shadow_lift():
|
|
plan = guide.fallback_plan("fix the lighting, it's too dark")
|
|
assert _ops(plan["steps"]) == ["photo.autoTone", "adjustments.apply"]
|
|
inner = plan["steps"][1]["params"]
|
|
assert inner["type"] == "shadows-highlights"
|
|
assert inner["params"]["highlights"] <= 0
|
|
# The sanctioned low-key pair, never a stack of unspecified lifts.
|
|
assert len(plan["steps"]) == 2
|
|
|
|
|
|
def test_ambiguous_prompt_asks_the_user_with_concrete_options():
|
|
plan = guide.fallback_plan("clean this up")
|
|
assert plan["steps"] == []
|
|
ask = plan["ask_user"]
|
|
assert ask["question"]
|
|
assert 2 <= len(ask["options"]) <= 3
|
|
assert all(o.get("label") for o in ask["options"])
|
|
assert guide.is_ambiguous("clean this up") is True
|
|
# A concrete defect is not ambiguous.
|
|
assert guide.is_ambiguous("fix the lighting") is False
|
|
|
|
|
|
def test_collapse_global_lifts_replaces_a_stack():
|
|
# With an auto tone present, extra brightness/exposure lifts are dropped
|
|
# (the auto tone is the single sanctioned lift).
|
|
with_auto = [
|
|
{"op": "photo.autoTone", "params": {}},
|
|
{"op": "adjustments.apply",
|
|
"params": {"type": "brightness-contrast", "params": {"brightness": 30}}},
|
|
{"op": "adjustments.apply",
|
|
"params": {"type": "exposure", "params": {"exposure": 20}}},
|
|
]
|
|
out = guide.collapse_global_lifts(with_auto)
|
|
assert len(out) == 1
|
|
assert out[0]["op"] == "photo.autoTone"
|
|
# Without an auto tone, a stack collapses to one safe shadows-highlights.
|
|
no_auto = [
|
|
{"op": "adjustments.apply",
|
|
"params": {"type": "brightness-contrast", "params": {"brightness": 30}}},
|
|
{"op": "adjustments.apply",
|
|
"params": {"type": "exposure", "params": {"exposure": 20}}},
|
|
]
|
|
out2 = guide.collapse_global_lifts(no_auto)
|
|
assert len(out2) == 1
|
|
assert out2[0]["params"]["type"] == "shadows-highlights"
|
|
assert out2[0]["params"]["params"]["highlights"] <= 0
|
|
# The sanctioned low-key pair (auto tone + one shadows-highlights) is kept.
|
|
pair = [
|
|
{"op": "photo.autoTone", "params": {}},
|
|
{"op": "adjustments.apply",
|
|
"params": {"type": "shadows-highlights",
|
|
"params": {"shadows": 30, "highlights": -15}}},
|
|
]
|
|
assert guide.collapse_global_lifts(pair) == pair
|
|
|
|
|
|
def test_collapse_global_lifts_clamps_a_single_brightness_lift():
|
|
steps = [{"op": "adjustments.apply",
|
|
"params": {"type": "brightness-contrast", "params": {"brightness": 80}}}]
|
|
out = guide.collapse_global_lifts(steps)
|
|
assert out[0]["params"]["params"]["brightness"] <= 25
|
|
|
|
|
|
def test_llm_plan_parse_collapses_lifts_and_keeps_new_ops():
|
|
raw = (
|
|
'{"reply": "x", "steps": ['
|
|
'{"op": "retouch.skin", "params": {"smooth": 20}},'
|
|
'{"op": "photo.autoTone", "params": {}},'
|
|
'{"op": "adjustments.apply", "params": {"type": "exposure", "params": {"exposure": 25}}}'
|
|
']}'
|
|
)
|
|
parsed = eaar._parse_plan(raw)
|
|
ops = _ops(parsed["steps"])
|
|
assert "retouch.skin" in ops
|
|
# Every global lift that trails retouch.skin is dropped (the client guard
|
|
# "never brighten skin"), so nothing but the retouch step remains.
|
|
assert ops == ["retouch.skin"]
|
|
|
|
|
|
def test_editor_agent_returns_ask_user_for_ambiguous_prompt(client):
|
|
eaar.set_editor_planner(lambda messages, owner: '{"reply": "ok", "steps": []}')
|
|
resp = client.post("/api/editor/agent", json={"prompt": "clean this up"})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body.get("ask_user_pending") is True
|
|
assert body["steps"] == []
|
|
assert 2 <= len(body["ask_user"]["options"]) <= 3
|
|
|
|
|
|
def test_editor_plan_returns_ask_user_for_ambiguous_prompt(client):
|
|
eaar.set_editor_planner(lambda messages, owner: None)
|
|
resp = client.post("/api/editor/plan", json={"prompt": "clean this up"})
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["steps"] == []
|
|
assert body.get("ask_user")
|
|
|
|
|
|
def test_style_guide_endpoint_serves_the_shared_source(client):
|
|
resp = client.get("/api/editor/style-guide")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["version"] == guide.STYLE_GUIDE_VERSION
|
|
assert {o["op"] for o in data["new_ops"]} == set(guide.NEW_OP_NAMES)
|
|
assert data["intents"]
|
|
assert "temperature" in data["rules"]["temperature_sign"].lower() or \
|
|
"negative" in data["rules"]["temperature_sign"]
|
|
# The prompt guidance states the sign rule explicitly.
|
|
assert "NEGATIVE" in data["guidance"] and "POSITIVE" in data["guidance"]
|
|
|
|
|
|
def test_prompts_embed_the_style_guidance():
|
|
assert guide.guidance() in eaar._SYSTEM_PROMPT
|
|
assert guide.guidance() in eaar._CHECK_SYSTEM_PROMPT
|
|
|
|
|
|
@pytest.mark.parametrize("prompt", [
|
|
"make it pretty", "make it pop", "professional", "film look",
|
|
])
|
|
def test_generic_look_intent_is_a_decisive_multi_step_plan(prompt):
|
|
plan = guide.fallback_plan(prompt)
|
|
assert plan is not None
|
|
ops = _ops(plan["steps"])
|
|
assert ops[0] == "photo.autoTone"
|
|
assert len(ops) >= 3
|
|
# A visible vibrance lift and a local-contrast filter are present.
|
|
assert any(s["op"] == "adjustments.apply" and s["params"].get("type") == "vibrance"
|
|
for s in plan["steps"])
|
|
assert any(s["op"] == "filters.apply" and s["params"].get("id") == "clarity"
|
|
for s in plan["steps"])
|
|
|
|
|
|
def test_restore_intent_never_large_exposure_lift():
|
|
plan = guide.fallback_plan("restore this old photo")
|
|
assert plan is not None
|
|
ops = _ops(plan["steps"])
|
|
assert ops[0] == "photo.autoTone"
|
|
assert "filters.apply" in ops # sharpen
|
|
# Colour recovery present.
|
|
assert any(s["op"] == "adjustments.apply" and s["params"].get("type") == "white-balance"
|
|
for s in plan["steps"])
|
|
# No exposure lift at all, and the contrast lift keeps brightness neutral.
|
|
assert not any(s["params"].get("type") == "exposure" for s in plan["steps"])
|
|
for s in plan["steps"]:
|
|
if s["params"].get("type") == "brightness-contrast":
|
|
assert s["params"]["params"].get("brightness", 0) == 0
|
|
|
|
|
|
def test_strengthen_promotes_a_timid_plan_to_the_recipe():
|
|
timid = [{"op": "adjustments.apply",
|
|
"params": {"type": "brightness-contrast", "params": {"brightness": 8}}}]
|
|
promoted = guide.strengthen("make it pop", timid)
|
|
assert promoted != timid
|
|
assert _ops(promoted)[0] == "photo.autoTone"
|
|
assert len(promoted) >= 3
|
|
# A prompt with no recipe leaves the plan untouched.
|
|
assert guide.strengthen("nonsense prompt zzz", timid) == timid
|
|
|
|
|
|
def test_enforce_guards_removes_trailing_lift_after_skin():
|
|
steps = [
|
|
{"op": "selection.face", "params": {}},
|
|
{"op": "retouch.skin", "params": {"smooth": 24}},
|
|
{"op": "photo.autoTone", "params": {}},
|
|
]
|
|
guarded = guide.enforce_guards(steps)
|
|
assert _ops(guarded) == ["selection.face", "retouch.skin"]
|
|
stacked = [
|
|
{"op": "adjustments.apply",
|
|
"params": {"type": "brightness-contrast", "params": {"brightness": 20}}},
|
|
{"op": "adjustments.apply",
|
|
"params": {"type": "exposure", "params": {"exposure": 20}}},
|
|
]
|
|
collapsed = guide.enforce_guards(stacked)
|
|
assert len(collapsed) == 1
|
|
assert collapsed[0]["params"]["type"] == "shadows-highlights"
|
|
|
|
|
|
def test_plan_route_follows_the_recipe_for_generic_look(client):
|
|
import json as _json
|
|
eaar.set_editor_planner(lambda messages, owner: _json.dumps({
|
|
"reply": "ok",
|
|
"steps": [{"op": "adjustments.apply",
|
|
"params": {"type": "brightness-contrast", "params": {"brightness": 8}}}],
|
|
}))
|
|
resp = client.post("/api/editor/plan", json={"prompt": "make it pop"})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["steps"][0]["op"] == "photo.autoTone"
|
|
assert len(data["steps"]) >= 3
|
|
assert data["planner"].endswith("+guide")
|
|
|
|
|
|
def test_guidance_documents_look_and_restore_recipes():
|
|
text = guide.guidance()
|
|
low = text.lower()
|
|
assert "clarity" in low and "vignette" in low
|
|
assert "vibrance" in low
|
|
assert "restore" in low and "exposure" in low
|
|
assert "autoTone" in text
|
|
|
|
|
|
def test_plan_special_format_crops_and_resizes():
|
|
special = eaar._plan_special(
|
|
"make a 1080x1350 Instagram post from this", {"width": 900, "height": 600}, False)
|
|
assert special is not None
|
|
steps, reply, note = special
|
|
assert _ops(steps) == ["document.crop", "document.resizeCanvas"]
|
|
assert "1080" in reply and "1350" in reply
|
|
assert set(steps[0]["params"]) >= {"x", "y", "width", "height"}
|
|
assert steps[1]["params"]["width"] == 1080 and steps[1]["params"]["height"] == 1350
|
|
|
|
|
|
def test_plan_special_text_add_with_shadow_is_not_generative():
|
|
assert eaar._generative_kind("add a title TOKYO with a drop shadow") is None
|
|
special = eaar._plan_special("add a title TOKYO with a drop shadow", {}, False)
|
|
assert special is not None
|
|
steps, reply, note = special
|
|
assert _ops(steps) == ["text.add", "layers.addStyle"]
|
|
assert steps[0]["params"]["text"] == "TOKYO"
|
|
assert steps[1]["params"]["type"] == "drop-shadow"
|
|
assert "drop shadow" in reply.lower()
|
|
|
|
|
|
def test_plan_special_extend_resizes_without_provider_generative_with():
|
|
steps, reply, note = eaar._plan_special(
|
|
"extend the canvas to 16:9", {"width": 900, "height": 600}, False)
|
|
assert _ops(steps) == ["document.resizeCanvas"]
|
|
assert "provider" in note.lower()
|
|
steps2, _, note2 = eaar._plan_special(
|
|
"extend the canvas to 16:9", {"width": 900, "height": 600}, True)
|
|
assert _ops(steps2) == ["ai.generativeExpand"]
|
|
assert note2 == ""
|
|
|
|
|
|
def test_plan_special_psd_export():
|
|
special = eaar._plan_special("save as PSD with layers", {}, False)
|
|
assert special is not None and _ops(special[0]) == ["document.savePsd"]
|
|
|
|
|
|
def test_background_replacement_is_generative_not_blur():
|
|
assert eaar._generative_kind("put her on a beach") == "replace"
|
|
assert eaar._generative_kind("remove the background and put her on a beach") == "replace"
|
|
|
|
|
|
def test_verified_reply_corrects_a_false_size_claim():
|
|
corrected = eaar._verified_reply(
|
|
"make a 1080x1350 Instagram post",
|
|
[{"op": "history.mark", "params": {}}],
|
|
[{"ok": True}],
|
|
{"width": 600, "height": 600},
|
|
"Exported your 900x600 as a 1080x1350 Instagram post.",
|
|
)
|
|
assert "600" in corrected
|
|
assert "still" in corrected.lower() or "didn't apply" in corrected.lower()
|
|
passed = eaar._verified_reply(
|
|
"make a 1080x1350 Instagram post",
|
|
[{"op": "document.crop", "params": {}}, {"op": "document.resizeCanvas", "params": {}}],
|
|
[{"ok": True}, {"ok": True}],
|
|
{"width": 1080, "height": 1350},
|
|
"Setting the canvas to 1080\u00d71350.",
|
|
)
|
|
assert "1080" in passed and "1350" in passed
|
|
|
|
|
|
def test_plan_endpoint_returns_deterministic_format_steps(client, monkeypatch):
|
|
eaar.set_editor_planner(lambda messages, owner: {"reply": "Exporting.", "steps": []})
|
|
monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: False)
|
|
resp = client.post("/api/editor/plan", json={
|
|
"prompt": "make a 1080x1350 Instagram post",
|
|
"state": {"width": 900, "height": 600},
|
|
})
|
|
data = resp.json()
|
|
assert data["planner"] == "deterministic"
|
|
assert _ops(data["steps"])[:2] == ["document.crop", "document.resizeCanvas"]
|
|
|
|
|
|
def test_style_guide_serves_format_text_psd_rules():
|
|
rules = guide.as_json()["rules"]
|
|
assert "format" in rules and "text" in rules and "psd" in rules
|
|
assert guide.STYLE_GUIDE_VERSION >= 3
|
|
|
|
|
|
# ── Quick Edit coverage: structural ops (server planner gaps) ──────────
|
|
# Every prompt that failed the live Quick Edit coverage audit because the
|
|
# server planner emitted a generic adjustments/transform step instead of the
|
|
# exact structural op the browser supports.
|
|
_STRUCTURAL_PROMPTS = [
|
|
("fill the white area with red", "fill.flood"), # 1
|
|
("flood fill the sky with blue", "fill.flood"), # 2
|
|
("make the text bigger", "text.edit"), # 3
|
|
("make the title red", "text.edit"), # 4
|
|
("add a colour overlay", "layers.addStyle"), # 8
|
|
("align the logo to the centre", "layers.align"), # 9
|
|
("align the layer to the left", "layers.align"), # 10
|
|
("rotate only this layer 90 degrees", "layer.rotate"), # 11
|
|
("flip this layer horizontally", "layer.flip"), # 12
|
|
("rotate the whole image 90 degrees", "document.rotate"), # 13
|
|
("flip the canvas vertically", "document.flip"), # 14
|
|
("copy the selection", "selection.copy"), # 19
|
|
("paste", "selection.paste"), # 20
|
|
("flatten the image", "layers.flatten"), # 40
|
|
("merge the layer down", "layers.mergeDown"), # 41
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("prompt,expected_op", _STRUCTURAL_PROMPTS)
|
|
def test_structural_plan_routes_to_the_exact_op(prompt, expected_op):
|
|
plan = guide.structural_plan(prompt)
|
|
assert plan is not None, prompt
|
|
assert plan["planner"] == "deterministic"
|
|
assert expected_op in _ops(plan["steps"]), (prompt, _ops(plan["steps"]))
|
|
|
|
|
|
@pytest.mark.parametrize("prompt,expected_op", _STRUCTURAL_PROMPTS)
|
|
def test_plan_special_emits_structural_ops(prompt, expected_op):
|
|
# The server override path (_plan_special) must return the structural step,
|
|
# otherwise a wrong model plan suppresses the browser fallback.
|
|
special = eaar._plan_special(prompt, {"width": 900, "height": 600}, True)
|
|
assert special is not None, prompt
|
|
steps, reply, _ = special
|
|
assert expected_op in _ops(steps), (prompt, _ops(steps))
|
|
assert reply
|
|
|
|
|
|
def test_structural_ops_are_in_the_server_op_catalog():
|
|
for name in ("fill.flood", "selection.fill", "text.edit", "layers.align",
|
|
"layer.rotate", "layer.flip", "document.rotate", "document.flip",
|
|
"selection.copy", "selection.paste", "layers.flatten",
|
|
"layers.mergeDown"):
|
|
assert name in eaar._OP_SET, name
|
|
|
|
|
|
def test_structural_plan_ignores_non_structural_intents():
|
|
for prompt in ("make it brighter", "blur the background", "make it punchy",
|
|
"restore this old photo", "smooth the skin", "sharpen it",
|
|
"add the title \"Summer\"", "duplicate this layer"):
|
|
assert guide.structural_plan(prompt) is None, prompt
|
|
|
|
|
|
def test_structural_params_match_the_client():
|
|
# Canonical params must match static/js/editor/galleryEditor.js control ops.
|
|
fill = guide.structural_plan("fill the white area with red")["steps"][0]
|
|
assert fill["op"] == "fill.flood" and fill["params"]["color"] == "#ff0000"
|
|
sel = guide.structural_plan("fill the selection with blue")["steps"][0]
|
|
assert sel["op"] == "selection.fill" and sel["params"]["color"] == "#0000ff"
|
|
align = guide.structural_plan("align the layer to the left")["steps"][0]
|
|
assert align["params"]["mode"] == "left"
|
|
rot = guide.structural_plan("rotate only this layer 90 degrees")["steps"][0]
|
|
assert rot["params"]["degrees"] == 90
|
|
flip = guide.structural_plan("flip the canvas vertically")["steps"][0]
|
|
assert flip["op"] == "document.flip" and flip["params"]["axis"] == "v"
|
|
overlay = guide.structural_plan("add a colour overlay")["steps"][0]
|
|
assert overlay["params"]["type"] == "color-overlay"
|
|
edit = guide.structural_plan("make the text bigger")["steps"][0]
|
|
assert edit["op"] == "text.edit" and edit["params"]["size"] > 0
|
|
|
|
|
|
def test_plan_route_returns_structural_steps(client):
|
|
resp = client.post("/api/editor/plan", json={
|
|
"prompt": "flatten the image",
|
|
"state": {"width": 900, "height": 600},
|
|
})
|
|
data = resp.json()
|
|
assert data["planner"] == "deterministic"
|
|
assert "layers.flatten" in _ops(data["steps"])
|
|
|
|
|
|
def test_style_guide_serves_structural_rule():
|
|
rules = guide.as_json()["rules"]
|
|
assert "structural" in rules
|
|
assert guide.STYLE_GUIDE_VERSION >= 5
|
|
|
|
|
|
# ── Targeted recolour + named reference looks ──────────────────────────
|
|
|
|
_RECOLOR_CASES = [
|
|
("make only the red car blue", "red"),
|
|
("recolour the red rock to teal", "red"),
|
|
("change the yellow car to red", "yellow"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("prompt,source", _RECOLOR_CASES)
|
|
def test_targeted_recolor_selects_object_then_masked_hue(prompt, source):
|
|
plan = guide.recolor_plan(prompt)
|
|
assert plan is not None
|
|
assert _ops(plan["steps"]) == ["selection.samText", "adjustments.applyMasked"]
|
|
sel, adj = plan["steps"]
|
|
assert sel["params"]["text"] # object description for the mask
|
|
assert sel["params"]["fallbackColor"] == guide.color_for_name(source)
|
|
assert adj["params"]["mode"] == "hue-saturation"
|
|
assert "hue" in adj["params"] and isinstance(adj["params"]["hue"], int)
|
|
|
|
|
|
def test_recolor_never_emits_selection_fill_or_a_global_tint():
|
|
for prompt, _ in _RECOLOR_CASES:
|
|
ops = _ops(guide.recolor_plan(prompt)["steps"])
|
|
# A flat fill destroys shading; a whole-image tint is not targeted.
|
|
assert "selection.fill" not in ops
|
|
assert "adjustments.apply" not in ops # exact op, not applyMasked
|
|
|
|
|
|
def test_targeted_recolor_hue_delta_is_source_to_target():
|
|
# red (hue 0) -> blue (hue 240): shortest rotation is -120 degrees.
|
|
plan = guide.recolor_plan("make only the red car blue")
|
|
assert plan["steps"][1]["params"]["hue"] == -120
|
|
|
|
|
|
def test_recolor_masked_hue_carries_source_hue():
|
|
# red has hue 0; the client uses sourceHue as the rotation center so
|
|
# neighbouring colours (chrome, windows, highlights) keep their own hue.
|
|
plan = guide.recolor_plan("make only the red car blue")
|
|
assert plan["steps"][1]["params"]["sourceHue"] == 0
|
|
|
|
|
|
def test_enforce_guards_drops_global_lift_after_masked_recolor():
|
|
# A recolour keeps shading; a later whole-image tone lift would tint the
|
|
# rest of the photo, so enforce_guards must drop global lifts after it.
|
|
steps = [
|
|
{"op": "selection.samText", "params": {"text": "red car"}},
|
|
{"op": "adjustments.applyMasked",
|
|
"params": {"mode": "hue-saturation", "hue": -120, "sourceHue": 0}},
|
|
{"op": "adjustments.apply",
|
|
"params": {"type": "brightness-contrast", "params": {"brightness": 20}}},
|
|
{"op": "photo.autoTone", "params": {}},
|
|
]
|
|
assert _ops(guide.enforce_guards(steps)) == ["selection.samText", "adjustments.applyMasked"]
|
|
|
|
|
|
def test_enforce_guards_drops_whole_image_grade_after_masked_recolor():
|
|
# Not just tone "lifts": ANY whole-image adjustment (vibrance/saturation/
|
|
# autoTone) after a masked recolour re-grades the photo and undoes the
|
|
# "only this object" intent, so enforce_guards must drop them all.
|
|
steps = [
|
|
{"op": "selection.samText", "params": {"text": "red car"}},
|
|
{"op": "adjustments.applyMasked",
|
|
"params": {"mode": "hue-saturation", "hue": -120, "sourceHue": 0}},
|
|
{"op": "adjustments.apply",
|
|
"params": {"type": "vibrance", "params": {"vibrance": 20}}},
|
|
{"op": "adjustments.apply",
|
|
"params": {"type": "hue-saturation", "params": {"saturation": 12}}},
|
|
{"op": "photo.autoTone", "params": {}},
|
|
]
|
|
assert _ops(guide.enforce_guards(steps)) == ["selection.samText", "adjustments.applyMasked"]
|
|
|
|
|
|
def test_enforce_guards_keeps_grades_when_there_is_no_recolor():
|
|
# A recipe/look (no masked recolour) still keeps its whole-image grades.
|
|
steps = [
|
|
{"op": "photo.autoTone", "params": {}},
|
|
{"op": "adjustments.apply", "params": {"type": "vibrance", "params": {"vibrance": 18}}},
|
|
{"op": "adjustments.apply",
|
|
"params": {"type": "brightness-contrast", "params": {"brightness": 6, "contrast": -10}}},
|
|
]
|
|
assert _ops(guide.enforce_guards(steps)) == ["photo.autoTone", "adjustments.apply"]
|
|
|
|
|
|
def test_recolor_to_white_uses_the_image_model_not_a_hue_shift():
|
|
# A white/black/grey target cannot be reached by a hue rotation.
|
|
plan = guide.recolor_plan("make the red car white")
|
|
assert plan is not None
|
|
assert _ops(plan["steps"]) == ["selection.samText", "ai_edit_image"]
|
|
assert plan["steps"][1]["params"]["mode"] == "masked"
|
|
|
|
|
|
def test_recolor_with_single_colour_uses_masked_image_model():
|
|
plan = guide.recolor_plan("make the flowers bright pink")
|
|
assert plan is not None
|
|
assert _ops(plan["steps"]) == ["selection.samText", "ai_edit_image"]
|
|
assert plan["steps"][0]["params"]["text"] == "flowers"
|
|
params = plan["steps"][1]["params"]
|
|
assert params["mode"] == "masked"
|
|
assert params["steps"] == 8
|
|
|
|
|
|
def test_recolor_does_not_hijack_unrelated_colour_prompts():
|
|
for prompt in ("make it blue", "fill the white area with red",
|
|
"make the title red", "make this black and white",
|
|
"blur the background"):
|
|
assert guide.recolor_plan(prompt) is None
|
|
|
|
|
|
@pytest.mark.parametrize("prompt", [
|
|
"make it look like a Wes Anderson film",
|
|
"make it look like a film noir",
|
|
"make it look like vintage Kodachrome",
|
|
])
|
|
def test_named_recipe_looks_are_adjustment_stacks(prompt):
|
|
plan = guide.look_plan(prompt)
|
|
assert plan is not None
|
|
ops = _ops(plan["steps"])
|
|
assert ops and "ai_edit_image" not in ops
|
|
|
|
|
|
@pytest.mark.parametrize("prompt", [
|
|
"make it look like cyberpunk",
|
|
"make it look like a Studio Ghibli film",
|
|
])
|
|
def test_strong_named_looks_use_instruction_mode(prompt):
|
|
plan = guide.look_plan(prompt)
|
|
assert plan is not None
|
|
assert _ops(plan["steps"]) == ["ai_edit_image"]
|
|
assert plan["steps"][0]["params"]["mode"] == "instruction"
|
|
assert plan["steps"][0]["params"]["steps"] == 8
|
|
|
|
|
|
def test_structural_plan_dispatches_looks_and_recolor():
|
|
wes = guide.structural_plan("make it look like a Wes Anderson film")
|
|
assert "adjustments.apply" in _ops(wes["steps"])
|
|
recolor = guide.structural_plan("make only the red car blue")
|
|
assert _ops(recolor["steps"]) == ["selection.samText", "adjustments.applyMasked"]
|
|
|
|
|
|
def test_named_looks_do_not_hijack_generative_lookalikes():
|
|
for prompt in ("make it look snowy", "make it look like summer"):
|
|
assert guide.look_plan(prompt) is None
|
|
|
|
|
|
def test_plan_route_returns_targeted_recolor(client):
|
|
resp = client.post("/api/editor/plan", json={
|
|
"prompt": "make only the red car blue",
|
|
"state": {"width": 900, "height": 600},
|
|
})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["planner"] == "deterministic"
|
|
assert _ops(data["steps"]) == ["selection.samText", "adjustments.applyMasked"]
|
|
assert "selection.fill" not in _ops(data["steps"])
|
|
|
|
|
|
def test_plan_route_returns_named_look(client):
|
|
resp = client.post("/api/editor/plan", json={
|
|
"prompt": "make it look like a Wes Anderson film",
|
|
"state": {"width": 900, "height": 600},
|
|
})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["planner"] == "deterministic"
|
|
assert "adjustments.apply" in _ops(data["steps"])
|
|
|
|
|
|
def test_style_guide_serves_recolor_and_looks_rules():
|
|
body = guide.as_json()
|
|
assert "recolor" in body["rules"]
|
|
assert "looks" in body["rules"]
|
|
assert {lk["id"] for lk in body["looks"]} >= {
|
|
"wes_anderson", "film_noir", "cyberpunk", "vintage_kodachrome",
|
|
"studio_ghibli",
|
|
}
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
raise SystemExit(pytest.main([__file__, "-q"]))
|