mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 20:02:20 +02: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,203 @@
|
||||
import json
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MODULE = (ROOT / 'static/js/editor/editor-agent.js').as_uri()
|
||||
|
||||
|
||||
def run_node(body):
|
||||
script = textwrap.dedent(f"""
|
||||
import {{ createEditorAgent, EDITOR_AGENT_VERSION, DEFAULT_AGENT_ENDPOINT }} from {json.dumps(MODULE)};
|
||||
{body}
|
||||
""")
|
||||
result = subprocess.run(
|
||||
['node', '--input-type=module', '-e', script],
|
||||
cwd=ROOT, text=True, capture_output=True, check=True,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_agent_run_posts_payload_streams_steps_and_collapses_history():
|
||||
out = run_node("""
|
||||
const calls = [];
|
||||
const steps = [];
|
||||
const posts = [];
|
||||
const control = { invoke(op, params) {
|
||||
calls.push(op);
|
||||
if (op === 'document.info') return { ok: true, result: { width: 800, height: 600, activeLayerId: 'layer-1' } };
|
||||
if (op === 'layers.list') return { ok: true, result: [{ id: 'layer-1', name: 'Bg' }] };
|
||||
if (op === 'screenshot') return { ok: true, result: { dataUrl: 'data:image/png;base64,AA' } };
|
||||
if (op === 'history.mark') return { ok: true, result: 5 };
|
||||
if (op === 'history.collapse') return { ok: true, result: 1 };
|
||||
return { ok: true, result: null };
|
||||
} };
|
||||
const fetchImpl = async (url, opts) => {
|
||||
posts.push({ url, body: JSON.parse(opts.body) });
|
||||
return { ok: true, status: 200, json: async () => ({
|
||||
ok: true, reply: 'done',
|
||||
tools_offered: ['plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image', 'generate_image'],
|
||||
steps: [{ op: 'adjustments.apply', params: { type: 'brightness-contrast' } }],
|
||||
}) };
|
||||
};
|
||||
const agent = createEditorAgent({ control, endpoint: '/api/editor/agent', fetchImpl, getSession: () => 'sess-1' });
|
||||
const p = agent.run('brighten the face', { onStep: (e) => steps.push(e.op + ':' + e.ok) });
|
||||
agent.reportStep({ ok: true, ms: 12 }, { op: 'selection.face' });
|
||||
const out = await p;
|
||||
console.log(JSON.stringify({ posts, calls, steps, out, version: EDITOR_AGENT_VERSION, def: DEFAULT_AGENT_ENDPOINT }));
|
||||
""")
|
||||
body = out['posts'][0]['body']
|
||||
assert out['posts'][0]['url'] == '/api/editor/agent'
|
||||
assert body['prompt'] == 'brighten the face'
|
||||
assert body['session'] == 'sess-1' and body['editor_session'] == 'sess-1'
|
||||
assert body['state']['width'] == 800 and len(body['state']['layers']) == 1
|
||||
assert body['screenshot'].startswith('data:image/png')
|
||||
assert 'history.mark' in out['calls'] and 'history.collapse' in out['calls']
|
||||
assert out['steps'] == ['selection.face:true']
|
||||
assert out['out']['ok'] is True
|
||||
assert 'ai_edit_image' in out['out']['toolsOffered']
|
||||
assert out['out']['trace'][0]['op'] == 'selection.face'
|
||||
assert out['version'] == '1.0'
|
||||
assert out['def'] == '/api/editor/agent'
|
||||
|
||||
|
||||
def test_agent_run_includes_the_selected_edit_model_in_the_payload():
|
||||
out = run_node("""
|
||||
const posts = [];
|
||||
const control = { invoke(op) {
|
||||
if (op === 'document.info') return { ok: true, result: { width: 800, height: 600, activeLayerId: 'layer-1' } };
|
||||
if (op === 'layers.list') return { ok: true, result: [{ id: 'layer-1', name: 'Bg' }] };
|
||||
if (op === 'screenshot') return { ok: true, result: { dataUrl: 'data:image/png;base64,AA' } };
|
||||
if (op === 'history.mark') return { ok: true, result: 5 };
|
||||
if (op === 'history.collapse') return { ok: true, result: 1 };
|
||||
return { ok: true, result: null };
|
||||
} };
|
||||
const fetchImpl = async (url, opts) => {
|
||||
posts.push({ url, body: JSON.parse(opts.body) });
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true, reply: 'done', steps: [] }) };
|
||||
};
|
||||
const agent = createEditorAgent({
|
||||
control, endpoint: '/api/editor/agent', fetchImpl, getSession: () => 'sess-1',
|
||||
getModel: () => ({ endpoint: 'http://seneca:8188/v1', model: 'qwen-image-edit-2511-8bit', label: 'Qwen-Image-Edit-2511' }),
|
||||
getAgentModel: () => ({ agent: 'Qwen3.5-9B-MLX-8bit', endpoint: 'http://seneca:8097/v1', label: 'Qwen3.5-9B VL · local' }),
|
||||
});
|
||||
await agent.run('brighten the face', {});
|
||||
console.log(JSON.stringify({ body: posts[0].body }));
|
||||
""")
|
||||
body = out['body']
|
||||
assert body['model'] == 'qwen-image-edit-2511-8bit'
|
||||
assert body['endpoint'] == 'http://seneca:8188/v1'
|
||||
assert body['model_label'] == 'Qwen-Image-Edit-2511'
|
||||
assert body['agent_model'] == 'Qwen3.5-9B-MLX-8bit'
|
||||
assert body['agent_endpoint'] == 'http://seneca:8097/v1'
|
||||
assert body['agent_label'] == 'Qwen3.5-9B VL · local'
|
||||
|
||||
|
||||
def test_agent_run_is_unavailable_when_endpoint_missing():
|
||||
out = run_node("""
|
||||
const control = { invoke(op) {
|
||||
if (op === 'screenshot') return { ok: true, result: { dataUrl: 'data:image/png;base64,AA' } };
|
||||
if (op === 'history.mark') return { ok: true, result: 0 };
|
||||
return { ok: true, result: {} };
|
||||
} };
|
||||
const fetchImpl = async () => ({ ok: false, status: 404, json: async () => ({}) });
|
||||
const agent = createEditorAgent({ control, fetchImpl });
|
||||
const out = await agent.run('make it moody');
|
||||
console.log(JSON.stringify({ out }));
|
||||
""")
|
||||
assert out['out']['unavailable'] is True
|
||||
assert out['out']['ok'] is False
|
||||
|
||||
|
||||
def test_agent_run_rolls_back_partial_edits_when_provider_missing():
|
||||
out = run_node("""
|
||||
let stack = 0;
|
||||
const undos = [];
|
||||
let collapses = 0;
|
||||
let agentRef = null;
|
||||
const control = { invoke(op) {
|
||||
if (op === 'document.info') return { ok: true, result: { width: 900, height: 600 } };
|
||||
if (op === 'layers.list') return { ok: true, result: [] };
|
||||
if (op === 'screenshot') return { ok: true, result: { dataUrl: 'data:image/png;base64,AA' } };
|
||||
if (op === 'history.mark') return { ok: true, result: stack };
|
||||
if (op === 'history.undo') { undos.push(1); if (stack > 0) stack--; return { ok: true, result: true }; }
|
||||
if (op === 'history.collapse') { collapses++; return { ok: true, result: 1 }; }
|
||||
return { ok: true, result: null };
|
||||
} };
|
||||
const fetchImpl = async () => {
|
||||
// Simulate the agent running two substitute ops through the bridge.
|
||||
stack = 2;
|
||||
agentRef.reportStep({ ok: true }, { op: 'selection.rectangle' });
|
||||
agentRef.reportStep({ ok: true }, { op: 'adjustments.apply' });
|
||||
return { ok: false, status: 200, json: async () => ({
|
||||
ok: false, needs_provider: true,
|
||||
reply: 'Removing objects needs an image generation provider.',
|
||||
}) };
|
||||
};
|
||||
agentRef = createEditorAgent({ control, fetchImpl, getSession: () => 's' });
|
||||
const out = await agentRef.run('remove the person on the left');
|
||||
console.log(JSON.stringify({ out, undos: undos.length, collapses, stack }));
|
||||
""")
|
||||
assert out['out']['blocked'] is True
|
||||
assert out['out']['needsProvider'] is True
|
||||
assert out['out']['ok'] is False
|
||||
assert out['out']['rolledBack'] == 2
|
||||
assert out['undos'] == 2
|
||||
assert out['collapses'] == 0
|
||||
assert out['stack'] == 0
|
||||
assert 'image generation provider' in out['out']['message'].lower()
|
||||
|
||||
|
||||
def test_agent_module_is_precached_for_offline_use():
|
||||
sw = (ROOT / 'static/sw.js').read_text(encoding='utf-8')
|
||||
assert "'/static/js/editor/editor-agent.js'" in sw
|
||||
|
||||
|
||||
def test_agent_has_skin_teeth_and_formatcrop_guards():
|
||||
src = (ROOT / 'static/js/editor/editor-agent.js').read_text(encoding='utf-8')
|
||||
assert "control.invoke('retouch.skin'" in src
|
||||
assert "control.invoke('retouch.teeth'" in src
|
||||
assert 'formatCropRect(prompt' in src
|
||||
assert 'isGenericIntent' in src
|
||||
assert 'dataUrlDelta' in src
|
||||
|
||||
|
||||
def test_agent_skips_format_crop_for_extend_intent():
|
||||
from pathlib import Path
|
||||
src = (Path(__file__).resolve().parents[1] / 'static/js/editor/editor-agent.js').read_text(encoding='utf-8')
|
||||
assert 'extendIntent' in src
|
||||
assert 'extend|expand' in src
|
||||
assert 'alreadyFormatted ? null : formatCropRect' in src
|
||||
|
||||
|
||||
def test_agent_suppresses_autotone_for_portrait_intents():
|
||||
from pathlib import Path
|
||||
src = (Path(__file__).resolve().parents[1] / 'static/js/editor/editor-agent.js').read_text(encoding='utf-8')
|
||||
# Portrait/teeth runs must not lead with a whole-image Auto Tone (it lightens
|
||||
# and whitens the face) unless the photo is genuinely under/over-exposed.
|
||||
assert 'dataUrlExposure' in src
|
||||
assert 'portraitIntent' in src
|
||||
assert 'teethIntent' in src
|
||||
assert 'portrait-no-auto-tone' in src
|
||||
assert 'restoreInvoke' in src
|
||||
assert 'exposure.dark > 0.32' in src
|
||||
|
||||
|
||||
|
||||
def test_agent_does_not_strengthen_a_clarification():
|
||||
from pathlib import Path
|
||||
src = (Path(__file__).resolve().parents[1] / 'static/js/editor/editor-agent.js').read_text(encoding='utf-8')
|
||||
# A zero-step plan (the planner asked a question) must skip every post-guard,
|
||||
# especially the imperceptible-edit tone strengthen, and be returned blocked.
|
||||
assert 'const asked = !blocked && planSteps.length === 0' in src
|
||||
assert '} else if (!blocked && !asked) {' in src
|
||||
assert 'if (blocked || asked) {' in src
|
||||
|
||||
|
||||
def test_agent_strengthen_guard_only_for_generic_intents():
|
||||
from pathlib import Path
|
||||
src = (Path(__file__).resolve().parents[1] / 'static/js/editor/editor-agent.js').read_text(encoding='utf-8')
|
||||
# Structural/action prompts must not get a spurious vibrance/brightness tweak.
|
||||
assert 'if (beforeShot && generic) {' in src
|
||||
Reference in New Issue
Block a user