Compare commits

...
1 Commits
7 changed files with 266 additions and 211 deletions
+23
View File
@@ -4,6 +4,29 @@
---
## v0.4.3
### バグ修正: 共有モーダルのお気に入り☆/リネームがLabモーダルへ残留 + Lab Model列の+/-をCheckpoint/LoRA/VAEで完全独立化
v0.4.2リリース直後、ユーザーからLabタブに関する2件のフィードバックを受けて修正した一連のセッション。
**1. 共有モーダルのお気に入り☆・リネームがLabモーダルに残留するバグ**: ユーザーから「Labタブモーダルについて確認したい。各モーダルでお気に入り☆、名前変更が可能なようですがこれは機能していますか?」との指摘を受けて調査。Workflow Studioの全「詳細モーダル」(Workflow/Models/Nodesタブの詳細表示、Labのキーフレーム編集)は`#wfm-modal-overlay`というDOMを1つだけ共有しており、共通の`openModal()`(`app.js`)はタイトルの**テキストだけ**を書き換え、呼び出し元が動的に注入したヘッダー要素(お気に入り☆ボタン、タイトルクリックでリネームするイベントリスナー)は一切クリアしていなかった。そのため「Workflowタブでワークフローの詳細モーダルを開いた直後にLabのキーフレームモーダルを開く」と、直前のワークフロー用の☆ボタンとリネームリスナーがそのまま残留表示され、Lab側で☆をクリックすると**Labのキーフレームとは無関係に、直前に見ていたワークフローのお気に入り状態が実際に切り替わってしまう**ことをPlaywright MCPで実機再現・立証した。`openModal()`にクリーンアップ処理(`.wfm-fav-btn`の除去、タイトル要素を`cloneNode`で複製・差し替えて前の呼び出し元のクリックリスナー/属性を一掃、リネーム用隠し入力欄のリセット)を追加して修正。副次効果として、Workflow詳細モーダルを連続で複数回開いた際にタイトルのクリックリスナーが際限なく累積していく別の潜在バグ(`workflow-tab.js`側にはリネーム用clickリスナーの重複防止ガードが無かった)も同時に解消した。
**2. Lab Model列の+/-が独立していなかった問題**: v0.4.2で実装したModel列(Checkpoint/LoRA/VAEを1つの`{checkpoint, lora, vae}`マージ行として1つのキーフレーム配列にまとめる設計)には設計上の欠陥があった。ユーザーから「checkpoint/lora/vaeいずれかで+で追加すると他のモデルも追加されてしまいます。逆に-で他の2つも削除される」との指摘を受け、根本的な設計見直しを実施。マージ行方式ではなく、**Checkpoint・LoRA・VAEを(Prompt・KSamplerと全く同じ)完全に独立したキーフレーム配列に戻し**、C/L/Vトグルは「今どの配列を表示・操作するか」を切り替えるルーティングとしてのみ機能する設計に変更した。この結果、v0.4.2で新設した`_resolveModelKeyframe()`(3フィールドを1行にバンドルしたまま個別に前方向解決するカスタムロジック)が丸ごと不要になり、既存の汎用`_resolveKeyframe()`(Checkpoint/VAE列が元々使っていたもの)にLoRAを1つ追加するだけで済むようになった——継承機能・ハイライト・inheritedヒント表示など、v0.4.2で追加した機能はすべてこのシンプルな独立配列方式のままで自然に成立する。
**設計上の教訓**: 複数の独立した設定を1つのUI列にまとめる際、「見た目を1列にする」ことと「データを1行にマージする」ことは別の問題であり、後者を選ぶと今回のような「+/-が意図せず道連れになる」バグを生みやすい。データモデルは独立させたまま、UI層(表示するフィールドの選択・共有される+/-ボタンのルーティング)だけを1列に見せかける設計の方が、機能追加・バグ修正のコストが低く、直感的な挙動にもなりやすい。
**ヘルプ・README更新**: `project_v0336_conventions`の慣習に従い、Lab関連ヘルプ(`helpLab14`・`helpLab15`、英/日/中3言語+`templates/index.html`の英語フォールバック)を独立配列方式の挙動に合わせて更新(特に`helpLab15`の「Revert to #1's settingは3項目まとめて戻る」という記述はもう正しくないため修正)。README.mdのLabセクションも同内容に更新した。
**検証**: 全JSファイルを`node --check`で構文確認。開発元gitリポジトリと実行時`custom_nodes`フォルダは別実体のため([[project_dev_deploy_sync]]参照)、変更ファイルのみ両フォルダへ同期しハッシュ一致を確認。Playwright MCP経由で実機ブラウザ操作: (1) Workflow詳細モーダル→Labモーダルの順で開き、☆・リネームツールチップ・cursorスタイルが一切残留しないことを確認、(2) 「C」表示での+/-がCheckpointのみに作用しLoRA/VAEは不変であること、「L」表示での+/-がLoRAのみに作用しCheckpoint/VAEは不変であることをPlan JSONタブのデータ構造で確認、(3) v0.4.2形式(マージ行)のPlan JSONを読み込ませ、新しい独立配列形式へ正しくマイグレーションされることを確認。実際の画像生成を伴うRun実行は割愛。
**How to apply**:
1. DOM/モーダルを複数の呼び出し元で共有する設計では、各呼び出し元が「自分固有のUI要素」を動的に注入する場合、その呼び出し元自身が毎回クリーンアップするのではなく、**共有側の入り口関数(今回は`openModal()`)で毎回リセットする**設計にしないと、呼び出し順序に依存した残留バグ・誤操作バグを生む。cloneNode+replaceWithは、addEventListenerの参照を保持していなくてもリスナーを確実に一掃できる簡便な手段。
2. 複数の独立した設定を1つのUI要素(列・パネル等)にまとめる要望を実装する際、「データも1つにまとめる」のではなく「データは独立させたままUI層だけ共有する」設計を最初から検討する。今回はユーザーの2度のフィードバック(継承挙動の誤り→+/-の誤り)を経てようやくこの設計にたどり着いたが、根本原因は同じ「フィールドを1行にバンドルした」という初期設計判断にあった。
---
## v0.4.2
### 機能追加: Lab「Model」列統合(Checkpoint / LoRA Single / VAE)+ フィールド単位継承 + C/L/V表示切替
+4 -4
View File
@@ -22,7 +22,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
- Built-in AI tools (translation and more)
![Workflow Studio](https://img.shields.io/badge/ComfyUI-Custom_Node-blue)
![Version](https://img.shields.io/badge/version-0.4.2-green)
![Version](https://img.shields.io/badge/version-0.4.3-green)
## Screenshots
@@ -146,13 +146,13 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
</details>
<details>
<summary><h3>Lab subtab (v0.3.87 – v0.4.2)</h3></summary>
<summary><h3>Lab subtab (v0.3.87 – v0.4.3)</h3></summary>
An experimental batch generator: runs the workflow currently loaded in GenerateUI N times, letting Model (Checkpoint/LoRA Single/VAE) / Prompt / KSampler each change independently starting at a chosen iteration — unlike the Batch tab's single-axis queue, every column keeps its own list of change points. Never mutates the loaded workflow; every iteration runs on a fresh clone. Works with both I2I and T2I workflows (v0.3.90).
- **Setting / Results / Plan JSON sub-panels** — Setting: image drop zone + 3 keyframe columns + run controls; Results: source image and up to 9 generated thumbnails (click to enlarge); Plan JSON: the current plan as raw editable JSON (v0.3.88)
- **Per-column keyframes** — Model, Prompt, KSampler each hold an independent list of `{iteration, value}` keyframes; +/− per column adds/removes the last keyframe (iteration #1 is fixed and can't be removed); any keyframe other than #1 can also be deleted individually from inside its own edit modal (v0.3.93), not just the last one; click a cell to edit its value, the iteration it starts applying from, and a "revert to #1's setting" checkbox; the effective value at iteration N is the latest keyframe with iteration ≤ N, carried forward until the next one changes it — except the Model column, which resolves each of its three fields independently (see below); lowering the Batch count below an existing keyframe's iteration doesn't delete or renumber it — a warning toast appears and it just stays dormant until Batch count is raised again (v0.3.93)
- **Model column — Checkpoint / LoRA Single / VAE** (v0.4.2) — the old separate Checkpoint and VAE columns merged into one Model column; a `[C][L][V]` toggle in its header picks which single field its cells summarize and its edit modal shows — switching the toggle never touches the other two fields, Save only updates whichever one is currently shown — and each button lights up the same highlight color as GenerateUI's Model tab (configurable in Settings) when the loaded workflow actually contains that node type; the three fields carry forward independently across keyframes, so a keyframe that only sets a LoRA doesn't reset Checkpoint/VAE back to the workflow default — it keeps whatever an earlier keyframe last set until a later one explicitly changes it (a field left blank in the edit modal shows "(inherits: ...)" instead of "(workflow default)" when there's something to inherit); "revert to #1's setting" still resets all three fields at once. LoRA support is Single only (no Stack, matching the experimental scope of this tab); if a LoRA is set but the loaded workflow has no LoRA node at all, Run warns once and skips it rather than auto-inserting one — unlike VAE (which does auto-inject a VAELoader when missing), wiring in a LoraLoader would require rewiring the Checkpoint's model/clip outputs through it
- **Per-column keyframes** — Checkpoint, LoRA, VAE, Prompt, and KSampler each hold their own fully independent list of `{iteration, value}` keyframes; +/− per column adds/removes the last keyframe (iteration #1 is fixed and can't be removed); any keyframe other than #1 can also be deleted individually from inside its own edit modal (v0.3.93), not just the last one; click a cell to edit its value, the iteration it starts applying from, and a "revert to #1's setting" checkbox; the effective value at iteration N is the latest keyframe with iteration ≤ N, carried forward until the next one changes it; lowering the Batch count below an existing keyframe's iteration doesn't delete or renumber it — a warning toast appears and it just stays dormant until Batch count is raised again (v0.3.93)
- **Model column — Checkpoint / LoRA Single / VAE** (v0.4.2, redesigned v0.4.3) — Checkpoint and VAE (and now LoRA) share one visual "Model" column; a `[C][L][V]` toggle in its header picks which of the three independent keyframe lists the shared cell list, its own +/-, and its edit modal currently operate on — each behaves exactly like its own column (same as Prompt/KSampler), just sharing the Model column's screen space, so adding, removing, editing, reverting, or bypassing a keyframe while viewing one never touches the other two. Each toggle button lights up the same highlight color as GenerateUI's Model tab (configurable in Settings) when the loaded workflow actually contains that node type. A field left blank in a later keyframe's edit modal shows "(inherits: ...)" instead of "(workflow default)" when an earlier keyframe in that same list already set something. LoRA support is Single only (no Stack, matching the experimental scope of this tab); if a LoRA is set but the loaded workflow has no LoRA node at all, Run warns once and skips it rather than auto-inserting one — unlike VAE (which does auto-inject a VAELoader when missing), wiring in a LoraLoader would require rewiring the Checkpoint's model/clip outputs through it
- **Wildcards** (v0.3.93) — Prompt keyframes' Positive/Negative text supports the same A1111-style `__name__` syntax as the main GenerateUI prompt fields; each keyframe is expanded fresh right before that iteration runs, so reusing the same keyframe across iterations can still draw a different random line each time
- **Prompt cell extras** (v0.3.88, refined in v0.3.89, v0.3.93, and v0.3.97) — **Get from GenerateUI** fills Positive/Negative from the workflow currently loaded in GenerateUI; **Get from Image** extracts a prompt from the image currently loaded in Lab's own Image drop zone (same detection as the Metadata tab; no separate file picker); **Get from Previous** (v0.3.97) copies both Positive and Negative wholesale from the keyframe immediately before this one in the same column (e.g. editing #3 copies #2's prompt; not shown on keyframe #1, which has no earlier row); **Clear** (v0.3.97) empties both fields; **Style** row — an **Apply** button merges whichever style is currently selected in GenerateUI's own top-bar Style dropdown into the Positive/Negative text below, comma-separated after any existing text (never clears it) and works regardless of that top-bar's own Style enable checkbox; a checkbox next to it tracks on/off and shows the applied style's name beside itself (not on the button) — unchecking only clears that name label, the already-merged text stays and is yours to edit/delete by hand
- **Keyframe #1 live-reflect** (v0.3.97) — in every column, keyframe #1 shows the currently loaded workflow's own live setting instead of a blank placeholder — for the Model column this covers Checkpoint and VAE only, LoRA is never live-reflected since there's no reliable way to infer "the" active LoRA back out of a workflow node — both in its grid cell (dashed border) and pre-filled into its edit modal, for as long as it hasn't been explicitly saved with an override; saving it — even unchanged — locks that value in as a real override and the dashed/live styling goes away; switching to the Lab subtab always refreshes this against whatever is currently loaded in GenerateUI
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-workflow-studio"
description = "A comprehensive workflow, asset management, and generation UI plugin for ComfyUI. Manage workflows, models, images, and prompts; batch generation and Image Feeder in the GenerateUI tab; library side panel with canvas drag & drop and AI tools."
version = "0.4.2"
version = "0.4.3"
license = "MIT"
requires-python = ">= 3.10"
dependencies = ["jinja2"]
+22 -1
View File
@@ -42,11 +42,32 @@ export function showToast(message, type = "info", duration = 3000) {
export function openModal(title, contentHtml) {
const overlay = document.getElementById("wfm-modal-overlay");
const titleEl = document.getElementById("wfm-modal-title");
let titleEl = document.getElementById("wfm-modal-title");
const bodyEl = document.getElementById("wfm-modal-body");
const titleInput = document.getElementById("wfm-modal-title-input");
if (!overlay || !titleEl || !bodyEl) return;
// This modal shell is shared by every caller (Workflow/Models/Nodes detail
// modals, Lab's keyframe editor, etc.). Some callers (e.g. workflow-tab.js's
// openDetailModal) inject extra header chrome after calling openModal() — a
// favorite star button, and a click-to-rename listener + tooltip/cursor on the
// title — but nothing ever tore that down again. The next caller to reuse this
// same DOM inherited it as stale leftovers: a Lab cell modal could show a
// favorite star wired to the PREVIOUS caller's workflow object, and clicking it
// silently toggled that unrelated workflow's favorite state. Clean slate every
// time, before any caller-specific chrome is added back on top.
titleEl.parentElement?.querySelectorAll(".wfm-fav-btn").forEach((el) => el.remove());
const freshTitleEl = titleEl.cloneNode(false); // drops any addEventListener from a prior caller
freshTitleEl.removeAttribute("title");
freshTitleEl.removeAttribute("style"); // also clears any leftover display:none from an interrupted rename
titleEl.replaceWith(freshTitleEl);
titleEl = freshTitleEl;
if (titleInput) {
titleInput.style.display = "none";
titleInput.value = "";
}
titleEl.textContent = title;
bodyEl.innerHTML = contentHtml;
overlay.style.display = "flex";
+6 -6
View File
@@ -821,8 +821,8 @@ const LANGUAGES = {
helpLab11: "Prompt cell editor also has Clear (empties both Positive and Negative) and Get from Previous (copies both Positive and Negative from the keyframe immediately before this one in the same column — e.g. editing #3 copies #2's prompt wholesale; not shown on keyframe #1, which has no earlier row to copy from)",
helpLab12: "Keyframe #1 in every column shows the currently loaded workflow's own live setting instead of a blank placeholder — for the Model column this covers Checkpoint and VAE only (LoRA is never live-reflected, since there's no reliable way to infer \"the\" active LoRA back out of a workflow node) — both in its grid cell (dashed border) and pre-filled into its edit modal, for as long as it hasn't been explicitly saved with an override; saving it (even without changing anything) locks that value in as a real override and the dashed/live styling goes away; switching to the Lab subtab always refreshes this against whatever is currently loaded in GenerateUI",
helpLab13: "Bypass checkbox (every keyframe, including #1): skips that row entirely when picking which setting applies at a given iteration — as if the row didn't exist — so whatever was active before it (an earlier keyframe, or ultimately nothing) just keeps running; Lab-internal only, it never touches the actual ComfyUI node's mode; disables that row's value fields and its Revert checkbox while checked (the two are mutually exclusive); the cell shows an amber \"⏭ Bypassed\" label, which takes priority over both the Revert and live-value display",
helpLab14: "Model column header's C / L / V buttons pick which single field (Checkpoint / LoRA / VAE — LoRA is Single-only, no Stack support here) its cells summarize and its edit modal shows; the two you're not currently viewing are left exactly as already saved when you Save. Each button turns the same highlight color as GenerateUI's Model tab (configurable in Settings) when the loaded workflow actually contains that node type. If a LoRA is set but the workflow has no LoRA node at all, Lab warns once per Run and skips applying it — unlike VAE, it never auto-inserts a LoRA node, since that would require rewiring the Checkpoint's model/clip outputs through it",
helpLab15: "Model column fields carry forward independently: a keyframe that only sets a LoRA doesn't reset Checkpoint/VAE to the workflow default — each of the three keeps whatever an earlier keyframe last set until a later keyframe explicitly changes it. A field left blank in the edit modal shows \"(inherits: ...)\" as its placeholder instead of \"(workflow default)\" when there's something to inherit, so it's clear what actually applies. \"Revert to #1's setting\" resets all three fields at once back to keyframe #1's row, not just the one you're currently viewing",
helpLab14: "Model column header's C / L / V buttons pick which independent keyframe list — Checkpoint, LoRA (Single-only, no Stack support here), or VAE — the shared cell list, its +/-, and its edit modal all currently operate on. Each of the three is its own timeline with its own keyframes, exactly like Prompt/KSampler; they only ever look merged because they share one column's worth of screen space. Adding, removing, or editing a keyframe while viewing one never touches the other two. Each button turns the same highlight color as GenerateUI's Model tab (configurable in Settings) when the loaded workflow actually contains that node type. If a LoRA is set but the workflow has no LoRA node at all, Lab warns once per Run and skips applying it — unlike VAE, it never auto-inserts a LoRA node, since that would require rewiring the Checkpoint's model/clip outputs through it",
helpLab15: "Checkpoint / LoRA / VAE each carry forward independently, the same effective-value rule as every other column (see above): a keyframe in one never resets the other two to the workflow default. A field left blank in the edit modal shows \"(inherits: ...)\" as its placeholder instead of \"(workflow default)\" when an earlier keyframe in that same list already set something, so it's clear what actually applies. \"Revert to #1's setting\" and Bypass — like +/- — only ever affect whichever ONE of the three is currently selected by the C/L/V toggle, never the other two",
helpLabCardPlanTitle: "Setting subtab — Run & Plan files",
helpLabPlan1: "Right pane — Batch: number of iterations to run; Note: free-text memo saved together with the plan",
helpLabPlan2: "Run / Pause / Cancel: Run starts the batch loop; Pause/Resume freezes it between iterations; Cancel interrupts the current generation and stops the loop",
@@ -2080,8 +2080,8 @@ const LANGUAGES = {
helpLab11: "プロンプトセルの編集モーダルにはClear(Positive/Negative両方を空にする)とGet from Previous(同じカラムの直前のキーフレームからPositive/Negative両方をコピー — 例:#3を編集中に押すと#2のプロンプトをそのままコピー)も用意されています。1回目(#1)には直前の行が無いため表示されません",
helpLab12: "各カラムの1回目(キーフレーム#1)は、明示的に上書き保存されていない間、現在GenerateUIで読み込まれているワークフロー本来の設定を、空欄プレースホルダの代わりにグリッドセル(破線枠)と編集モーダルの両方に表示します(Model列はCheckpointとVAEのみが対象 — LoRAはワークフローのノードから「どれが有効か」を安全に逆算する手段が無いため、ライブ表示の対象外です)。何も変更せずに保存しただけでも実際の上書き値として確定し、破線・ライブ表示は解除されます。Labサブタブに切り替えるたびに、その時点でGenerateUIに読み込まれている内容へ再同期されます",
helpLab13: "Bypassチェックボックス(1回目を含む全キーフレームに搭載):そのキーフレームの回数目判定において、行自体が存在しないかのように完全にスキップします — その結果、直前まで有効だった設定(それ以前のキーフレーム、なければ何も適用されない状態)がそのまま使われ続けます。Labタブ内だけの概念で、実際のComfyUIノードのmodeには一切触れません。チェック中はその行の値フィールドと「1回目に戻す」チェックボックスが無効化されます(両者は同時に使えない排他的な関係)。セルには琥珀色の「⏭ Bypassed」ラベルが表示され、Revert表示・ライブ表示のどちらよりも優先されます",
helpLab14: "Model列ヘッダーのC/L/Vボタンは、セル表示と編集モーダルがCheckpoint/LoRA/VAE(LoRAはSingleのみ、Stack非対応)のどれを表示・編集対象にするかを切り替えます。今見ていない残り2項目は、保存してもそれまでの保存値のまま変更されません。各ボタンは、読み込み中のワークフローに該当するノードが実際に存在する場合、GenerateUIのModelタブと同じハイライト色(Settingsで変更可)になります。LoRAを設定していてもワークフロー内にLoRAノードが1つも無い場合は、Run実行中に1回だけ警告し、その設定は適用されません — VAEと異なりLoRAノードの自動追加は行いません(CheckpointのModel/CLIP出力の配線を組み替える必要があるため)",
helpLab15: "Model列の3項目(Checkpoint/LoRA/VAE)はそれぞれ独立して引き継がれます:LoRAだけを設定したキーフレームがあっても、Checkpoint/VAEはワークフロー本来の設定に戻るのではなく、それ以前のキーフレームで最後に設定された値がそのまま維持されます(さらに後のキーフレームで明示的に変更されるまで)。編集モーダルで未設定のままの項目は、引き継ぐ値がある場合プレースホルダが「(ワークフローの設定のまま)」ではなく「(引き継ぎ: ...)」と表示され、実際に適用される値が分かるようになっています。「1回目の設定に戻す」は、今見ている項目だけでなく3項目すべてを一括でキーフレーム#1の内容に戻します",
helpLab14: "Model列ヘッダーのC/L/Vボタンは、Checkpoint・LoRA(Singleのみ、Stack非対応)・VAEという3つの独立したキーフレームリストのうち、今このセル表示・+/-・編集モーダルがどれを対象にしているかを切り替えます。3つはそれぞれPrompt・KSamplerと同じく完全に独立したタイムラインを持っており、1つの列にまとめて見えているのは表示スペースを共有しているだけです。1つを表示中にキーフレームを追加・削除・編集しても、残り2つには一切影響しません。各ボタンは、読み込み中のワークフローに該当するノードが実際に存在する場合、GenerateUIのModelタブと同じハイライト色(Settingsで変更可)になります。LoRAを設定していてもワークフロー内にLoRAノードが1つも無い場合は、Run実行中に1回だけ警告し、その設定は適用されません — VAEと異なりLoRAノードの自動追加は行いません(CheckpointのModel/CLIP出力の配線を組み替える必要があるため)",
helpLab15: "Checkpoint/LoRA/VAEは、他の列と全く同じ実効値ルール(上記参照)でそれぞれ独立して引き継がれます:いずれか1つのキーフレームが、残り2つをワークフロー本来の設定に戻すことはありません。編集モーダルで未設定のままの項目は、同じリスト内の以前のキーフレームで何か設定済みの場合、プレースホルダが「(ワークフローの設定のまま)」ではなく「(引き継ぎ: ...)」と表示され、実際に適用される値が分かるようになっています。「1回目の設定に戻す」やBypassも+/-と同様、現在C/L/Vトグルで選択中の1項目だけに作用し、残り2つには影響しません",
helpLabCardPlanTitle: "Settingサブタブ — 実行とプランファイル",
helpLabPlan1: "右ペイン — Batch:実行する回数。Note:プランと一緒に保存される自由記述のメモ",
helpLabPlan2: "Run/Pause/Cancel:Runでバッチループを開始、Pause/Resumeで回と回の間を一時停止・再開、Cancelで現在の生成を中断してループを止めます",
@@ -3337,8 +3337,8 @@ const LANGUAGES = {
helpLab11: "Prompt单元格编辑弹窗中还提供Clear(将Positive和Negative都清空)和Get from Previous(从同一列紧邻的上一个关键帧复制Positive和Negative——例如编辑#3时会整段复制#2的提示词);关键帧#1没有更早的行可复制,因此不显示该按钮",
helpLab12: "每一列的关键帧#1,只要还没有被显式保存覆盖,就会用当前GenerateUI中已加载工作流的实际设置代替空白占位显示(Model列仅覆盖Checkpoint和VAE两项——LoRA不会被实时反映,因为无法从工作流节点中安全地反推出\"当前生效的\"LoRA),同时体现在其网格单元格(虚线边框)和编辑弹窗的预填内容中;哪怕不做任何修改直接保存,也会将该值锁定为真正的覆盖值,虚线/实时显示随之消失;每次切换到Lab子标签页,都会重新根据当前GenerateUI中加载的内容刷新",
helpLab13: "Bypass复选框(包括#1在内的每个关键帧都有):在判定某次迭代应使用哪一行设置时,会把该行完全跳过,如同它不存在——于是它之前生效的设置(更早的关键帧,或最终什么都不应用)会继续保持;这只是Lab内部的概念,绝不会改动实际ComfyUI节点的mode;勾选后会禁用该行的取值字段以及其Revert复选框(二者互斥,不能同时生效);单元格会显示琥珀色的「⏭ Bypassed」标签,其优先级高于Revert显示和实时显示",
helpLab14: "Model列表头的C/L/V按钮用于切换单元格显示与编辑弹窗当前针对Checkpoint/LoRA/VAE(LoRA仅支持Single,不支持Stack)中的哪一项。未在查看的另外两项即使保存也会保持原值不变。当前加载的工作流中若确实存在对应类型的节点,相应按钮会变为与GenerateUI的Model标签页相同的高亮颜色(可在Settings中调整)。若设置了LoRA但工作流中完全没有LoRA节点,Run执行期间只会警告一次并跳过该项设置——与VAE不同,Lab不会自动插入LoRA节点,因为那需要重新连接Checkpoint的Model/CLIP输出线路",
helpLab15: "Model列的三个字段(Checkpoint/LoRA/VAE)各自独立向后延续:即使某个关键帧只设置了LoRA,Checkpoint/VAE也不会恢复为工作流原始设置,而是保持更早的关键帧最后设置的值(直到之后的关键帧明确修改它为止)。在编辑弹窗中,若某字段留空且存在可继承的值,其占位符会显示「(继承: ...)」而不是「(保持工作流原设置)」,方便确认实际会应用的值。「恢复第1次的设置」会一次性将三个字段全部恢复为关键帧#1的内容,而不仅仅是当前正在查看的这一项",
helpLab14: "Model列表头的C/L/V按钮用于切换Checkpoint、LoRA(仅支持Single,不支持Stack)、VAE这三个相互独立的关键帧列表中,当前单元格显示、+/-以及编辑弹窗针对的是哪一个。三者各自拥有完全独立的时间线,与Prompt、KSampler完全相同,只是共用同一列的显示空间,看起来像是合并在一起。查看其中一项时添加、删除或编辑关键帧,完全不会影响另外两项。当前加载的工作流中若确实存在对应类型的节点,相应按钮会变为与GenerateUI的Model标签页相同的高亮颜色(可在Settings中调整)。若设置了LoRA但工作流中完全没有LoRA节点,Run执行期间只会警告一次并跳过该项设置——与VAE不同,Lab不会自动插入LoRA节点,因为那需要重新连接Checkpoint的Model/CLIP输出线路",
helpLab15: "Checkpoint/LoRA/VAE与其他列采用完全相同的实际生效值规则(见上文),各自独立向后延续:其中一项的关键帧不会使另外两项恢复为工作流原始设置。在编辑弹窗中,若某字段留空,且同一列表中更早的关键帧已设置过值,其占位符会显示「(继承: ...)」而不是「(保持工作流原设置)」,方便确认实际会应用的值。「恢复第1次的设置」和Bypass与+/-一样,只会作用于当前C/L/V切换选中的那一项,不会影响另外两项",
helpLabCardPlanTitle: "Setting子标签 — 运行与方案文件",
helpLabPlan1: "右侧面板 — Batch:要运行的迭代次数;Note:与方案一起保存的自由备注",
helpLabPlan2: "Run/Pause/Cancel:Run开始批量循环;Pause/Resume在各次迭代之间暂停/继续;Cancel会中断当前生成并停止循环",
+208 -197
View File
@@ -17,70 +17,87 @@ import { extractAllMetadata, readAllPNGTextChunks } from "./metadata-tab.js";
import { syncJsonHighlight, syncScroll } from "./json-highlight.js";
import { _expandWildcardsInWorkflow } from "./generate-tab.js";
const COLUMN_KEYS = ["model", "prompt", "ksampler"];
// Checkpoint/LoRA/VAE are three fully independent keyframe timelines — same as
// Prompt/KSampler — each with its own +/-, its own atIteration sequence, its own
// forward-fill resolution (_resolveKeyframe). The Model column header's C/L/V toggle
// only picks which ONE of the three renders into the (single, shared) cell list and
// which one +/- and the cell-click modal operate on; it never bundles them together.
const COLUMN_KEYS = ["checkpoint", "lora", "vae", "prompt", "ksampler"];
const MAX_RESULTS = 9;
const RESULTS_GRID_COLS = 3;
const LAB_PLAN_PREFIX = "ws_labplan_";
const LAB_PLAN_PNG_KEY = "wfm_lab_plan";
function _defaultValueFor(col) {
if (col === "model") return { checkpoint: "", lora: { name: "", strengthModel: 1.0, strengthClip: 1.0 }, vae: "" };
if (col === "lora") return { name: "", strengthModel: 1.0, strengthClip: 1.0 };
if (col === "prompt") return { positive: "", negative: "", styleApplied: false, styleName: "" };
if (col === "ksampler") return { steps: null, cfg: null, sampler_name: "", scheduler: "", denoise: null, seed: null };
return "";
return ""; // checkpoint, vae
}
// Fills in defaults for a possibly-partial "model" column value — both for legacy
// plan data migrated by _migrateLabColumns (which never sets a lora) and defensively
// for any hand-edited Plan JSON.
function _normalizeModelValue(v) {
// Fills in defaults for a possibly-partial LoRA value — both for legacy plan data
// migrated by _migrateLabColumns and defensively for any hand-edited Plan JSON.
function _normalizeLoraValue(v) {
return {
checkpoint: v?.checkpoint || "",
lora: {
name: v?.lora?.name || "",
strengthModel: v?.lora?.strengthModel ?? 1.0,
strengthClip: v?.lora?.strengthClip ?? 1.0,
},
vae: v?.vae || "",
name: v?.name || "",
strengthModel: v?.strengthModel ?? 1.0,
strengthClip: v?.strengthClip ?? 1.0,
};
}
function _emptyColumns() {
return {
model: [{ atIteration: 1, value: _defaultValueFor("model"), revertToBase: false, bypassed: false }],
checkpoint: [{ atIteration: 1, value: _defaultValueFor("checkpoint"), revertToBase: false, bypassed: false }],
lora: [{ atIteration: 1, value: _defaultValueFor("lora"), revertToBase: false, bypassed: false }],
vae: [{ atIteration: 1, value: _defaultValueFor("vae"), revertToBase: false, bypassed: false }],
prompt: [{ atIteration: 1, value: _defaultValueFor("prompt"), revertToBase: false, bypassed: false }],
ksampler: [{ atIteration: 1, value: _defaultValueFor("ksampler"), revertToBase: false, bypassed: false }],
};
}
// Legacy Plan JSON (pre "Model column" merge) stored separate checkpoint/vae columns.
// Merges them into one "model" column keyed by atIteration so old .json/.png plans
// still load correctly. LoRA is left empty since the old format never had it.
// Two prior on-disk Plan JSON shapes need upgrading to today's independent
// checkpoint/lora/vae arrays:
// - Pre-v0.4.2: separate top-level `checkpoint`/`vae` columns, no `lora` at all.
// - v0.4.2's brief "merged Model row" design: `columns.model` was a single array of
// {atIteration, value:{checkpoint,lora,vae}, ...} rows bundling all three together.
// That bundling caused its own bugs (+/- and the cell modal affected all three
// fields at once) and was replaced the same release cycle, but a plan saved during
// that window must still load. Values are moved into their own independent arrays;
// iteration #1 is always kept (baseline row) even if a field was blank there.
function _migrateLabColumns(columns) {
if (!columns) return _emptyColumns();
if (columns.model) return columns;
if (!columns.model && Array.isArray(columns.lora)) return columns; // already current shape (independent lora array is the marker)
const ckptKfs = columns.checkpoint || [];
const vaeKfs = columns.vae || [];
const { checkpoint, vae, ...rest } = columns;
const defaults = _emptyColumns();
if (ckptKfs.length === 0 && vaeKfs.length === 0) {
return { ...rest, model: _emptyColumns().model };
if (Array.isArray(columns.model)) {
const checkpoint = [], lora = [], vae = [];
for (const kf of columns.model) {
const v = kf.value || {};
const base = { atIteration: kf.atIteration, revertToBase: !!kf.revertToBase, bypassed: !!kf.bypassed };
if (kf.atIteration === 1 || v.checkpoint) checkpoint.push({ ...base, value: v.checkpoint || "" });
if (kf.atIteration === 1 || v.lora?.name) lora.push({ ...base, value: _normalizeLoraValue(v.lora) });
if (kf.atIteration === 1 || v.vae) vae.push({ ...base, value: v.vae || "" });
}
const { model, ...rest } = columns;
return {
...rest,
checkpoint: checkpoint.length ? checkpoint : defaults.checkpoint,
lora: lora.length ? lora : defaults.lora,
vae: vae.length ? vae : defaults.vae,
};
}
const iterations = [...new Set([1, ...ckptKfs.map((k) => k.atIteration), ...vaeKfs.map((k) => k.atIteration)])].sort((a, b) => a - b);
const findAt = (kfs, iter) => kfs.find((k) => k.atIteration === iter);
const model = iterations.map((iter) => {
const ck = findAt(ckptKfs, iter);
const vk = findAt(vaeKfs, iter);
if (columns.checkpoint || columns.vae) {
return {
atIteration: iter,
bypassed: !!(ck?.bypassed || vk?.bypassed),
revertToBase: !!(ck?.revertToBase || vk?.revertToBase),
value: { checkpoint: ck?.value || "", lora: { name: "", strengthModel: 1.0, strengthClip: 1.0 }, vae: vk?.value || "" },
...columns,
checkpoint: columns.checkpoint?.length ? columns.checkpoint : defaults.checkpoint,
lora: defaults.lora,
vae: columns.vae?.length ? columns.vae : defaults.vae,
};
});
return { ...rest, model };
}
return columns;
}
function _emptyLabState() {
@@ -343,10 +360,21 @@ function _initPlanButtons() {
// Column keyframes: render / add / remove / edit
// ============================================
// A click on +/- always targets whichever of checkpoint/lora/vae the Model column
// header's C/L/V toggle currently has selected — "model" is a sentinel dataset.col
// value on those two shared buttons (there's only one +/- pair in the DOM for the
// merged-looking Model column), never a real key into _lab.columns. Resolving it here
// means adding/removing a keyframe only ever touches the ONE field being viewed, never
// the other two — Prompt/KSampler's own +/- buttons carry their real col directly and
// pass through unchanged.
function _resolveColumnKey(col) {
return col === "model" ? _modelViewMode : col;
}
function _initColumnButtons() {
document.querySelectorAll(".wfm-lab-col-add").forEach((btn) => {
btn.addEventListener("click", () => {
const col = btn.dataset.col;
const col = _resolveColumnKey(btn.dataset.col);
const kfs = _lab.columns[col];
const lastIter = kfs[kfs.length - 1].atIteration;
if (lastIter >= _lab.batchCount) {
@@ -354,42 +382,59 @@ function _initColumnButtons() {
return;
}
kfs.push({ atIteration: lastIter + 1, value: _defaultValueFor(col), revertToBase: false, bypassed: false });
_renderColumn(col);
_renderColumnForKey(col);
_openCellModal(col, kfs.length - 1);
});
});
document.querySelectorAll(".wfm-lab-col-remove").forEach((btn) => {
btn.addEventListener("click", () => {
const col = btn.dataset.col;
const col = _resolveColumnKey(btn.dataset.col);
const kfs = _lab.columns[col];
if (kfs.length <= 1) return; // Row 1 (baseline) cannot be removed
kfs.pop();
_renderColumn(col);
_renderColumnForKey(col);
});
});
}
// Wires the Model column header's C/L/V buttons — switches which single field its cells
// summarize (see _modelViewMode) and re-renders just that column. Purely a display
// filter: the underlying keyframe data (all three fields) is untouched either way.
// Wires the Model column header's C/L/V buttons — switches which independent field
// (checkpoint/lora/vae) the shared cell list, +/-, and cell-click modal all operate on.
// Purely a display/routing selector: none of the three keyframe arrays are touched by
// switching it.
function _initModelViewToggle() {
document.querySelectorAll(".wfm-lab-model-view-btn").forEach((btn) => {
btn.addEventListener("click", () => {
_modelViewMode = btn.dataset.view;
document.querySelectorAll(".wfm-lab-model-view-btn").forEach((b) => b.classList.toggle("active", b === btn));
_renderColumn("model");
_renderModelColumn();
});
});
}
function _renderAllColumns() {
COLUMN_KEYS.forEach(_renderColumn);
_renderModelColumn();
_renderColumn("prompt");
_renderColumn("ksampler");
}
function _renderColumn(col) {
const container = document.getElementById(`wfm-lab-col-cells-${col}`);
// Renders the Model column's shared cell list from whichever of checkpoint/lora/vae is
// currently toggled (_modelViewMode) — the other two arrays exist in _lab.columns but
// have no DOM of their own to render into; they're only ever shown when toggled to.
function _renderModelColumn() {
_updateModelViewHighlight();
_renderColumn(_modelViewMode, "wfm-lab-col-cells-model");
}
// Routes a keyframe-array key to whichever container renders it: checkpoint/lora/vae
// all share the Model column's single cell list, everything else has its own.
function _renderColumnForKey(col) {
if (col === "checkpoint" || col === "lora" || col === "vae") _renderModelColumn();
else _renderColumn(col);
}
function _renderColumn(col, containerId = `wfm-lab-col-cells-${col}`) {
const container = document.getElementById(containerId);
if (!container) return;
if (col === "model") _updateModelViewHighlight();
const keyframes = _lab.columns[col];
container.innerHTML = keyframes.map((kf, idx) => _cellHtml(col, kf, idx)).join("");
container.querySelectorAll(".wfm-lab-cell").forEach((el) => {
@@ -438,19 +483,13 @@ function _cellHtml(col, kf, idx) {
function _cellLabel(col, kf) {
if (kf.bypassed) return t("labBypassed");
if (kf.revertToBase) return t("labRevertToBase");
if (col === "model") {
// The header's C/L/V toggle (_modelViewMode) picks which single field this cell
// summarizes. Real keyframes (atIteration set) resolve the field's inherited/
// effective value via _resolveModelKeyframe — e.g. a keyframe that only touches
// LoRA still shows whatever Checkpoint is actually in effect when viewing "C",
// not a blank. The synthetic live-preview object built for keyframe #1 (no
// atIteration) already IS that resolved value, so it's used as-is.
const resolved = (kf.atIteration != null) ? _resolveModelKeyframe(kf.atIteration) : _normalizeModelValue(kf.value);
let display = "";
if (_modelViewMode === "checkpoint") display = resolved.checkpoint;
else if (_modelViewMode === "vae") display = resolved.vae;
else if (_modelViewMode === "lora" && resolved.lora.name) display = `${resolved.lora.name} (${resolved.lora.strengthModel}/${resolved.lora.strengthClip})`;
return display ? escapeHtml(display) : t("labUseWorkflowDefault");
if (col === "checkpoint" || col === "vae") {
return kf.value ? escapeHtml(kf.value) : t("labUseWorkflowDefault");
}
if (col === "lora") {
const v = kf.value;
if (!v?.name) return t("labUseWorkflowDefault");
return escapeHtml(`${v.name} (${v.strengthModel}/${v.strengthClip})`);
}
if (col === "prompt") {
const p = kf.value?.positive || "";
@@ -471,10 +510,8 @@ function _cellLabel(col, kf) {
}
function _isEmptyValue(col, value) {
if (col === "model") {
const v = _normalizeModelValue(value);
return !v.checkpoint && !v.lora.name && !v.vae;
}
if (col === "checkpoint" || col === "vae") return !value;
if (col === "lora") return !value?.name;
if (col === "prompt") return !value || (!value.positive && !value.negative);
if (col === "ksampler") {
return !value || (value.steps == null && value.cfg == null && value.denoise == null &&
@@ -492,14 +529,11 @@ function _isEmptyValue(col, value) {
function _liveValueFor(col) {
const analysis = comfyUI.currentAnalysis;
if (!analysis) return null;
if (col === "model") {
const ckpt = analysis.checkpoint_nodes?.[0]?.ckpt_name || "";
const vae = analysis.vae_nodes?.[0]?.vae_name || "";
// LoRA is intentionally left out of the live-reflected value — there's no safe
// way to pick "the" active LoRA back out of a workflow node to prefill with.
if (!ckpt && !vae) return null;
return { checkpoint: ckpt, lora: { name: "", strengthModel: 1.0, strengthClip: 1.0 }, vae };
}
if (col === "checkpoint") return analysis.checkpoint_nodes?.[0]?.ckpt_name || null;
if (col === "vae") return analysis.vae_nodes?.[0]?.vae_name || null;
// LoRA is intentionally never live-reflected — there's no safe way to pick "the"
// active LoRA back out of a workflow node to prefill with.
if (col === "lora") return null;
if (col === "prompt") {
const posNode = analysis.prompt_nodes?.find((n) => n.role === "positive");
const negNode = analysis.prompt_nodes?.find((n) => n.role === "negative");
@@ -544,53 +578,48 @@ function _openCellModal(col, idx) {
const effectiveValue = live ? _liveValueFor(col) : kf.value;
const hasPrevious = col === "prompt" && idx > 0;
// What this row would inherit if left blank (the latest earlier keyframe in this
// SAME field's own array, not touching the other two) — shown as the blank option's
// label so it's clear leaving it untouched still carries a value forward, rather
// than resetting to the workflow's own default. Only meaningful past #1.
const inherited = isFirst ? null : _resolveKeyframe(col, kf.atIteration - 1);
let valueFieldsHtml = "";
if (col === "model") {
// Shows ONLY the field matching the column header's C/L/V toggle (_modelViewMode)
// — no in-modal tab switcher — so what's open in the modal always matches what the
// cell/header are currently showing. To edit a different field, switch the header
// toggle first, then reopen the cell; Save only touches the field being shown here
// (see the save handler below), leaving the other two exactly as already stored.
const v = _normalizeModelValue(effectiveValue);
// What this row would inherit for the shown field if left blank (everything up to,
// but not including, this keyframe's own atIteration) — shown as the blank option's
// label so it's clear leaving it untouched still carries a value forward, rather
// than resetting to the workflow's own default. Only meaningful past #1.
const inherited = isFirst ? null : _resolveModelKeyframe(kf.atIteration - 1);
const selectFieldHtml = (idPrefix, options, currentVal, inheritedVal) => `
<div class="wfm-search-wrap" style="width:100%;">
<input type="text" id="wfm-lab-modal-${idPrefix}-filter" class="wfm-input wfm-search-input" placeholder="Filter...">
<button type="button" class="wfm-search-clear-btn" id="wfm-lab-modal-${idPrefix}-filter-clear" title="Clear search">✕</button>
</div>
<select id="wfm-lab-modal-${idPrefix}-select" class="wfm-select">
<option value="">${escapeHtml(inheritedVal ? t("labInheritsFrom", inheritedVal) : t("labUseWorkflowDefault"))}</option>
${options.map((m) => `<option value="${escapeHtml(m)}" ${m === currentVal ? "selected" : ""}>${escapeHtml(m)}</option>`).join("")}
</select>`;
if (_modelViewMode === "checkpoint") {
valueFieldsHtml = `
<div class="wfm-lab-modal-row">
<label>Checkpoint</label>
${selectFieldHtml("ckpt", comfyEditor.models.checkpoints || [], v.checkpoint, inherited?.checkpoint)}
</div>`;
} else if (_modelViewMode === "lora") {
valueFieldsHtml = `
<div class="wfm-lab-modal-row">
<label>LoRA</label>
${selectFieldHtml("lora", comfyEditor.models.loras || [], v.lora.name, inherited?.lora?.name)}
<div class="wfm-lora-strength-single" style="margin-top:6px;">
<span>M</span>
<input type="number" class="wfm-input" id="wfm-lab-modal-lora-strmodel" value="${v.lora.strengthModel}" step="0.05" min="0" max="2">
<span>C</span>
<input type="number" class="wfm-input" id="wfm-lab-modal-lora-strclip" value="${v.lora.strengthClip}" step="0.05" min="0" max="2">
</div>
</div>`;
} else if (_modelViewMode === "vae") {
valueFieldsHtml = `
<div class="wfm-lab-modal-row">
<label>VAE</label>
${selectFieldHtml("vae", comfyEditor.models.vaes || [], v.vae, inherited?.vae)}
</div>`;
}
if (col === "checkpoint" || col === "vae") {
const options = comfyEditor.models[col === "checkpoint" ? "checkpoints" : "vaes"] || [];
valueFieldsHtml = `
<div class="wfm-lab-modal-row">
<label>${col === "checkpoint" ? "Checkpoint" : "VAE"}</label>
<div class="wfm-search-wrap" style="margin-bottom:4px;width:100%;">
<input type="text" id="wfm-lab-modal-value-filter" class="wfm-input wfm-search-input" placeholder="Filter...">
<button type="button" class="wfm-search-clear-btn" id="wfm-lab-modal-value-filter-clear" title="Clear search">✕</button>
</div>
<select id="wfm-lab-modal-value" class="wfm-select">
<option value="">${escapeHtml(inherited ? t("labInheritsFrom", inherited) : t("labUseWorkflowDefault"))}</option>
${options.map((m) => `<option value="${escapeHtml(m)}" ${m === effectiveValue ? "selected" : ""}>${escapeHtml(m)}</option>`).join("")}
</select>
</div>`;
} else if (col === "lora") {
const v = _normalizeLoraValue(effectiveValue);
const options = comfyEditor.models.loras || [];
valueFieldsHtml = `
<div class="wfm-lab-modal-row">
<label>LoRA</label>
<div class="wfm-search-wrap" style="width:100%;">
<input type="text" id="wfm-lab-modal-lora-filter" class="wfm-input wfm-search-input" placeholder="Filter...">
<button type="button" class="wfm-search-clear-btn" id="wfm-lab-modal-lora-filter-clear" title="Clear search">✕</button>
</div>
<select id="wfm-lab-modal-lora-select" class="wfm-select">
<option value="">${escapeHtml(inherited?.name ? t("labInheritsFrom", inherited.name) : t("labUseWorkflowDefault"))}</option>
${options.map((m) => `<option value="${escapeHtml(m)}" ${m === v.name ? "selected" : ""}>${escapeHtml(m)}</option>`).join("")}
</select>
<div class="wfm-lora-strength-single" style="margin-top:6px;">
<span>M</span>
<input type="number" class="wfm-input" id="wfm-lab-modal-lora-strmodel" value="${v.strengthModel}" step="0.05" min="0" max="2">
<span>C</span>
<input type="number" class="wfm-input" id="wfm-lab-modal-lora-strclip" value="${v.strengthClip}" step="0.05" min="0" max="2">
</div>
</div>`;
} else if (col === "prompt") {
const v = effectiveValue || {};
valueFieldsHtml = `
@@ -683,7 +712,8 @@ function _openCellModal(col, idx) {
</div>`;
openModal(`${col} #${idx + 1}`, html);
if (col === "prompt") _wireLabPromptModalExtras(idx);
if (col === "model") _wireLabModelFieldFilter();
if (col === "checkpoint" || col === "vae") _wireLabValueFilter(col);
if (col === "lora") _wireLabLoraFilter();
// Bypass disables everything else on the modal (value fields AND the Revert
// checkbox) — the two are mutually exclusive since bypass already means "ignore
@@ -706,7 +736,7 @@ function _openCellModal(col, idx) {
document.getElementById("wfm-lab-modal-delete")?.addEventListener("click", () => {
_lab.columns[col].splice(idx, 1);
_renderColumn(col);
_renderColumnForKey(col);
closeModal();
});
@@ -721,23 +751,14 @@ function _openCellModal(col, idx) {
let value = kf.value;
if (!revert && !bypassed) {
if (col === "model") {
// Only the field the modal actually shows (_modelViewMode) gets updated —
// the other two are carried over from this row's own currently-saved
// value untouched, since their fields aren't in the DOM to read from.
const next = _normalizeModelValue(kf.value);
if (_modelViewMode === "checkpoint") {
next.checkpoint = document.getElementById("wfm-lab-modal-ckpt-select")?.value || "";
} else if (_modelViewMode === "lora") {
next.lora = {
name: document.getElementById("wfm-lab-modal-lora-select")?.value || "",
strengthModel: parseFloat(document.getElementById("wfm-lab-modal-lora-strmodel")?.value) || 1.0,
strengthClip: parseFloat(document.getElementById("wfm-lab-modal-lora-strclip")?.value) || 1.0,
};
} else if (_modelViewMode === "vae") {
next.vae = document.getElementById("wfm-lab-modal-vae-select")?.value || "";
}
value = next;
if (col === "checkpoint" || col === "vae") {
value = document.getElementById("wfm-lab-modal-value")?.value || "";
} else if (col === "lora") {
value = {
name: document.getElementById("wfm-lab-modal-lora-select")?.value || "",
strengthModel: parseFloat(document.getElementById("wfm-lab-modal-lora-strmodel")?.value) || 1.0,
strengthClip: parseFloat(document.getElementById("wfm-lab-modal-lora-strclip")?.value) || 1.0,
};
} else if (col === "prompt") {
value = {
positive: document.getElementById("wfm-lab-modal-positive")?.value || "",
@@ -763,7 +784,7 @@ function _openCellModal(col, idx) {
_lab.columns[col][idx] = { atIteration, value, revertToBase: revert, bypassed };
_lab.columns[col].sort((a, b) => a.atIteration - b.atIteration);
_renderColumn(col);
_renderColumnForKey(col);
closeModal();
});
}
@@ -784,28 +805,46 @@ function _applyStyleToText(positive, negative, style) {
};
}
// Wires the filter box above whichever single field the Model-column modal is
// currently showing (_modelViewMode picks which one exists in the DOM — the other two
// calls below are harmless no-ops since their elements aren't present). Same filter
// behavior as GenerateUI's Model tab filter inputs (comfyui-editor.js renderModelTab).
function _wireLabModelFieldFilter() {
const wireFilter = (idPrefix, modelsKey) => {
const filterInput = document.getElementById(`wfm-lab-modal-${idPrefix}-filter`);
const select = document.getElementById(`wfm-lab-modal-${idPrefix}-select`);
if (!filterInput || !select) return;
const options = comfyEditor.models[modelsKey] || [];
const applyFilter = () => {
const filter = filterInput.value.toLowerCase();
select.innerHTML = `<option value="">${t("labUseWorkflowDefault")}</option>` +
options.filter((m) => m.toLowerCase().includes(filter))
.map((m) => `<option value="${escapeHtml(m)}">${escapeHtml(m)}</option>`).join("");
};
filterInput.addEventListener("input", applyFilter);
setupSearchClearBtn(filterInput.id, `${filterInput.id}-clear`, applyFilter);
// Filter box above the Checkpoint/VAE modal's <select>, same behavior as the
// Model tab's filter inputs (comfyui-editor.js renderModelTab): rebuild the
// option list on every keystroke, always keeping the "workflow default" option.
// The overlay ✕ clear button reuses the same setupSearchClearBtn() helper as the
// Models/Nodes/Workflow/Gallery tab search boxes and the Raw JSON search bar.
function _wireLabValueFilter(col) {
const filterInput = document.getElementById("wfm-lab-modal-value-filter");
const select = document.getElementById("wfm-lab-modal-value");
if (!filterInput || !select) return;
const options = comfyEditor.models[col === "checkpoint" ? "checkpoints" : "vaes"] || [];
const applyFilter = () => {
const filter = filterInput.value.toLowerCase();
select.innerHTML = `<option value="">${t("labUseWorkflowDefault")}</option>` +
options.filter((m) => m.toLowerCase().includes(filter))
.map((m) => `<option value="${escapeHtml(m)}">${escapeHtml(m)}</option>`).join("");
};
wireFilter("ckpt", "checkpoints");
wireFilter("lora", "loras");
wireFilter("vae", "vaes");
filterInput.addEventListener("input", applyFilter);
setupSearchClearBtn("wfm-lab-modal-value-filter", "wfm-lab-modal-value-filter-clear", applyFilter);
}
// Same filter behavior as _wireLabValueFilter, for the LoRA modal's own select (it
// has separate element ids since a LoRA keyframe row also carries Strength M/C
// inputs alongside it, unlike the plain single-select Checkpoint/VAE modal).
function _wireLabLoraFilter() {
const filterInput = document.getElementById("wfm-lab-modal-lora-filter");
const select = document.getElementById("wfm-lab-modal-lora-select");
if (!filterInput || !select) return;
const options = comfyEditor.models.loras || [];
const applyFilter = () => {
const filter = filterInput.value.toLowerCase();
select.innerHTML = `<option value="">${t("labUseWorkflowDefault")}</option>` +
options.filter((m) => m.toLowerCase().includes(filter))
.map((m) => `<option value="${escapeHtml(m)}">${escapeHtml(m)}</option>`).join("");
};
filterInput.addEventListener("input", applyFilter);
setupSearchClearBtn("wfm-lab-modal-lora-filter", "wfm-lab-modal-lora-filter-clear", applyFilter);
}
// Wires the Prompt-column modal's extra actions: fetch the live GenerateUI prompt,
@@ -941,36 +980,6 @@ function _resolveKeyframe(col, iteration) {
return _isEmptyValue(col, applicable.value) ? null : applicable.value;
}
// Unlike _resolveKeyframe (which swaps in one keyframe's whole value), the Model column
// bundles three independent settings — Checkpoint/LoRA/VAE — into a single row, so a
// keyframe that only sets a LoRA must NOT blank out a Checkpoint/VAE chosen by an earlier
// keyframe. This resolves each of the three fields separately: walking keyframes up to
// `iteration` in order, each field keeps whatever value it last saw set (non-empty),
// carrying forward across keyframes that leave it blank. A `revertToBase` keyframe resets
// all three fields at once to iteration #1's row (mirrors _resolveKeyframe's revert
// semantics) rather than being treated as a per-field value of its own. Bypassed keyframes
// are skipped entirely, as elsewhere.
function _resolveModelKeyframe(iteration) {
const kfs = _lab.columns.model;
const row1 = kfs.find((k) => k.atIteration === 1);
const row1Value = (row1 && !row1.bypassed) ? _normalizeModelValue(row1.value) : null;
let current = _defaultValueFor("model");
for (const kf of kfs) {
if (kf.atIteration > iteration) break;
if (kf.bypassed) continue;
if (kf.revertToBase) {
current = row1Value ? _normalizeModelValue(row1Value) : _defaultValueFor("model");
continue;
}
const v = _normalizeModelValue(kf.value);
if (v.checkpoint) current.checkpoint = v.checkpoint;
if (v.lora.name) current.lora = { ...v.lora };
if (v.vae) current.vae = v.vae;
}
return current;
}
function _buildWorkflowForIteration(iteration, chainImageRef) {
const wf = JSON.parse(JSON.stringify(comfyUI.currentWorkflow || {}));
const analysis = comfyUI.currentAnalysis;
@@ -980,28 +989,30 @@ function _buildWorkflowForIteration(iteration, chainImageRef) {
if (nodeId != null && wf[nodeId]) wf[nodeId].inputs[key] = val;
};
const model = _resolveModelKeyframe(iteration);
if (model.checkpoint && analysis.checkpoint_nodes?.[0]) {
write(analysis.checkpoint_nodes[0].id, "ckpt_name", model.checkpoint);
const ckpt = _resolveKeyframe("checkpoint", iteration);
if (ckpt && analysis.checkpoint_nodes?.[0]) {
write(analysis.checkpoint_nodes[0].id, "ckpt_name", ckpt);
}
if (model.vae) {
const vae = _resolveKeyframe("vae", iteration);
if (vae) {
const vaeNode = analysis.vae_nodes?.[0];
if (vaeNode && wf[vaeNode.id]) {
write(vaeNode.id, "vae_name", model.vae);
write(vaeNode.id, "vae_name", vae);
} else {
// Workflow has no standalone VAELoader node (VAE baked into the checkpoint) —
// inject one and repoint every "vae" input link at it so the override actually applies.
const newId = String(Math.max(0, ...Object.keys(wf).map(Number)) + 1);
wf[newId] = { class_type: "VAELoader", inputs: { vae_name: model.vae }, _meta: { title: "VAE Loader" } };
wf[newId] = { class_type: "VAELoader", inputs: { vae_name: vae }, _meta: { title: "VAE Loader" } };
for (const node of Object.values(wf)) {
if (Array.isArray(node?.inputs?.vae)) node.inputs.vae = [newId, 0];
}
}
}
if (model.lora.name) {
_applyLabLoraToWorkflow(wf, analysis, model.lora);
const lora = _resolveKeyframe("lora", iteration);
if (lora?.name) {
_applyLabLoraToWorkflow(wf, analysis, lora);
}
const prompt = _resolveKeyframe("prompt", iteration);
+2 -2
View File
@@ -1432,8 +1432,8 @@
<li id="wfm-help-lab-11">Prompt cell editor also has <b>Clear</b> (empties both Positive and Negative) and <b>Get from Previous</b> (copies both Positive and Negative from the keyframe immediately before this one in the same column — e.g. editing #3 copies #2's prompt wholesale; not shown on keyframe #1, which has no earlier row to copy from)</li>
<li id="wfm-help-lab-12">Keyframe #1 in every column shows the currently loaded workflow's own live setting instead of a blank placeholder — for the Model column this covers Checkpoint and VAE only (LoRA is never live-reflected, since there's no reliable way to infer "the" active LoRA back out of a workflow node) — both in its grid cell (dashed border) and pre-filled into its edit modal, for as long as it hasn't been explicitly saved with an override; saving it (even without changing anything) locks that value in as a real override and the dashed/live styling goes away; switching to the Lab subtab always refreshes this against whatever is currently loaded in GenerateUI</li>
<li id="wfm-help-lab-13"><b>Bypass</b> checkbox (every keyframe, including #1): skips that row entirely when picking which setting applies at a given iteration — as if the row didn't exist — so whatever was active before it (an earlier keyframe, or ultimately nothing) just keeps running; Lab-internal only, it never touches the actual ComfyUI node's mode; disables that row's value fields and its Revert checkbox while checked (the two are mutually exclusive); the cell shows an amber "⏭ Bypassed" label, which takes priority over both the Revert and live-value display</li>
<li id="wfm-help-lab-14"><b>Model</b> column header's <b>C / L / V</b> buttons pick which single field (Checkpoint / LoRA / VAE — LoRA is Single-only, no Stack support here) its cells summarize and its edit modal shows; the two you're not currently viewing are left exactly as already saved when you Save. Each button turns the same highlight color as GenerateUI's Model tab (configurable in Settings) when the loaded workflow actually contains that node type. If a LoRA is set but the workflow has no LoRA node at all, Lab warns once per Run and skips applying it — unlike VAE, it never auto-inserts a LoRA node, since that would require rewiring the Checkpoint's model/clip outputs through it</li>
<li id="wfm-help-lab-15">Model column fields carry forward independently: a keyframe that only sets a LoRA doesn't reset Checkpoint/VAE to the workflow default — each of the three keeps whatever an earlier keyframe last set until a later keyframe explicitly changes it. A field left blank in the edit modal shows "(inherits: ...)" as its placeholder instead of "(workflow default)" when there's something to inherit, so it's clear what actually applies. "Revert to #1's setting" resets all three fields at once back to keyframe #1's row, not just the one you're currently viewing</li>
<li id="wfm-help-lab-14"><b>Model</b> column header's <b>C / L / V</b> buttons pick which independent keyframe list — Checkpoint, LoRA (Single-only, no Stack support here), or VAE — the shared cell list, its +/-, and its edit modal all currently operate on. Each of the three is its own timeline with its own keyframes, exactly like Prompt/KSampler; they only ever look merged because they share one column's worth of screen space. Adding, removing, or editing a keyframe while viewing one never touches the other two. Each button turns the same highlight color as GenerateUI's Model tab (configurable in Settings) when the loaded workflow actually contains that node type. If a LoRA is set but the workflow has no LoRA node at all, Lab warns once per Run and skips applying it — unlike VAE, it never auto-inserts a LoRA node, since that would require rewiring the Checkpoint's model/clip outputs through it</li>
<li id="wfm-help-lab-15">Checkpoint / LoRA / VAE each carry forward independently, the same effective-value rule as every other column (see above): a keyframe in one never resets the other two to the workflow default. A field left blank in the edit modal shows "(inherits: ...)" as its placeholder instead of "(workflow default)" when an earlier keyframe in that same list already set something, so it's clear what actually applies. "Revert to #1's setting" and Bypass — like +/- — only ever affect whichever ONE of the three is currently selected by the C/L/V toggle, never the other two</li>
</ul>
</div>