From 9fdbaa847bba00c071cc3c430d0eeb3efe5ea97d Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:11:31 +0800 Subject: [PATCH] Focus stars answer at once and save light A star turns at once with a little bounce, the value fades in (one unmarked fades out), a failed save turns it back; quick presses queue instead of being refused. Focus values save alone through POST /anomalous/recipe_focus (this computer only, size-capped read, only focusParams written, no version), instead of a full recipe update that kept a version and re-read every model each time. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- api/__init__.py | 1 + api/recipes.py | 37 ++++++++- docs/architecture/recipes.md | 5 +- tests/test_recipe_roundtrip.py | 15 ++++ web/modules/ui_recipe_focus.js | 124 ++++++++++++++++++++---------- web/styles/29-workflow-detail.css | 47 +++++++++-- 7 files changed, 178 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 854e42c..c909575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ - A new set is named after the workflow with a number (阿罗娜K4.0 2); values saved without names show their real names instead of 控件 1, 控件 2. A set equal to the workflow's own values (saving a workflow keeps one) is no longer listed twice. - **Compare pictures inside a workflow too**: every picture in 全部出图 and under a set has ⇄ — pick two, or one and the canvas or the default values. The pictures there now load as thumbnails. - Cards keep the picture, the name, a line of steps, sampler, LoRAs and size, and the model. The models' state is a dot on the cover, with words only when some are missing; 功能模块 marks a partial workflow. **▶ Open** and **⋯** (edit, export, delete) are two small round buttons in the cover's corner, shown when the pointer is on a card, so pressing a card to look inside no longer opens it on the canvas by mistake. -- **Focus values (重点参数)**: mark what matters most in a workflow. Under 全部节点参数 every value has a ☆; marked values show at the top of 默认参数 and of every set as large tiles with the node they belong to, a note you can add (「0.6–0.8 最好」), and, on a set, the default value when it differs. Set rows list them first, and in ⇄ 对比 they lead the key values with their note. They belong to that workflow (the ☆ in the comparison window still stars a value for every workflow), are kept when the workflow is updated from the canvas, and travel with it in an exported workflow package (share codes carry only the canvas workflow). +- **Focus values (重点参数)**: mark what matters most in a workflow. Under 全部节点参数 every value has a ☆; marked values show at the top of 默认参数 and of every set as large tiles with the node they belong to, a note you can add (「0.6–0.8 最好」), and, on a set, the default value when it differs. Set rows list them first, and in ⇄ 对比 they lead the key values with their note. A star turns at once with a little bounce, the value fades in at the top, and marking or noting one saves only that (no new version of the workflow). They belong to that workflow (the ☆ in the comparison window still stars a value for every workflow), are kept when the workflow is updated from the canvas, and travel with it in an exported workflow package (share codes carry only the canvas workflow). - **Tips where they help (参数方案的引导)**: a workflow without sets says what they are for, with **+ 从画布保存** right there; on a set, a line points at **⇄ 对比** until you have used it (or pressed 知道了). - **Folds remember you (折叠记住你的选择)**: 用到的模型 and 全部节点参数 open as you last left them. 用到的模型 says on its title whether the models are all here or how many are missing, and opens by itself when some are. - **Docked, the details fit (侧边栏模式不再挤)**: in a narrow panel the left column becomes one row of pills above the values (默认参数, your sets, new, 全部出图, 版本). diff --git a/api/__init__.py b/api/__init__.py index eeb956f..23f32ca 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -71,6 +71,7 @@ def setup_routes(app): app.router.add_get('/anomalous/recipe_gallery_compare', recipes.api_get_recipe_gallery_compare) app.router.add_post('/anomalous/save_recipe', recipes.api_save_recipe) app.router.add_post('/anomalous/update_recipe', recipes.api_update_recipe) + app.router.add_post('/anomalous/recipe_focus', recipes.api_set_recipe_focus) app.router.add_post('/anomalous/set_recipe_gallery_cover', recipes.api_set_recipe_gallery_cover) app.router.add_post('/anomalous/delete_recipe', recipes.api_delete_recipe) app.router.add_get('/anomalous/recipe_history', recipes.api_get_recipe_history) diff --git a/api/recipes.py b/api/recipes.py index 4cbd195..f05caff 100644 --- a/api/recipes.py +++ b/api/recipes.py @@ -26,13 +26,14 @@ from .recipe_schema import ( _enrich_recipe as _schema_enrich_recipe, _normalise_recipe, _normalise_source_image, _identity_for_reference as _schema_identity_for_reference, _model_reference_key, _model_reference_specs, - _model_roots, _preserve_model_reference_fields, + _model_roots, _normalise_focus_params, _preserve_model_reference_fields, _resolve_exact_model_reference, _updated_recipe, _workflow_identity_for_reference, ) from .recipe_store import ( _archive_recipe, _delete_recipe_with_history, _history_dir, _list_recipe_history, _list_recipes, _read_recipe, _write_recipe, get_recipes_dir, ) +from .mcp_server import _is_local from .utils import require_filename, resolve_within from .workflow_schema import ( _clean_volatile_params, _node_title, _node_type, _normalise_workflow_key, @@ -359,6 +360,40 @@ async def api_update_recipe(request): }) +def _set_recipe_focus(recipes_dir, filename, entries): + """Writes a recipe's focus values (重点参数) alone: no version kept, nothing else read again.""" + focus = _normalise_focus_params(entries) + path = resolve_within(recipes_dir, filename) + if os.path.getsize(path) > MAX_RECIPE_BYTES: + raise ValueError("Recipe is too large") + recipe = _read_recipe(path) + if not isinstance(recipe, dict) or not isinstance(recipe.get("workflow"), dict): + raise ValueError("Invalid recipe") + params = recipe.get("params") if isinstance(recipe.get("params"), dict) else {} + recipe["params"] = {**params, "focusParams": focus} + _write_recipe(recipes_dir, filename, recipe) + return focus + + +async def api_set_recipe_focus(request): + """Marks or notes a recipe's focus values (a star in its details). Only from this computer.""" + if not _is_local(request): + return web.json_response({"status": "error", "message": "Only from this computer"}, status=403) + try: + payload = await request.json() + filename = require_filename(payload.get("filename", "")) + if not filename.endswith(".json"): + raise ValueError("Invalid filename") + focus = await asyncio.to_thread(_set_recipe_focus, get_recipes_dir(), filename, payload.get("focusParams")) + except FileNotFoundError: + return web.json_response({"status": "error", "message": "File not found"}, status=404) + except (AttributeError, ValueError, json.JSONDecodeError): + return web.json_response({"status": "error", "message": "Invalid focus values"}, status=400) + except OSError: + return web.json_response({"status": "error", "message": "Could not update recipe"}, status=500) + return web.json_response({"status": "success", "focusParams": focus}) + + async def api_get_recipe_history(request): try: filename = require_filename(request.query.get("filename", "")) diff --git a/docs/architecture/recipes.md b/docs/architecture/recipes.md index e9ddd73..1f7c25b 100644 --- a/docs/architecture/recipes.md +++ b/docs/architecture/recipes.md @@ -225,7 +225,10 @@ still match. A workflow's focus values (重点参数) live in `params.focusParams`: at most 12 `{ node, type, widget, note }`, all strings (node ID, saved node type, widget name, a note of at most 120 characters). `api/recipe_schema.py` refuses any other -shape and drops repeats. They are shown, compared and kept only while a node of +shape and drops repeats. A star or a note saves them alone through +`POST /anomalous/recipe_focus` (`api/recipes.py`, from this computer only): the +recipe file is read (up to its size limit), only `focusParams` changes, and no +version is kept, unlike a recipe update. They are shown, compared and kept only while a node of that ID and type is still in the workflow (`recipe_focus.js focusEntries`); an update from the canvas keeps the ones still true. Its parameter sets, the canvas it opens and its pictures share the node IDs, so comparisons among them diff --git a/tests/test_recipe_roundtrip.py b/tests/test_recipe_roundtrip.py index 3b6f416..1257dc5 100644 --- a/tests/test_recipe_roundtrip.py +++ b/tests/test_recipe_roundtrip.py @@ -123,6 +123,21 @@ class RecipeWorkflowValidationTests(unittest.TestCase): with self.assertRaises(ValueError): recipes._normalise_recipe(payload) + def test_focus_values_save_alone_without_a_version(self): + with tempfile.TemporaryDirectory() as temp_dir: + recipe = recipes._normalise_recipe(recipe_payload(valid_workflow())) + Path(temp_dir, "r.json").write_text(json.dumps(recipe), encoding="utf-8") + mark = {"node": "2", "type": "KSampler", "widget": "cfg", "note": "7-9"} + self.assertEqual(recipes._set_recipe_focus(temp_dir, "r.json", [mark]), [mark]) + saved = json.loads(Path(temp_dir, "r.json").read_text(encoding="utf-8")) + self.assertEqual(saved["params"]["focusParams"], [mark]) + self.assertEqual(saved["workflow"], recipe["workflow"]) + self.assertFalse(Path(temp_dir, ".history").exists()) + with self.assertRaises(ValueError): + recipes._set_recipe_focus(temp_dir, "r.json", [{"node": "2"}]) + with self.assertRaises(FileNotFoundError): + recipes._set_recipe_focus(temp_dir, "missing.json", []) + def test_invalid_workflow_scope_is_rejected(self): payload = recipe_payload(valid_workflow()) payload["workflow_scope"] = "selection" diff --git a/web/modules/ui_recipe_focus.js b/web/modules/ui_recipe_focus.js index 6cb5564..c34fa86 100644 --- a/web/modules/ui_recipe_focus.js +++ b/web/modules/ui_recipe_focus.js @@ -2,20 +2,34 @@ * A workflow's focus values (recipe_focus.js) in its details: the 重点参数 section at the top of * a set (each value large with its node, its note, and for a saved set the workflow's own value * when it differs), a line on how to mark one while there are none, and the ☆ on each node - * value under 全部节点参数. A change saves the recipe, then redraws the section, the stars and - * the sets' rows. + * value under 全部节点参数. A star turns at once and the save follows (a failed save turns it + * back); then the section, the other stars and the sets' rows are drawn again, the value just + * marked fading in. */ import { translate } from './locales.js'; import { anomalousAlert, anomalousPrompt } from './ui_dialog.js'; import { appendText, button } from './ui_recipe_detail_dom.js'; -import { updateRecipeMetadata } from './ui_recipe_metadata.js'; + import { redrawParameterSets, workflowParams } from './ui_param_set_keys.js'; import { shownValue } from './param_compare.js'; import { MAX_FOCUS, focusEntries, notedFocus, toggledFocus } from './recipe_focus.js'; const t = (key, params) => translate(key, params); const starKey = (node, widget) => `${node}\n${widget}`; +let queue = Promise.resolve(); // changes save one after another, each from what the last one saved + +/** Saves the focus values alone (/anomalous/recipe_focus: no new version), then keeps them on `recipe`. */ +async function saveFocus(owner, recipe, focusParams) { + const response = await fetch('/anomalous/recipe_focus', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: owner.recipeDetailFilename, focusParams }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.status !== 'success') throw new Error(payload.message || `HTTP ${response.status}`); + recipe.params = { ...(recipe.params || {}), focusParams: payload.focusParams }; +} /** The value of `entry`'s widget in a workflow read by workflowParams, as shown; null when it has none. */ function valueIn(params, entry) { @@ -23,39 +37,54 @@ function valueIn(params, entry) { return widget ? shownValue(widget.value) : null; } +function showStar(star, on) { + star.classList.toggle('is-on', on); + star.textContent = on ? '★' : '☆'; + star.title = t(on ? 'focusUnstar' : 'focusStar'); + star.setAttribute('aria-pressed', String(on)); +} + +/** The star's little bounce when pressed (restarted on a quick second press). */ +function pop(star) { + star.classList.remove('is-popping'); + void star.offsetWidth; + star.classList.add('is-popping'); +} + /** Marks every ☆ under `root` as the recipe's marks now say. */ function syncStars(root, recipe) { const marked = new Set(focusEntries(recipe.params, recipe.workflow).map(entry => starKey(entry.node, entry.widget))); - for (const star of root.querySelectorAll('.anomalous-wfd-focus-star[data-focus]')) { - const on = marked.has(star.dataset.focus); - star.classList.toggle('is-on', on); - star.textContent = on ? '★' : '☆'; - star.title = t(on ? 'focusUnstar' : 'focusStar'); - } + for (const star of root.querySelectorAll('.anomalous-wfd-focus-star[data-focus]')) showStar(star, marked.has(star.dataset.focus)); } -/** Saves `params` (null: the list is full) on the recipe, then calls `after`. */ -async function change(ctx, params, control, after) { - if (!params) { - await anomalousAlert(t('focusFull', { count: MAX_FOCUS })); - return; - } - control.disabled = true; - try { - await updateRecipeMetadata(ctx.owner, ctx.recipe, { params }); - } catch (error) { - console.error('Could not save the focus values:', error); - await anomalousAlert(t('focusSaveError')); - return; - } finally { - control.disabled = false; - } - after(); - redrawParameterSets(); +/** + * Saves `next(params)` on the recipe (null: the list is full) after any change still saving; + * `after` once saved, `undo` when it was not (what the press already showed is taken back). + */ +function change(ctx, next, after, undo) { + queue = queue.then(async () => { + const params = next(ctx.recipe.params || {}); + if (!params) { + undo(); + await anomalousAlert(t('focusFull', { count: MAX_FOCUS })); + return; + } + try { + await saveFocus(ctx.owner, ctx.recipe, params.focusParams); + } catch (error) { + console.error('Could not save the focus values:', error); + undo(); + await anomalousAlert(t('focusSaveError')); + return; + } + after(); + redrawParameterSets(); + }); + return queue; } -/** Draws the 重点参数 of `source` (the recipe or one of its sets) into `slot`. */ -export function drawFocus(slot, ctx, source) { +/** Draws the 重点参数 of `source` (the recipe or one of its sets) into `slot`; `fresh`: the value just marked. */ +export function drawFocus(slot, ctx, source, fresh = '') { const { recipe } = ctx; const redraw = () => { drawFocus(slot, ctx, source); @@ -67,7 +96,7 @@ export function drawFocus(slot, ctx, source) { appendText(slot, 'p', t('focusHint'), 'anomalous-wfd-muted anomalous-wfd-focus-hint'); return; } - const box = appendText(slot, 'section', '', 'anomalous-wfd-section anomalous-wfd-focus'); + const box = appendText(slot, 'section', '', `anomalous-wfd-section anomalous-wfd-focus${fresh && entries.length === 1 ? ' is-new' : ''}`); appendText(appendText(box, 'div', '', 'anomalous-wfd-section-head'), 'h3', t('focusTitle')); const grid = appendText(box, 'div', '', 'anomalous-wfd-focus-grid'); const own = workflowParams(recipe.workflow); @@ -76,13 +105,22 @@ export function drawFocus(slot, ctx, source) { const value = valueIn(mine, entry); const base = source === recipe ? value : valueIn(own, entry); const differs = value !== null && base !== null && value !== base; - const tile = appendText(grid, 'div', '', `anomalous-wfd-focus-tile${differs ? ' is-differs' : ''}`); + const isNew = fresh === starKey(entry.node, entry.widget); + const tile = appendText(grid, 'div', '', `anomalous-wfd-focus-tile${differs ? ' is-differs' : ''}${isNew ? ' is-new' : ''}`); const top = appendText(tile, 'div', '', 'anomalous-wfd-focus-top'); const nodeTitle = own.get(entry.node)?.title || entry.type; appendText(top, 'span', entry.widget, 'anomalous-wfd-focus-name').title = `${nodeTitle} · ${entry.widget}`; - const unmark = button(top, '★', 'anomalous-wfd-focus-star is-on'); - unmark.title = t('focusUnstar'); - unmark.onclick = () => change(ctx, toggledFocus(recipe.params || {}, { id: entry.node, type: entry.type }, entry.widget), unmark, redraw); + const unmark = button(top, '', 'anomalous-wfd-focus-star'); + showStar(unmark, true); + unmark.onclick = () => { + showStar(unmark, false); + pop(unmark); + tile.classList.add('is-leaving'); + void change(ctx, params => toggledFocus(params, { id: entry.node, type: entry.type }, entry.widget), redraw, () => { + showStar(unmark, true); + tile.classList.remove('is-leaving'); + }); + }; appendText(tile, 'span', nodeTitle, 'anomalous-wfd-focus-node'); appendText(tile, 'strong', value ?? '—', 'anomalous-wfd-focus-value').title = value ?? ''; if (differs) appendText(tile, 'small', t('focusDefault', { value: base }), 'anomalous-wfd-focus-default'); @@ -91,7 +129,7 @@ export function drawFocus(slot, ctx, source) { note.onclick = async () => { const text = await anomalousPrompt(t('focusNotePrompt'), entry.note || ''); if (text === null) return; - await change(ctx, notedFocus(recipe.params || {}, entry, text), note, redraw); + await change(ctx, params => notedFocus(params, entry, text), redraw, () => {}); }; } } @@ -105,17 +143,19 @@ export function focusStars(ctx, slot, source) { return (node, widget) => { const star = document.createElement('button'); star.type = 'button'; + star.className = 'anomalous-wfd-focus-star'; star.dataset.focus = starKey(node.id, widget); - star.className = `anomalous-wfd-focus-star${marked.has(star.dataset.focus) ? ' is-on' : ''}`; - star.textContent = marked.has(star.dataset.focus) ? '★' : '☆'; - star.title = t(marked.has(star.dataset.focus) ? 'focusUnstar' : 'focusStar'); + showStar(star, marked.has(star.dataset.focus)); star.onclick = (event) => { event.stopPropagation(); - void change(ctx, toggledFocus(ctx.recipe.params || {}, node, widget), star, () => { - drawFocus(slot, ctx, source); + const on = !star.classList.contains('is-on'); + showStar(star, on); + pop(star); + void change(ctx, params => toggledFocus(params, node, widget), () => { + drawFocus(slot, ctx, source, on ? star.dataset.focus : ''); syncStars(star.closest('.anomalous-wfd-main') || slot.parentElement, ctx.recipe); - }); + }, () => showStar(star, !on)); }; return star; }; -} +} \ No newline at end of file diff --git a/web/styles/29-workflow-detail.css b/web/styles/29-workflow-detail.css index 573342d..1acb670 100644 --- a/web/styles/29-workflow-detail.css +++ b/web/styles/29-workflow-detail.css @@ -427,24 +427,55 @@ .anomalous-wfd-focus-note.is-empty { color: var(--amb-text-dim); } .anomalous-wfd-focus-star { + display: inline-flex; flex: 0 0 auto; - width: 20px; - height: 20px; - margin-right: 4px; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + margin: -3px 4px -3px -4px; padding: 0; border: none; border-radius: 50%; background: transparent; color: var(--amb-text-dim); - font-size: 13px; + font-size: 15px; line-height: 1; - vertical-align: -1px; + vertical-align: middle; cursor: pointer; + transition: color 0.15s ease, background-color 0.15s ease, transform 0.12s ease; } -.anomalous-wfd-focus-top .anomalous-wfd-focus-star { margin-right: -4px; } -.anomalous-wfd-focus-star:hover { background: rgba(var(--amb-ink-rgb), 0.08); color: var(--amb-link); } -.anomalous-wfd-focus-star.is-on { color: #f5b301; } +.anomalous-wfd-focus-top .anomalous-wfd-focus-star { margin: -4px -6px -4px 0; } +.anomalous-wfd-focus-star:hover { background: rgba(var(--amb-ink-rgb), 0.08); color: #f5b301; transform: scale(1.12); } +.anomalous-wfd-focus-star:active { transform: scale(0.88); } +.anomalous-wfd-focus-star.is-on { color: #f5b301; text-shadow: 0 0 8px rgba(245, 179, 1, 0.45); } +.anomalous-wfd-focus-star.is-popping { animation: anomalous-focus-star-pop 0.32s cubic-bezier(0.3, 1.6, 0.5, 1); } + +@keyframes anomalous-focus-star-pop { + 0% { transform: scale(0.7); } + 55% { transform: scale(1.35); } + 100% { transform: scale(1); } +} + +/* The value just marked fades in; one being unmarked fades while its save runs. */ +.anomalous-wfd-focus-tile { transition: opacity 0.2s ease, transform 0.2s ease; } +.anomalous-wfd-focus-tile.is-new, +.anomalous-wfd-focus.is-new { animation: anomalous-focus-in 0.35s ease-out; } +.anomalous-wfd-focus-tile.is-leaving { opacity: 0.35; transform: scale(0.97); } + +@keyframes anomalous-focus-in { + from { opacity: 0; transform: translateY(6px) scale(0.98); } + to { opacity: 1; transform: none; } +} + +@media (prefers-reduced-motion: reduce) { + .anomalous-wfd-focus-star, + .anomalous-wfd-focus-tile { transition: none; } + .anomalous-wfd-focus-star.is-popping, + .anomalous-wfd-focus-tile.is-new, + .anomalous-wfd-focus.is-new { animation: none; } +} /* A tip under a set's name: what sets are for, or where ⇄ is. */ .anomalous-wfd-tip {