feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)

Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).

Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pewdiepie-archdaemon
2026-10-15 11:57:35 +00:00
co-authored by Claude Opus 5.5
parent c74f475679
commit 5fac693c1c
290 changed files with 34358 additions and 766 deletions
+657 -6
View File
@@ -4,6 +4,8 @@ from tests.helpers.stylesheets import app_css
ROOT = Path(__file__).resolve().parents[1]
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
AI_SETTINGS = (ROOT / "static/js/editor/ai-settings.js").read_text(encoding="utf-8")
AGENT = (ROOT / "static/js/editor/editor-agent.js").read_text(encoding="utf-8")
STYLE = app_css()
@@ -17,7 +19,7 @@ def test_quick_edit_has_persistent_expanded_identity_and_labeled_input():
def test_quick_edit_exposes_initial_actions_without_running_them_on_click():
assert "if (!q) return [];" in EDITOR
assert ".slice(0, 3)" in EDITOR
assert ".slice(0, 4)" in EDITOR
click_handler = EDITOR[
EDITOR.index("suggestions?.addEventListener('click'") :
EDITOR.index("const setOpen", EDITOR.index("suggestions?.addEventListener('click'"))
@@ -25,11 +27,10 @@ def test_quick_edit_exposes_initial_actions_without_running_them_on_click():
assert "pickSuggestion(Number(btn.dataset.aiCommandSuggestion), false);" in click_handler
def test_quick_edit_has_clear_busy_and_action_type_states():
def test_quick_edit_has_clear_and_action_type_states():
assert 'id="ge-ai-command-clear"' in EDITOR
assert "let busy = false;" in EDITOR
assert "if (busy) return;" in EDITOR
assert "_waitForExistingButton" in EDITOR
assert "await _runQuickControlOp('ai.generativeUpscale', { scale });" in EDITOR
assert "not available in this editor state" not in EDITOR
assert "class=\"ge-ai-command-suggestion-kind\"" in EDITOR
assert ".ge-ai-command-clear" in STYLE
assert ".ge-ai-command-busy" in STYLE
@@ -41,9 +42,659 @@ def test_quick_edit_owns_escape_without_closing_the_editor():
assert "top: -2px;" in STYLE[STYLE.index(".ge-ai-command-clear"):STYLE.index(".ge-ai-command-run")]
def test_quick_edit_flip_and_rotate_accept_numeric_degrees():
assert "(?:flip|rotate|turn)" in EDITOR
assert "(90|180|270)" in EDITOR
assert "`Rotated ${deg}\\u00b0.`" in EDITOR
assert "'flip 90'" in EDITOR
assert "'flip 180'" in EDITOR
def test_quick_edit_transforms_do_not_add_a_second_undo_snapshot():
start = EDITOR.index("if (/\\brotate\\b.*\\b180\\b")
end = EDITOR.index("if (/\\b(remove|erase", start)
end = EDITOR.index("const _bgRemove = ", start)
transform_block = EDITOR[start:end]
assert "_saveState('Rotate" not in transform_block
assert "_saveState('Flip" not in transform_block
def test_quick_edit_handles_layer_management_commands():
for op in (
"layers.addRaster",
"layers.addAdjustment",
"layers.duplicate",
"layers.delete",
"layers.rename",
"layers.setVisibility",
"layers.reorder",
"layers.mergeDown",
"layers.flatten",
"layers.group",
"layers.ungroup",
):
assert f"'{op}'" in EDITOR, op
assert "function _detectAdjustmentType(" in EDITOR
assert "function _layerReorderDirection(" in EDITOR
assert "async function _runQuickControlOp(" in EDITOR
assert "'new layer'" in EDITOR
assert "'delete layer'" in EDITOR
def test_place_image_supports_instruction_difference_mask():
assert "const _differenceMaskCanvas = (before, after," in EDITOR
assert "changedFraction" in EDITOR
assert "differenceMask === true" in EDITOR
assert "String(p.mode || '').toLowerCase() === 'instruction'" in EDITOR
assert "const beforeSnap = useDiff ? flatten() : null;" in EDITOR
# (A) adaptive edge handling: the difference mask's coverage decides whether
# a thin edge band is dropped. Large coverage (scene/background replacement)
# keeps generated pixels out to the edge (edge-filling the outer band from
# nearest interior generated pixels); small local edits drop a 3px band
# (provider resampling otherwise leaves a spurious frame). The result is
# drawn back at the exact original document size with high-quality smoothing.
assert "const diff = _differenceMaskCanvas(beforeSnap, layer.canvas, { threshold: p.diffThreshold, grow: p.diffGrow, feather: p.diffFeather, edgeExcludePx: 0 });" in EDITOR
assert "const sceneReplace = diff.changedFraction > 0.5;" in EDITOR
assert "const edgeExclude = sceneReplace ? 0 : (explicitEdge != null ? explicitEdge : 3);" in EDITOR
assert "if (edgeExclude > 0) _clearMaskEdgeBand(diff.mask, edgeExclude);" in EDITOR
assert "if (sceneReplace) _edgeFillFromInterior(layer.ctx, dw, dh, 3);" in EDITOR
assert "imageSmoothingQuality = 'high'" in EDITOR
def test_generative_expand_inpaints_border_and_feathers_seam():
assert "extend the scene seamlessly, continuing the surrounding content and lighting naturally" in EDITOR
assert "_bakeMaskIntoLayer(layer.ctx, blendMask)" in EDITOR
assert "const seam = Math.max(12," in EDITOR
# Wider feather + a colour-match tint pull the generated border toward the
# original edge band so the rectangular seam is less visible.
assert "Math.round(seam * 1.4)" in EDITOR
assert "function _edgeBandAverage(canvas, b)" in EDITOR
assert "const edge = _edgeBandAverage(flat, b);" in EDITOR
# Border is pre-filled by stretching edge pixels before the inpaint, and
# the request forwards an explicit step count.
assert "const steps = Math.max(1, Math.round(Number(p && p.steps) || 4));" in EDITOR
assert "steps })" in EDITOR
assert "return { width: tw, height: th, layerId: layer.id, seam, steps }" in EDITOR
# Expand inpaints from the real content bounds, not the whole document, so
# uniform blank-canvas margins (white Background) aren't stretched into the
# new border and continued by the model.
assert "function _contentBounds()" in EDITOR
assert "function _layerIsUniform(canvas)" in EDITOR
assert "const flat = flatten(); const b = _contentBounds();" in EDITOR
assert "mctx.clearRect(b.x, b.y, b.w, b.h);" in EDITOR
def test_quick_edit_stacks_prompts_as_a_job_queue():
assert 'class="ge-ai-command-stack" id="ge-ai-command-stack"' in EDITOR
assert "let _quickEditActiveJob = null;" in EDITOR
assert "const executeQuickEdit = async (prompt, { forceAi = false, _noSelfCheck = false, _retried = false, signal = null } = {})" in EDITOR
assert "const renderJobStack = ()" in EDITOR
assert "const pumpQuickEdit = async ()" in EDITOR
assert "const jobIcon = (status)" in EDITOR
assert "status: 'queued'" in EDITOR
assert "_quickEditActiveJob = job;" in EDITOR
# the old single-flight lock is gone so prompts can be stacked
assert "let busy" not in EDITOR
assert "setBusy(" not in EDITOR
# finished prompts fade out and auto-dismiss (no persistent history row)
assert "const dismissJob = (job) =>" in EDITOR
assert "setTimeout(() => dismissJob(job), job.status === 'error' ? 3200 : 1600);" in EDITOR
assert "ge-ai-job-remove" not in EDITOR
# queue styling ships with the editor stylesheet; the running row uses the
# app's whirlpool spinner (canvas), not a CSS ring.
assert ".ge-ai-command-stack" in STYLE
assert "spinnerModule.createWhirlpool(16)" in EDITOR
assert ".ge-ai-job-icon .ai-spinner-whirlpool" in STYLE
assert ".ge-ai-job-spinner" not in STYLE
assert "ge-ai-job-leaving" in STYLE
assert "@keyframes ge-ai-job-in" in STYLE
def test_brush_preset_picker_is_visible_in_the_options_bar():
# Painting tools surface the preset picker inline instead of hiding it
# behind the "..." overflow button.
assert "classList.toggle('ge-has-brush-presets', needsBrush)" in EDITOR
# Descendant form: `ge-controls--in-options` sits on the parent
# `.ge-controls` while `ge-has-brush-presets` is on the child
# `#ge-brush-controls`, so both classes are never on the same element.
assert ".ge-controls--in-options .ge-has-brush-presets .ge-brush-preset-row" in STYLE
assert ".ge-controls--in-options.ge-has-brush-presets .ge-brush-preset-row" not in STYLE
def test_pixel_brush_shows_a_square_cursor():
# The pixel brush draws hard squares, so the cursor preview is a square
# snapped to whole pixels rather than a feathered circle.
assert "const pixelBrush = !!state.brushPixel && (state.tool === 'brush' || state.tool === 'eraser');" in EDITOR
assert "state.cursorEl.classList.toggle('ge-brush-cursor--pixel', pixelBrush);" in EDITOR
assert ".ge-brush-cursor.ge-brush-cursor--pixel" in STYLE
assert "border-radius: 0;" in STYLE
def test_quick_edit_is_local_first_and_only_uses_ai_when_needed():
# LOCAL-FIRST: deterministic client ops run first, then the keyword
# planner, and only generative / unrecognised prompts reach the server
# planner (window.geEditorAgent) / AI model.
assert "_shouldUseAgentEdit" not in EDITOR
assert "const executeQuickEdit = async (prompt, { forceAi = false, _noSelfCheck = false, _retried = false, signal = null } = {})" in EDITOR
assert "const _executeQuickEditLocal = async (prompt)" in EDITOR
local_at = EDITOR.index("const _executeQuickEditLocal = async (prompt)")
agent_at = EDITOR.index("const executeQuickEdit = async (prompt, { forceAi = false, _noSelfCheck = false, _retried = false, signal = null } = {})")
assert local_at < agent_at # deterministic helper defined first, then the wrapper
wrapper = EDITOR[agent_at:EDITOR.index("// Stacked prompt queue", agent_at)]
# The deterministic local op runs BEFORE the AI agent. Its contract is
# "return false only when nothing matched" (deterministic branches bail with
# a bare `return`, so undefined means handled).
assert "const done = await _executeQuickEditLocal(prompt);" in wrapper
assert "if (done !== false) return { ok: true };" in wrapper
assert "agent.run(_enrichLookPrompt(prompt)" in wrapper
assert wrapper.index("_executeQuickEditLocal(prompt)") < wrapper.index("agent.run(_enrichLookPrompt(prompt)")
# The keyword planner is tried before the AI agent too, and is guarded
# against faking generative requests locally.
assert "const _runLocalPlan = async (prompt)" in EDITOR
assert "const _looksGenerative = (text) => {" in EDITOR
# The generative gate must catch "add <adjectives> <noun>" and scene changes
# so they are never faked with a local adjustment.
assert "const GEN_ADD_NOUNS = " in EDITOR
assert r"add\\s+(?:a\\s+|an\\s+|the\\s+)?(?:\\w+\\s+){0,3}?" in EDITOR
# Targeted recolours ("recolour ...", "make only ...", "change/turn ... <colour>")
# must reach the model too, not a global tint.
assert "const GEN_RECOLOR = " in EDITOR
assert r"\brecolou?r\b" in EDITOR
assert r"\bmake\s+only\b" in EDITOR
# The deterministic fast path must bail out on those recolours instead of
# satisfying them with a global local adjustment.
assert "const _looksRecolor = (text) => {" in EDITOR
assert "if (_looksRecolor(p)) return false;" in EDITOR
assert "const planned = await _runLocalPlan(prompt);" in wrapper
assert "if (tryLocal && !_looksGenerative(prompt)) {" in wrapper
assert wrapper.index("_runLocalPlan(prompt)") < wrapper.index("agent.run(_enrichLookPrompt(prompt)")
# Routing mode selector: auto / quick / agent / image, with a helper.
assert "function _getQuickEditMode() {" in EDITOR
assert "id=\"ge-ai-command-mode\"" in EDITOR
assert "const mode = _getQuickEditMode();" in wrapper
# Auto/Image/Tab fall through to the IMAGE MODEL, not a filter planner.
assert "const _runImageModelEdit = async (prompt, { signal = null } = {}) => {" in EDITOR
assert "await _runQuickControlOp('ai.generativeFill', { prompt });" in EDITOR
assert "return await _runImageModelEdit(prompt, { signal });" in wrapper
# Agent (planner) is used only in Agent mode.
assert "const forceAgent = !forceAi && mode === 'agent';" in wrapper
assert "if (forceAgent) {" in wrapper
# Quick mode is deterministic only — never a model.
assert "const quickOnly = !forceAi && mode === 'quick';" in wrapper
assert "No built-in quick action matched" in wrapper
# Named-look aliases map phrasings onto the server's recipe keywords so
# "Blade Runner neon night" hits the cyberpunk instruction edit.
assert "const _LOOK_ALIASES = [" in EDITOR
assert "const _enrichLookPrompt = (text) => {" in EDITOR
assert "'cyberpunk'" in EDITOR
assert "agent.run(_enrichLookPrompt(prompt)," in EDITOR
# A bare crop is deterministic (trim empty borders), never sent to the AI.
assert "const b = _contentBounds();" in wrapper
assert "'Nothing to trim — the image already fills the canvas.'" in wrapper
assert "invoke('document.crop', { x: b.x, y: b.y, width: b.w, height: b.h })" in wrapper
# Deterministic ops are skipped in agent/image/Tab mode.
assert "const tryLocal = !forceAi && !forceAgent && mode !== 'image';" in wrapper
assert "if (tryLocal && /\\b(?:crop|trim)\\b/i.test(cropText) && !hasRatio && !/\\bselection\\b|\\bselected\\b/i.test(cropText)) {" in wrapper
def test_tab_cycles_the_routing_mode():
# Tab in the Quick Edit box cycles the routing mode (Auto/Quick/Agent/Image)
# instead of sending the prompt; advertised as a tooltip.
assert "if (e.key === 'Tab' && !e.shiftKey) {" in EDITOR
assert "const opts = Array.from(ms.options).map((o) => o.value);" in EDITOR
assert "ms.value = opts[(cur + 1) % opts.length];" in EDITOR
assert "ms.dispatchEvent(new Event('change', { bubbles: true }));" in EDITOR
assert 'title="Tip: press Tab to switch routing mode"' in EDITOR
assert 'id="ge-ai-command-hint"' not in EDITOR
def test_image_ops_retry_transient_server_failures():
# A transient 5xx/network hiccup from the image server must not silently
# leave the canvas unchanged; retry a couple of times with backoff.
assert "const _retryable = new Set([429, 500, 502, 503, 504]);" in EDITOR
assert "const maxAttempts = 3;" in EDITOR
assert "await new Promise((r) => setTimeout(r, 700 * attempt));" in EDITOR
assert "if (!_retryable.has(res.status)) break;" in EDITOR
def test_quick_edit_lifts_above_the_mobile_keyboard():
# Chat-style: the docked Quick Edit bar tracks the on-screen keyboard via
# visualViewport, polling briefly after focus because iOS fires resize late.
assert "const _liftQuickEdit = () => {" in EDITOR
assert "const visibleBottom = vv ? (vv.offsetTop + vv.height) : window.innerHeight;" in EDITOR
assert "wrap.style.setProperty('--ge-ai-lift', lift + 'px');" in EDITOR
assert "wrap.classList.toggle('ge-ai-command-lifted', lift > 0);" in EDITOR
assert "const _trackLift = () => {" in EDITOR
assert "_liftPoller = setInterval(() => { _liftQuickEdit();" in EDITOR
assert "input.addEventListener('focus', _trackLift);" in EDITOR
def test_deterministic_commands_never_reach_the_image_model():
# resize / save-PSD / vignette / punchy / cinematic must run a local control
# op, never the image model.
assert "await _runQuickControlOp('document.resizeCanvas', { width: w, height: h });" in EDITOR
assert "await _runQuickControlOp('document.savePsd', {});" in EDITOR
assert "await _runQuickControlOp('filters.apply', { id: 'vignette' });" in EDITOR
assert "Added punch." in EDITOR
assert "Applied a cinematic grade." in EDITOR
# "blur the background" is a masked Gaussian blur, never a scene replace.
assert "await _runQuickControlOp('filters.applyMasked', { id: 'blur-gaussian', radius, invert: true });" in EDITOR
assert "await _runQuickControlOp('selection.background', {});" in EDITOR
# Tone adjectives are a deterministic grade, not a creative repaint.
assert "|cinematic|dramatic|epic|dreamy|ethereal|moody|atmospheric|" not in EDITOR
# The vague->image fallback must not swallow a prompt a local op can handle.
assert "A prompt a deterministic control op can handle is never \"vague\"" in EDITOR
def test_quick_edit_has_a_harmonize_action_that_prompts_the_model():
# A named "Harmonize" action blends every layer into one coherent image via
# the image model, using a purpose-built prompt and running in every mode.
assert "const _HARMONIZE_PROMPT = 'Blend all the composited elements into one coherent" in EDITOR
assert "const _isHarmonizeIntent = (text) => {" in EDITOR
assert "if (_isHarmonizeIntent(prompt)) {" in EDITOR
assert "return await _runImageModelEdit(_HARMONIZE_PROMPT, { signal });" in EDITOR
assert "insert: 'harmonize the layers'" in EDITOR
def test_ai_work_glows_the_canvas():
assert "function _setCanvasAiBusy(busy)" in EDITOR
assert "area.classList.toggle('ge-canvas-ai-busy', !!busy);" in EDITOR
assert "_setCanvasAiBusy(kind === 'running');" in EDITOR
# The skeleton covers the WHOLE canvas area (edge to edge), not just the
# letterboxed image, so it reaches the sides instead of reading as a square.
assert "function _aiBusyTargetRect()" in EDITOR
assert "width: Math.max(1, area.clientWidth)," in EDITOR
assert "height: Math.max(1, area.clientHeight)," in EDITOR
assert "area.style.setProperty('--ge-ai-busy-w'" in EDITOR
assert ".ge-canvas-area.ge-canvas-ai-busy::after" in STYLE
assert "background: rgba(6, 14, 9, 0.38);" in STYLE
assert "left: var(--ge-ai-busy-x, 0px);" in STYLE
assert "width: var(--ge-ai-busy-w, 100%);" in STYLE
assert "@keyframes ge-ai-canvas-sheen" in STYLE
# The busy glow is an ASCII rendering of the fireflies drift (the yoyo
# theme's ascii-fireflies look), scoped to the same busy rect: the field is
# simulated offscreen then mapped to a glyph ramp.
assert "function _aiFirefliesStart()" in EDITOR
assert "function _aiFirefliesStop()" in EDITOR
assert "el.className = 'ge-ai-ascii-fireflies';" in EDITOR
assert "const ramp = ' .,:;irsXA253hMHGS#9B&@';" in EDITOR
assert "st.el.textContent = lines.join('\\n');" in EDITOR
assert "_aiFirefliesStart();" in EDITOR
assert "_aiFirefliesStop();" in EDITOR
assert ".ge-canvas-area .ge-ai-ascii-fireflies" in STYLE
assert ".ge-canvas-area.ge-canvas-ai-busy .ge-ai-ascii-fireflies { opacity: 1; }" in STYLE
# The skeleton is the photo turned into ASCII (transparent glyphs + a glow
# sweep), stretched across the whole canvas area, not a simulated firefly
# field.
assert "sctx.drawImage(main, 0, 0, main.width, main.height, 0, 0, source.width, source.height);" in EDITOR
assert "background: transparent !important;" in STYLE
# The old canvas-wide pulse is gone.
assert ".ge-canvas-area.ge-canvas-ai-busy .ge-main-canvas" not in STYLE
assert "@keyframes ge-ai-canvas-pulse" not in STYLE
def test_footer_shows_active_edit_model():
topbar = (ROOT / "static/js/editor/build/topbar.js").read_text()
models = (ROOT / "static/js/editor/ai-models.js").read_text()
assert 'id="ge-status-model"' in topbar
assert "class=\"ge-status-model\"" in topbar
assert ".ge-status-model" in STYLE
assert "export function updateFooterEditModel()" in models
assert "export function resolveQuickEditModel()" in models
assert "const info = resolveQuickEditModel();" in models
assert "el.textContent = `Edit: ${label}`;" in models
# Wired both after load and on inpaint-select change.
assert "updateFooterEditModel();" in models
assert "aiInpaintSelect.addEventListener('change', () => updateFooterEditModel());" in models
def test_quick_edit_has_a_model_picker():
models = (ROOT / "static/js/editor/ai-models.js").read_text()
# The picker lives in the AI settings dialog, opened from the editor
# footer (the Quick Edit box itself holds no model UI).
assert 'id="ge-ai-command-model"' in AI_SETTINGS
assert 'class="ge-ai-command-model"' in AI_SETTINGS
assert "ge-ai-command-modelrow" not in EDITOR
assert 'id="ge-ai-command-settings"' not in EDITOR
assert "createAiSettings({ container })" in EDITOR
assert "window.geEditorAiSettings = _aiSettings;" in EDITOR
assert "aiBtn.id = 'ge-ai-settings-btn';" in EDITOR
assert "aiBtn.addEventListener('click', () => _aiSettings?.open());" in EDITOR
# The agent request carries the selected model.
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
# Styled.
assert ".ge-ai-settings-card" in STYLE
assert ".ge-ai-command-model" in STYLE
assert ".ge-ai-command-settings" not in STYLE
def test_quick_edit_has_an_agent_model_picker():
models = (ROOT / "static/js/editor/ai-models.js").read_text()
agent = (ROOT / "static/js/editor/editor-agent.js").read_text()
# The agent (planner) picker sits in the AI settings dialog.
assert 'id="ge-ai-command-agent-model"' in AI_SETTINGS
assert "Recommended, not required" in AI_SETTINGS
assert "function _getSelectedQuickEditAgentModel()" in EDITOR
assert "getAgentModel: () => _getSelectedQuickEditAgentModel()," in EDITOR
# Local Qwen3.5-9B VL is the default; server default is the alternative.
assert "export function resolveQuickEditAgentModel()" in models
assert "export const QUICK_EDIT_AGENT_OPTIONS" in models
assert "Qwen3.5-9B VL · local" in models
assert "http://100.69.120.65:8097/v1::Qwen/Qwen3.5-9B" in models
assert "localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value)" in models
assert "localStorage.getItem('ge-ai-command-agent-model')" in models
# The request carries the agent model.
assert "agent_model: (agentInfo && agentInfo.agent) || ''" in agent
assert "getAgentModel = null," in agent
def test_quick_edit_hybrid_fast_path_handles_deterministic_intents_without_ai():
# Deterministic intents bypass the LLM entirely (no model call) so they are
# fast and reliable regardless of the planner's quality.
assert "window.geQuickEditRun = (prompt, opts) => executeQuickEdit(prompt, opts || {});" in EDITOR
# Selection commands.
assert "await _runQuickControlOp('selection.selectAll', {});" in EDITOR
assert "await _runQuickControlOp('selection.deselect', {});" in EDITOR
assert "await _runQuickControlOp('selection.invert', {});" in EDITOR
assert "await _runQuickControlOp('selection.subject', { label: 'person' });" in EDITOR
# Fill / paint bucket (canvas-colour targeting + region selection fallback).
assert "function _namedRgb(name)" in EDITOR
assert "function _findColorPoint(layer, rgb, tol = 60)" in EDITOR
assert "await _runQuickControlOp('fill.flood', { x: pt.x, y: pt.y, color, tolerance: 60 });" in EDITOR
assert "await _runQuickControlOp('selection.fill', { color });" in EDITOR
# "fill the canvas with <colour>" fills the WHOLE canvas (select all -> fill),
# not just the connected region around the centre — but NOT when the request
# is a generative extend/expand ("extend the canvas to 16:9 with generative fill").
assert "const _fillIsGenerative = /\\b(?:extend|expand|generative|genfill|outpaint|aspect|ratio)\\b|\\d+\\s*:\\s*\\d+/.test(p);" in EDITOR
assert "if (!_fillIsGenerative && (/\\bfill\\b|\\bflood\\s*fill\\b|\\bpaint\\s*bucket\\b/.test(p)) && /\\b(?:canvas|whole|entire|everything|all|layer)\\b/.test(p)) {" in EDITOR
assert "const fillAllColor = fillWith ? _colorForName(fillWith[1]) : (_fillFgBg || state.color || '#000000');" in EDITOR
assert "_setAiCommandStatus('Filled the canvas.', 'done');" in EDITOR
assert "label: 'Fill canvas'" in EDITOR
# Align + text edit.
assert "await _runQuickControlOp('layers.align', { mode });" in EDITOR
assert "await _runQuickControlOp('text.edit', tp);" in EDITOR
# The command-box queue runs the same hybrid entry point.
assert "await executeQuickEdit(job.prompt, { forceAi: !!job.forceAi, signal: controller ? controller.signal : null });" in EDITOR
# Portrait attributes take dedicated, observable ops instead of the blanket
# "enhance face" AI pass.
assert "await _runQuickControlOp('retouch.teeth', {});" in EDITOR
assert "await _runQuickControlOp('selection.samText', { text: 'eyes' });" in EDITOR
assert "await _runQuickControlOp('adjustments.applyMasked', { brightness: 18, contrast: 8 });" in EDITOR
assert "const _portraitAttr = /" in EDITOR
assert "if (/\\b(face|portrait|selfie)\\b/.test(p) && !_portraitAttr) {" in EDITOR
assert "blemishes?|acne|pores|blackheads?" in EDITOR
# Tone branches must not swallow generative prompts (e.g. "replace the
# background ... and make it cooler", "remove the person ... warm up").
assert "if (/\\b(warm|warmer|warmth)\\b/.test(p) && !_looksGenerative(p)) {" in EDITOR
assert "if (/\\b(cool|cooler|colder|moody|teal)\\b/.test(p) && !_looksGenerative(p)) {" in EDITOR
assert "if (/\\b(brighten|brighter|lighten|exposure|too\\s+dark|backlit|well[-\\s]*lit)\\b/.test(p) && !_looksGenerative(p)) {" in EDITOR
assert "if (/\\b(darken|darker|dimmer|reduce\\s+exposure|too\\s+bright)\\b/.test(p) && !_looksGenerative(p)) {" in EDITOR
def test_align_does_not_swallow_add_text_requests():
editor = (ROOT / "static/js/galleryEditor.js").read_text()
# "add a subtitle ... and align it" must add the text, not just align.
assert "const alignWantsText = " in editor
assert "if (/\\balign\\b/.test(p) && alignM && !alignWantsText) {" in editor
nl = (ROOT / "static/js/editor/nl-edit.js").read_text()
# add-text wins over effect/align words; only real text-edit attributes exclude it.
assert "const existingText = " in nl
assert "if (wantsAddText && !existingText && !/\\b(font|bigger|smaller|larger|colour|color)\\b/.test(p)) {" in nl
assert "|| /\\bwith\\s+the\\s+(?:text|title|word|caption|label)\\b/i.test(prompt)" in nl
def test_quick_edit_vl_self_check_retries_once():
# Generative and "look" edits get a VL self-check: the agent VL compares
# BEFORE/AFTER and may request a single retry.
assert "const _looksLook = (text) =>" in EDITOR
assert "const _captureShot = async () => {" in EDITOR
assert "const _vlJudgeAfter = async (prompt, before, after) => {" in EDITOR
assert "const selfCheckEligible = !_noSelfCheck" in EDITOR
assert "&& (_looksGenerative(prompt) || _looksLook(prompt));" in EDITOR
assert "if (selfCheckEligible && !_retried) {" in EDITOR
assert "const retryOut = await executeQuickEdit(judge.retryPrompt || prompt, { forceAi: true, _noSelfCheck: true, _retried: true, signal });" in EDITOR
# A retry that does not succeed keeps the first accepted attempt (ok).
assert "if (retryOut && retryOut.ok) return retryOut;" in EDITOR
# The judge talks to the agent VL (OpenAI-compatible chat/completions).
assert "ep.replace(/\\/$/, '') + '/chat/completions'" in EDITOR
assert "Reply with EXACTLY ONE word: \"FAIL\"" in EDITOR
# It prefers a same-origin proxy (the VL server has no CORS headers) and
# falls back to the direct endpoint.
assert "fetch('/api/editor/self-check'" in EDITOR
assert "const parseJudge = (txt) => {" in EDITOR
def test_self_check_is_off_by_default_with_an_opt_in_toggle():
# Self-check is opt-in: off unless the user enables "Double-check AI edits".
assert "window.geQuickEditSelfCheck === true" in EDITOR
# The toggle moved into the AI settings dialog.
assert 'id="ge-ai-command-selfcheck"' in AI_SETTINGS
assert "Double-check AI edits" in AI_SETTINGS
assert "localStorage.getItem('ge-ai-self-check') === '1'" in EDITOR
assert "localStorage.setItem('ge-ai-self-check', selfCheckToggle.checked ? '1' : '0')" in EDITOR
def test_remove_background_chains_a_tone_step():
# "remove the background and make it brighter" must do both, not drop the tone.
assert "await _runQuickControlOp('ai.removeBackground', {});" in EDITOR
assert "await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: 16, contrast: 4 } });" in EDITOR
assert "await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: 18 } });" in EDITOR
def test_remove_background_intent_matches_common_phrasings():
# The bg-removal fast path must catch "removal" (not just "remove"),
# "bg remov", "remove bg", "no background", "cutout", "isolate the
# subject" etc. — otherwise these fall through to the AI agent.
assert "const _bgRemove = " in EDITOR
assert "remov\\w*|erase|delete|drop|get\\s*rid\\s*of|cut\\s*out|cutout|knock\\s*out|isolate|strip|clear|transparent|no|without|rm" in EDITOR
assert "if (_bgRemove) {" in EDITOR
def test_remove_background_mask_is_a_layer_mask():
# The removal cutout must be a real LAYER mask (applied with destination-in),
# not a selection overlay — otherwise the background is never removed.
assert "name: 'Background removed', canvas: cut, ctx: cctx, visible: true, mode: 'layer', space: 'layer', linked: true, offset: { x: 0, y: 0 } }" in EDITOR
assert "mode: 'selection' }" not in EDITOR
def test_masked_adjust_supports_hue_rotation():
# adjustments.applyMasked must accept a `hue` (degrees) that rotates hue in
# HSL, preserving lightness/saturation — so a shaded object can be recoloured
# (red car -> blue) instead of flattened to a fill.
assert "const hue = Number(opts.hue) || 0;" in EDITOR
assert "hh = (hh + (hue * rotWeight) / 360) % 1;" in EDITOR
assert "hue rotation in degrees (0-360) that preserves lightness and saturation" in EDITOR
# The rotation is limited to pixels near the source hue (soft falloff) and
# gated by saturation, so windows/chrome keep their colour.
assert "let hueCenter = null;" in EDITOR
assert "const satW = Math.max(0, Math.min(1, (s - 0.12) / 0.18));" in EDITOR
assert "const inner = 0.06, outer = 0.17;" in EDITOR
assert "sourceHue" in EDITOR
def test_sam_text_falls_back_to_color_range_when_empty():
# When SAM returns an empty mask, selection.samText falls back to a
# colour-range selection if the caller supplies fallbackColor.
assert "selection.samText': { description:" in EDITOR
assert "fallbackColor" in EDITOR
assert "if (empty && p.fallbackColor) {" in EDITOR
assert "Color range selection (SAM fallback)" in EDITOR
assert "_colorRangeMask(src.data, src.w, src.h, target, fuzz)" in EDITOR
def test_masked_hue_saturation_mode_uses_delta_saturation():
# HARNESS sends {hue:<delta>, saturation:0, mode:'hue-saturation'} for a
# recolour; saturation must be read as a delta (0 = unchanged), not a factor
# (0 = grey), so the hue-rotated object keeps its colour.
assert "if (opts.mode === 'hue-saturation') saturation = 1 + saturation / 100;" in EDITOR
def test_quick_edit_handles_undo_and_redo_locally():
# "undo"/"redo" are deterministic actions, never a model call.
assert "await _runQuickControlOp('history.redo', {});" in EDITOR
assert "await _runQuickControlOp('history.undo', {});" in EDITOR
assert "label: 'Undo'" in EDITOR
# Completion feedback is silenced (no lingering "Done." text).
assert "Agent applied" not in EDITOR
assert "_setAiCommandStatus('Done.', 'done');" not in EDITOR
assert EDITOR.count("_setAiCommandStatus('', '');") >= 2
def test_quick_edit_lists_every_tool_with_its_icon():
# Every toolbar tool is offered in Quick Edit, carrying its SVG icon.
assert "const _QUICK_EDIT_TOOL_SUGGESTIONS = Object.entries(_TOOLS).map(([id, t]) => ({" in EDITOR
assert "const _QUICK_EDIT_CATALOG = [..._QUICK_EDIT_TOOL_SUGGESTIONS, ..._AI_COMMAND_SUGGESTIONS];" in EDITOR
assert "toolId: id," in EDITOR
assert "icon: t.icon || ''," in EDITOR
assert "if (!q) return [];" in EDITOR
# The suggestion row renders the icon and selecting a tool switches tools.
assert '<span class="ge-ai-command-suggestion-icon" aria-hidden="true">${item.icon || \'\'}</span>' in EDITOR
assert "if (item.toolId) {" in EDITOR
assert "_clickToolButton(item.toolId);" in EDITOR
# A deterministic tool-switch branch handles "use the eraser tool".
assert "function _matchTool(text) {" in EDITOR
assert "const toolId = _matchTool(p);" in EDITOR
assert "_clickToolButton(toolId);" in EDITOR
style = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
assert ".ge-ai-command-suggestion-icon" in style
assert "grid-template-columns: 22px 38px minmax(82px, auto) minmax(0, 1fr);" in style
def test_quick_edit_can_cancel_an_in_flight_ai_request():
# A running Quick Edit job shows a Cancel button that aborts the request and
# clears the row + status + canvas glow.
assert 'class="ge-ai-job-cancel"' in EDITOR
assert "const cancelJob = (job) => {" in EDITOR
assert "job.cancelled = true;" in EDITOR
assert "job.controller?.abort();" in EDITOR
assert "_setAiCommandStatus('', '');" in EDITOR
# A cancelled request must not fall through and silently re-run on the
# client planner.
assert "if (signal && signal.aborted) {" in EDITOR
assert "return { ok: false, cancelled: true };" in EDITOR
# Each job owns an AbortController threaded through executeQuickEdit/agent.run.
assert "const controller = (typeof AbortController === 'function') ? new AbortController() : null;" in EDITOR
assert "signal: controller ? controller.signal : null" in EDITOR
assert "if (job.cancelled) continue;" in EDITOR
# The agent forwards the abort signal to fetch.
assert "async function run(prompt, { onStep: stepCb = null, signal = null } = {})" in AGENT
assert "signal: signal || undefined," in AGENT
def test_quick_edit_repaints_creative_prompts_with_the_model():
# Creative / artistic intent must repaint the image (select-all + generative
# fill), not apply a generic vibrance/brightness tweak.
assert "const _CREATIVE_STYLES = " in EDITOR
assert "const _looksCreative = (text) =>" in EDITOR
assert "cyberpunk|vaporwave|synthwave" in EDITOR
assert "if (_looksCreative(p) && !/\\b(?:remove|erase|delete|replace)\\b/.test(p)) {" in EDITOR
assert "await _runQuickControlOp('selection.selectAll', {});" in EDITOR
assert "await _runQuickControlOp('ai.generativeFill', { prompt });" in EDITOR
assert "await _runQuickControlOp('selection.deselect', {});" in EDITOR
def test_quick_edit_falls_back_to_the_image_model_for_vague_prompts():
assert "const _VAGUE_PATTERNS = " in EDITOR
assert "const _isVague = (text) => {" in EDITOR
# A recognized generative prompt ("make it look snowy") must NOT be treated
# as a vague look just because it contains the word "look".
assert "if (_looksLook(s) && !_NAMED_LOOK.test(s) && !_looksGenerative(s)) return true;" in EDITOR
# A vague/unrecognised prompt is handed to the image model, not dead-ended.
assert "if (mode === 'auto' && !forceAi && _isVague(prompt)) {" in EDITOR
assert "Interpreting with the image model" in EDITOR
assert "return await _runImageModelEdit(prompt, { signal });" in EDITOR
def test_auto_scopes_generative_edits_to_named_regions():
# In Auto mode a generative prompt that names a region (sky/clouds/moon/
# background) selects that region first, so the image model only repaints
# that area (a whole-frame unmasked edit was the commonest weak case).
assert "const _regionSelectionOp = (text) => {" in EDITOR
assert "return 'selection.sky';" in EDITOR
assert "return 'selection.background';" in EDITOR
assert "const regionOp = _regionSelectionOp(prompt);" in EDITOR
assert "scoped = !!(state.wandMask && state.wandMask.width && state.wandMask.height);" in EDITOR
assert "if (!scoped) await _runQuickControlOp('selection.selectAll', {});" in EDITOR
# Generative prompts skip the client keyword planner (so a sky/foreground
# "add X" reaches the model, not a local region toning).
assert "if (tryLocal && !_looksGenerative(prompt)) {" in EDITOR
# Extend/outpaint to a ratio grows the canvas and generative-fills the border.
assert "const ratioAsk = prompt.match(" in EDITOR
assert "await _runQuickControlOp('ai.outpaint', { prompt, ratio: `${ratioAsk[1]}:${ratioAsk[2]}`, size: `${tw}x${th}`, steps: 4 });" in EDITOR
# The dedicated server outpaint pipeline (pad + strip inpaint + tone match).
assert "const _aiOutpaint = async (prompt, opts = {}) => {" in EDITOR
assert "await _aiEditCanvas('/api/image/outpaint', payload, { keepNaturalSize: true });" in EDITOR
assert "const { canvas } = await _aiEditCanvas('/api/image/outpaint', payload, { keepNaturalSize: true });" in EDITOR
assert "'ai.outpaint': { description:" in EDITOR
def test_background_replacement_uses_the_dedicated_server_pipeline():
# 'put her on a beach' / 'replace the background with mountains' route to the
# dedicated replace-background endpoint (server derives the subject mask,
# repaints the inverse, harmonises + contact shadow, pastes the subject back)
# instead of a masked generative fill.
assert "const _aiReplaceBackground = async (prompt) => {" in EDITOR
assert "await _aiEditCanvas('/api/image/replace-background', {" in EDITOR
assert "const { canvas } = await _aiEditCanvas('/api/image/replace-background', {" in EDITOR
assert "_placeResultLayer(canvas, 'Background replaced')" in EDITOR
assert "'ai.replaceBackground': { description:" in EDITOR
assert "run: (p) => _aiReplaceBackground((p && p.prompt) || '')" in EDITOR
assert "if (regionOp === 'selection.background') {" in EDITOR
assert "await _runQuickControlOp('ai.replaceBackground', { prompt, steps: 4 });" in EDITOR
def test_quick_edit_can_ai_fill_the_empty_canvas():
# "ai fill" / "fill the empty areas" / "complete the canvas" builds a mask
# from the transparent pixels and inpaints them with the image model.
assert "const _aiFillEmpty = async (prompt) => {" in EDITOR
assert "const on = data[i + 3] < 16 ? 255 : 0;" in EDITOR
assert "'the canvas has no empty areas to fill'" in EDITOR
assert "state.wandMask = mask;" in EDITOR
assert "await _aiInpaintSelection(prompt || 'continue the surrounding scene" in EDITOR
assert "'ai.fillEmpty': { description:" in EDITOR
assert "label: 'AI fill'" in EDITOR
assert "await _runQuickControlOp('ai.fillEmpty'," in EDITOR
# An extend/resize prompt ("extend to 16:9 with generative fill") must NOT
# be swallowed by the AI-fill branch; it falls through to the extend logic.
assert "const _extendAsk = " in EDITOR
assert "\\boutpaint\\b/i.test(p) && !_extendAsk && !_fillRegion)" in EDITOR
def test_quick_edit_shows_a_real_progress_bar():
# The Quick Edit box carries a progress bar (kept alongside the skeleton).
assert 'id="ge-ai-command-progress"' in EDITOR
assert 'id="ge-ai-command-progress-fill"' in EDITOR
assert 'id="ge-ai-command-progress-label"' in EDITOR
# Poller helpers and the poll itself.
assert "function _startAiProgress(requestId)" in EDITOR
assert "function _stopAiProgress()" in EDITOR
assert "function _setAiProgress(frac, label)" in EDITOR
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 "_startAiProgress(requestId);" in EDITOR
assert "_stopAiProgress();" in EDITOR
# Styling exists.
assert ".ge-ai-command-progress {" in STYLE
assert ".ge-ai-command-progress-fill {" in STYLE
def test_rulers_default_off_on_phones():
# Audit 2026-10-11 #6: the rulers eat scarce canvas width on a phone, so
# the editor opens with them hidden (View > Rulers can still enable them).
assert "window.matchMedia('(max-width: 700px)').matches" in EDITOR
assert "state.rulersVisible = false;" in EDITOR
def test_generation_503_is_handled_gracefully():
# Image generation (/v1/images/generations) can be disabled server-side
# (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