From ec81f8b230bead6b82f2b7d088a719854088df58 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:20:59 +0800 Subject: [PATCH] Bring a save's pictures back onto the output nodes Going back to a save left the last run's picture on the output node, so the canvas and the picture told different stories. - api/workflow_saves.py keeps what each output node made, previews too, the newest 16, and read_group gives them as `outputs`. - ui_snapshot_bar.js puts them on the nodes through the tab's changeTracker (store, restore): no `executed` event, so no plugin takes them for a new run. Several runs sit on the node as a batch does. A node the save has no picture for goes empty; Load Image's own picture stays (snapshot_plan.outputsAt). The lit number went out every other time with a Preview Image on the canvas, and an empty undo step came with it: a loaded workflow serializes a little differently from its file, and a node gains an empty widget list when it shows a picture. The number now goes out only when the canvas really changed (withoutEmptyValues), and those differences take no step. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 4 +++ api/workflow_saves.py | 34 +++++++++++++++++++++---- docs/architecture/ui-flows.md | 1 + tests/snapshot_plan.mjs | 18 +++++++++++++- tests/test_workflow_saves.py | 28 +++++++++++++++++++++ web/modules/snapshot_plan.js | 22 +++++++++++++++++ web/modules/ui_snapshot_bar.js | 45 ++++++++++++++++++++++++++++------ 8 files changed, 139 insertions(+), 15 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 07fb149..182a8f8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -225,7 +225,7 @@ covered by the `styles.css` manifest. extra or by its file (AD-023), and finds a run's pictures in ComfyUI's history; `web/modules/snapshot_store.js` is the page's side of its routes, every change going through it so what shows a group hears of it. `web/modules/ui_snapshot_bar.js` is the bar under the canvas, following the open tab, with what is used while working there (a node's and the - empty canvas's menu entry in `canvas_menu.js`; going back is one of ComfyUI's undo steps on the same tab, a new tab is + empty canvas's menu entry in `canvas_menu.js`; going back is one of ComfyUI's undo steps on the same tab and puts the save's pictures on their output nodes through the tab's `changeTracker`, a new tab is `loadGraphData` as ComfyUI opens a file). `web/modules/ui_saves_pictures.js` is a save's pictures there: a line of what fits, the rest folded into gallery cards. `web/modules/ui_saves_list.js` is the Workflows page's Saves view, a part for each canvas, open or not, with each save's pictures, its runs and the one thing a save becomes, a workflow card. `web/modules/snapshot_run.js` runs saves for both, on detached copies (`detached_graph.js`, diff --git a/CHANGELOG.md b/CHANGELOG.md index 114c51e..1468bad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## v1.63.0 — unreleased +- 回到一个存档时,输出节点上的图也跟着换:画布是哪个存档,节点上就是哪个存档跑出来的图。跑了几次就都排在节点里,和批量出图一样,新的在前,最多 16 张。这个存档没有图时节点空着,不留别的存档的图,免得看错。只对这个版本之后跑的图有效。 +- 回到存档时数字有时不亮、撤销多出一步的问题修好了:画布上有 Preview Image 这类节点时,每隔一次就会这样。 +- Going back to a save brings its pictures back too: the output nodes show what that save made. Several runs sit on the node together as a batch does, the newest first, up to 16. A save with no picture leaves the node empty rather than showing another save's. This works for runs from this version on. +- Going back to a save no longer loses the lit number or leaves an empty undo step every other time, as it did with a node like Preview Image on the canvas. - 发版前把每个页面按中英文、深浅色和 480 窄宽走了一遍,修了一批小问题。浅色主题下,模型整理页的标题和文件名、任务上“接着做”的标签原来几乎看不见。窄窗口下,任务的“接着哪个任务”标签伸出窗口、× 点不到,设置页的主题按钮把页面撑出横向滚动,模型页的搜索框压住标题,工作流详情的“新预设”被切掉。 - 英文界面的列表和冒号不再是中文标点,“1 saves”这类单复数对了,左边栏的 AI 工作台写作 “AI”,一条漏翻的提示补上了。深色主题下浅紫色按钮和角标上的字改成深色,看得更清楚。 - A round through every page in both languages, all themes and at 480 fixed small things. In the light theme, the Tidy page's headings and file names and a task's "goes on in" chip could hardly be read. In a narrow window, a task's "goes on from" chip ran out of the window with its × out of reach, the theme buttons in Settings pushed the page sideways, the Models search sat over the title, and "New preset" was cut off. diff --git a/api/workflow_saves.py b/api/workflow_saves.py index 5968ed4..cb967f4 100644 --- a/api/workflow_saves.py +++ b/api/workflow_saves.py @@ -4,7 +4,8 @@ the workflow carries in its extra: index.json says what its saves are and each s its own, so keeping one writes that file and a small index. Saves outlive their canvas: a closed tab or a deleted file leaves them here, and only the user removes them, to the Recycle Bin. The page names a save's runs (prompt ids); reading the group finds their pictures in ComfyUI's history and keeps the names, so a -page reloaded during a run loses nothing. Pictures stay where ComfyUI saved them; one gone is not shown. +page reloaded during a run loses nothing. It keeps them by output node too, previews as well: going back to +a save puts them on those nodes. Pictures stay where ComfyUI saved them; one gone is not shown. A save becomes something else only as a workflow card, made by the page from its workflow (AD-023).""" import asyncio @@ -26,6 +27,7 @@ INDEX = "index.json" MAX_SAVES = 50 # of a group: past that the user removes some, nothing goes by itself MAX_GROUPS = 2000 # read for a list MAX_PICTURES = 200 # kept of a save, the newest +MAX_ON_NODE = 16 # of an output node at a save, the newest: a full round of runs MAX_CHANGES = 40 MAX_RUNS = 16 # of a save at a time; web/modules/snapshot_plan.js MAX_COUNT MAX_WAITING = 200 @@ -97,11 +99,20 @@ def _queue(): return PromptServer.instance.prompt_queue +def _run_outputs(record): + """The pictures of a finished run by the node that shows them: {node: pictures}.""" + found = {} + for node, output in (record.get("outputs") or {}).items(): + pictures = [{"filename": image["filename"], "subfolder": image.get("subfolder") or "", "type": image.get("type") or "output"} + for image in output.get("images") or [] if str(image.get("filename") or "").lower().endswith(PICTURES)] + if pictures: + found[str(node)] = pictures + return found + + def _run_pictures(record): """What a finished run made: the pictures it saved, or with none saved those it previewed.""" - found = [{"filename": image["filename"], "subfolder": image.get("subfolder") or "", "type": image.get("type") or "output"} - for output in (record.get("outputs") or {}).values() for image in output.get("images") or [] - if str(image.get("filename") or "").lower().endswith(PICTURES)] + found = [picture for pictures in _run_outputs(record).values() for picture in pictures] return [picture for picture in found if picture["type"] == "output"] or found @@ -121,6 +132,10 @@ def _collect(group): record = _queue().get_history(prompt_id=run["id"]).get(run["id"]) save["pictures"].extend(picture for picture in _run_pictures(record or {}) if picture not in save["pictures"]) del save["pictures"][:-MAX_PICTURES] + for node, pictures in _run_outputs(record or {}).items(): + on_node = save.setdefault("outputs", {}).setdefault(node, []) + on_node.extend(picture for picture in pictures if picture not in on_node) + del on_node[:-MAX_ON_NODE] save["waiting"].remove(run) changed = True return changed @@ -144,6 +159,12 @@ def _view(group): return {"key": group["key"], "title": group.get("title") or "", "path": group.get("path") or "", "saves": saves} +def _outputs(save): + """What a save's output nodes made, the newest first: no picture that is gone, no node left without one.""" + shown = {node: [picture for picture in reversed(pictures) if _there(picture)] for node, pictures in (save.get("outputs") or {}).items()} + return {node: pictures for node, pictures in shown.items() if pictures} + + def read_group(key, path=""): """The group of `key`, or the newest one of the workflow file `path`; None when there is none.""" with _lock: @@ -154,7 +175,10 @@ def read_group(key, path=""): return None if _collect(group): _write(group) - return _view(group) + shown = _view(group) + for view, save in zip(shown["saves"], group["saves"]): + view["outputs"] = _outputs(save) + return shown def list_groups(): diff --git a/docs/architecture/ui-flows.md b/docs/architecture/ui-flows.md index 43183fa..c8b9b6b 100644 --- a/docs/architecture/ui-flows.md +++ b/docs/architecture/ui-flows.md @@ -120,6 +120,7 @@ AMB 更新以后,AI 软件还拿着旧的工具列表来调用时,标题栏 右键节点或画布空白处「存档当前工作流」后出现在画布下方,因为存档时人在画布上改东西,AMB 的窗口会盖住画布。存档跟着工作流走:切标签换成那个工作流的,没有存档的标签没有这一条;刷新、重启都还在。 - 存档的数字:回到那一步,在同一个标签页里,算一步撤销,Ctrl+Z 能回来,也可以接着改出新分支。亮着的数字就是画布现在的样子,画布一改就灭。 +- 回到一个存档时,输出节点上的图也换成这个存档跑出来的:跑了几次就都排在节点里,和批量出图一样,新的在前,最多 16 张。这个存档在某个节点上没有图(没跑过,或图删了),那个节点就空着,不留别的存档的图,免得看错。Load Image 选的图不动。Ctrl+Z 退回的只是画布,节点上的图不跟着退。 - 条上只放最新的 6 个,更早的收进一个「前 N 个」,点开是存档页。「运行」跑的是全部存档,不是画布现在的样子。 - 数字旁边的框:这个存档跑几次。条上的 +:再存一个。⌄:收成贴在画布底边的小把手。「随机」和「锁种」管整组运行的种子。 - 条放在画布这块区域里,居中对着画布,打开底部的日志也不会盖住它。提示飘在条的上方,按钮不会被顶开;跑完提示自己消失。 diff --git a/tests/snapshot_plan.mjs b/tests/snapshot_plan.mjs index 5761489..8ccd8d0 100644 --- a/tests/snapshot_plan.mjs +++ b/tests/snapshot_plan.mjs @@ -1,6 +1,6 @@ // The saves bar's rules (web/modules/snapshot_plan.js): which widgets are seeds, what each run gets, what is compiled. import assert from 'node:assert/strict'; -import { jobLine, MAX_COUNT, applySeeds, baseSeed, changeKind, compiled, runCount, runTotal, seedWidgets, seedsOf } from '../web/modules/snapshot_plan.js'; +import { jobLine, MAX_COUNT, applySeeds, baseSeed, changeKind, compiled, outputsAt, runCount, runTotal, seedWidgets, seedsOf, withoutEmptyValues } from '../web/modules/snapshot_plan.js'; const control = { name: 'control_after_generate', value: 'fixed' }; const seed = (name, value = 7, options = {}) => ({ name, value, options, linkedWidgets: [control] }); @@ -72,4 +72,20 @@ assert.equal((await compiled(wrapped, copy)).output, 'copy', 'a plugin that leav assert.equal(await add('bad', job('after')), 'after', 'a refused one neither blocks the line nor stays waiting'); } +{ + const picture = (filename, type = 'output') => ({ filename, subfolder: '', type }); + const shown = { 2: { images: [picture('old.png')] }, 3: { images: [picture('old2.png', 'temp')] }, 4: { images: [picture('cat.png', 'input')] }, 5: { text: ['words'] } }; + assert.deepEqual(outputsAt(shown, { 2: [picture('b.png'), picture('a.png')] }), + { 2: { images: [picture('b.png'), picture('a.png')] }, 4: shown[4], 5: shown[5] }, + "all the save's pictures on their node; none left from elsewhere; Load Image's picture and text stay"); + assert.deepEqual(outputsAt(shown), { 4: shown[4], 5: shown[5] }, 'a save with no pictures leaves the output nodes empty'); +} + +{ + const state = values => ({ links: [], nodes: [{ id: 1, type: 'SaveImage', ...values }] }); + assert.deepEqual(withoutEmptyValues(state({ widgets_values: [], widgets_values_named: {} })), state({}), 'a node showing a picture changed nothing'); + assert.deepEqual(withoutEmptyValues(state({ widgets_values: ['a'], widgets_values_named: { x: 'a' } })), state({ widgets_values: ['a'], widgets_values_named: { x: 'a' } })); + assert.deepEqual(withoutEmptyValues(state({ widgets_values: { seed: 1 } })), state({ widgets_values: { seed: 1 } }), 'values kept by name stay'); +} + console.log('snapshot_plan ok'); diff --git a/tests/test_workflow_saves.py b/tests/test_workflow_saves.py index 2b767eb..a4e8e76 100644 --- a/tests/test_workflow_saves.py +++ b/tests/test_workflow_saves.py @@ -105,6 +105,34 @@ class SavesTests(unittest.TestCase): self.queue.history["run1"] = {"outputs": {"9": {"images": [picture("preview.png", "temp")]}}} self.assertEqual(workflow_saves.read_group(KEY)["saves"][0]["pictures"], [picture("preview.png", "temp")]) + def test_a_save_keeps_what_each_output_node_made_the_newest_first(self): + save = self.add()["saves"][0] + for name in ("a.png", "b.png", "mid.png"): + (self.output / name).write_bytes(b"x") + workflow_saves.note_runs(KEY, save["id"], ["run1", "run2", "run3"]) + self.queue.history["run1"] = {"outputs": {"9": {"images": [picture("a.png")]}, "4": {"images": [picture("mid.png", "temp")]}, "7": {"text": ["no picture"]}}} + self.queue.history["run2"] = {"outputs": {"9": {"images": [picture("b.png")]}}} + self.queue.history["run3"] = {"outputs": {"9": {"images": [picture("a.png")]}}} + shown = workflow_saves.read_group(KEY)["saves"][0] + self.assertEqual(shown["outputs"], {"9": [picture("b.png"), picture("a.png")], "4": [picture("mid.png", "temp")]}, + "several runs stay together, previews too, and a run from the cache adds nothing") + (self.output / "mid.png").unlink() + self.assertEqual(list(workflow_saves.read_group(KEY)["saves"][0]["outputs"]), ["9"], "a node with its pictures gone is left out") + + def test_an_output_node_keeps_the_newest_of_many(self): + save = self.add()["saves"][0] + names = [f"{number}.png" for number in range(workflow_saves.MAX_ON_NODE + 2)] + for name in names: + (self.output / name).write_bytes(b"x") + workflow_saves.note_runs(KEY, save["id"], ["run1"]) + self.queue.history["run1"] = {"outputs": {"9": {"images": [picture(name) for name in names]}}} + kept = workflow_saves.read_group(KEY)["saves"][0]["outputs"]["9"] + self.assertEqual((len(kept), kept[0]), (workflow_saves.MAX_ON_NODE, picture(names[-1]))) + + def test_a_save_from_before_outputs_were_kept_has_none(self): + self.add() + self.assertEqual(workflow_saves.read_group(KEY)["saves"][0]["outputs"], {}) + def test_a_picture_gone_is_not_shown_and_a_run_off_the_queue_is_forgotten(self): save = self.add()["saves"][0] self.queue.pending = ["run1", "lost"] diff --git a/web/modules/snapshot_plan.js b/web/modules/snapshot_plan.js index 852af94..856f2ca 100644 --- a/web/modules/snapshot_plan.js +++ b/web/modules/snapshot_plan.js @@ -81,4 +81,26 @@ export function changeKind(changes) { } /** What the user typed as a mark's runs, within what a mark takes. */ +/** + * What the nodes show at a save, from ComfyUI's `nodeOutputs`: the save's pictures on the nodes that made + * them, as a batch shows, and no picture from elsewhere on the others. `saved` is the save's `outputs`. + * Load Image's own picture and what is no picture stay. + */ +export function outputsAt(nodeOutputs, saved = {}) { + const stays = ([, output]) => !output?.images?.length || output.images.every(image => image.type === 'input'); + const shown = Object.fromEntries(Object.entries(nodeOutputs).filter(stays)); + for (const [node, images] of Object.entries(saved)) shown[node] = { images }; + return shown; +} + +const blank = value => value == null || (typeof value === 'object' && !Object.keys(value).length); + +/** A canvas state without what a node gains by showing a picture, empty lists of values: no change of the canvas. */ +export function withoutEmptyValues(state) { + const nodes = (state?.nodes || []).map(({ widgets_values: values, widgets_values_named: named, ...node }) => ({ + ...node, ...(blank(values) ? {} : { widgets_values: values }), ...(blank(named) ? {} : { widgets_values_named: named }), + })); + return { ...state, nodes }; +} + export const runCount = value => Math.max(1, Math.min(MAX_COUNT, Math.floor(Number(value)) || 1)); diff --git a/web/modules/ui_snapshot_bar.js b/web/modules/ui_snapshot_bar.js index b0c17d1..b03a7a9 100644 --- a/web/modules/ui_snapshot_bar.js +++ b/web/modules/ui_snapshot_bar.js @@ -16,7 +16,7 @@ import { diffSnapshots, snapshotGraph } from './activity_diff.js'; import { recordCanvasStep } from './canvas_history.js'; import { freshId, readDetached } from './detached_graph.js'; import { translate as t } from './locales.js'; -import { runCount, runTotal } from './snapshot_plan.js'; +import { outputsAt, runCount, runTotal, withoutEmptyValues } from './snapshot_plan.js'; import { runSaves, seedMode, setSeedMode, watchRuns, watchSeedMode } from './snapshot_run.js'; import { addSave, findGroup, onSavesChange, readWorkflow, setCount } from './snapshot_store.js'; import { settle } from './ui_dom.js'; @@ -31,6 +31,7 @@ let group = null; // the open tab's saves: {key, title, path, saves: [{id, at, c let shownTab = ''; // the tab `group` is for let following = Promise.resolve(); // reading the open tab's saves, one tab after another let current = null; // the save the canvas stands at, while it is unchanged since +let standing = null; // the canvas as it was then let said = null; // what the bar is saying: {text, bad} let branched = false; // the tab was just opened from a save: an empty bar to start its own let folded = readFolded(); @@ -39,6 +40,13 @@ let browser = () => null; const canvasGraph = () => app.rootGraph || app.graph; const activeTab = () => app.extensionManager?.workflow?.activeWorkflow || null; +const sameCanvas = (a, b) => activeTab().changeTracker.constructor.graphEqual(withoutEmptyValues(a), withoutEmptyValues(b)); + +/** The canvas stands at the save `id` from now on. */ +function standAt(id) { + current = id; + standing = copied(canvasGraph().serialize()); +} const canvasKey = () => canvasGraph()?.extra?.[EXTRA_KEY] || ''; const tabPath = tab => (tab?.isPersisted ? tab.path : ''); const copied = value => JSON.parse(JSON.stringify(value)); @@ -138,7 +146,7 @@ export async function addMark() { const stands = await addSave({ key, workflow: kept, title: String(tab?.filename || ''), path: tabPath(tab), changes }); shownTab = `${tab?.path}\n${key}`; branched = false; - current = stands.saves[stands.saves.length - 1].id; + standAt(stands.saves[stands.saves.length - 1].id); draw(); showWorkbenchToast(t('snapshotMarked', { number: stands.saves.length })); } catch (error) { @@ -172,19 +180,40 @@ async function front(tab) { await followTab(); } +/** + * A save's pictures on the output nodes, the way ComfyUI brings a tab's back: no event goes out, so no + * plugin takes them for a new run. A node left with none drops what it has drawn. + */ +function showOutputs(tracker, saved) { + tracker.store(); + const before = tracker.nodeOutputs || {}; + tracker.nodeOutputs = outputsAt(before, saved); + tracker.restore(); + for (const id of Object.keys(before).filter(key => !(key in tracker.nodeOutputs))) { + const node = canvasGraph().getNodeById(id); + if (node) Object.assign(node, { imgs: undefined, images: undefined, imageIndex: null }); + } + app.canvas.setDirty(true, true); +} + /** * The canvas back at `save`, on the same tab, by ComfyUI's own undo step: what the canvas was goes on the * undo list, so Ctrl+Z comes back to it, and the tab still compares with its file. loadGraphData alone - * would take the save for the tab's starting state and keep no step. + * would take the save for the tab's starting state and keep no step. Its pictures come back on the nodes. */ async function goBack(key, save) { - const workflow = copied(await readWorkflow(key, save.id)); + const [workflow, shown] = await Promise.all([readWorkflow(key, save.id), findGroup(key, '')]); const tracker = activeTab().changeTracker; + // What a node gained by showing a picture is no step of its own, here or as the save comes in. + const now = copied(canvasGraph().serialize()); + if (sameCanvas(tracker.activeState, now)) tracker.activeState = now; recordCanvasStep(app); - await tracker.updateState([workflow], tracker.undoQueue); + await tracker.updateState([copied(workflow)], tracker.undoQueue); tracker.redoQueue.length = 0; - current = save.id; + tracker.activeState = copied(canvasGraph().serialize()); + standAt(save.id); draw(); + showOutputs(tracker, shown?.saves.find(item => item.id === save.id)?.outputs); } /** `save` of `item`'s group back on its canvas, that tab to the front first. */ @@ -325,8 +354,8 @@ export function watchSaves(getBrowser) { if (key === group?.key || key === canvasKey()) showGroup(found); }); // Any change, an undo too, leaves the save the canvas stood at. goBack and addMark say so after theirs. - api.addEventListener('graphChanged', () => { - if (!current) return; + api.addEventListener('graphChanged', ({ detail }) => { + if (!current || sameCanvas(standing, detail)) return; current = null; draw(false); });