mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 20:02:20 +02:00
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>
204 lines
9.5 KiB
Python
204 lines
9.5 KiB
Python
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
|