diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5413db5..beeaf91 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -220,10 +220,11 @@ covered by the `styles.css` manifest. shows the routes this ComfyUI can build for them, a saved card using both first, its answer says what to call, and `add_nodes(route=…)` builds one from the ways on a new tab; `get_toolbox` lists each picked model's cards as its examples. An AI placing nodes itself is a new route (`ai_gates.route_rule`): one reminder a task, or the user's OK. -- The snapshot queue. `web/modules/ui_snapshot_bar.js` keeps whole-workflow marks (a node's menu entry in `canvas_menu.js`, - the bar under the canvas, going back on the same tab) and runs them on detached copies (`detached_graph.js`, - `app.graphToPrompt`, `api.queuePrompt`), so the canvas never changes; `web/modules/snapshot_plan.js` holds the rules - (which widgets are seeds, what each run gets; `tests/snapshot_plan.mjs`); `api/snapshot_routes.py` checks each mark with +- Saves (the snapshot queue). `web/modules/ui_snapshot_bar.js` keeps whole-workflow marks (a node's and the empty canvas's + menu entry in `canvas_menu.js`, the bar under the canvas; going back is one of ComfyUI's undo steps on the same tab, a + new tab is `loadGraphData` as ComfyUI opens a file) and runs them on detached copies (`detached_graph.js`, + `api.queuePrompt`), so the canvas never changes; `web/modules/snapshot_plan.js` holds the rules (which widgets are seeds, + what each run gets, compiling past a plugin that drops the graph; `tests/snapshot_plan.mjs`); `api/snapshot_routes.py` checks each mark with ComfyUI's `validate_prompt` before anything is queued. A block's "variants" rebuild it for the canvas model's family (`reference_blocks.build`: on Anima an LLLite model patch instead of a ControlNet), and an "optional" node that only improves it (KJNodes' diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a38677..c92ed85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,8 @@ - **Fix hands: enlarge, then fix** gains a SAM3 pass: after the light redraw, SAM3 finds the hands by the word (up to 8 in a picture) and redraws each inside its own outline, then the hand detector mends them in their boxes, as the reference author does in two passes. SAM3's nodes come with ComfyUI; its model (sam3.1, 1.7 GB) is one click in the Ways panel, checked by its SHA-256, and without it the way runs the detector's pass alone as before. - **半途换模型接着画** 可以在换模型那一步顺手放大(1.5 倍):后面的步数在大尺寸上画细节,省掉画完再放大重画的那一轮;AI 加一个放大节点、改两个设置就行,前后用同一个模型也可以。放大前,前一段会先把画面收干净:带着剩下的噪声直接放大,整张图会变成彩色方块。skill“分阶段出图的思路”补了一句。 - **Switch models mid-way** can enlarge at the switch (1.5×): the later steps draw the detail at the larger size, in place of an upscale pass after; the AI adds one node and changes two settings, with the same model or another. The first part finishes its picture before it is enlarged: enlarged with its leftover noise, the picture turned into coloured blocks. The skill "Drawing a picture in stages" says so in one sentence. -- 新增 **快照队列**:在画布上改任何东西(参数、模型、节点连接),右键节点“选为标记”就把整个工作流存成一个标记,画布下方出现标记条。接着改、接着打标记;点标记的数字回到那一步,再改出新的分支。每个标记设好跑几次,种子选 **随机**(每次换新,不管节点原来是固定还是随机)或 **锁种**(每轮一个种子,所有标记共用,公平对比),按 **运行**:AMB 先让 ComfyUI 检查每个标记,有问题(断线、模型没了)就一张都不排,并说明是哪个标记;没问题就按顺序全部排进队列。跑的是副本,你的画布和上面的种子都不动,可以接着改。标记只在这次打开页面时有效,想留下就回到那个标记、存成工作流卡。 -- New **snapshot queue**: change anything on the canvas (values, models, how nodes connect), right-click a node and choose "Mark this state", and the whole workflow is kept as a mark in a bar under the canvas. Go on changing and marking; a mark's number goes back to that state to branch from it. Set each mark's runs, pick **Random** seeds (new every run, whatever the nodes are set to) or **Locked** (one seed a round, shared by all marks, for a fair comparison), and **Run**: ComfyUI checks every mark first, and a broken link or a missing model stops it before anything is queued, naming the mark; else all of it is queued in order. Copies run, so your canvas and its seeds stay as they are and you can work on. Marks last while the page is open; to keep one, go back to it and save a workflow card. +- 新增 **存档**:在画布上改任何东西(参数、模型、节点连接),右键节点或画布空白处选“存档当前工作流”,就把整个工作流存成一个存档,画布下方出现存档条,之后按条上的 + 接着存。点存档的数字回到那一步,就在当前标签页,Ctrl+Z 能回到回退之前,也可以接着改出新的分支;点 ↗ 在新标签页打开,当前画布不动。每个存档设好跑几次,种子选 **随机**(每次换新,不管节点原来是固定还是随机)或 **锁种**(每轮一个种子,所有存档共用,公平对比),按 **运行**:AMB 先让 ComfyUI 检查每个存档,有问题(断线、模型没了)就一次都不排,并说明是哪个存档;没问题就按顺序全部排进队列。跑的是副本,你的画布和上面的种子都不动,可以接着改。存档只在这次打开页面时有效,想留下就回到那个存档、存成工作流卡。 +- New **saves**: change anything on the canvas (values, models, how nodes connect), right-click a node or the empty canvas and choose "Save this state", and the whole workflow is kept as a save in a bar under the canvas; the bar's + keeps the next ones. A save's number goes back to that state on the same tab, Ctrl+Z returns to where you were, and you can branch from it; its ↗ opens it on a new tab and leaves the canvas as it is. Set each save's runs, pick **Random** seeds (new every run, whatever the nodes are set to) or **Locked** (one seed a round, shared by all saves, for a fair comparison), and **Run**: ComfyUI checks every save first, and a broken link or a missing model stops it before anything is queued, naming the save; else all of it is queued in order. Copies run, so your canvas and its seeds stay as they are and you can work on. Saves last while the page is open; to keep one, go back to it and save a workflow card. - 你给任务挑了两个主模型时,AI 不用自己琢磨怎么分工:AMB 准备好几种用法问你(比如“第一个出底图,第二个用 Tile 放大重画”“第一个画前面,第二个接着画完”“只用第一个一次出图”),你存过同时用这两个模型的卡时,它排第一并标推荐。你选好后,AMB 在新标签页里把整条路线搭好,LoRA 也挂到每个模型上,AI 只写提示词。AI 看模型时还能看到这个模型在你哪些卡里用过、每张卡是什么路线。 - 关卡里新增 **新路线**:AI 不用现成的卡和做法、自己往画布上放节点时,默认提醒一次“这条路线还没验证过,跑完看看效果,好的话存成卡”;选“先问我”,每个任务要先等你点头。 - With two main models picked for a task, the AI need not work out who does what: Anomalous prepares the ways to use them and asks you ("the first draws the base, the second redraws it enlarged under Tile", "the first draws the early steps, the second finishes", "only the first, in one pass"), a card you saved with both on top and recommended. Once you choose, it builds the whole route on a new tab, the LoRAs on each model, and the AI only writes the prompt. The AI also sees which of your cards use a model and each card's route. diff --git a/docs/architecture/lessons.md b/docs/architecture/lessons.md index 8fc3fb8..d8bb4ef 100644 --- a/docs/architecture/lessons.md +++ b/docs/architecture/lessons.md @@ -147,3 +147,13 @@ body:has(#anomalous-toolbox-modal[style*="display: flex"]) #anomalous-sidebar-to **以后怎么做** - 底层模块(看图、卡片、纯函数)只从同样底层的模块取工具函数,不从“页面”模块(下载、设置、浏览器主对象)取,哪怕只是一个小函数;需要就挪到没有导入的小文件里(`file_size.js`)。 - 改了导入关系,在真的 ComfyUI 页面上确认 `window.anomalousBrowserInstance` 存在、控制台没有 `before initialization`,再提交。 + +## 2026-11-04 · 存档跑出来全是画布上的内容 + +**现象**:两个存档跑 3 + 1 次,只出了一轮图。四次提交一模一样,第二个存档的改动和新种子都没进去,后三次全走缓存;运行前的检查查的也是画布。 + +**根因**:ComfyUI-Manager 把 `app.graphToPrompt` 包了一层,调用原函数时不带参数,传进去的离屏图被丢掉,ComfyUI 就用默认的画布。测试环境没装 Manager,所以当时没测出来。 + +**以后怎么做** +- 往 ComfyUI 的公共方法传参数前,先想到别的插件可能包过它;能从结果核对的就核对(`snapshot_plan.compiled` 看返回的 `workflow.id`)。 +- 依赖 `app`、`api` 上这类方法的功能,验证时要带上 Manager 这样的常见包装;测试环境没有,就在页面上照它的写法包一层。 diff --git a/docs/architecture/ui-flows.md b/docs/architecture/ui-flows.md index 0d6d654..0f93a3b 100644 --- a/docs/architecture/ui-flows.md +++ b/docs/architecture/ui-flows.md @@ -107,13 +107,13 @@ AMB 更新以后,AI 软件还拿着旧的工具列表来调用时,标题栏 | 「素材」 | 「给 AI 的素材」:挑模型、节点、搭配、工作流、提示词交给 AI;「来源」列表里鼠标所在的行会亮,选中的打勾 | `ui_ai_toolbox.js`、`ui_ai_facet_menu.js` | | 「成果」 | 这个任务出的图、工作流、搭配、提示词、音频,按步骤排 | `ui_ai_outputs.js` | -## 快照队列(`ui_snapshot_bar.js`) +## 存档(`ui_snapshot_bar.js`) -右键节点「选为标记」后出现在画布下方,因为打标记时人在画布上改东西,AMB 的窗口会盖住画布。这里只管“接下来要跑什么”;跑过什么看记录页和图库。 +右键节点或画布空白处「存档当前工作流」后出现在画布下方,因为存档时人在画布上改东西,AMB 的窗口会盖住画布。这里只管“接下来要跑什么”;跑过什么看记录页和图库。 -- 标记的数字:回到那一步,在同一个标签页里,可以接着改出新分支。 -- 数字旁边的框:这个标记跑几次。「随机」和「锁种」管所有标记的种子。 -- 运行前逐个标记让 ComfyUI 检查,有问题一张都不排。跑的是副本,画布和上面的种子都不动。 +- 存档的数字:回到那一步,在同一个标签页里,算一步撤销,Ctrl+Z 能回来,也可以接着改出新分支。 +- 数字旁边的框:这个存档跑几次。↗:在新标签页打开,当前画布不动。条上的 +:再存一个。「随机」和「锁种」管所有存档的种子。 +- 运行前逐个存档让 ComfyUI 检查,有问题一次都不排。跑的是副本,画布和上面的种子都不动。 ## 设计上的固定做法 diff --git a/tests/snapshot_plan.mjs b/tests/snapshot_plan.mjs index 109150f..0245252 100644 --- a/tests/snapshot_plan.mjs +++ b/tests/snapshot_plan.mjs @@ -1,6 +1,6 @@ -// The snapshot queue's rules (web/modules/snapshot_plan.js): which widgets are seeds, what each run gets. +// 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 { MAX_COUNT, applySeeds, baseSeed, runCount, runTotal, seedWidgets, seedsOf } from '../web/modules/snapshot_plan.js'; +import { MAX_COUNT, applySeeds, baseSeed, compiled, runCount, runTotal, seedWidgets, seedsOf } 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] }); @@ -35,4 +35,20 @@ assert.equal(runCount('nope'), 1); assert.equal(runCount(999), MAX_COUNT); assert.ok(baseSeed(() => 0.999999999) + MAX_COUNT <= 1125899906842624); +class App { + rootGraph = { id: 'canvas' }; + async graphToPrompt(graph = this.rootGraph) { + return { workflow: { id: graph.id }, output: graph.id }; + } +} +const copy = { id: 'copy' }; +assert.equal((await compiled(new App(), copy)).output, 'copy'); +const wrapped = new App(); +const own = wrapped.graphToPrompt; +wrapped.graphToPrompt = async function () { // ComfyUI-Manager's components-manager.js + return await own.call(wrapped); +}; +assert.equal((await wrapped.graphToPrompt(copy)).output, 'canvas'); +assert.equal((await compiled(wrapped, copy)).output, 'copy', 'a plugin that leaves the graph out must not send the canvas instead'); + console.log('snapshot_plan ok'); diff --git a/web/modules/canvas_menu.js b/web/modules/canvas_menu.js index 96ede18..405697d 100644 --- a/web/modules/canvas_menu.js +++ b/web/modules/canvas_menu.js @@ -4,7 +4,8 @@ * (canvas_menu_settings.js). A node's menu offers what Current node does for it — open it in * AMB, change its model by picture, insert a LoRA before / after, its saved values, the * model's pictures and details, save its values or the picked nodes as a combo — each only - * where it applies; the empty canvas's menu opens the quick search and AMB's pages. `browser()` gives the browser, + * where it applies; the empty canvas's menu opens the quick search and AMB's pages. Both keep a save of the + * workflow (ui_snapshot_bar.js). `browser()` gives the browser, * made on first use. */ @@ -22,6 +23,7 @@ import { RAIL_PAGES } from './ui_shell_rail.js'; import { openQuickSearch } from './ui_quick_search.js'; const item = (content, run) => ({ content, callback: () => void run() }); +const snapshotMark = () => [item(t('canvasMenuEntry_snapshotMark'), addMark)]; /** The widgets naming a model file that is set (not empty, not "None"). */ const setModels = node => modelComboWidgets(node).filter(widget => typeof widget.value === 'string' && widget.value && widget.value !== 'None'); @@ -75,7 +77,7 @@ const NODE_ITEMS = { showWorkbenchToast(t('currentNodeSaveFailed')); } })] : [], - snapshotMark: node => [item(t('canvasMenuEntry_snapshotMark'), () => addMark(node))], + snapshotMark, saveCombo: (node, browser) => { const nodes = menuNodes(node); if (!hasComboNodes(nodes)) return []; @@ -95,6 +97,7 @@ const CANVAS_ITEMS = { const pages = RAIL_PAGES.map(({ page, labelKey }) => item(t(labelKey), () => browser()?.show({ page }))); return placement === 'top' ? [{ content: t('canvasMenuOpenPage'), has_submenu: true, submenu: { options: pages } }] : pages; }, + snapshotMark, }; /** The items of `list` that are on and apply, built by `builders`; a broken one is left out. */ diff --git a/web/modules/canvas_menu_settings.js b/web/modules/canvas_menu_settings.js index daa3bcf..1e1329d 100644 --- a/web/modules/canvas_menu_settings.js +++ b/web/modules/canvas_menu_settings.js @@ -24,6 +24,7 @@ export const NODE_ENTRIES = Object.freeze([ export const CANVAS_ENTRIES = Object.freeze([ { id: 'quickSearch', on: true }, { id: 'openPage', on: true }, + { id: 'snapshotMark', on: true }, ]); export const PLACEMENTS = Object.freeze(['submenu', 'top']); diff --git a/web/modules/detached_graph.js b/web/modules/detached_graph.js index ebee13e..9e003b9 100644 --- a/web/modules/detached_graph.js +++ b/web/modules/detached_graph.js @@ -9,7 +9,7 @@ */ /** A random version-4 UUID; `crypto.randomUUID` exists only on https or this computer. */ -function freshId() { +export function freshId() { if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID(); const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)); bytes[6] = (bytes[6] & 0x0f) | 0x40; diff --git a/web/modules/locales.js b/web/modules/locales.js index d2ab7b0..dee8f0c 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -1879,31 +1879,33 @@ export const i18n = { aiPanelOther: "写下你想怎么做,回车发送", aiTool_ask_user: "问了你", aiTool_propose_repaint_area: "给你看要重画的范围", - canvasMenuEntry_snapshotMark: "选为标记", - canvasMenuEntryHelp_snapshotMark: "把现在的整个工作流存成一个标记,放进画布下方的快照队列。", - snapshotTitle: "快照队列", - snapshotMarked: "已存标记 {number}", - snapshotFirst: "第一个标记(在 {node} 上打的)", - snapshotSame: "和上一个标记一样", - snapshotBack: "回到这个标记", - snapshotCount: "这个标记跑几次", - snapshotRemove: "删掉这个标记", + canvasMenuEntry_snapshotMark: "存档当前工作流", + canvasMenuEntryHelp_snapshotMark: "把现在的整个工作流存成一个存档,放进画布下方的存档条。", + snapshotTitle: "存档", + snapshotMarked: "已存好存档 {number}", + snapshotFirst: "第一个存档", + snapshotSame: "和上一个存档一样", + snapshotBack: "回到这个存档", + snapshotCount: "这个存档跑几次", + snapshotRemove: "删掉这个存档", + snapshotAdd: "把现在的工作流存成新存档", + snapshotNewTab: "在新标签页打开这个存档,当前画布不动", snapshotSeeds: "种子", snapshotMode_random: "随机", snapshotMode_locked: "锁种", snapshotModeHint_random: "每次都换新种子,不管节点原来怎么设", - snapshotModeHint_locked: "每轮一个种子,所有标记共用,方便公平对比", - snapshotRun: "运行 {count} 张", - snapshotChecking: "正在检查每个标记…", + snapshotModeHint_locked: "每轮一个种子,所有存档共用,方便公平对比", + snapshotRun: "运行 {count} 次", + snapshotChecking: "正在检查每个存档…", snapshotQueueing: "正在排队 {done} / {total}", - snapshotQueued: "已排好 {count} 张,按标记顺序跑", - snapshotProblem: "标记 {number}:{line}", + snapshotQueued: "已排好 {count} 次,按存档顺序跑", + snapshotProblem: "存档 {number}:{line}", snapshotNoSeed: "没有能换的种子,跑几次都是同一张,把次数改成 1", - snapshotTooMany: "一次最多跑 {most} 张,减少一些次数", + snapshotTooMany: "一次最多跑 {most} 次,减少一些次数", snapshotRefused: "ComfyUI 没接受:{why}", - snapshotClose: "关掉快照队列", - snapshotCloseConfirm: "关掉后这 {count} 个标记就没了,确定吗?", - snapshotLogged: "{marks} 个标记,{count} 张", + snapshotClose: "关掉存档条", + snapshotCloseConfirm: "关掉后这 {count} 个存档就没了,确定吗?", + snapshotLogged: "{marks} 个存档,{count} 次", aiTool_pick_base: "请你挑底图", aiRouteTitle: "你挑的两个模型怎么用?", aiRouteCard: "用你存的卡:{name}", @@ -5477,31 +5479,33 @@ export const i18n = { aiPanelOther: "Say what you want, Enter sends", aiTool_ask_user: "Asked you", aiTool_propose_repaint_area: "Showed you the area to repaint", - canvasMenuEntry_snapshotMark: "Mark this state", - canvasMenuEntryHelp_snapshotMark: "Keeps the whole workflow as it is now as a mark in the snapshot queue under the canvas.", - snapshotTitle: "Snapshot queue", - snapshotMarked: "Mark {number} kept", - snapshotFirst: "The first mark (made on {node})", - snapshotSame: "The same as the mark before", - snapshotBack: "Back to this mark", - snapshotCount: "How many runs of this mark", - snapshotRemove: "Remove this mark", + canvasMenuEntry_snapshotMark: "Save this state", + canvasMenuEntryHelp_snapshotMark: "Keeps the whole workflow as it is now as a save in the bar under the canvas.", + snapshotTitle: "Saves", + snapshotMarked: "Save {number} kept", + snapshotFirst: "The first save", + snapshotSame: "The same as the save before", + snapshotBack: "Back to this save", + snapshotCount: "How many runs of this save", + snapshotRemove: "Remove this save", + snapshotAdd: "Keep the workflow as it is now as a new save", + snapshotNewTab: "Open this save on a new tab; the canvas stays as it is", snapshotSeeds: "Seeds", snapshotMode_random: "Random", snapshotMode_locked: "Locked", snapshotModeHint_random: "A new seed every run, whatever the nodes are set to", - snapshotModeHint_locked: "One seed a round, shared by all marks, for a fair comparison", + snapshotModeHint_locked: "One seed a round, shared by all saves, for a fair comparison", snapshotRun: "Run {count}", - snapshotChecking: "Checking each mark…", + snapshotChecking: "Checking each save…", snapshotQueueing: "Queueing {done} / {total}", - snapshotQueued: "{count} queued, in the order of the marks", - snapshotProblem: "Mark {number}: {line}", + snapshotQueued: "{count} runs queued, in the order of the saves", + snapshotProblem: "Save {number}: {line}", snapshotNoSeed: "no seed to change, so every run is the same picture: set its runs to 1", - snapshotTooMany: "At most {most} in one go: lower some runs", + snapshotTooMany: "At most {most} runs in one go: lower some", snapshotRefused: "ComfyUI did not take it: {why}", - snapshotClose: "Close the snapshot queue", - snapshotCloseConfirm: "Closing drops these {count} marks. Go on?", - snapshotLogged: "{marks} marks, {count} pictures", + snapshotClose: "Close the saves bar", + snapshotCloseConfirm: "Closing drops these {count} saves. Go on?", + snapshotLogged: "{marks} saves, {count} runs", aiTool_pick_base: "Asked you to pick a base", aiRouteTitle: "How should the two models you picked be used?", aiRouteCard: "Your saved card: {name}", diff --git a/web/modules/snapshot_plan.js b/web/modules/snapshot_plan.js index f37d1ea..8e15289 100644 --- a/web/modules/snapshot_plan.js +++ b/web/modules/snapshot_plan.js @@ -1,6 +1,6 @@ /** - * The snapshot queue's rules (ui_snapshot_bar.js draws it): how many pictures the marks make and which - * seed each run gets. A seed is any number widget with ComfyUI's control_after_generate beside it, a + * The saves bar's rules (ui_snapshot_bar.js draws it): how many runs the marks make, which seed each + * run gets and how a mark is compiled. A seed is any number widget with ComfyUI's control_after_generate beside it, a * third party's as well, in subgraphs too. "random": every run a new seed in each. "locked": one seed * per round, the same in every mark, so marks compare from the same noise; a fixed seed alone would * send the same prompt again, and ComfyUI answers that from its cache with no new picture. Pure. @@ -43,6 +43,16 @@ export function applySeeds(graph, seed = null, random = Math.random) { /** A seed to start a locked queue from, leaving room for its rounds. */ export const baseSeed = (random = Math.random) => Math.floor(random() * (SEED_TOP - MAX_COUNT)); +/** + * What ComfyUI would queue for `graph`, a graph off the canvas. A plugin may put its own + * app.graphToPrompt in front of ComfyUI's and leave the graph out (ComfyUI-Manager does), and ComfyUI + * then compiles the canvas: a result that is not `graph`'s is made again by ComfyUI's own method, past them. + */ +export async function compiled(app, graph) { + const made = await app.graphToPrompt(graph); + return made.workflow?.id === graph.id ? made : Object.getPrototypeOf(app).graphToPrompt.call(app, graph); +} + /** The seeds of `mark`'s runs in order: null for a new one each time. */ export const seedsOf = (mark, mode, base) => Array.from({ length: mark.count }, (_, round) => (mode === 'locked' ? base + round : null)); diff --git a/web/modules/ui_snapshot_bar.js b/web/modules/ui_snapshot_bar.js index 7440bdb..6ae2607 100644 --- a/web/modules/ui_snapshot_bar.js +++ b/web/modules/ui_snapshot_bar.js @@ -1,26 +1,28 @@ /** - * The snapshot queue: save points of a whole workflow, run in order with Anomalous in charge of the - * seeds (snapshot_plan.js). A node's right-click "Mark this state" keeps the canvas as it is now - * (app.graph.serialize) and opens a bar under the canvas, where the user goes on changing anything: - * a mark's number goes back to that state, each has its runs, and Run checks every mark with ComfyUI - * first, so a broken one stops the queue before it starts. Marks run on a detached copy - * (detached_graph.js): seeds are written to the copy's widgets and app.graphToPrompt compiles it, so the - * canvas, its tabs and the seeds on it stay as they are while the user works on. Marks last for this - * page; a state worth keeping is saved as a workflow card from the canvas. + * The saves bar: save points of a whole workflow (marks here, "saves" on screen), run in order with + * Anomalous in charge of the seeds (snapshot_plan.js). The right-click entry of a node or the empty + * canvas, or the bar's +, keeps the canvas as it is now (app.graph.serialize) and opens a bar under the + * canvas, where the user goes on changing anything: a mark's number goes back to that state, its arrow + * opens it on a new tab, each has its runs, and Run checks every mark with ComfyUI first, so a broken one + * stops the queue before it starts. Marks run on a detached copy (detached_graph.js): seeds are written to + * the copy's widgets and it is compiled on its own, so the canvas, its tabs and the seeds on it stay as + * they are while the user works on. Marks last for this page; a state worth keeping is saved as a + * workflow card from the canvas. */ import { app } from '../../../scripts/app.js'; import { api } from '../../../scripts/api.js'; import { changeLine, postCanvasActivity } from './activity_log.js'; import { diffSnapshots, snapshotGraph } from './activity_diff.js'; -import { readDetached } from './detached_graph.js'; +import { recordCanvasStep } from './canvas_history.js'; +import { freshId, readDetached } from './detached_graph.js'; import { translate as t } from './locales.js'; -import { MAX_RUNS, applySeeds, baseSeed, runCount, runTotal, seedWidgets, seedsOf } from './snapshot_plan.js'; +import { MAX_RUNS, applySeeds, baseSeed, compiled, runCount, runTotal, seedWidgets, seedsOf } from './snapshot_plan.js'; import { anomalousConfirm } from './ui_dialog.js'; import { showWorkbenchToast } from './ui_prompt_toast.js'; const TOOLTIP_LINES = 8; -const marks = []; // {graph, snapshot, changes, node, count} +const marks = []; // {graph, snapshot, changes, count} let mode = 'random'; let current = null; // the mark the canvas was last sent back to let bar = null; @@ -45,28 +47,39 @@ function button(className, label, run, title = '') { /** What `mark` changes from the one before it, for its tooltip. */ function markWords(mark, at) { - if (at === 0) return t('snapshotFirst', { node: mark.node }); + if (at === 0) return t('snapshotFirst'); const lines = mark.changes.slice(0, TOOLTIP_LINES).map(changeLine); return lines.length ? lines.join('\n') : t('snapshotSame'); } -/** Keeps the canvas as it is now; `node` names where the user marked it. */ -export function addMark(node) { +/** Keeps the canvas as it is now. */ +export function addMark() { const graph = canvasGraph(); const snapshot = snapshotGraph(graph); const before = marks[marks.length - 1]; - marks.push({ graph: JSON.parse(JSON.stringify(graph.serialize())), snapshot, changes: before ? diffSnapshots(before.snapshot, snapshot) : [], - node: node?.title || node?.type || '', count: 1 }); + marks.push({ graph: JSON.parse(JSON.stringify(graph.serialize())), snapshot, changes: before ? diffSnapshots(before.snapshot, snapshot) : [], count: 1 }); current = marks[marks.length - 1]; draw(); showWorkbenchToast(t('snapshotMarked', { number: marks.length })); } -/** The canvas back at `mark`, on the same tab: one more change of that workflow, as Ctrl+Z makes. */ +/** + * The canvas back at `mark`, 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 `mark` for the tab's starting state and keep no step. + */ async function goBack(mark) { - const workflow = app.extensionManager?.workflow?.activeWorkflow; - await app.loadGraphData(JSON.parse(JSON.stringify(mark.graph)), false, false, workflow, { showMissingNodesDialog: false, showMissingModelsDialog: false }); - workflow?.changeTracker?.checkState?.(); + const tracker = app.extensionManager.workflow.activeWorkflow.changeTracker; + recordCanvasStep(app); + await tracker.updateState([JSON.parse(JSON.stringify(mark.graph))], tracker.undoQueue); + tracker.redoQueue.length = 0; + current = mark; + draw(); +} + +/** `mark` on a tab of its own, as ComfyUI opens a workflow; the tab it leaves stays as it is. */ +async function openOnNewTab(mark) { + await app.loadGraphData({ ...JSON.parse(JSON.stringify(mark.graph)), id: freshId() }); current = mark; draw(); } @@ -84,7 +97,7 @@ async function problems() { for (const [at, mark] of marks.entries()) { await readDetached(mark.graph, async (graph) => { if (mark.count > 1 && !seedWidgets(graph).length) found.push(t('snapshotProblem', { number: at + 1, line: t('snapshotNoSeed') })); - const { output } = await app.graphToPrompt(graph); + const { output } = await compiled(app, graph); const response = await fetch('/anomalous/snapshot_check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: output }) }); const data = await response.json(); for (const line of data.problems || []) found.push(t('snapshotProblem', { number: at + 1, line })); @@ -101,7 +114,7 @@ async function submit() { await readDetached(mark.graph, async (graph) => { for (const seed of seedsOf(mark, mode, base)) { applySeeds(graph, seed); - await api.queuePrompt(0, await app.graphToPrompt(graph)); + await api.queuePrompt(0, await compiled(app, graph)); status(t('snapshotQueueing', { done: ++queued, total: runTotal(marks) })); } }); @@ -158,7 +171,7 @@ function chip(mark, at) { if (current === mark) current = null; draw(); }, t('snapshotRemove')); - box.append(number, count, remove); + box.append(number, count, button('anomalous-snapshot-tab', '↗', () => void openOnNewTab(mark), t('snapshotNewTab')), remove); return box; } @@ -179,7 +192,7 @@ function draw(clear = true) { } const row = el('div', 'anomalous-snapshot-row'); const list = el('div', 'anomalous-snapshot-marks'); - list.append(...marks.map(chip)); + list.append(...marks.map(chip), button('anomalous-snapshot-add', '+', () => addMark(), t('snapshotAdd'))); const seeds = el('div', 'anomalous-snapshot-modes'); seeds.setAttribute('role', 'radiogroup'); seeds.setAttribute('aria-label', t('snapshotSeeds')); diff --git a/web/styles/50-snapshot-bar.css b/web/styles/50-snapshot-bar.css index 0477f39..6070270 100644 --- a/web/styles/50-snapshot-bar.css +++ b/web/styles/50-snapshot-bar.css @@ -1,4 +1,4 @@ -/* The snapshot queue's bar under the canvas (ui_snapshot_bar.js). */ +/* The saves bar under the canvas (ui_snapshot_bar.js). */ .anomalous-snapshot-bar { position: fixed; left: 50%; @@ -31,6 +31,8 @@ .anomalous-snapshot-mark.is-current { border-color: var(--amb-accent); } .anomalous-snapshot-number, +.anomalous-snapshot-tab, +.anomalous-snapshot-add, .anomalous-snapshot-remove, .anomalous-snapshot-close, .anomalous-snapshot-mode, @@ -45,10 +47,14 @@ font: inherit; } -.anomalous-snapshot-number { font-weight: 600; } +.anomalous-snapshot-number, +.anomalous-snapshot-add { font-weight: 600; } +.anomalous-snapshot-tab, .anomalous-snapshot-remove, .anomalous-snapshot-close { padding: 0 5px; color: var(--amb-text-muted); } .anomalous-snapshot-number:hover, +.anomalous-snapshot-tab:hover, +.anomalous-snapshot-add:hover, .anomalous-snapshot-remove:hover, .anomalous-snapshot-close:hover, .anomalous-snapshot-mode:hover { background: var(--amb-bg-card-hover); }