mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-05 04:32:21 +01:00
feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).
Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c74f475679
commit
5fac693c1c
@@ -0,0 +1,123 @@
|
||||
import json
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MODULE = (ROOT / "static/js/editor/look-recipes.js").as_uri()
|
||||
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _recipe_name(prompt: str):
|
||||
source = textwrap.dedent(
|
||||
f"""
|
||||
import {{ lookRecipeSteps }} from {json.dumps(MODULE)};
|
||||
const r = lookRecipeSteps({json.dumps(prompt)});
|
||||
process.stdout.write(JSON.stringify(r ? {{ name: r.name, ops: r.steps.map(s => s.op) }} : null));
|
||||
"""
|
||||
)
|
||||
out = subprocess.run(
|
||||
["node", "--input-type=module", "-e", source],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def _recipe_steps(prompt: str, strength=None):
|
||||
opts = "" if strength is None else f", {{ strength: {strength} }}"
|
||||
source = textwrap.dedent(
|
||||
f"""
|
||||
import {{ lookRecipeSteps }} from {json.dumps(MODULE)};
|
||||
const r = lookRecipeSteps({json.dumps(prompt)}{opts});
|
||||
process.stdout.write(JSON.stringify(r ? r.steps : null));
|
||||
"""
|
||||
)
|
||||
out = subprocess.run(
|
||||
["node", "--input-type=module", "-e", source],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def test_named_looks_map_to_deterministic_recipes():
|
||||
for prompt in [
|
||||
"make it look like a Wes Anderson film",
|
||||
"make it look like a National Geographic photo",
|
||||
"make it look like a Kodachrome photo",
|
||||
"make it look like an Ansel Adams black and white landscape",
|
||||
"make it look like a 1970s film photograph",
|
||||
"make it look like a vintage Polaroid",
|
||||
"make it look like a film noir",
|
||||
"give it a cinematic teal and orange grade",
|
||||
"make it look like a golden hour portrait",
|
||||
"make it a moody Scandinavian scene",
|
||||
]:
|
||||
recipe = _recipe_name(prompt)
|
||||
assert recipe is not None, prompt
|
||||
# Every step must be one of the supported deterministic control ops.
|
||||
assert all(op in {"photo.autoTone", "adjustments.apply", "filters.apply"} for op in recipe["ops"]), prompt
|
||||
assert any(op == "adjustments.apply" for op in recipe["ops"]), prompt
|
||||
|
||||
|
||||
def test_content_looks_route_to_the_model_not_a_recipe():
|
||||
for prompt in [
|
||||
"give it a Blade Runner neon night look",
|
||||
"make it a neon-lit city street",
|
||||
"give it a Studio Ghibli animated look",
|
||||
"make it look like an anime",
|
||||
"turn it into an oil painting",
|
||||
"make it look like a watercolour",
|
||||
]:
|
||||
assert _recipe_name(prompt) is None, prompt
|
||||
|
||||
|
||||
def test_strength_scales_the_recipe():
|
||||
base = _recipe_steps("make it look like a Kodachrome photo")
|
||||
assert base is not None
|
||||
strong = _recipe_steps("make it look like a Kodachrome photo", strength=2)
|
||||
assert strong is not None
|
||||
# The vibrance-style adjustment must scale up with strength.
|
||||
def _max_numeric(steps):
|
||||
best = 0.0
|
||||
for s in steps:
|
||||
if s.get("op") != "adjustments.apply":
|
||||
continue
|
||||
for v in (s.get("params", {}).get("params", {}) or {}).values():
|
||||
if isinstance(v, (int, float)):
|
||||
best = max(best, abs(float(v)))
|
||||
return best
|
||||
assert _max_numeric(strong) > _max_numeric(base)
|
||||
assert _max_numeric(strong) >= 2 * _max_numeric(base) - 1
|
||||
|
||||
|
||||
def test_non_look_prompts_are_not_intercepted():
|
||||
for prompt in [
|
||||
"rotate 90",
|
||||
"brighten the photo",
|
||||
"make it warmer",
|
||||
"remove the background",
|
||||
"add a man to the scene",
|
||||
"make it look snowy",
|
||||
"make it a square",
|
||||
]:
|
||||
assert _recipe_name(prompt) is None, prompt
|
||||
|
||||
|
||||
def test_general_look_deriver_handles_unknown_named_looks():
|
||||
recipe = _recipe_name("give it a warm dreamy aesthetic")
|
||||
assert recipe is not None
|
||||
ops = recipe["ops"]
|
||||
assert "photo.autoTone" in ops
|
||||
assert "adjustments.apply" in ops
|
||||
|
||||
|
||||
def test_editor_wires_the_look_recipe_branch():
|
||||
assert "import { lookRecipeSteps } from './editor/look-recipes.js';" in EDITOR
|
||||
assert "const _lookRec = lookRecipeSteps(prompt, { strength: _lookStrength() });" in EDITOR
|
||||
assert "for (const step of _lookRec.steps) {" in EDITOR
|
||||
# Content-change looks route to the model (creative repaint), not a grade.
|
||||
assert r"neon|blade\\s*runner|bloom|holograph" in EDITOR
|
||||
ai_settings = (ROOT / "static/js/editor/ai-settings.js").read_text(encoding="utf-8")
|
||||
assert 'id="ge-look-strength"' in ai_settings
|
||||
assert "localStorage.setItem('ge-look-strength'" in ai_settings
|
||||
Reference in New Issue
Block a user