mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-31 02:02:22 +01:00
fix(editor): stabilize image workflows and editing controls
This commit is contained in:
@@ -29,8 +29,10 @@ def test_secondary_brush_controls_live_after_the_menu_open():
|
||||
block = CONTROLS[i:j]
|
||||
for marker in ('id="ge-brush-spacing"', 'id="ge-brush-smoothing"',
|
||||
'id="ge-brush-blend"', 'ge-brush-pressure-row',
|
||||
'id="ge-brush-flow-row"', 'id="ge-brush-softness-row"'):
|
||||
'id="ge-brush-flow-row"'):
|
||||
assert marker in block, marker
|
||||
assert 'id="ge-brush-softness-row"' not in block
|
||||
assert CONTROLS.index('id="ge-brush-softness-row"') < i
|
||||
|
||||
|
||||
def test_brush_size_label_drops_the_redundant_word():
|
||||
@@ -98,7 +100,7 @@ def test_brush_strip_fits_and_smudge_row_respects_inline_hide():
|
||||
# The Opacity/Flow/Softness sliders are wound tighter so the strip fits.
|
||||
assert ".ge-controls--in-options #ge-brush-section { gap: 6px; }" in CSS
|
||||
# The brush controls hug their content so the sliders sit next to the caret.
|
||||
assert ".ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }" in CSS
|
||||
assert ".ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; flex-direction: row;" in CSS
|
||||
# The smudge Strength row is hidden with an inline display:none on other
|
||||
# tools; the `.ge-eraser-row { display:flex !important }` rule must not
|
||||
# override that.
|
||||
|
||||
@@ -44,9 +44,11 @@ def test_quick_edit_shows_the_generation_message_verbatim():
|
||||
assert "_isGenOfflineMsg" in EDITOR
|
||||
assert "_setAiCommandStatus(_isGenOfflineMsg(_genErr) ? _genErr : `Could not generate that edit: ${_genErr}`, 'error');" in EDITOR
|
||||
assert "_setAiCommandStatus(_isGenOfflineMsg(msg) ? msg : `Could not generate an image: ${msg}`, 'error');" in EDITOR
|
||||
# the generateImage op maps its own fetch/HTTP failures through the classifier
|
||||
assert "_generationErrorMessage({ network: true" in EDITOR
|
||||
assert "_generationErrorMessage({ status: res.status, detail: _gd })" in EDITOR
|
||||
# The extracted generation action keeps the shared failure classifier.
|
||||
generation = (ROOT / 'static/js/editor/image-generation.js').read_text()
|
||||
assert "formatError: _generationErrorMessage" in EDITOR
|
||||
assert "formatError({ network: true" in generation
|
||||
assert "formatError({ status: response.status" in generation
|
||||
|
||||
|
||||
def test_sw_cache_is_bumped():
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from routes import editor_agent_routes as routes
|
||||
from src.agent_tools import editor_tools
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def allow_image_generation_privilege(monkeypatch):
|
||||
monkeypatch.setattr('src.auth_helpers.require_privilege', lambda *args: 'test-user')
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chosen", ["openai/gpt-5-image", ""])
|
||||
def test_quick_edit_generation_uses_generation_selection_not_planner_model(monkeypatch, chosen):
|
||||
seen = []
|
||||
|
||||
async def execute(self, content, ctx):
|
||||
seen.append(json.loads(content))
|
||||
return "provider stub", {"exit_code": 1, "error": "test stops before canvas verification"}
|
||||
|
||||
monkeypatch.setattr(editor_tools.GenerateImageLayerTool, "execute", execute)
|
||||
monkeypatch.setattr(routes, "_user", lambda request: None)
|
||||
monkeypatch.setattr(routes.editor_agent_model, "startup_probe", lambda: None)
|
||||
monkeypatch.setattr(routes.style_guide, "strengthen", lambda prompt, steps: steps)
|
||||
monkeypatch.setattr(routes.style_guide, "enforce_guards", lambda steps: steps)
|
||||
monkeypatch.setattr(routes, "_image_provider_available", lambda owner: True)
|
||||
monkeypatch.setattr(routes, "_plan_special", lambda *args: None)
|
||||
monkeypatch.setattr(routes, "_ensure_generative", lambda prompt, steps: steps)
|
||||
monkeypatch.setattr(routes, "_unsure_plan", lambda prompt, plan: plan)
|
||||
routes.set_editor_planner(lambda messages, owner: json.dumps({
|
||||
"reply": "Generate", "steps": [{"op": "generate_image", "params": {
|
||||
"prompt": "a cow", "model": "gpt-image-1",
|
||||
}}],
|
||||
}))
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_editor_agent_routes())
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/api/editor/agent", json={
|
||||
"prompt": "create a new image of a cow",
|
||||
"model": "edit-only-model", "generation_model": chosen,
|
||||
})
|
||||
assert response.status_code == 200, response.text
|
||||
assert len(seen) == 1, response.text
|
||||
assert seen[0].get("model", "") == chosen
|
||||
finally:
|
||||
routes.set_editor_planner(None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('op,expected', [('generate_image','saved-generation'),('ai_edit_image','saved-editor')])
|
||||
def test_quick_edit_agent_uses_saved_models_over_browser_and_planner(monkeypatch, op, expected):
|
||||
seen = []
|
||||
|
||||
async def execute(self, content, ctx):
|
||||
seen.append(json.loads(content))
|
||||
return 'stub', {'exit_code':1, 'error':'stop before verification'}
|
||||
|
||||
monkeypatch.setattr('src.editor_image_settings.load_settings', lambda: {
|
||||
'image_model':'saved-generation', 'image_edit_model':'saved-editor'})
|
||||
monkeypatch.setattr(editor_tools.GenerateImageLayerTool, 'execute', execute)
|
||||
monkeypatch.setattr(editor_tools.AiEditImageTool, 'execute', execute)
|
||||
monkeypatch.setattr(routes, '_user', lambda request: None)
|
||||
monkeypatch.setattr(routes.editor_agent_model, 'startup_probe', lambda: None)
|
||||
monkeypatch.setattr(routes.style_guide, 'strengthen', lambda prompt, steps: steps)
|
||||
monkeypatch.setattr(routes.style_guide, 'enforce_guards', lambda steps: steps)
|
||||
monkeypatch.setattr(routes, '_image_provider_available', lambda owner: True)
|
||||
monkeypatch.setattr(routes, '_plan_special', lambda *args: None)
|
||||
monkeypatch.setattr(routes, '_ensure_generative', lambda prompt, steps: steps)
|
||||
monkeypatch.setattr(routes, '_unsure_plan', lambda prompt, plan: plan)
|
||||
routes.set_editor_planner(lambda messages, owner: json.dumps({
|
||||
'reply':'Edit', 'steps':[{'op':op,'params':{'prompt':'a cow','model':'planner-model','size':'1024x1024'}}]}))
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_editor_agent_routes())
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
response = client.post('/api/editor/agent', json={
|
||||
'prompt':'create an image of a cow','_use_image_settings':True,
|
||||
'model':'stale-editor','generation_model':'stale-generator','state':{'width':1280,'height':720}})
|
||||
assert response.status_code == 200, response.text
|
||||
assert len(seen) == 1, response.text
|
||||
assert seen[0]['model'] == expected
|
||||
assert seen[0]['_use_image_settings'] is True
|
||||
if op == 'generate_image':
|
||||
assert seen[0]['size'] == '1280x720'
|
||||
finally:
|
||||
routes.set_editor_planner(None)
|
||||
|
||||
|
||||
def test_quick_generation_route_uses_saved_setting(monkeypatch):
|
||||
import asyncio
|
||||
from routes.gallery import gallery_routes as gallery
|
||||
from src import ai_interaction
|
||||
|
||||
seen = []
|
||||
|
||||
async def generate(content, **kwargs):
|
||||
seen.append(json.loads(content))
|
||||
return {'image_url':'data:image/png;base64,dGVzdA=='}
|
||||
|
||||
monkeypatch.setattr(gallery, 'require_privilege', lambda *args: 'alice')
|
||||
monkeypatch.setattr(ai_interaction, 'do_generate_image', generate)
|
||||
monkeypatch.setattr('src.editor_image_settings.load_settings', lambda: {'image_model':'saved-generation'})
|
||||
|
||||
class Request:
|
||||
async def json(self):
|
||||
return {'prompt':'a cow','model':'stale-generator','_use_image_settings':True,'width':1280,'height':720}
|
||||
|
||||
endpoint = next(route.endpoint for route in gallery.setup_gallery_routes().routes if route.path == '/api/image/generate')
|
||||
result = asyncio.run(endpoint(Request()))
|
||||
assert result['image'] == 'dGVzdA=='
|
||||
assert seen[0]['model'] == 'saved-generation'
|
||||
assert seen[0]['size'] == '1280x720'
|
||||
|
||||
|
||||
def test_edit_default_uses_generation_only_when_edit_setting_is_empty(monkeypatch):
|
||||
from src import editor_image_settings
|
||||
monkeypatch.setattr(editor_image_settings, 'load_settings', lambda: {'image_model':'generator','image_edit_model':''})
|
||||
assert editor_image_settings.editor_image_models() == {'generation':'generator','edit':'generator'}
|
||||
@@ -49,10 +49,9 @@ def test_ctrl_zoom_shortcuts_drive_the_toolbar_and_override_the_browser():
|
||||
|
||||
def test_ctrl_alt_l_opens_the_levels_adjustment():
|
||||
keyboard = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8")
|
||||
# Photoshop-style chord: reuse the Image menu row so the dialog and the
|
||||
# adjustment-layer plumbing stay in one place.
|
||||
# Open layer-local controls without creating/committing a new adjustment layer.
|
||||
assert "e.altKey && e.code === 'KeyL'" in keyboard
|
||||
assert "document.querySelector('[data-image-action=\"adj-levels\"]')" in keyboard
|
||||
assert "openLayerAdjustment?.('levels');" in keyboard
|
||||
|
||||
|
||||
def test_ctrl_alt_shift_e_stamps_the_visible_layers():
|
||||
|
||||
@@ -0,0 +1,770 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page():
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1000, "height": 700})
|
||||
|
||||
def serve(route):
|
||||
relative = route.request.url.split('http://editor.test/', 1)[1].split('?', 1)[0]
|
||||
if not relative:
|
||||
route.fulfill(body='<html><body></body></html>', content_type='text/html')
|
||||
else:
|
||||
route.fulfill(path=str(ROOT / relative))
|
||||
|
||||
page.route('http://editor.test/**', serve)
|
||||
page.goto('http://editor.test/')
|
||||
yield page
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_remove_background_sends_only_active_layer_and_preserves_others(page):
|
||||
result = page.evaluate("""async () => {
|
||||
const {state} = await import('/static/js/editor/state.js');
|
||||
const {createApplyImageTool} = await import('/static/js/editor/ai-tool-runner.js');
|
||||
function layer(id, color) {
|
||||
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 4;
|
||||
const ctx = canvas.getContext('2d'); ctx.fillStyle = color; ctx.fillRect(0,0,4,4);
|
||||
return {id, canvas, ctx, visible:true, locks:{}};
|
||||
}
|
||||
const red = layer('red','red'), blue = layer('blue','blue');
|
||||
red.kind = 'placed'; red.placed = {sourceName:'original'};
|
||||
state.layers = [red, blue]; state.activeLayerId = red.id; state.editorOpen = true;
|
||||
const beforeBlue = blue.canvas.toDataURL(); const expected = red.canvas.toDataURL().split(',')[1];
|
||||
let sent, snapshots = 0;
|
||||
const clear = document.createElement('canvas'); clear.width = clear.height = 4;
|
||||
window.fetch = async (url, opts) => {
|
||||
sent = JSON.parse(opts.body);
|
||||
state.activeLayerId = blue.id; // Changing selection during the request must not change its target.
|
||||
return {ok:true, json:async () => ({image:clear.toDataURL()})};
|
||||
};
|
||||
const run = createApplyImageTool({
|
||||
flatten: () => {throw Error('Must not flatten');}, saveState: () => snapshots++,
|
||||
createLayer: () => {throw Error('Must not create a merged layer');},
|
||||
composite: () => {}, renderLayerPanel: () => {}, deriveBusyLabel: () => 'Removing',
|
||||
getSelectedAIEndpoint: () => ({}), spinnerModule: {}, uiModule: {showToast(){}},
|
||||
});
|
||||
await run('/api/image/remove-bg', {}, 'BG Removed', document.createElement('button'));
|
||||
return {correctInput:sent.image === expected, blueUnchanged:blue.canvas.toDataURL() === beforeBlue,
|
||||
count:state.layers.length, visible:state.layers.every(l => l.visible), snapshots,
|
||||
alpha:red.ctx.getImageData(0,0,1,1).data[3], kind:red.kind, placed:red.placed};
|
||||
}""")
|
||||
assert result == dict(correctInput=True, blueUnchanged=True, count=2, visible=True, snapshots=1, alpha=0, kind='raster', placed=None)
|
||||
|
||||
|
||||
def test_levels_shortcut_opens_controls_without_menu_commit(page):
|
||||
result = page.evaluate("""async () => {
|
||||
const {state} = await import('/static/js/editor/state.js');
|
||||
const {wireKeyboardShortcuts} = await import('/static/js/editor/keyboard-shortcuts.js');
|
||||
state.editorOpen = true;
|
||||
let selected;
|
||||
wireKeyboardShortcuts({toolbar:document.createElement('div'), toolKeyMap:{},
|
||||
openLayerAdjustment:type => selected = type});
|
||||
const event = new KeyboardEvent('keydown', {key:'l', code:'KeyL', ctrlKey:true, altKey:true,
|
||||
bubbles:true, cancelable:true});
|
||||
document.dispatchEvent(event);
|
||||
return {selected, prevented:event.defaultPrevented};
|
||||
}""")
|
||||
assert result == dict(selected='levels', prevented=True)
|
||||
|
||||
|
||||
def test_hue_saturation_shortcut_opens_adjustment(page):
|
||||
result = page.evaluate("""async () => {
|
||||
const {state} = await import('/static/js/editor/state.js');
|
||||
const {wireKeyboardShortcuts} = await import('/static/js/editor/keyboard-shortcuts.js');
|
||||
state.editorOpen = true;
|
||||
let selected;
|
||||
wireKeyboardShortcuts({toolbar:document.createElement('div'), toolKeyMap:{},
|
||||
openLayerAdjustment:type => selected = type});
|
||||
const event = new KeyboardEvent('keydown', {key:'U', code:'KeyU', ctrlKey:true, shiftKey:true,
|
||||
bubbles:true, cancelable:true});
|
||||
document.dispatchEvent(event);
|
||||
return {selected, prevented:event.defaultPrevented};
|
||||
}""")
|
||||
assert result == dict(selected='hue-saturation', prevented=True)
|
||||
|
||||
|
||||
def test_inner_shadow_clips_inside_and_reverses_direction(page):
|
||||
result = page.evaluate("""async () => {
|
||||
const {renderEffects} = await import('/static/js/editor/effects.js');
|
||||
const source = document.createElement('canvas'); source.width=source.height=60;
|
||||
const ctx=source.getContext('2d');ctx.fillStyle='white';ctx.fillRect(15,15,30,30);
|
||||
function sample(inside,x) {
|
||||
const out=renderEffects(source,[{type:'drop-shadow',params:{inside,x,y:0,blur:0,opacity:1,color:'#000000'}}]);
|
||||
const c=out.getContext('2d');
|
||||
return {left:[...c.getImageData(16,30,1,1).data],right:[...c.getImageData(43,30,1,1).data],
|
||||
outside:[...c.getImageData(47,30,1,1).data]};
|
||||
}
|
||||
return {right:sample(true,6),left:sample(true,-6),outer:sample(false,6)};
|
||||
}""")
|
||||
assert result['right']['left'] == [0,0,0,255]
|
||||
assert result['right']['right'] == [255,255,255,255]
|
||||
assert result['left']['right'] == [0,0,0,255]
|
||||
assert result['right']['outside'][3] == 0
|
||||
assert result['outer']['outside'] == [0,0,0,255]
|
||||
|
||||
|
||||
def test_cut_placed_image_rasterizes_once_without_confirmation(page):
|
||||
page.route('http://editor.test/api/**', lambda route: route.fulfill(json={}))
|
||||
page.evaluate("""async () => {
|
||||
document.body.innerHTML='<div id="toast"></div><div id="gallery-editor-container"></div>';
|
||||
const editor=await import('/static/js/galleryEditor.js');
|
||||
const {state}=await import('/static/js/editor/state.js');
|
||||
editor.openEditor(null,null,{w:32,h:32},'Cut test');
|
||||
const layer=state.layers.at(-1);
|
||||
layer.ctx.fillStyle='red';layer.ctx.fillRect(0,0,32,32);
|
||||
layer.kind='placed';layer.placed={sourceName:'Original image'};
|
||||
document.activeElement?.blur();
|
||||
document.dispatchEvent(new KeyboardEvent('keydown',{key:'x',code:'KeyX',ctrlKey:true,bubbles:true,cancelable:true}));
|
||||
}""")
|
||||
page.wait_for_function("""async () => {
|
||||
const {state}=await import('/static/js/editor/state.js');
|
||||
return !!state.internalClipboard && state.layers.at(-1).ctx.getImageData(0,0,1,1).data[3]===0;
|
||||
}""")
|
||||
result=page.evaluate("""async () => {
|
||||
const {state}=await import('/static/js/editor/state.js');
|
||||
return {kind:state.layers.at(-1).kind,placed:state.layers.at(-1).placed,
|
||||
copied:[...state.internalClipboard.getContext('2d').getImageData(0,0,1,1).data],
|
||||
prompted:document.querySelector('#styled-confirm-modal')?.textContent?.includes('Rasterize layer')||false};
|
||||
}""")
|
||||
assert result==dict(kind='raster',placed=None,copied=[255,0,0,255],prompted=False)
|
||||
|
||||
|
||||
def test_drop_shadow_dialog_inside_and_direction_apply(page):
|
||||
page.route('http://editor.test/api/**', lambda route: route.fulfill(json={}))
|
||||
page.add_style_tag(content=(ROOT/'static/css/13-image-editor.css').read_text())
|
||||
page.add_style_tag(content=':root{--panel:#242426;--bg:#171719;--fg:#eee;--border:#454549;--red:#d47965;}body{background:var(--bg);color:var(--fg);font-family:Arial,sans-serif;}')
|
||||
page.evaluate("""async () => {
|
||||
document.body.innerHTML='<div id="toast"></div><div id="gallery-editor-container" style="height:680px"></div>';
|
||||
const editor=await import('/static/js/galleryEditor.js');
|
||||
editor.openEditor(null,null,{w:64,h:64},'Shadow test');
|
||||
document.querySelector('[data-filter-action="effect-drop-shadow"]').click();
|
||||
}""")
|
||||
page.locator('.ge-filter-overlay select[data-key="inside"]').select_option('1')
|
||||
page.locator('.ge-filter-overlay input[data-key="angle"]').evaluate("el=>{el.value='90';el.dispatchEvent(new Event('input',{bubbles:true}));}")
|
||||
page.screenshot(path='/tmp/editor-shadow-inside-direction.png')
|
||||
page.locator('.ge-filter-overlay [data-action="apply"]').click()
|
||||
page.wait_for_function("""async () => {
|
||||
const {state}=await import('/static/js/editor/state.js');
|
||||
return state.layers.at(-1).effects.length===1;
|
||||
}""")
|
||||
result=page.evaluate("""async () => {
|
||||
const {state}=await import('/static/js/editor/state.js');
|
||||
const {inside,x,y}=state.layers.at(-1).effects[0].params;
|
||||
return {inside,x,y};
|
||||
}""")
|
||||
assert result==dict(inside=True,x=0,y=-7)
|
||||
|
||||
|
||||
def test_reorder_rebuilds_effect_rows_under_their_parent(page):
|
||||
result=page.evaluate("""async () => {
|
||||
const {state}=await import('/static/js/editor/state.js');
|
||||
const {createLayerPanelRenderer}=await import('/static/js/editor/layer-panel.js');
|
||||
document.body.innerHTML='<div id="ge-layers-list"></div>';
|
||||
state.container=document.body;state.editorOpen=true;
|
||||
function layer(id,effects=[]) {
|
||||
const canvas=document.createElement('canvas');canvas.width=canvas.height=16;
|
||||
return {id,name:id,kind:'raster',canvas,ctx:canvas.getContext('2d'),visible:true,
|
||||
opacity:1,locks:{},effects,masks:[],adjLayers:[]};
|
||||
}
|
||||
const a=layer('a',[{id:'shadow-a',type:'drop-shadow',params:{}}]), b=layer('b');
|
||||
state.layers=[a,b];state.activeLayerId=a.id;state.layerOffsets=new Map();state.layerGroups=[];
|
||||
let options;
|
||||
const renderer=createLayerPanelRenderer({composite(){},saveState(){},
|
||||
dragSortModule:{enable:(id,selector,config)=>{options=config;}},uiModule:{showToast(){}}});
|
||||
renderer.render();
|
||||
const list=document.getElementById('ge-layers-list');
|
||||
const row=id=>list.querySelector(`.ge-layer-item[data-layer-id="${id}"]`);
|
||||
list.insertBefore(row('a'),row('b'));
|
||||
options.onReorder([...list.querySelectorAll('.ge-layer-item[data-layer-id]')]);
|
||||
return {order:state.layers.map(layer=>layer.id),excluded:options.excludeSelector,
|
||||
rows:[...list.children].map(el=>el.dataset.layerId||el.dataset.effectId)};
|
||||
}""")
|
||||
assert result==dict(order=['b','a'],excluded='.ge-adj-sub-item',rows=['a','shadow-a','b'])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('tool', ['marquee','lasso'])
|
||||
@pytest.mark.parametrize('pointer_type', ['mouse','pen','touch'])
|
||||
@pytest.mark.parametrize('pan', [False,True])
|
||||
def test_selections_start_outside_canvas_unless_explicitly_panning(page,tool,pointer_type,pan):
|
||||
result=page.evaluate("""async ({tool,pointerType,pan}) => {
|
||||
const {state}=await import('/static/js/editor/state.js');
|
||||
const {wireCanvasEvents}=await import('/static/js/editor/canvas-events.js');
|
||||
document.body.innerHTML='<div id="area"><canvas width="100" height="100"></canvas><button>Chrome</button></div>';
|
||||
const area=document.getElementById('area');
|
||||
state.mainCanvas=area.querySelector('canvas');state.tool=tool;
|
||||
state.editorOpen=true;state.spacePanActive=pan;state.panX=state.panY=0;
|
||||
let starts=0,moves=0,ends=0;
|
||||
wireCanvasEvents({canvasArea:area,beginDraw:()=>starts++,continueDraw:()=>moves++,
|
||||
endDraw:()=>ends++,updateBrushCursor(){},syncZoomControls(){}});
|
||||
function pointer(type,target=area) {
|
||||
target.dispatchEvent(new PointerEvent(type,{pointerId:5,pointerType,button:0,
|
||||
clientX:type==='pointerdown'?10:30,clientY:type==='pointerdown'?10:30,bubbles:true,cancelable:true}));
|
||||
}
|
||||
pointer('pointerdown');
|
||||
if(pointerType==='mouse')area.dispatchEvent(new MouseEvent('mousedown',{button:0,bubbles:true}));
|
||||
pointer('pointermove');
|
||||
if(pointerType==='mouse')window.dispatchEvent(new MouseEvent('mousemove'));
|
||||
pointer('pointerup');
|
||||
if(pointerType==='mouse')window.dispatchEvent(new MouseEvent('mouseup'));
|
||||
const after={starts,moves,ends,panX:state.panX};
|
||||
pointer('pointerdown',area.querySelector('button'));
|
||||
return {...after,chromeStarted:starts!==after.starts};
|
||||
}""",dict(tool=tool,pointerType=pointer_type,pan=pan))
|
||||
assert result['starts']==(0 if pan else 1)
|
||||
assert result['panX']==(20 if pan else 0)
|
||||
assert not result['chromeStarted']
|
||||
if not pan:
|
||||
assert result['moves']==1
|
||||
assert result['ends']==1
|
||||
|
||||
|
||||
def test_marquee_drag_from_workspace_creates_clipped_selection(page):
|
||||
result=page.evaluate("""async () => {
|
||||
const {state}=await import('/static/js/editor/state.js');
|
||||
const {wireCanvasEvents}=await import('/static/js/editor/canvas-events.js');
|
||||
const {createMarqueeTool}=await import('/static/js/editor/tools/marquee.js');
|
||||
document.body.innerHTML='<div id="area"><canvas width="100" height="100" style="position:absolute;left:100px;top:100px;width:100px;height:100px"></canvas></div>';
|
||||
const area=document.getElementById('area');state.mainCanvas=area.querySelector('canvas');
|
||||
state.tool='marquee';state.imgWidth=state.imgHeight=100;state.editorOpen=true;
|
||||
state.spacePanActive=false;state.panX=state.panY=0;state.wandMask=null;
|
||||
const layer={id:'a'};state.layers=[layer];state.activeLayerId='a';state.layerOffsets=new Map();
|
||||
const tool=createMarqueeTool({activeLayer:()=>layer,saveState(){},composite(){},drawOverlay(){},syncSelectionUi(){}});
|
||||
wireCanvasEvents({canvasArea:area,beginDraw:e=>tool.begin(e),continueDraw:e=>tool.drag(e),
|
||||
endDraw:e=>tool.end(e),updateBrushCursor(){},syncZoomControls(){}});
|
||||
area.dispatchEvent(new PointerEvent('pointerdown',{pointerType:'mouse',pointerId:1,button:0,clientX:80,clientY:80,bubbles:true}));
|
||||
area.dispatchEvent(new MouseEvent('mousedown',{button:0,clientX:80,clientY:80,bubbles:true}));
|
||||
window.dispatchEvent(new MouseEvent('mousemove',{clientX:150,clientY:150}));
|
||||
window.dispatchEvent(new MouseEvent('mouseup',{clientX:150,clientY:150}));
|
||||
const ctx=state.wandMask.getContext('2d');
|
||||
return {inside:ctx.getImageData(0,0,1,1).data[3],outside:ctx.getImageData(70,70,1,1).data[3],pan:state.panX};
|
||||
}""")
|
||||
assert result==dict(inside=255,outside=0,pan=0)
|
||||
|
||||
|
||||
def test_save_menu_remains_clickable_after_png_export_and_window_raise(page):
|
||||
page.add_style_tag(content='''
|
||||
.modal {position:fixed;inset:0;z-index:20000;background:#222;}
|
||||
#ge-save-menu-btn {position:absolute;right:20px;top:20px;}
|
||||
.ge-save-menu {position:fixed;z-index:10005;width:200px;background:#333;}
|
||||
.ge-save-menu[hidden] {display:none;}
|
||||
.ge-save-menu button {display:block;width:100%;height:30px;}
|
||||
''')
|
||||
page.evaluate("""async () => {
|
||||
const {wireTopbar}=await import('/static/js/editor/wire-topbar.js');
|
||||
document.body.innerHTML='<div id="gallery-modal" class="modal"><div id="gallery-editor-container"><div class="ge-topbar"><div class="ge-save-wrap"><button id="ge-save-menu-btn">Save</button><div id="ge-save-menu" class="ge-save-menu" hidden><button id="ge-save">Save image</button><button id="ge-export-png">Save as PNG</button></div></div></div></div></div>';
|
||||
window.pngExports=0;window.saves=0;
|
||||
document.getElementById('ge-save').addEventListener('click',()=>window.saves++);
|
||||
wireTopbar({registerDocClickAway(){},exportPNGFile:()=>window.pngExports++});
|
||||
}""")
|
||||
page.locator('#ge-save-menu-btn').click()
|
||||
page.locator('#ge-export-png').click(timeout=2000)
|
||||
page.evaluate("document.getElementById('gallery-modal').style.zIndex='50000'")
|
||||
page.locator('#ge-save-menu-btn').click()
|
||||
page.locator('#ge-save').click(timeout=2000)
|
||||
assert page.evaluate('({png:window.pngExports,saves:window.saves})') == dict(png=1,saves=1)
|
||||
|
||||
|
||||
def test_submenu_hover_aligns_to_row_and_flips_at_right_edge(page):
|
||||
page.add_style_tag(content='''
|
||||
#menu {position:absolute; left:780px; top:100px; width:180px;}
|
||||
button {display:block; height:35px; width:180px;}
|
||||
.ge-submenu {display:none; position:absolute; width:160px; height:120px; top:0; left:100%;}
|
||||
.ge-menu-parent:hover + .ge-submenu {display:block;}
|
||||
''')
|
||||
page.evaluate("""async () => {
|
||||
document.body.innerHTML = '<div id="menu"><button>First</button><button>Second</button>'
|
||||
+ '<button class="ge-menu-parent">Blur ></button><div class="ge-submenu">Gaussian</div></div>';
|
||||
const {wireSubmenuToggles} = await import('/static/js/editor/submenu-toggles.js');
|
||||
wireSubmenuToggles(document.getElementById('menu'));
|
||||
}""")
|
||||
page.locator('.ge-menu-parent').hover()
|
||||
row = page.locator('.ge-menu-parent').bounding_box()
|
||||
panel = page.locator('.ge-submenu').bounding_box()
|
||||
assert abs(panel['y'] - row['y']) <= 1
|
||||
assert panel['x'] + panel['width'] <= row['x'] + 1
|
||||
page.screenshot(path='/tmp/editor-submenu-alignment.png')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('prompt', ['remove bg', 'remove background', 'remove the background from the selected layer'])
|
||||
def test_quick_edit_planner_uses_background_tool(prompt):
|
||||
from routes.editor_agent_routes import _plan_special
|
||||
steps, _, _ = _plan_special(prompt, {}, True)
|
||||
assert steps == [{'op': 'ai.removeBackground', 'params': {}}]
|
||||
|
||||
|
||||
def test_quick_edit_background_removal_precedes_creative_routing():
|
||||
source = (ROOT / 'static/js/galleryEditor.js').read_text()
|
||||
local = source.split('const _executeQuickEditLocal = async (prompt) => {', 1)[1]
|
||||
assert local.index('if (_looksBackgroundRemoval(p))') < local.index('if (_looksRecolor(p))')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('run', [False, True])
|
||||
def test_background_tool_suggestion_prepares_command_and_enter_submits(page, run):
|
||||
source = (ROOT / 'static/js/galleryEditor.js').read_text()
|
||||
picker = 'const pickSuggestion = ' + source.split('const pickSuggestion = ', 1)[1].split(" wrap.addEventListener('pointerdown'", 1)[0]
|
||||
assert "runPrompt: id === 'rembg' ? 'remove background' : null" in source
|
||||
result = page.evaluate("""({picker, run}) => {
|
||||
const suggestionItems = [{toolId:'rembg', label:'Bg Remove', runPrompt:'remove background'}];
|
||||
const input = document.createElement('input'); input.value = 'bg';
|
||||
let submitted = 0, selected = null, clearSynced = false;
|
||||
const form = {requestSubmit: () => submitted++};
|
||||
const _clickToolButton = id => selected = id;
|
||||
const _setAiCommandStatus = () => {};
|
||||
const hideSuggestions = () => {};
|
||||
const syncClearButton = () => clearSynced = true;
|
||||
eval(picker + '; pickSuggestion(0, run);');
|
||||
return {prompt:input.value, submitted, selected, clearSynced};
|
||||
}""", {'picker':picker, 'run':run})
|
||||
assert result == dict(prompt='remove background', submitted=int(run), selected='rembg', clearSynced=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('tool', ['dodge', 'burn'])
|
||||
def test_retouch_invalidates_adjustment_preview_and_stops_before_undo(page, tool):
|
||||
result = page.evaluate("""async tool => {
|
||||
const {state} = await import('/static/js/editor/state.js');
|
||||
const {createStrokePipeline} = await import('/static/js/editor/stroke-pipeline.js');
|
||||
const {renderLayerWithAdjLayers} = await import('/static/js/editor/fx/pixel-pass.js');
|
||||
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 30;
|
||||
const ctx = canvas.getContext('2d'); ctx.fillStyle = 'rgb(100,100,100)'; ctx.fillRect(0,0,30,30);
|
||||
const layer = {id:'target', canvas, ctx, adjLayers:[{id:'adj', type:'brightness-contrast',
|
||||
params:{brightness:1.1,contrast:1}, visible:true, opacity:1}]};
|
||||
state.layers = [layer]; state.layerOffsets = new Map(); state.tool = tool;
|
||||
Object.assign(state,{brushSize:12, brushOpacity:100, brushFlow:100, brushSoftness:0,
|
||||
brushSpacing:20, brushSmoothing:0, dodgeBurnIntensity:50});
|
||||
const original = ctx.getImageData(0,0,30,30);
|
||||
const pixel = () => renderLayerWithAdjLayers(layer).getContext('2d').getImageData(15,15,1,1).data[0];
|
||||
const before = pixel();
|
||||
const pipeline = createStrokePipeline({activeLayer:()=>layer, getActiveMaskLayer:()=>null, composite:()=>{}});
|
||||
pipeline.beginStroke({x:15,y:15,pressure:1},tool);
|
||||
const during = pixel();
|
||||
pipeline.endStroke(null); // Finish before Undo restores the saved pixels.
|
||||
ctx.putImageData(original,0,0); layer._adjFinalKey = null; layer._adjCacheKey = null;
|
||||
pipeline.endStroke({x:28,y:28,pressure:1}); // A late pointer-up cannot replay this stroke.
|
||||
return {before,during,after:pixel(),corner:ctx.getImageData(28,28,1,1).data[0]};
|
||||
}""", tool)
|
||||
assert result['during'] > result['before'] if tool == 'dodge' else result['during'] < result['before']
|
||||
assert result['after'] == result['before']
|
||||
assert result['corner'] == 100
|
||||
|
||||
|
||||
def test_undo_finishes_active_stroke_before_restoring_history():
|
||||
source = (ROOT / 'static/js/galleryEditor.js').read_text()
|
||||
undo = source.split('function undo() {', 1)[1].split('function redo()', 1)[0]
|
||||
assert undo.index('if (state.drawing) _strokeTool.tryEnd();') < undo.index('state.undoStack.pop()')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('width,height', [(1000,700), (390,844)])
|
||||
def test_filter_submenu_rows_have_equal_heights_with_and_without_icons(page, width, height):
|
||||
page.set_viewport_size({'width':width,'height':height})
|
||||
page.add_style_tag(content=(ROOT / 'static/css/13-image-editor.css').read_text())
|
||||
page.evaluate("""() => {
|
||||
document.body.innerHTML = '<div class="ge-submenu" style="display:block;left:8px;top:8px;">'
|
||||
+ '<button class="dropdown-item-compact"><span>Plain</span></button>'
|
||||
+ '<button class="dropdown-item-compact"><svg width="24" height="24"><circle cx="12" cy="12" r="8"/></svg><span>Icon</span></button>'
|
||||
+ '<button class="dropdown-item-compact"><span>Longer label</span><svg width="10" height="10"></svg></button></div>';
|
||||
}""")
|
||||
heights = page.locator('.dropdown-item-compact').evaluate_all('(rows)=>rows.map(row=>row.getBoundingClientRect().height)')
|
||||
assert heights == ([28]*3 if width > 820 else [32]*3)
|
||||
page.screenshot(path=f'/tmp/editor-filter-row-heights-{width}.png')
|
||||
|
||||
|
||||
def test_ai_settings_escape_closes_only_the_dialog(page):
|
||||
page.route('**/api/auth/settings', lambda route: route.fulfill(json={'image_model':'', 'image_edit_model':''}))
|
||||
result = page.evaluate("""async () => {
|
||||
const {createAiSettings} = await import('/static/js/editor/ai-settings.js');
|
||||
const container = document.createElement('div'); document.body.append(container);
|
||||
const settings = createAiSettings({container}); settings.open();
|
||||
let reachedEditor = false;
|
||||
document.addEventListener('keydown', () => reachedEditor = true, {capture:true, once:true});
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', {key:'Escape', bubbles:true, cancelable:true}));
|
||||
const result = {closed:settings.element.hidden, reachedEditor}; settings.destroy();
|
||||
return result;
|
||||
}""")
|
||||
assert result == dict(closed=True, reachedEditor=False)
|
||||
|
||||
|
||||
def test_image_model_settings_sync_both_directions_and_survive_reopening(page):
|
||||
saved = {'image_model':'openai/gpt-5-image', 'image_edit_model':'', 'image_quality':'medium', 'image_gen_enabled':True}
|
||||
writes = []
|
||||
|
||||
def settings_api(route):
|
||||
if route.request.method == 'POST':
|
||||
patch = route.request.post_data_json
|
||||
writes.append(patch)
|
||||
saved.update(patch)
|
||||
route.fulfill(json=dict(saved))
|
||||
|
||||
page.route('**/api/auth/settings', settings_api)
|
||||
page.route('**/api/model-endpoints', lambda route: route.fulfill(json=[{
|
||||
'is_enabled':True, 'online':True, 'model_type':'llm',
|
||||
'models':['openai/gpt-5-image','openai/gpt-image-2'],
|
||||
}]))
|
||||
page.evaluate("""async () => {
|
||||
document.body.innerHTML = '<select id="set-imgModelSelect"><option value="">Auto</option></select>'
|
||||
+ '<select id="set-imgEditModelSelect"><option value="">Same</option></select>'
|
||||
+ '<select id="set-imgQualitySelect"><option value="medium">Medium</option></select>'
|
||||
+ '<input type="checkbox" id="set-imgEnabledToggle"><span id="set-imgSettingsMsg"></span>';
|
||||
const {initImageSettings} = await import('/static/js/settings/imageModels.js');
|
||||
await initImageSettings();
|
||||
const {createAiSettings} = await import('/static/js/editor/ai-settings.js');
|
||||
window.dialog = createAiSettings({container:document.body});
|
||||
for (const id of ['ge-ai-model','ge-ai-command-model']) {
|
||||
const select = document.getElementById(id);
|
||||
for (const model of ['', 'openai/gpt-5-image','openai/gpt-image-2']) {
|
||||
const option = document.createElement('option');
|
||||
option.value = model ? 'https://openrouter.ai/api/v1::'+model : '';
|
||||
option.textContent = model || 'Auto'; select.append(option);
|
||||
}
|
||||
}
|
||||
dialog.open();
|
||||
}""")
|
||||
page.wait_for_function("document.getElementById('ge-ai-model').value.endsWith('::openai/gpt-5-image')")
|
||||
page.select_option('#ge-ai-model', 'https://openrouter.ai/api/v1::openai/gpt-image-2')
|
||||
page.wait_for_function("document.getElementById('set-imgModelSelect').value === 'openai/gpt-image-2'")
|
||||
assert saved['image_model'] == 'openai/gpt-image-2'
|
||||
page.select_option('#set-imgEditModelSelect', 'openai/gpt-image-2')
|
||||
page.wait_for_function("document.getElementById('ge-ai-command-model').value.endsWith('::openai/gpt-image-2')")
|
||||
page.evaluate('dialog.close(); dialog.open();')
|
||||
page.wait_for_function("document.getElementById('ge-ai-command-model').value.endsWith('::openai/gpt-image-2')")
|
||||
assert saved['image_edit_model'] == 'openai/gpt-image-2'
|
||||
assert len(writes) == 2
|
||||
|
||||
|
||||
def test_generation_places_result_without_stretching_or_refresh(page):
|
||||
result = page.evaluate("""async () => {
|
||||
const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js');
|
||||
const image = document.createElement('canvas'); image.width = 80; image.height = 20;
|
||||
const ctx = image.getContext('2d'); ctx.fillStyle = 'red'; ctx.fillRect(0,0,80,20);
|
||||
const selection = document.createElement('canvas'); selection.width = selection.height = 40;
|
||||
selection.getContext('2d').fillRect(0,0,40,40);
|
||||
let placed = null, body = null;
|
||||
const run = createImageGenerationAction({
|
||||
getDocument:()=>({key:'one',width:40,height:40,mask:selection}),
|
||||
fetchImpl:async (url,opts) => {
|
||||
body = JSON.parse(opts.body);
|
||||
selection.getContext('2d').clearRect(0,0,40,40);
|
||||
return {ok:true,json:async()=>({image:image.toDataURL()})};
|
||||
},
|
||||
placeResult:(canvas,name,mask)=>{
|
||||
const data = canvas.getContext('2d');
|
||||
placed = {width:canvas.width,height:canvas.height,left:data.getImageData(0,10,1,1).data[0],
|
||||
right:data.getImageData(79,10,1,1).data[0], mask:mask.getContext('2d').getImageData(20,20,1,1).data[3]};
|
||||
return 'new-layer';
|
||||
},
|
||||
});
|
||||
const id = await run({prompt:'a cow',masked:true});
|
||||
return {id,placed,body};
|
||||
}""")
|
||||
assert result['id'] == 'new-layer'
|
||||
assert result['placed'] == dict(width=80,height=20,left=255,right=255,mask=255)
|
||||
assert result['body'] == dict(_use_image_settings=True,prompt='a cow',width=40,height=40)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('action', ['cancel','timeout','invalid-image','document-switch'])
|
||||
def test_generation_finishes_cleanly_on_failure_without_late_placement(page, action):
|
||||
result = page.evaluate("""async action => {
|
||||
const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js');
|
||||
let key = 'original', placements = 0;
|
||||
const controller = new AbortController();
|
||||
const run = createImageGenerationAction({
|
||||
getDocument:()=>({key,width:40,height:40}), getSignal:()=>controller.signal,
|
||||
timeoutMs:action === 'timeout' ? 20 : 2000,
|
||||
fetchImpl:async (url,opts)=>{
|
||||
if (action === 'timeout' || action === 'cancel') {
|
||||
if (action === 'cancel') setTimeout(()=>controller.abort(),10);
|
||||
return new Promise((resolve,reject)=>opts.signal.addEventListener('abort',
|
||||
()=>reject(new DOMException('Aborted','AbortError')), {once:true}));
|
||||
}
|
||||
if (action === 'invalid-image') return {ok:true,json:async()=>({image:'bad-image-data'})};
|
||||
const image = document.createElement('canvas'); image.width = image.height = 4;
|
||||
key = 'different';
|
||||
return {ok:true,json:async()=>({image:image.toDataURL()})};
|
||||
},
|
||||
placeResult:()=>placements++,
|
||||
});
|
||||
let error = '';
|
||||
try {await run({prompt:'a cow'});} catch (e) {error = e.message;}
|
||||
return {error,placements};
|
||||
}""", action)
|
||||
assert result['placements'] == 0
|
||||
assert result['error']
|
||||
if action == 'timeout':
|
||||
assert 'timed out' in result['error']
|
||||
if action == 'document-switch':
|
||||
assert 'document changed' in result['error']
|
||||
|
||||
|
||||
def test_generation_provider_error_is_not_retried_and_next_request_can_succeed(page):
|
||||
result = page.evaluate("""async () => {
|
||||
const {createImageGenerationAction} = await import('/static/js/editor/image-generation.js');
|
||||
let calls = 0, placements = 0;
|
||||
const image = document.createElement('canvas'); image.width = image.height = 4;
|
||||
const run = createImageGenerationAction({
|
||||
getDocument:()=>({key:'one',width:40,height:40}),
|
||||
formatError:({status})=>status === 503 ? 'Generation is disabled' : 'error',
|
||||
fetchImpl:async ()=> ++calls === 1
|
||||
? {ok:false,status:503,json:async()=>({detail:'Disabled'})}
|
||||
: {ok:true,json:async()=>({image:image.toDataURL()})},
|
||||
placeResult:()=>++placements,
|
||||
});
|
||||
let error;
|
||||
try {await run({prompt:'one'});} catch (e) {error = e.message;}
|
||||
const callsAfterFailure = calls;
|
||||
await run({prompt:'two'});
|
||||
return {error,callsAfterFailure,calls,placements};
|
||||
}""")
|
||||
assert result == dict(error='Generation is disabled',callsAfterFailure=1,calls=2,placements=1)
|
||||
|
||||
|
||||
def test_late_progress_response_cannot_restore_working_after_completion(page):
|
||||
source = (ROOT / 'static/js/galleryEditor.js').read_text()
|
||||
functions = source.split('function _startAiProgress(requestId) {',1)[1].split('function _setAiCommandStatus(',1)[0]
|
||||
functions = 'function _startAiProgress(requestId) {' + functions
|
||||
result = page.evaluate("""async functions => {
|
||||
let _aiProgressTimer = null, _aiProgressEpoch = 0, status = 'Working';
|
||||
let resolveRequest;
|
||||
const fetch = () => new Promise(resolve => resolveRequest = resolve);
|
||||
const _setAiProgress = () => {};
|
||||
const _setAiCommandStatus = text => status = text;
|
||||
const api = eval(functions + '; ({start:_startAiProgress,stop:_stopAiProgress});');
|
||||
api.start('old-request');
|
||||
api.stop(); status = 'Done';
|
||||
resolveRequest({ok:true,json:async()=>({stage:'editing',progress:0.5})});
|
||||
await new Promise(resolve=>setTimeout(resolve,20));
|
||||
return status;
|
||||
}""", functions)
|
||||
assert result == 'Done'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('success', [True,False])
|
||||
def test_quick_edit_queue_clears_working_on_completion(page, success):
|
||||
source = (ROOT / 'static/js/galleryEditor.js').read_text()
|
||||
pump = 'const pumpQuickEdit = ' + source.split('const pumpQuickEdit = ',1)[1].split(' const enqueueQuickEditJob',1)[0]
|
||||
result = page.evaluate("""async ({pump,success}) => {
|
||||
const {finishQuickEditJob} = await import('/static/js/editor/quick-edit-job.js');
|
||||
const jobs = [{id:'one',prompt:'fill',status:'queued'}];
|
||||
let pumping = false, _quickEditActiveJob = null, stopped = 0, terminal;
|
||||
const renderJobStack = () => {};
|
||||
const dismissJob = () => {};
|
||||
const _stopAiProgress = () => stopped++;
|
||||
const _setAiCommandStatus = (text,kind) => terminal = {text,kind};
|
||||
const executeQuickEdit = async () => success ? {ok:true} : {ok:false,error:'Provider failed'};
|
||||
await eval(pump + '; pumpQuickEdit();');
|
||||
return {status:jobs[0].status,detail:jobs[0].detail,terminal,stopped,pumping};
|
||||
}""", {'pump':pump,'success':success})
|
||||
expected = 'done' if success else 'error'
|
||||
message = 'Done' if success else 'Provider failed'
|
||||
assert result == dict(status=expected,detail=message,terminal=dict(text=message,kind=expected),stopped=1,pumping=False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bridge', [False,True])
|
||||
@pytest.mark.parametrize('instruction', [False,True])
|
||||
@pytest.mark.parametrize('matching_ratio', [False,True])
|
||||
def test_generated_layer_can_keep_pixels_outside_document_bounds(page, bridge, instruction, matching_ratio):
|
||||
source = (ROOT / 'static/js/galleryEditor.js').read_text()
|
||||
placement = 'const _placeResultLayer = ' + source.split('const _placeResultLayer = ',1)[1].split(' const _generateImage',1)[0]
|
||||
bridge_line = next(line for line in source.splitlines() if "'layers.placeImage':" in line)
|
||||
result = page.evaluate("""async ({placement,bridgeLine,bridge,instruction,matchingRatio}) => {
|
||||
const {imageResultGeometry} = await import('/static/js/editor/image-result-geometry.js');
|
||||
const state = {imgWidth:matchingRatio ? 1280 : 40,imgHeight:matchingRatio ? 720 : 40,layers:[],layerOffsets:new Map()};
|
||||
let saved = 0;
|
||||
const _saveState = ()=>saved++;
|
||||
const composite = ()=>{};
|
||||
const _renderLayerPanel = ()=>{};
|
||||
const createLayer = (name,w,h)=>{
|
||||
const canvas = document.createElement('canvas'); canvas.width=w;canvas.height=h;
|
||||
return {id:'new',name,canvas,ctx:canvas.getContext('2d')};
|
||||
};
|
||||
const image = document.createElement('canvas'); image.width=matchingRatio ? 1536 : 80;image.height=matchingRatio ? 864 : 20;
|
||||
image.getContext('2d').fillRect(0,0,image.width,image.height);
|
||||
if (bridge) {
|
||||
const run = eval('({' + bridgeLine + '})')["layers.placeImage"].run;
|
||||
await run({dataUrl:image.toDataURL(),nativeSize:true,masked:false,
|
||||
mode:instruction ? 'instruction' : undefined,differenceMask:instruction});
|
||||
} else {
|
||||
eval(placement + '; _placeResultLayer(image,"Generated",null);');
|
||||
}
|
||||
const layer = state.layers[0];
|
||||
return {width:layer.canvas.width,height:layer.canvas.height,offset:state.layerOffsets.get(layer.id),
|
||||
outsideAlpha:layer.ctx.getImageData(79,10,1,1).data[3],saved};
|
||||
}""", {'placement':placement,'bridgeLine':bridge_line,'bridge':bridge,'instruction':instruction,'matchingRatio':matching_ratio})
|
||||
expected = dict(width=1280,height=720,offset=dict(x=0,y=0),outsideAlpha=255,saved=1) if matching_ratio else dict(width=80,height=20,offset=dict(x=-20,y=10),outsideAlpha=255,saved=1)
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_whole_image_edit_does_not_clip_native_result_to_select_all(page):
|
||||
source = (ROOT / 'static/js/galleryEditor.js').read_text()
|
||||
operation = 'const _aiInpaintSelection = ' + source.split('const _aiInpaintSelection = ',1)[1].split(' // Replace the background/scene',1)[0]
|
||||
result = page.evaluate("""async operation => {
|
||||
const mask = document.createElement('canvas'); mask.width=40;mask.height=40;
|
||||
const canvas = document.createElement('canvas'); canvas.width=80;canvas.height=20;
|
||||
const state = {wandMask:mask};
|
||||
const activeLayer = ()=>({id:'one'});
|
||||
const _aiEditCanvas = async ()=>({canvas,fullFrame:true});
|
||||
const _placeResultLayer = (result,name,resultMask)=>({width:result.width,height:result.height,masked:!!resultMask});
|
||||
return await eval(operation + '; _aiInpaintSelection("change the sky");');
|
||||
}""", operation)
|
||||
assert result == dict(width=80,height=20,masked=False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('matching_ratio', [False,True])
|
||||
def test_inpaint_panel_preserves_native_whole_edit_dimensions(page, matching_ratio):
|
||||
source = (ROOT / 'static/js/editor/ai-inpaint.js').read_text()
|
||||
body = 'const fullFrame = ' + source.split('const fullFrame = ',1)[1].split(' // Snapshot the AI result',1)[0]
|
||||
result = page.evaluate("""async ({body,matchingRatio}) => {
|
||||
const {imageResultGeometry} = await import('/static/js/editor/image-result-geometry.js');
|
||||
const state = {imgWidth:matchingRatio ? 1280 : 40,imgHeight:matchingRatio ? 720 : 40,layerOffsets:new Map()};
|
||||
const data = {full_frame:true}, layerName = 'Edit';
|
||||
const createLayer = (name,w,h)=>{
|
||||
const canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
|
||||
return {id:'result',canvas,ctx:canvas.getContext('2d')};
|
||||
};
|
||||
const source = document.createElement('canvas'); source.width=matchingRatio ? 1536 : 80;source.height=matchingRatio ? 864 : 20;
|
||||
source.getContext('2d').fillRect(0,0,source.width,source.height);
|
||||
const resultImg = new Image(); resultImg.src=source.toDataURL(); await resultImg.decode();
|
||||
return eval(body + '; ({width:resultLayer.canvas.width,height:resultLayer.canvas.height,offset:state.layerOffsets.get(resultLayer.id)});');
|
||||
}""", {'body':body,'matchingRatio':matching_ratio})
|
||||
assert result == (dict(width=1280,height=720,offset=dict(x=0,y=0)) if matching_ratio else dict(width=80,height=20,offset=dict(x=-20,y=10)))
|
||||
|
||||
|
||||
def test_brush_softness_is_in_main_bar_and_saves_extended_value(page):
|
||||
page.add_style_tag(content=(ROOT / 'static/css/13-image-editor.css').read_text())
|
||||
result = page.evaluate("""async () => {
|
||||
const {controlsHTML} = await import('/static/js/editor/build/controls.js');
|
||||
const {wireStrokeToolSliders} = await import('/static/js/editor/stroke-tool-sliders.js');
|
||||
const {state} = await import('/static/js/editor/state.js');
|
||||
const bar = document.createElement('div');bar.className='ge-options-bar';
|
||||
const controls = document.createElement('div');controls.className='ge-controls ge-controls--in-options';
|
||||
controls.innerHTML=controlsHTML({color:'#000000',brushSize:40,wandTolerance:20});
|
||||
for(const child of controls.children) if(child.id !== 'ge-brush-controls') child.style.display='none';
|
||||
bar.append(controls);document.body.append(bar);
|
||||
wireStrokeToolSliders();
|
||||
const slider=document.getElementById('ge-brush-softness');
|
||||
slider.value='1200';slider.dispatchEvent(new Event('input',{bubbles:true}));
|
||||
return {inMore:!!slider.closest('#ge-brush-more-menu'),max:slider.max,value:state.brushSoftness,
|
||||
visible:slider.getBoundingClientRect().height>0,label:document.getElementById('ge-brush-softness-label').textContent};
|
||||
}""")
|
||||
assert result == dict(inMore=False,max='1200',value=1200,visible=True,label='1200%')
|
||||
page.screenshot(path='/tmp/editor-brush-softness-main-bar.png')
|
||||
|
||||
|
||||
def test_extended_softness_produces_softer_pixels_not_just_larger_slider_values(page):
|
||||
result = page.evaluate("""async () => {
|
||||
const {state}=await import('/static/js/editor/state.js');
|
||||
const {createStrokePipeline}=await import('/static/js/editor/stroke-pipeline.js');
|
||||
const results=[];
|
||||
for(const softness of [300,1200]) {
|
||||
const canvas=document.createElement('canvas');canvas.width=canvas.height=100;
|
||||
const layer={id:'brush',canvas,ctx:canvas.getContext('2d')};
|
||||
Object.assign(state,{tool:'brush',brushSize:60,brushSoftness:softness,brushOpacity:100,brushFlow:100,
|
||||
brushBlendMode:'source-over',color:'#000000',brushSpacing:15,brushSmoothing:0,
|
||||
brushPixel:false,pressureSize:false,pressureOpacity:false,pressureFlow:false,layerOffsets:new Map()});
|
||||
const pipeline=createStrokePipeline({activeLayer:()=>layer,getActiveMaskLayer:()=>null,composite:()=>{}});
|
||||
pipeline.beginStroke({x:50,y:50,pressure:1},'brush');pipeline.endStroke(null);
|
||||
const alpha=x=>layer.ctx.getImageData(x,50,1,1).data[3];
|
||||
results.push({middle:alpha(65),center:alpha(50),outside:alpha(85)});
|
||||
document.body.append(canvas);
|
||||
}
|
||||
return results;
|
||||
}""")
|
||||
assert result[1]['middle'] < result[0]['middle'] / 3
|
||||
assert result[1]['center'] > 150
|
||||
assert result[0]['outside'] == result[1]['outside'] == 0
|
||||
page.screenshot(path='/tmp/editor-brush-softness-falloff.png')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('width', [1000,840])
|
||||
def test_brush_numbers_do_not_overlap_after_real_panel_normalization(page,width):
|
||||
page.set_viewport_size({'width':width,'height':700})
|
||||
for stylesheet in ['static/css/13-image-editor.css','static/css/documents-gallery-editor.css']:
|
||||
page.add_style_tag(content=(ROOT / stylesheet).read_text())
|
||||
page.add_style_tag(content=':root{--bg:#171719;--panel:#242426;--fg:#eee;--red:#d47965;--border:#454549;}body{background:var(--bg);}')
|
||||
result = page.evaluate("""async () => {
|
||||
const {state}=await import('/static/js/editor/state.js');
|
||||
const {controlsHTML}=await import('/static/js/editor/build/controls.js');
|
||||
const {buildRightPanel}=await import('/static/js/editor/build/right-panel.js');
|
||||
const {wireStrokeToolSliders}=await import('/static/js/editor/stroke-tool-sliders.js');
|
||||
state.container=document.createElement('div');document.body.append(state.container);
|
||||
state.brushSize=800;
|
||||
const {controls}=buildRightPanel({controlsHTML,layerPanelHTML:()=>'<div></div>'});
|
||||
controls.classList.add('ge-controls--in-options');
|
||||
const bar=document.createElement('div');bar.className='ge-options-bar';bar.append(controls);state.container.append(bar);
|
||||
for(const child of controls.children) if(child.id !== 'ge-brush-controls') child.style.display='none';
|
||||
wireStrokeToolSliders();
|
||||
const slider=document.getElementById('ge-brush-softness');
|
||||
const elements=[document.querySelector('#ge-brush-softness-row label'),slider,
|
||||
document.getElementById('ge-brush-softness-label'),document.querySelector('.ge-brush-size-row label'),
|
||||
document.querySelector('.ge-brush-size-row input')];
|
||||
const bounds=()=>elements.map(element=>{
|
||||
const r=element.getBoundingClientRect();return {x:r.x,right:r.right,width:r.width};
|
||||
});
|
||||
slider.value='1';slider.dispatchEvent(new Event('input'));const small=bounds();
|
||||
slider.value='1200';slider.dispatchEvent(new Event('input'));const large=bounds();
|
||||
return {small,large,value:document.getElementById('ge-brush-softness-label').textContent};
|
||||
}""")
|
||||
assert result['value'] == '1200%'
|
||||
assert all(bounds['width'] > 0 for bounds in result['large']), result
|
||||
for left,right in zip(result['large'],result['large'][1:]):
|
||||
assert left['right'] <= right['x'] + 1, result
|
||||
for small,large in zip(result['small'],result['large']):
|
||||
assert abs(small['x']-large['x']) <= 1, result
|
||||
page.screenshot(path=f'/tmp/editor-brush-number-layout-{width}.png')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('fail_once', [False, True])
|
||||
def test_new_project_create_initializes_canvas(page, fail_once):
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.route('http://editor.test/api/**', lambda route: route.fulfill(json={}))
|
||||
page.add_style_tag(content='''
|
||||
.modal {position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:250;pointer-events:none;}
|
||||
.modal-content {pointer-events:auto;background:#333;padding:20px;}
|
||||
#gallery-modal {z-index:700;}
|
||||
#gallery-modal > .modal-content {width:90vw;height:90vh;}
|
||||
''')
|
||||
page.evaluate("""async () => {
|
||||
document.body.innerHTML = '<div id="toast"></div><div class="modal" id="gallery-modal"><div class="modal-content"><div id="gallery-editor-container"></div></div></div>';
|
||||
const editor = await import('/static/js/galleryEditor.js');
|
||||
window.newProject = editor.openEditor(null, null, null, 'New canvas');
|
||||
window.newProject.catch(error => { window.createError = error.stack; });
|
||||
}""")
|
||||
if fail_once:
|
||||
page.evaluate("""() => {
|
||||
const fill = CanvasRenderingContext2D.prototype.fillRect;
|
||||
CanvasRenderingContext2D.prototype.fillRect = function(...args) {
|
||||
if (this.canvas.width === 1024 && this.canvas.height === 1024) {
|
||||
CanvasRenderingContext2D.prototype.fillRect = fill;
|
||||
throw Error('Canvas allocation test failure');
|
||||
}
|
||||
return fill.apply(this, args);
|
||||
};
|
||||
}""")
|
||||
page.locator('#ge-canvas-prompt-ok').click(timeout=3000)
|
||||
if fail_once:
|
||||
page.wait_for_function("window.createError")
|
||||
assert 'Canvas allocation test failure' in page.evaluate('window.createError')
|
||||
assert 'Could not create project' in page.locator('body').inner_text()
|
||||
page.evaluate("""async () => {
|
||||
window.createError = null;
|
||||
const editor = await import('/static/js/galleryEditor.js');
|
||||
window.newProject = editor.openEditor(null, null, null, 'New canvas');
|
||||
window.newProject.catch(error => { window.createError = error.stack; });
|
||||
}""")
|
||||
page.locator('#ge-canvas-prompt-ok').click(timeout=3000)
|
||||
page.wait_for_timeout(500)
|
||||
result = page.evaluate("""async () => {
|
||||
const {state} = await import('/static/js/editor/state.js');
|
||||
return {error:window.createError, width:state.mainCanvas?.width,
|
||||
height:state.mainCanvas?.height, layers:state.layers.map(layer=>layer.name)};
|
||||
}""")
|
||||
assert not errors, errors
|
||||
assert not result.get('error'), result
|
||||
assert result == {'error':None,'width':1024,'height':1024,'layers':['Background','Edit']}
|
||||
@@ -143,8 +143,8 @@ def test_crop_can_start_outside_the_canvas_without_panning():
|
||||
# image (so it can be pulled out past the edge) instead of the canvas-area
|
||||
# pan grabbing the pointer and switching to hand mode.
|
||||
CE = (ROOT / "static/js/editor/canvas-events.js").read_text(encoding="utf-8")
|
||||
assert "if (state.tool !== 'lasso' && state.tool !== 'crop') return;" in CE
|
||||
assert "(state.tool === 'lasso' || state.tool === 'crop') && !directPan" in CE
|
||||
assert "['marquee', 'lasso', 'crop'].includes(state.tool)" in CE
|
||||
assert "if (canStartOutside() && !directPan)" in CE
|
||||
# Pressing editor chrome inside the area (e.g. the crop Apply panel) must
|
||||
# not start a fresh crop.
|
||||
assert ".ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup" in CE
|
||||
|
||||
@@ -32,13 +32,12 @@ def test_preflight_module_has_the_four_setup_steps():
|
||||
assert "required ready" in src
|
||||
|
||||
|
||||
def test_preflight_is_wired_into_the_editor():
|
||||
def test_editor_uses_ai_settings_without_redundant_setup():
|
||||
editor = _read("static/js/galleryEditor.js")
|
||||
assert "import { createPreflight } from './editor/preflight.js';" in editor
|
||||
assert "createPreflight({ canvasArea })" in editor
|
||||
assert "_preflight?.maybeOpen();" in editor
|
||||
assert "setupBtn.id = 'ge-setup-btn';" in editor
|
||||
assert "_preflight?.destroy();" in editor
|
||||
assert "createPreflight" not in editor
|
||||
assert "setupBtn.id = 'ge-setup-btn';" not in editor
|
||||
assert "createAiSettings({ container })" in editor
|
||||
assert "aiBtn.addEventListener('click', () => _aiSettings?.open());" in editor
|
||||
|
||||
|
||||
def test_ai_models_exposes_status_for_the_checklist():
|
||||
|
||||
@@ -49,7 +49,42 @@ def test_generate_route_forwards_the_picked_model():
|
||||
assert 'model_choice = str((body or {}).get("model") or "").strip()' in route
|
||||
assert 'payload["model"] = model_choice' in route
|
||||
assert "content = _json.dumps(payload)" in route
|
||||
assert "localStorage.getItem('ge-ai-command-model')" in JS
|
||||
operation = JS.split("'ai.generateImage':", 1)[1].split("\n", 1)[0]
|
||||
generation = (ROOT / 'static/js/editor/image-generation.js').read_text()
|
||||
assert '_generateImage(p || {})' in operation
|
||||
assert "_use_image_settings: true" in generation
|
||||
assert "document.getElementById('ge-ai-model')" not in operation
|
||||
assert "_getSelectedQuickEditModel()" not in operation
|
||||
assert "localStorage.getItem" not in operation
|
||||
|
||||
|
||||
def test_ai_settings_separates_generation_from_editing_without_setup():
|
||||
settings = (ROOT / "static/js/editor/ai-settings.js").read_text()
|
||||
assert 'id="ge-ai-model"' in settings
|
||||
assert 'id="ge-ai-command-model"' in settings
|
||||
assert "Image generation model" in settings
|
||||
assert "createPreflight" not in JS
|
||||
assert "ge-setup-btn';" not in JS.split("const aiBtn", 1)[0]
|
||||
|
||||
|
||||
def test_generate_uses_live_picker_and_auto_edit_fallback():
|
||||
import subprocess
|
||||
|
||||
module = (ROOT / "static/js/editor/ai-models.js").as_uri()
|
||||
script = """
|
||||
import assert from 'node:assert/strict';
|
||||
const {resolveQuickEditModel} = await import(MODULE);
|
||||
const selected = 'https://openrouter.ai/api/v1::openai/gpt-5-image';
|
||||
const option = {value: selected, textContent: 'GPT-5 Image'};
|
||||
const picker = {value: selected, selectedIndex: 0, options: [option]};
|
||||
const fallback = {value: '', options: [option]};
|
||||
globalThis.document = {getElementById: id => id === 'ge-ai-command-model' ? picker : fallback};
|
||||
globalThis.localStorage = {getItem: () => {throw new Error('must not read stale storage');}};
|
||||
assert.equal(resolveQuickEditModel().model, 'openai/gpt-5-image');
|
||||
picker.value = '';
|
||||
assert.equal(resolveQuickEditModel().model, '');
|
||||
""".replace("MODULE", repr(module))
|
||||
subprocess.run(["node", "--input-type=module", "-e", script], check=True)
|
||||
|
||||
|
||||
def test_cloud_image_model_is_resolved_before_the_local_fallback():
|
||||
|
||||
@@ -52,7 +52,7 @@ def test_quick_edit_flip_and_rotate_accept_numeric_degrees():
|
||||
|
||||
def test_quick_edit_transforms_do_not_add_a_second_undo_snapshot():
|
||||
start = EDITOR.index("if (/\\brotate\\b.*\\b180\\b")
|
||||
end = EDITOR.index("if (_looksBackgroundRemoval(p)) {", start)
|
||||
end = EDITOR.index("if (!/\\b(text|title|caption|font|heading|subtitle)", start)
|
||||
transform_block = EDITOR[start:end]
|
||||
assert "_saveState('Rotate" not in transform_block
|
||||
assert "_saveState('Flip" not in transform_block
|
||||
@@ -372,9 +372,10 @@ def test_quick_edit_has_a_model_picker():
|
||||
assert "function _getSelectedQuickEditModel()" in EDITOR
|
||||
assert "getModel: () => _getSelectedQuickEditModel()," in EDITOR
|
||||
# Populated from the provider list and persisted per user.
|
||||
assert "aiCommandSelect.innerHTML = '<option value=\"\">Auto (default)</option>';" in models
|
||||
assert "localStorage.setItem('ge-ai-command-model', aiCommandSelect.value)" in models
|
||||
assert "localStorage.getItem('ge-ai-command-model')" in models
|
||||
assert "aiCommandSelect.innerHTML = '<option value=\"\">Same as generation</option>';" in models
|
||||
assert "localStorage.setItem('ge-ai-command-model'" not in models
|
||||
assert "localStorage.getItem('ge-ai-command-model')" not in models
|
||||
assert 'saveImagePreferences({ [key]: model })' in AI_SETTINGS
|
||||
# Styled.
|
||||
assert ".ge-ai-settings-card" in STYLE
|
||||
assert ".ge-ai-command-model" in STYLE
|
||||
@@ -698,7 +699,7 @@ def test_quick_edit_shows_a_real_progress_bar():
|
||||
assert "fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}`" in EDITOR
|
||||
# The edit request carries the request_id the server keys progress on.
|
||||
assert "const requestId = 'qe-' + Date.now().toString(36)" in EDITOR
|
||||
assert "const body = JSON.stringify({ image, request_id: requestId, ...payload });" in EDITOR
|
||||
assert "const body = JSON.stringify({ image, width: flat.width, height: flat.height, request_id: requestId, ...payload });" in EDITOR
|
||||
assert "_startAiProgress(requestId);" in EDITOR
|
||||
assert "_stopAiProgress();" in EDITOR
|
||||
# Styling exists.
|
||||
@@ -718,7 +719,7 @@ def test_generation_503_is_handled_gracefully():
|
||||
# (returns 503). Surface a clear message and do not retry immediately.
|
||||
assert "if (status === 503) return _GEN_DISABLED_MSG;" in EDITOR
|
||||
assert "_GEN_DISABLED_MSG = 'Image generation is switched off on the server at the moment.'" in EDITOR
|
||||
assert "window.__geGenerationDisabled" in EDITOR
|
||||
assert "formatError: _generationErrorMessage" in EDITOR
|
||||
|
||||
|
||||
def test_sending_a_prompt_returns_focus_to_the_document():
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
import routes.gallery_routes as gallery
|
||||
@@ -16,7 +17,10 @@ def png(image):
|
||||
return base64.b64encode(output.getvalue()).decode()
|
||||
|
||||
|
||||
def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog):
|
||||
@pytest.mark.parametrize('model', ['openai/gpt-5-image', 'openai/gpt-image-2'])
|
||||
@pytest.mark.parametrize('saved_settings', [False, True])
|
||||
@pytest.mark.parametrize('full_frame', [False, True])
|
||||
def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog, model, saved_settings, full_frame):
|
||||
import logging
|
||||
caplog.set_level(logging.DEBUG)
|
||||
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', api_key='test')
|
||||
@@ -29,36 +33,97 @@ def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog):
|
||||
monkeypatch.setattr(gallery, 'require_privilege', lambda *args: 'alice')
|
||||
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *args: endpoint)
|
||||
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *args, **kwargs: (True, ''))
|
||||
monkeypatch.setattr('src.editor_image_settings.load_settings', lambda: {'image_model':'wrong-generation-model', 'image_edit_model':model})
|
||||
def resolve_model(selected, **kwargs):
|
||||
assert selected == model
|
||||
assert kwargs['owner'] == 'alice'
|
||||
return endpoint.base_url + '/chat/completions', model, {}
|
||||
monkeypatch.setattr('src.ai_interaction._resolve_model', resolve_model)
|
||||
requests = []
|
||||
|
||||
def respond(request):
|
||||
requests.append(request)
|
||||
assert str(request.url) == endpoint.base_url + '/images'
|
||||
assert str(request.url) == endpoint.base_url + ('/chat/completions' if model == 'openai/gpt-5-image' else '/images')
|
||||
payload = json.loads(request.content)
|
||||
assert payload['model'] == 'openai/gpt-5-image'
|
||||
assert len(payload['input_references']) == 2
|
||||
assert payload['model'] == model
|
||||
if model == 'openai/gpt-5-image':
|
||||
assert len(payload['messages'][0]['content']) == 3
|
||||
else:
|
||||
assert len(payload['input_references']) == 2
|
||||
assert request.headers['authorization'] == 'Bearer test'
|
||||
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (2, 1), 'blue'))}]})
|
||||
image = png(Image.new('RGB', (4, 4) if full_frame else (2, 1), 'blue'))
|
||||
if model == 'openai/gpt-5-image':
|
||||
return httpx.Response(200, json={'choices':[{'message':{'images':[{'image_url':{'url':'data:image/png;base64,'+image}}]}}]})
|
||||
return httpx.Response(200, json={'data': [{'b64_json': image}]})
|
||||
|
||||
client = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kwargs: client(transport=httpx.MockTransport(respond), **kwargs))
|
||||
mask = Image.new('L', (2, 1))
|
||||
if full_frame:
|
||||
mask.paste(255, (0,0,2,1))
|
||||
mask.putpixel((1, 0), 255)
|
||||
|
||||
class Request:
|
||||
async def json(self):
|
||||
return {'_endpoint': endpoint.base_url, '_model': 'openai/gpt-5-image',
|
||||
return {'_endpoint': endpoint.base_url, '_model': 'stale-model' if saved_settings else model,
|
||||
'_use_image_settings':saved_settings,
|
||||
'image': png(Image.new('RGB', (2, 1), 'red')), 'mask': png(mask), 'prompt': 'private-cloud-prompt'}
|
||||
|
||||
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
|
||||
result = asyncio.run(route.endpoint(Request()))
|
||||
output = Image.open(io.BytesIO(base64.b64decode(result['image'])))
|
||||
assert output.getpixel((0, 0)) == (255, 0, 0, 255)
|
||||
assert output.getpixel((1, 0)) == (0, 0, 255, 255)
|
||||
if full_frame:
|
||||
assert output.size == (4,4)
|
||||
assert result['full_frame'] is True
|
||||
else:
|
||||
assert output.getpixel((0, 0)) == (255, 0, 0, 255)
|
||||
assert output.getpixel((1, 0)) == (0, 0, 255, 255)
|
||||
assert len(requests) == 1
|
||||
assert 'private-cloud-prompt' not in caplog.text
|
||||
|
||||
|
||||
def test_partial_image_edit_preserves_ratio_and_unmasked_pixels():
|
||||
source = Image.new('RGBA',(4,4),'red')
|
||||
generated = Image.new('RGBA',(4,2),'blue')
|
||||
mask = Image.new('L',(4,4),255)
|
||||
mask.putpixel((0,0),0)
|
||||
result, full = gallery._compose_cloud_image_edit(source,generated,mask)
|
||||
assert full is False
|
||||
assert result.size == (4,4)
|
||||
assert result.getpixel((2,0)) == source.getpixel((2,0))
|
||||
assert result.getpixel((2,3)) == source.getpixel((2,3))
|
||||
assert result.getpixel((2,1)) == (0,0,255,255)
|
||||
|
||||
|
||||
def test_quick_edit_inpaint_requests_source_ratio_without_width_height_fields(monkeypatch):
|
||||
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1',api_key='test')
|
||||
monkeypatch.setattr(gallery,'SessionLocal',lambda:SimpleNamespace(close=lambda:None))
|
||||
monkeypatch.setattr(gallery,'require_privilege',lambda *args:'alice')
|
||||
monkeypatch.setattr(gallery,'_visible_image_endpoint_for_base',lambda *args:endpoint)
|
||||
monkeypatch.setattr('src.url_safety.check_outbound_url',lambda *args,**kwargs:(True,''))
|
||||
sent = []
|
||||
original = httpx.AsyncClient
|
||||
|
||||
def respond(request):
|
||||
payload = json.loads(request.content)
|
||||
sent.append(payload)
|
||||
return httpx.Response(200,json={'data':[{'b64_json':png(Image.new('RGB',(1536,864),'blue'))}]})
|
||||
|
||||
monkeypatch.setattr(httpx,'AsyncClient',lambda **kwargs:original(transport=httpx.MockTransport(respond),**kwargs))
|
||||
|
||||
class Request:
|
||||
async def json(self):
|
||||
return {'_endpoint':endpoint.base_url,'_model':'openai/gpt-image-2','prompt':'change the sky',
|
||||
'image':png(Image.new('RGB',(1280,720),'red')),'mask':png(Image.new('L',(1280,720),255))}
|
||||
|
||||
route = next(route for route in gallery.setup_gallery_routes().routes if route.path == '/api/image/inpaint')
|
||||
result = asyncio.run(route.endpoint(Request()))
|
||||
assert sent[0]['aspect_ratio'] == '16:9'
|
||||
assert 'size' not in sent[0]
|
||||
image = Image.open(io.BytesIO(base64.b64decode(result['image'])))
|
||||
assert image.size == (1536,864)
|
||||
|
||||
|
||||
def test_mixed_endpoint_requires_configured_image_model(monkeypatch):
|
||||
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', owner='alice',
|
||||
model_type='llm', cached_models='["openai/gpt-5-image", "text-model"]', pinned_models=None)
|
||||
|
||||
@@ -23,7 +23,7 @@ def test_native_generation_uses_configured_model_and_multiline_prompt(image_tran
|
||||
json.dumps({'prompt': prompt}), session_id='fixture', owner='alice'))
|
||||
assert 'error' not in result
|
||||
assert seen == [('openai/gpt-5-image', 'alice')]
|
||||
assert json.loads(requests[0].content)['prompt'] == prompt
|
||||
assert json.loads(requests[0].content)['messages'][0]['content'] == prompt
|
||||
assert result['image_id']
|
||||
|
||||
|
||||
@@ -87,13 +87,17 @@ def image_transport(monkeypatch, tmp_path):
|
||||
original_client = httpx.AsyncClient
|
||||
requests = []
|
||||
|
||||
def configure(base, status=200, body=None):
|
||||
def configure(base, status=200, body=None, model='openai/gpt-5-image'):
|
||||
monkeypatch.setattr(ai_interaction, '_resolve_model', lambda *args, **kwargs: (
|
||||
base + '/chat/completions', 'openai/gpt-5-image', {'Authorization': 'Bearer test'},
|
||||
base + '/chat/completions', model, {'Authorization': 'Bearer test'},
|
||||
))
|
||||
|
||||
def handle(request):
|
||||
requests.append(request)
|
||||
if body is None and 'openrouter.ai' in base and request.url.path.endswith('/chat/completions'):
|
||||
return httpx.Response(status, json={'choices': [{'message': {'images': [{
|
||||
'image_url': {'url': 'data:image/png;base64,' + base64.b64encode(source.read_bytes()).decode()},
|
||||
}]}}]})
|
||||
return httpx.Response(status, json=body if body is not None else {
|
||||
'data': [{'b64_json': base64.b64encode(source.read_bytes()).decode()}],
|
||||
})
|
||||
@@ -130,12 +134,13 @@ def test_image_provider_protocol(image_transport, editing, base):
|
||||
assert request.headers['authorization'] == 'Bearer test'
|
||||
if 'openrouter.ai' in base:
|
||||
assert len(requests) == 1 # No unsupported local progress/fallback probes.
|
||||
assert str(request.url) == base + '/images'
|
||||
assert str(request.url) == base + '/chat/completions'
|
||||
payload = json.loads(request.content)
|
||||
assert payload['n'] == 1
|
||||
assert payload['model'] == 'openai/gpt-5-image'
|
||||
assert payload['modalities'] == ['image', 'text']
|
||||
assert 'prompt' not in payload
|
||||
if editing:
|
||||
assert payload['size'] == '1536x1024'
|
||||
reference = payload['input_references'][0]['image_url']['url']
|
||||
reference = payload['messages'][0]['content'][1]['image_url']['url']
|
||||
assert reference.startswith('data:image/png;base64,')
|
||||
assert base64.b64decode(reference.split(',', 1)[1]) == source.read_bytes()
|
||||
assert 'request_id' not in payload
|
||||
@@ -181,13 +186,65 @@ def test_edit_dimensions_respect_exif_and_explicit_size(image_transport, explici
|
||||
exif = Image.Exif()
|
||||
exif[274] = 6
|
||||
Image.new('RGB', (120, 80)).save(source, exif=exif)
|
||||
configure('https://openrouter.ai/api/v1')
|
||||
configure('https://openrouter.ai/api/v1', model='openai/gpt-image-1')
|
||||
result = asyncio.run(ai_interaction.do_edit_image(
|
||||
'Edit', str(source), model_spec='openai/gpt-5-image',
|
||||
'Edit', str(source), model_spec='openai/gpt-image-1',
|
||||
size='1024x1024' if explicit else 'auto',
|
||||
))
|
||||
assert 'error' not in result
|
||||
assert json.loads(requests[0].content)['size'] == ('1024x1024' if explicit else '1024x1536')
|
||||
assert json.loads(requests[0].content)['aspect_ratio'] == ('1:1' if explicit else '2:3')
|
||||
|
||||
|
||||
def test_native_openrouter_image_model_keeps_dedicated_image_api(image_transport):
|
||||
configure, source, requests, rows = image_transport
|
||||
configure('https://openrouter.ai/api/v1', model='openai/gpt-image-1')
|
||||
result = asyncio.run(ai_interaction.do_generate_image('A cow\nopenai/gpt-image-1', incognito=True))
|
||||
assert 'error' not in result
|
||||
assert str(requests[0].url).endswith('/api/v1/images')
|
||||
assert json.loads(requests[0].content)['model'] == 'openai/gpt-image-1'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('editing', [False, True])
|
||||
def test_http_200_provider_deprecation_is_not_reported_as_empty_image(image_transport, editing):
|
||||
configure, source, requests, rows = image_transport
|
||||
configure('https://openrouter.ai/api/v1', body={'choices': [{
|
||||
'finish_reason': 'error', 'message': {'content': None},
|
||||
'error': {'code': 502, 'message': 'The model gpt-image-1 has been deprecated'},
|
||||
}]})
|
||||
if editing:
|
||||
result = asyncio.run(ai_interaction.do_edit_image('Edit', str(source), model_spec='openai/gpt-5-image'))
|
||||
else:
|
||||
result = asyncio.run(ai_interaction.do_generate_image('A cow\nopenai/gpt-5-image'))
|
||||
assert 'deprecated' in result['error']
|
||||
assert 'openai/gpt-5-image' in result['error']
|
||||
assert 'AI settings' in result['error']
|
||||
assert len(requests) == 1
|
||||
assert not rows
|
||||
|
||||
|
||||
def test_empty_image_response_identifies_the_selected_model(image_transport):
|
||||
configure, source, requests, rows = image_transport
|
||||
configure('https://openrouter.ai/api/v1', body={'choices': [{'message': {'content': 'No image.'}}]})
|
||||
result = asyncio.run(ai_interaction.do_generate_image('A cow\nopenai/gpt-5-image'))
|
||||
assert "'openai/gpt-5-image' returned no image" in result['error']
|
||||
assert 'AI settings' in result['error']
|
||||
assert len(requests) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('editing', [False,True])
|
||||
def test_openrouter_1280_by_720_requests_16_by_9_not_square(image_transport, editing):
|
||||
configure, source, requests, rows = image_transport
|
||||
Image.new('RGB',(1280,720),'red').save(source)
|
||||
configure('https://openrouter.ai/api/v1',model='openai/gpt-image-2')
|
||||
if editing:
|
||||
result = asyncio.run(ai_interaction.do_edit_image('Change the sky',str(source),model_spec='openai/gpt-image-2',incognito=True))
|
||||
else:
|
||||
result = asyncio.run(ai_interaction.do_generate_image(json.dumps({
|
||||
'prompt':'a landscape','model':'openai/gpt-image-2','size':'1280x720'}),incognito=True))
|
||||
assert 'error' not in result
|
||||
body = json.loads(requests[0].content)
|
||||
assert body['aspect_ratio'] == '16:9'
|
||||
assert 'size' not in body
|
||||
|
||||
|
||||
def test_unreadable_image_does_not_call_provider(image_transport):
|
||||
|
||||
Reference in New Issue
Block a user