fix(editor): stabilize image generation, layer editing and controls (#6992)

* fix(editor): stabilize image workflows and editing controls

* docs(editor): add desktop and mobile visual validation evidence

* fix(editor): synchronize quick edit model events and register imagePreferences in test coordinator

* fix(settings): enforce authoritative global image-gen policy and non-admin preference persistence

- Enforce authoritative administrator global image_gen_enabled disable in get_user_setting, do_generate_image, and image_editing_provider_available, preventing non-admin user preference overrides while preserving user-disabled opt-outs and can_generate_images privilege checks across direct, gallery, and editor paths.
- Filter non-admin preference persistence in saveImagePreferences to user preference keys only (image_model, image_edit_model, image_quality), preventing global policy toggles from leaking into user preferences.
- Return accurate 200 OK Response from saveImagePreferences after fallback persistence succeeds so settings UI displays 'Saved' instead of false 403 error, while retaining visibility of real errors.
- Load per-user preferences via readImagePreferences in initImageSettings so non-admin selections persist and display correctly after page reload.
- Clear per-user model preferences on successful admin save (POST /api/auth/settings) via PUT /api/prefs/{key} with value: null to prevent stale per-user preferences from shadowing updated global settings.
- Support clearing keys in PUT /api/prefs/{key} when value is null.
- Add comprehensive Python regression tests for global-disabled/user-enabled, global-enabled/user-disabled, and authorized generation, plus Node and browser regression tests for non-admin persistence, toggle isolation, reload, and admin clearing.

* fix(settings): invalidate shared appConfig cache near auth settings write

* fix(test): isolate test_tool_policy unit suite from external model endpoint state

* fix(test): isolate web_fetch failure regression from contextual follow-up heuristics

* fix(test): isolate test_tool_policy from cross-test approval gate state

Ensure _patch_loop_basics disables TOOL_APPROVAL_GATE_ENABLED so routing tests in test_tool_policy run in the intended opt-out posture even when prior tests populate DATA_DIR with skills or untrusted context. Remove unnecessary forced_tools and contextual web mock workarounds.

* fix(settings): isolate non-admin image toggle and surface preference cleanup errors

- Restrict image generation availability toggle to admin users in imageModels.js, disabling the switch for non-admins and reverting unauthorized changes with an explicit message.
- Separate non-admin image model preference saves from global toggle updates.
- Track preference cleanup results on admin save in imagePreferences.js, propagating cleanup failures instead of ignoring them.
- Require valid preference keys on 403 fallback so toggle-only attempts reject cleanly.
- Add /api/prefs route mock to test_image_model_settings_sync_both_directions_and_survive_reopening.
- Add regression coverage for admin cleanup failures and non-admin toggle rejection.

* fix(settings): reconcile admin toggle on partial cleanup failure and report errors

- In imagePreferences.js, distinguish pre-persistence failures from post-save cleanup failures using err.globalSaved and err.phase.

- In imagePreferences.js, notify listeners of global policy updates on partial success before propagating cleanup failure.

- In imageModels.js, retain the persisted target state on admin toggle when global persistence succeeds but cleanup fails.

- In imageModels.js, reconcile admin toggle with authoritative server state via readImagePreferences() after ambiguous or partial failures.

- In imageModels.js, report partial success accurately without suppressing preference cleanup error messages.

- Add regression coverage in test_settings_save_response_ok_js.py for cleanup HTTP 500, cleanup network rejection, successful cleanup, and failed global POST.

---------

Co-authored-by: pewdiepie-archdaemon <pewdiepie-archdaemon@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
This commit is contained in:
PewDiePie
2026-11-01 02:56:46 +00:00
committed by GitHub
co-authored by pewdiepie-archdaemon Alexandre Teixeira
parent 5353241743
commit 39303f19cd
56 changed files with 3384 additions and 278 deletions
+73 -8
View File
@@ -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)