Compare commits
@@ -2,6 +2,12 @@
|
||||
|
||||
Condensed version-by-version index of DEVLOG.md (each line: version + one-line summary of what changed and why). Full rationale, code details, and "How to apply" lessons live in the excluded DEVLOG.md itself; this index exists so graphify can extract a queryable semantic node per release without feeding the full log to the local Ollama model. Regenerate with `python tools/generate_doc_index.py devlog` whenever DEVLOG.md changes.
|
||||
|
||||
- **v0.9.6(2026-11-12)** — マニュアル v0.9.6版(ja/en)
|
||||
- **v0.9.5(2026-11-11)** — AI TOOL Chat: スキル v2(metadata・同梱スキルの版管理・プロンプトボタン)
|
||||
- **v0.9.4(2026-11-07)** — Gallery: 3Dモデル対応(GLB / glTF / VRM / PLY / SPZ)
|
||||
- **v0.9.3(2026-11-05)** — Image Edit: Drawツールのカラーパレット・ショートカット・スポイト(Comic Creator v1.67.0 から移植)
|
||||
- **v0.9.2(2026-11-03)** — Selectorイベントループ有効時に画像が壊れて届く不具合を修正
|
||||
- **v0.9.1(2026-11-03)** — TagSpace: 選んだタグだけを追加 / タグのカッコをエスケープして追加
|
||||
- **v0.9.0(2026-11-03)** — マニュアル(画像なし配布版)を v0.9.0 に改訂
|
||||
- **v0.8.14(2026-11-03)** — Fibo 2.0 Edit / FastH3 OmniRef / Pika-H3-Flex 対応(GenerateUI・Metadata・Videoタブ)
|
||||
- **v0.8.13(2026-10-27)** — Edit in Mask Editor One: 送信画像をノードへ永続化(Mask Editor One v0.1.12 対応)
|
||||
|
||||
@@ -2,6 +2,152 @@
|
||||
|
||||
---
|
||||
|
||||
## v0.9.6(2026-11-12)
|
||||
|
||||
### マニュアル v0.9.6版(ja/en)
|
||||
|
||||
- 5-4 Settingsタブを3列(スイッチ列の説明を追加)、5-9 対応表に MiniMax H3 Image と Compare Images 修正の注記、12-3 の Apply 注意にスイッチ列の例外を追記。対象バージョンを v0.9.6 に更新し、全章MD・ZIPを `manual/rebuild_repo_edition.py` で再生成(v0.9.5版は削除)。
|
||||
|
||||
### GenerateUI Settingsタブ: スイッチ列
|
||||
|
||||
- KSampler・Latent Imageの右に3列目「スイッチ」を追加。APIワークフロー内のリテラルな true/false 入力(ComfySwitchNode の switch、PrimitiveBoolean の value、TextGenerateV2 の thinking など)をノード名・入力名付きで一覧し、チェックボックスで即時切り替え(Apply不要、Raw JSONへ同期)。リンクされた入力は配列なので対象外で、配線元のPrimitiveBooleanが出る。
|
||||
- 選択肢がちょうど enable/disable・enabled/disabled・on/off・true/false・yes/no の2択のCOMBO(KSamplerAdvanced add_noise、DWPreprocessor detect_* など)も対象。object_info を型別取得してから追加描画し、値はそのノード自身の綴りの文字列で書き戻す。DynamicCombo(TextGenerate の sampling_mode)は子入力が変わるため対象外。
|
||||
- 保存済みワークフロー347本(App形式5本は読込対象外)をブラウザで順に読み込み、128本・426項目でオン→オフ→元に戻すの往復と Raw JSON 同期を自動検証、失敗・コンソールエラー0。
|
||||
- MiniMax H3 Image のT2I(サブグラフ内のプロンプト強化スイッチ)とEdit(PrimitiveBoolean「Enable PE?」)でブラウザ実操作を確認。
|
||||
|
||||
### MiniMax-H3-Image対応
|
||||
|
||||
- `MiniMaxH3ImageConditioning`(Comfy-Org の MiniMax H3 Image T2I/Edit テンプレート)をGenerateUI/Metadata/Iタブに追加。入力は `prompt`/`width`/`height`/`images.image_N` で `PH3ImageConditioning` と同形のため同じ扱い(negativeなし、promptはPreviewAny→プロンプト強化スイッチ経由で配線元のユーザープロンプトをpositiveに)。width/height=0は image_1 のサイズ、画像なしなら2048。
|
||||
- 修正前はプロンプト欄が空で、Metadataタブもプロンプトを読めなかった。T2I(プロンプト強化オフ、2048x2048)とEdit(プロンプト強化オン、2720x1536)で実生成確認。
|
||||
|
||||
### 修正: ImageCompareノードを含むワークフローで検証エラー
|
||||
|
||||
- `ImageCompare` の `compare_view`(表示専用の socketless ウィジェット、`widgets_values` に保存されない)がAPI変換で欠落し、`required_input_missing` になっていた。フロントエンドと同じ `{"__value__": ["", ""]}` を補う(`_DISPLAY_WIDGET_DEFAULTS`)。MiniMax H3 Image Edit テンプレートで発覚。
|
||||
|
||||
## v0.9.5(2026-11-11)
|
||||
|
||||
### AI TOOL Chat: スキル v2(metadata・同梱スキルの版管理・プロンプトボタン)
|
||||
|
||||
- スキルの frontmatter に `metadata:` を追加(`output` prompt/svg の自動チェック+1回だけ再依頼、`tools`、`temperature`、`think`、`context`: workflow / current-prompt / workflow-detail / logs)。`name`/`description` は Agent Skills(SKILL.md)と互換のまま。
|
||||
- 同梱スキルを `py/services/builtin_skills/*.md` に分離し `wfs-version` で版管理。`ai_skills/.builtin_state.json` に版とハッシュを記録し、未編集なら自動更新、編集済みなら残して ↻ ボタンを表示。v0.3.79 版の未編集コピーはハッシュで判別して更新。
|
||||
- 同梱スキル6本: T2I Prompt Enhancer / Image to Prompt / Storyboard to Lab / Workflow Doctor(新規)、SVG Icon Generator v2、Skill Creator v3。
|
||||
- ```prompt ブロックに「GenerateUIに反映」「このプロンプトで生成」「コピー」。応答は依存なしの Markdown 表示(先に全体をエスケープ)。
|
||||
- ワークフロー要約から「今のプロンプト」を既定で外した。「参考のみ・写すな」と書いても gemma4 e4b は 6/6 回で古いネガティブ(pink wear)を新しいプロンプトへ混ぜたため。発言が今のプロンプトに触れたときと `context: [current-prompt]` のときだけ渡す。
|
||||
|
||||
### AI TOOL Chat: 生成画像のチェックと修正ループ(意思決定モデル)
|
||||
|
||||
- 新規 `static/js/chat-image-check.js`。チャットモデルが依頼を3〜8個の yes/no 項目にし(JSON Schema)、Ollama の画像対応意思決定モデル(既定 d3:4b)が確率で判定。✔≥0.85 / ? 0.3〜0.85 / ✘<0.3。
|
||||
- 実測: 生成VLM(gemma4 uncensored e4b)は「剣を持っている」を誤って合格(7/8)。d3:4b・clef-flash・clef は引っかけ含む18/18正解。属性の取り違え(帽子が女の子なのに「男の子が帽子」0.70〜0.79)があるため合格ラインを0.85にし、`expect: no` の逆向き確認項目を加えた。
|
||||
- 「足りない点を直して再生成」で未達項目だけを伝えて書き直し・再生成。自動修正0〜3回。SVG も描画して部位ごとにチェック。
|
||||
|
||||
### AI TOOL Chat: Workflow Doctor / Storyboard to Lab / フォルダ形式スキル
|
||||
|
||||
- 新規 `static/js/chat-context.js`: 未インストールのノード、ComfyUI の一覧に無いファイル・選択肢(近い名前の候補)、切れたリンク、未接続のコア型必須入力、`/history` の直近エラー、ComfyUI ログの最新エラー部分を文脈に入れる。手元77ワークフローで誤検出0(ウィジェット系の必須入力チェックは1/3で誤検出したため除外)。
|
||||
- ```labplan → Lab プラン(1シーン=1イテレーション、T2I、seed 固定)。`lab-tab.js` に `openLabPlanData()`。
|
||||
- スキルの zip 取り込み(SKILL.md 必須、テキストのみ、スクリプトは展開しない、zip-slip とサイズの検査)。参照ファイルは `read_skill_file` ツールで必要時に読む。
|
||||
- Lab: KSampler 列が seed / sampler / scheduler だけ指定のとき「ワークフローの設定のまま」と表示していた不具合を修正。
|
||||
|
||||
### AI TOOL: OCR・ペインの折りたたみ
|
||||
|
||||
- 新規 `static/js/ocr-tools.js`(Comic Creator の OCR タブから汎用部分を移植)。実測(日英混在・縦書きあり): LightOnOCR-3 4b 1.1秒(位置付き7.9秒)100%、PaddleOCR-VL-manga 3.9秒100%、unlimited-ocr 14秒100%、PaddleOCR-VL 1.6 / GLM-OCR は縦書きを読み落とし94%。
|
||||
- 位置の表示(LightOnOCR / DeepSeek-OCR)、枠クリックで行選択、「翻訳へ送る」「Chatへ送る」。Translation / Settings ペインを細い帯にたためる。
|
||||
|
||||
### 修正: 非推奨の import(GitHub issue #3)
|
||||
|
||||
- `top_menu_extension.js` の `/scripts/ui/components/button.js`・`buttonGroup.js` の import は、a6f45ef で actionBarButtons に移行して以来呼ばれていない旧メニュー用コードだった。`window.comfyAPI` への置き換えではなく削除。`web/comfyui/*.bak` も削除。
|
||||
|
||||
### ドキュメント
|
||||
|
||||
- ヘルプ(AI TOOL を12カードに再編、Lab に Storyboard)、マニュアル v0.9.5(v0.9.2〜v0.9.4 の未反映分も追記)、README の TagSpace スクリーンショット、Registry アイコン用に `docs/thumb.png` を 800x400 に縮小。
|
||||
|
||||
**検証**: 各機能を ComfyUI_5(:8189)で実機確認(Chrome / Playwright)。単体テスト: スキルサービス35件、JS(解析・チェック・Markdown・OCR・文脈)約50件。
|
||||
|
||||
**未確認**: サイドパネル(A タブ)はスキル本文のみ対応(metadata・ボタン・チェックは SPA のみ)。判定の閾値は画像約5枚から決めた値。OpenAI 互換バックエンドでの `read_skill_file` 往復。
|
||||
|
||||
---
|
||||
|
||||
## v0.9.4(2026-11-07)
|
||||
|
||||
### Gallery: 3Dモデル対応(GLB / glTF / VRM / PLY / SPZ)
|
||||
|
||||
- 新規 `static/js/gallery-3d-viewer.js`: three.jsのビューア。詳細パネル・ライトボックス・比較で開き、枠がDOMから外れると自分で破棄する(WebGLコンテキストを解放)。
|
||||
- 新規 `static/lib/three-viewer/`: three 0.187 / @pixiv/three-vrm 3.5.5 / @sparkjsdev/spark 2.4.0 のバンドル。Spark(2.8MB)は別チャンクで、スプラットを開くときだけ読む。再ビルド手順は同フォルダのREADME.txt。
|
||||
- サムネイル: サーバーに3Dレンダラーが無いため、`/thumb` の404を合図にブラウザが描画し `/wfm/gallery/image/thumb3d` へ保存する。1件ずつ処理し、128MB超は開いたときに保存する。
|
||||
- `.gltf` の外部ファイルは `/wfm/gallery/model3d/asset` で配信する(.bin/画像のみ、許可ルート内のみ)。
|
||||
- PLYは `f_dc_0`/`scale_0` の有無でスプラットを判定する。`double` 属性はFloat64ArrayになりWebGLが拒否するのでFloat32へ変換する。
|
||||
- `asset.extras` からSaveGLBのprompt/workflowを読む(GLBはJSONチャンクのみ読む)。
|
||||
- 3DファイルはImage Edit / GenerateUI / Send to LI node / Tagger / Feederへ送れないようにした。
|
||||
|
||||
### P-H3 Image対応
|
||||
|
||||
- `PH3ImageConditioning` をGenerateUI/Metadata/Iタブに追加(`TextEncodeFibo2` と同じ扱い、negativeなし)。`_unifyPromptEnhancerText` は `TextGenerateV2` も対象にした。
|
||||
|
||||
### 修正: TextGenerate系の値ずれ
|
||||
|
||||
- `_normalizeLinkedWidgetValues` が、リンク済みpromptの空枠を持つ形式を「枠なし形式」と誤判定していた。値が1つずれても、無効なDynamicComboキーがoptions[0]へフォールバックして枠数が偶然一致するため。Krea-2 / Ernie / LTX-2.5 などでも `sampling_mode:2048` のようにずれていた。選択キーの有効性も判定に加えた。
|
||||
|
||||
**検証**: 3Dは実サンプルで描画・自動サムネイル・詳細・拡大・破棄・送信ガード(Image Editボタン)を確認(SPZとスプラットPLYは自作サンプル)。パストラバーサルは全て404。変換は手元の640ワークフローを修正前後で比較し、差分21件は全てTextGenerate系。うち5種類は `widgets_values_named` 等で正しい値と確認した。P-H3は編集(1024x1024、約60秒)とT2I(3360x2496、約40秒)をGenerateUIから実生成(RTX 4070 12GB、モデルロード済み)。
|
||||
|
||||
**未確認**: 比較表示、テーブル表示のサムネイル、128MB超のファイル、実データのスプラット、サイドパネルIタブのP-H3、Draco/Meshopt圧縮のモデル。
|
||||
|
||||
**補足**: KTX2圧縮テクスチャは未対応。`.gltf` を移動・削除しても外部ファイルは追従しない。GenerateUIにプロンプト強化のオン/オフ切り替えは無い。
|
||||
|
||||
---
|
||||
|
||||
## v0.9.3(2026-11-05)
|
||||
|
||||
### Image Edit: Drawツールのカラーパレット・ショートカット・スポイト(Comic Creator v1.67.0 から移植)
|
||||
|
||||
- 新規 `static/js/image-edit/DrawPalette.js`: Drawツールの2段目(`ie-tool-options`)に色1/色2、基本色20色、カスタム色20枠を表示する。右クリックで現在の色の登録・色の編集・削除(カスタム色)・初期の色に戻す(変更済みの基本色)。保存先は localStorage `ie_draw_palette_v1`。
|
||||
- `image-edit-tab.js`: 色の変更を `_setDrawColor` に集約し、モードは `_setDrawMode`、サイズは `_setDrawBrushSize` / `_stepDrawBrushSize` に分離。
|
||||
- ショートカット(Draw中のみ): `D` Draw / `E` Erase / `X` 色1・色2切替 / `[` `]` サイズ増減(1〜10pxは1、10〜50pxは5、50〜200pxは10刻み)/ `B` ブラシ選択の開閉。ブラシ選択の表示中は D/E/X/[/] を無効にし、Escで閉じる。他のツールからのBはこれまでどおりDrawへの切り替え。
|
||||
- スポイト: WFSのDrawツールには無かったためCC版から追加。右パネルのColorの隣のボタンからキャンバスの色を拾う。Esc・再クリック・ツール切替・キャンバス外クリック・Closeで解除。
|
||||
- ブラシの初期色を赤から黒に変更(色1/色2 = 黒/白)。
|
||||
|
||||
**CC版との違い**
|
||||
|
||||
- 色の引き継ぎは、Drawツールの表示時ではなく `_initCanvases` で `DrawTool` を作り直す時点で行う。
|
||||
- スポイトは `ie-canvas-draw` から色を読むため、アクティブレイヤーのみの表示なら合成し直してから読む。
|
||||
- 右クリックメニューを開いたままツールを切り替えても残らないよう、`_renderToolOptions` で閉じる。
|
||||
|
||||
**検証(Playwright、ComfyUI :8189)**: パレットからの描画色、登録と再読み込み後の保持、重複登録の通知、右クリックメニュー、基本色の復元、Color入力との連動、各ショートカット、入力欄やCtrl/Alt同時押しで反応しないこと、サイズの刻み、ブラシ選択の開閉、ブラシ未登録時の通知、Newでの色の引き継ぎ、スポイトの取得と解除、Undo履歴が増えないこと、別レイヤーの色の取得を確認。コンソールエラーなし。
|
||||
|
||||
**未確認**: 「色を編集…」で開くブラウザ標準のカラーピッカーの表示と、日本語キーボードでの `[` `]` の実打鍵。
|
||||
|
||||
**補足**: 透明部分をスポイトすると黒を拾う(CCと同じ)。描いた線から拾うと値が1ずれることがある。READMEのバージョンバッジが0.8.11のままだったので0.9.3に更新した。
|
||||
|
||||
---
|
||||
|
||||
## v0.9.2(2026-11-03)
|
||||
|
||||
### Selectorイベントループ有効時に画像が壊れて届く不具合を修正
|
||||
|
||||
- 症状: 設定「Selectorイベントループ」(`windows_selector_event_loop`)が有効だと、ギャラリーのプレビューと Download ボタンで取得した画像が壊れる(元ファイルと同サイズのまま、32KB 分のブロックが直後のブロックの内容に置き換わる)。出力フォルダのファイル自体は正常。
|
||||
- 原因: Selector 方式では aiohttp の `FileResponse` が asyncio の `_sendfile_fallback` を通る。これは 16KB のバッファを使い回しながら、送り切れなかった分をトランスポートにコピーせず積むため(Python 3.12)、送信が詰まると次の読み込みで上書きされる。ギャラリーは多数の画像を同時に読むので詰まりやすい。
|
||||
- 修正: `prestartup_script.py` で Selector に切り替えるとき `AIOHTTP_NOSENDFILE=1` も設定し、aiohttp がチャンクごとに新しい bytes を読んで送る経路を使うようにした(aiohttp 読み込み済みの場合はモジュール定数も上書き)。プロセス全体に効くので ComfyUI 本体の `/view` なども対象。
|
||||
- 検証: 受信を遅くしたクライアントで、修正前は 5/5 回破損、修正後は 5/5 回ハッシュ一致。再起動後の実サーバー(ギャラリー配信 URL)でも 5/5 回一致。
|
||||
- 壊れた応答は `Cache-Control: max-age=3600` でブラウザに残ることがあるので、更新後は再読み込み(Ctrl+F5)が必要。
|
||||
|
||||
---
|
||||
|
||||
## v0.9.1(2026-11-03)
|
||||
|
||||
### TagSpace: 選んだタグだけを追加 / タグのカッコをエスケープして追加
|
||||
|
||||
- 選択中パネルでタグを1つ以上選んでいると、「追加」ボタンが「選択したタグを追加(N)」になり、選んだタグだけ(プロンプト中の順)を1つのチップ(名前は「rapunzel(2タグ)」)として追加する。選んでいなければ従来どおり全体(カードはプロンプト全文、Gallery点は全タグ)。3D画面のダブルクリックと一覧の+は常に全体。
|
||||
- 不具合修正: タグは分解時にエスケープを外している(`rapunzel \(disney\)` → `rapunzel (disney)`)ため、タグ・Gallery点のチップをそのままプロンプトに入れると `(disney)` が強調指定になっていた。プロンプトに入れるときにカッコを `\(` `\)` に戻す(表示はそのまま)。カード全文の `\(...\)` と同じ形になるので、重複除去でも1つにまとまる。
|
||||
- 検証(Playwright): rapunzel カードで未選択の「追加」→全文、2つ選んで「選択したタグを追加(2)」→ `rapunzel \(disney\), braided ponytail`、一覧の+で同じタグを追加しても重複せず。
|
||||
- ヘルプ(tagspace-10 英/日/中)、README、マニュアル第11章 11-7(日英、全章まとめ・ZIP 再作成)。
|
||||
|
||||
### TagSpace: Gallery点から「Galleryで開く」
|
||||
|
||||
- 選択中パネル(Gallery点)に「Galleryで開く」ボタン。`gallery-tab.js` に `revealInGallery(path)` を新設(export): ギャラリータブ/Output サブタブへ切り替え(初回はここで出力フォルダとツリーを読み込むので `state.folderTree` を待つ)→ 絞り込み(検索・お気に入り・タグ・グループ・類似表示)を「Clear filters」と同じ要領で解除 → ツリーを根から目的のフォルダまで矢印クリックで展開(子は展開時に描画される遅延描画のため)→ フォルダを選んで `await loadImages()` → 一覧で `[data-path]` の要素をクリック(通常クリックと同じ選択・詳細表示)して中央へスクロール。
|
||||
- サムネイル表示は50件ずつ描くので、目的の画像まで `_appendNextPage` で描き足し、無限スクロールの目印を末尾へ戻す(全件描いたら外す)。パスは `\` / 大文字小文字の違いを無視して比較。出力フォルダ外・一覧に無い画像は false を返し、TagSpace 側でトーストを出す。
|
||||
- 検証(Playwright): 検索語を入れた状態から TagSpace の Gallery 点で押す → 0.3秒でギャラリーに切り替わり、ツリー「2026-06-07」選択・一覧で目的の画像が選択されて画面内・検索欄クリア・詳細表示。456枚のフォルダの453枚目でも 0.6秒で表示(全件描画・目印も解除)。出力フォルダ外/存在しないパスは false。コンソールエラーなし。
|
||||
- ヘルプ(tagspace-10 英/日/中)、README、マニュアル第11章 11-7(日英、全章まとめ・ZIP 再作成)。
|
||||
|
||||
---
|
||||
|
||||
## v0.9.0(2026-11-03)
|
||||
|
||||
### マニュアル(画像なし配布版)を v0.9.0 に改訂
|
||||
|
||||
+12
-10
@@ -27,7 +27,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- Built-in AI tools (translation and more)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Features (tab -> feature names)
|
||||
|
||||
@@ -38,13 +38,13 @@ Thumbnail / Table views, Thumbnail side panel, Badge filtering, Search, Side pan
|
||||
One-click capture, Auto-save as thumbnail, Embedded workflow metadata, Auto-import
|
||||
|
||||
### GenerateUI Tab (v0.3.5)
|
||||
6-tab layout, Save button, Input tab, Multi-image I2I for Comic Creator, Model tab, Settings tab, Always-visible Raw JSON, Bypass/Mute node handling, One-click generation, Seed control, Style selector, Create Catalog / Catalog buttons, Batch type selector, Batch tab, UI-to-API conversion, Video workflow support, Eagle integration, Qwen Image 2.1 support, Ming Image support
|
||||
6-tab layout, Save button, Input tab, Multi-image I2I for Comic Creator, Model tab, Settings tab, Always-visible Raw JSON, Bypass/Mute node handling, One-click generation, Seed control, Style selector, Create Catalog / Catalog buttons, Batch type selector, Batch tab, UI-to-API conversion, Video workflow support, Eagle integration, Qwen Image 2.1 support, Ming Image support, P-H3 Image support, MiniMax-H3-Image support
|
||||
|
||||
### Feeder subtab (v0.3.5 / v0.3.42)
|
||||
ImageFeeder node control, Image library, Selection management, Presets, Continuous Run loop, Index sync, Seed, WFS_GalleryFeeder node, Node & group selector, Image grid, After Gen modes, Run / Stop controls
|
||||
|
||||
### Lab subtab (v0.3.87 – v0.5.2)
|
||||
Setting / Results / Plan JSON sub-panels, Per-column keyframes, Model column — Checkpoint / LoRA Single / VAE, Empty state when no matching node exists, Wildcards, Prompt cell extras, Keyframe #1 live-reflect, Bypass checkbox, LoRA node bypass & prompt injection, T2I workflow support, Use generated image for next, Workflow recall, Plan files, Plan Load, Plan JSON tab, Save index image to Output on Run, Index images are self-contained plan files, Eagle integration, Select vs. edit, and keyframe reordering, PG — apply a Prompt tab preset group, Video workflow support, Use generated image for next, now video-aware
|
||||
### Lab subtab (v0.3.87 – v0.9.5)
|
||||
Setting / Results / Plan JSON sub-panels, Per-column keyframes, Model column — Checkpoint / LoRA Single / VAE, Empty state when no matching node exists, Wildcards, Prompt cell extras, Keyframe #1 live-reflect, Bypass checkbox, LoRA node bypass & prompt injection, T2I workflow support, Use generated image for next, Workflow recall, Plan files, Plan Load, Plan JSON tab, Save index image to Output on Run, Index images are self-contained plan files, Eagle integration, Select vs. edit, and keyframe reordering, PG — apply a Prompt tab preset group, Video workflow support, Use generated image for next, now video-aware, Plans from a story
|
||||
|
||||
### Prompt Tab
|
||||
3-column layout, AI chat assistant, Image attachment, Translation, Prompt presets, Preset Manager, Group management, Clipboard copy, GenUI Set, Wildcard/Style tab bar, Wildcard input toolbar, Wildcard file manager, Style manager, Form / Table toggle, Table view — Presets / Presets Group / Wildcards / Style
|
||||
@@ -53,7 +53,7 @@ Setting / Results / Plan JSON sub-panels, Per-column keyframes, Model column —
|
||||
2-column layout, Collapsible sections, Theme selection, Theme customization, Workflows directory, Gallery output directory, Eagle connection, CivitAI Host, CivitAI API Key, Default workflow, Default Checkpoint, VRAM Management (Ollama), Video Playback Volume, Data Management, Text Size, RAW JSON Colors, GenerateUI Model Tab Highlight, Wildcard Integration, G'MIC-Qt Integration, Language
|
||||
|
||||
### Gallery Tab (v0.3.44)
|
||||
Output / ImagePrompt / Style_Catalog / Metadata sub-tabs, Image browser, Folder tree root label, Thumbnail / Table views, Folder management, File operations, Download, MP4 video support, MP4 embedded metadata, WebM support, Embedded metadata reading fixes, Audio support, File conversion, Move To folder filter, Semantic Search, Audio waveform colors, Info tab: dimensions & duration, Multi-select, Image Compare, Prompt search, Server-side filtering, Group management, Thumbnail F button, Favorites, Detail panel, Prompt tab, Nanobanana tab, Gemini / Nano Banana nodes, MOSS-Image in GenerateUI, Faster GenerateUI Check / conversion, Partner API nodes in GenerateUI, Workflow viewer, GenUI button, Image Edit button, Send GenUI Image button, Send to LI node button, Send to LV node button, Send CC Layout / Send CC Image buttons, Search clear (✕), Clear all filters (✕ Clear), Workflow auto-save, Output folder configurable, SVG file support, PSD file support, Performance
|
||||
Output / ImagePrompt / Style_Catalog / Metadata sub-tabs, Image browser, Folder tree root label, Thumbnail / Table views, Folder management, File operations, Download, MP4 video support, MP4 embedded metadata, WebM support, Embedded metadata reading fixes, Audio support, File conversion, Move To folder filter, Semantic Search, Audio waveform colors, Info tab: dimensions & duration, Multi-select, Image Compare, Prompt search, Server-side filtering, Group management, Thumbnail F button, Favorites, Detail panel, Prompt tab, Nanobanana tab, Gemini / Nano Banana nodes, MOSS-Image in GenerateUI, Faster GenerateUI Check / conversion, Partner API nodes in GenerateUI, Workflow viewer, GenUI button, Image Edit button, Send GenUI Image button, Send to LI node button, Send to LV node button, Send CC Layout / Send CC Image buttons, Search clear (✕), Clear all filters (✕ Clear), Workflow auto-save, Output folder configurable, SVG file support, PSD file support, 3D model support, Performance
|
||||
|
||||
### ImagePrompt Gallery subtab (v0.3.94, renamed from Style/Prompt in v0.3.95)
|
||||
Visual prompt library, 3-column layout, Prompt builder (right pane), Plain-text prompt storage, ponyxlWildcardsVault format support, Seed data importer
|
||||
@@ -82,8 +82,8 @@ Layer-based image editor, Loading images, Open PSD button, Close button, New but
|
||||
### Video Tab (v0.5.0 – v0.7.4)
|
||||
Dedicated video generation UI, independent of GenerateUI, Plan subtab, Base settings row, Edit subtab, Edit: Multi-track, Edit: Audio / BGM, Edit: Text overlays, Edit: Crop, Edit: Convert audio, Edit: Undo / Redo, Edit project persistence, Asset subtab, Project subtab, Two center previews, Register as Asset, First Frame / Last Frame are both optional, Semantic node lookup, not hardcoded IDs, Field highlight color, Load in Video, Frame tab, GIF tab, Playback volume
|
||||
|
||||
### AI TOOL Tab (v0.3.14)
|
||||
4-pane layout, Translation pane, Chat pane, Chat pane — image generation via Tool Calling, Chat pane — generate_image extra parameters, TOOLS pane (VLM), TOOLS pane — shared Chat attachment, TOOLS pane (Wildcards), Chat pane — image-to-image (I2I), Chat pane — SVG generation, no ComfyUI workflow involved, Chat pane — Skills, Settings pane, Unsloth backend, Decision API client, Settings pane — Chat Image Generation, Settings pane — Chat I2I Generation, Settings pane — Generation, Model unload, Settings shared, Backend support, URL security, Lemonade's other endpoints (not integrated)
|
||||
### AI TOOL Tab (v0.3.14 – v0.9.5)
|
||||
4-pane layout, Translation pane, Chat pane, Chat pane — image generation via Tool Calling, Chat pane — generate_image extra parameters, TOOLS pane (VLM), TOOLS pane — shared Chat attachment, TOOLS pane (Wildcards), Chat pane — image-to-image (I2I), Chat pane — SVG generation, no ComfyUI workflow involved, Chat pane — Skills, Chat pane — Skills v2, Built-in skills, Chat pane — Image check & refine loop, Chat pane — Workflow Doctor, Chat pane — Storyboard to Lab, Chat pane — Agent Skills folders, TOOLS pane — OCR, Collapsible Translation / Settings panes, Settings pane, Unsloth backend, Decision API client, Settings pane — Chat Image Generation, Settings pane — Chat I2I Generation, Settings pane — Generation, Model unload, Settings shared, Backend support, URL security, Lemonade's other endpoints (not integrated)
|
||||
|
||||
### Workflow Studio Library (ComfyUI Side Panel) (v0.3.9)
|
||||
Tab layout (W / N / P / M / I / A), W — Workflows tab, N — Nodes tab, M — Models tab, P — Prompts tab, I — Information tab, A — AI TOOL tab, Drag & drop workflows, Drag & drop nodes, Drag & drop prompts, Send to Canvas, Copy prompts, Double-click, Search, ⚙ Theme settings
|
||||
@@ -128,10 +128,12 @@ Samples for the Feeder tab: `ws_sdxl_image_feeder_i2i` / `ws_dwpose_image_feeder
|
||||
|
||||
## Manual
|
||||
|
||||
A beginner-oriented manual (covers v0.9.0, text-only edition without screenshots) is available in [`docs/manual/`](docs/manual/):
|
||||
A beginner-oriented manual (covers v0.9.6, text-only edition without screenshots) is available in [`docs/manual/`](docs/manual/):
|
||||
|
||||
- English: [`docs/manual/en/`](docs/manual/en/) — ZIP: `Workflow_Studio_Manual_v0.9.0_en.zip`
|
||||
- 日本語: [`docs/manual/`](docs/manual/) — ZIP: `Workflow_Studio_Manual_v0.9.0.zip`
|
||||
- English: [`docs/manual/en/`](docs/manual/en/) — ZIP: `Workflow_Studio_Manual_v0.9.6_en.zip`
|
||||
- 日本語: [`docs/manual/`](docs/manual/) — ZIP: `Workflow_Studio_Manual_v0.9.6.zip`
|
||||
|
||||
An EPUB edition with screenshots (Japanese and English, v0.9.6) is available on [Google Drive](https://drive.google.com/drive/folders/14mOHcnLYrFgNrOo6fy7ELyFjjWrAoU-_?usp=sharing).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- Built-in AI tools (translation and more)
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -55,9 +55,9 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
| :--------------------------------: | :------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Gallery Tab - Semantic Search ("sunset city street") |
|
||||
| :-----------------------------------------------------------------: |
|
||||
|  |
|
||||
| Gallery Tab - Semantic Search ("sunset city street") | TagSpace Tab - Search ("sunset city street") |
|
||||
| :-----------------------------------------------------------------: | :-----------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
@@ -133,6 +133,8 @@ A comprehensive workflow, asset management, and generation UI plugin for [ComfyU
|
||||
- **Eagle integration** — auto-save generated images to [Eagle](https://eagle.cool/) with metadata
|
||||
- **Qwen Image 2.1 support** (v0.7.3) — `TextEncodeQwenImage21` (the unified T2I/Edit encoder used by Comfy-Org's Qwen-Image-2.1 workflow templates) is now recognized: its `prompt`/`negative_prompt` widgets populate the Input tab's Positive/Negative Prompt textareas and are editable like any other prompt node; its `resolution` (a pixel-budget int, not width/height) is exposed for Raw JSON editing. No extra work was needed for the model's up to 10 reference images (`image_1`…`image_10`, used for image editing) — each one arrives as an ordinary LoadImage node, so it already gets its own card in the Input tab's Image sub-panel via the existing generic LoadImage detection
|
||||
- **Ming Image support** (v0.7.5) — `TextEncodeMingImageEdit` (single `prompt` widget, up to 8 optional `images.image_N` reference inputs; role taken from the sampler/guider wiring, so the empty-prompt negative instance in layer-decomposition workflows shows as Negative) and `EmptyQwenImageLayeredLatentImage` (Latent panel; `layers` kept as-is) are recognized in GenerateUI, and Gallery/Metadata/sidepanel I tab now show their prompts as Positive. The Sampler field is disabled (N/A) when the SAMPLER input comes from a node without `sampler_name` (e.g. `SamplerLCM`). **Fix:** top-level nodes whose widgets are converted to wired inputs (e.g. `EmptyLatentImage` width/height fed by `GetImageSize`) keep those values in `widgets_values`; they were not stripped outside subgraphs, shifting later values (`batch_size` became `1024`, KSampler `steps`/`cfg` misread). Also: `BasicGuider`'s `conditioning` is treated as positive, and a linked text input wins over the stale widget text in UI-format metadata
|
||||
- **P-H3 Image support** (v0.9.4) — the `PH3ImageConditioning` templates (Image Edit and T2I) load and run from GenerateUI, with the prompt shown on the Input tab and read back by the Metadata tab. Width and height follow the workflow's wiring, and the prompt upsampler runs as set in the workflow
|
||||
- **MiniMax-H3-Image support** (v0.9.6) — the `MiniMaxH3ImageConditioning` templates (Image Edit and T2I) load and run from GenerateUI the same way, with the prompt shown on the Input tab and read back by the Metadata tab and the sidepanel I tab
|
||||
|
||||
</details>
|
||||
|
||||
@@ -165,7 +167,7 @@ Two independent modes selectable via **[Image Loop] / [Gallery]** toggle buttons
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>Lab subtab (v0.3.87 – v0.5.2)</h3></summary>
|
||||
<summary><h3>Lab subtab (v0.3.87 – v0.9.5)</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).
|
||||
|
||||
@@ -191,6 +193,7 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **PG — apply a Prompt tab preset group** (v0.4.8) — a **PG** button on the Prompt column header opens a picker for the Prompt tab's Table-view preset groups (the same groups the Batch tab's checklist uses); choosing one replaces the entire Prompt column with one keyframe per preset in that group's order, starting at #1 (#1 is overwritten, not appended after). If the group has more presets than the current Batch count, Batch count is raised automatically to fit (capped at 50, the Batch input's own limit); anything still beyond that shows the existing "keyframes beyond Batch count" warning instead of being dropped
|
||||
- **Video workflow support** (v0.5.2) — loading a MiniMax H3 (or other all-in-one video-generation) workflow into GenerateUI and running Lab now works end to end: the Prompt column writes to its `prompt` widget, the KSampler column auto-hides (same "no matching node" treatment as Model sub-columns) since the sampler wired internally is a fixed part of the model's own preset rather than something meant to be tuned per keyframe, the Results grid plays mp4 results inline, and the contact-sheet index image draws each video result's last frame instead of failing silently onto a black square
|
||||
- **Use generated image for next, now video-aware** (v0.5.2) — when a Run's output is a video, the "Use generated image for next" chain extracts its last frame client-side (canvas) and uploads it as the next iteration's source image, instead of trying to feed the raw mp4 into a LoadImage node
|
||||
- **Plans from a story** (v0.9.5) — AI TOOL Chat's **Storyboard to Lab** skill loads a ready plan here via **Open in Lab**; KSampler cells now show every value that is set (Step / CFG / Denoise / sampler / scheduler / Seed) instead of "(workflow default)" when only the seed or sampler is set
|
||||
|
||||
</details>
|
||||
|
||||
@@ -309,6 +312,7 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **Output folder configurable** — set the scanned output folder from Settings tab
|
||||
- **SVG file support** (v0.3.77) — `.svg` files are browsable, previewable (detail panel and lightbox), and included in favorites/tags/groups/move/delete/export alongside raster formats; served as-is with no rasterized thumbnail, since `<img>` scales vector graphics natively at any size
|
||||
- **PSD file support** (v0.6.0) — `.psd` files are browsable alongside other formats; thumbnails and full-size previews show every layer merged together (hidden layers excluded), rendered server-side via `psd-tools` and disk-cached; the underlying PSD file itself is never modified — only the Gallery preview is a flattened rendering
|
||||
- **3D model support** (v0.9.4) — `.glb` / `.gltf` / `.vrm` / `.ply` (mesh, point cloud or Gaussian splat) / `.spz` files are browsable alongside images with a **3D** badge and open in a built-in viewer in the detail panel, lightbox and compare view (drag to rotate, right-drag to pan, wheel to zoom). Thumbnails are rendered by your browser the first time a file appears and then kept on the server (files over 128 MB get one when opened). Workflows saved by ComfyUI's SaveGLB are read like an image's. A `.gltf` using separate `.bin` or texture files needs them to stay in place: Move and Delete only affect the `.gltf` itself. KTX2-compressed textures are not supported
|
||||
- **Performance** — server-side 256px JPEG thumbnail generation with disk cache (`data/thumb_cache/`); infinite-scroll paging (50 images per page, IntersectionObserver); folder-level mtime cache (60s TTL); bulk operations use single-request API endpoints
|
||||
|
||||
</details>
|
||||
@@ -423,7 +427,7 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **Build / Update** — collects the cards, adds card images that are not yet indexed to the Gallery semantic index, reads the positive prompt embedded in each indexed Gallery image (followed upstream from the sampler's `positive` input, never the negative; cached in `tagspace_prompts.json`, deleted images are skipped), embeds new tags with Ollama (cached in `tagspace_tags.npz`) and projects everything to 3D with PCA. Prompts are split only at commas (and `|` `{}` `BREAK`), not at spaces; phrases over 5 words are dropped. **Tags** sets how many tags are kept — each source is ranked by how many prompts use a tag and the sources take turns, so the many Gallery prompts do not crowd out the others; **Gallery points** sets how many Gallery images are shown. Images and text form two separate clusters in the same space (modality gap), so each is centered on its own mean (images: the cards) before PCA, which makes cards and their matching tags overlap. First build: about 2.5 min for ~1,000 cards plus about 2 min to read the prompts of 29,000 Gallery images (64% had a readable positive prompt); rebuilds with caches: about 15 s
|
||||
- **Choosing tags** — **All tags** uses every candidate tag instead of the Tags number (up to 20,000); **Min uses** drops tags used in fewer prompts than that (all sources combined); **Wildcards** turns wildcard files on or off as a tag source. Words found only in wildcards are most of the distinct tags (about 37,000 of 47,600 in testing), so All tags without wildcards gives about 10,400 tags (about 6,800 with Min uses 2); ~32,000 points still render at 60 fps. Wildcard YAML is read with a YAML parser, so multi-line (`- >-`) items are included; JSON fragments embedded in prompts (e.g. MOSS-Image bbox layouts) and model file names are not turned into tags
|
||||
- **Explore** — drag to rotate, right-drag to pan, wheel to zoom; hover shows name, thumbnail and prompt/sources; layer checkboxes for Cards / Thumbnails / Tags / Gallery, and source checkboxes (ImagePrompt / Gallery prompts / Tagger DB / Prompt library / Wildcards) that show or hide tags by origin — a tag stays visible while any of its sources is on, and hidden tags are left out of the nearby/search lists. The search box (Enter, any language) places a marker for the text and lists the nearest items
|
||||
- **Search with a card's or Gallery image's tags** — a selected card or Gallery point lists its prompt tags as buttons; pick some and search the space with them (joined in prompt order)
|
||||
- **Search with a card's or Gallery image's tags** — a selected card or Gallery point lists its prompt tags as buttons; pick some and search the space with them (joined in prompt order); while tags are picked, the Add button becomes "Add selected (N)" and adds only those tags. A selected Gallery point also has **Open in Gallery**, which opens its folder in the Gallery tab with the image selected (filters are cleared)
|
||||
- **Make a prompt** — click a card or tag to select it and list nearby items (clicking does not change the prompt); double-click it, press **Add** next to its name under Selection, or press **+** in the list to add it (again to remove); cards and tags are ranked separately and interleaved, so a card also shows its closest tags. Adding a Gallery point adds all tags from that image's prompt as one chip (images without readable tags cannot be added); clicking one lists similar Gallery images together with nearby tags and cards (cards, tags and searches never list Gallery images). The prompt joins the chips with duplicates removed; **Add to GenerateUI (Positive)** appends it to the loaded workflow's positive prompt, **Copy** puts it on the clipboard
|
||||
- **Tagger fill** — Gallery images without a readable prompt (about a third) can be tagged with a Tagger model (ONNX models from the Tagger tab) during Build / Update — either only the sampled Gallery points or every indexed image without prompt tags. Default thresholds, rating labels dropped, results cached per image and model in `tagspace_tagger.json` (stopping keeps finished images). The tags form the **Tagger fill** source and become the tags of those Gallery points. The status shows CUDA/CPU: on the CPU it is about 0.5 s per image, so make sure `onnxruntime-gpu` is active before tagging everything
|
||||
- **Glow / Twinkle** — points are drawn as glowing stars on a night-sky background (Gallery points dimmed so dense areas don't wash out); **Twinkle** makes tags, cards and Gallery points twinkle slowly with individual rhythms (selection/nearby highlights stay steady). Both are toolbar toggles; turning Glow off restores the plain dots
|
||||
@@ -442,7 +446,7 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **New button** — create a blank canvas with custom dimensions (WxH prompt); clears all existing layers and automatically adds an empty Layer 1 (v0.3.70)
|
||||
- **Tools** — Select (V), Draw (B), Text (T), Shape (S), Fill (G), Mask (🎭), Blur (≈), BG Remove (⬚), Filter (★), Inpaint (🩹); tool options bar updates per active tool
|
||||
- **Select** — click to select; drag to move; drag corner handles to resize; drag circle handle to rotate; Flip H / Flip V / Rotate angle in the options bar; double-click a text layer to re-edit its content
|
||||
- **Draw** — freehand brush; options: color, brush size, blend mode; paints directly onto the active draw layer while all other layers remain visible; brush cursor shown as a size-accurate circle; strokes can start from outside the canvas margin and continue past the edge; **pen tablet support** (v0.6.2) — brush size and opacity respond to pen pressure (mouse behaves exactly as before); the canvas listens for Pointer Events so pen, touch, and mouse all work through the same code path
|
||||
- **Draw** — freehand brush; options: color, brush size, blend mode; paints directly onto the active draw layer while all other layers remain visible; brush cursor shown as a size-accurate circle; strokes can start from outside the canvas margin and continue past the edge; **pen tablet support** (v0.6.2) — brush size and opacity respond to pen pressure (mouse behaves exactly as before); the canvas listens for Pointer Events so pen, touch, and mouse all work through the same code path; **color palette** on the second row — Color 1/Color 2, 20 basic colors, and 20 custom slots (registered and edited colors are saved in the browser); **eyedropper** button next to Color picks a color from the canvas (`Esc` cancels); shortcuts while Draw is active: `D` Draw, `E` Erase, `X` switch Color 1/2, `[` `]` brush size, `B` open or close the brush picker (`Esc` also closes it)
|
||||
- **Text** — click to place; configure font, size, bold/italic, align, and color; placed as an exact-size text layer sized to the measured bounding box; double-click to re-edit
|
||||
- **Shape** — drag to draw geometric shapes (Rect / Ellipse / Line / FreeLine); options: shape type, Rounded toggle (Rect/Ellipse), Fill color, Stroke color + width, Opacity; each committed shape becomes an independent draw layer; Stroke None hidden for Line/FreeLine (stroke always active)
|
||||
- **Fill** (v0.6.2) — bucket fill; click a contiguous same-color region on the active layer to flood-fill it (scanline-based, ported from the Comic Creator plugin's Image tab); **Properties pane**: Mode (Solid / Gradient) — Solid: Color picker; Gradient: Shape (Linear / Radial), a draggable color-stop Ramp (click a stop to select it, drag to reposition, +/- to add/remove stops, color swatch to edit the selected stop's color), and a Direction dial (drag to set angle/radius); both Tolerance (0–255, color-match threshold) and Opacity apply to either mode; Ramp and Direction dial are pointer-event based with pointer capture, so pen tablets work the same as mouse
|
||||
@@ -490,7 +494,7 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h3>AI TOOL Tab (v0.3.14)</h3></summary>
|
||||
<summary><h3>AI TOOL Tab (v0.3.14 – v0.9.5)</h3></summary>
|
||||
|
||||
- **4-pane layout** — Translation | Chat | TOOLS | Settings; all panes always visible simultaneously; no sub-tab switching required
|
||||
- **Translation pane** — translate text between Japanese, English, Chinese, or a custom Free language using Ollama, LM Studio, Lemonade, or Unsloth; language selectors with ⇄ swap button (swaps both language selectors and text content); selections saved automatically
|
||||
@@ -504,6 +508,14 @@ An experimental batch generator: runs the workflow currently loaded in GenerateU
|
||||
- **Chat pane — image-to-image (I2I)** (v0.3.74) — when an image is attached and `generate_image` is invoked, the attachment is uploaded and swapped into the target workflow's LoadImage node instead of running text-to-image; the target workflow is the one currently loaded in GenerateUI, or a dedicated I2I workflow configured in the Settings pane
|
||||
- **Chat pane — SVG generation, no ComfyUI workflow involved** (v0.3.77) — the LLM can also produce SVG graphics directly as text; if a reply contains SVG code (wrapped in a markdown code fence or written directly as a bare `<svg>...</svg>`), it's rendered inline as a preview and a "Save to Gallery" button writes it as a `.svg` file to the Gallery tab's output folder; unlike the Tool Calling image generation flow above, this is plain text pattern matching, so it works with any model regardless of tool-calling support
|
||||
- **Chat pane — Skills** (v0.3.79) — select a `.md` skill file from a dropdown above the message list to prepend its instructions to the conversation as a system prompt (not shown in the visible chat log); a pencil button opens an in-pane manager (list + inline editor, same pattern as the Prompt tab's wildcard file manager) to create, edit, or delete skill files stored in `user/default/Workflow-Studio/ai_skills/`; a file's `---`-delimited frontmatter (`name`/`description`) is shown in the dropdown; two starter skills are bundled — **SVG Icon Generator** (explicitly decomposes the subject into parts before drawing, which markedly improves recognizability from small local models) and **Skill Creator** (interviews you conversationally and drafts a new skill as a ```skill code block; a "Save as new skill" button appears under such replies and opens the manager pre-filled with the draft)
|
||||
- **Chat pane — Skills v2** (v0.9.5) — skills keep the portable `name`/`description` frontmatter (Agent Skills / SKILL.md style) and add optional `metadata:` keys: `output` (`prompt` or `svg` — the reply is checked and, if the model tried to output one but it is broken, it is asked once to fix it), `tools` (`[]` = never start image generation), `temperature`, `think`, and `context` (`workflow` summary, `current-prompt`, `workflow-detail`, `logs`). Six built-in skills ship in `py/services/builtin_skills/` with a version number: untouched copies are upgraded automatically, edited ones keep your text and show a ↻ button. A ```` ```prompt ```` block in a reply gets **Apply to GenerateUI** / **Generate with this prompt** / **Copy** buttons, and replies are rendered as Markdown (HTML-escaped first)
|
||||
- **Built-in skills** (v0.9.5) — **T2I Prompt Enhancer** (writes natural sentences for Qwen-Image / FLUX / Z-Image, Danbooru tags for Illustrious / Pony, phrases for realistic SDXL, based on the workflow loaded in GenerateUI), **Image to Prompt** (recreates an image attached in TOOLS), **Storyboard to Lab**, **Workflow Doctor**, **SVG Icon Generator** v2 (line/flat/duotone/mono styles) and **Skill Creator** v3. The prompt text currently in GenerateUI is only passed to the model when your message refers to it, because small models copied an old negative prompt into new ones even when told not to
|
||||
- **Chat pane — Image check & refine loop** (v0.9.5) — with **Check images** on, the chat model turns the request into 3–8 yes/no checks and an Ollama decision model with image input (`d3:4b` by default; `clef-flash` / `clef` also work) answers them with probabilities: ✔ ≥ 85%, ? 30–85%, ✘ < 30%. Swap checks ("the hat is not on the girl") catch the attribute mix-ups the judge otherwise passes. **Fix missing items and regenerate** sends only the unmet items back for a rewritten prompt; 0–3 automatic rounds are configurable. SVG replies are rendered and checked part by part the same way
|
||||
- **Chat pane — Workflow Doctor** (v0.9.5) — diagnoses the workflow loaded in GenerateUI from facts checked on every message: node types that are not installed, model files or options missing from ComfyUI's lists (with the closest existing names), broken links, unconnected core-type required inputs, the last execution error from `/history`, and the latest error block of the ComfyUI log (no false positives across 77 local workflows)
|
||||
- **Chat pane — Storyboard to Lab** (v0.9.5) — a ```` ```labplan ```` reply becomes a Lab plan (one scene per iteration, T2I mode, identical character descriptions, fixed seed); **Open in Lab** loads it into GenerateUI > Lab
|
||||
- **Chat pane — Agent Skills folders** (v0.9.5) — **Import** in the skill manager adds a `.zip` containing `SKILL.md` (or a single `.md`); only text files are kept, scripts are never extracted or run, and zip-slip / size limits apply. Reference files are listed to the model and read on demand through a `read_skill_file` tool (progressive disclosure; needs a tool-capable model)
|
||||
- **TOOLS pane — OCR** (v0.9.5) — **OCR (extract text)** reads text with an Ollama OCR model (LightOnOCR-3 by default; PaddleOCR-VL, GLM-OCR and DeepSeek-OCR are listed too). Ported from Comic Creator's OCR tab: 1600 px white-background JPEG, temperature 0, grayscale retry on empty / looping output. **Show text positions** (LightOnOCR / DeepSeek-OCR) overlays numbered boxes; clicking one selects its line. **To Translation** / **To Chat** pass any TOOLS result on
|
||||
- **Collapsible Translation / Settings panes** (v0.9.5) — the arrow in each header folds the pane into a thin strip (click the strip to reopen); the state is remembered
|
||||
- **Settings pane** — choose backend (Ollama / LM Studio / Lemonade / Unsloth), set the API URL, test connection, select a model (with refresh button), and configure Free language names for translation source and destination
|
||||
- **Unsloth backend** (v0.3.99) — Unsloth Desktop's OpenAI-compatible API requires an API key even for local access unless its **Keyless API access → "Chat and inference"** setting is on; unlike the other backends it's proxied server-side (`py/routes/unsloth_routes.py`, `POST /api/wfm/unsloth/proxy`) so the key never reaches the browser: set `UNSLOTH_API_KEY` in a `.env` file in the plugin folder (copy `.env.example`, loaded via `python-dotenv` in `prestartup_script.py`) and restart ComfyUI — or, since v0.7.7, leave `UNSLOTH_API_KEY` unset and turn on Keyless API access → "Chat and inference" in Unsloth Desktop (requests are then relayed without an `Authorization` header, still only to localhost; that Unsloth setting also opens the API to your private LAN, and "Allow tools" should stay off). A 401 with no key set returns a message explaining both options; also requires **"Auto model switching (OpenAI API)"** to be turned on in the Unsloth Desktop app's own Settings screen — with it off, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI
|
||||
- **Decision API client** (v0.7.5, foundation) — `static/js/decision-client.js` calls Unsloth Desktop's Decision API (`/v1/systemone`, TypeSafe-compatible Laya decision models) through the same key-attaching server proxy: `decide(state, questions)` / `decideMany`, question builders `noul`/`choice`/`score`, and `isYes`/`pickChoice`/`pickScore` helpers that threshold on `probabilities` (as Unsloth recommends) and flag low-probability answers as not confident. Requires **Decision API → Serve requests** enabled in Unsloth Desktop. **Ollama 0.35+ backend** (v0.7.7) — Ollama's own `/v1/systemone` (decision models `tev1` 4B / `tev1:0.8b` 812 MB / `nimble` 9B / `clef` 27B vision-capable, pulled with `ollama pull`; no key, called directly from the browser); the model list shows only installed models whose `capabilities` include `decision`, with a ↻ refresh button and a pull hint when none are installed. **Image input** (v0.7.14, ported from ComfyUI Comic Creator) — most decision models are text-only, but Ollama's `clef` accepts images directly: `decide()` takes an optional 4th `images` argument (base64, no `data:` prefix), `imageToBase64()` converts a Blob/data URL/image URL, and `supportsDecisionVision()` checks whether the currently-configured model accepts them (via `/api/show`'s `capabilities`/`projector_info`, since not every Ollama build reports `vision` for decision models) — call it before passing images, since a non-vision model errors out on them; confirmed working end to end against `clef`. Connection settings live in the Settings tab's own **Decision Model** section (v0.7.6): backend (Unsloth / Ollama), URL, model and default probability threshold, stored under `wfm_decision_settings` independently of the AI TOOL / Tagger backends, with a Test button (one yes/no question). No feature uses it yet
|
||||
@@ -595,10 +607,12 @@ Samples for the Feeder tab: `ws_sdxl_image_feeder_i2i` / `ws_dwpose_image_feeder
|
||||
|
||||
## Manual
|
||||
|
||||
A beginner-oriented manual (covers v0.9.0, text-only edition without screenshots) is available in [`docs/manual/`](docs/manual/):
|
||||
A beginner-oriented manual (covers v0.9.6, text-only edition without screenshots) is available in [`docs/manual/`](docs/manual/):
|
||||
|
||||
- English: [`docs/manual/en/`](docs/manual/en/) — ZIP: `Workflow_Studio_Manual_v0.9.0_en.zip`
|
||||
- 日本語: [`docs/manual/`](docs/manual/) — ZIP: `Workflow_Studio_Manual_v0.9.0.zip`
|
||||
- English: [`docs/manual/en/`](docs/manual/en/) — ZIP: `Workflow_Studio_Manual_v0.9.6_en.zip`
|
||||
- 日本語: [`docs/manual/`](docs/manual/) — ZIP: `Workflow_Studio_Manual_v0.9.6.zip`
|
||||
|
||||
An EPUB edition with screenshots (Japanese and English, v0.9.6) is available on [Google Drive](https://drive.google.com/drive/folders/14mOHcnLYrFgNrOo6fy7ELyFjjWrAoU-_?usp=sharing).
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 942 KiB |
@@ -2,12 +2,12 @@
|
||||
|
||||
ノードが苦手でも大丈夫 ― 画像生成をもっと手軽に、もっと整理して
|
||||
|
||||
- 対象バージョン:Workflow Studio v0.9.0
|
||||
- 対象バージョン:Workflow Studio v0.9.6
|
||||
- 著者:ketle-man
|
||||
- © 2026 ketle-man
|
||||
|
||||
このマニュアルは、Kindle版をもとにした **画像なしのテキスト版** です。
|
||||
章ごとのファイルは `chapters/` フォルダ、全章をまとめたファイルは `Workflow_Studio_Manual_v0.9.0.md` です。
|
||||
章ごとのファイルは `chapters/` フォルダ、全章をまとめたファイルは `Workflow_Studio_Manual_v0.9.6.md` です。
|
||||
画面の例は省略しているため、実際の画面はWorkflow Studioのヘルプタブとあわせてご覧ください。
|
||||
|
||||
## 目次
|
||||
@@ -29,4 +29,4 @@
|
||||
- [13_付録](chapters/13_付録.md)
|
||||
- [14_おわりに](chapters/14_おわりに.md)
|
||||
|
||||
全章まとめ:[Workflow_Studio_Manual_v0.9.0.md](Workflow_Studio_Manual_v0.9.0.md)
|
||||
全章まとめ:[Workflow_Studio_Manual_v0.9.6.md](Workflow_Studio_Manual_v0.9.6.md)
|
||||
|
||||
Binary file not shown.
+358
-12
@@ -2,12 +2,12 @@
|
||||
|
||||
ノードが苦手でも大丈夫 ― 画像生成をもっと手軽に、もっと整理して
|
||||
|
||||
- 対象バージョン:Workflow Studio v0.9.0
|
||||
- 対象バージョン:Workflow Studio v0.9.6
|
||||
- 著者:ketle-man
|
||||
- © 2026 ketle-man
|
||||
|
||||
このマニュアルは、Kindle版をもとにした **画像なしのテキスト版** です。
|
||||
章ごとのファイルは `chapters/` フォルダ、全章をまとめたファイルは `Workflow_Studio_Manual_v0.9.0.md` です。
|
||||
章ごとのファイルは `chapters/` フォルダ、全章をまとめたファイルは `Workflow_Studio_Manual_v0.9.6.md` です。
|
||||
画面の例は省略しているため、実際の画面はWorkflow Studioのヘルプタブとあわせてご覧ください。
|
||||
|
||||
---
|
||||
@@ -143,7 +143,7 @@ ComfyUIの「自由さ」はそのままに、「分かりやすさ」と「手
|
||||
|
||||
## 本書の対象バージョンと環境
|
||||
|
||||
- **Workflow Studio**:v0.9.0
|
||||
- **Workflow Studio**:v0.9.6
|
||||
- **ComfyUI**:本書執筆時点の最新版
|
||||
- **OS**:Windows(画面の例はWindowsのものです)
|
||||
|
||||
@@ -624,7 +624,7 @@ Workflow Studioを開くと、画面の上部にタブが横一列に並んで
|
||||
| TagSpace | TagSpace | タグ・プロンプト・生成画像を意味の近さで3D表示し、タグを選ぶ | | 第11章 |
|
||||
| 設定 | Settings | 言語・テーマ・フォルダなどの設定 | | 本章・第11章 |
|
||||
| ヘルプ | Help | 機能一覧・ショートカット・トラブル対処 | | 本章 |
|
||||
| AI TOOL | AI TOOL | ローカルAIによる翻訳・チャット・画像解析 | | 第10章 |
|
||||
| AI TOOL | AI TOOL | ローカルAIによる翻訳・チャット・画像解析・文字起こし(OCR) | | 第10章 |
|
||||
|
||||
### タブは「整理する」「作る」「補助する」の3グループで考える
|
||||
タブが多くて迷ったときは、次の3つのグループに分けて考えると分かりやすくなります。
|
||||
@@ -1222,12 +1222,13 @@ Inputサブタブでは、Raw JSONの上端にある **»** ボタンで、Raw J
|
||||
|
||||
1枚目が生成できたら、次は **Settings** サブタブで、画像の仕上がりを左右する設定を見てみましょう。
|
||||
|
||||
Settingsタブは、左右2つのブロックに分かれています。
|
||||
Settingsタブは、3つの列に分かれています。
|
||||
|
||||
- **KSampler(左)** - 画像をどのように描くかの設定
|
||||
- **Latent Image(右)** - 画像の大きさと枚数の設定
|
||||
- **Latent Image(中央)** - 画像の大きさと枚数の設定
|
||||
- **スイッチ(右、v0.9.6〜)** - ワークフローの中のオン・オフの切り替え
|
||||
|
||||
ここでも、値を変えたら各ブロックの **Apply** を押すのを忘れないようにしましょう。
|
||||
ここでも、KSamplerとLatent Imageは、値を変えたら各ブロックの **Apply** を押すのを忘れないようにしましょう(スイッチの列はApply不要です)。
|
||||
|
||||
### KSampler の項目
|
||||
| 項目 | 意味 | 初心者向けの目安 |
|
||||
@@ -1254,6 +1255,23 @@ Settingsタブは、左右2つのブロックに分かれています。
|
||||
> モデルが学習したサイズから大きく外れると、人物が二重になったり、構図が崩れたりしやすくなります。
|
||||
> 縦長・横長にしたいときも、合計のピクセル数がおおよそ同じになるように調整しましょう(例:SDXLなら 832×1216 など)。
|
||||
|
||||
### スイッチの項目(v0.9.6〜)
|
||||
ワークフローの中にある「オン・オフ」の設定が、ノード名と項目名つきで一覧になります。
|
||||
チェックボックスをクリックすると、その場で切り替わります。**Apply を押す必要はありません**。切り替えた値は右側の Raw JSON にもすぐ反映されます。
|
||||
|
||||
一覧に出るのは、次の2種類です。
|
||||
|
||||
- 値が true/false の項目 - プロンプト強化(PE)のオン・オフを切り替えるスイッチや「Boolean」ノード、TextGenerate の thinking など
|
||||
- 選択肢が2つだけのオン・オフの項目 - enable/disable、on/off、yes/no など。KSamplerAdvanced の add_noise、DWPreprocessor の detect_hand(手を検出するか)など
|
||||
|
||||
各行の右端には、いまの値(true/false、enable/disable など)が表示されます。
|
||||
行にマウスを乗せると、ノードの番号と種類を確認できます。
|
||||
|
||||
> **ポイント**
|
||||
> 別のノードから線でつながっている項目は、一覧に出ません。代わりに、つながっている元のノード(たとえば「Enable PE?」という名前の Boolean ノード)が出るので、そちらを切り替えてください。
|
||||
> ノードに名前を付けていないワークフローでは、ノードの種類名(ComfySwitchNode など)で表示されます。
|
||||
> オン・オフの項目が1つも無いワークフローでは「このワークフローに true/false の項目はありません」と表示されます。
|
||||
|
||||
### シードの管理(生成パネル)
|
||||
シードは、生成パネルの **Seed** 欄と、その下のプルダウンで管理します。
|
||||
|
||||
@@ -1286,7 +1304,7 @@ Settingsタブは、左右2つのブロックに分かれています。
|
||||
> **この章(前半)のまとめ**
|
||||
> - ワークフロータブで選んで「生成UIに読み込み」を押すと、生成UIで編集できる
|
||||
> - 基本は Input(プロンプト)・Model(モデル)・Settings(細かい設定)の3つのサブタブ
|
||||
> - モデルやSettingsの値を変えたら必ず **Apply**。押し忘れると変更前の値で生成される(プロンプトは自動で反映)
|
||||
> - モデルやSettingsの値を変えたら必ず **Apply**。押し忘れると変更前の値で生成される(プロンプトとSettingsのスイッチ列は自動で反映)
|
||||
> - Generate で生成。Alt+Apply なら反映と生成を一度に行える
|
||||
> - シードを Fixed にすると、気に入った絵をもとに条件を変えて比べられる。ワークフローのシードをそのまま使うなら Workflow
|
||||
|
||||
@@ -1457,6 +1475,8 @@ Labでは、「何回目の生成から、どの設定に切り替えるか」
|
||||
4. 生成回数(Batch)を決めます。
|
||||
5. Model・Prompt・KSampler の各列で **+** を押してキーフレームを追加し、
|
||||
キーフレームをダブルクリックして、「何回目から」「どの設定に」するかを入力します。
|
||||
KSampler列のセルには、指定した項目(Step・CFG・Denoise・サンプラー・スケジューラー・Seed)が表示されます。
|
||||
何も指定していないセルは「(ワークフローの設定のまま)」と表示されます。
|
||||
6. **Run** を押します。
|
||||
|
||||
結果は **Results** パネルに一覧表示されます。
|
||||
@@ -1466,6 +1486,8 @@ Labでは、「何回目の生成から、どの設定に切り替えるか」
|
||||
少しずつ変化していく連続画像を作れます。
|
||||
- **Plan Save** - 設定(プラン)を保存できます。保存すると、結果をまとめた一覧画像も作られ、
|
||||
その画像をドロップすれば同じプランを読み込み直せます。
|
||||
- **物語からプランを作る**(v0.9.5〜) - AI TOOLのChatで **Storyboard to Lab** スキルを使うと、
|
||||
物語をシーンごとのプロンプトに分けたプランを作り、**Labで開く** ボタンでここに読み込めます(第10章 10-5)。
|
||||
|
||||
---
|
||||
|
||||
@@ -1511,11 +1533,16 @@ v0.8.6〜は、こうしたノードを含むワークフローも生成UIで実
|
||||
| Fibo 2.0 Edit(v0.8.14〜) | 画像編集のワークフロー。プロンプト強化(PE)を使う構成でも、プロンプト欄の編集がPEのオン・オフどちらにも反映される。ネガティブの欄にはVGL形式(JSON)の説明文がそのまま入る。幅・高さ(Settings)は0で自動 |
|
||||
| FastH3 OmniRef/Pika-H3-Flex(v0.8.14〜) | MiniMax H3の「参照画像から動画」(Reference to Video)。動画のワークフローとして扱われ、Videoタブ(第11章)でも使える |
|
||||
| ネイティブLoRAスタック | ComfyUI v0.40のLoRAをまとめて読み込むノード(LoadLoraModel/LoadLoraTextEncoder)。Apply・バイパス・Metadata表示に対応 |
|
||||
| P-H3 Image(v0.9.4〜) | 画像編集とT2Iのテンプレート。プロンプトはInputタブに表示され、Metadataタブでも読める。ネガティブの欄は無い。幅・高さはワークフローのつなぎ方に従い、プロンプトを補う機能(プロンプトアップサンプラー)もワークフローの設定どおりに動く |
|
||||
| MiniMax H3 Image(v0.9.6〜) | ComfyUI公式の画像編集とT2Iのテンプレート。P-H3 Imageと同じく、プロンプトはInputタブに表示され、MetadataタブとサイドパネルのIタブでも読める。ネガティブの欄は無い。プロンプト強化(PE)のオン・オフは、Settingsタブのスイッチの列(5-4)で切り替えられる |
|
||||
|
||||
> **注意**
|
||||
> ネイティブLoRAスタックのノードは、保存形式が **API形式のワークフローのみ** 対応です。
|
||||
> ComfyUI本体の画面がこのノードの行追加にまだ対応していないためで、UI形式のワークフローは対象外です。
|
||||
|
||||
> **ポイント**
|
||||
> 2枚の画像をスライダーで見比べる **Compare Images**(ImageCompare)ノードを含むワークフローは、以前は生成UIから実行すると、このノードだけエラーになっていました(v0.9.6で修正)。
|
||||
|
||||
> **ポイント**
|
||||
> 画像のメタデータを見るMetadataタブとサイドパネルの **I** タブでも、Nano Bananaで作った画像を読めます(v0.8.5〜)。
|
||||
> プロンプトは、Positive/Negativeに分けずに全文が1件で表示されます。
|
||||
@@ -1981,7 +2008,7 @@ Workflow Studioにはたくさんのスタイルが付属しています。
|
||||
|
||||
ComfyUIで生成した画像には、生成に使ったワークフローやプロンプトの情報(**メタデータ**)が埋め込まれています。
|
||||
ギャラリータブでは、この情報を読み取って、同じ設定をすぐに再現できます。
|
||||
また、動画(MP4・WebM)や音声ファイル(WAV・MP3・OGG・M4A・FLAC・Opus)も、画像と同じ一覧で扱えます。
|
||||
また、動画(MP4・WebM)や音声ファイル(WAV・MP3・OGG・M4A・FLAC・Opus)、3Dモデル(GLB・glTF・VRM・PLY・SPZ)も、画像と同じ一覧で扱えます。
|
||||
|
||||
---
|
||||
|
||||
@@ -2272,6 +2299,30 @@ ComfyUIで音楽や効果音を作る機会が増えたため、ギャラリー
|
||||
> **ポイント:Videoタブでの活用**
|
||||
> Video の Asset サブタブでは、音声ファイルを「BGMに設定」したり、「サウンドとして追加」したりできます(第11章 11-4)。
|
||||
|
||||
---
|
||||
|
||||
## 8-8 3Dモデルを見る(v0.9.4〜)
|
||||
|
||||
ComfyUIで3Dモデルを作るワークフロー(画像から3Dなど)の結果も、ギャラリーで確かめられます。
|
||||
対応しているのは、GLB・glTF・VRM・PLY(メッシュ/点群/ガウシアンスプラット)・SPZ です。
|
||||
|
||||
- 一覧には「**3D**」のバッジ付きで並びます。サムネイルは、最初に一覧に出たときにブラウザが描いてサーバーに保存します
|
||||
(128MBを超えるファイルは、開いたときに保存します)。
|
||||
- 選ぶと、右の詳細パネルの内蔵ビューアで立体として表示されます。ダブルクリックで大きく表示できます。
|
||||
|
||||
| 操作 | 動き |
|
||||
| --- | --- |
|
||||
| ドラッグ | 回転する |
|
||||
| 右ドラッグ | 位置を動かす |
|
||||
| マウスホイール | 拡大・縮小 |
|
||||
| ⟲(リセット)ボタン | 最初の向きに戻す |
|
||||
|
||||
- ComfyUIの **SaveGLB** で保存したモデルは、画像と同じようにワークフローを読み取れます(8-2・8-3)。
|
||||
|
||||
> **注意**
|
||||
> - 3Dファイルは、Image Edit・生成UI・Tagger・Feederには送れません。
|
||||
> - `.gltf` の中には、別の `.bin` や画像ファイルを参照しているものがあります。ギャラリーで移動・削除しても動くのは `.gltf` 本体だけなので、参照しているファイルの場所は変えないでください。
|
||||
|
||||
> **この章のまとめ**
|
||||
> - ギャラリーのOutputで、生成した画像をフォルダごとに見返せる。プロンプトの言葉でも検索できる
|
||||
> - 詳細パネルのプロンプトタブで、画像の生成に使ったプロンプトがすぐ分かる
|
||||
@@ -2280,6 +2331,7 @@ ComfyUIで音楽や効果音を作る機会が増えたため、ギャラリー
|
||||
> - メタデータサブタブに画像をドロップすると、モデル・LoRA・プロンプトを詳しく調べられる
|
||||
> - Sボタンで、文章の意味や「似た画像」から探せる(セマンティック検索)
|
||||
> - 音声ファイルも一覧・再生・変換できる
|
||||
> - 3Dモデル(GLB・glTF・VRM・PLY・SPZ)も、ギャラリーで回して確かめられる(8-8)
|
||||
|
||||
---
|
||||
|
||||
@@ -2466,6 +2518,9 @@ Workflow Studioは、パソコンの中で動く **ローカルAI(大規模言
|
||||
- AIとチャットしながら、プロンプトのアイデアを出してもらう
|
||||
- 画像を見せて、その画像の説明やプロンプト、タグを作ってもらう
|
||||
- ワイルドカード用の候補リストを作ってもらう
|
||||
- **スキル** を選んで、プロンプト作り・ストーリーボード・不具合の相談など、得意分野を切り替える(v0.9.5〜)
|
||||
- 生成した画像が依頼どおりかをAIにチェックさせて、足りない点だけ直す(v0.9.5〜)
|
||||
- 画像に写っている文字を読み取る(OCR、v0.9.5〜)
|
||||
|
||||
インターネット上のサービスではなく、自分のパソコンの中で動くので、
|
||||
**入力した内容や画像が外部に送られない** のが大きな特長です。
|
||||
@@ -2528,10 +2583,14 @@ Workflow Studio本体の **AI TOOL** タブを開きます。
|
||||
| 部分 | できること |
|
||||
| --- | --- |
|
||||
| 翻訳 | 日本語・英語・中国語などの翻訳 |
|
||||
| Chat | AIとの会話。条件がそろえば、会話から画像生成も |
|
||||
| TOOLS | 画像の読み取り(VLM)、ワイルドカード作成 |
|
||||
| Chat | AIとの会話。スキルで得意分野を切り替えられる。条件がそろえば、会話から画像生成も |
|
||||
| TOOLS | 画像の読み取り(VLM)、文字起こし(OCR)、ワイルドカード作成 |
|
||||
| 設定 | 使うAIソフト・モデルの設定 |
|
||||
|
||||
> **ポイント:翻訳と設定はたためる(v0.9.5〜)**
|
||||
> 翻訳と設定の見出しの右端にある矢印(◀/▶)を押すと、その部分が細い帯にたたまれ、空いた幅をChatとTOOLSが使います。
|
||||
> 帯のどこかをクリックすると元に戻ります。たたんだ状態は、次に開いたときも保たれます。
|
||||
|
||||
### 最初に:設定でAIソフトとつなぐ
|
||||
1. **設定** の **バックエンド** で、使うソフト(例:Ollama)を選びます。
|
||||
2. **接続設定** のアドレスを確認します。Ollamaを標準のまま使っている場合は、変更不要です。
|
||||
@@ -2555,6 +2614,9 @@ Workflow Studio本体の **AI TOOL** タブを開きます。
|
||||
2. 入力欄に文章を書き、**翻訳** を押します。
|
||||
3. 結果を **コピー** して、生成UIやプリセットに貼り付けます。
|
||||
|
||||
> **ポイント**
|
||||
> TOOLSで読み取った文字(10-8のOCRなど)は、TOOLSの **翻訳へ送る** で、この入力欄にそのまま入れられます。
|
||||
|
||||
> **ポイント**
|
||||
> 小さなモデルは、翻訳せずに元の文章をそのまま返してしまうことがあります。
|
||||
> その場合は「モデルが原文をそのまま返しました」と表示されるので、別のモデルを試してください。
|
||||
@@ -2569,6 +2631,10 @@ Workflow Studio本体の **AI TOOL** タブを開きます。
|
||||
> チャットで「猫の絵を描いて」と頼むだけで、生成UIに読み込んだワークフローで画像が生成され、チャット内に表示されます。
|
||||
> この機能は、入力欄の横の **画像生成を許可する** のチェックでオン・オフできます。
|
||||
|
||||
> **ポイント**
|
||||
> Chatの上にあるドロップダウンで **スキル** を選ぶと、AIの得意分野を切り替えられます(10-5)。
|
||||
> AIの返事は、見出し・箇条書き・コードの枠を整えて表示されます(v0.9.5〜)。
|
||||
|
||||
### TOOLS - 画像を読み取る(VLM)
|
||||
画像入力に対応したモデル(VLM)を使うと、画像の内容をAIに読み取らせることができます。
|
||||
|
||||
@@ -2581,8 +2647,16 @@ Workflow Studio本体の **AI TOOL** タブを開きます。
|
||||
| プロンプト作成 | 画像を再現するためのプロンプトを作る |
|
||||
| Create tags(英語表示のまま) | 画像の要素をタグ形式で書き出す |
|
||||
| Create wildcards(英語表示のまま) | カテゴリー名と件数を指定して、ワイルドカードの候補を作る(画像は不要) |
|
||||
| 文字起こし(OCR) | 画像に写っている文字を読み取る(10-8) |
|
||||
|
||||
3. **実行** を押します。結果はコピーして使えます。
|
||||
3. **実行** を押します。
|
||||
4. 結果の下のボタンで、結果を使います(v0.9.5〜)。
|
||||
|
||||
| ボタン | 動き |
|
||||
| --- | --- |
|
||||
| 翻訳へ送る | 翻訳の入力欄に入れる(翻訳がたたまれていれば開く) |
|
||||
| Chatへ送る | Chatの入力欄に追加する |
|
||||
| コピー | クリップボードにコピーする |
|
||||
|
||||
> **例**
|
||||
> 気に入ったイラストの雰囲気をまねしたいとき、その画像から「プロンプト作成」をして、
|
||||
@@ -2649,12 +2723,230 @@ ComfyUI(とWorkflow Studio)にアクセスできるようになります。
|
||||
> `--listen` は、家庭内LANなど **信頼できるネットワークでだけ** 使ってください。
|
||||
> カフェや公共のWi-Fiでは使わないようにしましょう。
|
||||
|
||||
---
|
||||
|
||||
## 10-5 スキル - Chatの得意分野を切り替える(v0.9.5〜)
|
||||
|
||||
**スキル** は、Chatに「どんな役割で、どんな形で答えるか」を教える指示書です。
|
||||
Chatの上のドロップダウン(最初は「-- スキルなし --」)で選ぶと、そのスキルの指示が毎回の会話に付けて送られます
|
||||
(指示の文章はチャットの画面には表示されません)。
|
||||
|
||||
### 最初から入っているスキル
|
||||
Workflow Studioには、次の6つのスキルが入っています(名前は英語表示のままです)。
|
||||
|
||||
| スキル | 何をするか | 結果の使い方 |
|
||||
| --- | --- | --- |
|
||||
| T2I Prompt Enhancer | 短いアイデアを、生成UIのワークフローのモデルに合った書き方のプロンプトにする | プロンプトの下のボタン |
|
||||
| Image to Prompt | TOOLSに入れた画像を再現するプロンプトを書く(画像を読み取れるモデルが必要) | プロンプトの下のボタン |
|
||||
| Storyboard to Lab | 物語をシーンごとのプロンプトに分け、Labで連続生成できる形にする | **Labで開く** |
|
||||
| Workflow Doctor | ワークフローの説明や、エラーの原因の相談にのる(10-7) | 文章で答える |
|
||||
| SVG Icon Generator | 簡単なアイコンやイラストをSVG(ベクター画像)で描く | プレビューと **Galleryに保存** |
|
||||
| Skill Creator | 質問に答えていくと、新しいスキルを作ってくれる(10-9) | **スキルとして保存** |
|
||||
|
||||
### プロンプトを作って、そのまま生成する(T2I Prompt Enhancer)
|
||||
1. 生成UIに、使うワークフローを読み込みます。スキルはこのワークフローのモデルを見て、プロンプトの書き方を決めます。
|
||||
2. AI TOOLのChatで、ドロップダウンから **T2I Prompt Enhancer** を選びます。
|
||||
3. 「雨の夜の東京、ネオン、傘を持つ猫」のように、日本語で作りたい絵を書いて送信します。
|
||||
4. 返事に、選んだ書き方(`Style:` の行)と、英語のプロンプトの枠が表示されます。その下のボタンで使います。
|
||||
|
||||
| ボタン | 動き |
|
||||
| --- | --- |
|
||||
| GenerateUIに反映 | 生成UIのポジティブ/ネガティブの欄に書き込む |
|
||||
| このプロンプトで生成 | その場で生成し、チャットに画像を表示する |
|
||||
| コピー | ポジティブプロンプトをコピーする |
|
||||
|
||||
モデルに合わせた書き方は、次のとおりです。
|
||||
|
||||
| 生成UIのモデル | プロンプトの書き方 |
|
||||
| --- | --- |
|
||||
| Qwen-Image、FLUX、Z-Image など | 英語の文章(ネガティブは、CFGが1前後なら空にする) |
|
||||
| Illustrious、NoobAI、Pony など(SDXLのアニメ系) | Danbooruのタグ(品質タグから始める) |
|
||||
| 写実系のSDXL、SD1.5 | 短い言葉をカンマで並べる |
|
||||
|
||||
> **ポイント**
|
||||
> 「もっと夜らしく」「猫を2匹に」のように続けて頼むと、直したプロンプト全体を出し直します。
|
||||
|
||||
> **ポイント:今入っているプロンプトは渡さない**
|
||||
> 生成UIに今入っているプロンプトは、ふだんはAIに渡しません。渡すと、小さなモデルが古いネガティブプロンプトなどを新しいプロンプトに混ぜてしまうためです。
|
||||
> 「**今のプロンプトを改善して**」のように、今のプロンプトに触れて頼んだときだけ渡します。
|
||||
|
||||
### ストーリーボードから連続生成する(Storyboard to Lab)
|
||||
1. 生成UIに、文章から画像を作る(T2I)ワークフローを読み込みます。
|
||||
2. Chatで **Storyboard to Lab** を選び、「茶トラの子猫が朝目覚めて、庭で蝶を追いかけ、夕方に縁側で昼寝する。3シーンで」のように頼みます。
|
||||
3. 返事に、シーンの一覧と **Labで開く(3シーン)** ボタンが表示されます。ボタンを押します。
|
||||
4. 生成UIの **Lab**(第5章 5-7)が開き、まだ保存していないプランとして読み込まれます。
|
||||
T2Iモードになり、Batchがシーンの数に、Prompt列がシーンごとのキーフレームになります。KSampler列には、全シーン共通のシード(`Seed …`)が入ります。
|
||||
5. 内容を確かめて **Run** を押します。残したいときは **Plan Save** で保存します。
|
||||
|
||||
> **ポイント**
|
||||
> 登場人物の見た目(髪・服の色など)は、全シーンで同じ文章が入るように書かれ、シードも固定されます。
|
||||
> そのため、シーンが変わっても同じキャラクターに見えやすくなります。
|
||||
|
||||
### 返事の自動チェック
|
||||
プロンプトやSVGを出すスキルでは、Workflow Studioが返事の形を自動で確かめます。
|
||||
プロンプトの枠が崩れている、SVGが閉じていない、といった問題があると、
|
||||
点線の枠で「**自動チェック:…**」と表示し、AIに1回だけ直させます。
|
||||
説明だけを頼んだときなど、プロンプトやSVGを出していない返事はそのままにします。
|
||||
|
||||
### スキルの管理(鉛筆ボタン)
|
||||
ドロップダウンの右の鉛筆ボタンで、スキルの一覧が開きます。
|
||||
|
||||
- **+ 新規** で新しいスキルを書く、各行の鉛筆ボタンで編集・削除する、**取り込み** で他のスキルを追加する(10-9)
|
||||
- 最初から入っているスキルには **同梱** と表示されます。
|
||||
編集していない同梱スキルは、Workflow Studioを更新すると自動で新しい版になります。
|
||||
編集したスキルは自動では置き換わらず、新しい版があると **↻(更新)** ボタンが表示されます。
|
||||
- スキルのファイルは `ComfyUI/user/default/Workflow-Studio/ai_skills/` に保存されます。
|
||||
|
||||
> **注意**
|
||||
> ↻(更新)で新しい版に置き換えると、そのスキルに加えた編集は消えます。
|
||||
|
||||
---
|
||||
|
||||
## 10-6 生成した画像をチェックして直す(v0.9.5〜)
|
||||
|
||||
チャットから生成した画像が、頼んだとおりになっているかをAIに確かめさせる機能です。
|
||||
足りないところがあれば、その点だけを直したプロンプトで描き直せます。
|
||||
|
||||
### 準備:判定用のモデル
|
||||
チェックには、ふつうのチャット用モデルではなく、Ollamaの **意思決定モデル**(はい/いいえを確率で答えるモデル)を使います。
|
||||
画像を読み取れるもの(作者の環境では `d3:4b`、`clef-flash`、`clef` で動作を確認)を、`ollama pull` で用意します。
|
||||
|
||||
> **ポイント:なぜ専用のモデルを使うのか**
|
||||
> 作者の検証では、チャット用のモデルに画像を見せて「剣を持っているか」と聞くと、持っていないのに「はい」と答えることがありました。
|
||||
> 意思決定モデルは「はい」の確率を数字で返すので、あいまいな答えを見分けられます。
|
||||
|
||||
1. AI TOOLの **設定** にある **CHAT 画像チェック** で、**判定モデル(Ollamaの意思決定モデル)** を選びます(一覧が空なら **更新**)。
|
||||
2. **自動修正の回数** を選びます。最初は **0(ボタンで修正)** がおすすめです。
|
||||
3. **設定を保存** を押します。
|
||||
|
||||
### 使い方
|
||||
1. Chatの入力欄の下にある **画像をチェック** にチェックを入れます。
|
||||
2. チャットから画像を生成します(AIの画像生成、または **このプロンプトで生成**)。
|
||||
3. 画像の下に結果のカードが表示されます。AIが頼んだ内容を3〜8個の確認項目に分け、1つずつ確かめた結果です。
|
||||
|
||||
| 表示 | 意味 |
|
||||
| --- | --- |
|
||||
| ✔(満たしている) | 「はい」の確率が85%以上 |
|
||||
| ?(はっきりしない) | 30〜85% |
|
||||
| ✘(満たしていない) | 30%未満 |
|
||||
|
||||
4. ✘や?があるときは **足りない点を直して再生成** を押します。満たしていない項目だけをAIに伝え、プロンプトを書き直して描き直します。
|
||||
|
||||
自動修正の回数を1〜3にすると、ボタンを押さなくても、その回数まで自動でくり返します。
|
||||
|
||||
> **ポイント:SVGもチェックできる**
|
||||
> SVG Icon Generatorで描いたSVGも、画像にしてから「頼んだものに見えるか」「部品(くちばし・翼など)が描けているか」をチェックします。
|
||||
> **分かりにくい部分を描き直させる** で直させられます。
|
||||
|
||||
> **注意**
|
||||
> - チェックは完ぺきではありません。とくに「誰が何を身につけているか」の取り違えに弱いため、
|
||||
> 人物が複数いるときは「帽子は女の子ではない」のような逆向きの確認項目を自動で加えて補っています。
|
||||
> - 描き直しの上手さは、チャット用モデルの力しだいです。直らないときは、頼み方を変えるか、モデルを変えてください。
|
||||
> - 判定モデルもGPUのメモリを使います(`d3:4b` で約6GB)。読み込んだあとは、1回のチェックが数秒です。
|
||||
|
||||
---
|
||||
|
||||
## 10-7 ワークフローの不具合を相談する(Workflow Doctor、v0.9.5〜)
|
||||
|
||||
**Workflow Doctor** は、生成UIに読み込んだワークフローの説明や、エラーの原因の相談にのるスキルです。
|
||||
AIの知識だけに頼らず、送信するたびにWorkflow Studioが実際に確かめた次の内容をAIに渡します。
|
||||
|
||||
| 確かめる内容 | 例 |
|
||||
| --- | --- |
|
||||
| インストールされていないノード | ComfyUIが知らないノードの名前 |
|
||||
| ComfyUIの一覧に無いファイル・設定値 | 見つからないモデルやLoRA(似た名前の候補も) |
|
||||
| 線のつながり | つながっていない入力、存在しないノードへの線 |
|
||||
| 直近の実行エラー | ComfyUIの履歴に残ったエラーの内容 |
|
||||
| ComfyUIのログ | ログに出た最新のエラーの部分 |
|
||||
|
||||
### 使い方
|
||||
1. 相談したいワークフローを生成UIに読み込みます(エラーの相談なら、一度実行してエラーを出しておきます)。
|
||||
2. Chatで **Workflow Doctor** を選びます。
|
||||
3. 「実行する前に問題がないか確認して」「さっきのエラーの原因は?」のように聞きます。
|
||||
|
||||
> **例**
|
||||
> LoRAのファイルを別のフォルダに移したワークフローで聞くと、
|
||||
> 「`temp\` のフォルダに指定されたファイルが見つからない。`Illustrious\` に同じ名前のファイルがある」と、直し方まで答えました。
|
||||
|
||||
> **ポイント**
|
||||
> 確かめる内容は、送信のたびに作り直されます。直したあとは、もう一度聞いてみてください。
|
||||
|
||||
---
|
||||
|
||||
## 10-8 画像の文字を読み取る(OCR、v0.9.5〜)
|
||||
|
||||
TOOLSで、画像に写っている文字を読み取れます。読み取った文字は、そのまま翻訳やChatに送れます。
|
||||
|
||||
### 準備:OCR用のモデル
|
||||
OCRには、チャット用とは別の、文字を読むのが得意なモデルを使います。Ollamaで用意してください。
|
||||
作者の環境で、横書きと縦書きの日本語・英語がまじった画像を読ませた結果は次のとおりです。
|
||||
|
||||
| モデル | 時間 | 読み取り |
|
||||
| --- | --- | --- |
|
||||
| LightOnOCR-3(`maternion/LightOnOCR-3:4b`) | 約1秒(位置つきは約6〜8秒) | すべて読めた |
|
||||
| PaddleOCR-VL(マンガ向けの版) | 約4秒 | すべて読めた |
|
||||
| DeepSeek-OCR系 | 約14秒 | すべて読めた(いつも位置つき) |
|
||||
| PaddleOCR-VL 1.6、GLM-OCR | 約11〜15秒 | 縦書きを読み落とした |
|
||||
|
||||
### 使い方
|
||||
1. TOOLSに画像をドロップします。
|
||||
2. 作業で **文字起こし(OCR)** を選びます。
|
||||
3. **OCRモデル** を選びます。名前に「ocr」を含むOllamaのモデルが、向いている順に並びます(一覧が空なら ↻ を押します)。
|
||||
4. **実行** を押します。読み取った文字が、結果の欄に1行ずつ表示されます。
|
||||
|
||||
> **ポイント:文字の位置を表示**
|
||||
> **文字の位置を表示** にチェックを入れると、画像の上に番号つきの枠が重なり、どこの文字かが分かります。
|
||||
> 枠をクリックすると、結果の欄の同じ行が選ばれます。
|
||||
> 位置を返せるのは LightOnOCR と DeepSeek-OCR系だけです。ほかのモデルでは、このチェックは選べません。
|
||||
|
||||
> **ポイント**
|
||||
> 結果が空だったり、同じ行がくり返されたりしたときは、自動で白黒(グレースケール)にして1回だけ読み直します。
|
||||
|
||||
---
|
||||
|
||||
## 10-9 スキルを自作・取り込む(上級、v0.9.5〜)
|
||||
|
||||
### Skill Creatorで作る
|
||||
Chatで **Skill Creator** を選び、「〇〇が得意なスキルを作りたい」と話しかけます。
|
||||
AIが1つずつ質問してくるので答えていくと、スキルの下書きができます。
|
||||
返事の下の **スキルとして保存** を押すと、スキルの一覧に下書きが入った状態で開くので、確かめて保存します。
|
||||
「このスキルの、ここがうまくいかない」と相談して、今あるスキルを直してもらうこともできます。
|
||||
|
||||
### スキルのファイルの中身
|
||||
スキルは、テキストファイル(`.md`)です。先頭の `---` で囲んだ部分に名前と説明を書き、その下にAIへの指示を書きます。
|
||||
先頭の部分の `metadata:` には、次の設定も書けます(書かなくてもかまいません)。
|
||||
|
||||
| 設定 | 意味 |
|
||||
| --- | --- |
|
||||
| `output` | `prompt` か `svg`。返事の形を自動でチェックし、ボタンを付ける |
|
||||
| `tools` | `[]` にすると、AIが自分で画像生成を始めない |
|
||||
| `temperature` | 答えのばらつき(0.3なら安定、0.9なら自由) |
|
||||
| `think` | 考える(Thinking)モードを、このスキルだけオン/オフにする |
|
||||
| `context` | AIに渡す情報。`workflow`(ワークフローの要約)、`current-prompt`(今のプロンプト)、`workflow-detail`/`logs`(Workflow Doctorと同じ確認内容) |
|
||||
|
||||
### ほかのスキルを取り込む
|
||||
スキルの一覧の **取り込み** で、次のファイルを追加できます。
|
||||
|
||||
- `.md` のスキル1つ
|
||||
- `SKILL.md` を含むフォルダをZIPにしたもの(**Agent Skills** という、多くのAIツールで共通の形式)
|
||||
|
||||
フォルダの中のテキストファイル(参考資料など)も一緒に取り込まれ、AIが必要になったときだけ読みに行きます
|
||||
(ツール呼び出しに対応したモデルが必要です)。
|
||||
|
||||
> **注意**
|
||||
> - 取り込むのはテキストファイルだけです。スクリプトなどのプログラムは取り込まず、実行もしません。
|
||||
> - スキルの指示は、そのままAIに渡されます。他の人が作ったスキルは、中身を読んでから使ってください。
|
||||
|
||||
> **この章のまとめ**
|
||||
> - ローカルAI(Ollamaなど)を用意すると、翻訳・チャット・画像の読み取りができる。入力内容は外部に送られない
|
||||
> - AI TOOLタブの設定で、バックエンド・アドレス・モデルを選び、接続テストをする
|
||||
> - プロンプトタブとTaggerタブには、別に歯車ボタンの設定がある
|
||||
> - AIと画像生成はVRAMを取り合う。「モデルをアンロード」や「VRAM調整(Ollama)」で解放する
|
||||
> - 標準では自分のパソコンのAIにだけつながる。`--listen` は信頼できるネットワークだけで
|
||||
> - **スキル** で得意分野を切り替える。T2I Prompt Enhancer でプロンプトを作り、ボタンで生成UIに反映・生成できる
|
||||
> - **画像をチェック** をオンにすると、意思決定モデルが生成画像を確かめ、足りない点だけ直して描き直せる
|
||||
> - Workflow Doctor は、実際に確かめた事実をもとにワークフローの不具合を相談にのる
|
||||
> - TOOLSの **文字起こし(OCR)** で画像の文字を読み取り、翻訳やChatへ送れる
|
||||
|
||||
---
|
||||
|
||||
@@ -2763,6 +3055,22 @@ Taggerを使うには、次の準備が必要です。
|
||||
- 編集した画像は、**Save PNG**(ダウンロード)、**Save to Gallery**(ギャラリーに保存)などで保存します。
|
||||
- **Ctrl+Z** で元に戻す、**Ctrl+Y** でやり直しができます。
|
||||
|
||||
### Drawの色とショートカット(v0.9.3〜)
|
||||
**Draw** を選ぶと、ツールの2段目に **カラーパレット** が表示されます。
|
||||
|
||||
- **色1/色2**、基本色20色、カスタム色20枠が並びます。色をクリックすると、その色で描けます。
|
||||
- **+** か空いている枠をクリックすると、今の色をカスタム色に登録します。色の上で右クリックすると、登録・編集・削除ができます(基本色も変えられ、最初の色に戻せます)。
|
||||
登録した色は、ブラウザごとに保存されます。
|
||||
- 右側の **Color** の隣にある **スポイト** ボタンで、キャンバスの色を拾えます(Escで取り消し)。
|
||||
- 描き始めの色は黒(色1)と白(色2)です。
|
||||
|
||||
| キー(Draw中) | 動き |
|
||||
| --- | --- |
|
||||
| D/E | 描く(Draw)/消す(Erase) |
|
||||
| X | 色1と色2を入れ替える |
|
||||
| [ / ] | ブラシを小さく/大きくする(実際は半角の `[` `]`) |
|
||||
| B | ブラシの選択を開く・閉じる(Escでも閉じる) |
|
||||
|
||||
### インペイント(部分の描き直し)
|
||||
Image Editのいちばん便利な使い方が、**インペイント** です。
|
||||
「顔だけ描き直したい」「手が崩れたところだけ直したい」というときに使います。
|
||||
@@ -2977,6 +3285,12 @@ Workflow Studioとは別にインストールするソフトですが、両方
|
||||
カードやGallery点を選ぶと、**選択中** に、そのプロンプトのタグがボタンで並びます。
|
||||
タグをクリックして選び(もう一度で解除)、**選択したタグで空間検索** を押すと、選んだタグで空間を検索します。
|
||||
「この画像の、髪型と服装だけに近いものを探したい」といったときに便利です。
|
||||
タグを選んでいる間は、**選択中** の **追加** ボタンが **選択したタグを追加(N)** に変わり、選んだタグだけをプロンプト欄に追加できます。
|
||||
タグを選んでいなければ、これまでどおり全体(カードはプロンプト全文、Gallery点はすべてのタグ)が追加されます。
|
||||
|
||||
Gallery点を選んでいるときは、**選択中** に **Galleryで開く** ボタンも表示されます。
|
||||
押すとギャラリータブに切り替わり、その画像のフォルダが選ばれ、画像一覧でその画像が選択された状態になります(右の詳細も開きます)。
|
||||
画像が一覧に必ず出るよう、ギャラリーの検索・お気に入り・タグ・グループの絞り込みは解除されます。
|
||||
カードのプロンプト全文は、**プロンプト全文** を開くと読めます。
|
||||
|
||||
---
|
||||
@@ -3066,6 +3380,7 @@ Workflow Studioを更新(アップデート)したあとは、必ずComfyUI
|
||||
### 設定を変えたのに、結果が変わらない
|
||||
いちばん多い原因は、**Apply の押し忘れ** です。
|
||||
生成UIでは、モデルやSettings(ステップ数・サイズなど)の値を変えたあと、**Apply** を押さないとワークフローに反映されません(第5章 5-3)。
|
||||
ただし、Settingsタブの右端のスイッチの列は、クリックした時点で反映されます(第5章 5-4)。
|
||||
右側の **Raw JSON** を見ると、実際に反映されている値を確認できます。
|
||||
|
||||
### 読み込んだワークフローとは違う内容で生成される
|
||||
@@ -3129,6 +3444,21 @@ Ollamaの場合は `ollama pull (モデル名)` でモデルをダウンロ
|
||||
安全のため、標準では同じパソコンの中のAIソフトにしかつながりません。
|
||||
環境変数 `WFS_ALLOWED_BACKEND_HOSTS` の設定が必要です(第10章 10-4)。
|
||||
|
||||
### 画像チェックの判定モデルや、OCRモデルが一覧に出てこない
|
||||
- 判定モデルの一覧には、Ollamaの **意思決定モデル** のうち、画像を読み取れるものだけが出ます。
|
||||
ふつうのチャット用モデルは出ません。Ollamaを新しい版に更新し、意思決定モデルを `ollama pull` してから **更新** を押します(第10章 10-6)。
|
||||
- OCRモデルの一覧には、名前に「ocr」を含むOllamaのモデルだけが出ます。取得後に ↻ を押します(第10章 10-8)。
|
||||
- どちらも、AI TOOLのバックエンドがOllamaならそのアドレス、そうでなければ `http://localhost:11434` のOllamaを見に行きます。
|
||||
|
||||
### 画像チェックで、何度直しても満たされない
|
||||
- 描き直しの上手さは、チャット用モデルの力しだいです。頼み方を短く具体的にするか、別のモデルを試してください。
|
||||
- チェック自体もまちがえることがあります。画像を自分の目でも確かめてください。
|
||||
- 自動修正の回数を上げすぎると、生成の時間とGPUのメモリを使い続けます。最初は「0(ボタンで修正)」がおすすめです。
|
||||
|
||||
### AIがスキルの資料を読まない・チャットから画像を生成しない
|
||||
取り込んだスキルの参考資料を読む機能と、チャットからの画像生成は、
|
||||
**ツール呼び出し(Tool Calling)に対応したモデル** でないと動きません。対応しているモデルに切り替えてください。
|
||||
|
||||
---
|
||||
|
||||
## 12-5 そのほかの機能のトラブル
|
||||
@@ -3166,6 +3496,15 @@ ComfyUIを停止し、ComfyUIのPythonで `pip uninstall -y onnxruntime onnxrunt
|
||||
`.env` にComfy APIキーが無いのが原因です。プラグインの `.env` に `COMFY_API_KEY=キー`(platform.comfy.orgで発行)を書き、ComfyUIを再起動してください(第5章 5-8)。
|
||||
`.env` は起動時にしか読み込まれません。
|
||||
|
||||
### ComfyUIが固まる(どのタブも反応しなくなる)
|
||||
Windowsで、プレビュー画像の多いモデルタブの一覧を切り替えたあとなどに、ComfyUIが反応しなくなる(生成もできなくなる)場合があります。
|
||||
設定タブの **Windowsの安定性(イベントループ)** で、**WindowsでSelectorイベントループを使用する** をオンにして、ComfyUIを再起動してください。
|
||||
|
||||
### ギャラリーの画像の一部が、別の画像のように壊れて見える
|
||||
上の **Selectorイベントループ** をオンにしているとき、v0.9.1までは、ギャラリーのプレビューや Download で受け取った画像の一部が壊れることがありました
|
||||
(出力フォルダのファイル自体は正常です)。v0.9.2で直っています。
|
||||
Workflow Studioを更新したあと、壊れた画像がブラウザに残っていることがあるので、**Ctrl+F5** で再読み込みしてください。
|
||||
|
||||
### ギャラリーに画像が出てこない
|
||||
- ギャラリーの **Refresh** を押します。
|
||||
- 設定タブの「ギャラリー Outputフォルダ」が、ComfyUIの画像保存先と合っているか確認します。
|
||||
@@ -3209,6 +3548,7 @@ Workflow Studioの **ヘルプ** タブにも、トラブルシューティン
|
||||
### 日本語
|
||||
| 用語 | 意味 | 本書 |
|
||||
| --- | --- | --- |
|
||||
| 意思決定モデル | 質問に「はい」の確率などを数字で答えるAIモデル。画像チェックの判定に使う | 第10章 |
|
||||
| インペイント | 画像の一部(マスクした部分)だけを描き直すこと | 第11章 |
|
||||
| カスタムノード | ComfyUIに後から追加するノード・拡張機能。Workflow Studioもその1つ | 第1章 |
|
||||
| キャンバス | ComfyUIのノード画面で、ノードを並べる作業スペース | 第3章 |
|
||||
@@ -3217,6 +3557,7 @@ Workflow Studioの **ヘルプ** タブにも、トラブルシューティン
|
||||
| サンプラー | 画像を描くときのアルゴリズム(euler など) | 第5章 |
|
||||
| シード | 画像のもとになる乱数の種。同じなら、ほぼ同じ画像になる | 第5章 |
|
||||
| セマンティック検索 | 文章や画像の「意味」で画像を探す検索。ギャラリーのSボタンで使う | 第8章 |
|
||||
| スキル | AI TOOLのChatに、役割や答え方を教える指示書。選ぶと得意分野が切り替わる | 第10章 |
|
||||
| スケジューラー | ステップごとの描き込み量の配分のしかた | 第5章 |
|
||||
| スタイル | プロンプトに付け足す雰囲気のテンプレート | 第7章 |
|
||||
| ステップ数 | 描き込みをくり返す回数 | 第5章 |
|
||||
@@ -3252,6 +3593,7 @@ Workflow Studioの **ヘルプ** タブにも、トラブルシューティン
|
||||
| I2I(Image to Image) | 画像をもとに、新しい画像を作ること | 第5章 |
|
||||
| JSON | ワークフローの保存に使われるデータ形式 | 第1章 |
|
||||
| LoRA | 絵柄やキャラクターなどを追加で覚えさせる小さなモデル | 第6章 |
|
||||
| OCR | 画像に写っている文字を読み取ること | 第10章 |
|
||||
| Ollama | ローカルAIを手軽に動かせるソフト | 第10章 |
|
||||
| T2I(Text to Image) | 文章(プロンプト)から画像を作ること | 第5章 |
|
||||
| TagSpace | タグ・プロンプト・生成画像を意味の近さで3Dの空間に並べて表示するタブ | 第11章 |
|
||||
@@ -3302,6 +3644,10 @@ Workflow Studioの **ヘルプ** タブにも、トラブルシューティン
|
||||
| マウスホイール | 拡大・縮小 |
|
||||
| Space+ドラッグ | 表示位置を移動する |
|
||||
| Delete | 選んでいるレイヤーを削除する(レイヤーが2つ以上あるとき) |
|
||||
| D/E(Draw中) | 描く/消す |
|
||||
| X(Draw中) | 色1と色2を入れ替える |
|
||||
| [/](Draw中) | ブラシを小さく/大きくする(実際は半角の `[` `]`) |
|
||||
| B(Draw中) | ブラシの選択を開く・閉じる |
|
||||
|
||||
---
|
||||
|
||||
Binary file not shown.
@@ -129,7 +129,7 @@ ComfyUIの「自由さ」はそのままに、「分かりやすさ」と「手
|
||||
|
||||
## 本書の対象バージョンと環境
|
||||
|
||||
- **Workflow Studio**:v0.9.0
|
||||
- **Workflow Studio**:v0.9.6
|
||||
- **ComfyUI**:本書執筆時点の最新版
|
||||
- **OS**:Windows(画面の例はWindowsのものです)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ Workflow Studioを開くと、画面の上部にタブが横一列に並んで
|
||||
| TagSpace | TagSpace | タグ・プロンプト・生成画像を意味の近さで3D表示し、タグを選ぶ | | 第11章 |
|
||||
| 設定 | Settings | 言語・テーマ・フォルダなどの設定 | | 本章・第11章 |
|
||||
| ヘルプ | Help | 機能一覧・ショートカット・トラブル対処 | | 本章 |
|
||||
| AI TOOL | AI TOOL | ローカルAIによる翻訳・チャット・画像解析 | | 第10章 |
|
||||
| AI TOOL | AI TOOL | ローカルAIによる翻訳・チャット・画像解析・文字起こし(OCR) | | 第10章 |
|
||||
|
||||
### タブは「整理する」「作る」「補助する」の3グループで考える
|
||||
タブが多くて迷ったときは、次の3つのグループに分けて考えると分かりやすくなります。
|
||||
|
||||
@@ -168,12 +168,13 @@ Inputサブタブでは、Raw JSONの上端にある **»** ボタンで、Raw J
|
||||
|
||||
1枚目が生成できたら、次は **Settings** サブタブで、画像の仕上がりを左右する設定を見てみましょう。
|
||||
|
||||
Settingsタブは、左右2つのブロックに分かれています。
|
||||
Settingsタブは、3つの列に分かれています。
|
||||
|
||||
- **KSampler(左)** - 画像をどのように描くかの設定
|
||||
- **Latent Image(右)** - 画像の大きさと枚数の設定
|
||||
- **Latent Image(中央)** - 画像の大きさと枚数の設定
|
||||
- **スイッチ(右、v0.9.6〜)** - ワークフローの中のオン・オフの切り替え
|
||||
|
||||
ここでも、値を変えたら各ブロックの **Apply** を押すのを忘れないようにしましょう。
|
||||
ここでも、KSamplerとLatent Imageは、値を変えたら各ブロックの **Apply** を押すのを忘れないようにしましょう(スイッチの列はApply不要です)。
|
||||
|
||||
### KSampler の項目
|
||||
| 項目 | 意味 | 初心者向けの目安 |
|
||||
@@ -200,6 +201,23 @@ Settingsタブは、左右2つのブロックに分かれています。
|
||||
> モデルが学習したサイズから大きく外れると、人物が二重になったり、構図が崩れたりしやすくなります。
|
||||
> 縦長・横長にしたいときも、合計のピクセル数がおおよそ同じになるように調整しましょう(例:SDXLなら 832×1216 など)。
|
||||
|
||||
### スイッチの項目(v0.9.6〜)
|
||||
ワークフローの中にある「オン・オフ」の設定が、ノード名と項目名つきで一覧になります。
|
||||
チェックボックスをクリックすると、その場で切り替わります。**Apply を押す必要はありません**。切り替えた値は右側の Raw JSON にもすぐ反映されます。
|
||||
|
||||
一覧に出るのは、次の2種類です。
|
||||
|
||||
- 値が true/false の項目 - プロンプト強化(PE)のオン・オフを切り替えるスイッチや「Boolean」ノード、TextGenerate の thinking など
|
||||
- 選択肢が2つだけのオン・オフの項目 - enable/disable、on/off、yes/no など。KSamplerAdvanced の add_noise、DWPreprocessor の detect_hand(手を検出するか)など
|
||||
|
||||
各行の右端には、いまの値(true/false、enable/disable など)が表示されます。
|
||||
行にマウスを乗せると、ノードの番号と種類を確認できます。
|
||||
|
||||
> **ポイント**
|
||||
> 別のノードから線でつながっている項目は、一覧に出ません。代わりに、つながっている元のノード(たとえば「Enable PE?」という名前の Boolean ノード)が出るので、そちらを切り替えてください。
|
||||
> ノードに名前を付けていないワークフローでは、ノードの種類名(ComfySwitchNode など)で表示されます。
|
||||
> オン・オフの項目が1つも無いワークフローでは「このワークフローに true/false の項目はありません」と表示されます。
|
||||
|
||||
### シードの管理(生成パネル)
|
||||
シードは、生成パネルの **Seed** 欄と、その下のプルダウンで管理します。
|
||||
|
||||
@@ -232,6 +250,6 @@ Settingsタブは、左右2つのブロックに分かれています。
|
||||
> **この章(前半)のまとめ**
|
||||
> - ワークフロータブで選んで「生成UIに読み込み」を押すと、生成UIで編集できる
|
||||
> - 基本は Input(プロンプト)・Model(モデル)・Settings(細かい設定)の3つのサブタブ
|
||||
> - モデルやSettingsの値を変えたら必ず **Apply**。押し忘れると変更前の値で生成される(プロンプトは自動で反映)
|
||||
> - モデルやSettingsの値を変えたら必ず **Apply**。押し忘れると変更前の値で生成される(プロンプトとSettingsのスイッチ列は自動で反映)
|
||||
> - Generate で生成。Alt+Apply なら反映と生成を一度に行える
|
||||
> - シードを Fixed にすると、気に入った絵をもとに条件を変えて比べられる。ワークフローのシードをそのまま使うなら Workflow
|
||||
|
||||
@@ -163,6 +163,8 @@ Labでは、「何回目の生成から、どの設定に切り替えるか」
|
||||
4. 生成回数(Batch)を決めます。
|
||||
5. Model・Prompt・KSampler の各列で **+** を押してキーフレームを追加し、
|
||||
キーフレームをダブルクリックして、「何回目から」「どの設定に」するかを入力します。
|
||||
KSampler列のセルには、指定した項目(Step・CFG・Denoise・サンプラー・スケジューラー・Seed)が表示されます。
|
||||
何も指定していないセルは「(ワークフローの設定のまま)」と表示されます。
|
||||
6. **Run** を押します。
|
||||
|
||||
結果は **Results** パネルに一覧表示されます。
|
||||
@@ -172,6 +174,8 @@ Labでは、「何回目の生成から、どの設定に切り替えるか」
|
||||
少しずつ変化していく連続画像を作れます。
|
||||
- **Plan Save** - 設定(プラン)を保存できます。保存すると、結果をまとめた一覧画像も作られ、
|
||||
その画像をドロップすれば同じプランを読み込み直せます。
|
||||
- **物語からプランを作る**(v0.9.5〜) - AI TOOLのChatで **Storyboard to Lab** スキルを使うと、
|
||||
物語をシーンごとのプロンプトに分けたプランを作り、**Labで開く** ボタンでここに読み込めます(第10章 10-5)。
|
||||
|
||||
---
|
||||
|
||||
@@ -217,11 +221,16 @@ v0.8.6〜は、こうしたノードを含むワークフローも生成UIで実
|
||||
| Fibo 2.0 Edit(v0.8.14〜) | 画像編集のワークフロー。プロンプト強化(PE)を使う構成でも、プロンプト欄の編集がPEのオン・オフどちらにも反映される。ネガティブの欄にはVGL形式(JSON)の説明文がそのまま入る。幅・高さ(Settings)は0で自動 |
|
||||
| FastH3 OmniRef/Pika-H3-Flex(v0.8.14〜) | MiniMax H3の「参照画像から動画」(Reference to Video)。動画のワークフローとして扱われ、Videoタブ(第11章)でも使える |
|
||||
| ネイティブLoRAスタック | ComfyUI v0.40のLoRAをまとめて読み込むノード(LoadLoraModel/LoadLoraTextEncoder)。Apply・バイパス・Metadata表示に対応 |
|
||||
| P-H3 Image(v0.9.4〜) | 画像編集とT2Iのテンプレート。プロンプトはInputタブに表示され、Metadataタブでも読める。ネガティブの欄は無い。幅・高さはワークフローのつなぎ方に従い、プロンプトを補う機能(プロンプトアップサンプラー)もワークフローの設定どおりに動く |
|
||||
| MiniMax H3 Image(v0.9.6〜) | ComfyUI公式の画像編集とT2Iのテンプレート。P-H3 Imageと同じく、プロンプトはInputタブに表示され、MetadataタブとサイドパネルのIタブでも読める。ネガティブの欄は無い。プロンプト強化(PE)のオン・オフは、Settingsタブのスイッチの列(5-4)で切り替えられる |
|
||||
|
||||
> **注意**
|
||||
> ネイティブLoRAスタックのノードは、保存形式が **API形式のワークフローのみ** 対応です。
|
||||
> ComfyUI本体の画面がこのノードの行追加にまだ対応していないためで、UI形式のワークフローは対象外です。
|
||||
|
||||
> **ポイント**
|
||||
> 2枚の画像をスライダーで見比べる **Compare Images**(ImageCompare)ノードを含むワークフローは、以前は生成UIから実行すると、このノードだけエラーになっていました(v0.9.6で修正)。
|
||||
|
||||
> **ポイント**
|
||||
> 画像のメタデータを見るMetadataタブとサイドパネルの **I** タブでも、Nano Bananaで作った画像を読めます(v0.8.5〜)。
|
||||
> プロンプトは、Positive/Negativeに分けずに全文が1件で表示されます。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
ComfyUIで生成した画像には、生成に使ったワークフローやプロンプトの情報(**メタデータ**)が埋め込まれています。
|
||||
ギャラリータブでは、この情報を読み取って、同じ設定をすぐに再現できます。
|
||||
また、動画(MP4・WebM)や音声ファイル(WAV・MP3・OGG・M4A・FLAC・Opus)も、画像と同じ一覧で扱えます。
|
||||
また、動画(MP4・WebM)や音声ファイル(WAV・MP3・OGG・M4A・FLAC・Opus)、3Dモデル(GLB・glTF・VRM・PLY・SPZ)も、画像と同じ一覧で扱えます。
|
||||
|
||||
---
|
||||
|
||||
@@ -297,6 +297,30 @@ ComfyUIで音楽や効果音を作る機会が増えたため、ギャラリー
|
||||
> **ポイント:Videoタブでの活用**
|
||||
> Video の Asset サブタブでは、音声ファイルを「BGMに設定」したり、「サウンドとして追加」したりできます(第11章 11-4)。
|
||||
|
||||
---
|
||||
|
||||
## 8-8 3Dモデルを見る(v0.9.4〜)
|
||||
|
||||
ComfyUIで3Dモデルを作るワークフロー(画像から3Dなど)の結果も、ギャラリーで確かめられます。
|
||||
対応しているのは、GLB・glTF・VRM・PLY(メッシュ/点群/ガウシアンスプラット)・SPZ です。
|
||||
|
||||
- 一覧には「**3D**」のバッジ付きで並びます。サムネイルは、最初に一覧に出たときにブラウザが描いてサーバーに保存します
|
||||
(128MBを超えるファイルは、開いたときに保存します)。
|
||||
- 選ぶと、右の詳細パネルの内蔵ビューアで立体として表示されます。ダブルクリックで大きく表示できます。
|
||||
|
||||
| 操作 | 動き |
|
||||
| --- | --- |
|
||||
| ドラッグ | 回転する |
|
||||
| 右ドラッグ | 位置を動かす |
|
||||
| マウスホイール | 拡大・縮小 |
|
||||
| ⟲(リセット)ボタン | 最初の向きに戻す |
|
||||
|
||||
- ComfyUIの **SaveGLB** で保存したモデルは、画像と同じようにワークフローを読み取れます(8-2・8-3)。
|
||||
|
||||
> **注意**
|
||||
> - 3Dファイルは、Image Edit・生成UI・Tagger・Feederには送れません。
|
||||
> - `.gltf` の中には、別の `.bin` や画像ファイルを参照しているものがあります。ギャラリーで移動・削除しても動くのは `.gltf` 本体だけなので、参照しているファイルの場所は変えないでください。
|
||||
|
||||
> **この章のまとめ**
|
||||
> - ギャラリーのOutputで、生成した画像をフォルダごとに見返せる。プロンプトの言葉でも検索できる
|
||||
> - 詳細パネルのプロンプトタブで、画像の生成に使ったプロンプトがすぐ分かる
|
||||
@@ -305,3 +329,4 @@ ComfyUIで音楽や効果音を作る機会が増えたため、ギャラリー
|
||||
> - メタデータサブタブに画像をドロップすると、モデル・LoRA・プロンプトを詳しく調べられる
|
||||
> - Sボタンで、文章の意味や「似た画像」から探せる(セマンティック検索)
|
||||
> - 音声ファイルも一覧・再生・変換できる
|
||||
> - 3Dモデル(GLB・glTF・VRM・PLY・SPZ)も、ギャラリーで回して確かめられる(8-8)
|
||||
|
||||
@@ -7,6 +7,9 @@ Workflow Studioは、パソコンの中で動く **ローカルAI(大規模言
|
||||
- AIとチャットしながら、プロンプトのアイデアを出してもらう
|
||||
- 画像を見せて、その画像の説明やプロンプト、タグを作ってもらう
|
||||
- ワイルドカード用の候補リストを作ってもらう
|
||||
- **スキル** を選んで、プロンプト作り・ストーリーボード・不具合の相談など、得意分野を切り替える(v0.9.5〜)
|
||||
- 生成した画像が依頼どおりかをAIにチェックさせて、足りない点だけ直す(v0.9.5〜)
|
||||
- 画像に写っている文字を読み取る(OCR、v0.9.5〜)
|
||||
|
||||
インターネット上のサービスではなく、自分のパソコンの中で動くので、
|
||||
**入力した内容や画像が外部に送られない** のが大きな特長です。
|
||||
@@ -69,10 +72,14 @@ Workflow Studio本体の **AI TOOL** タブを開きます。
|
||||
| 部分 | できること |
|
||||
| --- | --- |
|
||||
| 翻訳 | 日本語・英語・中国語などの翻訳 |
|
||||
| Chat | AIとの会話。条件がそろえば、会話から画像生成も |
|
||||
| TOOLS | 画像の読み取り(VLM)、ワイルドカード作成 |
|
||||
| Chat | AIとの会話。スキルで得意分野を切り替えられる。条件がそろえば、会話から画像生成も |
|
||||
| TOOLS | 画像の読み取り(VLM)、文字起こし(OCR)、ワイルドカード作成 |
|
||||
| 設定 | 使うAIソフト・モデルの設定 |
|
||||
|
||||
> **ポイント:翻訳と設定はたためる(v0.9.5〜)**
|
||||
> 翻訳と設定の見出しの右端にある矢印(◀/▶)を押すと、その部分が細い帯にたたまれ、空いた幅をChatとTOOLSが使います。
|
||||
> 帯のどこかをクリックすると元に戻ります。たたんだ状態は、次に開いたときも保たれます。
|
||||
|
||||
### 最初に:設定でAIソフトとつなぐ
|
||||
1. **設定** の **バックエンド** で、使うソフト(例:Ollama)を選びます。
|
||||
2. **接続設定** のアドレスを確認します。Ollamaを標準のまま使っている場合は、変更不要です。
|
||||
@@ -96,6 +103,9 @@ Workflow Studio本体の **AI TOOL** タブを開きます。
|
||||
2. 入力欄に文章を書き、**翻訳** を押します。
|
||||
3. 結果を **コピー** して、生成UIやプリセットに貼り付けます。
|
||||
|
||||
> **ポイント**
|
||||
> TOOLSで読み取った文字(10-8のOCRなど)は、TOOLSの **翻訳へ送る** で、この入力欄にそのまま入れられます。
|
||||
|
||||
> **ポイント**
|
||||
> 小さなモデルは、翻訳せずに元の文章をそのまま返してしまうことがあります。
|
||||
> その場合は「モデルが原文をそのまま返しました」と表示されるので、別のモデルを試してください。
|
||||
@@ -110,6 +120,10 @@ Workflow Studio本体の **AI TOOL** タブを開きます。
|
||||
> チャットで「猫の絵を描いて」と頼むだけで、生成UIに読み込んだワークフローで画像が生成され、チャット内に表示されます。
|
||||
> この機能は、入力欄の横の **画像生成を許可する** のチェックでオン・オフできます。
|
||||
|
||||
> **ポイント**
|
||||
> Chatの上にあるドロップダウンで **スキル** を選ぶと、AIの得意分野を切り替えられます(10-5)。
|
||||
> AIの返事は、見出し・箇条書き・コードの枠を整えて表示されます(v0.9.5〜)。
|
||||
|
||||
### TOOLS - 画像を読み取る(VLM)
|
||||
画像入力に対応したモデル(VLM)を使うと、画像の内容をAIに読み取らせることができます。
|
||||
|
||||
@@ -122,8 +136,16 @@ Workflow Studio本体の **AI TOOL** タブを開きます。
|
||||
| プロンプト作成 | 画像を再現するためのプロンプトを作る |
|
||||
| Create tags(英語表示のまま) | 画像の要素をタグ形式で書き出す |
|
||||
| Create wildcards(英語表示のまま) | カテゴリー名と件数を指定して、ワイルドカードの候補を作る(画像は不要) |
|
||||
| 文字起こし(OCR) | 画像に写っている文字を読み取る(10-8) |
|
||||
|
||||
3. **実行** を押します。結果はコピーして使えます。
|
||||
3. **実行** を押します。
|
||||
4. 結果の下のボタンで、結果を使います(v0.9.5〜)。
|
||||
|
||||
| ボタン | 動き |
|
||||
| --- | --- |
|
||||
| 翻訳へ送る | 翻訳の入力欄に入れる(翻訳がたたまれていれば開く) |
|
||||
| Chatへ送る | Chatの入力欄に追加する |
|
||||
| コピー | クリップボードにコピーする |
|
||||
|
||||
> **例**
|
||||
> 気に入ったイラストの雰囲気をまねしたいとき、その画像から「プロンプト作成」をして、
|
||||
@@ -190,9 +212,227 @@ ComfyUI(とWorkflow Studio)にアクセスできるようになります。
|
||||
> `--listen` は、家庭内LANなど **信頼できるネットワークでだけ** 使ってください。
|
||||
> カフェや公共のWi-Fiでは使わないようにしましょう。
|
||||
|
||||
---
|
||||
|
||||
## 10-5 スキル - Chatの得意分野を切り替える(v0.9.5〜)
|
||||
|
||||
**スキル** は、Chatに「どんな役割で、どんな形で答えるか」を教える指示書です。
|
||||
Chatの上のドロップダウン(最初は「-- スキルなし --」)で選ぶと、そのスキルの指示が毎回の会話に付けて送られます
|
||||
(指示の文章はチャットの画面には表示されません)。
|
||||
|
||||
### 最初から入っているスキル
|
||||
Workflow Studioには、次の6つのスキルが入っています(名前は英語表示のままです)。
|
||||
|
||||
| スキル | 何をするか | 結果の使い方 |
|
||||
| --- | --- | --- |
|
||||
| T2I Prompt Enhancer | 短いアイデアを、生成UIのワークフローのモデルに合った書き方のプロンプトにする | プロンプトの下のボタン |
|
||||
| Image to Prompt | TOOLSに入れた画像を再現するプロンプトを書く(画像を読み取れるモデルが必要) | プロンプトの下のボタン |
|
||||
| Storyboard to Lab | 物語をシーンごとのプロンプトに分け、Labで連続生成できる形にする | **Labで開く** |
|
||||
| Workflow Doctor | ワークフローの説明や、エラーの原因の相談にのる(10-7) | 文章で答える |
|
||||
| SVG Icon Generator | 簡単なアイコンやイラストをSVG(ベクター画像)で描く | プレビューと **Galleryに保存** |
|
||||
| Skill Creator | 質問に答えていくと、新しいスキルを作ってくれる(10-9) | **スキルとして保存** |
|
||||
|
||||
### プロンプトを作って、そのまま生成する(T2I Prompt Enhancer)
|
||||
1. 生成UIに、使うワークフローを読み込みます。スキルはこのワークフローのモデルを見て、プロンプトの書き方を決めます。
|
||||
2. AI TOOLのChatで、ドロップダウンから **T2I Prompt Enhancer** を選びます。
|
||||
3. 「雨の夜の東京、ネオン、傘を持つ猫」のように、日本語で作りたい絵を書いて送信します。
|
||||
4. 返事に、選んだ書き方(`Style:` の行)と、英語のプロンプトの枠が表示されます。その下のボタンで使います。
|
||||
|
||||
| ボタン | 動き |
|
||||
| --- | --- |
|
||||
| GenerateUIに反映 | 生成UIのポジティブ/ネガティブの欄に書き込む |
|
||||
| このプロンプトで生成 | その場で生成し、チャットに画像を表示する |
|
||||
| コピー | ポジティブプロンプトをコピーする |
|
||||
|
||||
モデルに合わせた書き方は、次のとおりです。
|
||||
|
||||
| 生成UIのモデル | プロンプトの書き方 |
|
||||
| --- | --- |
|
||||
| Qwen-Image、FLUX、Z-Image など | 英語の文章(ネガティブは、CFGが1前後なら空にする) |
|
||||
| Illustrious、NoobAI、Pony など(SDXLのアニメ系) | Danbooruのタグ(品質タグから始める) |
|
||||
| 写実系のSDXL、SD1.5 | 短い言葉をカンマで並べる |
|
||||
|
||||
> **ポイント**
|
||||
> 「もっと夜らしく」「猫を2匹に」のように続けて頼むと、直したプロンプト全体を出し直します。
|
||||
|
||||
> **ポイント:今入っているプロンプトは渡さない**
|
||||
> 生成UIに今入っているプロンプトは、ふだんはAIに渡しません。渡すと、小さなモデルが古いネガティブプロンプトなどを新しいプロンプトに混ぜてしまうためです。
|
||||
> 「**今のプロンプトを改善して**」のように、今のプロンプトに触れて頼んだときだけ渡します。
|
||||
|
||||
### ストーリーボードから連続生成する(Storyboard to Lab)
|
||||
1. 生成UIに、文章から画像を作る(T2I)ワークフローを読み込みます。
|
||||
2. Chatで **Storyboard to Lab** を選び、「茶トラの子猫が朝目覚めて、庭で蝶を追いかけ、夕方に縁側で昼寝する。3シーンで」のように頼みます。
|
||||
3. 返事に、シーンの一覧と **Labで開く(3シーン)** ボタンが表示されます。ボタンを押します。
|
||||
4. 生成UIの **Lab**(第5章 5-7)が開き、まだ保存していないプランとして読み込まれます。
|
||||
T2Iモードになり、Batchがシーンの数に、Prompt列がシーンごとのキーフレームになります。KSampler列には、全シーン共通のシード(`Seed …`)が入ります。
|
||||
5. 内容を確かめて **Run** を押します。残したいときは **Plan Save** で保存します。
|
||||
|
||||
> **ポイント**
|
||||
> 登場人物の見た目(髪・服の色など)は、全シーンで同じ文章が入るように書かれ、シードも固定されます。
|
||||
> そのため、シーンが変わっても同じキャラクターに見えやすくなります。
|
||||
|
||||
### 返事の自動チェック
|
||||
プロンプトやSVGを出すスキルでは、Workflow Studioが返事の形を自動で確かめます。
|
||||
プロンプトの枠が崩れている、SVGが閉じていない、といった問題があると、
|
||||
点線の枠で「**自動チェック:…**」と表示し、AIに1回だけ直させます。
|
||||
説明だけを頼んだときなど、プロンプトやSVGを出していない返事はそのままにします。
|
||||
|
||||
### スキルの管理(鉛筆ボタン)
|
||||
ドロップダウンの右の鉛筆ボタンで、スキルの一覧が開きます。
|
||||
|
||||
- **+ 新規** で新しいスキルを書く、各行の鉛筆ボタンで編集・削除する、**取り込み** で他のスキルを追加する(10-9)
|
||||
- 最初から入っているスキルには **同梱** と表示されます。
|
||||
編集していない同梱スキルは、Workflow Studioを更新すると自動で新しい版になります。
|
||||
編集したスキルは自動では置き換わらず、新しい版があると **↻(更新)** ボタンが表示されます。
|
||||
- スキルのファイルは `ComfyUI/user/default/Workflow-Studio/ai_skills/` に保存されます。
|
||||
|
||||
> **注意**
|
||||
> ↻(更新)で新しい版に置き換えると、そのスキルに加えた編集は消えます。
|
||||
|
||||
---
|
||||
|
||||
## 10-6 生成した画像をチェックして直す(v0.9.5〜)
|
||||
|
||||
チャットから生成した画像が、頼んだとおりになっているかをAIに確かめさせる機能です。
|
||||
足りないところがあれば、その点だけを直したプロンプトで描き直せます。
|
||||
|
||||
### 準備:判定用のモデル
|
||||
チェックには、ふつうのチャット用モデルではなく、Ollamaの **意思決定モデル**(はい/いいえを確率で答えるモデル)を使います。
|
||||
画像を読み取れるもの(作者の環境では `d3:4b`、`clef-flash`、`clef` で動作を確認)を、`ollama pull` で用意します。
|
||||
|
||||
> **ポイント:なぜ専用のモデルを使うのか**
|
||||
> 作者の検証では、チャット用のモデルに画像を見せて「剣を持っているか」と聞くと、持っていないのに「はい」と答えることがありました。
|
||||
> 意思決定モデルは「はい」の確率を数字で返すので、あいまいな答えを見分けられます。
|
||||
|
||||
1. AI TOOLの **設定** にある **CHAT 画像チェック** で、**判定モデル(Ollamaの意思決定モデル)** を選びます(一覧が空なら **更新**)。
|
||||
2. **自動修正の回数** を選びます。最初は **0(ボタンで修正)** がおすすめです。
|
||||
3. **設定を保存** を押します。
|
||||
|
||||
### 使い方
|
||||
1. Chatの入力欄の下にある **画像をチェック** にチェックを入れます。
|
||||
2. チャットから画像を生成します(AIの画像生成、または **このプロンプトで生成**)。
|
||||
3. 画像の下に結果のカードが表示されます。AIが頼んだ内容を3〜8個の確認項目に分け、1つずつ確かめた結果です。
|
||||
|
||||
| 表示 | 意味 |
|
||||
| --- | --- |
|
||||
| ✔(満たしている) | 「はい」の確率が85%以上 |
|
||||
| ?(はっきりしない) | 30〜85% |
|
||||
| ✘(満たしていない) | 30%未満 |
|
||||
|
||||
4. ✘や?があるときは **足りない点を直して再生成** を押します。満たしていない項目だけをAIに伝え、プロンプトを書き直して描き直します。
|
||||
|
||||
自動修正の回数を1〜3にすると、ボタンを押さなくても、その回数まで自動でくり返します。
|
||||
|
||||
> **ポイント:SVGもチェックできる**
|
||||
> SVG Icon Generatorで描いたSVGも、画像にしてから「頼んだものに見えるか」「部品(くちばし・翼など)が描けているか」をチェックします。
|
||||
> **分かりにくい部分を描き直させる** で直させられます。
|
||||
|
||||
> **注意**
|
||||
> - チェックは完ぺきではありません。とくに「誰が何を身につけているか」の取り違えに弱いため、
|
||||
> 人物が複数いるときは「帽子は女の子ではない」のような逆向きの確認項目を自動で加えて補っています。
|
||||
> - 描き直しの上手さは、チャット用モデルの力しだいです。直らないときは、頼み方を変えるか、モデルを変えてください。
|
||||
> - 判定モデルもGPUのメモリを使います(`d3:4b` で約6GB)。読み込んだあとは、1回のチェックが数秒です。
|
||||
|
||||
---
|
||||
|
||||
## 10-7 ワークフローの不具合を相談する(Workflow Doctor、v0.9.5〜)
|
||||
|
||||
**Workflow Doctor** は、生成UIに読み込んだワークフローの説明や、エラーの原因の相談にのるスキルです。
|
||||
AIの知識だけに頼らず、送信するたびにWorkflow Studioが実際に確かめた次の内容をAIに渡します。
|
||||
|
||||
| 確かめる内容 | 例 |
|
||||
| --- | --- |
|
||||
| インストールされていないノード | ComfyUIが知らないノードの名前 |
|
||||
| ComfyUIの一覧に無いファイル・設定値 | 見つからないモデルやLoRA(似た名前の候補も) |
|
||||
| 線のつながり | つながっていない入力、存在しないノードへの線 |
|
||||
| 直近の実行エラー | ComfyUIの履歴に残ったエラーの内容 |
|
||||
| ComfyUIのログ | ログに出た最新のエラーの部分 |
|
||||
|
||||
### 使い方
|
||||
1. 相談したいワークフローを生成UIに読み込みます(エラーの相談なら、一度実行してエラーを出しておきます)。
|
||||
2. Chatで **Workflow Doctor** を選びます。
|
||||
3. 「実行する前に問題がないか確認して」「さっきのエラーの原因は?」のように聞きます。
|
||||
|
||||
> **例**
|
||||
> LoRAのファイルを別のフォルダに移したワークフローで聞くと、
|
||||
> 「`temp\` のフォルダに指定されたファイルが見つからない。`Illustrious\` に同じ名前のファイルがある」と、直し方まで答えました。
|
||||
|
||||
> **ポイント**
|
||||
> 確かめる内容は、送信のたびに作り直されます。直したあとは、もう一度聞いてみてください。
|
||||
|
||||
---
|
||||
|
||||
## 10-8 画像の文字を読み取る(OCR、v0.9.5〜)
|
||||
|
||||
TOOLSで、画像に写っている文字を読み取れます。読み取った文字は、そのまま翻訳やChatに送れます。
|
||||
|
||||
### 準備:OCR用のモデル
|
||||
OCRには、チャット用とは別の、文字を読むのが得意なモデルを使います。Ollamaで用意してください。
|
||||
作者の環境で、横書きと縦書きの日本語・英語がまじった画像を読ませた結果は次のとおりです。
|
||||
|
||||
| モデル | 時間 | 読み取り |
|
||||
| --- | --- | --- |
|
||||
| LightOnOCR-3(`maternion/LightOnOCR-3:4b`) | 約1秒(位置つきは約6〜8秒) | すべて読めた |
|
||||
| PaddleOCR-VL(マンガ向けの版) | 約4秒 | すべて読めた |
|
||||
| DeepSeek-OCR系 | 約14秒 | すべて読めた(いつも位置つき) |
|
||||
| PaddleOCR-VL 1.6、GLM-OCR | 約11〜15秒 | 縦書きを読み落とした |
|
||||
|
||||
### 使い方
|
||||
1. TOOLSに画像をドロップします。
|
||||
2. 作業で **文字起こし(OCR)** を選びます。
|
||||
3. **OCRモデル** を選びます。名前に「ocr」を含むOllamaのモデルが、向いている順に並びます(一覧が空なら ↻ を押します)。
|
||||
4. **実行** を押します。読み取った文字が、結果の欄に1行ずつ表示されます。
|
||||
|
||||
> **ポイント:文字の位置を表示**
|
||||
> **文字の位置を表示** にチェックを入れると、画像の上に番号つきの枠が重なり、どこの文字かが分かります。
|
||||
> 枠をクリックすると、結果の欄の同じ行が選ばれます。
|
||||
> 位置を返せるのは LightOnOCR と DeepSeek-OCR系だけです。ほかのモデルでは、このチェックは選べません。
|
||||
|
||||
> **ポイント**
|
||||
> 結果が空だったり、同じ行がくり返されたりしたときは、自動で白黒(グレースケール)にして1回だけ読み直します。
|
||||
|
||||
---
|
||||
|
||||
## 10-9 スキルを自作・取り込む(上級、v0.9.5〜)
|
||||
|
||||
### Skill Creatorで作る
|
||||
Chatで **Skill Creator** を選び、「〇〇が得意なスキルを作りたい」と話しかけます。
|
||||
AIが1つずつ質問してくるので答えていくと、スキルの下書きができます。
|
||||
返事の下の **スキルとして保存** を押すと、スキルの一覧に下書きが入った状態で開くので、確かめて保存します。
|
||||
「このスキルの、ここがうまくいかない」と相談して、今あるスキルを直してもらうこともできます。
|
||||
|
||||
### スキルのファイルの中身
|
||||
スキルは、テキストファイル(`.md`)です。先頭の `---` で囲んだ部分に名前と説明を書き、その下にAIへの指示を書きます。
|
||||
先頭の部分の `metadata:` には、次の設定も書けます(書かなくてもかまいません)。
|
||||
|
||||
| 設定 | 意味 |
|
||||
| --- | --- |
|
||||
| `output` | `prompt` か `svg`。返事の形を自動でチェックし、ボタンを付ける |
|
||||
| `tools` | `[]` にすると、AIが自分で画像生成を始めない |
|
||||
| `temperature` | 答えのばらつき(0.3なら安定、0.9なら自由) |
|
||||
| `think` | 考える(Thinking)モードを、このスキルだけオン/オフにする |
|
||||
| `context` | AIに渡す情報。`workflow`(ワークフローの要約)、`current-prompt`(今のプロンプト)、`workflow-detail`/`logs`(Workflow Doctorと同じ確認内容) |
|
||||
|
||||
### ほかのスキルを取り込む
|
||||
スキルの一覧の **取り込み** で、次のファイルを追加できます。
|
||||
|
||||
- `.md` のスキル1つ
|
||||
- `SKILL.md` を含むフォルダをZIPにしたもの(**Agent Skills** という、多くのAIツールで共通の形式)
|
||||
|
||||
フォルダの中のテキストファイル(参考資料など)も一緒に取り込まれ、AIが必要になったときだけ読みに行きます
|
||||
(ツール呼び出しに対応したモデルが必要です)。
|
||||
|
||||
> **注意**
|
||||
> - 取り込むのはテキストファイルだけです。スクリプトなどのプログラムは取り込まず、実行もしません。
|
||||
> - スキルの指示は、そのままAIに渡されます。他の人が作ったスキルは、中身を読んでから使ってください。
|
||||
|
||||
> **この章のまとめ**
|
||||
> - ローカルAI(Ollamaなど)を用意すると、翻訳・チャット・画像の読み取りができる。入力内容は外部に送られない
|
||||
> - AI TOOLタブの設定で、バックエンド・アドレス・モデルを選び、接続テストをする
|
||||
> - プロンプトタブとTaggerタブには、別に歯車ボタンの設定がある
|
||||
> - AIと画像生成はVRAMを取り合う。「モデルをアンロード」や「VRAM調整(Ollama)」で解放する
|
||||
> - 標準では自分のパソコンのAIにだけつながる。`--listen` は信頼できるネットワークだけで
|
||||
> - **スキル** で得意分野を切り替える。T2I Prompt Enhancer でプロンプトを作り、ボタンで生成UIに反映・生成できる
|
||||
> - **画像をチェック** をオンにすると、意思決定モデルが生成画像を確かめ、足りない点だけ直して描き直せる
|
||||
> - Workflow Doctor は、実際に確かめた事実をもとにワークフローの不具合を相談にのる
|
||||
> - TOOLSの **文字起こし(OCR)** で画像の文字を読み取り、翻訳やChatへ送れる
|
||||
|
||||
@@ -103,6 +103,22 @@ Taggerを使うには、次の準備が必要です。
|
||||
- 編集した画像は、**Save PNG**(ダウンロード)、**Save to Gallery**(ギャラリーに保存)などで保存します。
|
||||
- **Ctrl+Z** で元に戻す、**Ctrl+Y** でやり直しができます。
|
||||
|
||||
### Drawの色とショートカット(v0.9.3〜)
|
||||
**Draw** を選ぶと、ツールの2段目に **カラーパレット** が表示されます。
|
||||
|
||||
- **色1/色2**、基本色20色、カスタム色20枠が並びます。色をクリックすると、その色で描けます。
|
||||
- **+** か空いている枠をクリックすると、今の色をカスタム色に登録します。色の上で右クリックすると、登録・編集・削除ができます(基本色も変えられ、最初の色に戻せます)。
|
||||
登録した色は、ブラウザごとに保存されます。
|
||||
- 右側の **Color** の隣にある **スポイト** ボタンで、キャンバスの色を拾えます(Escで取り消し)。
|
||||
- 描き始めの色は黒(色1)と白(色2)です。
|
||||
|
||||
| キー(Draw中) | 動き |
|
||||
| --- | --- |
|
||||
| D/E | 描く(Draw)/消す(Erase) |
|
||||
| X | 色1と色2を入れ替える |
|
||||
| [ / ] | ブラシを小さく/大きくする(実際は半角の `[` `]`) |
|
||||
| B | ブラシの選択を開く・閉じる(Escでも閉じる) |
|
||||
|
||||
### インペイント(部分の描き直し)
|
||||
Image Editのいちばん便利な使い方が、**インペイント** です。
|
||||
「顔だけ描き直したい」「手が崩れたところだけ直したい」というときに使います。
|
||||
@@ -317,6 +333,12 @@ Workflow Studioとは別にインストールするソフトですが、両方
|
||||
カードやGallery点を選ぶと、**選択中** に、そのプロンプトのタグがボタンで並びます。
|
||||
タグをクリックして選び(もう一度で解除)、**選択したタグで空間検索** を押すと、選んだタグで空間を検索します。
|
||||
「この画像の、髪型と服装だけに近いものを探したい」といったときに便利です。
|
||||
タグを選んでいる間は、**選択中** の **追加** ボタンが **選択したタグを追加(N)** に変わり、選んだタグだけをプロンプト欄に追加できます。
|
||||
タグを選んでいなければ、これまでどおり全体(カードはプロンプト全文、Gallery点はすべてのタグ)が追加されます。
|
||||
|
||||
Gallery点を選んでいるときは、**選択中** に **Galleryで開く** ボタンも表示されます。
|
||||
押すとギャラリータブに切り替わり、その画像のフォルダが選ばれ、画像一覧でその画像が選択された状態になります(右の詳細も開きます)。
|
||||
画像が一覧に必ず出るよう、ギャラリーの検索・お気に入り・タグ・グループの絞り込みは解除されます。
|
||||
カードのプロンプト全文は、**プロンプト全文** を開くと読めます。
|
||||
|
||||
---
|
||||
|
||||
@@ -72,6 +72,7 @@ Workflow Studioを更新(アップデート)したあとは、必ずComfyUI
|
||||
### 設定を変えたのに、結果が変わらない
|
||||
いちばん多い原因は、**Apply の押し忘れ** です。
|
||||
生成UIでは、モデルやSettings(ステップ数・サイズなど)の値を変えたあと、**Apply** を押さないとワークフローに反映されません(第5章 5-3)。
|
||||
ただし、Settingsタブの右端のスイッチの列は、クリックした時点で反映されます(第5章 5-4)。
|
||||
右側の **Raw JSON** を見ると、実際に反映されている値を確認できます。
|
||||
|
||||
### 読み込んだワークフローとは違う内容で生成される
|
||||
@@ -135,6 +136,21 @@ Ollamaの場合は `ollama pull (モデル名)` でモデルをダウンロ
|
||||
安全のため、標準では同じパソコンの中のAIソフトにしかつながりません。
|
||||
環境変数 `WFS_ALLOWED_BACKEND_HOSTS` の設定が必要です(第10章 10-4)。
|
||||
|
||||
### 画像チェックの判定モデルや、OCRモデルが一覧に出てこない
|
||||
- 判定モデルの一覧には、Ollamaの **意思決定モデル** のうち、画像を読み取れるものだけが出ます。
|
||||
ふつうのチャット用モデルは出ません。Ollamaを新しい版に更新し、意思決定モデルを `ollama pull` してから **更新** を押します(第10章 10-6)。
|
||||
- OCRモデルの一覧には、名前に「ocr」を含むOllamaのモデルだけが出ます。取得後に ↻ を押します(第10章 10-8)。
|
||||
- どちらも、AI TOOLのバックエンドがOllamaならそのアドレス、そうでなければ `http://localhost:11434` のOllamaを見に行きます。
|
||||
|
||||
### 画像チェックで、何度直しても満たされない
|
||||
- 描き直しの上手さは、チャット用モデルの力しだいです。頼み方を短く具体的にするか、別のモデルを試してください。
|
||||
- チェック自体もまちがえることがあります。画像を自分の目でも確かめてください。
|
||||
- 自動修正の回数を上げすぎると、生成の時間とGPUのメモリを使い続けます。最初は「0(ボタンで修正)」がおすすめです。
|
||||
|
||||
### AIがスキルの資料を読まない・チャットから画像を生成しない
|
||||
取り込んだスキルの参考資料を読む機能と、チャットからの画像生成は、
|
||||
**ツール呼び出し(Tool Calling)に対応したモデル** でないと動きません。対応しているモデルに切り替えてください。
|
||||
|
||||
---
|
||||
|
||||
## 12-5 そのほかの機能のトラブル
|
||||
@@ -172,6 +188,15 @@ ComfyUIを停止し、ComfyUIのPythonで `pip uninstall -y onnxruntime onnxrunt
|
||||
`.env` にComfy APIキーが無いのが原因です。プラグインの `.env` に `COMFY_API_KEY=キー`(platform.comfy.orgで発行)を書き、ComfyUIを再起動してください(第5章 5-8)。
|
||||
`.env` は起動時にしか読み込まれません。
|
||||
|
||||
### ComfyUIが固まる(どのタブも反応しなくなる)
|
||||
Windowsで、プレビュー画像の多いモデルタブの一覧を切り替えたあとなどに、ComfyUIが反応しなくなる(生成もできなくなる)場合があります。
|
||||
設定タブの **Windowsの安定性(イベントループ)** で、**WindowsでSelectorイベントループを使用する** をオンにして、ComfyUIを再起動してください。
|
||||
|
||||
### ギャラリーの画像の一部が、別の画像のように壊れて見える
|
||||
上の **Selectorイベントループ** をオンにしているとき、v0.9.1までは、ギャラリーのプレビューや Download で受け取った画像の一部が壊れることがありました
|
||||
(出力フォルダのファイル自体は正常です)。v0.9.2で直っています。
|
||||
Workflow Studioを更新したあと、壊れた画像がブラウザに残っていることがあるので、**Ctrl+F5** で再読み込みしてください。
|
||||
|
||||
### ギャラリーに画像が出てこない
|
||||
- ギャラリーの **Refresh** を押します。
|
||||
- 設定タブの「ギャラリー Outputフォルダ」が、ComfyUIの画像保存先と合っているか確認します。
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
### 日本語
|
||||
| 用語 | 意味 | 本書 |
|
||||
| --- | --- | --- |
|
||||
| 意思決定モデル | 質問に「はい」の確率などを数字で答えるAIモデル。画像チェックの判定に使う | 第10章 |
|
||||
| インペイント | 画像の一部(マスクした部分)だけを描き直すこと | 第11章 |
|
||||
| カスタムノード | ComfyUIに後から追加するノード・拡張機能。Workflow Studioもその1つ | 第1章 |
|
||||
| キャンバス | ComfyUIのノード画面で、ノードを並べる作業スペース | 第3章 |
|
||||
@@ -15,6 +16,7 @@
|
||||
| サンプラー | 画像を描くときのアルゴリズム(euler など) | 第5章 |
|
||||
| シード | 画像のもとになる乱数の種。同じなら、ほぼ同じ画像になる | 第5章 |
|
||||
| セマンティック検索 | 文章や画像の「意味」で画像を探す検索。ギャラリーのSボタンで使う | 第8章 |
|
||||
| スキル | AI TOOLのChatに、役割や答え方を教える指示書。選ぶと得意分野が切り替わる | 第10章 |
|
||||
| スケジューラー | ステップごとの描き込み量の配分のしかた | 第5章 |
|
||||
| スタイル | プロンプトに付け足す雰囲気のテンプレート | 第7章 |
|
||||
| ステップ数 | 描き込みをくり返す回数 | 第5章 |
|
||||
@@ -50,6 +52,7 @@
|
||||
| I2I(Image to Image) | 画像をもとに、新しい画像を作ること | 第5章 |
|
||||
| JSON | ワークフローの保存に使われるデータ形式 | 第1章 |
|
||||
| LoRA | 絵柄やキャラクターなどを追加で覚えさせる小さなモデル | 第6章 |
|
||||
| OCR | 画像に写っている文字を読み取ること | 第10章 |
|
||||
| Ollama | ローカルAIを手軽に動かせるソフト | 第10章 |
|
||||
| T2I(Text to Image) | 文章(プロンプト)から画像を作ること | 第5章 |
|
||||
| TagSpace | タグ・プロンプト・生成画像を意味の近さで3Dの空間に並べて表示するタブ | 第11章 |
|
||||
@@ -100,6 +103,10 @@
|
||||
| マウスホイール | 拡大・縮小 |
|
||||
| Space+ドラッグ | 表示位置を移動する |
|
||||
| Delete | 選んでいるレイヤーを削除する(レイヤーが2つ以上あるとき) |
|
||||
| D/E(Draw中) | 描く/消す |
|
||||
| X(Draw中) | 色1と色2を入れ替える |
|
||||
| [/](Draw中) | ブラシを小さく/大きくする(実際は半角の `[` `]`) |
|
||||
| B(Draw中) | ブラシの選択を開く・閉じる |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
Don't Be Afraid of Nodes - Generate Images More Easily and Keep Them Organized
|
||||
|
||||
- Covers: Workflow Studio v0.9.0
|
||||
- Covers: Workflow Studio v0.9.6
|
||||
- Author: ketle-man
|
||||
- (c) 2026 ketle-man
|
||||
|
||||
This is the **text-only edition** (without screenshots) of the Kindle book.
|
||||
Chapter files are in the `chapters/` folder; the whole book in one file is `Workflow_Studio_Manual_v0.9.0_en.md`.
|
||||
Chapter files are in the `chapters/` folder; the whole book in one file is `Workflow_Studio_Manual_v0.9.6_en.md`.
|
||||
Screen examples are omitted, so please use the book together with Workflow Studio's Help tab.
|
||||
|
||||
## Contents
|
||||
@@ -29,4 +29,4 @@ Screen examples are omitted, so please use the book together with Workflow Studi
|
||||
- [13_Appendix](chapters/13_Appendix.md)
|
||||
- [14_Afterword](chapters/14_Afterword.md)
|
||||
|
||||
Whole book: [Workflow_Studio_Manual_v0.9.0_en.md](Workflow_Studio_Manual_v0.9.0_en.md)
|
||||
Whole book: [Workflow_Studio_Manual_v0.9.6_en.md](Workflow_Studio_Manual_v0.9.6_en.md)
|
||||
|
||||
Binary file not shown.
+357
-12
@@ -2,12 +2,12 @@
|
||||
|
||||
Don't Be Afraid of Nodes - Generate Images More Easily and Keep Them Organized
|
||||
|
||||
- Covers: Workflow Studio v0.9.0
|
||||
- Covers: Workflow Studio v0.9.6
|
||||
- Author: ketle-man
|
||||
- (c) 2026 ketle-man
|
||||
|
||||
This is the **text-only edition** (without screenshots) of the Kindle book.
|
||||
Chapter files are in the `chapters/` folder; the whole book in one file is `Workflow_Studio_Manual_v0.9.0_en.md`.
|
||||
Chapter files are in the `chapters/` folder; the whole book in one file is `Workflow_Studio_Manual_v0.9.6_en.md`.
|
||||
Screen examples are omitted, so please use the book together with Workflow Studio's Help tab.
|
||||
|
||||
---
|
||||
@@ -143,7 +143,7 @@ It is written so that you can follow it if you know basic computer operations (c
|
||||
|
||||
## Version and environment covered
|
||||
|
||||
- **Workflow Studio**: v0.9.0
|
||||
- **Workflow Studio**: v0.9.6
|
||||
- **ComfyUI**: the latest version at the time of writing
|
||||
- **OS**: Windows (screen examples are from Windows)
|
||||
|
||||
@@ -621,7 +621,7 @@ It is enough to learn the tabs marked with a star under "Often used" first.
|
||||
| TagSpace | Showing tags, prompts and generated images in 3D by closeness of meaning, and picking tags | | Chapter 11 |
|
||||
| Settings | Language, theme, folders and other settings | | This chapter, Chapter 11 |
|
||||
| Help | Feature list, shortcuts, troubleshooting | | This chapter |
|
||||
| AI TOOL | Translation, chat and image analysis with local AI | | Chapter 10 |
|
||||
| AI TOOL | Translation, chat, image analysis and text reading (OCR) with local AI | | Chapter 10 |
|
||||
|
||||
### Think of the tabs in three groups: "organize", "create" and "support"
|
||||
If the number of tabs makes you lose your way, it helps to think of them in these three groups.
|
||||
@@ -1220,12 +1220,13 @@ If you save it with a name, it is added as a new workflow in the Workflow tab.
|
||||
|
||||
Once the first image is out, next look at the **Settings** subtab, where settings that decide how the result turns out are found.
|
||||
|
||||
The Settings tab is divided into two blocks, left and right.
|
||||
The Settings tab is divided into three columns.
|
||||
|
||||
- **KSampler (left)** - settings for how the image is drawn
|
||||
- **Latent Image (right)** - settings for the size and number of images
|
||||
- **Latent Image (middle)** - settings for the size and number of images
|
||||
- **Switches (right, v0.9.6+)** - the on/off settings inside the workflow
|
||||
|
||||
Here too, don't forget to press **Apply** on each block after changing values.
|
||||
Here too, for KSampler and Latent Image, don't forget to press **Apply** on each block after changing values (the Switches column needs no Apply).
|
||||
|
||||
### KSampler items
|
||||
| Item | Meaning | Rule of thumb for beginners |
|
||||
@@ -1252,6 +1253,23 @@ Here too, don't forget to press **Apply** on each block after changing values.
|
||||
> If you stray far from the size the model learned, people tend to come out doubled or the composition falls apart.
|
||||
> Even when you want portrait or landscape, adjust so the total pixel count stays about the same (for example 832x1216 for SDXL).
|
||||
|
||||
### Switches items (v0.9.6+)
|
||||
The "on/off" settings inside the workflow are listed with their node name and item name.
|
||||
Clicking a checkbox switches it right away. **You don't need to press Apply**. The new value also shows up in the Raw JSON on the right at once.
|
||||
|
||||
Two kinds of items are listed:
|
||||
|
||||
- Items whose value is true/false - for example the switch that turns the prompt enhancer (PE) on and off, "Boolean" nodes, and TextGenerate's thinking
|
||||
- On/off items with only two choices - enable/disable, on/off, yes/no and so on. For example KSamplerAdvanced's add_noise and DWPreprocessor's detect_hand (whether to detect hands)
|
||||
|
||||
The right end of each row shows the current value (true/false, enable/disable and so on).
|
||||
Hover over a row to see the node's number and type.
|
||||
|
||||
> **Tip**
|
||||
> Items wired from another node are not listed. Instead, the source node they are wired from (for example a Boolean node named "Enable PE?") is listed, so switch that one.
|
||||
> In workflows where nodes have no names, the node type name (such as ComfySwitchNode) is shown.
|
||||
> If the workflow has no on/off items at all, "No true/false settings in this workflow" is shown.
|
||||
|
||||
### Managing the seed (generate panel)
|
||||
The seed is managed with the **Seed** field in the generate panel and the pull-down below it.
|
||||
|
||||
@@ -1284,7 +1302,7 @@ This is the shortest path to getting used to image generation.
|
||||
> **Summary of this chapter (first half)**
|
||||
> - Select in the Workflow tab and press "Load in GenerateUI" to edit in GenerateUI
|
||||
> - The basics are the three subtabs Input (prompt), Model (model) and Settings (detailed settings)
|
||||
> - After changing the model or Settings values, always press **Apply**. If you forget, it generates with the values from before the change (prompts are reflected automatically)
|
||||
> - After changing the model or Settings values, always press **Apply**. If you forget, it generates with the values from before the change (prompts and the Settings Switches column are reflected automatically)
|
||||
> - Generate to produce. Alt + Apply reflects and generates in one go
|
||||
> - If you set the seed to Fixed, you can keep a picture you like and compare by changing conditions. To use the workflow's seed as it is, use Workflow
|
||||
|
||||
@@ -1455,6 +1473,8 @@ Until the next keyframe arrives, the previous setting is carried over.
|
||||
4. Decide the number of generations (Batch).
|
||||
5. In each of the Model, Prompt and KSampler columns, press **+** to add a keyframe,
|
||||
then double-click the keyframe and enter "from which run" and "to which setting".
|
||||
Cells in the KSampler column show the items you set (Step, CFG, Denoise, sampler, scheduler, Seed).
|
||||
A cell with nothing set shows "(workflow default)".
|
||||
6. Press **Run**.
|
||||
|
||||
Results are listed in the **Results** panel.
|
||||
@@ -1464,6 +1484,8 @@ Results are listed in the **Results** panel.
|
||||
You can make a series of images that change little by little.
|
||||
- **Plan Save** - lets you save the settings (the plan). When saved, an overview image summarizing the results is also made,
|
||||
and dropping that image lets you load the same plan again.
|
||||
- **Making a plan from a story** (v0.9.5+) - with the **Storyboard to Lab** skill in the AI TOOL chat,
|
||||
a story is split into one prompt per scene and the **Open in Lab** button loads the plan here (Chapter 10, section 10-5).
|
||||
|
||||
---
|
||||
|
||||
@@ -1506,11 +1528,16 @@ The workflows you can use in GenerateUI have increased as follows. They are used
|
||||
- **Fibo 2.0 Edit** (v0.8.14 and later) - an image-editing workflow. With the prompt enhancer (PE) setup, edits in the prompt area apply whether PE is on or off. The negative area holds a VGL-format (JSON) description as is. Width and height (Settings) of 0 mean automatic.
|
||||
- **FastH3 OmniRef / Pika-H3-Flex** (v0.8.14 and later) - MiniMax H3 reference to video. Treated as video workflows, and also usable in the Video tab (Chapter 11).
|
||||
- **Native LoRA stack** - ComfyUI v0.40's nodes that load several LoRAs at once (LoadLoraModel / LoadLoraTextEncoder). Apply, bypass and the Metadata display are supported.
|
||||
- **P-H3 Image** (v0.9.4+) - the image-edit and T2I templates. The prompt is shown on the Input tab and can be read by the Metadata tab; there is no negative prompt field. Width and height follow the workflow's wiring, and the prompt upsampler runs as set in the workflow.
|
||||
- **MiniMax H3 Image** (v0.9.6+) - ComfyUI's official image-edit and T2I templates. As with P-H3 Image, the prompt is shown on the Input tab and can be read by the Metadata tab and the sidepanel I tab; there is no negative prompt field. The prompt enhancer (PE) can be turned on and off in the Settings tab's Switches column (5-4).
|
||||
|
||||
> **Caution**
|
||||
> The native LoRA stack nodes are supported **only in API-format workflows**.
|
||||
> ComfyUI's own screen cannot yet add rows to this node, so UI-format workflows are not covered.
|
||||
|
||||
> **Tip**
|
||||
> Workflows containing the **Compare Images** (ImageCompare) node, which compares two images with a slider, used to fail on that node when run from GenerateUI (fixed in v0.9.6).
|
||||
|
||||
> **Tip**
|
||||
> The Metadata tab and the side panel's **I** tab can also read images made with Nano Banana (v0.8.5 and later).
|
||||
> The prompt appears as one whole entry, not split into Positive/Negative.
|
||||
@@ -1972,7 +1999,7 @@ and to solve "how did I make this image?".
|
||||
|
||||
Images generated in ComfyUI have information about the workflow and prompt used for generation (**metadata**) embedded in them.
|
||||
The Gallery tab reads this information so that you can reproduce the same settings right away.
|
||||
Videos (MP4 and WebM) and audio files (WAV, MP3, OGG, M4A, FLAC and Opus) can also be handled in the same list as images.
|
||||
Videos (MP4 and WebM), audio files (WAV, MP3, OGG, M4A, FLAC and Opus) and 3D models (GLB, glTF, VRM, PLY and SPZ) can also be handled in the same list as images.
|
||||
|
||||
---
|
||||
|
||||
@@ -2262,6 +2289,30 @@ When you choose a video, only the audio track is extracted and saved.
|
||||
> **Tip: use in the Video tab**
|
||||
> In the Asset subtab of Video, you can set an audio file as "Set as BGM" or "Add as sound" (Chapter 11, section 11-4).
|
||||
|
||||
---
|
||||
|
||||
## 8-8 Viewing 3D models (v0.9.4+)
|
||||
|
||||
The results of ComfyUI workflows that make 3D models (such as image to 3D) can also be checked in the Gallery.
|
||||
Supported formats are GLB, glTF, VRM, PLY (mesh, point cloud or Gaussian splat) and SPZ.
|
||||
|
||||
- They are listed with a "**3D**" badge. The thumbnail is drawn by your browser the first time the file appears in the list and is then kept on the server
|
||||
(files over 128 MB get one when opened).
|
||||
- When selected, the file is shown in 3D in the built-in viewer of the detail panel on the right. Double-click to enlarge it.
|
||||
|
||||
| Action | What it does |
|
||||
| --- | --- |
|
||||
| Drag | Rotate |
|
||||
| Right-drag | Move (pan) |
|
||||
| Mouse wheel | Zoom in / out |
|
||||
| ⟲ (reset) button | Return to the starting view |
|
||||
|
||||
- For a model saved with ComfyUI's **SaveGLB**, the workflow can be read just like an image's (sections 8-2 and 8-3).
|
||||
|
||||
> **Caution**
|
||||
> - 3D files cannot be sent to Image Edit, GenerateUI, Tagger or Feeder.
|
||||
> - Some `.gltf` files refer to separate `.bin` or image files. Moving or deleting in the Gallery only affects the `.gltf` itself, so do not change where those files are.
|
||||
|
||||
> **Summary of this chapter**
|
||||
> - In the Gallery's Output, you can look back at generated images folder by folder. You can also search by words in the prompt
|
||||
> - The detail panel's Prompt tab shows right away the prompt used to generate the image
|
||||
@@ -2270,6 +2321,7 @@ When you choose a video, only the audio track is extracted and saved.
|
||||
> - If you drop an image onto the Metadata subtab, you can examine the models, LoRAs and prompt in detail
|
||||
> - With the S button you can search by the meaning of a sentence or by "similar images" (semantic search)
|
||||
> - Audio files can also be listed, played and converted
|
||||
> - 3D models (GLB, glTF, VRM, PLY, SPZ) can also be turned around and checked in the Gallery (8-8)
|
||||
|
||||
---
|
||||
|
||||
@@ -2456,6 +2508,9 @@ If you prepare local AI, you can do things like the following.
|
||||
- Chat with the AI and have it come up with prompt ideas
|
||||
- Show it an image and have it make a description, prompt or tags for that image
|
||||
- Have it make candidate lists for wildcards
|
||||
- Pick a **skill** to switch what it is good at: writing prompts, storyboards, troubleshooting and more (v0.9.5+)
|
||||
- Have it check whether a generated image matches your request and fix only what is missing (v0.9.5+)
|
||||
- Read the text shown in an image (OCR, v0.9.5+)
|
||||
|
||||
Because it runs inside your own computer rather than as a service on the internet,
|
||||
a big feature is that **what you type and the images you use are not sent outside**.
|
||||
@@ -2518,10 +2573,14 @@ The screen is divided into four parts, all shown at the same time.
|
||||
| Part | What you can do |
|
||||
| --- | --- |
|
||||
| Translate | Translation between languages such as Japanese, English and Chinese |
|
||||
| Chat | Conversation with the AI. If the conditions are met, generating images from the conversation too |
|
||||
| TOOLS | Reading images (VLM), making wildcards |
|
||||
| Chat | Conversation with the AI. Skills switch what it is good at. If the conditions are met, generating images from the conversation too |
|
||||
| TOOLS | Reading images (VLM), reading text (OCR), making wildcards |
|
||||
| Settings | Settings for which AI software and model to use |
|
||||
|
||||
> **Tip: Translate and Settings can be collapsed (v0.9.5+)**
|
||||
> Press the arrow (◀ / ▶) at the right end of the Translate or Settings header to collapse that part into a thin strip, giving Chat and TOOLS more room.
|
||||
> Click anywhere on the strip to open it again. The collapsed state is kept the next time you open the tab.
|
||||
|
||||
### First: connect to the AI software in the settings
|
||||
1. In **Backend** under the settings, choose the software to use (for example Ollama).
|
||||
2. Check the address under **Connection**. If you use Ollama with its defaults, no change is needed.
|
||||
@@ -2545,6 +2604,9 @@ The screen is divided into four parts, all shown at the same time.
|
||||
2. Write the text in the input box and press **Translate**.
|
||||
3. **Copy** the result and paste it into GenerateUI or a preset.
|
||||
|
||||
> **Tip**
|
||||
> Text read in TOOLS (such as OCR in 10-8) can be put straight into this input box with **To Translation** in TOOLS.
|
||||
|
||||
> **Tip**
|
||||
> Small models sometimes return the original text as it is without translating.
|
||||
> In that case a message says the model returned the original text, so try another model.
|
||||
@@ -2559,6 +2621,10 @@ It remembers the flow of the conversation, so follow-up requests such as "make i
|
||||
> just asking in chat "draw a cat" generates an image with the workflow loaded in GenerateUI, and it is shown inside the chat.
|
||||
> This feature can be turned on or off with the **Allow image generation** check next to the input box.
|
||||
|
||||
> **Tip**
|
||||
> Choose a **skill** in the dropdown above the chat to switch what the AI is good at (10-5).
|
||||
> The AI's replies are shown with headings, lists and code boxes formatted (v0.9.5+).
|
||||
|
||||
### TOOLS - reading images (VLM)
|
||||
With a model that supports image input (a VLM), you can have the AI read the contents of an image.
|
||||
|
||||
@@ -2571,8 +2637,16 @@ With a model that supports image input (a VLM), you can have the AI read the con
|
||||
| Create prompt | Makes a prompt for reproducing the image |
|
||||
| Create tags | Writes out the elements of the image in tag format |
|
||||
| Create wildcards | Makes candidate wildcard lists by specifying a category name and count (no image needed) |
|
||||
| OCR (extract text) | Reads the text shown in the image (10-8) |
|
||||
|
||||
3. Press **Run**. You can copy the result and use it.
|
||||
3. Press **Run**.
|
||||
4. Use the result with the buttons under it (v0.9.5+).
|
||||
|
||||
| Button | What it does |
|
||||
| --- | --- |
|
||||
| To Translation | Puts it into the Translate input box (opens Translate if it is collapsed) |
|
||||
| To Chat | Adds it to the chat input box |
|
||||
| Copy | Copies it to the clipboard |
|
||||
|
||||
> **Example**
|
||||
> When you want to imitate the mood of an illustration you like, run "Create prompt" on that image
|
||||
@@ -2639,12 +2713,229 @@ In that case, anyone who can access it can use your AI software and images.
|
||||
> Use `--listen` **only on a trusted network**, such as a home LAN.
|
||||
> Do not use it in cafes or on public Wi-Fi.
|
||||
|
||||
---
|
||||
|
||||
## 10-5 Skills - switching what the chat is good at (v0.9.5+)
|
||||
|
||||
A **skill** is a set of instructions that tells the chat what role to play and how to answer.
|
||||
When you pick one in the dropdown above the chat (it starts as "-- No skill --"), its instructions are sent along with every message
|
||||
(the instructions themselves are not shown in the chat).
|
||||
|
||||
### Built-in skills
|
||||
Workflow Studio comes with the following six skills.
|
||||
|
||||
| Skill | What it does | How you use the result |
|
||||
| --- | --- | --- |
|
||||
| T2I Prompt Enhancer | Turns a short idea into a prompt written the way the model in your GenerateUI workflow understands best | Buttons under the prompt |
|
||||
| Image to Prompt | Writes a prompt that recreates the image you put in TOOLS (needs a model that can read images) | Buttons under the prompt |
|
||||
| Storyboard to Lab | Splits a story into one prompt per scene, ready for continuous generation in Lab | **Open in Lab** |
|
||||
| Workflow Doctor | Explains the workflow and helps you find the cause of errors (10-7) | Answers in text |
|
||||
| SVG Icon Generator | Draws simple icons and illustrations as SVG (vector images) | Preview and **Save to Gallery** |
|
||||
| Skill Creator | Asks you questions and builds a new skill (10-9) | **Save as new skill** |
|
||||
|
||||
### Making a prompt and generating with it (T2I Prompt Enhancer)
|
||||
1. Load the workflow you want to use into GenerateUI. The skill looks at that workflow's model to decide how to write the prompt.
|
||||
2. In the AI TOOL chat, choose **T2I Prompt Enhancer** in the dropdown.
|
||||
3. Describe the picture in your own words, for example "a cat holding an umbrella in rainy Tokyo at night, neon", and send it.
|
||||
4. The reply shows which writing style was chosen (the `Style:` line) and the English prompt in a box. Use the buttons under it.
|
||||
|
||||
| Button | What it does |
|
||||
| --- | --- |
|
||||
| Apply to GenerateUI | Writes the positive / negative prompt into GenerateUI |
|
||||
| Generate with this prompt | Generates right away and shows the image in the chat |
|
||||
| Copy | Copies the positive prompt |
|
||||
|
||||
The writing style depends on the model:
|
||||
|
||||
| Model in GenerateUI | How the prompt is written |
|
||||
| --- | --- |
|
||||
| Qwen-Image, FLUX, Z-Image, etc. | English sentences (the negative is left empty when CFG is about 1) |
|
||||
| Illustrious, NoobAI, Pony, etc. (SDXL anime models) | Danbooru tags (starting with quality tags) |
|
||||
| Realistic SDXL, SD1.5 | Short phrases separated by commas |
|
||||
|
||||
> **Tip**
|
||||
> Follow-up requests such as "make it more night-like" or "two cats" give you the whole updated prompt again.
|
||||
|
||||
> **Tip: the prompt currently in GenerateUI is not sent**
|
||||
> Normally the prompt already typed into GenerateUI is not passed to the AI, because small models tend to mix it (for example an old negative prompt) into the new prompt.
|
||||
> It is passed only when your message refers to it, such as "**improve the current prompt**".
|
||||
|
||||
### Generating a storyboard in a row (Storyboard to Lab)
|
||||
1. Load a text-to-image (T2I) workflow into GenerateUI.
|
||||
2. In the chat, choose **Storyboard to Lab** and ask, for example, "A tabby kitten wakes up in the morning, chases a butterfly in the garden, and naps on the veranda in the evening. 3 scenes."
|
||||
3. The reply shows the scene list and an **Open in Lab (3 scenes)** button. Press it.
|
||||
4. GenerateUI's **Lab** (Chapter 5, section 5-7) opens with the plan loaded as an unsaved plan.
|
||||
T2I mode is on, Batch is the number of scenes, and the Prompt column has one keyframe per scene. The KSampler column holds a seed shared by all scenes (`Seed ...`).
|
||||
5. Check it and press **Run**. To keep it, use **Plan Save**.
|
||||
|
||||
> **Tip**
|
||||
> The characters' looks (hair, clothing colors and so on) are written with the same words in every scene, and the seed is fixed,
|
||||
> so the same character is easier to keep across scenes.
|
||||
|
||||
### Automatic check of replies
|
||||
For skills that output a prompt or an SVG, Workflow Studio checks the shape of the reply automatically.
|
||||
If, for example, the prompt box is broken or the SVG is not closed, a dashed notice "**Auto-check: ...**" appears and the AI is asked once to fix it.
|
||||
Replies that do not try to output a prompt or SVG (for example when you only asked for an explanation) are left alone.
|
||||
|
||||
### Managing skills (pencil button)
|
||||
The pencil button to the right of the dropdown opens the skill list.
|
||||
|
||||
- **+ New** writes a new skill, the pencil on each row edits or deletes it, and **Import** adds other skills (10-9)
|
||||
- Skills that ship with Workflow Studio are marked **built-in**.
|
||||
Built-in skills you have not edited are updated automatically when Workflow Studio is updated.
|
||||
An edited one is not replaced automatically; when a newer version exists, a **↻ (update)** button appears.
|
||||
- Skill files are saved in `ComfyUI/user/default/Workflow-Studio/ai_skills/`.
|
||||
|
||||
> **Caution**
|
||||
> Replacing a skill with the newer version via ↻ (update) discards your edits to that skill.
|
||||
|
||||
---
|
||||
|
||||
## 10-6 Checking generated images and fixing them (v0.9.5+)
|
||||
|
||||
This feature has the AI check whether an image generated from the chat matches what you asked for.
|
||||
If something is missing, you can redraw with a prompt that fixes just that.
|
||||
|
||||
### Preparation: a judging model
|
||||
The check does not use a normal chat model but an Ollama **decision model** (a model that answers yes/no questions with a probability).
|
||||
Get one that can read images with `ollama pull` (the author confirmed `d3:4b`, `clef-flash` and `clef`).
|
||||
|
||||
> **Tip: why a separate model**
|
||||
> In the author's tests, a chat model shown an image sometimes answered "yes" to "is the person holding a sword?" when there was no sword.
|
||||
> A decision model returns the probability of "yes" as a number, so uncertain answers can be told apart.
|
||||
|
||||
1. In AI TOOL's **Settings**, under **Chat Image Check**, choose the **Judge model (Ollama decision model)** (press **Refresh** if the list is empty).
|
||||
2. Choose **Automatic fix rounds**. **0 (fix by button)** is recommended at first.
|
||||
3. Press **Save Settings**.
|
||||
|
||||
### How to use it
|
||||
1. Tick **Check images** below the chat input.
|
||||
2. Generate an image from the chat (the AI's image generation, or **Generate with this prompt**).
|
||||
3. A result card appears under the image. The AI splits your request into 3 to 8 check items and checks them one by one.
|
||||
|
||||
| Mark | Meaning |
|
||||
| --- | --- |
|
||||
| ✔ (met) | Probability of "yes" is 85% or more |
|
||||
| ? (unclear) | 30-85% |
|
||||
| ✘ (not met) | Less than 30% |
|
||||
|
||||
4. If there is a ✘ or ?, press **Fix missing items and regenerate**. Only the unmet items are sent to the AI, which rewrites the prompt and draws again.
|
||||
|
||||
If you set Automatic fix rounds to 1-3, this repeats automatically up to that number of times.
|
||||
|
||||
> **Tip: SVGs can be checked too**
|
||||
> SVGs drawn with SVG Icon Generator are turned into an image and checked for "does it look like what you asked for" and "are its parts (beak, wings and so on) drawn".
|
||||
> **Ask to redraw unclear parts** asks the AI to fix them.
|
||||
|
||||
> **Caution**
|
||||
> - The check is not perfect. It is especially weak at "who is wearing what",
|
||||
> so when there are several people it automatically adds reverse check items such as "the hat is not on the girl" to compensate.
|
||||
> - How well the redraw works depends on the chat model. If it does not improve, change your request or the model.
|
||||
> - The judging model also uses GPU memory (about 6 GB for `d3:4b`). Once loaded, a check takes a few seconds.
|
||||
|
||||
---
|
||||
|
||||
## 10-7 Asking about workflow problems (Workflow Doctor, v0.9.5+)
|
||||
|
||||
**Workflow Doctor** is a skill that explains the workflow loaded in GenerateUI and helps you find the cause of errors.
|
||||
Instead of relying only on the AI's knowledge, every time you send a message Workflow Studio passes it the following facts it has actually checked.
|
||||
|
||||
| What is checked | Example |
|
||||
| --- | --- |
|
||||
| Nodes that are not installed | Names of nodes ComfyUI does not know |
|
||||
| Files and values not in ComfyUI's lists | Models or LoRAs that cannot be found (with similar names) |
|
||||
| Connections | Inputs that are not connected, links to nodes that do not exist |
|
||||
| The last execution error | The error recorded in ComfyUI's history |
|
||||
| ComfyUI's log | The latest error part of the log |
|
||||
|
||||
### How to use it
|
||||
1. Load the workflow you want to ask about into GenerateUI (for an error, run it once so the error is recorded).
|
||||
2. In the chat, choose **Workflow Doctor**.
|
||||
3. Ask, for example, "check this workflow for problems before I run it" or "what caused the last error?".
|
||||
|
||||
> **Example**
|
||||
> For a workflow whose LoRA file had been moved to another folder, it answered
|
||||
> "the file specified in the `temp\` folder cannot be found; a file with the same name exists in `Illustrious\`", including how to fix it.
|
||||
|
||||
> **Tip**
|
||||
> The checked facts are rebuilt every time you send a message. After fixing something, just ask again.
|
||||
|
||||
---
|
||||
|
||||
## 10-8 Reading text in images (OCR, v0.9.5+)
|
||||
|
||||
In TOOLS you can read the text shown in an image. The text you read can be sent straight to translation or the chat.
|
||||
|
||||
### Preparation: an OCR model
|
||||
OCR uses a model that is good at reading text, separate from the chat model. Prepare one in Ollama.
|
||||
These are the author's results on an image mixing horizontal and vertical Japanese and English text.
|
||||
|
||||
| Model | Time | Result |
|
||||
| --- | --- | --- |
|
||||
| LightOnOCR-3 (`maternion/LightOnOCR-3:4b`) | About 1 s (6-8 s with positions) | Read everything |
|
||||
| PaddleOCR-VL (manga version) | About 4 s | Read everything |
|
||||
| DeepSeek-OCR family | About 14 s | Read everything (always with positions) |
|
||||
| PaddleOCR-VL 1.6, GLM-OCR | About 11-15 s | Missed the vertical text |
|
||||
|
||||
### How to use it
|
||||
1. Drop an image into TOOLS.
|
||||
2. Choose **OCR (extract text)** as the task.
|
||||
3. Choose the **OCR model**. Ollama models whose name contains "ocr" are listed, best first (press ↻ if the list is empty).
|
||||
4. Press **Run**. The text is shown in the result box, one line each.
|
||||
|
||||
> **Tip: Show text positions**
|
||||
> Tick **Show text positions** to draw numbered boxes over the image so you can see where each text is.
|
||||
> Clicking a box selects the same line in the result box.
|
||||
> Only LightOnOCR and the DeepSeek-OCR family can return positions; for other models this check cannot be selected.
|
||||
|
||||
> **Tip**
|
||||
> If the result is empty or the same line repeats, it is automatically read once more in grayscale.
|
||||
|
||||
---
|
||||
|
||||
## 10-9 Making and importing skills (advanced, v0.9.5+)
|
||||
|
||||
### Making one with Skill Creator
|
||||
In the chat, choose **Skill Creator** and say something like "I want a skill that is good at ...".
|
||||
The AI asks questions one at a time; answer them and you get a draft skill.
|
||||
Press **Save as new skill** under the reply; the skill list opens with the draft filled in, so check it and save.
|
||||
You can also ask it to improve an existing skill, for example "this part of this skill does not work well".
|
||||
|
||||
### What a skill file contains
|
||||
A skill is a text file (`.md`). Write the name and description in the part enclosed by `---` at the top, and the instructions for the AI below it.
|
||||
Under `metadata:` in the top part you can also write these settings (all optional).
|
||||
|
||||
| Setting | Meaning |
|
||||
| --- | --- |
|
||||
| `output` | `prompt` or `svg`. Checks the shape of the reply automatically and adds the buttons |
|
||||
| `tools` | `[]` stops the AI from starting image generation on its own |
|
||||
| `temperature` | How varied the answers are (0.3 stable, 0.9 free) |
|
||||
| `think` | Turns Thinking mode on or off just for this skill |
|
||||
| `context` | Information passed to the AI: `workflow` (workflow summary), `current-prompt` (the current prompt), `workflow-detail` / `logs` (the same checks as Workflow Doctor) |
|
||||
|
||||
### Importing other skills
|
||||
**Import** in the skill list adds:
|
||||
|
||||
- a single `.md` skill
|
||||
- a ZIP of a folder containing `SKILL.md` (the **Agent Skills** format shared by many AI tools)
|
||||
|
||||
Text files in the folder (reference material and so on) are imported too, and the AI reads them only when it needs them
|
||||
(this needs a model that supports tool calling).
|
||||
|
||||
> **Caution**
|
||||
> - Only text files are imported. Programs such as scripts are not imported and never run.
|
||||
> - A skill's instructions are passed to the AI as they are. Read a skill made by someone else before using it.
|
||||
|
||||
> **Summary of this chapter**
|
||||
> - If you prepare local AI (Ollama and so on), you can translate, chat and read images. What you type is not sent outside
|
||||
> - In the AI TOOL tab's settings, choose the backend, address and model, and test the connection
|
||||
> - The Prompt tab and Tagger tab have separate gear-button settings
|
||||
> - AI and image generation compete for VRAM. Free it with "Unload Model" or "VRAM Management (Ollama)"
|
||||
> - By default it connects only to AI on your own computer. Use `--listen` only on a trusted network
|
||||
> - **Skills** switch what the chat is good at. T2I Prompt Enhancer writes a prompt that buttons apply to GenerateUI or generate with
|
||||
> - With **Check images** on, a decision model checks the generated image and you can redraw fixing only what is missing
|
||||
> - Workflow Doctor helps with workflow problems based on facts it has actually checked
|
||||
> - **OCR (extract text)** in TOOLS reads the text in an image and sends it to translation or the chat
|
||||
|
||||
---
|
||||
|
||||
@@ -2753,6 +3044,22 @@ You can put text on a generated image, or make a mask for redrawing part of it.
|
||||
- Save the edited image with **Save PNG** (download), **Save to Gallery** and so on.
|
||||
- Undo with **Ctrl + Z**, redo with **Ctrl + Y**.
|
||||
|
||||
### Draw colors and shortcuts (v0.9.3+)
|
||||
When you choose **Draw**, a **color palette** appears in the second row of the tool options.
|
||||
|
||||
- It shows **Color 1 / Color 2**, 20 basic colors and 20 custom slots. Click a color to paint with it.
|
||||
- Click **+** or an empty slot to register the current color as a custom color. Right-click a color to register, edit or delete it (basic colors can be changed too, and returned to their original color).
|
||||
Registered colors are saved per browser.
|
||||
- The **eyedropper** button next to **Color** on the right picks a color from the canvas (Esc cancels).
|
||||
- The starting colors are black (Color 1) and white (Color 2).
|
||||
|
||||
| Key (while Draw is active) | What it does |
|
||||
| --- | --- |
|
||||
| D / E | Draw / Erase |
|
||||
| X | Swap Color 1 and Color 2 |
|
||||
| [ / ] | Make the brush smaller / larger |
|
||||
| B | Open or close the brush picker (Esc also closes it) |
|
||||
|
||||
### Inpainting (redrawing part of an image)
|
||||
The handiest use of Image Edit is **inpainting**.
|
||||
It is for when "I want to redraw only the face" or "I want to fix only the place where the hand broke".
|
||||
@@ -2961,6 +3268,12 @@ Choose the settings in the toolbar and press **Build / Update**.
|
||||
When you select a card or Gallery point, its prompt tags are shown as buttons under **Selection**.
|
||||
Click tags to choose them (click again to deselect) and press **Search with selected** to search the space with them.
|
||||
This helps when you want to find things close to, say, only the hairstyle and clothing of an image.
|
||||
While tags are selected, the **Add** button in **Selection** becomes **Add selected (N)** and adds only those tags to the prompt area.
|
||||
With no tags selected it adds the whole item as before (a card's whole prompt, or all tags of a Gallery point).
|
||||
|
||||
When a Gallery point is selected, **Selection** also shows an **Open in Gallery** button.
|
||||
It switches to the Gallery tab, selects the image's folder, and selects the image in the list (its details open on the right).
|
||||
So that the image is sure to appear, the Gallery's search, favorite, tag and group filters are cleared.
|
||||
A card's whole prompt can be read under **Full prompt**.
|
||||
|
||||
---
|
||||
@@ -3050,6 +3363,7 @@ This means **the custom nodes the workflow needs are not installed**.
|
||||
### You changed a setting but the result did not change
|
||||
The most common cause is **forgetting to press Apply**.
|
||||
In GenerateUI, after changing the model or Settings values (steps, size and so on), the change is not reflected in the workflow unless you press **Apply** (Chapter 5, section 5-3).
|
||||
The Switches column at the right of the Settings tab is the exception: it takes effect the moment you click (Chapter 5, section 5-4).
|
||||
Looking at the **Raw JSON** on the right lets you check the values actually reflected.
|
||||
|
||||
### It generates something different from the loaded workflow
|
||||
@@ -3113,6 +3427,21 @@ For Ollama, download the model with `ollama pull (model name)`, then press **Ref
|
||||
For safety, by default it connects only to AI software inside the same computer.
|
||||
You need to set the environment variable `WFS_ALLOWED_BACKEND_HOSTS` (Chapter 10, section 10-4).
|
||||
|
||||
### The judge model for image checks, or the OCR model, does not appear in the list
|
||||
- The judge model list shows only Ollama **decision models** that can read images; normal chat models are not shown.
|
||||
Update Ollama, `ollama pull` a decision model, then press **Refresh** (Chapter 10, section 10-6).
|
||||
- The OCR model list shows only Ollama models whose name contains "ocr". Press ↻ after getting one (Chapter 10, section 10-8).
|
||||
- Both look at Ollama at the AI TOOL backend address when the backend is Ollama, otherwise at `http://localhost:11434`.
|
||||
|
||||
### Image checks never pass no matter how often it fixes
|
||||
- How well the redraw works depends on the chat model. Make your request shorter and more concrete, or try another model.
|
||||
- The check itself can be wrong too. Look at the image yourself as well.
|
||||
- Too many automatic fix rounds keep using generation time and GPU memory. "0 (fix by button)" is recommended at first.
|
||||
|
||||
### The AI does not read a skill's reference files, or does not generate images from the chat
|
||||
Reading the reference files of an imported skill and generating images from the chat only work with
|
||||
**a model that supports tool calling**. Switch to such a model.
|
||||
|
||||
---
|
||||
|
||||
## 12-5 Troubles with other features
|
||||
@@ -3150,6 +3479,15 @@ Run the installation command shown in the error message in the Python that Comfy
|
||||
The cause is a missing Comfy API key in `.env`. Write `COMFY_API_KEY=your key` (issued at platform.comfy.org) in the plugin's `.env` and restart ComfyUI (Chapter 5, section 5-8).
|
||||
`.env` is read only at startup.
|
||||
|
||||
### ComfyUI freezes (no tab responds)
|
||||
On Windows, ComfyUI can stop responding (generation included), for example after switching a Models tab list with many preview images.
|
||||
In the Settings tab, under **Windows Stability (Event Loop)**, turn on **Use the Selector event loop on Windows (restart required)** and restart ComfyUI.
|
||||
|
||||
### Part of a Gallery image looks broken, as if mixed with another image
|
||||
With the **Selector event loop** above turned on, up to v0.9.1 part of an image received by the Gallery preview or Download could be corrupted
|
||||
(the file in the output folder itself is fine). This is fixed in v0.9.2.
|
||||
After updating Workflow Studio, broken images may remain in the browser cache, so reload with **Ctrl + F5**.
|
||||
|
||||
### Images do not appear in the Gallery
|
||||
- Press **Refresh** in the Gallery.
|
||||
- Check that the "Gallery Output Folder" in the Settings tab matches where ComfyUI saves images.
|
||||
@@ -3202,6 +3540,7 @@ The main terms that appear in this book, in alphabetical order.
|
||||
| Civitai | A sharing site for image-generation models | Chapter 6 |
|
||||
| ComfyUI Manager | A management tool for searching for and installing custom nodes | Chapter 2 |
|
||||
| Custom node | A node or extension added to ComfyUI afterwards. Workflow Studio is one of them | Chapter 1 |
|
||||
| Decision model | An AI model that answers questions with numbers such as the probability of "yes". Used to judge image checks | Chapter 10 |
|
||||
| Denoise | How strongly to redraw the original image | Chapter 5 |
|
||||
| Embedding | A small file that teaches a particular word a meaning | Chapter 6 |
|
||||
| Hash value | A "fingerprint"-like value computed from a file's contents. Used to look up models on Civitai | Chapter 6 |
|
||||
@@ -3217,6 +3556,7 @@ The main terms that appear in this book, in alphabetical order.
|
||||
| Negative prompt | A prompt where you write what you do not want drawn | Chapter 7 |
|
||||
| Node | A "box" arranged on ComfyUI's screen. It handles one process | Chapter 1 |
|
||||
| Node set | Several nodes and their connections saved together | Chapter 9 |
|
||||
| OCR | Reading the text shown in an image | Chapter 10 |
|
||||
| Ollama | Software for running local AI easily | Chapter 10 |
|
||||
| Port number | A number for distinguishing which software to communicate with inside one computer | Chapter 2 |
|
||||
| Positive prompt | A prompt where you write what you want drawn | Chapter 7 |
|
||||
@@ -3227,6 +3567,7 @@ The main terms that appear in this book, in alphabetical order.
|
||||
| Scheduler | How the drawing amount is distributed across steps | Chapter 5 |
|
||||
| Seed | The seed of random numbers an image grows from. If the same, you get almost the same image | Chapter 5 |
|
||||
| Semantic search | Searching images by the "meaning" of a sentence or image. Used with the Gallery's S button | Chapter 8 |
|
||||
| Skill | Instructions that tell the AI TOOL chat its role and how to answer. Picking one switches what it is good at | Chapter 10 |
|
||||
| Steps | The number of times drawing is repeated | Chapter 5 |
|
||||
| Style | A mood template added to a prompt | Chapter 7 |
|
||||
| T2I (Text to Image) | Making an image from text (a prompt) | Chapter 5 |
|
||||
@@ -3281,6 +3622,10 @@ The main terms that appear in this book, in alphabetical order.
|
||||
| Mouse wheel | Zoom in and out |
|
||||
| Space + drag | Move the view position |
|
||||
| Delete | Delete the selected layer (when there are two or more layers) |
|
||||
| D / E (while Draw is active) | Draw / Erase |
|
||||
| X (while Draw is active) | Swap Color 1 and Color 2 |
|
||||
| [ / ] (while Draw is active) | Make the brush smaller / larger |
|
||||
| B (while Draw is active) | Open or close the brush picker |
|
||||
|
||||
---
|
||||
|
||||
Binary file not shown.
@@ -129,7 +129,7 @@ It is written so that you can follow it if you know basic computer operations (c
|
||||
|
||||
## Version and environment covered
|
||||
|
||||
- **Workflow Studio**: v0.9.0
|
||||
- **Workflow Studio**: v0.9.6
|
||||
- **ComfyUI**: the latest version at the time of writing
|
||||
- **OS**: Windows (screen examples are from Windows)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ It is enough to learn the tabs marked with a star under "Often used" first.
|
||||
| TagSpace | Showing tags, prompts and generated images in 3D by closeness of meaning, and picking tags | | Chapter 11 |
|
||||
| Settings | Language, theme, folders and other settings | | This chapter, Chapter 11 |
|
||||
| Help | Feature list, shortcuts, troubleshooting | | This chapter |
|
||||
| AI TOOL | Translation, chat and image analysis with local AI | | Chapter 10 |
|
||||
| AI TOOL | Translation, chat, image analysis and text reading (OCR) with local AI | | Chapter 10 |
|
||||
|
||||
### Think of the tabs in three groups: "organize", "create" and "support"
|
||||
If the number of tabs makes you lose your way, it helps to think of them in these three groups.
|
||||
|
||||
@@ -168,12 +168,13 @@ If you save it with a name, it is added as a new workflow in the Workflow tab.
|
||||
|
||||
Once the first image is out, next look at the **Settings** subtab, where settings that decide how the result turns out are found.
|
||||
|
||||
The Settings tab is divided into two blocks, left and right.
|
||||
The Settings tab is divided into three columns.
|
||||
|
||||
- **KSampler (left)** - settings for how the image is drawn
|
||||
- **Latent Image (right)** - settings for the size and number of images
|
||||
- **Latent Image (middle)** - settings for the size and number of images
|
||||
- **Switches (right, v0.9.6+)** - the on/off settings inside the workflow
|
||||
|
||||
Here too, don't forget to press **Apply** on each block after changing values.
|
||||
Here too, for KSampler and Latent Image, don't forget to press **Apply** on each block after changing values (the Switches column needs no Apply).
|
||||
|
||||
### KSampler items
|
||||
| Item | Meaning | Rule of thumb for beginners |
|
||||
@@ -200,6 +201,23 @@ Here too, don't forget to press **Apply** on each block after changing values.
|
||||
> If you stray far from the size the model learned, people tend to come out doubled or the composition falls apart.
|
||||
> Even when you want portrait or landscape, adjust so the total pixel count stays about the same (for example 832x1216 for SDXL).
|
||||
|
||||
### Switches items (v0.9.6+)
|
||||
The "on/off" settings inside the workflow are listed with their node name and item name.
|
||||
Clicking a checkbox switches it right away. **You don't need to press Apply**. The new value also shows up in the Raw JSON on the right at once.
|
||||
|
||||
Two kinds of items are listed:
|
||||
|
||||
- Items whose value is true/false - for example the switch that turns the prompt enhancer (PE) on and off, "Boolean" nodes, and TextGenerate's thinking
|
||||
- On/off items with only two choices - enable/disable, on/off, yes/no and so on. For example KSamplerAdvanced's add_noise and DWPreprocessor's detect_hand (whether to detect hands)
|
||||
|
||||
The right end of each row shows the current value (true/false, enable/disable and so on).
|
||||
Hover over a row to see the node's number and type.
|
||||
|
||||
> **Tip**
|
||||
> Items wired from another node are not listed. Instead, the source node they are wired from (for example a Boolean node named "Enable PE?") is listed, so switch that one.
|
||||
> In workflows where nodes have no names, the node type name (such as ComfySwitchNode) is shown.
|
||||
> If the workflow has no on/off items at all, "No true/false settings in this workflow" is shown.
|
||||
|
||||
### Managing the seed (generate panel)
|
||||
The seed is managed with the **Seed** field in the generate panel and the pull-down below it.
|
||||
|
||||
@@ -232,6 +250,6 @@ This is the shortest path to getting used to image generation.
|
||||
> **Summary of this chapter (first half)**
|
||||
> - Select in the Workflow tab and press "Load in GenerateUI" to edit in GenerateUI
|
||||
> - The basics are the three subtabs Input (prompt), Model (model) and Settings (detailed settings)
|
||||
> - After changing the model or Settings values, always press **Apply**. If you forget, it generates with the values from before the change (prompts are reflected automatically)
|
||||
> - After changing the model or Settings values, always press **Apply**. If you forget, it generates with the values from before the change (prompts and the Settings Switches column are reflected automatically)
|
||||
> - Generate to produce. Alt + Apply reflects and generates in one go
|
||||
> - If you set the seed to Fixed, you can keep a picture you like and compare by changing conditions. To use the workflow's seed as it is, use Workflow
|
||||
|
||||
@@ -163,6 +163,8 @@ Until the next keyframe arrives, the previous setting is carried over.
|
||||
4. Decide the number of generations (Batch).
|
||||
5. In each of the Model, Prompt and KSampler columns, press **+** to add a keyframe,
|
||||
then double-click the keyframe and enter "from which run" and "to which setting".
|
||||
Cells in the KSampler column show the items you set (Step, CFG, Denoise, sampler, scheduler, Seed).
|
||||
A cell with nothing set shows "(workflow default)".
|
||||
6. Press **Run**.
|
||||
|
||||
Results are listed in the **Results** panel.
|
||||
@@ -172,6 +174,8 @@ Results are listed in the **Results** panel.
|
||||
You can make a series of images that change little by little.
|
||||
- **Plan Save** - lets you save the settings (the plan). When saved, an overview image summarizing the results is also made,
|
||||
and dropping that image lets you load the same plan again.
|
||||
- **Making a plan from a story** (v0.9.5+) - with the **Storyboard to Lab** skill in the AI TOOL chat,
|
||||
a story is split into one prompt per scene and the **Open in Lab** button loads the plan here (Chapter 10, section 10-5).
|
||||
|
||||
---
|
||||
|
||||
@@ -214,11 +218,16 @@ The workflows you can use in GenerateUI have increased as follows. They are used
|
||||
- **Fibo 2.0 Edit** (v0.8.14 and later) - an image-editing workflow. With the prompt enhancer (PE) setup, edits in the prompt area apply whether PE is on or off. The negative area holds a VGL-format (JSON) description as is. Width and height (Settings) of 0 mean automatic.
|
||||
- **FastH3 OmniRef / Pika-H3-Flex** (v0.8.14 and later) - MiniMax H3 reference to video. Treated as video workflows, and also usable in the Video tab (Chapter 11).
|
||||
- **Native LoRA stack** - ComfyUI v0.40's nodes that load several LoRAs at once (LoadLoraModel / LoadLoraTextEncoder). Apply, bypass and the Metadata display are supported.
|
||||
- **P-H3 Image** (v0.9.4+) - the image-edit and T2I templates. The prompt is shown on the Input tab and can be read by the Metadata tab; there is no negative prompt field. Width and height follow the workflow's wiring, and the prompt upsampler runs as set in the workflow.
|
||||
- **MiniMax H3 Image** (v0.9.6+) - ComfyUI's official image-edit and T2I templates. As with P-H3 Image, the prompt is shown on the Input tab and can be read by the Metadata tab and the sidepanel I tab; there is no negative prompt field. The prompt enhancer (PE) can be turned on and off in the Settings tab's Switches column (5-4).
|
||||
|
||||
> **Caution**
|
||||
> The native LoRA stack nodes are supported **only in API-format workflows**.
|
||||
> ComfyUI's own screen cannot yet add rows to this node, so UI-format workflows are not covered.
|
||||
|
||||
> **Tip**
|
||||
> Workflows containing the **Compare Images** (ImageCompare) node, which compares two images with a slider, used to fail on that node when run from GenerateUI (fixed in v0.9.6).
|
||||
|
||||
> **Tip**
|
||||
> The Metadata tab and the side panel's **I** tab can also read images made with Nano Banana (v0.8.5 and later).
|
||||
> The prompt appears as one whole entry, not split into Positive/Negative.
|
||||
|
||||
@@ -6,7 +6,7 @@ and to solve "how did I make this image?".
|
||||
|
||||
Images generated in ComfyUI have information about the workflow and prompt used for generation (**metadata**) embedded in them.
|
||||
The Gallery tab reads this information so that you can reproduce the same settings right away.
|
||||
Videos (MP4 and WebM) and audio files (WAV, MP3, OGG, M4A, FLAC and Opus) can also be handled in the same list as images.
|
||||
Videos (MP4 and WebM), audio files (WAV, MP3, OGG, M4A, FLAC and Opus) and 3D models (GLB, glTF, VRM, PLY and SPZ) can also be handled in the same list as images.
|
||||
|
||||
---
|
||||
|
||||
@@ -296,6 +296,30 @@ When you choose a video, only the audio track is extracted and saved.
|
||||
> **Tip: use in the Video tab**
|
||||
> In the Asset subtab of Video, you can set an audio file as "Set as BGM" or "Add as sound" (Chapter 11, section 11-4).
|
||||
|
||||
---
|
||||
|
||||
## 8-8 Viewing 3D models (v0.9.4+)
|
||||
|
||||
The results of ComfyUI workflows that make 3D models (such as image to 3D) can also be checked in the Gallery.
|
||||
Supported formats are GLB, glTF, VRM, PLY (mesh, point cloud or Gaussian splat) and SPZ.
|
||||
|
||||
- They are listed with a "**3D**" badge. The thumbnail is drawn by your browser the first time the file appears in the list and is then kept on the server
|
||||
(files over 128 MB get one when opened).
|
||||
- When selected, the file is shown in 3D in the built-in viewer of the detail panel on the right. Double-click to enlarge it.
|
||||
|
||||
| Action | What it does |
|
||||
| --- | --- |
|
||||
| Drag | Rotate |
|
||||
| Right-drag | Move (pan) |
|
||||
| Mouse wheel | Zoom in / out |
|
||||
| ⟲ (reset) button | Return to the starting view |
|
||||
|
||||
- For a model saved with ComfyUI's **SaveGLB**, the workflow can be read just like an image's (sections 8-2 and 8-3).
|
||||
|
||||
> **Caution**
|
||||
> - 3D files cannot be sent to Image Edit, GenerateUI, Tagger or Feeder.
|
||||
> - Some `.gltf` files refer to separate `.bin` or image files. Moving or deleting in the Gallery only affects the `.gltf` itself, so do not change where those files are.
|
||||
|
||||
> **Summary of this chapter**
|
||||
> - In the Gallery's Output, you can look back at generated images folder by folder. You can also search by words in the prompt
|
||||
> - The detail panel's Prompt tab shows right away the prompt used to generate the image
|
||||
@@ -304,3 +328,4 @@ When you choose a video, only the audio track is extracted and saved.
|
||||
> - If you drop an image onto the Metadata subtab, you can examine the models, LoRAs and prompt in detail
|
||||
> - With the S button you can search by the meaning of a sentence or by "similar images" (semantic search)
|
||||
> - Audio files can also be listed, played and converted
|
||||
> - 3D models (GLB, glTF, VRM, PLY, SPZ) can also be turned around and checked in the Gallery (8-8)
|
||||
|
||||
@@ -7,6 +7,9 @@ If you prepare local AI, you can do things like the following.
|
||||
- Chat with the AI and have it come up with prompt ideas
|
||||
- Show it an image and have it make a description, prompt or tags for that image
|
||||
- Have it make candidate lists for wildcards
|
||||
- Pick a **skill** to switch what it is good at: writing prompts, storyboards, troubleshooting and more (v0.9.5+)
|
||||
- Have it check whether a generated image matches your request and fix only what is missing (v0.9.5+)
|
||||
- Read the text shown in an image (OCR, v0.9.5+)
|
||||
|
||||
Because it runs inside your own computer rather than as a service on the internet,
|
||||
a big feature is that **what you type and the images you use are not sent outside**.
|
||||
@@ -69,10 +72,14 @@ The screen is divided into four parts, all shown at the same time.
|
||||
| Part | What you can do |
|
||||
| --- | --- |
|
||||
| Translate | Translation between languages such as Japanese, English and Chinese |
|
||||
| Chat | Conversation with the AI. If the conditions are met, generating images from the conversation too |
|
||||
| TOOLS | Reading images (VLM), making wildcards |
|
||||
| Chat | Conversation with the AI. Skills switch what it is good at. If the conditions are met, generating images from the conversation too |
|
||||
| TOOLS | Reading images (VLM), reading text (OCR), making wildcards |
|
||||
| Settings | Settings for which AI software and model to use |
|
||||
|
||||
> **Tip: Translate and Settings can be collapsed (v0.9.5+)**
|
||||
> Press the arrow (◀ / ▶) at the right end of the Translate or Settings header to collapse that part into a thin strip, giving Chat and TOOLS more room.
|
||||
> Click anywhere on the strip to open it again. The collapsed state is kept the next time you open the tab.
|
||||
|
||||
### First: connect to the AI software in the settings
|
||||
1. In **Backend** under the settings, choose the software to use (for example Ollama).
|
||||
2. Check the address under **Connection**. If you use Ollama with its defaults, no change is needed.
|
||||
@@ -96,6 +103,9 @@ The screen is divided into four parts, all shown at the same time.
|
||||
2. Write the text in the input box and press **Translate**.
|
||||
3. **Copy** the result and paste it into GenerateUI or a preset.
|
||||
|
||||
> **Tip**
|
||||
> Text read in TOOLS (such as OCR in 10-8) can be put straight into this input box with **To Translation** in TOOLS.
|
||||
|
||||
> **Tip**
|
||||
> Small models sometimes return the original text as it is without translating.
|
||||
> In that case a message says the model returned the original text, so try another model.
|
||||
@@ -110,6 +120,10 @@ It remembers the flow of the conversation, so follow-up requests such as "make i
|
||||
> just asking in chat "draw a cat" generates an image with the workflow loaded in GenerateUI, and it is shown inside the chat.
|
||||
> This feature can be turned on or off with the **Allow image generation** check next to the input box.
|
||||
|
||||
> **Tip**
|
||||
> Choose a **skill** in the dropdown above the chat to switch what the AI is good at (10-5).
|
||||
> The AI's replies are shown with headings, lists and code boxes formatted (v0.9.5+).
|
||||
|
||||
### TOOLS - reading images (VLM)
|
||||
With a model that supports image input (a VLM), you can have the AI read the contents of an image.
|
||||
|
||||
@@ -122,8 +136,16 @@ With a model that supports image input (a VLM), you can have the AI read the con
|
||||
| Create prompt | Makes a prompt for reproducing the image |
|
||||
| Create tags | Writes out the elements of the image in tag format |
|
||||
| Create wildcards | Makes candidate wildcard lists by specifying a category name and count (no image needed) |
|
||||
| OCR (extract text) | Reads the text shown in the image (10-8) |
|
||||
|
||||
3. Press **Run**. You can copy the result and use it.
|
||||
3. Press **Run**.
|
||||
4. Use the result with the buttons under it (v0.9.5+).
|
||||
|
||||
| Button | What it does |
|
||||
| --- | --- |
|
||||
| To Translation | Puts it into the Translate input box (opens Translate if it is collapsed) |
|
||||
| To Chat | Adds it to the chat input box |
|
||||
| Copy | Copies it to the clipboard |
|
||||
|
||||
> **Example**
|
||||
> When you want to imitate the mood of an illustration you like, run "Create prompt" on that image
|
||||
@@ -190,9 +212,226 @@ In that case, anyone who can access it can use your AI software and images.
|
||||
> Use `--listen` **only on a trusted network**, such as a home LAN.
|
||||
> Do not use it in cafes or on public Wi-Fi.
|
||||
|
||||
---
|
||||
|
||||
## 10-5 Skills - switching what the chat is good at (v0.9.5+)
|
||||
|
||||
A **skill** is a set of instructions that tells the chat what role to play and how to answer.
|
||||
When you pick one in the dropdown above the chat (it starts as "-- No skill --"), its instructions are sent along with every message
|
||||
(the instructions themselves are not shown in the chat).
|
||||
|
||||
### Built-in skills
|
||||
Workflow Studio comes with the following six skills.
|
||||
|
||||
| Skill | What it does | How you use the result |
|
||||
| --- | --- | --- |
|
||||
| T2I Prompt Enhancer | Turns a short idea into a prompt written the way the model in your GenerateUI workflow understands best | Buttons under the prompt |
|
||||
| Image to Prompt | Writes a prompt that recreates the image you put in TOOLS (needs a model that can read images) | Buttons under the prompt |
|
||||
| Storyboard to Lab | Splits a story into one prompt per scene, ready for continuous generation in Lab | **Open in Lab** |
|
||||
| Workflow Doctor | Explains the workflow and helps you find the cause of errors (10-7) | Answers in text |
|
||||
| SVG Icon Generator | Draws simple icons and illustrations as SVG (vector images) | Preview and **Save to Gallery** |
|
||||
| Skill Creator | Asks you questions and builds a new skill (10-9) | **Save as new skill** |
|
||||
|
||||
### Making a prompt and generating with it (T2I Prompt Enhancer)
|
||||
1. Load the workflow you want to use into GenerateUI. The skill looks at that workflow's model to decide how to write the prompt.
|
||||
2. In the AI TOOL chat, choose **T2I Prompt Enhancer** in the dropdown.
|
||||
3. Describe the picture in your own words, for example "a cat holding an umbrella in rainy Tokyo at night, neon", and send it.
|
||||
4. The reply shows which writing style was chosen (the `Style:` line) and the English prompt in a box. Use the buttons under it.
|
||||
|
||||
| Button | What it does |
|
||||
| --- | --- |
|
||||
| Apply to GenerateUI | Writes the positive / negative prompt into GenerateUI |
|
||||
| Generate with this prompt | Generates right away and shows the image in the chat |
|
||||
| Copy | Copies the positive prompt |
|
||||
|
||||
The writing style depends on the model:
|
||||
|
||||
| Model in GenerateUI | How the prompt is written |
|
||||
| --- | --- |
|
||||
| Qwen-Image, FLUX, Z-Image, etc. | English sentences (the negative is left empty when CFG is about 1) |
|
||||
| Illustrious, NoobAI, Pony, etc. (SDXL anime models) | Danbooru tags (starting with quality tags) |
|
||||
| Realistic SDXL, SD1.5 | Short phrases separated by commas |
|
||||
|
||||
> **Tip**
|
||||
> Follow-up requests such as "make it more night-like" or "two cats" give you the whole updated prompt again.
|
||||
|
||||
> **Tip: the prompt currently in GenerateUI is not sent**
|
||||
> Normally the prompt already typed into GenerateUI is not passed to the AI, because small models tend to mix it (for example an old negative prompt) into the new prompt.
|
||||
> It is passed only when your message refers to it, such as "**improve the current prompt**".
|
||||
|
||||
### Generating a storyboard in a row (Storyboard to Lab)
|
||||
1. Load a text-to-image (T2I) workflow into GenerateUI.
|
||||
2. In the chat, choose **Storyboard to Lab** and ask, for example, "A tabby kitten wakes up in the morning, chases a butterfly in the garden, and naps on the veranda in the evening. 3 scenes."
|
||||
3. The reply shows the scene list and an **Open in Lab (3 scenes)** button. Press it.
|
||||
4. GenerateUI's **Lab** (Chapter 5, section 5-7) opens with the plan loaded as an unsaved plan.
|
||||
T2I mode is on, Batch is the number of scenes, and the Prompt column has one keyframe per scene. The KSampler column holds a seed shared by all scenes (`Seed ...`).
|
||||
5. Check it and press **Run**. To keep it, use **Plan Save**.
|
||||
|
||||
> **Tip**
|
||||
> The characters' looks (hair, clothing colors and so on) are written with the same words in every scene, and the seed is fixed,
|
||||
> so the same character is easier to keep across scenes.
|
||||
|
||||
### Automatic check of replies
|
||||
For skills that output a prompt or an SVG, Workflow Studio checks the shape of the reply automatically.
|
||||
If, for example, the prompt box is broken or the SVG is not closed, a dashed notice "**Auto-check: ...**" appears and the AI is asked once to fix it.
|
||||
Replies that do not try to output a prompt or SVG (for example when you only asked for an explanation) are left alone.
|
||||
|
||||
### Managing skills (pencil button)
|
||||
The pencil button to the right of the dropdown opens the skill list.
|
||||
|
||||
- **+ New** writes a new skill, the pencil on each row edits or deletes it, and **Import** adds other skills (10-9)
|
||||
- Skills that ship with Workflow Studio are marked **built-in**.
|
||||
Built-in skills you have not edited are updated automatically when Workflow Studio is updated.
|
||||
An edited one is not replaced automatically; when a newer version exists, a **↻ (update)** button appears.
|
||||
- Skill files are saved in `ComfyUI/user/default/Workflow-Studio/ai_skills/`.
|
||||
|
||||
> **Caution**
|
||||
> Replacing a skill with the newer version via ↻ (update) discards your edits to that skill.
|
||||
|
||||
---
|
||||
|
||||
## 10-6 Checking generated images and fixing them (v0.9.5+)
|
||||
|
||||
This feature has the AI check whether an image generated from the chat matches what you asked for.
|
||||
If something is missing, you can redraw with a prompt that fixes just that.
|
||||
|
||||
### Preparation: a judging model
|
||||
The check does not use a normal chat model but an Ollama **decision model** (a model that answers yes/no questions with a probability).
|
||||
Get one that can read images with `ollama pull` (the author confirmed `d3:4b`, `clef-flash` and `clef`).
|
||||
|
||||
> **Tip: why a separate model**
|
||||
> In the author's tests, a chat model shown an image sometimes answered "yes" to "is the person holding a sword?" when there was no sword.
|
||||
> A decision model returns the probability of "yes" as a number, so uncertain answers can be told apart.
|
||||
|
||||
1. In AI TOOL's **Settings**, under **Chat Image Check**, choose the **Judge model (Ollama decision model)** (press **Refresh** if the list is empty).
|
||||
2. Choose **Automatic fix rounds**. **0 (fix by button)** is recommended at first.
|
||||
3. Press **Save Settings**.
|
||||
|
||||
### How to use it
|
||||
1. Tick **Check images** below the chat input.
|
||||
2. Generate an image from the chat (the AI's image generation, or **Generate with this prompt**).
|
||||
3. A result card appears under the image. The AI splits your request into 3 to 8 check items and checks them one by one.
|
||||
|
||||
| Mark | Meaning |
|
||||
| --- | --- |
|
||||
| ✔ (met) | Probability of "yes" is 85% or more |
|
||||
| ? (unclear) | 30-85% |
|
||||
| ✘ (not met) | Less than 30% |
|
||||
|
||||
4. If there is a ✘ or ?, press **Fix missing items and regenerate**. Only the unmet items are sent to the AI, which rewrites the prompt and draws again.
|
||||
|
||||
If you set Automatic fix rounds to 1-3, this repeats automatically up to that number of times.
|
||||
|
||||
> **Tip: SVGs can be checked too**
|
||||
> SVGs drawn with SVG Icon Generator are turned into an image and checked for "does it look like what you asked for" and "are its parts (beak, wings and so on) drawn".
|
||||
> **Ask to redraw unclear parts** asks the AI to fix them.
|
||||
|
||||
> **Caution**
|
||||
> - The check is not perfect. It is especially weak at "who is wearing what",
|
||||
> so when there are several people it automatically adds reverse check items such as "the hat is not on the girl" to compensate.
|
||||
> - How well the redraw works depends on the chat model. If it does not improve, change your request or the model.
|
||||
> - The judging model also uses GPU memory (about 6 GB for `d3:4b`). Once loaded, a check takes a few seconds.
|
||||
|
||||
---
|
||||
|
||||
## 10-7 Asking about workflow problems (Workflow Doctor, v0.9.5+)
|
||||
|
||||
**Workflow Doctor** is a skill that explains the workflow loaded in GenerateUI and helps you find the cause of errors.
|
||||
Instead of relying only on the AI's knowledge, every time you send a message Workflow Studio passes it the following facts it has actually checked.
|
||||
|
||||
| What is checked | Example |
|
||||
| --- | --- |
|
||||
| Nodes that are not installed | Names of nodes ComfyUI does not know |
|
||||
| Files and values not in ComfyUI's lists | Models or LoRAs that cannot be found (with similar names) |
|
||||
| Connections | Inputs that are not connected, links to nodes that do not exist |
|
||||
| The last execution error | The error recorded in ComfyUI's history |
|
||||
| ComfyUI's log | The latest error part of the log |
|
||||
|
||||
### How to use it
|
||||
1. Load the workflow you want to ask about into GenerateUI (for an error, run it once so the error is recorded).
|
||||
2. In the chat, choose **Workflow Doctor**.
|
||||
3. Ask, for example, "check this workflow for problems before I run it" or "what caused the last error?".
|
||||
|
||||
> **Example**
|
||||
> For a workflow whose LoRA file had been moved to another folder, it answered
|
||||
> "the file specified in the `temp\` folder cannot be found; a file with the same name exists in `Illustrious\`", including how to fix it.
|
||||
|
||||
> **Tip**
|
||||
> The checked facts are rebuilt every time you send a message. After fixing something, just ask again.
|
||||
|
||||
---
|
||||
|
||||
## 10-8 Reading text in images (OCR, v0.9.5+)
|
||||
|
||||
In TOOLS you can read the text shown in an image. The text you read can be sent straight to translation or the chat.
|
||||
|
||||
### Preparation: an OCR model
|
||||
OCR uses a model that is good at reading text, separate from the chat model. Prepare one in Ollama.
|
||||
These are the author's results on an image mixing horizontal and vertical Japanese and English text.
|
||||
|
||||
| Model | Time | Result |
|
||||
| --- | --- | --- |
|
||||
| LightOnOCR-3 (`maternion/LightOnOCR-3:4b`) | About 1 s (6-8 s with positions) | Read everything |
|
||||
| PaddleOCR-VL (manga version) | About 4 s | Read everything |
|
||||
| DeepSeek-OCR family | About 14 s | Read everything (always with positions) |
|
||||
| PaddleOCR-VL 1.6, GLM-OCR | About 11-15 s | Missed the vertical text |
|
||||
|
||||
### How to use it
|
||||
1. Drop an image into TOOLS.
|
||||
2. Choose **OCR (extract text)** as the task.
|
||||
3. Choose the **OCR model**. Ollama models whose name contains "ocr" are listed, best first (press ↻ if the list is empty).
|
||||
4. Press **Run**. The text is shown in the result box, one line each.
|
||||
|
||||
> **Tip: Show text positions**
|
||||
> Tick **Show text positions** to draw numbered boxes over the image so you can see where each text is.
|
||||
> Clicking a box selects the same line in the result box.
|
||||
> Only LightOnOCR and the DeepSeek-OCR family can return positions; for other models this check cannot be selected.
|
||||
|
||||
> **Tip**
|
||||
> If the result is empty or the same line repeats, it is automatically read once more in grayscale.
|
||||
|
||||
---
|
||||
|
||||
## 10-9 Making and importing skills (advanced, v0.9.5+)
|
||||
|
||||
### Making one with Skill Creator
|
||||
In the chat, choose **Skill Creator** and say something like "I want a skill that is good at ...".
|
||||
The AI asks questions one at a time; answer them and you get a draft skill.
|
||||
Press **Save as new skill** under the reply; the skill list opens with the draft filled in, so check it and save.
|
||||
You can also ask it to improve an existing skill, for example "this part of this skill does not work well".
|
||||
|
||||
### What a skill file contains
|
||||
A skill is a text file (`.md`). Write the name and description in the part enclosed by `---` at the top, and the instructions for the AI below it.
|
||||
Under `metadata:` in the top part you can also write these settings (all optional).
|
||||
|
||||
| Setting | Meaning |
|
||||
| --- | --- |
|
||||
| `output` | `prompt` or `svg`. Checks the shape of the reply automatically and adds the buttons |
|
||||
| `tools` | `[]` stops the AI from starting image generation on its own |
|
||||
| `temperature` | How varied the answers are (0.3 stable, 0.9 free) |
|
||||
| `think` | Turns Thinking mode on or off just for this skill |
|
||||
| `context` | Information passed to the AI: `workflow` (workflow summary), `current-prompt` (the current prompt), `workflow-detail` / `logs` (the same checks as Workflow Doctor) |
|
||||
|
||||
### Importing other skills
|
||||
**Import** in the skill list adds:
|
||||
|
||||
- a single `.md` skill
|
||||
- a ZIP of a folder containing `SKILL.md` (the **Agent Skills** format shared by many AI tools)
|
||||
|
||||
Text files in the folder (reference material and so on) are imported too, and the AI reads them only when it needs them
|
||||
(this needs a model that supports tool calling).
|
||||
|
||||
> **Caution**
|
||||
> - Only text files are imported. Programs such as scripts are not imported and never run.
|
||||
> - A skill's instructions are passed to the AI as they are. Read a skill made by someone else before using it.
|
||||
|
||||
> **Summary of this chapter**
|
||||
> - If you prepare local AI (Ollama and so on), you can translate, chat and read images. What you type is not sent outside
|
||||
> - In the AI TOOL tab's settings, choose the backend, address and model, and test the connection
|
||||
> - The Prompt tab and Tagger tab have separate gear-button settings
|
||||
> - AI and image generation compete for VRAM. Free it with "Unload Model" or "VRAM Management (Ollama)"
|
||||
> - By default it connects only to AI on your own computer. Use `--listen` only on a trusted network
|
||||
> - **Skills** switch what the chat is good at. T2I Prompt Enhancer writes a prompt that buttons apply to GenerateUI or generate with
|
||||
> - With **Check images** on, a decision model checks the generated image and you can redraw fixing only what is missing
|
||||
> - Workflow Doctor helps with workflow problems based on facts it has actually checked
|
||||
> - **OCR (extract text)** in TOOLS reads the text in an image and sends it to translation or the chat
|
||||
|
||||
@@ -103,6 +103,22 @@ You can put text on a generated image, or make a mask for redrawing part of it.
|
||||
- Save the edited image with **Save PNG** (download), **Save to Gallery** and so on.
|
||||
- Undo with **Ctrl + Z**, redo with **Ctrl + Y**.
|
||||
|
||||
### Draw colors and shortcuts (v0.9.3+)
|
||||
When you choose **Draw**, a **color palette** appears in the second row of the tool options.
|
||||
|
||||
- It shows **Color 1 / Color 2**, 20 basic colors and 20 custom slots. Click a color to paint with it.
|
||||
- Click **+** or an empty slot to register the current color as a custom color. Right-click a color to register, edit or delete it (basic colors can be changed too, and returned to their original color).
|
||||
Registered colors are saved per browser.
|
||||
- The **eyedropper** button next to **Color** on the right picks a color from the canvas (Esc cancels).
|
||||
- The starting colors are black (Color 1) and white (Color 2).
|
||||
|
||||
| Key (while Draw is active) | What it does |
|
||||
| --- | --- |
|
||||
| D / E | Draw / Erase |
|
||||
| X | Swap Color 1 and Color 2 |
|
||||
| [ / ] | Make the brush smaller / larger |
|
||||
| B | Open or close the brush picker (Esc also closes it) |
|
||||
|
||||
### Inpainting (redrawing part of an image)
|
||||
The handiest use of Image Edit is **inpainting**.
|
||||
It is for when "I want to redraw only the face" or "I want to fix only the place where the hand broke".
|
||||
@@ -311,6 +327,12 @@ Choose the settings in the toolbar and press **Build / Update**.
|
||||
When you select a card or Gallery point, its prompt tags are shown as buttons under **Selection**.
|
||||
Click tags to choose them (click again to deselect) and press **Search with selected** to search the space with them.
|
||||
This helps when you want to find things close to, say, only the hairstyle and clothing of an image.
|
||||
While tags are selected, the **Add** button in **Selection** becomes **Add selected (N)** and adds only those tags to the prompt area.
|
||||
With no tags selected it adds the whole item as before (a card's whole prompt, or all tags of a Gallery point).
|
||||
|
||||
When a Gallery point is selected, **Selection** also shows an **Open in Gallery** button.
|
||||
It switches to the Gallery tab, selects the image's folder, and selects the image in the list (its details open on the right).
|
||||
So that the image is sure to appear, the Gallery's search, favorite, tag and group filters are cleared.
|
||||
A card's whole prompt can be read under **Full prompt**.
|
||||
|
||||
---
|
||||
|
||||
@@ -72,6 +72,7 @@ This means **the custom nodes the workflow needs are not installed**.
|
||||
### You changed a setting but the result did not change
|
||||
The most common cause is **forgetting to press Apply**.
|
||||
In GenerateUI, after changing the model or Settings values (steps, size and so on), the change is not reflected in the workflow unless you press **Apply** (Chapter 5, section 5-3).
|
||||
The Switches column at the right of the Settings tab is the exception: it takes effect the moment you click (Chapter 5, section 5-4).
|
||||
Looking at the **Raw JSON** on the right lets you check the values actually reflected.
|
||||
|
||||
### It generates something different from the loaded workflow
|
||||
@@ -135,6 +136,21 @@ For Ollama, download the model with `ollama pull (model name)`, then press **Ref
|
||||
For safety, by default it connects only to AI software inside the same computer.
|
||||
You need to set the environment variable `WFS_ALLOWED_BACKEND_HOSTS` (Chapter 10, section 10-4).
|
||||
|
||||
### The judge model for image checks, or the OCR model, does not appear in the list
|
||||
- The judge model list shows only Ollama **decision models** that can read images; normal chat models are not shown.
|
||||
Update Ollama, `ollama pull` a decision model, then press **Refresh** (Chapter 10, section 10-6).
|
||||
- The OCR model list shows only Ollama models whose name contains "ocr". Press ↻ after getting one (Chapter 10, section 10-8).
|
||||
- Both look at Ollama at the AI TOOL backend address when the backend is Ollama, otherwise at `http://localhost:11434`.
|
||||
|
||||
### Image checks never pass no matter how often it fixes
|
||||
- How well the redraw works depends on the chat model. Make your request shorter and more concrete, or try another model.
|
||||
- The check itself can be wrong too. Look at the image yourself as well.
|
||||
- Too many automatic fix rounds keep using generation time and GPU memory. "0 (fix by button)" is recommended at first.
|
||||
|
||||
### The AI does not read a skill's reference files, or does not generate images from the chat
|
||||
Reading the reference files of an imported skill and generating images from the chat only work with
|
||||
**a model that supports tool calling**. Switch to such a model.
|
||||
|
||||
---
|
||||
|
||||
## 12-5 Troubles with other features
|
||||
@@ -172,6 +188,15 @@ Run the installation command shown in the error message in the Python that Comfy
|
||||
The cause is a missing Comfy API key in `.env`. Write `COMFY_API_KEY=your key` (issued at platform.comfy.org) in the plugin's `.env` and restart ComfyUI (Chapter 5, section 5-8).
|
||||
`.env` is read only at startup.
|
||||
|
||||
### ComfyUI freezes (no tab responds)
|
||||
On Windows, ComfyUI can stop responding (generation included), for example after switching a Models tab list with many preview images.
|
||||
In the Settings tab, under **Windows Stability (Event Loop)**, turn on **Use the Selector event loop on Windows (restart required)** and restart ComfyUI.
|
||||
|
||||
### Part of a Gallery image looks broken, as if mixed with another image
|
||||
With the **Selector event loop** above turned on, up to v0.9.1 part of an image received by the Gallery preview or Download could be corrupted
|
||||
(the file in the output folder itself is fine). This is fixed in v0.9.2.
|
||||
After updating Workflow Studio, broken images may remain in the browser cache, so reload with **Ctrl + F5**.
|
||||
|
||||
### Images do not appear in the Gallery
|
||||
- Press **Refresh** in the Gallery.
|
||||
- Check that the "Gallery Output Folder" in the Settings tab matches where ComfyUI saves images.
|
||||
|
||||
@@ -16,6 +16,7 @@ The main terms that appear in this book, in alphabetical order.
|
||||
| Civitai | A sharing site for image-generation models | Chapter 6 |
|
||||
| ComfyUI Manager | A management tool for searching for and installing custom nodes | Chapter 2 |
|
||||
| Custom node | A node or extension added to ComfyUI afterwards. Workflow Studio is one of them | Chapter 1 |
|
||||
| Decision model | An AI model that answers questions with numbers such as the probability of "yes". Used to judge image checks | Chapter 10 |
|
||||
| Denoise | How strongly to redraw the original image | Chapter 5 |
|
||||
| Embedding | A small file that teaches a particular word a meaning | Chapter 6 |
|
||||
| Hash value | A "fingerprint"-like value computed from a file's contents. Used to look up models on Civitai | Chapter 6 |
|
||||
@@ -31,6 +32,7 @@ The main terms that appear in this book, in alphabetical order.
|
||||
| Negative prompt | A prompt where you write what you do not want drawn | Chapter 7 |
|
||||
| Node | A "box" arranged on ComfyUI's screen. It handles one process | Chapter 1 |
|
||||
| Node set | Several nodes and their connections saved together | Chapter 9 |
|
||||
| OCR | Reading the text shown in an image | Chapter 10 |
|
||||
| Ollama | Software for running local AI easily | Chapter 10 |
|
||||
| Port number | A number for distinguishing which software to communicate with inside one computer | Chapter 2 |
|
||||
| Positive prompt | A prompt where you write what you want drawn | Chapter 7 |
|
||||
@@ -41,6 +43,7 @@ The main terms that appear in this book, in alphabetical order.
|
||||
| Scheduler | How the drawing amount is distributed across steps | Chapter 5 |
|
||||
| Seed | The seed of random numbers an image grows from. If the same, you get almost the same image | Chapter 5 |
|
||||
| Semantic search | Searching images by the "meaning" of a sentence or image. Used with the Gallery's S button | Chapter 8 |
|
||||
| Skill | Instructions that tell the AI TOOL chat its role and how to answer. Picking one switches what it is good at | Chapter 10 |
|
||||
| Steps | The number of times drawing is repeated | Chapter 5 |
|
||||
| Style | A mood template added to a prompt | Chapter 7 |
|
||||
| T2I (Text to Image) | Making an image from text (a prompt) | Chapter 5 |
|
||||
@@ -95,6 +98,10 @@ The main terms that appear in this book, in alphabetical order.
|
||||
| Mouse wheel | Zoom in and out |
|
||||
| Space + drag | Move the view position |
|
||||
| Delete | Delete the selected layer (when there are two or more layers) |
|
||||
| D / E (while Draw is active) | Draw / Erase |
|
||||
| X (while Draw is active) | Swap Color 1 and Color 2 |
|
||||
| [ / ] (while Draw is active) | Make the brush smaller / larger |
|
||||
| B (while Draw is active) | Open or close the brush picker |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 422 KiB |
@@ -52,6 +52,15 @@ def _maybe_use_selector_event_loop():
|
||||
print("[Workflow Studio] Using WindowsSelectorEventLoopPolicy (windows_selector_event_loop enabled)")
|
||||
except Exception as e:
|
||||
print(f"[WARNING] Workflow Studio: failed to switch to selector event loop: {e}")
|
||||
return
|
||||
# Selector方式ではloop.sendfileがasyncioの_sendfile_fallbackになるが、これは16KBバッファを
|
||||
# 使い回しつつ未送信分をコピーせずトランスポートに積むため、送信が詰まると後続ブロックで
|
||||
# 上書きされ画像等が壊れて届く(Python 3.12で確認)。aiohttpのsendfileを無効化し、
|
||||
# チャンクごとに新しいbytesを読む自前フォールバックを使わせる
|
||||
os.environ["AIOHTTP_NOSENDFILE"] = "1"
|
||||
fileresponse = sys.modules.get("aiohttp.web_fileresponse")
|
||||
if fileresponse is not None:
|
||||
fileresponse.NOSENDFILE = True
|
||||
|
||||
|
||||
_maybe_use_selector_event_loop()
|
||||
|
||||
@@ -1,9 +1,66 @@
|
||||
import os
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ─── パス検査(クライアント由来のパスを resolve() する前に文字列のまま検査する)───
|
||||
# Windows では UNC パス(\\host\share)は resolve()・exists() を呼んだ時点で相手ホストへ
|
||||
# Windows アカウントの資格情報付きで接続してしまうため、チェックはパス操作より先に行う。
|
||||
|
||||
_DRIVE_RE = re.compile(r"^[A-Za-z]:")
|
||||
|
||||
|
||||
def is_unc(value) -> bool:
|
||||
"""ネットワーク共有/デバイスパス(\\\\host\\share, //host/share, \\\\?\\ 等)か。"""
|
||||
return str(value or "").replace("/", "\\").startswith("\\\\")
|
||||
|
||||
|
||||
def is_safe_rel(rel) -> bool:
|
||||
"""相対パスとして安全か。UNC・ドライブ指定・絶対パス・「..」・空セグメントを
|
||||
結合・resolve() より前に文字列のまま拒否する。"""
|
||||
s = str(rel or "")
|
||||
if not s or "\x00" in s:
|
||||
return False
|
||||
if s.startswith(("/", "\\")) or _DRIVE_RE.match(s):
|
||||
return False
|
||||
return not any(part in ("", "..") for part in re.split(r"[/\\]", s))
|
||||
|
||||
|
||||
def resolve_untrusted(value, roots=()) -> Path:
|
||||
"""クライアント由来の絶対パス文字列を検査してから resolve() して返す。
|
||||
|
||||
ファイルシステムに触れない文字列処理(normpath)だけで「..」を正規化・拒否し、
|
||||
UNC は roots のうち UNC のルート配下に文字列上一致する場合のみ許可する
|
||||
(ギャラリーのルートをネットワーク共有に置いている構成だけを通すため)。
|
||||
拒否時は ValueError。"""
|
||||
s = str(value or "")
|
||||
if not s or "\x00" in s:
|
||||
raise ValueError("Access denied: invalid path")
|
||||
p = Path(os.path.normpath(s))
|
||||
if ".." in p.parts:
|
||||
raise ValueError("Access denied: path traversal")
|
||||
if is_unc(s):
|
||||
for root in roots:
|
||||
if root is None or not is_unc(str(root)):
|
||||
continue
|
||||
try:
|
||||
p.relative_to(Path(root)) # 文字列上の照合(解決しない)
|
||||
return p.resolve()
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
raise ValueError("Access denied: network path")
|
||||
# 受け付ける形はドライブレター絶対パス(Windows)または / 始まり(その他OS)に限る。
|
||||
# ルート相対(\foo)・ドライブ相対(C:foo)・NT デバイス形式(\??\, \.\ 等)・相対パスは拒否する
|
||||
if os.name == "nt":
|
||||
if not re.match(r"^[A-Za-z]:[\\/]", s):
|
||||
raise ValueError("Access denied: invalid path")
|
||||
elif not s.startswith("/"):
|
||||
raise ValueError("Access denied: invalid path")
|
||||
return p.resolve()
|
||||
|
||||
|
||||
# Plugin root directory
|
||||
PLUGIN_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import urllib.error
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import is_safe_rel
|
||||
from ..services.url_guard import require_backend_url, urlopen as _guarded_urlopen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,6 +39,11 @@ def _resolve_local_path(filename, subfolder, type_name):
|
||||
if not filename:
|
||||
return None
|
||||
|
||||
# subfolder の UNC・ドライブ指定・絶対パス・「..」は、結合より前に文字列のまま拒否する
|
||||
# (別ドライブ/UNC だと下の commonpath が ValueError を投げるため、その前に弾く)
|
||||
if subfolder and not is_safe_rel(subfolder):
|
||||
return None
|
||||
|
||||
full_dir = os.path.abspath(os.path.join(base_dir, subfolder or ""))
|
||||
# Windowsはパス比較で大文字小文字を区別しないため normcase で正規化してから比較する
|
||||
if os.path.commonpath((os.path.normcase(full_dir), os.path.normcase(base_dir))) != os.path.normcase(base_dir):
|
||||
@@ -62,9 +68,9 @@ def _resolve_absolute_svg_path(abs_path):
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# isfile() も UNC(\\host\share)だと相手ホストへ資格情報付きで接続してしまうため、
|
||||
# 文字列処理(abspath/commonpath)だけで ComfyUI 管理下と確認できるまでファイルシステムに触れない
|
||||
abs_path = os.path.abspath(abs_path)
|
||||
if not os.path.isfile(abs_path):
|
||||
return None
|
||||
|
||||
for type_name in ("output", "temp", "input"):
|
||||
base_dir = folder_paths.get_directory_by_type(type_name)
|
||||
@@ -74,9 +80,9 @@ def _resolve_absolute_svg_path(abs_path):
|
||||
try:
|
||||
# Windowsはパス比較で大文字小文字を区別しないため normcase で正規化してから比較する
|
||||
if os.path.commonpath((os.path.normcase(abs_path), os.path.normcase(base_dir))) == os.path.normcase(base_dir):
|
||||
return abs_path
|
||||
return abs_path if os.path.isfile(abs_path) else None
|
||||
except ValueError:
|
||||
# Windowsで異なるドライブレターの場合 commonpath が例外を投げる
|
||||
# Windowsで異なるドライブレターやUNCの場合 commonpath が例外を投げる
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
@@ -59,7 +59,10 @@ _init_allowed_root()
|
||||
|
||||
# Windowsのレジストリ依存で音声のMIMEが返らない/不統一なため明示登録する(<audio>の再生に必要)
|
||||
for _ext, _mime in ((".wav", "audio/wav"), (".mp3", "audio/mpeg"), (".ogg", "audio/ogg"),
|
||||
(".opus", "audio/ogg"), (".m4a", "audio/mp4"), (".flac", "audio/flac")):
|
||||
(".opus", "audio/ogg"), (".m4a", "audio/mp4"), (".flac", "audio/flac"),
|
||||
(".glb", "model/gltf-binary"), (".vrm", "model/gltf-binary"), (".gltf", "model/gltf+json"),
|
||||
(".ply", "application/octet-stream"), (".spz", "application/octet-stream"),
|
||||
(".bin", "application/octet-stream")):
|
||||
mimetypes.add_type(_mime, _ext)
|
||||
|
||||
|
||||
@@ -79,6 +82,8 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_get("/wfm/gallery/image/workflow", get_image_workflow)
|
||||
app.router.add_get("/wfm/gallery/image/serve", serve_image)
|
||||
app.router.add_get("/wfm/gallery/image/thumb", serve_thumb)
|
||||
app.router.add_post("/wfm/gallery/image/thumb3d", save_model3d_thumb)
|
||||
app.router.add_get("/wfm/gallery/model3d/asset", serve_model3d_asset)
|
||||
app.router.add_post("/wfm/gallery/image/meta", save_image_meta)
|
||||
app.router.add_get("/wfm/gallery/image-prompt/root", get_image_prompt_root)
|
||||
app.router.add_post("/wfm/gallery/image/image-prompt", save_image_prompt)
|
||||
@@ -386,6 +391,37 @@ async def serve_thumb(request: web.Request) -> web.Response:
|
||||
})
|
||||
|
||||
|
||||
async def save_model3d_thumb(request: web.Request) -> web.Response:
|
||||
"""POST /wfm/gallery/image/thumb3d — ブラウザが描画した3Dモデルのサムネイルを保存する"""
|
||||
try:
|
||||
body = await request.json()
|
||||
path = body.get("path", "")
|
||||
image = body.get("image", "")
|
||||
if not path or not image:
|
||||
return web.json_response({"ok": False, "error": "path and image required"}, status=400)
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, _service.save_model3d_thumbnail, path, image)
|
||||
return web.json_response(result, status=200 if result.get("ok") else 400)
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def serve_model3d_asset(request: web.Request) -> web.Response:
|
||||
"""GET /wfm/gallery/model3d/asset — .gltfが参照する外部ファイル(.bin/テクスチャ)を配信する"""
|
||||
path = request.rel_url.query.get("path", "")
|
||||
uri = request.rel_url.query.get("uri", "")
|
||||
if not path or not uri:
|
||||
return web.Response(status=400)
|
||||
asset = _service.serve_model3d_asset(path, uri)
|
||||
if asset is None:
|
||||
return web.Response(status=404)
|
||||
content_type, _ = mimetypes.guess_type(str(asset))
|
||||
return web.FileResponse(asset, headers={
|
||||
"Content-Type": content_type or "application/octet-stream",
|
||||
"Cache-Control": "max-age=3600",
|
||||
})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# バルク操作
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.settings_service import SettingsService
|
||||
from ..config import DATA_DIR
|
||||
from ..config import DATA_DIR, resolve_untrusted
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -153,17 +153,20 @@ async def handle_result(request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
result_path = body.get("result_path", "")
|
||||
if not result_path or not os.path.exists(result_path):
|
||||
return web.json_response({"error": f"Result file not found: {result_path}"}, status=404)
|
||||
|
||||
# Path traversal guard: only allow files inside gmic_temp
|
||||
# Path traversal guard: only allow files inside gmic_temp.
|
||||
# UNC(\\host\share)は exists()/resolve() を呼んだ時点で相手ホストへ資格情報付きで
|
||||
# 接続してしまうため、パス操作より先に文字列のまま検査する(resolve_untrusted)。
|
||||
allowed_dir = (Path(DATA_DIR) / "gmic_temp").resolve()
|
||||
try:
|
||||
Path(result_path).resolve().relative_to(allowed_dir)
|
||||
resolved = resolve_untrusted(result_path)
|
||||
resolved.relative_to(allowed_dir)
|
||||
except ValueError:
|
||||
return web.json_response({"error": "Access denied: path outside gmic_temp"}, status=403)
|
||||
if not resolved.is_file():
|
||||
return web.json_response({"error": f"Result file not found: {result_path}"}, status=404)
|
||||
|
||||
with open(result_path, "rb") as f:
|
||||
with open(resolved, "rb") as f:
|
||||
img_bytes = f.read()
|
||||
|
||||
ext = os.path.splitext(result_path)[1].lower()
|
||||
|
||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
from aiohttp import web
|
||||
|
||||
from ..services.settings_service import SettingsService
|
||||
from ..config import DEFAULT_WORKFLOWS_DIR, DATA_DIR, VIDEO_EDIT_PROJECT_DIR
|
||||
from ..config import DEFAULT_WORKFLOWS_DIR, DATA_DIR, VIDEO_EDIT_PROJECT_DIR, is_safe_rel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -397,6 +397,10 @@ def _add_video_edit_media_to_zip(zf: zipfile.ZipFile) -> None:
|
||||
input_dir = _comfyui_input_dir().resolve()
|
||||
seen = set()
|
||||
for subfolder, filename in _iter_video_edit_media_refs():
|
||||
# プロジェクトファイルは細工できるため、UNC・絶対パス・「..」は結合・resolve() の前に
|
||||
# 文字列のまま拒否する(Windows では UNC を resolve() するだけで相手ホストへ接続する)
|
||||
if not is_safe_rel(filename) or (subfolder and not is_safe_rel(subfolder)):
|
||||
continue
|
||||
src = (input_dir / subfolder / filename).resolve()
|
||||
try:
|
||||
rel = src.relative_to(input_dir) # a crafted project file must not reach outside input/
|
||||
@@ -466,9 +470,15 @@ def _apply_full_backup_zip(zip_source) -> dict:
|
||||
summary["skipped"].append(info.filename)
|
||||
continue
|
||||
if info.filename.startswith(_VIDEO_MEDIA_PREFIX):
|
||||
media_rel = info.filename[len(_VIDEO_MEDIA_PREFIX):]
|
||||
# ZIP エントリ名は細工できるため、UNC・絶対パス・「..」は結合・resolve() の前に
|
||||
# 文字列のまま拒否する(Windows では UNC を resolve() するだけで相手ホストへ接続する)
|
||||
if not is_safe_rel(media_rel):
|
||||
summary["skipped"].append(info.filename)
|
||||
continue
|
||||
if input_base is None:
|
||||
input_base = _comfyui_input_dir().resolve()
|
||||
target = (input_base / info.filename[len(_VIDEO_MEDIA_PREFIX):]).resolve()
|
||||
target = (input_base / media_rel).resolve()
|
||||
try:
|
||||
target.relative_to(input_base)
|
||||
except ValueError:
|
||||
@@ -482,6 +492,9 @@ def _apply_full_backup_zip(zip_source) -> dict:
|
||||
shutil.copyfileobj(src, dst)
|
||||
summary["media_restored"].append(info.filename)
|
||||
continue
|
||||
if not is_safe_rel(info.filename):
|
||||
summary["skipped"].append(info.filename)
|
||||
continue
|
||||
target = (base / info.filename).resolve()
|
||||
try:
|
||||
target.relative_to(base)
|
||||
|
||||
@@ -18,6 +18,9 @@ def setup_routes(app: web.Application):
|
||||
app.router.add_get("/api/wfm/skills/content", handle_get_content)
|
||||
app.router.add_post("/api/wfm/skills/save", handle_save)
|
||||
app.router.add_post("/api/wfm/skills/delete", handle_delete)
|
||||
app.router.add_post("/api/wfm/skills/restore-builtin", handle_restore_builtin)
|
||||
app.router.add_post("/api/wfm/skills/import", handle_import)
|
||||
app.router.add_get("/api/wfm/skills/resource", handle_get_resource)
|
||||
|
||||
|
||||
async def handle_list(request: web.Request) -> web.Response:
|
||||
@@ -62,3 +65,52 @@ async def handle_delete(request: web.Request) -> web.Response:
|
||||
except Exception as e:
|
||||
logger.error("Error deleting skill: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_restore_builtin(request: web.Request) -> web.Response:
|
||||
"""Overwrite a built-in skill with the shipped version (user-confirmed in the UI)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
filename = body.get("filename", "")
|
||||
result = await asyncio.to_thread(_service.restore_builtin, filename)
|
||||
return web.json_response({"status": "ok", "file": result})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error restoring built-in skill: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_import(request: web.Request) -> web.Response:
|
||||
"""Import an Agent Skills folder (.zip) or a single .md skill (multipart field "file")."""
|
||||
try:
|
||||
form = await request.post()
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "file"):
|
||||
return web.json_response({"error": "no file"}, status=400)
|
||||
data = upload.file.read(20 * 1024 * 1024 + 1)
|
||||
if len(data) > 20 * 1024 * 1024:
|
||||
return web.json_response({"error": "file too large (max 20 MB)"}, status=400)
|
||||
result = await asyncio.to_thread(_service.import_skill, upload.filename or "", data)
|
||||
return web.json_response({"status": "ok", **result})
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error importing skill: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_get_resource(request: web.Request) -> web.Response:
|
||||
"""Read one reference file of a folder skill (?skill=<folder>&path=<relative path>)."""
|
||||
folder = request.rel_url.query.get("skill", "")
|
||||
rel = request.rel_url.query.get("path", "")
|
||||
try:
|
||||
content = await asyncio.to_thread(_service.get_resource, folder, rel)
|
||||
return web.json_response({"content": content})
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
except Exception as e:
|
||||
logger.error("Error reading skill resource: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: Image to Prompt
|
||||
description: Looks at an attached image and writes a prompt that recreates it, in the style the current GenerateUI model understands best.
|
||||
metadata:
|
||||
wfs-version: 2
|
||||
output: prompt
|
||||
tools: []
|
||||
temperature: 0.3
|
||||
think: false
|
||||
context: [workflow]
|
||||
---
|
||||
|
||||
You reverse-engineer images into text-to-image prompts. The user attaches an image (in the TOOLS pane, which also attaches it to the chat) and you write a prompt that would recreate it with the model in their current workflow. This only works with a vision-capable chat model.
|
||||
|
||||
If no image is attached to the user's message, do not invent one: reply in one or two sentences asking them to drop an image into the TOOLS pane (PNG / JPG / WebP) and send their message again.
|
||||
|
||||
## Step 1 - look carefully, describe only what is visible
|
||||
Go through the image in this order and note only what you can actually see - never guess names of characters, people, brands or artists:
|
||||
1. Main subject(s): count, kind, apparent age group, hair, eyes, clothing and accessories with colors.
|
||||
2. Pose, action, expression, gaze direction.
|
||||
3. Setting and background, important objects.
|
||||
4. Lighting (direction, softness, time of day), color palette.
|
||||
5. Framing: shot size (close-up, upper body, full body, wide), camera angle, lens feel (shallow depth of field, wide angle).
|
||||
6. Medium and style: photo, anime illustration, 3D render, oil painting, flat vector, etc.
|
||||
7. Any readable text in the image - copy it exactly.
|
||||
|
||||
## Step 2 - pick the prompt style from the "Current workflow context"
|
||||
- NATURAL style (Qwen-Image, FLUX, Z-Image, HiDream, SD3 and other T5 / LLM text-encoder models): fluent English sentences in the order above, 40-120 words, no quality tags; text to render goes in double quotes. Leave negative empty when CFG is about 1.
|
||||
- TAG style (SDXL anime checkpoints: Illustrious, NoobAI, Animagine, Pony): Danbooru tags in the order quality -> count -> appearance -> expression/pose -> background -> lighting -> composition. Quality tags: Pony `score_9, score_8_up, score_7_up`; others `masterpiece, best quality, very aesthetic, absurdres`. Negative: `worst quality, low quality, lowres, bad anatomy, bad hands, jpeg artifacts, watermark, signature`.
|
||||
- MIXED style (realistic SDXL / SD1.5): short comma-separated descriptive phrases plus camera and lighting terms.
|
||||
- No context: use NATURAL.
|
||||
- Write every positive and negative prompt from scratch from the user's request and the style rules here. Only when the context contains "Prompts currently typed into the workflow" (it appears when the user refers to the current prompt) edit or extend those as the user asks; even then, keep only the parts the user wants to keep.
|
||||
|
||||
## Output format (always)
|
||||
1. One line starting with "Style:" naming the style and why (e.g. "Style: TAG - workflow uses an Illustrious checkpoint").
|
||||
2. The prompt in a fenced block labeled `prompt`:
|
||||
|
||||
```prompt
|
||||
positive: <the full prompt on one line>
|
||||
negative: <negative prompt on one line, or empty>
|
||||
```
|
||||
|
||||
3. One line "Aspect: W:H" matching the image's orientation (e.g. "Aspect: 2:3 (portrait)").
|
||||
|
||||
Reply to the user in their language outside the block; the prompt itself is English. If the user asks for changes ("make it night", "remove the hat"), output the complete updated block again.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: Skill Creator
|
||||
description: Interactively interviews you and drafts a new WFS chat skill (.md system prompt) ready to save.
|
||||
metadata:
|
||||
wfs-version: 3
|
||||
tools: []
|
||||
temperature: 0.5
|
||||
---
|
||||
|
||||
You are a skill-design assistant embedded in ComfyUI Workflow Studio's AI TOOL Chat pane. Your job is to interactively help the user design a NEW "skill" - a markdown file with frontmatter followed by system-prompt instructions. Once saved, a skill can be selected from a dropdown and is silently prepended to future chat messages as a system prompt.
|
||||
|
||||
## How to conduct the interview
|
||||
- Ask ONE focused question at a time, never a bulleted list of questions. Wait for the user's answer before asking the next one.
|
||||
- Cover, in this order, only what is not already clear from context:
|
||||
1. What task or domain should the skill specialize in (what should the model do differently once this skill is active)?
|
||||
2. What output format is expected - plain prose, a specific fenced code block, a fixed structure, a length limit? If the result is an image prompt, an SVG, or another skill, suggest the matching Workflow Studio output type below so the user gets action buttons for free.
|
||||
3. Any hard constraints or things to avoid (tone, forbidden content, security concerns, things past attempts got wrong)?
|
||||
4. Would a short worked example help smaller/local models follow the pattern more reliably? If yes, ask what a good example input/output looks like, or offer to draft one.
|
||||
- Keep it short - 3 to 5 questions is usually enough. If the user says "just generate it", "that's enough", or similar at any point, stop asking and produce the best skill you can from what you already have.
|
||||
|
||||
## Workflow Studio output types (the fenced blocks the app recognizes)
|
||||
- ```prompt block - an image-generation prompt in this exact shape (the app shows "Apply to GenerateUI" / "Generate with this prompt" buttons):
|
||||
positive: <prompt text>
|
||||
negative: <negative prompt text, may be empty>
|
||||
- ```svg block - one complete `<svg>` element (the app previews it, can save it to the Gallery, and auto-checks it).
|
||||
- ```skill block - a complete skill file (the app shows a "Save as new skill" button).
|
||||
|
||||
## Optional metadata a skill can declare
|
||||
All keys are optional and go under `metadata:` in the frontmatter. Only include the ones that matter for this skill.
|
||||
- `output`: `prompt` or `svg` - the app checks that the reply contains that block and automatically asks the model to fix it once if it is missing or broken.
|
||||
- `tools`: `[]` to never offer image generation to the model, or `[generate_image]` to let it start ComfyUI generation by itself (only when the user turned on the image-generation toggle).
|
||||
- `temperature`: a number such as `0.3` (precise, repeatable) to `0.9` (creative).
|
||||
- `think`: `true` / `false` to force the model's thinking mode on or off for this skill.
|
||||
- `context`: a list of facts sent with every message:
|
||||
- `workflow`: a summary of the workflow loaded in GenerateUI (model files, prompt nodes, CFG, resolution). The prompt text currently in the workflow is added only when the user's message refers to it ("improve the current prompt").
|
||||
- `current-prompt`: always include the prompt text currently in the workflow (for skills whose job is to edit that prompt).
|
||||
- `workflow-detail`: every node with its settings, checked against this ComfyUI install (missing nodes / files, broken links) - for troubleshooting skills.
|
||||
- `logs`: the last execution error and the latest error block of the ComfyUI log.
|
||||
|
||||
## Output format for the finished skill
|
||||
- Once you have enough information, output the complete skill file in a single fenced code block labeled `skill` (```skill ... ```). Never mix prose inside that block - explanation goes outside it.
|
||||
- The block must start with frontmatter in this shape (drop the `metadata:` part entirely if no key is needed):
|
||||
---
|
||||
name: <short display name, shown in the skill dropdown>
|
||||
description: <one sentence, shown as a tooltip in the dropdown>
|
||||
metadata:
|
||||
output: <prompt or svg>
|
||||
temperature: <number>
|
||||
---
|
||||
- After the frontmatter, write clear, imperative system-prompt instructions for the target skill: what role the model should take, concrete output-format rules, hard constraints, and - if useful - one short worked example following a "request -> ideal output" shape.
|
||||
- Write the instructions in English unless the user asks otherwise (small local models follow English instructions most reliably), but tell the skill to reply in the user's language.
|
||||
- After the code block, tell the user in one line that they can click the "Save as new skill" button below the message to add it, or copy the block manually into a new skill.
|
||||
|
||||
## Improving an existing skill
|
||||
If the user pastes an existing skill and describes what went wrong, do not interview from scratch: identify the cause in one or two sentences, then output the complete revised skill in a ```skill block, keeping its name unless asked to change it.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: Storyboard to Lab
|
||||
description: Turns a story or scene list into a Lab plan - one prompt per scene with consistent characters - that opens in GenerateUI > Lab with one click.
|
||||
metadata:
|
||||
wfs-version: 2
|
||||
output: labplan
|
||||
tools: []
|
||||
temperature: 0.6
|
||||
think: false
|
||||
context: [workflow]
|
||||
---
|
||||
|
||||
You are a storyboard artist for ComfyUI Workflow Studio. The user describes a story, a sequence, or a list of shots (in any language). You split it into scenes and write one text-to-image prompt per scene, packaged as a Lab plan: the Lab tab runs the scenes as consecutive iterations of the workflow loaded in GenerateUI.
|
||||
|
||||
## Before writing
|
||||
- If the user did not say how many scenes, use 4-8 that cover the story's key beats. Never exceed 32.
|
||||
- If an essential detail is missing and would change every scene (e.g. who the main character is), ask ONE short question first. Otherwise just make sensible choices.
|
||||
|
||||
## Consistency (the most important part)
|
||||
- Write a fixed "character sheet" phrase for each recurring character (hair, eyes, age range, outfit with colors) and paste it IDENTICALLY into every scene where that character appears. Do the same for a recurring place or style.
|
||||
- Keep one art style / medium for the whole plan and repeat it in every scene.
|
||||
- Use the same "seed" for the whole plan (any integer, e.g. 123456789) so the look stays stable; use null only if the user wants variety.
|
||||
|
||||
## Prompt style per scene
|
||||
Use the "Current workflow context" to choose the style:
|
||||
- NATURAL (Qwen-Image, FLUX, Z-Image, HiDream, SD3, other T5 / LLM-encoder models): English sentences - character sheet, action in this scene, setting, lighting, camera framing, style. 40-100 words, no quality tags. Negative empty when CFG is about 1.
|
||||
- TAG (Illustrious, NoobAI, Animagine, Pony): Danbooru tags - quality tags (Pony: `score_9, score_8_up, score_7_up`; others: `masterpiece, best quality, very aesthetic, absurdres`), count tag, character sheet tags, scene action, background, lighting, framing. Negative: `worst quality, low quality, lowres, bad anatomy, bad hands, jpeg artifacts, watermark, signature`.
|
||||
- MIXED (realistic SDXL / SD1.5): short descriptive phrases plus camera and lighting terms.
|
||||
- No context: NATURAL.
|
||||
- Write every positive and negative prompt from scratch from the user's request and the style rules here. Only when the context contains "Prompts currently typed into the workflow" (it appears when the user refers to the current prompt) edit or extend those as the user asks; even then, keep only the parts the user wants to keep.
|
||||
|
||||
Vary the camera framing across scenes (wide establishing shot, medium shot, close-up) like a real storyboard.
|
||||
|
||||
## Output format (always)
|
||||
1. A short numbered list of the scenes in the user's language (one line each).
|
||||
2. The plan in one fenced block labeled `labplan` containing valid JSON only (double quotes, no comments, no trailing commas):
|
||||
|
||||
```labplan
|
||||
{
|
||||
"name": "short_plan_name",
|
||||
"note": "one-line summary of the story",
|
||||
"seed": 123456789,
|
||||
"negative": "default negative prompt for every scene, written from the style rules above (empty string for CFG about 1)",
|
||||
"scenes": [
|
||||
{ "title": "Scene title", "positive": "full prompt for this scene" },
|
||||
{ "title": "Scene title", "positive": "full prompt", "negative": "only if this scene needs a different negative" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
3. One line telling the user to press "Open in Lab" below the message, check the plan, and press Run in the Lab tab (GenerateUI must have the workflow loaded).
|
||||
|
||||
When the user asks for changes ("make scene 3 at night", "add a scene"), output the complete updated `labplan` block again, keeping the character sheets identical.
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: SVG Icon Generator
|
||||
description: Produces clean, minimal, valid SVG icons and simple illustrations.
|
||||
metadata:
|
||||
wfs-version: 2
|
||||
output: svg
|
||||
tools: []
|
||||
temperature: 0.4
|
||||
---
|
||||
|
||||
You are an expert SVG icon and illustration designer. When the user asks for an icon, logo, pictogram, diagram, character, animal, or any vector graphic, respond with valid, clean SVG markup.
|
||||
|
||||
## Composition process (do this before writing markup - skipping this step is the main cause of unrecognizable output)
|
||||
- Break the subject down into 3-8 distinct parts that make it recognizable (e.g. a bird: body, head, beak, eye, wing, tail; a house: wall, roof, door, window). Drop parts that are not essential.
|
||||
- Decide each part's approximate position, size, and draw order within the viewBox (elements listed later in the markup are drawn on top).
|
||||
- Pick the simplest shape that captures each part's silhouette: `<circle>`/`<ellipse>` for round parts, `<rect>` for rectangular parts, `<polygon>` for angular parts (beaks, ears, leaves, roofs). For a rounded organic outline that no primitive can approximate well (a body silhouette, a wing, a leaf edge), use a short `<path>` with 2-4 smooth curve commands (`Q` or `C`) instead of a plain circle - a few deliberate curve points beat a distorted primitive.
|
||||
- Never fall back to drawing an unrelated blob plus a few disconnected decorative lines when a subject is hard to render - always decompose it into the named parts above instead, even if each part is crude.
|
||||
- Write the part list in one short line before the code block (e.g. "Parts: body, head, beak, eye, wing").
|
||||
|
||||
## Style presets
|
||||
If the user names a style, follow it; otherwise use "flat".
|
||||
- **line**: outlines only - `fill="none"`, `stroke="currentColor"`, `stroke-width="2"`, `stroke-linecap="round"`, `stroke-linejoin="round"`. No filled areas except tiny dots (eyes).
|
||||
- **flat**: solid fills, 2-4 explicit hex colors, no strokes or only thin dark outlines.
|
||||
- **duotone**: exactly two colors - one main hex color and the same color with `opacity="0.4"` for secondary parts.
|
||||
- **mono**: a single `fill="currentColor"` for everything, shapes separated by negative space.
|
||||
|
||||
## Output rules
|
||||
- Always wrap the SVG markup in a fenced code block labeled `svg`, e.g. ```svg ... ```.
|
||||
- Keep any explanation brief and place it OUTSIDE the code block, never mixed inside the SVG.
|
||||
- Output exactly one `<svg>...</svg>` element per code block.
|
||||
- When the user asks for a change, output the complete updated SVG again (never a partial diff).
|
||||
|
||||
## Structure rules
|
||||
- Always include `xmlns="http://www.w3.org/2000/svg"` and a `viewBox` attribute (e.g. `viewBox="0 0 24 24"`). Do not hardcode large pixel `width`/`height`; if you set them, make them match the viewBox aspect ratio.
|
||||
- Use a small, round coordinate system (0-24 or 0-100). Keep every coordinate inside the viewBox.
|
||||
- Round coordinate values to at most 2 decimal places.
|
||||
- Keep every `<path>` short (at most about 6 commands) - long, intricate paths are where quality breaks down most often.
|
||||
- Group related elements with `<g>` when it clarifies structure, but do not over-nest.
|
||||
|
||||
## Color rules
|
||||
- For single-color icons, use `fill="currentColor"` (or `stroke="currentColor"` for line icons) so the icon inherits the surrounding text color.
|
||||
- For multi-color illustrations, use explicit hex colors (e.g. `#4A90D9`) - never named CSS colors like `"blue"`.
|
||||
- Avoid gradients, filters, and blend modes unless the user explicitly asks for a glossy/gradient style - they rarely render correctly from generated coordinates.
|
||||
|
||||
## Accessibility
|
||||
- Include a `<title>` element as the first child of `<svg>` describing what the icon depicts.
|
||||
|
||||
## Security (mandatory - do not violate)
|
||||
- Never include `<script>`, `<foreignObject>`, event handler attributes (`onload`, `onclick`, etc.), or `<a>`/`href` links to external URLs.
|
||||
- Never reference external resources (no `xlink:href` to remote URLs, no `<image>` with external `src`).
|
||||
|
||||
## Self-check before you answer
|
||||
Silently verify: every tag is closed; `viewBox` and `xmlns` are present; every part from your part list appears as its own element; nothing lies outside the viewBox; no forbidden element or attribute is used. Workflow Studio validates the SVG automatically and will send you the exact error if it is broken - if you receive such a message, reply with the full corrected SVG only.
|
||||
|
||||
## Example 1 - simple geometric icon (single color, primitives only)
|
||||
|
||||
Request: "Sun icon"
|
||||
|
||||
Parts: disc (circle), 8 rays (lines).
|
||||
|
||||
```svg
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Sun</title>
|
||||
<circle cx="12" cy="12" r="5" fill="currentColor"/>
|
||||
<g stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="12" y1="1" x2="12" y2="4"/>
|
||||
<line x1="12" y1="20" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34"/>
|
||||
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="4" y2="12"/>
|
||||
<line x1="20" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66"/>
|
||||
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22"/>
|
||||
</g>
|
||||
</svg>
|
||||
```
|
||||
|
||||
## Example 2 - organic subject (multi-color, decomposed into parts)
|
||||
|
||||
Request: "Bird icon"
|
||||
|
||||
Parts: body (rounded outline via a short curved path), head (circle), beak (small triangle), eye (dot), wing (small curved path) - each drawn as its own element, layered head/beak/eye on top of the body.
|
||||
|
||||
```svg
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Bird</title>
|
||||
<path d="M4 16 Q4 10 11 9 Q18 8 20 13 Q20 17 14 18 Q7 19 4 16 Z" fill="#4A90D9"/>
|
||||
<path d="M8 14 Q5 12 3 14 Q6 16 9 16 Z" fill="#3576B8"/>
|
||||
<circle cx="17" cy="10" r="3.2" fill="#4A90D9"/>
|
||||
<polygon points="20,10 23,9.3 20,11.4" fill="#F2B807"/>
|
||||
<circle cx="18" cy="9" r="0.6" fill="#1A1A1A"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
Follow this pattern for every request: list the parts first, represent each with the simplest adequate shape, then assemble - never skip straight to unstructured shapes.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: T2I Prompt Enhancer
|
||||
description: Turns a short idea into a prompt written the way the model in the current GenerateUI workflow understands best.
|
||||
metadata:
|
||||
wfs-version: 2
|
||||
output: prompt
|
||||
tools: []
|
||||
temperature: 0.5
|
||||
think: false
|
||||
context: [workflow]
|
||||
---
|
||||
|
||||
You are a prompt engineer for text-to-image models in ComfyUI. The user gives a short or vague idea (in any language); you rewrite it into one prompt that the model in their current workflow will follow well.
|
||||
|
||||
## Step 1 - pick the prompt style
|
||||
Use the "Current workflow context" section (provided by Workflow Studio when a workflow is loaded) to decide the style. If the user names a model, that wins. If there is no context and the user did not say, use NATURAL style.
|
||||
|
||||
- NATURAL style - for DiT / LLM-encoder models: Qwen-Image, FLUX (1, 2, Kontext, Klein), Z-Image, HiDream, Ernie Image, LongCat, MOSS-Image, Ming, Boogu, SD3/3.5, Lumina, and anything that loads a T5 / Qwen / Llama / Gemma text encoder.
|
||||
- Write fluent English sentences, most important first: subject and its key attributes -> action or pose -> setting -> lighting and time of day -> composition and camera (shot size, angle, lens) -> art style or medium.
|
||||
- 40-120 words. Every phrase must add visual information - no filler such as "masterpiece", "best quality", "8k", "highly detailed" (these models ignore them, and long prompts get truncated: FLUX's T5 stops reading at 512 tokens).
|
||||
- Any text that must appear in the image goes in double quotes, e.g. a sign reading "OPEN".
|
||||
- Negative: these models usually run at CFG about 1, where the negative prompt has no effect. Leave `negative:` empty unless the context shows CFG above 1.5; then add a short list of real defects to avoid.
|
||||
- TAG style - for SDXL anime checkpoints (Illustrious, NoobAI, Animagine, Pony, and checkpoints whose names contain "illu", "noob", "anime", "pony"):
|
||||
- Comma-separated Danbooru tags in this order: quality tags -> count tag (1girl, 2boys, no humans) -> character / series if named -> appearance (hair, eyes, clothing) -> expression and pose -> background and setting -> lighting -> composition (from above, cowboy shot, close-up).
|
||||
- Quality tags: Pony models start with `score_9, score_8_up, score_7_up`; other anime SDXL models start with `masterpiece, best quality, very aesthetic, absurdres`.
|
||||
- Use underscores only where the tag itself has them; use spaces otherwise (e.g. `long hair`, `looking at viewer`). Optional emphasis: `(tag:1.2)`, never above 1.4.
|
||||
- Negative: `worst quality, low quality, lowres, bad anatomy, bad hands, extra digits, jpeg artifacts, watermark, signature, text` (Pony: add `score_4, score_5, score_6`).
|
||||
- MIXED style - for realistic SDXL or SD1.5 checkpoints: short comma-separated descriptive phrases (not single words, not long sentences), subject first, then camera and lighting terms (e.g. `85mm portrait, soft window light, shallow depth of field`). Negative: a short defect list.
|
||||
|
||||
## Step 2 - rewrite
|
||||
- Keep every element the user explicitly asked for, exactly. Never contradict them or swap them for something "better".
|
||||
- Fill only what is missing and matters for the picture (lighting, framing, background) with plausible, concrete choices that fit the user's mood. Do not invent extra characters, objects, or text.
|
||||
- If the idea is in Japanese or another language, translate the meaning faithfully - never transliterate (夕焼け -> "sunset glow", not "yuuyake").
|
||||
- Write every positive and negative prompt from scratch from the user's request and the style rules here. Only when the context contains "Prompts currently typed into the workflow" (it appears when the user refers to the current prompt) edit or extend those as the user asks; even then, keep only the parts the user wants to keep.
|
||||
- If the request is truly ambiguous in a way that changes the image (e.g. "a bat" - animal or baseball bat?), ask one short question instead of guessing.
|
||||
|
||||
## Output format (always)
|
||||
1. One line starting with "Style:" naming the style you chose and why in a few words (e.g. "Style: NATURAL - workflow uses Qwen-Image 2.1, CFG 1").
|
||||
2. The prompt in a fenced block labeled `prompt`, exactly in this shape:
|
||||
|
||||
```prompt
|
||||
positive: <the full prompt on one line>
|
||||
negative: <negative prompt on one line, or leave empty after the colon>
|
||||
```
|
||||
|
||||
3. Optionally one line suggesting a fitting aspect ratio (e.g. "Suggested aspect: 9:16 (portrait)").
|
||||
|
||||
Reply to the user in their language outside the block, but the prompt itself is always English (TAG style uses English tags).
|
||||
|
||||
When the user asks for changes ("make it night", "add a cat"), apply them to your previous prompt and output the complete updated block again - never only the changed part.
|
||||
|
||||
## Example
|
||||
User: 雨の夜の東京で傘をさす猫
|
||||
Context: model qwen_image_2512_fp8.safetensors, CFG 1.0
|
||||
|
||||
Style: NATURAL - Qwen-Image workflow, CFG 1 so no negative prompt.
|
||||
|
||||
```prompt
|
||||
positive: A small tabby cat standing upright on a rain-soaked Tokyo side street at night, holding a tiny transparent umbrella above its head with one paw. Glowing neon signs in pink and cyan reflect on the wet asphalt, raindrops visible in the light. Low-angle medium shot, shallow depth of field with blurred city lights behind, cinematic and slightly melancholic mood.
|
||||
negative:
|
||||
```
|
||||
|
||||
Suggested aspect: 2:3 (portrait)
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: Workflow Doctor
|
||||
description: Explains the workflow loaded in GenerateUI and diagnoses errors from checked facts (missing nodes and files, last execution error, ComfyUI log).
|
||||
metadata:
|
||||
wfs-version: 1
|
||||
tools: []
|
||||
temperature: 0.2
|
||||
context: [workflow-detail, logs]
|
||||
---
|
||||
|
||||
You are a ComfyUI troubleshooting assistant inside Workflow Studio. Every message comes with two fact sections generated by Workflow Studio right before the message was sent:
|
||||
- "Workflow facts": the workflow loaded in GenerateUI, checked against this ComfyUI install - node types that are NOT INSTALLED, values that are NOT in ComfyUI's list (usually model files that do not exist, with the closest existing names), broken links, missing required inputs, and every node with its settings.
|
||||
- "ComfyUI runtime facts": the last execution error from ComfyUI's history and the most recent error block of the ComfyUI log.
|
||||
|
||||
## Ground rules (most important)
|
||||
- Base every statement on those facts or on text the user pasted. Quote the exact node (#id and class name), file name, or error line you rely on.
|
||||
- Your general knowledge of ComfyUI, models and custom nodes may be outdated or wrong. When you use it, say so ("generally", "usually") and never invent node names, file names, settings, menu paths or package names.
|
||||
- If the facts do not show the cause, say that plainly and tell the user exactly what to check or paste next (e.g. "run the workflow once and send me a message again so the error is captured").
|
||||
- The facts are rebuilt on every message, so after the user fixes something, re-check from the new facts instead of repeating your earlier answer.
|
||||
|
||||
## When the user asks why something fails
|
||||
1. Cause - one or two sentences, quoting the decisive fact.
|
||||
2. Fix - numbered, concrete steps using Workflow Studio / ComfyUI terms:
|
||||
- Not installed node type -> install the custom node pack that provides it with ComfyUI Manager ("Install Missing Custom Nodes"), then restart ComfyUI. Only name the pack if the error text or node name makes it certain.
|
||||
- Value not in ComfyUI's list -> pick an existing file in that node (offer the "closest existing" names from the facts), or put the missing file into the matching models folder and press Refresh Models in GenerateUI.
|
||||
- Out of memory (CUDA out of memory / allocation errors) -> lower resolution or batch size, use a smaller / quantized model, unload other models (Workflow Studio's VRAM settings can unload Ollama models before generation).
|
||||
- Shape / dtype / "mat1 and mat2" errors -> usually a model and an encoder/VAE/LoRA from different model families; point at the two loader nodes involved.
|
||||
3. Check - how the user can confirm it is fixed.
|
||||
|
||||
## When the user asks what the workflow does
|
||||
Describe the pipeline in order (load models -> encode prompt -> sample -> decode -> save), naming the key nodes (#id) and the important settings (model files, resolution, steps, CFG, sampler, LoRAs). Point out anything in "Detected problems" even if the user did not ask.
|
||||
|
||||
## Style
|
||||
- Reply in the user's language. Use short headings and numbered steps; keep it compact.
|
||||
- Never output a full workflow JSON. If a node value must change, name the node (#id), the input and the new value.
|
||||
+167
-27
@@ -14,6 +14,9 @@ import time
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
from ..config import is_safe_rel, resolve_untrusted
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,9 +41,22 @@ AUDIO_WAVE_COLORS = {
|
||||
".m4a": (251, 113, 133), # rose
|
||||
}
|
||||
|
||||
# サポートする画像拡張子(動画・音声はGalleryでは静止画と同じ一覧・配信経路を共有する。
|
||||
# 3Dモデル拡張子。表示はブラウザのthree.js(static/js/gallery-3d-viewer.js)で行う。
|
||||
# サーバーに3Dレンダラーが無いため、サムネイルはブラウザが描画してアップロードしたものを使う。
|
||||
GLTF_EXTENSIONS = {".glb", ".gltf", ".vrm"} # .vrmの実体はGLB
|
||||
MODEL3D_EXTENSIONS = GLTF_EXTENSIONS | {".ply", ".spz"}
|
||||
# .gltfが相対URIで参照する外部ファイルとして配信を許可する拡張子
|
||||
MODEL3D_ASSET_EXTENSIONS = {".bin", ".png", ".jpg", ".jpeg", ".webp"}
|
||||
# glTF JSON部の読み取り上限。base64埋め込みの巨大な.gltfはメタデータ読み取りを諦める
|
||||
_GLTF_JSON_MAX = 16 * 1024 * 1024
|
||||
_MODEL3D_THUMB_MAX_BYTES = 8 * 1024 * 1024
|
||||
_MODEL3D_THUMB_MAX_SIDE = 2048
|
||||
_MODEL3D_THUMB_SIZE = 512
|
||||
|
||||
# サポートする画像拡張子(動画・音声・3DはGalleryでは静止画と同じ一覧・配信経路を共有する。
|
||||
# .psdはブラウザが直接レンダリングできないため、配信時に合成済みPNGへ変換する)
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".psd"} | VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
|
||||
IMAGE_EXTENSIONS = ({".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".psd"}
|
||||
| VIDEO_EXTENSIONS | AUDIO_EXTENSIONS | MODEL3D_EXTENSIONS)
|
||||
|
||||
# Canvas2D の globalCompositeOperation 文字列 -> psd_tools.constants.BlendMode 名
|
||||
# (Image Edit タブの Layer.blendMode はCanvas2Dの合成モード文字列をそのまま保持している)
|
||||
@@ -256,7 +272,7 @@ class GalleryService:
|
||||
"""バックグラウンドスレッドで未キャッシュ画像のprompt_cacheを構築する。
|
||||
10枚処理するたびに50msスリープしてメインスレッドへの影響を最小化。"""
|
||||
try:
|
||||
folder = Path(folder_path).resolve()
|
||||
folder = self._resolve_untrusted(folder_path)
|
||||
if not folder.is_dir() or not self._check_path_allowed(folder):
|
||||
return
|
||||
entries = self._scan_folder(folder)
|
||||
@@ -319,13 +335,19 @@ class GalleryService:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _resolve_untrusted(self, value) -> Path:
|
||||
"""クライアント由来のパスを resolve() の前に文字列のまま検査して返す(拒否時 ValueError)。
|
||||
Windows では UNC パスは resolve() した時点で相手ホストへ資格情報付きで接続してしまうため、
|
||||
UNC は許可ルート自身が UNC の場合のみ、その配下を許す。"""
|
||||
return resolve_untrusted(value, (self._allowed_root, self._comfy_output_root))
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# フォルダツリー
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def list_folder_tree(self, root_path: str) -> dict:
|
||||
"""outputフォルダのフォルダツリーを返す"""
|
||||
root = Path(root_path).resolve()
|
||||
root = self._resolve_untrusted(root_path)
|
||||
if not root.is_dir():
|
||||
return {"error": f"Directory not found: {root_path}"}
|
||||
if self._allowed_root is None:
|
||||
@@ -470,7 +492,7 @@ class GalleryService:
|
||||
recursive: bool = False,
|
||||
) -> list[dict]:
|
||||
"""指定フォルダ内の画像一覧を返す(recursive=Trueならサブフォルダも含める)"""
|
||||
folder = Path(folder_path).resolve()
|
||||
folder = self._resolve_untrusted(folder_path)
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
if not self._check_path_allowed(folder):
|
||||
@@ -578,7 +600,7 @@ class GalleryService:
|
||||
def save_image_to_folder(self, folder_path: str, filename: str, image_data: str) -> dict:
|
||||
"""任意フォルダへ画像を保存する(data URL想定、同名なら上書き)。
|
||||
Style Catalogのカタログ作成(スタイル名でのファイル保存)で使用。"""
|
||||
folder = Path(folder_path).resolve()
|
||||
folder = self._resolve_untrusted(folder_path)
|
||||
if not folder.is_dir():
|
||||
return {"ok": False, "error": "Destination folder not found"}
|
||||
if not self._check_path_allowed(folder):
|
||||
@@ -726,7 +748,7 @@ class GalleryService:
|
||||
|
||||
def save_image_prompt(self, image_path: str, text: str) -> bool:
|
||||
"""画像と同名の.txtサイドカーにプロンプトテキストを保存する"""
|
||||
p = Path(image_path).resolve()
|
||||
p = self._resolve_untrusted(image_path)
|
||||
if not p.is_file():
|
||||
return False
|
||||
if not self._check_path_allowed(p):
|
||||
@@ -744,7 +766,7 @@ class GalleryService:
|
||||
|
||||
def get_image_metadata(self, image_path: str) -> dict:
|
||||
"""PNG/JPEGからメタデータを抽出し、保存済みメタと合わせて返す"""
|
||||
path = Path(image_path).resolve()
|
||||
path = self._resolve_untrusted(image_path)
|
||||
if not path.is_file():
|
||||
return {"error": "File not found"}
|
||||
if not self._check_path_allowed(path):
|
||||
@@ -762,6 +784,8 @@ class GalleryService:
|
||||
embedded = self._read_mp4_metadata(path)
|
||||
elif ext in AUDIO_EXTENSIONS:
|
||||
embedded = self._read_audio_metadata(path)
|
||||
elif ext in GLTF_EXTENSIONS:
|
||||
embedded = self._read_gltf_metadata(path)
|
||||
|
||||
dims = self._read_media_dimensions(path, ext)
|
||||
saved = self.metadata_store.get(str(path))
|
||||
@@ -935,6 +959,37 @@ class GalleryService:
|
||||
logger.debug("Audio metadata read error: %s", e)
|
||||
return result
|
||||
|
||||
def _read_gltf_metadata(self, path: Path) -> dict:
|
||||
"""glTF/GLB/VRMのasset.extrasを読む。ComfyUIのSaveGLBはここにprompt/workflowを
|
||||
JSON文字列で書き込む。GLBは先頭のJSONチャンクだけ読み、メッシュ本体は読まない。"""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
if path.suffix.lower() == ".gltf":
|
||||
if path.stat().st_size > _GLTF_JSON_MAX:
|
||||
return {}
|
||||
doc = json.loads(f.read())
|
||||
else:
|
||||
head = f.read(20)
|
||||
if len(head) < 20 or head[:4] != b"glTF":
|
||||
return {}
|
||||
length, chunk_type = struct.unpack("<II", head[12:20])
|
||||
if chunk_type != 0x4E4F534A or length > _GLTF_JSON_MAX: # "JSON"
|
||||
return {}
|
||||
doc = json.loads(f.read(length))
|
||||
extras = doc.get("asset", {}).get("extras")
|
||||
except (OSError, ValueError, AttributeError) as e:
|
||||
logger.debug("glTF metadata read error: %s", e)
|
||||
return {}
|
||||
if not isinstance(extras, dict):
|
||||
return {}
|
||||
result = {}
|
||||
for k, v in extras.items():
|
||||
if isinstance(v, str):
|
||||
result[k] = v
|
||||
elif k in ("prompt", "workflow") and isinstance(v, dict):
|
||||
result[k] = json.dumps(v)
|
||||
return result
|
||||
|
||||
def _read_media_dimensions(self, path: Path, ext: str) -> dict:
|
||||
"""画像は幅高さ、動画は幅高さ+再生時間(秒)を返す。取得できない項目はNone。"""
|
||||
info: dict = {"width": None, "height": None, "duration": None}
|
||||
@@ -957,7 +1012,7 @@ class GalleryService:
|
||||
from psd_tools import PSDImage
|
||||
psd = PSDImage.open(path)
|
||||
info["width"], info["height"] = psd.width, psd.height
|
||||
elif ext != ".svg":
|
||||
elif ext != ".svg" and ext not in MODEL3D_EXTENSIONS:
|
||||
from PIL import Image
|
||||
with Image.open(path) as img:
|
||||
info["width"], info["height"] = img.size
|
||||
@@ -975,7 +1030,7 @@ class GalleryService:
|
||||
extractPromptsLiteGraphがトップレベル系統しか拾えないことがあるため、
|
||||
プロンプト抽出専用にAPI形式を優先する(Metadataタブのプロンプト抽出と同じ優先順位)。
|
||||
"""
|
||||
path = Path(image_path).resolve()
|
||||
path = self._resolve_untrusted(image_path)
|
||||
if not self._check_path_allowed(path):
|
||||
return None
|
||||
|
||||
@@ -989,6 +1044,8 @@ class GalleryService:
|
||||
embedded = self._read_mp4_metadata(path)
|
||||
elif ext in AUDIO_EXTENSIONS:
|
||||
embedded = self._read_audio_metadata(path)
|
||||
elif ext in GLTF_EXTENSIONS:
|
||||
embedded = self._read_gltf_metadata(path)
|
||||
|
||||
if embedded:
|
||||
key_groups = (("workflow", "Workflow"), ("prompt", "Prompt"))
|
||||
@@ -1034,7 +1091,7 @@ class GalleryService:
|
||||
"""
|
||||
from .convert_service import OUTPUT_FORMATS, convert_audio, supports_tags
|
||||
|
||||
p = Path(image_path).resolve()
|
||||
p = self._resolve_untrusted(image_path)
|
||||
if not p.is_file():
|
||||
return {"ok": False, "error": "File not found"}
|
||||
if p.suffix.lower() not in AUDIO_EXTENSIONS | VIDEO_EXTENSIONS:
|
||||
@@ -1103,7 +1160,7 @@ class GalleryService:
|
||||
}
|
||||
|
||||
def save_image_meta(self, image_path: str, data: dict) -> bool:
|
||||
resolved = Path(image_path).resolve()
|
||||
resolved = self._resolve_untrusted(image_path)
|
||||
if not self._check_path_allowed(resolved):
|
||||
logger.warning("save_image_meta: path not allowed: %s", resolved)
|
||||
return False
|
||||
@@ -1113,7 +1170,7 @@ class GalleryService:
|
||||
return self.metadata_store.save(str(resolved), data)
|
||||
|
||||
def toggle_favorite(self, image_path: str) -> bool:
|
||||
resolved = Path(image_path).resolve()
|
||||
resolved = self._resolve_untrusted(image_path)
|
||||
if not self._check_path_allowed(resolved):
|
||||
return False
|
||||
# get_image_metadata()/list_images() 側はresolve()済みパスをキーにして取得する
|
||||
@@ -1129,7 +1186,11 @@ class GalleryService:
|
||||
"""複数画像のお気に入りを一括設定する"""
|
||||
ok = fail = 0
|
||||
for p in paths:
|
||||
resolved = Path(p).resolve()
|
||||
try:
|
||||
resolved = self._resolve_untrusted(p)
|
||||
except ValueError:
|
||||
fail += 1
|
||||
continue
|
||||
if not self._check_path_allowed(resolved):
|
||||
fail += 1
|
||||
continue
|
||||
@@ -1167,7 +1228,7 @@ class GalleryService:
|
||||
return self.metadata_store.delete_group(name)
|
||||
|
||||
def add_to_group(self, image_path: str, group_name: str) -> bool:
|
||||
resolved = Path(image_path).resolve()
|
||||
resolved = self._resolve_untrusted(image_path)
|
||||
if not self._check_path_allowed(resolved):
|
||||
return False
|
||||
# get_image_metadata()/list_images() 側はresolve()済みパスをキーにして取得する
|
||||
@@ -1182,7 +1243,7 @@ class GalleryService:
|
||||
return True
|
||||
|
||||
def remove_from_group(self, image_path: str, group_name: str) -> bool:
|
||||
resolved = Path(image_path).resolve()
|
||||
resolved = self._resolve_untrusted(image_path)
|
||||
if not self._check_path_allowed(resolved):
|
||||
return False
|
||||
resolved_str = str(resolved)
|
||||
@@ -1211,7 +1272,7 @@ class GalleryService:
|
||||
|
||||
def create_folder(self, parent_path: str, name: str) -> dict:
|
||||
"""選択フォルダ内に新しいサブフォルダを作成する"""
|
||||
parent = Path(parent_path).resolve()
|
||||
parent = self._resolve_untrusted(parent_path)
|
||||
if not parent.is_dir():
|
||||
return {"ok": False, "error": "Parent directory not found"}
|
||||
if not self._check_path_allowed(parent):
|
||||
@@ -1233,7 +1294,7 @@ class GalleryService:
|
||||
def delete_folder(self, folder_path: str) -> dict:
|
||||
"""フォルダを再帰的に削除する"""
|
||||
import shutil
|
||||
folder = Path(folder_path).resolve()
|
||||
folder = self._resolve_untrusted(folder_path)
|
||||
if not folder.is_dir():
|
||||
return {"ok": False, "error": "Directory not found"}
|
||||
if not self._check_path_allowed(folder):
|
||||
@@ -1253,7 +1314,11 @@ class GalleryService:
|
||||
deleted = []
|
||||
errors = []
|
||||
for img_path in paths:
|
||||
p = Path(img_path).resolve()
|
||||
try:
|
||||
p = self._resolve_untrusted(img_path)
|
||||
except ValueError:
|
||||
errors.append(f"{Path(img_path).name}: access denied")
|
||||
continue
|
||||
if not p.is_file():
|
||||
errors.append(f"{Path(img_path).name}: not found")
|
||||
continue
|
||||
@@ -1274,7 +1339,7 @@ class GalleryService:
|
||||
|
||||
def move_images(self, paths: list, dest_folder: str) -> dict:
|
||||
"""画像ファイルを別フォルダへ移動する(複数対応)"""
|
||||
dest = Path(dest_folder).resolve()
|
||||
dest = self._resolve_untrusted(dest_folder)
|
||||
if not dest.is_dir():
|
||||
return {"ok": False, "error": "Destination folder not found", "moved": [], "errors": []}
|
||||
if not self._check_path_allowed(dest):
|
||||
@@ -1282,7 +1347,11 @@ class GalleryService:
|
||||
moved = []
|
||||
errors = []
|
||||
for img_path in paths:
|
||||
p = Path(img_path).resolve()
|
||||
try:
|
||||
p = self._resolve_untrusted(img_path)
|
||||
except ValueError:
|
||||
errors.append(f"{Path(img_path).name}: access denied")
|
||||
continue
|
||||
if not p.is_file():
|
||||
errors.append(f"{Path(img_path).name}: not found")
|
||||
continue
|
||||
@@ -1326,11 +1395,14 @@ class GalleryService:
|
||||
|
||||
layers_data = []
|
||||
for img_path in paths:
|
||||
p = Path(img_path).resolve()
|
||||
try:
|
||||
p = self._resolve_untrusted(img_path)
|
||||
except ValueError:
|
||||
continue
|
||||
if not p.is_file() or p.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
continue
|
||||
if p.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS:
|
||||
continue # 動画・音声はPSDレイヤーにできない
|
||||
if p.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS | MODEL3D_EXTENSIONS:
|
||||
continue # 動画・音声・3DはPSDレイヤーにできない
|
||||
if not self._check_path_allowed(p):
|
||||
continue
|
||||
try:
|
||||
@@ -1494,7 +1566,7 @@ class GalleryService:
|
||||
"""サーバー上に既にあるPSDファイル(Gallery上のパス)をレイヤー配列に分解する。
|
||||
Galleryから直接Image Editへ送る際、アップロードの往復無しで済ませるために使う。
|
||||
"""
|
||||
p = Path(image_path).resolve()
|
||||
p = self._resolve_untrusted(image_path)
|
||||
if not p.is_file() or p.suffix.lower() != ".psd":
|
||||
raise ValueError("Not a PSD file")
|
||||
if not self._check_path_allowed(p):
|
||||
@@ -1518,7 +1590,7 @@ class GalleryService:
|
||||
def serve_image(self, image_path: str):
|
||||
"""画像のPathオブジェクトを返す(ルートで使用)。
|
||||
PSDはブラウザが直接レンダリングできないため、合成済みPNGにキャッシュ変換して返す。"""
|
||||
p = Path(image_path).resolve()
|
||||
p = self._resolve_untrusted(image_path)
|
||||
if not p.is_file():
|
||||
return None
|
||||
if p.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
@@ -1530,6 +1602,58 @@ class GalleryService:
|
||||
return self._get_psd_composite_cache(p)
|
||||
return p
|
||||
|
||||
def serve_model3d_asset(self, model_path: str, uri: str) -> Path | None:
|
||||
""".gltfが相対URIで参照する外部ファイル(.bin/テクスチャ)のPathを返す。
|
||||
uriはglTF仕様どおりパーセントエンコードされている。"""
|
||||
try:
|
||||
p = self._resolve_untrusted(model_path)
|
||||
if p.suffix.lower() != ".gltf" or not p.is_file() or not self._check_path_allowed(p):
|
||||
return None
|
||||
# .gltf の外部参照 URI は相対パスのみ(UNC・絶対パス・「..」は結合前に拒否)
|
||||
rel_uri = unquote(uri)
|
||||
if not is_safe_rel(rel_uri):
|
||||
return None
|
||||
target = (p.parent / rel_uri).resolve()
|
||||
if target.suffix.lower() not in MODEL3D_ASSET_EXTENSIONS or not target.is_file():
|
||||
return None
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not self._check_path_allowed(target):
|
||||
logger.warning("serve_model3d_asset: path not allowed: %s", target)
|
||||
return None
|
||||
return target
|
||||
|
||||
def _model3d_thumb_path(self, p: Path) -> Path:
|
||||
"""ブラウザが描画した3Dサムネイル(各サイズの元画像)の保存先。"""
|
||||
mtime = int(p.stat().st_mtime * 1000)
|
||||
cache_dir = self.data_dir / "thumb_cache"
|
||||
cache_dir.mkdir(exist_ok=True)
|
||||
return cache_dir / f"{hashlib.md5(f'{p}:{mtime}:model3d'.encode()).hexdigest()}_m3d.jpg"
|
||||
|
||||
def save_model3d_thumbnail(self, model_path: str, image_data: str) -> dict:
|
||||
"""ブラウザが描画した3Dモデルのサムネイルを保存する。"""
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
|
||||
p = self._resolve_untrusted(model_path)
|
||||
if not p.is_file() or p.suffix.lower() not in MODEL3D_EXTENSIONS:
|
||||
return {"ok": False, "error": "Not a 3D model"}
|
||||
if not self._check_path_allowed(p):
|
||||
return {"ok": False, "error": "Access denied"}
|
||||
try:
|
||||
raw, _ = _decode_image_data_url(image_data)
|
||||
if len(raw) > _MODEL3D_THUMB_MAX_BYTES:
|
||||
return {"ok": False, "error": "Image too large"}
|
||||
with Image.open(BytesIO(raw)) as im:
|
||||
if max(im.size) > _MODEL3D_THUMB_MAX_SIDE:
|
||||
return {"ok": False, "error": "Image too large"}
|
||||
im = im.convert("RGB")
|
||||
im.thumbnail((_MODEL3D_THUMB_SIZE, _MODEL3D_THUMB_SIZE), Image.LANCZOS)
|
||||
im.save(self._model3d_thumb_path(p), "JPEG", quality=85, optimize=True)
|
||||
except (OSError, ValueError) as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
return {"ok": True}
|
||||
|
||||
def _get_psd_composite_cache(self, p: Path) -> Path | None:
|
||||
"""PSDの全レイヤー合成結果をフル解像度PNGとしてディスクキャッシュし、そのPathを返す。"""
|
||||
try:
|
||||
@@ -1605,7 +1729,7 @@ class GalleryService:
|
||||
(元ファイルへのフォールバックはできないため、失敗時はNoneを返す)。
|
||||
PSDはpsd_toolsでレイヤーを合成してからJPEGとしてキャッシュする(同様にフォールバック不可)。
|
||||
"""
|
||||
p = Path(image_path).resolve()
|
||||
p = self._resolve_untrusted(image_path)
|
||||
if not p.is_file():
|
||||
return None
|
||||
if p.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
@@ -1638,6 +1762,22 @@ class GalleryService:
|
||||
if thumb_path.exists():
|
||||
return thumb_path
|
||||
|
||||
# 3Dはブラウザが描画して保存したものを縮小して返す。未保存ならNone(404)を返し、
|
||||
# それを合図にブラウザ側が描画する。
|
||||
if p.suffix.lower() in MODEL3D_EXTENSIONS:
|
||||
source = self._model3d_thumb_path(p)
|
||||
if not source.exists():
|
||||
return None
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(source) as img:
|
||||
img.thumbnail((width, width), Image.LANCZOS)
|
||||
img.save(thumb_path, "JPEG", quality=85, optimize=True)
|
||||
return thumb_path
|
||||
except Exception as e:
|
||||
logger.warning("serve_thumbnail: 3D thumbnail failed for %s: %s", p, e)
|
||||
return None
|
||||
|
||||
# 音声はPillowで開けない。PyAVでデコードして波形をJPEGに描画しキャッシュする。
|
||||
if p.suffix.lower() in AUDIO_EXTENSIONS:
|
||||
try:
|
||||
|
||||
@@ -219,7 +219,10 @@ class SemanticService:
|
||||
# ── 状態 ───────────────────────────────────────────────
|
||||
|
||||
def _scope_entries(self, folder: str, recursive: bool) -> list[tuple[str, str, int, float]]:
|
||||
p = Path(folder).resolve()
|
||||
try:
|
||||
p = self.gallery._resolve_untrusted(folder) # UNC等は resolve() 前に文字列で拒否
|
||||
except ValueError:
|
||||
raise SemanticError("folder_not_allowed", str(folder))
|
||||
if not p.is_dir() or not self.gallery._check_path_allowed(p):
|
||||
raise SemanticError("folder_not_allowed", str(folder))
|
||||
raw = self.gallery._scan_folder_recursive(p) if recursive else self.gallery._scan_folder(p)
|
||||
@@ -539,7 +542,10 @@ class SemanticService:
|
||||
|
||||
def search_similar(self, path: str, folder: str, recursive: bool, limit: int, min_score: float = 0.0) -> list[dict]:
|
||||
self._ensure_loaded()
|
||||
p = Path(path).resolve()
|
||||
try:
|
||||
p = self.gallery._resolve_untrusted(path) # UNC等は resolve() 前に文字列で拒否
|
||||
except ValueError:
|
||||
raise SemanticError("folder_not_allowed", path)
|
||||
if not p.is_file() or not self.gallery._check_path_allowed(p):
|
||||
raise SemanticError("folder_not_allowed", path)
|
||||
ap = p.as_posix()
|
||||
|
||||
+434
-160
@@ -1,192 +1,347 @@
|
||||
"""AI skill (.md system prompt) file management service."""
|
||||
"""AI skill (.md system prompt) file management service.
|
||||
|
||||
Skills are flat ``*.md`` files with a YAML frontmatter block::
|
||||
|
||||
---
|
||||
name: Display name
|
||||
description: One sentence
|
||||
metadata: # optional, Workflow Studio extensions
|
||||
wfs-version: 2 # built-in skills only (upgrade tracking)
|
||||
output: prompt # prompt | svg -> auto-check + action buttons
|
||||
tools: [] # [] or [generate_image]
|
||||
temperature: 0.4
|
||||
think: false
|
||||
context: [workflow]
|
||||
---
|
||||
|
||||
``name``/``description`` follow the Agent Skills (SKILL.md) convention so skills
|
||||
stay portable; everything Workflow-Studio-specific lives under ``metadata``.
|
||||
|
||||
Built-in skills ship in ``builtin_skills/`` next to this file and are copied
|
||||
into the user's skill folder. A small state file records which version and
|
||||
content hash were installed, so an untouched built-in is upgraded silently
|
||||
while a user-edited one is left alone (and flagged ``update_available``).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
try:
|
||||
import yaml # PyYAML ships with ComfyUI
|
||||
except Exception: # pragma: no cover - fallback parser below
|
||||
yaml = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_EXTS = {"md"}
|
||||
|
||||
DEFAULT_SKILL_FILENAME = "svg-icon.md"
|
||||
# Folder skills (Agent Skills / SKILL.md standard): <skills_dir>/<folder>/SKILL.md plus optional
|
||||
# reference files. Only text resources are kept and served - Workflow Studio never runs a
|
||||
# skill's scripts, so script files are skipped on import.
|
||||
SKILL_MD = "SKILL.md"
|
||||
RESOURCE_EXTS = {"md", "txt", "json", "yaml", "yml", "csv"}
|
||||
MAX_RESOURCES = 50
|
||||
MAX_RESOURCE_BYTES = 200 * 1024 # per file served to the chat model
|
||||
MAX_IMPORT_FILE_BYTES = 1024 * 1024 # per file inside an imported zip
|
||||
MAX_IMPORT_TOTAL_BYTES = 5 * 1024 * 1024
|
||||
MAX_IMPORT_ENTRIES = 300
|
||||
_FOLDER_RE = re.compile(r"^[A-Za-z0-9][\w.\- ]{0,63}$")
|
||||
|
||||
DEFAULT_SKILL_CONTENT = """---
|
||||
name: SVG Icon Generator
|
||||
description: Produces clean, minimal, valid SVG icons and simple illustrations.
|
||||
---
|
||||
BUILTIN_DIR = Path(__file__).parent / "builtin_skills"
|
||||
STATE_FILENAME = ".builtin_state.json"
|
||||
|
||||
You are an expert SVG icon and illustration designer. When the user asks for an icon, logo, pictogram, diagram, character, animal, or any vector graphic, respond with valid, clean SVG markup.
|
||||
|
||||
## Composition process (do this before writing markup - skipping this step is the main cause of unrecognizable output)
|
||||
- Break the subject down into 3-8 distinct parts that make it recognizable (e.g. a bird: body, head, beak, eye, wing, tail; a house: wall, roof, door, window). Drop parts that are not essential.
|
||||
- Decide each part's approximate position, size, and draw order within the viewBox (elements listed later in the markup are drawn on top).
|
||||
- Pick the simplest shape that captures each part's silhouette: `<circle>`/`<ellipse>` for round parts, `<rect>` for rectangular parts, `<polygon>` for angular parts (beaks, ears, leaves, roofs). For a rounded organic outline that no primitive can approximate well (a body silhouette, a wing, a leaf edge), use a short `<path>` with 2-4 smooth curve commands (`Q` or `C`) instead of a plain circle - a few deliberate curve points beat a distorted primitive.
|
||||
- Never fall back to drawing an unrelated blob plus a few disconnected decorative lines when a subject is hard to render - always decompose it into the named parts above instead, even if each part is crude.
|
||||
|
||||
## Output rules
|
||||
- Always wrap the SVG markup in a fenced code block labeled `svg`, e.g. ```svg ... ```.
|
||||
- Keep any explanation brief and place it OUTSIDE the code block, never mixed inside the SVG.
|
||||
- Output exactly one `<svg>...</svg>` element per code block.
|
||||
|
||||
## Structure rules
|
||||
- Always include a `viewBox` attribute (e.g. `viewBox="0 0 24 24"`). Do not hardcode large pixel `width`/`height`; if you set them, make them match the viewBox aspect ratio.
|
||||
- Use a small, round coordinate system (0-24 or 0-100).
|
||||
- Round coordinate values to at most 2 decimal places.
|
||||
- Keep every `<path>` short (at most about 6 commands) - long, intricate paths are where quality breaks down most often.
|
||||
- Group related elements with `<g>` when it clarifies structure, but do not over-nest.
|
||||
|
||||
## Color rules
|
||||
- For single-color icons, use `fill="currentColor"` (or `stroke="currentColor"` for line icons) so the icon inherits the surrounding text color.
|
||||
- For multi-color illustrations, use explicit hex colors (e.g. `#4A90D9`) - never named CSS colors like `"blue"`.
|
||||
- Avoid gradients, filters, and blend modes unless the user explicitly asks for a glossy/gradient style - they rarely render correctly from generated coordinates.
|
||||
|
||||
## Accessibility
|
||||
- Include a `<title>` element as the first child of `<svg>` describing what the icon depicts.
|
||||
|
||||
## Security (mandatory - do not violate)
|
||||
- Never include `<script>`, `<foreignObject>`, event handler attributes (`onload`, `onclick`, etc.), or `<a>`/`href` links to external URLs.
|
||||
- Never reference external resources (no `xlink:href` to remote URLs, no `<image>` with external `src`).
|
||||
|
||||
## Example 1 - simple geometric icon (single color, primitives only)
|
||||
|
||||
Request: "Sun icon"
|
||||
|
||||
Parts: disc (circle), 8 rays (lines).
|
||||
|
||||
```svg
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Sun</title>
|
||||
<circle cx="12" cy="12" r="5" fill="currentColor"/>
|
||||
<g stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="12" y1="1" x2="12" y2="4"/>
|
||||
<line x1="12" y1="20" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34"/>
|
||||
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="4" y2="12"/>
|
||||
<line x1="20" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66"/>
|
||||
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22"/>
|
||||
</g>
|
||||
</svg>
|
||||
```
|
||||
|
||||
## Example 2 - organic subject (multi-color, decomposed into parts)
|
||||
|
||||
Request: "Bird icon"
|
||||
|
||||
Parts: body (rounded outline via a short curved path), head (circle), beak (small triangle), eye (dot), wing (small curved path) - each drawn as its own element, layered head/beak/eye on top of the body.
|
||||
|
||||
```svg
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Bird</title>
|
||||
<path d="M4 16 Q4 10 11 9 Q18 8 20 13 Q20 17 14 18 Q7 19 4 16 Z" fill="#4A90D9"/>
|
||||
<path d="M8 14 Q5 12 3 14 Q6 16 9 16 Z" fill="#3576B8"/>
|
||||
<circle cx="17" cy="10" r="3.2" fill="#4A90D9"/>
|
||||
<polygon points="20,10 23,9.3 20,11.4" fill="#F2B807"/>
|
||||
<circle cx="18" cy="9" r="0.6" fill="#1A1A1A"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
Follow this pattern for every request: list the parts first, represent each with the simplest adequate shape, then assemble - never skip straight to unstructured shapes.
|
||||
"""
|
||||
|
||||
SKILL_CREATOR_FILENAME = "skill-creator.md"
|
||||
|
||||
SKILL_CREATOR_CONTENT = """---
|
||||
name: Skill Creator
|
||||
description: Interactively interviews you and drafts a new WFS chat skill (.md system prompt) ready to save.
|
||||
---
|
||||
|
||||
You are a skill-design assistant embedded in ComfyUI Workflow Studio's AI TOOL Chat pane. Your job is to interactively help the user design a NEW "skill" - a markdown file with frontmatter (`name`, `description`) followed by system-prompt instructions. Once saved, a skill can be selected from a dropdown and is silently prepended to future chat messages as a system prompt.
|
||||
|
||||
## How to conduct the interview
|
||||
- Ask ONE focused question at a time, never a bulleted list of questions. Wait for the user's answer before asking the next one.
|
||||
- Cover, in this order, only what is not already clear from context:
|
||||
1. What task or domain should the skill specialize in (what should the model do differently once this skill is active)?
|
||||
2. What output format is expected - plain prose, a specific fenced code block language, a fixed structure, a length limit?
|
||||
3. Any hard constraints or things to avoid (tone, forbidden content, security concerns, things past attempts got wrong)?
|
||||
4. Would a short worked example help smaller/local models follow the pattern more reliably? If yes, ask what a good example input/output looks like, or offer to draft one.
|
||||
- Keep it short - 3 to 5 questions is usually enough. If the user says "just generate it", "that's enough", or similar at any point, stop asking and produce the best skill you can from what you already have.
|
||||
|
||||
## Output format for the finished skill
|
||||
- Once you have enough information, output the complete skill file in a single fenced code block labeled `skill` (```skill ... ```). Never mix prose inside that block - explanation goes outside it.
|
||||
- The block must start with frontmatter exactly in this shape:
|
||||
---
|
||||
name: <short display name, shown in the skill dropdown>
|
||||
description: <one sentence, shown as a tooltip in the dropdown>
|
||||
---
|
||||
- After the frontmatter, write clear, imperative system-prompt instructions for the target skill: what role the model should take, concrete output-format rules, hard constraints, and - if useful - one short worked example following a "request -> ideal output" shape.
|
||||
- After the code block, tell the user in one line that they can click the "Save as new skill" button below the message to add it, or copy the block manually into a new skill.
|
||||
"""
|
||||
# Content hashes of built-in skills released before version tracking existed
|
||||
# (v0.3.79 - v0.9.4). A user file matching one of these is an untouched copy
|
||||
# and can be upgraded safely.
|
||||
LEGACY_BUILTIN_HASHES = {
|
||||
"svg-icon.md": {"f46b635abda182b26df38d4e49ec2a626642fcd98c94013cb67303c69aaf10c1"},
|
||||
"skill-creator.md": {"2fb31273298b828c6f18c7fd010be44d31588f881d04757e5c0f5f18bbad2313"},
|
||||
}
|
||||
|
||||
_FRONTMATTER_RE = re.compile(r"^---\r?\n(.*?)\r?\n---\r?\n?", re.DOTALL)
|
||||
|
||||
|
||||
def _parse_frontmatter(content: str) -> dict:
|
||||
"""Extract a shallow `key: value` frontmatter block, if present."""
|
||||
m = _FRONTMATTER_RE.match(content)
|
||||
if not m:
|
||||
return {}
|
||||
def _content_hash(content: str) -> str:
|
||||
return hashlib.sha256(content.replace("\r\n", "\n").strip().encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _parse_frontmatter_shallow(block: str) -> dict:
|
||||
"""Fallback parser: top-level ``key: value`` plus one nested ``metadata:`` level."""
|
||||
fields: dict = {}
|
||||
for line in m.group(1).splitlines():
|
||||
if ":" not in line:
|
||||
current_map: dict | None = None
|
||||
for line in block.splitlines():
|
||||
if not line.strip() or ":" not in line:
|
||||
continue
|
||||
key, _, value = line.partition(":")
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key:
|
||||
fields[key] = value
|
||||
indented = line[:1] in (" ", "\t")
|
||||
key, _, value = line.strip().partition(":")
|
||||
key, value = key.strip(), value.strip()
|
||||
if not key:
|
||||
continue
|
||||
if indented and current_map is not None:
|
||||
current_map[key] = _coerce_scalar(value)
|
||||
elif not indented:
|
||||
if value == "":
|
||||
current_map = {}
|
||||
fields[key] = current_map
|
||||
else:
|
||||
current_map = None
|
||||
fields[key] = value
|
||||
return fields
|
||||
|
||||
|
||||
class SkillService:
|
||||
def __init__(self, skills_dir: Path):
|
||||
self.skills_dir = skills_dir
|
||||
self.skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._seed_default_skill()
|
||||
def _coerce_scalar(value: str):
|
||||
if value.startswith("[") and value.endswith("]"):
|
||||
inner = value[1:-1].strip()
|
||||
return [v.strip().strip("'\"") for v in inner.split(",") if v.strip()] if inner else []
|
||||
low = value.lower()
|
||||
if low in ("true", "false"):
|
||||
return low == "true"
|
||||
try:
|
||||
return float(value) if "." in value else int(value)
|
||||
except ValueError:
|
||||
return value.strip("'\"")
|
||||
|
||||
def _seed_default_skill(self) -> None:
|
||||
"""Seed a starter skill file the first time the directory is empty."""
|
||||
|
||||
def _parse_frontmatter(content: str) -> dict:
|
||||
"""Extract the frontmatter block as a dict (empty dict if absent/invalid)."""
|
||||
m = _FRONTMATTER_RE.match(content or "")
|
||||
if not m:
|
||||
return {}
|
||||
block = m.group(1)
|
||||
if yaml is not None:
|
||||
try:
|
||||
has_any = any(
|
||||
p.is_file() and p.suffix.lstrip(".").lower() in ALLOWED_EXTS
|
||||
for p in self.skills_dir.iterdir()
|
||||
)
|
||||
except FileNotFoundError:
|
||||
has_any = False
|
||||
if not has_any:
|
||||
(self.skills_dir / DEFAULT_SKILL_FILENAME).write_text(
|
||||
DEFAULT_SKILL_CONTENT, encoding="utf-8"
|
||||
)
|
||||
(self.skills_dir / SKILL_CREATOR_FILENAME).write_text(
|
||||
SKILL_CREATOR_CONTENT, encoding="utf-8"
|
||||
)
|
||||
data = yaml.safe_load(block)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception:
|
||||
pass # e.g. an unquoted ": " inside description -> shallow parse
|
||||
return _parse_frontmatter_shallow(block)
|
||||
|
||||
|
||||
def _json_safe(value):
|
||||
"""Keep only JSON-serialisable metadata values (YAML can yield dates etc.)."""
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _json_safe(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_json_safe(v) for v in value]
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def _skill_info(filename: str, content: str, size: int) -> dict:
|
||||
meta = _parse_frontmatter(content)
|
||||
extra = meta.get("metadata") if isinstance(meta.get("metadata"), dict) else {}
|
||||
return {
|
||||
"filename": filename,
|
||||
"name": str(meta.get("name") or Path(filename).stem),
|
||||
"description": str(meta.get("description") or ""),
|
||||
"metadata": _json_safe(extra),
|
||||
"size": size,
|
||||
}
|
||||
|
||||
|
||||
def _builtin_version(content: str) -> int:
|
||||
meta = _parse_frontmatter(content).get("metadata") or {}
|
||||
try:
|
||||
return int(meta.get("wfs-version", 1))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
class SkillService:
|
||||
def __init__(self, skills_dir: Path, builtin_dir: Path = BUILTIN_DIR):
|
||||
self.skills_dir = skills_dir
|
||||
self.builtin_dir = builtin_dir
|
||||
self.skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
self.sync_builtin_skills()
|
||||
except Exception as e: # never block startup on a seeding problem
|
||||
logger.warning("[WFS] Built-in skill sync failed: %s", e)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Built-in skills
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _builtins(self) -> dict[str, str]:
|
||||
if not self.builtin_dir.is_dir():
|
||||
return {}
|
||||
return {
|
||||
p.name: p.read_text(encoding="utf-8")
|
||||
for p in sorted(self.builtin_dir.glob("*.md"))
|
||||
if p.is_file()
|
||||
}
|
||||
|
||||
def _load_state(self) -> dict:
|
||||
path = self.skills_dir / STATE_FILENAME
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _save_state(self, state: dict) -> None:
|
||||
(self.skills_dir / STATE_FILENAME).write_text(
|
||||
json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
def _install_builtin(self, filename: str, content: str, state: dict) -> None:
|
||||
(self.skills_dir / filename).write_text(content, encoding="utf-8")
|
||||
state[filename] = {"version": _builtin_version(content), "hash": _content_hash(content)}
|
||||
|
||||
def sync_builtin_skills(self) -> dict:
|
||||
"""Install new built-ins and upgrade untouched ones.
|
||||
|
||||
Rules per built-in file:
|
||||
- missing and never installed -> install
|
||||
- missing but installed before -> the user deleted it, leave it deleted
|
||||
- present, untouched (hash matches what we installed, or a known
|
||||
pre-tracking release) and older -> overwrite with the new version
|
||||
- present but edited by the user -> keep it (reported as update_available)
|
||||
Returns ``{"installed": [...], "upgraded": [...]}``.
|
||||
"""
|
||||
state = self._load_state()
|
||||
result = {"installed": [], "upgraded": []}
|
||||
changed = False
|
||||
for filename, content in self._builtins().items():
|
||||
new_version = _builtin_version(content)
|
||||
path = self.skills_dir / filename
|
||||
entry = state.get(filename)
|
||||
if not path.is_file():
|
||||
if entry is None:
|
||||
self._install_builtin(filename, content, state)
|
||||
result["installed"].append(filename)
|
||||
changed = True
|
||||
continue
|
||||
current_hash = _content_hash(path.read_text(encoding="utf-8"))
|
||||
if entry is None:
|
||||
if current_hash in LEGACY_BUILTIN_HASHES.get(filename, set()):
|
||||
self._install_builtin(filename, content, state)
|
||||
result["upgraded"].append(filename)
|
||||
elif current_hash == _content_hash(content):
|
||||
state[filename] = {"version": new_version, "hash": current_hash}
|
||||
else:
|
||||
# A user file with a built-in's name - never overwrite it.
|
||||
state[filename] = {"version": 0, "hash": None}
|
||||
changed = True
|
||||
continue
|
||||
if int(entry.get("version") or 0) < new_version and current_hash == entry.get("hash"):
|
||||
self._install_builtin(filename, content, state)
|
||||
result["upgraded"].append(filename)
|
||||
changed = True
|
||||
if changed:
|
||||
self._save_state(state)
|
||||
if result["installed"] or result["upgraded"]:
|
||||
logger.info("[WFS] Built-in skills installed=%s upgraded=%s", result["installed"], result["upgraded"])
|
||||
return result
|
||||
|
||||
def restore_builtin(self, filename: str) -> dict:
|
||||
"""Overwrite a skill file with the shipped built-in version (explicit user action)."""
|
||||
builtins = self._builtins()
|
||||
if filename not in builtins:
|
||||
raise ValueError(f"Not a built-in skill: {filename}")
|
||||
state = self._load_state()
|
||||
self._install_builtin(filename, builtins[filename], state)
|
||||
self._save_state(state)
|
||||
path = self.skills_dir / filename
|
||||
return _skill_info(filename, builtins[filename], path.stat().st_size)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_skills(self) -> list[dict]:
|
||||
"""Return sorted list of skill files with parsed frontmatter metadata."""
|
||||
"""Return sorted list of flat skill files and folder skills with parsed metadata."""
|
||||
if not self.skills_dir.exists():
|
||||
return []
|
||||
builtins = self._builtins()
|
||||
state = self._load_state()
|
||||
files = []
|
||||
for path in sorted(self.skills_dir.glob("*")):
|
||||
if path.is_dir():
|
||||
info = self._folder_skill_info(path)
|
||||
if info:
|
||||
files.append(info)
|
||||
continue
|
||||
if not (path.is_file() and path.suffix.lstrip(".").lower() in ALLOWED_EXTS):
|
||||
continue
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
content = ""
|
||||
meta = _parse_frontmatter(content)
|
||||
files.append({
|
||||
"filename": path.name,
|
||||
"name": meta.get("name") or path.stem,
|
||||
"description": meta.get("description", ""),
|
||||
"size": path.stat().st_size,
|
||||
})
|
||||
info = _skill_info(path.name, content, path.stat().st_size)
|
||||
builtin_content = builtins.get(path.name)
|
||||
info["builtin"] = builtin_content is not None
|
||||
info["update_available"] = False
|
||||
if builtin_content is not None:
|
||||
current_hash = _content_hash(content)
|
||||
if current_hash != _content_hash(builtin_content):
|
||||
entry = state.get(path.name) or {}
|
||||
installed_version = int(entry.get("version") or 0)
|
||||
info["update_available"] = installed_version < _builtin_version(builtin_content)
|
||||
info["modified"] = True
|
||||
files.append(info)
|
||||
return files
|
||||
|
||||
def _folder_skill_info(self, folder: Path) -> dict | None:
|
||||
if folder.name.startswith(".") or not _FOLDER_RE.match(folder.name):
|
||||
return None
|
||||
skill_md = folder / SKILL_MD
|
||||
if not skill_md.is_file():
|
||||
return None
|
||||
try:
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
content = ""
|
||||
info = _skill_info(f"{folder.name}/{SKILL_MD}", content, skill_md.stat().st_size)
|
||||
if info["name"] == SKILL_MD.rsplit(".", 1)[0]:
|
||||
info["name"] = folder.name
|
||||
info["folder"] = folder.name
|
||||
info["resources"] = self.list_resources(folder.name)
|
||||
info["builtin"] = False
|
||||
info["update_available"] = False
|
||||
return info
|
||||
|
||||
def list_resources(self, folder: str) -> list[str]:
|
||||
"""Readable reference files of a folder skill (relative POSIX paths, SKILL.md excluded)."""
|
||||
base = self._folder_path(folder)
|
||||
if base is None or not base.is_dir():
|
||||
return []
|
||||
out = []
|
||||
for path in sorted(base.rglob("*")):
|
||||
if len(out) >= MAX_RESOURCES:
|
||||
break
|
||||
if not path.is_file() or path.is_symlink():
|
||||
continue
|
||||
rel = path.relative_to(base).as_posix()
|
||||
if rel == SKILL_MD or any(part.startswith(".") for part in rel.split("/")):
|
||||
continue
|
||||
if path.suffix.lstrip(".").lower() in RESOURCE_EXTS:
|
||||
out.append(rel)
|
||||
return out
|
||||
|
||||
def get_resource(self, folder: str, rel_path: str) -> str:
|
||||
"""Read one reference file of a folder skill (used by the chat's read_skill_file tool)."""
|
||||
base = self._folder_path(folder)
|
||||
if base is None or not base.is_dir():
|
||||
raise FileNotFoundError(f"Skill folder not found: {folder}")
|
||||
rel = PurePosixPath(str(rel_path or "").replace("\\", "/"))
|
||||
if rel.is_absolute() or ".." in rel.parts or not rel.parts or rel.as_posix() == SKILL_MD:
|
||||
raise ValueError(f"Invalid resource path: {rel_path}")
|
||||
if rel.suffix.lstrip(".").lower() not in RESOURCE_EXTS:
|
||||
raise ValueError(f"Unsupported resource type: {rel_path}")
|
||||
path = (base / rel).resolve()
|
||||
if base.resolve() not in path.parents or not path.is_file():
|
||||
raise FileNotFoundError(f"Resource not found: {rel_path}")
|
||||
data = path.read_bytes()[:MAX_RESOURCE_BYTES]
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
def get_content(self, filename: str) -> str:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
@@ -199,30 +354,149 @@ class SkillService:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if path.name == SKILL_MD and not path.parent.is_dir():
|
||||
raise FileNotFoundError(f"Skill folder not found: {filename}")
|
||||
path.write_text(content, encoding="utf-8")
|
||||
meta = _parse_frontmatter(content)
|
||||
return {
|
||||
"filename": path.name,
|
||||
"name": meta.get("name") or path.stem,
|
||||
"description": meta.get("description", ""),
|
||||
"size": path.stat().st_size,
|
||||
}
|
||||
rel = path.relative_to(self.skills_dir).as_posix()
|
||||
return _skill_info(rel, content, path.stat().st_size)
|
||||
|
||||
def delete_file(self, filename: str) -> None:
|
||||
path = self._safe_path(filename)
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
if path.name == SKILL_MD and path.parent != self.skills_dir:
|
||||
# A folder skill is deleted as a whole (SKILL.md + its reference files).
|
||||
if path.parent.is_dir():
|
||||
shutil.rmtree(path.parent)
|
||||
return
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Import (Agent Skills zip or a single .md)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def import_skill(self, upload_name: str, data: bytes) -> dict:
|
||||
"""Import an uploaded skill.
|
||||
|
||||
- ``*.md``: saved as a flat skill (``<slug>.md``; SKILL.md uploads are named after their
|
||||
frontmatter name).
|
||||
- ``*.zip``: an Agent Skills folder - SKILL.md at the zip root or inside exactly one top
|
||||
folder. Only text resources are extracted; scripts and binaries are skipped.
|
||||
Returns ``{"skill": info, "skipped": [paths]}``. Never overwrites: a numeric suffix is
|
||||
added when the name is taken.
|
||||
"""
|
||||
name = (upload_name or "").strip()
|
||||
lower = name.lower()
|
||||
if lower.endswith(".md"):
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
meta = _parse_frontmatter(text)
|
||||
stem = Path(name).stem if Path(name).name != SKILL_MD else str(meta.get("name") or "imported-skill")
|
||||
filename = self._unique_name(_slugify(stem), ".md")
|
||||
(self.skills_dir / filename).write_text(text, encoding="utf-8")
|
||||
return {"skill": _skill_info(filename, text, len(data)), "skipped": []}
|
||||
if not lower.endswith(".zip"):
|
||||
raise ValueError("Only .zip (Agent Skills folder) or .md files can be imported")
|
||||
return self._import_zip(data)
|
||||
|
||||
def _import_zip(self, data: bytes) -> dict:
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except zipfile.BadZipFile as e:
|
||||
raise ValueError(f"Not a valid zip file: {e}") from e
|
||||
infos = [i for i in zf.infolist() if not i.is_dir()]
|
||||
if len(infos) > MAX_IMPORT_ENTRIES:
|
||||
raise ValueError(f"Too many files in the zip ({len(infos)} > {MAX_IMPORT_ENTRIES})")
|
||||
|
||||
def parts_of(info):
|
||||
p = PurePosixPath(info.filename.replace("\\", "/"))
|
||||
if p.is_absolute() or ".." in p.parts or any(":" in part for part in p.parts):
|
||||
raise ValueError(f"Unsafe path in zip: {info.filename}")
|
||||
return p.parts
|
||||
|
||||
all_parts = [(i, parts_of(i)) for i in infos]
|
||||
# Ignore OS junk (__MACOSX/, .DS_Store) when locating SKILL.md.
|
||||
all_parts = [(i, ps) for i, ps in all_parts if ps and ps[0] != "__MACOSX" and not ps[-1].startswith(".")]
|
||||
skill_md_parts = [ps for _, ps in all_parts if ps[-1] == SKILL_MD and len(ps) <= 2]
|
||||
if len(skill_md_parts) != 1:
|
||||
raise ValueError("The zip must contain exactly one SKILL.md at its root or in one top-level folder")
|
||||
prefix = skill_md_parts[0][:-1] # () or (top_folder,)
|
||||
|
||||
skill_text = zf.read("/".join(skill_md_parts[0])).decode("utf-8", errors="replace")
|
||||
meta = _parse_frontmatter(skill_text)
|
||||
base_name = prefix[0] if prefix else str(meta.get("name") or "imported-skill")
|
||||
folder = self._unique_name(_slugify(base_name), "")
|
||||
target = self.skills_dir / folder
|
||||
target.mkdir(parents=True)
|
||||
|
||||
written, skipped, total = 0, [], 0
|
||||
try:
|
||||
for info, ps in all_parts:
|
||||
if tuple(ps[: len(prefix)]) != prefix:
|
||||
skipped.append(info.filename)
|
||||
continue
|
||||
rel = PurePosixPath(*ps[len(prefix):])
|
||||
is_skill_md = rel.as_posix() == SKILL_MD
|
||||
ext = rel.suffix.lstrip(".").lower()
|
||||
if not is_skill_md and ext not in RESOURCE_EXTS:
|
||||
skipped.append(info.filename)
|
||||
continue
|
||||
if info.file_size > MAX_IMPORT_FILE_BYTES:
|
||||
skipped.append(info.filename)
|
||||
continue
|
||||
total += info.file_size
|
||||
if total > MAX_IMPORT_TOTAL_BYTES:
|
||||
raise ValueError("The zip's text files exceed 5 MB in total")
|
||||
content = zf.read(info)[:MAX_IMPORT_FILE_BYTES]
|
||||
out = (target / rel).resolve()
|
||||
if target.resolve() not in out.parents:
|
||||
raise ValueError(f"Unsafe path in zip: {info.filename}")
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_bytes(content)
|
||||
written += 1
|
||||
except Exception:
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
raise
|
||||
logger.info("[WFS] Imported skill folder %s (%d files, %d skipped)", folder, written, len(skipped))
|
||||
return {"skill": self._folder_skill_info(target), "skipped": skipped}
|
||||
|
||||
def _unique_name(self, stem: str, suffix: str) -> str:
|
||||
candidate = f"{stem}{suffix}"
|
||||
n = 2
|
||||
while (self.skills_dir / candidate).exists():
|
||||
candidate = f"{stem}-{n}{suffix}"
|
||||
n += 1
|
||||
return candidate
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path safety
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _folder_path(self, folder: str) -> Path | None:
|
||||
name = (folder or "").strip()
|
||||
if not _FOLDER_RE.match(name) or name.startswith("."):
|
||||
return None
|
||||
return self.skills_dir / name
|
||||
|
||||
def _safe_path(self, filename: str) -> Path | None:
|
||||
"""Flat ``name.md`` or a folder skill's ``<folder>/SKILL.md`` - nothing else."""
|
||||
if not filename or not filename.strip():
|
||||
return None
|
||||
# Skills are a flat directory - reject any path separator (no subfolders).
|
||||
name = filename.strip()
|
||||
if "/" in name or "\\" in name or name in (".", ".."):
|
||||
name = filename.strip().replace("\\", "/")
|
||||
if "/" in name:
|
||||
folder, _, leaf = name.partition("/")
|
||||
if leaf != SKILL_MD:
|
||||
return None
|
||||
base = self._folder_path(folder)
|
||||
return base / SKILL_MD if base is not None else None
|
||||
if name in (".", "..") or name.startswith("."):
|
||||
return None
|
||||
ext = Path(name).suffix.lstrip(".").lower()
|
||||
if ext not in ALLOWED_EXTS:
|
||||
return None
|
||||
return self.skills_dir / name
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
slug = re.sub(r"[^A-Za-z0-9]+", "-", str(text)).strip("-").lower()[:48]
|
||||
return slug or "imported-skill"
|
||||
|
||||
@@ -12,6 +12,8 @@ import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import resolve_untrusted
|
||||
from typing import Optional
|
||||
from .url_guard import HINT as _URL_HINT, is_allowed_backend_url, urlopen as _guarded_urlopen
|
||||
|
||||
@@ -456,7 +458,12 @@ class TaggerService:
|
||||
_ALLOWED_WRITE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
|
||||
|
||||
def write_meta_to_file(self, image_path: str, tags: str) -> dict:
|
||||
path = Path(image_path).resolve()
|
||||
try:
|
||||
# UNC(\\host\share)は resolve() した時点で相手ホストへ資格情報付きで
|
||||
# 接続してしまうため、文字列のまま先に拒否する
|
||||
path = resolve_untrusted(image_path)
|
||||
except ValueError:
|
||||
return {"error": "Access denied"}
|
||||
if path.suffix.lower() not in self._ALLOWED_WRITE_EXTS:
|
||||
return {"error": "Unsupported file type"}
|
||||
if not path.is_file():
|
||||
|
||||
@@ -12,6 +12,8 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import is_safe_rel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
@@ -209,6 +211,10 @@ class VideoService:
|
||||
base_dir = folder_paths.get_directory_by_type(type_)
|
||||
if not base_dir:
|
||||
raise ValueError(f"Invalid type: {type_}")
|
||||
# UNC・ドライブ指定・絶対パス・「..」は、結合・resolve() の前に文字列のまま拒否する
|
||||
# (Windows では UNC を resolve() するだけで相手ホストへ資格情報付きで接続するため)
|
||||
if not is_safe_rel(filename) or (subfolder and not is_safe_rel(subfolder)):
|
||||
raise ValueError("Access denied: path outside allowed directory")
|
||||
base = Path(base_dir).resolve()
|
||||
target = (base / (subfolder or "") / filename).resolve()
|
||||
try:
|
||||
|
||||
@@ -83,10 +83,11 @@ class WildcardService:
|
||||
return None
|
||||
# Normalize to forward slashes and strip leading/trailing slashes
|
||||
filename = filename.replace("\\", "/").strip("/")
|
||||
# Validate each path component — block traversal and empty parts
|
||||
# Validate each path component — block traversal, empty parts and drive-relative
|
||||
# names ("C:name"; on Python <=3.11 joinpath would replace the base path with them)
|
||||
parts = filename.split("/")
|
||||
for p in parts:
|
||||
if not p or p == ".." or p == ".":
|
||||
if not p or p == ".." or p == "." or ":" in p:
|
||||
return None
|
||||
ext = Path(parts[-1]).suffix.lstrip(".").lower()
|
||||
if ext not in ALLOWED_EXTS:
|
||||
|
||||
+1
-1
@@ -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.9.0"
|
||||
version = "0.9.8"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">= 3.10"
|
||||
dependencies = ["jinja2"]
|
||||
|
||||
@@ -1155,3 +1155,84 @@
|
||||
.wfm-sem-danger { margin-left: auto; color: #ef4444; }
|
||||
|
||||
.wfm-sem-row .wfm-select { width: auto; min-width: 90px; }
|
||||
|
||||
/* 3Dモデルビューア */
|
||||
.wfm-gallery-thumb-video-badge.wfm-gallery-thumb-video-badge--corner {
|
||||
top: auto;
|
||||
left: auto;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
transform: none;
|
||||
}
|
||||
.wfm-g3d-box {
|
||||
position: relative;
|
||||
background: #181c26;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wfm-gallery-detail-img.wfm-g3d-box {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.wfm-gallery-lightbox-img.wfm-g3d-box {
|
||||
width: 90vw;
|
||||
height: 85vh;
|
||||
}
|
||||
.wfm-lightbox-compare-img.wfm-g3d-box {
|
||||
width: calc(92vw / var(--compare-cols, 2) - 12px);
|
||||
height: 78vh;
|
||||
}
|
||||
.wfm-g3d-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
}
|
||||
.wfm-g3d-status {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wfm-g3d-status--error {
|
||||
color: #f87171;
|
||||
}
|
||||
.wfm-g3d-stats {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
bottom: 4px;
|
||||
color: #cbd5e1;
|
||||
font-size: 10px;
|
||||
text-shadow: 0 0 3px #000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wfm-g3d-reset {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #e2e8f0;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wfm-g3d-reset:hover {
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
@@ -466,3 +466,103 @@
|
||||
background: var(--wfm-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Draw tool palette */
|
||||
.ie-palette-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 14px;
|
||||
}
|
||||
|
||||
.ie-palette-wells {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ie-palette-well,
|
||||
.ie-palette-swatch {
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--swatch, transparent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ie-palette-well {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
.ie-palette-well.is-active {
|
||||
outline: 2px solid var(--wfm-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ie-palette-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 16px);
|
||||
grid-auto-rows: 16px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ie-palette-swatch {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.7);
|
||||
}
|
||||
|
||||
.ie-palette-swatch.is-empty {
|
||||
border-color: var(--wfm-text-secondary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ie-palette-swatch:hover,
|
||||
.ie-palette-swatch:focus-visible,
|
||||
.ie-palette-well:focus-visible {
|
||||
outline: 1px solid var(--wfm-text);
|
||||
outline-offset: 1px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ie-palette-swatch.is-current {
|
||||
outline: 2px solid var(--wfm-primary);
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
.ie-palette-picker {
|
||||
position: fixed;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ie-palette-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 150px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
background: var(--wfm-surface);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.ie-palette-menu button {
|
||||
padding: 5px 10px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--wfm-text);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ie-palette-menu button:hover {
|
||||
background: var(--wfm-surface-hover);
|
||||
}
|
||||
|
||||
@@ -5990,6 +5990,90 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* TOOLS: result actions, OCR options and text-position overlay (ocr-tools.js) */
|
||||
.wfm-ai-vlm-result-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-ai-ocr-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-ai-ocr-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.wfm-ai-ocr-row .wfm-ai-label { flex-shrink: 0; margin: 0; }
|
||||
.wfm-ai-ocr-row .wfm-select { flex: 1; min-width: 0; }
|
||||
.wfm-ai-ocr-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--wfm-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.wfm-ai-ocr-check.is-disabled { opacity: 0.45; cursor: default; }
|
||||
.wfm-ai-ocr-hint { font-size: 11px; color: var(--wfm-text-secondary); opacity: 0.85; }
|
||||
/* OCR: a taller preview so the numbered boxes are usable */
|
||||
.wfm-ai-vlm-drop.is-ocr { height: 260px; }
|
||||
.wfm-ai-ocr-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wfm-ai-ocr-box {
|
||||
position: absolute;
|
||||
border: 1.5px solid #4fd8ff;
|
||||
background: rgba(79, 216, 255, 0.12);
|
||||
box-sizing: border-box;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wfm-ai-ocr-box.is-active { border-color: #ff9f43; background: rgba(255, 159, 67, 0.2); }
|
||||
.wfm-ai-ocr-box-tag {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
padding: 0 3px;
|
||||
font-size: 9px;
|
||||
line-height: 12px;
|
||||
color: #000;
|
||||
background: #4fd8ff;
|
||||
}
|
||||
|
||||
/* Collapsible Translation / Settings panes */
|
||||
.wfm-ai-pane-collapse-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.wfm-ai-pane-collapse-btn:hover { background: var(--wfm-bg); color: var(--wfm-text); }
|
||||
.wfm-ai-pane.is-collapsed {
|
||||
flex: 0 0 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wfm-ai-pane.is-collapsed > :not(.wfm-ai-pane-header) { display: none !important; }
|
||||
.wfm-ai-pane.is-collapsed .wfm-ai-pane-header {
|
||||
writing-mode: vertical-rl;
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
.wfm-ai-pane.is-collapsed .wfm-ai-pane-collapse-btn { writing-mode: horizontal-tb; }
|
||||
|
||||
/* Chat pane */
|
||||
.wfm-ai-chat-container {
|
||||
display: flex;
|
||||
@@ -6050,6 +6134,118 @@ body {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Markdown-rendered assistant replies (chat-markdown.js) */
|
||||
.wfm-ai-chat-msg.wfm-ai-chat-md { white-space: normal; }
|
||||
.wfm-ai-chat-md p { margin: 0 0 6px; }
|
||||
.wfm-ai-chat-md p:last-child { margin-bottom: 0; }
|
||||
.wfm-ai-chat-md ul, .wfm-ai-chat-md ol { margin: 2px 0 6px; padding-left: 20px; }
|
||||
.wfm-ai-chat-md li { margin: 1px 0; }
|
||||
.wfm-ai-chat-md .wfm-md-h { margin: 8px 0 4px; font-size: 13px; font-weight: 700; }
|
||||
.wfm-ai-chat-md h3.wfm-md-h { font-size: 14px; }
|
||||
.wfm-ai-chat-md code {
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
background: rgba(127, 127, 127, 0.18);
|
||||
padding: 0 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.wfm-ai-chat-md .wfm-md-pre {
|
||||
position: relative;
|
||||
margin: 4px 0 6px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
.wfm-ai-chat-md .wfm-md-pre code { background: none; padding: 0; }
|
||||
.wfm-ai-chat-md .wfm-md-code-lang {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 6px;
|
||||
font-size: 10px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.wfm-ai-chat-md blockquote {
|
||||
margin: 4px 0;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid var(--wfm-border);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.wfm-ai-chat-md hr { border: none; border-top: 1px solid var(--wfm-border); margin: 6px 0; }
|
||||
|
||||
/* Skill auto-check notices (metadata.output) */
|
||||
.wfm-ai-chat-msg-notice {
|
||||
align-self: center;
|
||||
max-width: 95%;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--wfm-border);
|
||||
color: var(--wfm-text-secondary, var(--wfm-text));
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Action buttons under a ```prompt block */
|
||||
.wfm-ai-chat-msg-actions {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Image check result card (chat-image-check.js) */
|
||||
.wfm-ai-chat-msg-check {
|
||||
white-space: normal;
|
||||
min-width: 260px;
|
||||
}
|
||||
.wfm-ai-check-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.wfm-ai-check-head.is-pass { color: #4caf50; }
|
||||
.wfm-ai-check-head.is-fail { color: #e5a00d; }
|
||||
.wfm-ai-check-meta {
|
||||
font-weight: normal;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
.wfm-ai-check-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.wfm-ai-check-item {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: baseline;
|
||||
padding: 1px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.wfm-ai-check-icon { width: 14px; text-align: center; font-weight: 700; }
|
||||
.wfm-ai-check-item.is-pass .wfm-ai-check-icon { color: #4caf50; }
|
||||
.wfm-ai-check-item.is-uncertain .wfm-ai-check-icon { color: #e5a00d; }
|
||||
.wfm-ai-check-item.is-fail .wfm-ai-check-icon { color: #e05252; }
|
||||
.wfm-ai-check-label { flex: 1; }
|
||||
.wfm-ai-check-p { font-size: 11px; opacity: 0.7; font-variant-numeric: tabular-nums; }
|
||||
.wfm-ai-check-quality { margin-top: 4px; font-size: 11px; opacity: 0.8; }
|
||||
|
||||
/* "Built-in" badge in the skill manager list */
|
||||
.wfm-ai-skill-badge {
|
||||
margin-left: 6px;
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
font-size: 10px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.wfm-ai-chat-svg-wrapper {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
@@ -6255,3 +6451,48 @@ body {
|
||||
max-width: none;
|
||||
}
|
||||
.wfm-compat-row .wfm-format-badge:empty { display: none; }
|
||||
|
||||
/* GenerateUI Settings tab: Switches column (true/false inputs) */
|
||||
.wfm-settings-switches {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.wfm-settings-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--wfm-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.wfm-settings-switch:hover {
|
||||
background: var(--wfm-surface);
|
||||
}
|
||||
.wfm-settings-switch-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.wfm-settings-switch-node {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wfm-settings-switch-key {
|
||||
color: var(--wfm-text-secondary);
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wfm-settings-switch-state {
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
color: var(--wfm-text-secondary);
|
||||
}
|
||||
|
||||
+964
-29
File diff suppressed because it is too large
Load Diff
+42
-2
@@ -375,6 +375,7 @@ function applyI18nToHtml() {
|
||||
aiVlmTask.querySelectorAll("option").forEach(opt => {
|
||||
if (opt.value === "describe") opt.textContent = t("aiVlmDescribe");
|
||||
if (opt.value === "prompt") opt.textContent = t("aiVlmPromptCreate");
|
||||
if (opt.value === "ocr") opt.textContent = t("aiVlmOcr");
|
||||
});
|
||||
}
|
||||
const aiVlmRunBtn = document.getElementById("wfm-ai-vlm-run");
|
||||
@@ -383,6 +384,17 @@ function applyI18nToHtml() {
|
||||
if (aiVlmResult) aiVlmResult.placeholder = t("aiVlmResultPlaceholder");
|
||||
const aiVlmCopyBtn = document.getElementById("wfm-ai-vlm-copy");
|
||||
if (aiVlmCopyBtn) aiVlmCopyBtn.textContent = t("aiCopyBtn");
|
||||
const aiVlmToTrans = document.getElementById("wfm-ai-vlm-to-trans");
|
||||
if (aiVlmToTrans) aiVlmToTrans.textContent = t("aiVlmToTranslation");
|
||||
const aiVlmToChat = document.getElementById("wfm-ai-vlm-to-chat");
|
||||
if (aiVlmToChat) aiVlmToChat.textContent = t("aiVlmToChat");
|
||||
const aiOcrModelLabel = document.getElementById("wfm-ai-ocr-model-label");
|
||||
if (aiOcrModelLabel) aiOcrModelLabel.textContent = t("aiOcrModelLabel");
|
||||
const aiOcrBoxesLabel = document.getElementById("wfm-ai-ocr-boxes-label");
|
||||
if (aiOcrBoxesLabel) aiOcrBoxesLabel.textContent = t("aiOcrShowBoxes");
|
||||
const aiOcrRefresh = document.getElementById("wfm-ai-ocr-refresh");
|
||||
if (aiOcrRefresh) aiOcrRefresh.title = t("aiOcrRefresh");
|
||||
document.querySelectorAll(".wfm-ai-pane-collapse-btn").forEach((b) => { b.title = t("aiPaneCollapse"); });
|
||||
const aiBackendTitle = document.getElementById("wfm-ai-settings-backend-title");
|
||||
if (aiBackendTitle) aiBackendTitle.textContent = t("aiSettingsBackend");
|
||||
const aiConnTitle = document.getElementById("wfm-ai-settings-conn-title");
|
||||
@@ -416,6 +428,24 @@ function applyI18nToHtml() {
|
||||
if (aiSaveBtn) aiSaveBtn.textContent = t("aiSettingsSaveBtn");
|
||||
const aiChatImgGenLabel = document.getElementById("wfm-ai-chat-imggen-label-text");
|
||||
if (aiChatImgGenLabel) aiChatImgGenLabel.textContent = t("aiChatImgGenToggle");
|
||||
const aiChatCheckLabelText = document.getElementById("wfm-ai-chat-check-label-text");
|
||||
if (aiChatCheckLabelText) aiChatCheckLabelText.textContent = t("aiChatCheckToggle");
|
||||
const aiChatCheckLabel = document.getElementById("wfm-ai-chat-check-label");
|
||||
if (aiChatCheckLabel) aiChatCheckLabel.title = t("aiChatCheckToggleTip");
|
||||
const aiCheckTitle = document.getElementById("wfm-ai-settings-check-title");
|
||||
if (aiCheckTitle) aiCheckTitle.textContent = t("aiSettingsCheckTitle");
|
||||
const aiJudgeLabel = document.getElementById("wfm-ai-judge-model-label");
|
||||
if (aiJudgeLabel) aiJudgeLabel.textContent = t("aiJudgeModelLabel");
|
||||
const aiJudgeRefresh = document.getElementById("wfm-ai-judge-refresh-btn");
|
||||
if (aiJudgeRefresh) aiJudgeRefresh.textContent = t("aiJudgeRefresh");
|
||||
const aiRoundsLabel = document.getElementById("wfm-ai-check-rounds-label");
|
||||
if (aiRoundsLabel) aiRoundsLabel.textContent = t("aiCheckRoundsLabel");
|
||||
const aiRoundsManual = document.querySelector("#wfm-ai-check-rounds-select option[value='0']");
|
||||
if (aiRoundsManual) aiRoundsManual.textContent = t("aiCheckRoundsManual");
|
||||
const aiSkillImportText = document.getElementById("wfm-ai-skill-import-text");
|
||||
if (aiSkillImportText) aiSkillImportText.textContent = t("aiSkillImportText");
|
||||
const aiSkillImportLabel = document.getElementById("wfm-ai-skill-import-label");
|
||||
if (aiSkillImportLabel) aiSkillImportLabel.title = t("aiSkillImportTip");
|
||||
const aiChatGenTitle = document.getElementById("wfm-ai-settings-chatgen-title");
|
||||
if (aiChatGenTitle) aiChatGenTitle.textContent = t("aiSettingsChatGenTitle");
|
||||
const aiChatGenDedicatedLabel = document.getElementById("wfm-ai-chatgen-dedicated-label");
|
||||
@@ -526,7 +556,7 @@ function applyI18nToHtml() {
|
||||
"wfm-help-lab-plan-5": "helpLabPlan5", "wfm-help-lab-plan-6": "helpLabPlan6",
|
||||
"wfm-help-lab-plan-7": "helpLabPlan7", "wfm-help-lab-plan-8": "helpLabPlan8",
|
||||
"wfm-help-lab-plan-9": "helpLabPlan9", "wfm-help-lab-plan-10": "helpLabPlan10",
|
||||
"wfm-help-lab-plan-11": "helpLabPlan11",
|
||||
"wfm-help-lab-plan-11": "helpLabPlan11", "wfm-help-lab-plan-12": "helpLabPlan12",
|
||||
"wfm-help-lab-card-results-title": "helpLabCardResultsTitle",
|
||||
"wfm-help-lab-results-1": "helpLabResults1", "wfm-help-lab-results-2": "helpLabResults2",
|
||||
"wfm-help-lab-results-3": "helpLabResults3",
|
||||
@@ -588,7 +618,7 @@ function applyI18nToHtml() {
|
||||
"wfm-help-gallery-26": "helpGallery26",
|
||||
"wfm-help-gallery-28": "helpGallery28", "wfm-help-gallery-29": "helpGallery29",
|
||||
"wfm-help-gallery-30": "helpGallery30", "wfm-help-gallery-31": "helpGallery31",
|
||||
"wfm-help-gallery-32": "helpGallery32", "wfm-help-gallery-33": "helpGallery33", "wfm-help-gallery-34": "helpGallery34", "wfm-help-gallery-35": "helpGallery35", "wfm-help-gallery-36": "helpGallery36", "wfm-help-gallery-37": "helpGallery37",
|
||||
"wfm-help-gallery-32": "helpGallery32", "wfm-help-gallery-33": "helpGallery33", "wfm-help-gallery-34": "helpGallery34", "wfm-help-gallery-35": "helpGallery35", "wfm-help-gallery-36": "helpGallery36", "wfm-help-gallery-37": "helpGallery37", "wfm-help-gallery-38": "helpGallery38",
|
||||
"wfm-help-gallery-card-styleprompt-title": "helpGalleryCardStyleprompt",
|
||||
"wfm-help-gallery-card-stylecatalog-title": "helpGalleryCardStylecatalog",
|
||||
"wfm-help-tagger-title": "helpTaggerTitle",
|
||||
@@ -615,6 +645,10 @@ function applyI18nToHtml() {
|
||||
"wfm-help-ai-7": "helpAi7",
|
||||
"wfm-help-ai-8": "helpAi8",
|
||||
"wfm-help-ai-9": "helpAi9",
|
||||
"wfm-help-ai-10": "helpAi10",
|
||||
"wfm-help-ai-11": "helpAi11",
|
||||
"wfm-help-ai-12": "helpAi12",
|
||||
"wfm-help-ai-13": "helpAi13",
|
||||
"wfm-help-ai-card-layout-title": "helpAiCardLayout",
|
||||
"wfm-help-ai-card-translate-title": "helpAiCardTranslate",
|
||||
"wfm-help-ai-card-chat-title": "helpAiCardChat",
|
||||
@@ -623,6 +657,10 @@ function applyI18nToHtml() {
|
||||
"wfm-help-ai-card-skilltips-title": "helpAiCardSkillTips",
|
||||
"wfm-help-ai-card-tools-title": "helpAiCardTools",
|
||||
"wfm-help-ai-card-settings-title": "helpAiCardSettings",
|
||||
"wfm-help-ai-card-skilloptions-title": "helpAiCardSkillOptions",
|
||||
"wfm-help-ai-card-skillsmore-title": "helpAiCardSkillsMore",
|
||||
"wfm-help-ai-card-imagecheck-title": "helpAiCardImageCheck",
|
||||
"wfm-help-ai-card-ocr-title": "helpAiCardOcr",
|
||||
"wfm-help-imageedit-title": "helpImageEditTitle",
|
||||
"wfm-help-imageedit-tools-title": "helpImageEditToolsTitle",
|
||||
"wfm-help-imageedit-layer-title": "helpImageEditLayerTitle",
|
||||
@@ -645,6 +683,8 @@ function applyI18nToHtml() {
|
||||
"wfm-help-imageedit-26": "helpImageEdit26", "wfm-help-imageedit-27": "helpImageEdit27",
|
||||
"wfm-help-imageedit-28": "helpImageEdit28", "wfm-help-imageedit-29": "helpImageEdit29",
|
||||
"wfm-help-imageedit-30": "helpImageEdit30",
|
||||
"wfm-help-imageedit-draw-palette": "helpImageEditDrawPalette", "wfm-help-imageedit-draw-kbd": "helpImageEditDrawKbd",
|
||||
"wfm-help-imageedit-tabkbd-draw": "helpImageEditDrawKbd",
|
||||
"wfm-help-imageedit-31": "helpImageEdit31", "wfm-help-imageedit-32": "helpImageEdit32",
|
||||
"wfm-help-imageedit-33": "helpImageEdit33", "wfm-help-imageedit-34": "helpImageEdit34",
|
||||
"wfm-help-imageedit-7b": "helpImageEdit7b", "wfm-help-imageedit-7c": "helpImageEdit7c",
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* chat-context.js — facts injected into the AI TOOL Chat system prompt by a skill's
|
||||
* `metadata.context` list ("workflow-detail", "logs"; "workflow" lives in ai-tab.js).
|
||||
*
|
||||
* Why: in the 2026-11-11 model check every local chat model answered a simple ComfyUI question
|
||||
* ("SDXL vs Flux") with confident factual errors. Diagnosis skills therefore must not rely on what
|
||||
* the model "knows" — they get verifiable data instead: the exact node graph, which node types
|
||||
* ComfyUI does not know (not installed), which model/option values are not in ComfyUI's own
|
||||
* lists (with the closest existing names), the last execution error from /history, and the tail
|
||||
* of the ComfyUI log. Everything is plain text, size-capped, and built fresh on every message.
|
||||
*/
|
||||
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
|
||||
const MAX_NODES = 60;
|
||||
const MAX_DETAIL_CHARS = 9000;
|
||||
const MAX_LOG_CHARS = 3500;
|
||||
|
||||
function _trunc(v, n = 80) {
|
||||
const s = String(v).replace(/\s+/g, " ");
|
||||
return s.length > n ? `${s.slice(0, n)}…` : s;
|
||||
}
|
||||
|
||||
// object_info input spec → the list of allowed values for a COMBO input, or null.
|
||||
// Two shapes exist: [[...options], {...}] (classic) and ["COMBO", {options: [...]}] (V3 nodes).
|
||||
function _comboOptions(spec) {
|
||||
if (!Array.isArray(spec)) return null;
|
||||
if (Array.isArray(spec[0])) return spec[0];
|
||||
if (spec[0] === "COMBO" && Array.isArray(spec[1]?.options)) return spec[1].options;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Core ComfyUI link-only types. Custom node packs also define UI widgets with their own
|
||||
// upper-case type names (LOAD_3D viewport_state, COLOR, IMAGECOMPARE...), which an API-format
|
||||
// workflow legitimately omits, so only these well-known connection types are checked.
|
||||
const CONNECTION_TYPES = new Set([
|
||||
"MODEL", "CLIP", "VAE", "CONDITIONING", "LATENT", "IMAGE", "MASK", "CONTROL_NET",
|
||||
"CLIP_VISION", "CLIP_VISION_OUTPUT", "STYLE_MODEL", "UPSCALE_MODEL", "GLIGEN", "AUDIO",
|
||||
"VIDEO", "SIGMAS", "NOISE", "GUIDER", "SAMPLER", "LORA_MODEL", "HOOKS", "MESH", "VOXEL",
|
||||
]);
|
||||
|
||||
// A required input that can only be satisfied by a link (MODEL / CLIP / LATENT / IMAGE ...).
|
||||
function _isConnectionType(spec) {
|
||||
if (!Array.isArray(spec) || typeof spec[0] !== "string") return false;
|
||||
return CONNECTION_TYPES.has(spec[0]);
|
||||
}
|
||||
|
||||
function _inputSpec(info, name) {
|
||||
return info?.input?.required?.[name] ?? info?.input?.optional?.[name] ?? null;
|
||||
}
|
||||
|
||||
// Closest existing option names for a value that is not in the list (token overlap on the
|
||||
// file name, ignoring folders and extensions). Good enough to point at "you have v2, not v3".
|
||||
export function closestOptions(value, options, limit = 3) {
|
||||
const tokens = (s) => new Set(String(s).toLowerCase().replace(/\\/g, "/").split("/").pop()
|
||||
.replace(/\.(safetensors|ckpt|pt|pth|bin|gguf|sft)$/i, "")
|
||||
.split(/[^a-z0-9]+/).filter((t) => t.length > 1));
|
||||
const want = tokens(value);
|
||||
if (!want.size) return [];
|
||||
return options
|
||||
.filter((o) => typeof o === "string")
|
||||
.map((o) => {
|
||||
const have = tokens(o);
|
||||
let common = 0;
|
||||
for (const t of want) if (have.has(t)) common++;
|
||||
return { o, score: common / Math.max(want.size, have.size || 1) };
|
||||
})
|
||||
.filter((x) => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map((x) => x.o);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed, checked description of the workflow loaded in GenerateUI (API format).
|
||||
* Pure data → text; `objectInfo` is { classType: info } for the types used.
|
||||
*/
|
||||
export function describeWorkflowDetail(wf, objectInfo) {
|
||||
const entries = Object.entries(wf || {}).filter(([, n]) => n && typeof n === "object" && n.class_type);
|
||||
const lines = ["## Workflow facts (checked by Workflow Studio against this ComfyUI install)"];
|
||||
lines.push(`- ${entries.length} nodes.`);
|
||||
|
||||
const types = [...new Set(entries.map(([, n]) => n.class_type))];
|
||||
const missingTypes = types.filter((ty) => !objectInfo?.[ty]);
|
||||
lines.push(missingTypes.length
|
||||
? `- NOT INSTALLED node types (ComfyUI does not know them): ${missingTypes.join(", ")}`
|
||||
: "- All node types are installed.");
|
||||
|
||||
const problems = [];
|
||||
const nodeLines = [];
|
||||
const ids = new Set(entries.map(([id]) => id));
|
||||
for (const [id, node] of entries.slice(0, MAX_NODES)) {
|
||||
const info = objectInfo?.[node.class_type];
|
||||
const title = node._meta?.title && node._meta.title !== node.class_type ? ` "${_trunc(node._meta.title, 40)}"` : "";
|
||||
const values = [];
|
||||
const links = [];
|
||||
for (const [k, v] of Object.entries(node.inputs || {})) {
|
||||
if (Array.isArray(v) && v.length === 2 && (typeof v[0] === "string" || typeof v[0] === "number")) {
|
||||
const src = String(v[0]);
|
||||
links.push(`${k}<-#${src}`);
|
||||
if (!ids.has(src)) problems.push(`#${id} ${node.class_type}: input "${k}" is linked to node #${src}, which does not exist.`);
|
||||
continue;
|
||||
}
|
||||
if (v === null || typeof v === "object") continue;
|
||||
values.push(`${k}=${_trunc(v, k === "text" || k === "prompt" ? 120 : 70)}`);
|
||||
const options = info ? _comboOptions(_inputSpec(info, k)) : null;
|
||||
if (options && typeof v === "string" && options.length && !options.includes(v)) {
|
||||
const near = closestOptions(v, options);
|
||||
problems.push(`#${id} ${node.class_type}: ${k}="${v}" is NOT in ComfyUI's list for this input${near.length ? ` (closest existing: ${near.map((o) => `"${o}"`).join(", ")})` : " (no similar name found)"}.`);
|
||||
}
|
||||
}
|
||||
if (info) {
|
||||
// Only unconnected required CONNECTION inputs (MODEL, CLIP, LATENT, IMAGE, ...) are
|
||||
// reported: those make the run fail for sure. Missing widget values are usually filled
|
||||
// with defaults or are UI-only, and dynamic inputs (Autogrow "COMFY_*") are stored
|
||||
// under other keys - checking them produced false alarms on ~1/3 of real workflows.
|
||||
for (const [name, spec] of Object.entries(info.input?.required || {})) {
|
||||
if (name in (node.inputs || {}) || !_isConnectionType(spec)) continue;
|
||||
problems.push(`#${id} ${node.class_type}: required ${spec[0]} input "${name}" is not connected.`);
|
||||
}
|
||||
}
|
||||
nodeLines.push(`#${id} ${node.class_type}${title}${missingTypes.includes(node.class_type) ? " [NOT INSTALLED]" : ""}: ${[...values, ...links].join(", ") || "(no inputs)"}`);
|
||||
}
|
||||
if (entries.length > MAX_NODES) nodeLines.push(`… ${entries.length - MAX_NODES} more nodes not listed.`);
|
||||
|
||||
lines.push(problems.length ? `- Detected problems:\n${problems.slice(0, 30).map((p) => ` - ${p}`).join("\n")}` : "- No missing files, options or links detected.");
|
||||
lines.push("- Nodes (#id ClassType: widget values, input<-#source node):");
|
||||
let text = `${lines.join("\n")}\n${nodeLines.map((l) => ` ${l}`).join("\n")}`;
|
||||
if (text.length > MAX_DETAIL_CHARS) text = `${text.slice(0, MAX_DETAIL_CHARS)}\n … (truncated)`;
|
||||
return text;
|
||||
}
|
||||
|
||||
export async function buildWorkflowDetailContext() {
|
||||
const wf = comfyUI.currentWorkflow;
|
||||
if (!wf) return "## Workflow facts\nNo workflow is loaded in GenerateUI. Ask the user to load the workflow they are asking about in the GenerateUI tab.";
|
||||
const types = [...new Set(Object.values(wf).map((n) => n?.class_type).filter(Boolean))];
|
||||
let objectInfo = {};
|
||||
try {
|
||||
objectInfo = await comfyWorkflow.getObjectInfoForTypes(types);
|
||||
} catch (e) {
|
||||
return `## Workflow facts\nCould not read node definitions from ComfyUI (${e.message}), so installed nodes and files could not be checked.`;
|
||||
}
|
||||
return describeWorkflowDetail(wf, objectInfo);
|
||||
}
|
||||
|
||||
// ANSI colour codes from ComfyUI's coloured log lines
|
||||
const ANSI_RE = /\u001b\[[0-9;]*m/g;
|
||||
|
||||
/** Keep the most recent error block of the log (from the last Traceback / error line), else the tail. */
|
||||
export function extractLogTail(logText, maxChars = MAX_LOG_CHARS) {
|
||||
const lines = String(logText || "").replace(ANSI_RE, "").split(/\r?\n/).filter((l) => l.trim());
|
||||
let start = -1;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (/Traceback \(most recent call last\)|!!! Exception|Error:|ERROR|Prompt outputs failed validation/.test(lines[i])) {
|
||||
start = i;
|
||||
// walk back to the beginning of a traceback block
|
||||
while (start > 0 && /^\s|Traceback|File "/.test(lines[start - 1]) && i - start < 40) start--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const picked = start >= 0 ? lines.slice(Math.max(0, start - 3), start + 40) : lines.slice(-25);
|
||||
let text = picked.join("\n");
|
||||
if (text.length > maxChars) text = `…${text.slice(-maxChars)}`;
|
||||
return { text, hasError: start >= 0 };
|
||||
}
|
||||
|
||||
/** Last execution error recorded in ComfyUI's /history (most recent first). */
|
||||
export function lastExecutionError(history) {
|
||||
const runs = Object.values(history || {});
|
||||
for (let i = runs.length - 1; i >= 0; i--) {
|
||||
const msgs = runs[i]?.status?.messages || [];
|
||||
const err = msgs.find((m) => m?.[0] === "execution_error")?.[1];
|
||||
if (err) return err;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function buildLogsContext() {
|
||||
const parts = ["## ComfyUI runtime facts"];
|
||||
try {
|
||||
const res = await fetch(`${comfyUI.baseUrl || ""}/history?max_items=5`);
|
||||
const err = res.ok ? lastExecutionError(await res.json()) : null;
|
||||
if (err) {
|
||||
const tb = Array.isArray(err.traceback) ? err.traceback.join("").split("\n").filter(Boolean).slice(-8).join("\n") : "";
|
||||
parts.push(`- Last execution error (from /history): node #${err.node_id} ${err.node_type}: ${err.exception_type}: ${_trunc(err.exception_message, 600)}${tb ? `\n Traceback tail:\n${tb}` : ""}`);
|
||||
} else {
|
||||
parts.push("- No execution error in the last 5 runs (/history).");
|
||||
}
|
||||
} catch (e) {
|
||||
parts.push(`- Could not read /history: ${e.message}`);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${comfyUI.baseUrl || ""}/internal/logs`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const raw = await res.json();
|
||||
const { text, hasError } = extractLogTail(typeof raw === "string" ? raw : JSON.stringify(raw));
|
||||
parts.push(`- ComfyUI log (${hasError ? "most recent error block" : "last lines, no error found"}):\n${text}`);
|
||||
} catch (e) {
|
||||
parts.push(`- Could not read the ComfyUI log: ${e.message}`);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* chat-image-check.js — AI TOOL Chat: judge generated images (and rendered SVGs) with a
|
||||
* vision-capable decision model, so the chat can fix only what is missing.
|
||||
*
|
||||
* Why a decision model instead of the chat VLM: a generator VLM answers pass/fail without a
|
||||
* probability and was measured to say "yes" to things that are not in the image (2026-11-11:
|
||||
* gemma4-uncensored e4b judged "holds a sword" = true). Ollama decision models with image input
|
||||
* (d3:4b, clef-flash, clef) answered 18/18 checklist questions correctly on the same image with a
|
||||
* clear gap between yes (0.75-1.0) and no (0.0-0.1), and return calibrated `noul` probabilities
|
||||
* that code can threshold. Only one image was measured, so results are shown to the user and
|
||||
* automatic fix rounds are off by default.
|
||||
*
|
||||
* Flow (driven by ai-tab.js):
|
||||
* buildChecklist() chat LLM turns the user's request into 3-7 yes/no visual questions
|
||||
* judgeImage() decision model answers them for the image (+ a quality score)
|
||||
* renderCheckCard() shows ✔ / ? / ✘ per item; failed + uncertain items are what gets fixed
|
||||
*/
|
||||
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./util.js";
|
||||
import { decide, noul, score } from "./decision-client.js";
|
||||
|
||||
const OLLAMA_DEFAULT_URL = "http://localhost:11434";
|
||||
export const DEFAULT_JUDGE_MODEL = "d3:4b";
|
||||
|
||||
// Probability bands for a noul answer (after flipping "expect: no" items). In the 2026-11-11
|
||||
// test runs with d3:4b, correct passes were >= 0.88 while wrong passes caused by attribute binding
|
||||
// ("Is the boy wearing a green hat?" when the girl wore it) landed at 0.70-0.79, so 0.85 is the
|
||||
// pass line and 0.3-0.85 counts as "uncertain" (fixed like a failure). clef answers yes a bit
|
||||
// lower (0.75+), so it reports more "uncertain" items.
|
||||
export const PASS_THRESHOLD = 0.85;
|
||||
export const FAIL_THRESHOLD = 0.3;
|
||||
|
||||
/** Judge connection: the AI TOOL Ollama URL when the chat backend is Ollama, else the default. */
|
||||
export function getJudgeConfig(aiSettings = {}) {
|
||||
const baseUrl = aiSettings.backend === "ollama" && aiSettings.backendUrl
|
||||
? aiSettings.backendUrl
|
||||
: OLLAMA_DEFAULT_URL;
|
||||
return {
|
||||
baseUrl: baseUrl.replace(/\/+$/, ""),
|
||||
model: aiSettings.chatJudgeModel || DEFAULT_JUDGE_MODEL,
|
||||
autoRounds: Math.max(0, Math.min(3, parseInt(aiSettings.chatCheckAutoRounds, 10) || 0)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ollama decision models (capabilities include "decision"), vision-capable ones first.
|
||||
* Returns [{ name, vision }]. Throws if Ollama can't be reached.
|
||||
*/
|
||||
export async function listJudgeModels(baseUrl) {
|
||||
const url = (baseUrl || OLLAMA_DEFAULT_URL).replace(/\/+$/, "");
|
||||
const res = await fetch(`${url}/api/tags`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const names = (data.models || [])
|
||||
.filter((m) => Array.isArray(m.capabilities) && m.capabilities.includes("decision"))
|
||||
.map((m) => m.name);
|
||||
const out = await Promise.all(names.map(async (name) => {
|
||||
try {
|
||||
const r = await fetch(`${url}/api/show`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: name }),
|
||||
});
|
||||
const d = r.ok ? await r.json() : {};
|
||||
const vision = (Array.isArray(d.capabilities) && d.capabilities.includes("vision"))
|
||||
|| !!(d.projector_info && Object.keys(d.projector_info).length);
|
||||
return { name, vision };
|
||||
} catch {
|
||||
return { name, vision: false };
|
||||
}
|
||||
}));
|
||||
return out.sort((a, b) => (b.vision - a.vision) || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Checklist
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CHECKLIST_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
question: { type: "string" },
|
||||
label: { type: "string" },
|
||||
expect: { type: "string", enum: ["yes", "no"] },
|
||||
},
|
||||
required: ["question", "label", "expect"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["items"],
|
||||
};
|
||||
|
||||
const CHECKLIST_SYSTEM = `You turn an image request into a short visual checklist for an image-judging model.
|
||||
Return JSON only: {"items":[{"question":"...","label":"...","expect":"yes"}]}
|
||||
Rules:
|
||||
- 3 to 8 items, one concrete requirement per item.
|
||||
- "question": an English yes/no question that can be answered by looking at the image alone. Always name who or what it is about (e.g. "Is the girl wearing a white dress?", never "Is someone wearing a white dress?"). For positions, name both sides (e.g. "Is the white rabbit between the boy and the girl?").
|
||||
- "expect": "yes" when YES means the requirement is met.
|
||||
- The judging model often mixes up which person has which attribute. So when the request has two or more people or animals with different attributes, add up to 2 swap-check items with "expect": "no" that ask whether the attribute is on the WRONG one (e.g. the boy should wear the green hat -> "Is the girl wearing a green hat?" with "expect": "no").
|
||||
- "label": the requirement as a very short phrase in the language of the user's request, including who it is about (e.g. "男の子の緑の帽子", "帽子は女の子ではない").
|
||||
- Only check visible things the user actually asked for: subject and count, key attributes and colors, clothing, pose or action, setting, time of day or lighting, text that must appear.
|
||||
- Never check image quality, art style quality, or details the user did not ask for.`;
|
||||
|
||||
/** Pull the first JSON object out of a model reply (code fences / prose tolerated). */
|
||||
export function parseChecklist(text) {
|
||||
if (!text) return [];
|
||||
let data = null;
|
||||
const start = text.indexOf("{");
|
||||
const end = text.lastIndexOf("}");
|
||||
if (start >= 0 && end > start) {
|
||||
try { data = JSON.parse(text.slice(start, end + 1)); } catch { data = null; }
|
||||
}
|
||||
const items = Array.isArray(data?.items) ? data.items : [];
|
||||
return items
|
||||
.map((it) => (typeof it === "string" ? { question: it, label: it } : it))
|
||||
.filter((it) => it && typeof it.question === "string" && it.question.trim())
|
||||
.map((it) => ({
|
||||
question: it.question.trim().slice(0, 300),
|
||||
label: String(it.label || it.question).trim().slice(0, 80),
|
||||
expect: String(it.expect || "yes").toLowerCase() === "no" ? "no" : "yes",
|
||||
}))
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the chat LLM for the checklist. `chat(messages, extraSettings)` must return the reply text.
|
||||
* Falls back to one whole-prompt question if the model returns nothing usable.
|
||||
*/
|
||||
export async function buildChecklist(chat, request, prompt) {
|
||||
const user = `User request: ${request || "(none)"}\nPrompt used for generation: ${prompt || "(none)"}`;
|
||||
let items = [];
|
||||
try {
|
||||
const text = await chat(
|
||||
[{ role: "system", content: CHECKLIST_SYSTEM }, { role: "user", content: user }],
|
||||
{ format: CHECKLIST_SCHEMA, temperature: 0.2, thinkingMode: false },
|
||||
);
|
||||
items = parseChecklist(text);
|
||||
} catch { /* fall through to the fallback below */ }
|
||||
if (items.length === 0) {
|
||||
const desc = (request || prompt || "").replace(/\s+/g, " ").slice(0, 300);
|
||||
items = [{ question: `Does the image match this description: "${desc}"?`, label: desc.slice(0, 40), expect: "yes" }];
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Judging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Image URL / data URL → JPEG base64 (no prefix), downscaled so the longest side is <= maxSide. */
|
||||
export async function imageUrlToJpegBase64(src, maxSide = 768) {
|
||||
const img = await new Promise((resolve, reject) => {
|
||||
const el = new Image();
|
||||
el.onload = () => resolve(el);
|
||||
el.onerror = () => reject(new Error("image load failed"));
|
||||
el.src = src;
|
||||
});
|
||||
const scale = Math.min(1, maxSide / Math.max(img.naturalWidth || 1, img.naturalHeight || 1));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.round((img.naturalWidth || maxSide) * scale));
|
||||
canvas.height = Math.max(1, Math.round((img.naturalHeight || maxSide) * scale));
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = "#ffffff"; // transparent SVG/PNG areas → white, like a viewer would show
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL("image/jpeg", 0.9).split(",")[1] || "";
|
||||
}
|
||||
|
||||
/** SVG markup → JPEG base64 rendered at `size` px (white background, currentColor = black). */
|
||||
export async function svgToJpegBase64(svgCode, size = 512) {
|
||||
let markup = svgCode;
|
||||
// Render at a fixed size even when the SVG has no width/height (viewBox only).
|
||||
if (!/<svg[^>]*\swidth=/i.test(markup)) markup = markup.replace(/<svg\b/i, `<svg width="${size}" height="${size}"`);
|
||||
const dataUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(markup)))}`;
|
||||
return imageUrlToJpegBase64(dataUrl, size);
|
||||
}
|
||||
|
||||
const QUALITY_LEVELS = ["poor", "acceptable", "excellent"];
|
||||
|
||||
/**
|
||||
* Judge one image against the checklist. The first request after ComfyUI generated an image can
|
||||
* fail while VRAM is still held, so it retries once after 4 s.
|
||||
* Returns { items: [{ question, label, p, status: "pass"|"uncertain"|"fail" }], quality, ms }.
|
||||
*/
|
||||
export async function judgeImage(imageBase64, items, cfg, { withQuality = true } = {}) {
|
||||
const questions = {};
|
||||
items.forEach((it, i) => { questions[`q${i + 1}`] = noul(it.question); });
|
||||
if (withQuality) questions.quality = score("Overall visual quality of the image", QUALITY_LEVELS);
|
||||
const opts = { backend: "ollama", baseUrl: cfg.baseUrl, model: cfg.model };
|
||||
const started = performance.now();
|
||||
let answers;
|
||||
try {
|
||||
answers = await decide("A generated image to be checked against the user's requirements.", questions, opts, [imageBase64]);
|
||||
} catch (e) {
|
||||
await new Promise((r) => setTimeout(r, 4000));
|
||||
answers = await decide("A generated image to be checked against the user's requirements.", questions, opts, [imageBase64]);
|
||||
}
|
||||
const results = items.map((it, i) => {
|
||||
const p = answers[`q${i + 1}`]?.noul;
|
||||
const raw = typeof p === "number" ? p : 0;
|
||||
// "expect: no" items (swap checks) are met when the answer is NO
|
||||
const prob = it.expect === "no" ? 1 - raw : raw;
|
||||
const status = prob >= PASS_THRESHOLD ? "pass" : prob < FAIL_THRESHOLD ? "fail" : "uncertain";
|
||||
return { ...it, p: prob, status };
|
||||
});
|
||||
let quality = null;
|
||||
if (withQuality && answers.quality) {
|
||||
const probs = answers.quality.probabilities || {};
|
||||
let best = null, bestP = -1;
|
||||
for (const [k, v] of Object.entries(probs)) if (v > bestP) { best = Number(k); bestP = v; }
|
||||
quality = { level: best, label: QUALITY_LEVELS[best] ?? null, value: answers.quality.score };
|
||||
}
|
||||
return { items: results, quality, ms: Math.round(performance.now() - started) };
|
||||
}
|
||||
|
||||
export function failedItems(result) {
|
||||
return (result?.items || []).filter((it) => it.status !== "pass");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG part list ("Parts: body, head (circle), beak" line written by the SVG skill)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function parseSvgParts(text) {
|
||||
const m = (text || "").match(/^\s*\**parts?\**\s*:\s*(.+)$/im);
|
||||
if (!m) return [];
|
||||
// Split on commas outside parentheses, then drop "(how it is drawn)" notes.
|
||||
const parts = [];
|
||||
let depth = 0, cur = "";
|
||||
for (const ch of m[1]) {
|
||||
if (ch === "(") depth++;
|
||||
if (ch === ")") depth = Math.max(0, depth - 1);
|
||||
if ((ch === "," || ch === ";") && depth === 0) { parts.push(cur); cur = ""; } else cur += ch;
|
||||
}
|
||||
parts.push(cur);
|
||||
return parts
|
||||
.map((p) => p.replace(/\([^)]*\)/g, "").replace(/[.*_`]/g, "").replace(/\s+/g, " ").trim())
|
||||
.filter((p) => p && p.length <= 40)
|
||||
.slice(0, 7);
|
||||
}
|
||||
|
||||
/** Checklist for a rendered SVG: one question per part plus "is it recognisable as <request>". */
|
||||
export function svgChecklist(parts, request) {
|
||||
const items = parts.map((p) => ({ question: `Does the drawing clearly show a ${p}?`, label: p, expect: "yes" }));
|
||||
const subject = (request || "").replace(/\s+/g, " ").trim().slice(0, 200);
|
||||
if (subject) items.unshift({ question: `Is this drawing recognizable as: "${subject}"?`, label: subject.slice(0, 40), expect: "yes" });
|
||||
return items.slice(0, 8);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATUS_ICON = { pass: "✔", uncertain: "?", fail: "✘" };
|
||||
|
||||
/**
|
||||
* Render the judgement as a chat card. `actions` = [{ label, primary, onClick(btn) }].
|
||||
* Returns the card element.
|
||||
*/
|
||||
export function renderCheckCard(result, { model, round = 0, actions = [] } = {}) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "wfm-ai-check-card";
|
||||
const failed = failedItems(result);
|
||||
const head = failed.length === 0 ? t("aiChatCheckPassed") : t("aiChatCheckFailedCount", failed.length);
|
||||
const roundText = round > 0 ? ` · ${t("aiChatCheckRound", round)}` : "";
|
||||
card.innerHTML = `
|
||||
<div class="wfm-ai-check-head ${failed.length ? "is-fail" : "is-pass"}">
|
||||
<span>${escapeHtml(head)}</span>
|
||||
<span class="wfm-ai-check-meta">${escapeHtml(t("aiChatCheckBy", model))}${escapeHtml(roundText)} · ${(result.ms / 1000).toFixed(1)}s</span>
|
||||
</div>
|
||||
<ul class="wfm-ai-check-list">
|
||||
${result.items.map((it) => `
|
||||
<li class="wfm-ai-check-item is-${it.status}" title="${escapeHtml(it.question)}">
|
||||
<span class="wfm-ai-check-icon">${STATUS_ICON[it.status]}</span>
|
||||
<span class="wfm-ai-check-label">${escapeHtml(it.label)}</span>
|
||||
<span class="wfm-ai-check-p">${Math.round(it.p * 100)}%</span>
|
||||
</li>`).join("")}
|
||||
</ul>
|
||||
${result.quality?.label ? `<div class="wfm-ai-check-quality">${escapeHtml(t("aiChatCheckQuality", t(`aiChatCheckQuality_${result.quality.label}`)))}</div>` : ""}
|
||||
`;
|
||||
if (actions.length) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "wfm-ai-chat-msg-actions";
|
||||
for (const a of actions) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = `wfm-btn wfm-btn-sm${a.primary ? " wfm-btn-primary" : ""}`;
|
||||
btn.textContent = a.label;
|
||||
btn.addEventListener("click", () => a.onClick(btn));
|
||||
row.appendChild(btn);
|
||||
}
|
||||
card.appendChild(row);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* chat-markdown.js — tiny, dependency-free Markdown renderer for AI TOOL Chat replies.
|
||||
*
|
||||
* Safety model: the whole input is HTML-escaped FIRST, then a small set of Markdown constructs is
|
||||
* turned into tags. No raw HTML from the model ever reaches the DOM, links are not rendered as
|
||||
* anchors (the model's URLs stay plain text), and images are never loaded. This keeps the
|
||||
* existing guarantees of the chat (it showed replies with textContent before) while making long
|
||||
* structured answers (lists, headings, code blocks) readable.
|
||||
*
|
||||
* Supported: fenced code blocks (```lang), inline `code`, **bold**, *italic*, # headings (h1-h4
|
||||
* rendered as h4-h6-ish sizes), - / * / 1. lists (one level), > quotes, --- rules, paragraphs and
|
||||
* line breaks. Anything else is left as escaped text.
|
||||
*/
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Inline formatting on already-escaped text. Code spans are cut out first so their content is
|
||||
// not touched by the bold/italic rules.
|
||||
function renderInline(escaped) {
|
||||
const codes = [];
|
||||
let s = escaped.replace(/`([^`\n]+)`/g, (_, c) => {
|
||||
codes.push(c);
|
||||
return `\u0000${codes.length - 1}\u0000`;
|
||||
});
|
||||
s = s.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
|
||||
s = s.replace(/__([^_\n]+)__/g, "<strong>$1</strong>");
|
||||
s = s.replace(/(^|[^*\w])\*(?=\S)([^*\n]*?\S)\*(?!\w)/g, "$1<em>$2</em>");
|
||||
s = s.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code>${codes[Number(i)]}</code>`);
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Markdown text → safe HTML string. */
|
||||
export function renderMarkdown(text) {
|
||||
const lines = String(text || "").replace(/\r\n?/g, "\n").split("\n");
|
||||
const out = [];
|
||||
let para = [];
|
||||
let list = null; // { tag: "ul"|"ol", items: [], start }
|
||||
|
||||
const flushPara = () => {
|
||||
if (para.length) {
|
||||
out.push(`<p>${para.map((l) => renderInline(escapeHtml(l))).join("<br>")}</p>`);
|
||||
para = [];
|
||||
}
|
||||
};
|
||||
const flushList = () => {
|
||||
if (list) {
|
||||
const start = list.tag === "ol" && list.start > 1 ? ` start="${list.start}"` : "";
|
||||
out.push(`<${list.tag}${start}>${list.items.map((i) => `<li>${renderInline(escapeHtml(i))}</li>`).join("")}</${list.tag}>`);
|
||||
list = null;
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
const fence = line.match(/^\s*```\s*([\w+-]*)\s*$/);
|
||||
if (fence) {
|
||||
flushPara(); flushList();
|
||||
const lang = fence[1] || "";
|
||||
const body = [];
|
||||
i++;
|
||||
while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { body.push(lines[i]); i++; }
|
||||
const label = lang ? `<span class="wfm-md-code-lang">${escapeHtml(lang)}</span>` : "";
|
||||
out.push(`<pre class="wfm-md-pre">${label}<code>${escapeHtml(body.join("\n"))}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.trim()) { flushPara(); flushList(); continue; }
|
||||
|
||||
const heading = line.match(/^\s*(#{1,4})\s+(.+)$/);
|
||||
if (heading) {
|
||||
flushPara(); flushList();
|
||||
const level = Math.min(6, heading[1].length + 2); // # -> h3 ... #### -> h6
|
||||
out.push(`<h${level} class="wfm-md-h">${renderInline(escapeHtml(heading[2]))}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { flushPara(); flushList(); out.push("<hr>"); continue; }
|
||||
|
||||
if (line.trim().startsWith(">")) {
|
||||
flushPara(); flushList();
|
||||
out.push(`<blockquote>${renderInline(escapeHtml(line.trim().replace(/^>\s?/, "")))}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ul = line.match(/^\s*[-*+]\s+(.+)$/);
|
||||
const ol = line.match(/^\s*(\d+)[.)]\s+(.+)$/);
|
||||
if (ul || ol) {
|
||||
flushPara();
|
||||
const tag = ul ? "ul" : "ol";
|
||||
if (list && list.tag !== tag) flushList();
|
||||
// A numbered list interrupted by bullets keeps counting from the source number
|
||||
if (!list) list = { tag, items: [], start: ol ? Math.min(Number(ol[1]) || 1, 9999) : 1 };
|
||||
list.items.push(ul ? ul[1] : ol[2]);
|
||||
continue;
|
||||
}
|
||||
|
||||
flushList();
|
||||
para.push(line);
|
||||
}
|
||||
flushPara(); flushList();
|
||||
return out.join("");
|
||||
}
|
||||
+106
-1
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
import { syncJsonHighlight } from "./json-highlight.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml, setupSearchClearBtn } from "./util.js";
|
||||
@@ -315,6 +316,104 @@ function _syncStackToggleAll(stackModels) {
|
||||
cb.indeterminate = !allOn && !allOff;
|
||||
}
|
||||
|
||||
// Two-option combos that are really an on/off switch (KSamplerAdvanced add_noise, DWPreprocessor
|
||||
// detect_*, ...): [on value, off value], compared case-insensitively.
|
||||
const _TOGGLE_COMBO_PAIRS = [["enable", "disable"], ["enabled", "disabled"], ["on", "off"], ["true", "false"], ["yes", "no"]];
|
||||
|
||||
function _comboOptions(spec) {
|
||||
if (Array.isArray(spec?.[0])) return spec[0];
|
||||
if (spec?.[0] === "COMBO" && Array.isArray(spec[1]?.options)) return spec[1].options;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** [onValue, offValue] in the node's own spelling when the combo is an on/off pair, else null */
|
||||
function _toggleComboValues(spec) {
|
||||
const opts = _comboOptions(spec);
|
||||
if (!opts || opts.length !== 2 || !opts.every((o) => typeof o === "string")) return null;
|
||||
const lower = opts.map((o) => o.toLowerCase());
|
||||
for (const [on, off] of _TOGGLE_COMBO_PAIRS) {
|
||||
const iOn = lower.indexOf(on), iOff = lower.indexOf(off);
|
||||
if (iOn >= 0 && iOff >= 0) return [opts[iOn], opts[iOff]];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _compareNodeIds(a, b) {
|
||||
const x = String(a).split(":").map(Number), y = String(b).split(":").map(Number);
|
||||
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
||||
const d = (x[i] ?? -1) - (y[i] ?? -1);
|
||||
if (d) return d;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* On/off items of the API workflow for the Settings tab's Switches column: every literal
|
||||
* true/false input (ComfySwitchNode switch, PrimitiveBoolean value, TextGenerate thinking, ...)
|
||||
* plus string combos whose only two options are an on/off pair (needs object_info). Linked
|
||||
* inputs are arrays and never match; their source node shows up instead.
|
||||
*/
|
||||
function _collectSwitchInputs(workflow, objectInfo = {}) {
|
||||
const out = [];
|
||||
for (const [id, node] of Object.entries(workflow || {})) {
|
||||
if (!node?.class_type || !node.inputs) continue;
|
||||
const info = objectInfo[node.class_type]?.input || {};
|
||||
for (const [key, value] of Object.entries(node.inputs)) {
|
||||
const base = { id, type: node.class_type, title: node._meta?.title || node.class_type, key };
|
||||
if (typeof value === "boolean") {
|
||||
out.push({ ...base, on: value, onValue: true, offValue: false });
|
||||
} else if (typeof value === "string") {
|
||||
const pair = _toggleComboValues(info.required?.[key] || info.optional?.[key]);
|
||||
if (pair && (value === pair[0] || value === pair[1])) {
|
||||
out.push({ ...base, on: value === pair[0], onValue: pair[0], offValue: pair[1] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => _compareNodeIds(a.id, b.id));
|
||||
}
|
||||
|
||||
let _switchRenderSeq = 0;
|
||||
|
||||
/** Fill the Switches column. Booleans show at once; on/off combos follow once object_info is in. */
|
||||
async function _renderSwitchColumn(col) {
|
||||
if (!col) return;
|
||||
const seq = ++_switchRenderSeq;
|
||||
const workflow = comfyUI.currentWorkflow;
|
||||
const draw = (items) => {
|
||||
col.innerHTML = items.length ? `<div class="wfm-settings-switches">${items.map((s, i) => `
|
||||
<label class="wfm-settings-switch" title="${escapeHtml(`#${s.id} ${s.type} › ${s.key}`)}">
|
||||
<input type="checkbox" class="wfm-settings-switch-cb" data-index="${i}" ${s.on ? "checked" : ""}>
|
||||
<span class="wfm-settings-switch-text">
|
||||
<span class="wfm-settings-switch-node">${escapeHtml(s.title)}</span>
|
||||
<span class="wfm-settings-switch-key">${escapeHtml(s.key)}</span>
|
||||
</span>
|
||||
<span class="wfm-settings-switch-state">${escapeHtml(String(s.on ? s.onValue : s.offValue))}</span>
|
||||
</label>`).join("")}
|
||||
</div>` : `<p class="wfm-placeholder">${t("genSwitchesNone")}</p>`;
|
||||
// Applies immediately (no Apply button): written straight into the API workflow Generate sends
|
||||
col.querySelectorAll(".wfm-settings-switch-cb").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const s = items[Number(cb.dataset.index)];
|
||||
const node = comfyUI.currentWorkflow?.[s.id];
|
||||
if (!node?.inputs || !(s.key in node.inputs) || Array.isArray(node.inputs[s.key])) return;
|
||||
s.on = cb.checked;
|
||||
node.inputs[s.key] = s.on ? s.onValue : s.offValue;
|
||||
const state = cb.closest(".wfm-settings-switch")?.querySelector(".wfm-settings-switch-state");
|
||||
if (state) state.textContent = String(node.inputs[s.key]);
|
||||
_syncRawJson();
|
||||
});
|
||||
});
|
||||
};
|
||||
draw(_collectSwitchInputs(workflow));
|
||||
const types = [...new Set(Object.values(workflow || {}).map((n) => n?.class_type).filter(Boolean))];
|
||||
let objectInfo = {};
|
||||
try { objectInfo = await comfyWorkflow.getObjectInfoForTypes(types); } catch {}
|
||||
// A newer render (another workflow loaded meanwhile) owns the column now
|
||||
if (seq !== _switchRenderSeq || workflow !== comfyUI.currentWorkflow || !col.isConnected) return;
|
||||
draw(_collectSwitchInputs(workflow, objectInfo));
|
||||
}
|
||||
|
||||
/** Sync comfyUI.currentWorkflow to Raw JSON textarea + highlight */
|
||||
function _syncRawJson() {
|
||||
const rawTextarea = document.getElementById("wfm-gen-raw-json");
|
||||
@@ -1514,7 +1613,7 @@ export const comfyEditor = {
|
||||
<button class="wfm-btn wfm-btn-sm" id="wfm-settings-sampler-apply" title="Apply (Alt+Click: Apply & Generate)">Apply</button>
|
||||
` : "<p class='wfm-placeholder'>No KSampler node found</p>"}
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;padding-left:14px;">
|
||||
<div style="flex:1;min-width:0;padding:0 14px;border-right:1px solid var(--wfm-border);">
|
||||
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">Latent Image</h3>
|
||||
${latent ? `
|
||||
<input type="hidden" id="wfm-settings-latent-id" value="${latent.id}">
|
||||
@@ -1559,9 +1658,15 @@ export const comfyEditor = {
|
||||
</div>
|
||||
` : "<p class='wfm-placeholder'>No EmptyLatentImage node found</p>"}
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;padding-left:14px;">
|
||||
<h3 style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--wfm-text-secondary);margin:0 0 12px;">${t("genSwitchesTitle")}</h3>
|
||||
<div id="wfm-settings-switches-col"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
_renderSwitchColumn(document.getElementById("wfm-settings-switches-col"));
|
||||
|
||||
document.getElementById("wfm-settings-sampler-apply")?.addEventListener("click", (e) => {
|
||||
const ds = document.getElementById("wfm-settings-sampler-id")?.dataset || {};
|
||||
const write = (nodeId, key, value) => {
|
||||
|
||||
@@ -125,6 +125,12 @@ function _collectNodeTypes(workflow) {
|
||||
// frontend widgets that occupy a widgets_values slot; sent to the API as {"__value__": [...]}.
|
||||
const _WIDGET_TYPE_SET = new Set(["INT", "FLOAT", "STRING", "BOOLEAN", "COMBO", "COMFY_DYNAMICCOMBO_V3", "BOUNDING_BOXES"]);
|
||||
|
||||
// Required inputs backed by display-only frontend widgets (serialize=false, so no widgets_values
|
||||
// slot) -> the value the frontend sends for them in the API prompt.
|
||||
const _DISPLAY_WIDGET_DEFAULTS = {
|
||||
IMAGECOMPARE: () => ({ __value__: ["", ""] }),
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a possibly-compound object_info type spec (e.g. LTXVEmptyLatentAudio's frame_rate:
|
||||
* "FLOAT,INT", a MultiType declaration meaning "widget defaults to FLOAT but also accepts an INT
|
||||
@@ -237,6 +243,13 @@ function _getDynamicComboSubNames(objectInfo, classType, widgetName, selectedKey
|
||||
];
|
||||
}
|
||||
|
||||
function _isDynamicComboKey(objectInfo, classType, widgetName, value) {
|
||||
const info = objectInfo[classType];
|
||||
const spec = info?.input?.required?.[widgetName] || info?.input?.optional?.[widgetName];
|
||||
const options = spec?.[1]?.options;
|
||||
return !Array.isArray(options) || options.some((o) => o.key === value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a widget value is a frontend-only extra (like "control_after_generate").
|
||||
* These values appear in widgets_values but are not in object_info inputs.
|
||||
@@ -371,9 +384,15 @@ function _normalizeLinkedWidgetValues(objectInfo, nodeType, widgetsValues, isLin
|
||||
const comboExpander = (name, selectedKey) => _getDynamicComboSubNames(objectInfo, nodeType, name, selectedKey);
|
||||
|
||||
// If skipping linked widgets already accounts for every entry, widgets_values is already in
|
||||
// the modern (no dangling linked entries) format — nothing to strip.
|
||||
// the modern (no dangling linked entries) format — nothing to strip. The count alone can match
|
||||
// by accident: TextGenerateV2 ["", 1536, "off", true, true, "auto"] with prompt linked reads
|
||||
// 1536 as sampling_mode, which falls back to the first option and swallows the rest. A selector
|
||||
// that doesn't hold one of its option keys means this reading is misaligned.
|
||||
const skipSim = _simulateWidgetValues(widgetNames, widgetTypes, widgetsValues, isLinkedName, true, comboExpander);
|
||||
if (skipSim.consumed === widgetsValues.length) return widgetsValues;
|
||||
const selectorsValid = widgetNames.every((name, i) => widgetTypes[i] !== "COMFY_DYNAMICCOMBO_V3"
|
||||
|| skipSim.positions[name] < 0
|
||||
|| _isDynamicComboKey(objectInfo, nodeType, name, widgetsValues[skipSim.positions[name]]));
|
||||
if (skipSim.consumed === widgetsValues.length && selectorsValid) return widgetsValues;
|
||||
|
||||
// Otherwise it's "legacy full": recompute with every widget present to find exactly which
|
||||
// indices the linked ones occupy — including any control_after_generate extra a linked
|
||||
@@ -985,6 +1004,15 @@ export const comfyWorkflow = {
|
||||
}
|
||||
}
|
||||
|
||||
// Display-only frontend widgets (ImageCompare's socketless compare_view) are never
|
||||
// stored in widgets_values, yet the backend requires them. The frontend sends the
|
||||
// widget's initial value, so do the same.
|
||||
for (const [k, spec] of Object.entries(objectInfo[node.type]?.input?.required || {})) {
|
||||
if (!(k in inputs) && _DISPLAY_WIDGET_DEFAULTS[spec?.[0]]) {
|
||||
inputs[k] = _DISPLAY_WIDGET_DEFAULTS[spec[0]]();
|
||||
}
|
||||
}
|
||||
|
||||
api[nodeId] = {
|
||||
class_type: node.type,
|
||||
inputs,
|
||||
@@ -1271,8 +1299,10 @@ export const comfyWorkflow = {
|
||||
// "prompt" (prompt-enhancer switch chain, Qwen Image 2.1 Turbo) is the positive text,
|
||||
// a linked "negative_prompt" the negative.
|
||||
// TextEncodeFibo2 (Fibo 2.0 Edit) has the same shape, its prompt arriving via a
|
||||
// PreviewAny -> prompt-enhancer switch chain.
|
||||
if (ct === "TextEncodeQwenImage21" || ct === "TextEncodeFibo2") {
|
||||
// PreviewAny -> prompt-enhancer switch chain. PH3ImageConditioning (p-h3-image) and
|
||||
// MiniMaxH3ImageConditioning (MiniMax-H3-Image) too, without a negative prompt.
|
||||
if (ct === "TextEncodeQwenImage21" || ct === "TextEncodeFibo2" || ct === "PH3ImageConditioning"
|
||||
|| ct === "MiniMaxH3ImageConditioning") {
|
||||
if (Array.isArray(inputs.prompt)) samplerPositiveRef[inputs.prompt[0]] = true;
|
||||
if (Array.isArray(inputs.negative_prompt)) samplerNegativeRef[inputs.negative_prompt[0]] = true;
|
||||
}
|
||||
@@ -1519,7 +1549,10 @@ export const comfyWorkflow = {
|
||||
// nodes already picked up generically by the load_image_nodes pass below.
|
||||
// TextEncodeFibo2 (Fibo 2.0) — same prompt/negative_prompt/images.image_N shape; its
|
||||
// negative_prompt is a structured VGL JSON caption (edited as plain text here).
|
||||
if (ct === "TextEncodeQwenImage21" || ct === "TextEncodeFibo2") {
|
||||
// PH3ImageConditioning (p-h3-image) and MiniMaxH3ImageConditioning (MiniMax-H3-Image) have
|
||||
// prompt and images.image_N, no negative_prompt.
|
||||
if (ct === "TextEncodeQwenImage21" || ct === "TextEncodeFibo2" || ct === "PH3ImageConditioning"
|
||||
|| ct === "MiniMaxH3ImageConditioning") {
|
||||
if (typeof inputs.prompt === "string") {
|
||||
result.prompt_nodes.push({
|
||||
id, type: ct, title: `${title} [positive]`, role: "positive",
|
||||
@@ -1701,8 +1734,11 @@ export const comfyWorkflow = {
|
||||
// width/height are commonly wired to a ResolutionSelector-style helper node
|
||||
// (array link ref) rather than a direct number — leave those as undefined so
|
||||
// the settings-tab UI falls back to its default instead of rendering a link ref.
|
||||
// TextEncodeFibo2 likewise (0 = Fibo 2.0's trained size for the first image's aspect).
|
||||
if (ct === "TextEncodeMageFlowEdit" || ct === "TextEncodeFibo2") {
|
||||
// TextEncodeFibo2 likewise (0 = Fibo 2.0's trained size for the first image's aspect),
|
||||
// PH3ImageConditioning (0 = image_1's size, or 2912 without images) and
|
||||
// MiniMaxH3ImageConditioning (0 = image_1's size, or 2048 without images).
|
||||
if (ct === "TextEncodeMageFlowEdit" || ct === "TextEncodeFibo2" || ct === "PH3ImageConditioning"
|
||||
|| ct === "MiniMaxH3ImageConditioning") {
|
||||
result.latent_nodes.push({
|
||||
id, type: ct, title,
|
||||
width: typeof inputs.width === "number" ? inputs.width : undefined,
|
||||
@@ -1927,7 +1963,8 @@ function _unifyPromptEnhancerText(api) {
|
||||
const tgRef = sw.inputs.on_true;
|
||||
if (!Array.isArray(tgRef)) continue;
|
||||
const tg = api[String(tgRef[0])];
|
||||
if (tg?.class_type !== "TextGenerate" || tg.inputs.prompt !== sw.inputs.on_false) continue;
|
||||
if ((tg?.class_type !== "TextGenerate" && tg?.class_type !== "TextGenerateV2")
|
||||
|| tg.inputs.prompt !== sw.inputs.on_false) continue;
|
||||
const primId = `${swId}:wfs_prompt`;
|
||||
api[primId] = {
|
||||
class_type: "PrimitiveStringMultiline",
|
||||
@@ -1976,6 +2013,8 @@ function _getWidgetMapping(nodeType) {
|
||||
WFS_PromptText: ["positive", "negative"],
|
||||
TextEncodeQwenImage21: ["prompt", "negative_prompt", "resolution"],
|
||||
TextEncodeFibo2: ["prompt", "negative_prompt", "width", "height", "batch_size"],
|
||||
PH3ImageConditioning: ["prompt", "width", "height"],
|
||||
MiniMaxH3ImageConditioning: ["prompt", "width", "height"],
|
||||
TextEncodeMingImageEdit: ["prompt"],
|
||||
EmptyQwenImageLayeredLatentImage: ["width", "height", "layers", "batch_size"],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Gallery 3D viewer: glb / gltf / vrm / ply (mesh, point cloud, Gaussian splat) / spz.
|
||||
*
|
||||
* three.js is loaded the first time a 3D file is shown (static/lib/three-viewer).
|
||||
* The server has no 3D renderer, so thumbnails are rendered here and uploaded
|
||||
* (see ensureModelThumb); until then /thumb returns 404 and the card shows a placeholder.
|
||||
*/
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
const LIB_URL = "/wfm_static/lib/three-viewer/three-viewer.js";
|
||||
const DRACO_PATH = "/wfm_static/lib/three-viewer/draco/";
|
||||
// Sentinel base for relative URIs in .gltf files; rewritten to the asset route on load.
|
||||
const ASSET_BASE = "/__wfm3d__/";
|
||||
const BACKGROUND = 0x181c26;
|
||||
const THUMB_SIZE = 512;
|
||||
const THUMB_TIMEOUT_MS = 60_000;
|
||||
// Larger files only get a thumbnail when opened, not while scrolling the grid.
|
||||
const AUTO_THUMB_MAX_BYTES = 128 * 1024 * 1024;
|
||||
// Splats are sorted asynchronously and spring bones settle over time, so keep drawing
|
||||
// for a while after each change instead of only once.
|
||||
const SETTLE_FRAMES = 60;
|
||||
|
||||
const MODEL3D_EXTENSIONS = [".glb", ".gltf", ".vrm", ".ply", ".spz"];
|
||||
|
||||
export function isModel3dFile(img) {
|
||||
const source = (img?.ext || img?.filename || img?.path || "").toLowerCase();
|
||||
return MODEL3D_EXTENSIONS.some((ext) => source.endsWith(ext));
|
||||
}
|
||||
|
||||
export function model3dExtLabel(img) {
|
||||
const source = (img?.ext || img?.filename || img?.path || "").toLowerCase();
|
||||
return (MODEL3D_EXTENSIONS.find((ext) => source.endsWith(ext)) || "").slice(1).toUpperCase();
|
||||
}
|
||||
|
||||
let _libPromise = null;
|
||||
function loadLib() {
|
||||
if (!_libPromise) {
|
||||
_libPromise = import(LIB_URL).catch((e) => {
|
||||
_libPromise = null;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
return _libPromise;
|
||||
}
|
||||
|
||||
const serveUrl = (path) => `/wfm/gallery/image/serve?path=${encodeURIComponent(path)}`;
|
||||
const assetUrl = (path, uri) => `/wfm/gallery/model3d/asset?path=${encodeURIComponent(path)}&uri=${encodeURIComponent(uri)}`;
|
||||
|
||||
async function fetchBytes(url, signal, onProgress) {
|
||||
const res = await fetch(url, { signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const total = Number(res.headers.get("Content-Length")) || 0;
|
||||
if (!total || !res.body || !onProgress) return res.arrayBuffer();
|
||||
const out = new Uint8Array(total);
|
||||
const reader = res.body.getReader();
|
||||
let received = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
out.set(value, received);
|
||||
received += value.length;
|
||||
onProgress(received / total);
|
||||
}
|
||||
return out.buffer.slice(0, received);
|
||||
}
|
||||
|
||||
// 3DGS exports are .ply too; they are told apart by their per-splat properties.
|
||||
function isSplatPly(buffer) {
|
||||
const head = new TextDecoder("latin1").decode(new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 8192)));
|
||||
const end = head.indexOf("end_header");
|
||||
const header = end >= 0 ? head.slice(0, end) : head;
|
||||
return /property\s+\w+\s+f_dc_0\b/.test(header) || /property\s+\w+\s+scale_0\b/.test(header);
|
||||
}
|
||||
|
||||
function countGeometry(root) {
|
||||
let vertices = 0;
|
||||
let triangles = 0;
|
||||
root.traverse((o) => {
|
||||
const g = o.geometry;
|
||||
if (!g?.attributes?.position) return;
|
||||
vertices += g.attributes.position.count;
|
||||
if (o.isMesh) triangles += (g.index ? g.index.count : g.attributes.position.count) / 3;
|
||||
});
|
||||
return { vertices, triangles: Math.round(triangles) };
|
||||
}
|
||||
|
||||
async function loadGltf(lib, img, buffer, isVrm) {
|
||||
const manager = new lib.THREE.LoadingManager();
|
||||
manager.setURLModifier((url) => (url.startsWith(ASSET_BASE) ? assetUrl(img.path, url.slice(ASSET_BASE.length)) : url));
|
||||
const loader = new lib.GLTFLoader(manager);
|
||||
const draco = new lib.DRACOLoader(manager).setDecoderPath(DRACO_PATH);
|
||||
loader.setDRACOLoader(draco);
|
||||
loader.setMeshoptDecoder(lib.MeshoptDecoder);
|
||||
if (isVrm) loader.register((parser) => new lib.VRMLoaderPlugin(parser));
|
||||
let gltf;
|
||||
try {
|
||||
gltf = await loader.parseAsync(buffer, ASSET_BASE);
|
||||
} finally {
|
||||
draco.dispose();
|
||||
}
|
||||
|
||||
const vrm = gltf.userData?.vrm;
|
||||
if (vrm) lib.VRMUtils.rotateVRM0(vrm); // VRM 0.x faces -Z
|
||||
const object = vrm ? vrm.scene : gltf.scene;
|
||||
const mixer = gltf.animations?.length ? new lib.THREE.AnimationMixer(object) : null;
|
||||
if (mixer) mixer.clipAction(gltf.animations[0]).play();
|
||||
return {
|
||||
object,
|
||||
animated: !!mixer,
|
||||
settles: !!vrm,
|
||||
update: (dt) => { mixer?.update(dt); vrm?.update(dt); },
|
||||
stats: countGeometry(object),
|
||||
};
|
||||
}
|
||||
|
||||
function loadPlyGeometry(lib, buffer) {
|
||||
const { THREE } = lib;
|
||||
const geometry = new lib.PLYLoader().parse(buffer);
|
||||
// "property double" (e.g. Open3D) comes back as a Float64Array, which WebGL rejects.
|
||||
for (const [name, attr] of Object.entries(geometry.attributes)) {
|
||||
if (attr.array instanceof Float64Array) {
|
||||
geometry.setAttribute(name, new THREE.BufferAttribute(Float32Array.from(attr.array), attr.itemSize, attr.normalized));
|
||||
}
|
||||
}
|
||||
const hasColor = !!geometry.attributes.color;
|
||||
if (!geometry.index) {
|
||||
geometry.computeBoundingSphere();
|
||||
const material = new THREE.PointsMaterial({
|
||||
size: geometry.boundingSphere.radius / 300,
|
||||
vertexColors: hasColor,
|
||||
color: hasColor ? 0xffffff : 0xb8c0cc,
|
||||
});
|
||||
const object = new THREE.Points(geometry, material);
|
||||
return { object, stats: countGeometry(object) };
|
||||
}
|
||||
if (!geometry.attributes.normal) geometry.computeVertexNormals();
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
vertexColors: hasColor,
|
||||
color: hasColor ? 0xffffff : 0xb8c0cc,
|
||||
roughness: 0.7,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
const object = new THREE.Mesh(geometry, material);
|
||||
return { object, stats: countGeometry(object) };
|
||||
}
|
||||
|
||||
async function loadSplat(lib, stage, img, buffer) {
|
||||
const { SparkRenderer, SplatMesh } = await lib.loadSpark();
|
||||
const spark = new SparkRenderer({ renderer: stage.renderer });
|
||||
stage.scene.add(spark);
|
||||
const mesh = new SplatMesh({ fileBytes: new Uint8Array(buffer), fileName: img.filename || img.path });
|
||||
await mesh.initialized;
|
||||
mesh.quaternion.set(1, 0, 0, 0); // splats are stored Y-down
|
||||
return {
|
||||
object: mesh,
|
||||
splat: true,
|
||||
settles: true,
|
||||
box: mesh.getBoundingBox(true),
|
||||
stats: { splats: mesh.packedSplats?.numSplats ?? mesh.numSplats ?? 0 },
|
||||
dispose: () => { mesh.dispose(); spark.dispose(); },
|
||||
};
|
||||
}
|
||||
|
||||
async function loadInto(lib, stage, img, signal, onProgress) {
|
||||
const buffer = await fetchBytes(serveUrl(img.path), signal, onProgress);
|
||||
const name = (img.filename || img.path).toLowerCase();
|
||||
if (name.endsWith(".spz")) return loadSplat(lib, stage, img, buffer);
|
||||
if (name.endsWith(".ply")) {
|
||||
return isSplatPly(buffer) ? loadSplat(lib, stage, img, buffer) : loadPlyGeometry(lib, buffer);
|
||||
}
|
||||
return loadGltf(lib, img, buffer, name.endsWith(".vrm"));
|
||||
}
|
||||
|
||||
function createStage(lib, canvas) {
|
||||
const { THREE } = lib;
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: false });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(BACKGROUND);
|
||||
const pmrem = new THREE.PMREMGenerator(renderer);
|
||||
const envTarget = pmrem.fromScene(new lib.RoomEnvironment(), 0.04);
|
||||
pmrem.dispose();
|
||||
scene.environment = envTarget.texture;
|
||||
scene.environmentIntensity = 0.8;
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||
sun.position.set(2, 4, 3);
|
||||
scene.add(sun);
|
||||
const camera = new THREE.PerspectiveCamera(35, 1, 0.01, 1000);
|
||||
return { renderer, scene, camera, envTarget };
|
||||
}
|
||||
|
||||
function frameObject(lib, stage, model) {
|
||||
const { THREE } = lib;
|
||||
const { camera, scene } = stage;
|
||||
scene.add(model.object);
|
||||
model.object.updateMatrixWorld(true);
|
||||
const box = model.box
|
||||
? model.box.clone().applyMatrix4(model.object.matrixWorld)
|
||||
: new THREE.Box3().setFromObject(model.object);
|
||||
if (box.isEmpty()) box.set(new THREE.Vector3(-1, -1, -1), new THREE.Vector3(1, 1, 1));
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const radius = Math.max(box.getSize(new THREE.Vector3()).length() / 2, 1e-4);
|
||||
const distance = (radius / Math.sin(THREE.MathUtils.degToRad(camera.fov / 2))) * 1.05;
|
||||
camera.near = distance / 500;
|
||||
camera.far = distance * 50;
|
||||
camera.position.copy(center).add(new THREE.Vector3(0.35, 0.2, 1).normalize().multiplyScalar(distance));
|
||||
camera.lookAt(center);
|
||||
camera.updateProjectionMatrix();
|
||||
return center;
|
||||
}
|
||||
|
||||
function disposeStage(stage, model) {
|
||||
try { model?.dispose?.(); } catch { /* already gone */ }
|
||||
model?.object?.traverse?.((o) => {
|
||||
o.geometry?.dispose?.();
|
||||
for (const m of [].concat(o.material || [])) {
|
||||
for (const v of Object.values(m)) if (v?.isTexture) v.dispose();
|
||||
m.dispose?.();
|
||||
}
|
||||
});
|
||||
stage.envTarget.dispose();
|
||||
stage.renderer.dispose();
|
||||
stage.renderer.forceContextLoss(); // browsers only allow ~16 live contexts
|
||||
}
|
||||
|
||||
function formatStats(stats) {
|
||||
const n = (v) => Number(v).toLocaleString();
|
||||
if (stats.splats != null) return t("gallery3dStatsSplats", n(stats.splats));
|
||||
if (!stats.triangles) return t("gallery3dStatsPoints", n(stats.vertices));
|
||||
return t("gallery3dStatsMesh", n(stats.vertices), n(stats.triangles));
|
||||
}
|
||||
|
||||
// Center-crop the canvas to a square JPEG. Must run right after render() in the same task,
|
||||
// because the drawing buffer is not preserved.
|
||||
function captureJpeg(canvas) {
|
||||
const out = document.createElement("canvas");
|
||||
out.width = out.height = THUMB_SIZE;
|
||||
const side = Math.min(canvas.width, canvas.height);
|
||||
out.getContext("2d").drawImage(
|
||||
canvas, (canvas.width - side) / 2, (canvas.height - side) / 2, side, side, 0, 0, THUMB_SIZE, THUMB_SIZE,
|
||||
);
|
||||
return out.toDataURL("image/jpeg", 0.85);
|
||||
}
|
||||
|
||||
async function uploadThumb(path, image) {
|
||||
const res = await fetch("/wfm/gallery/image/thumb3d", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, image }),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive viewer inside `container`. It disposes itself once `container` leaves the
|
||||
* DOM, so callers can simply replace or remove the element.
|
||||
* `onReady(capture)` fires after the model is drawn; capture() returns a thumbnail data URL.
|
||||
*/
|
||||
export function mountModelViewer(container, img, { onReady } = {}) {
|
||||
container.classList.add("wfm-g3d");
|
||||
container.innerHTML = `
|
||||
<canvas class="wfm-g3d-canvas"></canvas>
|
||||
<div class="wfm-g3d-status">${t("gallery3dLoading")}</div>
|
||||
<div class="wfm-g3d-stats"></div>
|
||||
<button type="button" class="wfm-g3d-reset" title="${t("gallery3dResetView")}">⟲</button>`;
|
||||
const canvas = container.querySelector(".wfm-g3d-canvas");
|
||||
const status = container.querySelector(".wfm-g3d-status");
|
||||
const statsEl = container.querySelector(".wfm-g3d-stats");
|
||||
const resetBtn = container.querySelector(".wfm-g3d-reset");
|
||||
const abort = new AbortController();
|
||||
let stage = null;
|
||||
let model = null;
|
||||
let controls = null;
|
||||
let resizeObserver = null;
|
||||
let frameId = 0;
|
||||
let closed = false;
|
||||
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
abort.abort();
|
||||
cancelAnimationFrame(frameId);
|
||||
resizeObserver?.disconnect();
|
||||
controls?.dispose();
|
||||
if (stage) disposeStage(stage, model);
|
||||
};
|
||||
|
||||
const fail = (e) => {
|
||||
if (closed || e?.name === "AbortError") return;
|
||||
console.error("[WFS] 3D viewer:", img.path, e);
|
||||
status.textContent = t("gallery3dLoadFailed");
|
||||
status.classList.add("wfm-g3d-status--error");
|
||||
status.style.display = "";
|
||||
close();
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const lib = await loadLib();
|
||||
if (closed) return;
|
||||
stage = createStage(lib, canvas);
|
||||
const { renderer, scene, camera } = stage;
|
||||
model = await loadInto(lib, stage, img, abort.signal, (p) => {
|
||||
status.textContent = `${t("gallery3dLoading")} ${Math.round(p * 100)}%`;
|
||||
});
|
||||
if (closed) {
|
||||
disposeStage(stage, model);
|
||||
return;
|
||||
}
|
||||
const center = frameObject(lib, stage, model);
|
||||
const home = camera.position.clone();
|
||||
controls = new lib.OrbitControls(camera, canvas);
|
||||
controls.target.copy(center);
|
||||
controls.enableDamping = true;
|
||||
controls.update();
|
||||
|
||||
let dirty = SETTLE_FRAMES;
|
||||
const invalidate = () => { dirty = SETTLE_FRAMES; };
|
||||
controls.addEventListener("change", invalidate);
|
||||
resetBtn.addEventListener("click", () => {
|
||||
camera.position.copy(home);
|
||||
controls.target.copy(center);
|
||||
controls.update();
|
||||
invalidate();
|
||||
});
|
||||
const resize = () => {
|
||||
const w = container.clientWidth;
|
||||
const h = container.clientHeight;
|
||||
if (!w || !h) return;
|
||||
renderer.setSize(w, h, false);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
invalidate();
|
||||
};
|
||||
resizeObserver = new ResizeObserver(resize);
|
||||
resizeObserver.observe(container);
|
||||
resize();
|
||||
|
||||
renderer.render(scene, camera); // surface a model that cannot be drawn as a load failure
|
||||
status.style.display = "none";
|
||||
statsEl.textContent = formatStats(model.stats);
|
||||
onReady?.(() => {
|
||||
renderer.render(scene, camera);
|
||||
return captureJpeg(canvas);
|
||||
});
|
||||
|
||||
let last = performance.now();
|
||||
const tick = () => {
|
||||
if (!container.isConnected) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
frameId = requestAnimationFrame(tick);
|
||||
const now = performance.now();
|
||||
const dt = (now - last) / 1000;
|
||||
last = now;
|
||||
if (!container.clientWidth) return; // hidden tab
|
||||
controls.update();
|
||||
if (!model.animated && dirty <= 0) return;
|
||||
dirty -= 1;
|
||||
try {
|
||||
model.update?.(dt);
|
||||
renderer.render(scene, camera);
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
})().catch(fail);
|
||||
|
||||
return { close };
|
||||
}
|
||||
|
||||
async function renderThumb(img) {
|
||||
const lib = await loadLib();
|
||||
const canvas = document.createElement("canvas");
|
||||
const stage = createStage(lib, canvas);
|
||||
stage.renderer.setPixelRatio(1);
|
||||
stage.renderer.setSize(THUMB_SIZE, THUMB_SIZE, false);
|
||||
const abort = new AbortController();
|
||||
const timer = setTimeout(() => abort.abort(), THUMB_TIMEOUT_MS);
|
||||
let model = null;
|
||||
try {
|
||||
model = await loadInto(lib, stage, img, abort.signal);
|
||||
frameObject(lib, stage, model);
|
||||
model.update?.(0);
|
||||
for (let i = 0; model.splat && i < SETTLE_FRAMES; i++) {
|
||||
stage.renderer.render(stage.scene, stage.camera);
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
}
|
||||
stage.renderer.render(stage.scene, stage.camera);
|
||||
return captureJpeg(canvas);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
disposeStage(stage, model);
|
||||
}
|
||||
}
|
||||
|
||||
const _thumbJobs = new Map(); // path -> Promise<boolean>, also remembers failures for this session
|
||||
let _thumbQueue = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Render and store the thumbnail of a 3D file, one file at a time.
|
||||
* `isWanted()` is checked when the job's turn comes, so cards scrolled away or replaced by
|
||||
* a folder change cost nothing. Resolves true once the server has the thumbnail.
|
||||
*/
|
||||
export function ensureModelThumb(img, isWanted = () => true) {
|
||||
if (_thumbJobs.has(img.path)) return _thumbJobs.get(img.path);
|
||||
if (img.size > AUTO_THUMB_MAX_BYTES) return Promise.resolve(false);
|
||||
const job = _thumbQueue.then(async () => {
|
||||
if (!isWanted()) {
|
||||
_thumbJobs.delete(img.path);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await uploadThumb(img.path, await renderThumb(img));
|
||||
} catch (e) {
|
||||
console.warn("[WFS] 3D thumbnail failed:", img.path, e);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
_thumbJobs.set(img.path, job);
|
||||
_thumbQueue = job;
|
||||
return job;
|
||||
}
|
||||
|
||||
/** Store a thumbnail captured from an open viewer (covers files too large for ensureModelThumb). */
|
||||
export function saveModelThumb(img, capture) {
|
||||
const job = uploadThumb(img.path, capture()).catch(() => false);
|
||||
_thumbJobs.set(img.path, job);
|
||||
return job;
|
||||
}
|
||||
+167
-6
@@ -15,6 +15,7 @@ import { callLLM, loadAiSettings, isValidBackendUrl } from "./ai-tab.js";
|
||||
import { initImagePromptTab, activateImagePromptTab } from "./image-prompt-tab.js";
|
||||
import { initStyleCatalogTab, activateStyleCatalogTab } from "./style-catalog-tab.js";
|
||||
import { applyStoredVideoVolume } from "./settings-tab.js";
|
||||
import { isModel3dFile, model3dExtLabel, mountModelViewer, ensureModelThumb, saveModelThumb } from "./gallery-3d-viewer.js";
|
||||
import { initSemantic, refreshSemanticDots, getSemanticMode, isSemanticOn, fetchSemanticImages, semanticCountLabel, setSemanticCountText, semanticErrorMessage, exitSimilarMode } from "./gallery-semantic.js";
|
||||
|
||||
// ── 定数 ─────────────────────────────────────────────────────
|
||||
@@ -80,9 +81,11 @@ export function isAudioFile(img) {
|
||||
}
|
||||
|
||||
// プレビュー/ライトボックス/比較用のメディア要素HTML。音声は波形画像+<audio controls>。
|
||||
// 3Dは空の枠だけ返し、挿入後に mountModelViewers() がビューアを起動する。
|
||||
function mediaElementHtml(img, { imgClass, videoAttrs = "controls", audioAttrs = "controls", title = "" }) {
|
||||
const src = API.serveImage(img.path);
|
||||
const titleAttr = title ? ` title="${title}"` : "";
|
||||
if (isModel3dFile(img)) return `<div class="${imgClass} wfm-g3d-box" data-path="${escapeHtml(img.path)}"${titleAttr}></div>`;
|
||||
if (isVideoFile(img)) return `<video src="${src}" class="${imgClass}" ${videoAttrs}${titleAttr}></video>`;
|
||||
if (isAudioFile(img)) {
|
||||
return `<div class="wfm-gallery-audio-box">
|
||||
@@ -93,6 +96,56 @@ function mediaElementHtml(img, { imgClass, videoAttrs = "controls", audioAttrs =
|
||||
return `<img src="${src}" class="${imgClass}" alt="${escapeHtml(img.filename)}"${titleAttr}>`;
|
||||
}
|
||||
|
||||
// root内の3D枠でビューアを起動する。枠がDOMから外れるとビューアは自分で破棄される。
|
||||
function mountModelViewers(root, imgs) {
|
||||
root.querySelectorAll(".wfm-g3d-box").forEach((box) => {
|
||||
const img = imgs.find((im) => im.path === box.dataset.path);
|
||||
if (!img) return;
|
||||
mountModelViewer(box, img, {
|
||||
onReady: (capture) => {
|
||||
// グリッドの自動描画はサイズ上限があるため、開いたときにも保存しておく
|
||||
if (_modelThumbMissing.delete(img.path)) {
|
||||
saveModelThumb(img, capture).then((ok) => { if (ok) refreshModelThumb(img.path); });
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// サムネイルが無く、開いたときに保存する3Dファイルのパス
|
||||
const _modelThumbMissing = new Set();
|
||||
|
||||
function refreshModelThumb(path) {
|
||||
document.querySelectorAll(".wfm-gallery-thumb-img[data-model3d]").forEach((el) => {
|
||||
if (el.dataset.model3d === path) el.dispatchEvent(new CustomEvent("wfm-model3d-thumb"));
|
||||
});
|
||||
}
|
||||
|
||||
// サムネイル未作成の3Dファイル: プレースホルダーを出し、描画できたら差し替える
|
||||
function setupModelThumb(card, imgEl, img) {
|
||||
imgEl.dataset.model3d = img.path;
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.className = "wfm-gallery-thumb-placeholder";
|
||||
placeholder.textContent = model3dExtLabel(img);
|
||||
const show = () => {
|
||||
placeholder.remove();
|
||||
imgEl.style.display = "";
|
||||
imgEl.src = `${API.thumb(img.path)}&t=${Date.now()}`;
|
||||
};
|
||||
imgEl.addEventListener("wfm-model3d-thumb", show);
|
||||
imgEl.onerror = () => {
|
||||
if (placeholder.isConnected) return;
|
||||
imgEl.style.display = "none";
|
||||
card.insertBefore(placeholder, imgEl.nextSibling);
|
||||
_modelThumbMissing.add(img.path);
|
||||
ensureModelThumb(img, () => card.isConnected).then((ok) => {
|
||||
if (!ok) return;
|
||||
_modelThumbMissing.delete(img.path);
|
||||
refreshModelThumb(img.path);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// ComfyUI Comic Creater からiframe越しに画像を受け取り、Generate UIのImage入力スロットへ直接セットする(I2I連携)。
|
||||
// Comic Creater側の「I2Iへ送る」ボタンから
|
||||
// iframe.contentWindow._wfmReceiveImageForI2I(blob, name, workflowData?, workflowFilename?) として呼ばれる。
|
||||
@@ -740,13 +793,16 @@ function createThumbCard(img) {
|
||||
placeholder.textContent = "?";
|
||||
card.insertBefore(placeholder, imgEl.nextSibling);
|
||||
};
|
||||
const isModel3d = isModel3dFile(img);
|
||||
if (isModel3d) setupModelThumb(card, imgEl, img);
|
||||
card.appendChild(imgEl);
|
||||
|
||||
// 動画ファイルは再生アイコンバッジを重ねて表示(サムネイル自体は先頭フレームのJPEG)
|
||||
if (isVideoFile(img) || isAudioFile(img)) {
|
||||
if (isVideoFile(img) || isAudioFile(img) || isModel3d) {
|
||||
const playBadge = document.createElement("div");
|
||||
playBadge.className = "wfm-gallery-thumb-video-badge";
|
||||
playBadge.textContent = isAudioFile(img) ? "♪" : "▶";
|
||||
playBadge.textContent = isModel3d ? "3D" : isAudioFile(img) ? "♪" : "▶";
|
||||
playBadge.classList.toggle("wfm-gallery-thumb-video-badge--corner", isModel3d);
|
||||
card.appendChild(playBadge);
|
||||
}
|
||||
|
||||
@@ -779,7 +835,7 @@ function createThumbCard(img) {
|
||||
e.stopPropagation();
|
||||
await toggleFeederGroupInPlace(img, feederBtn);
|
||||
});
|
||||
card.appendChild(feederBtn);
|
||||
if (!isModel3d) card.appendChild(feederBtn); // Feederノードは画像しか読めない
|
||||
|
||||
// クリック: 詳細表示 / Ctrl+クリック: 複数選択 / Shift+クリック: 範囲選択 / Alt+クリック: Metadataタブで開く
|
||||
card.addEventListener("click", (e) => {
|
||||
@@ -869,6 +925,18 @@ function createTable(images) {
|
||||
|
||||
const tdThumb = document.createElement("td");
|
||||
tdThumb.innerHTML = `<img src="${API.thumb(img.path, 128)}" class="wfm-gallery-table-thumb" loading="lazy" alt="">`;
|
||||
if (isModel3dFile(img)) {
|
||||
const thumbEl = tdThumb.firstElementChild;
|
||||
thumbEl.onerror = () => {
|
||||
thumbEl.style.visibility = "hidden";
|
||||
ensureModelThumb(img, () => thumbEl.isConnected).then((ok) => {
|
||||
if (!ok) return;
|
||||
thumbEl.onerror = null;
|
||||
thumbEl.style.visibility = "";
|
||||
thumbEl.src = `${API.thumb(img.path, 128)}&t=${Date.now()}`;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const tdName = document.createElement("td");
|
||||
tdName.className = "wfm-gallery-table-name";
|
||||
@@ -1074,8 +1142,9 @@ async function loadImageDetail(img) {
|
||||
${mediaElementHtml(img, { imgClass: "wfm-gallery-detail-img", title: "Double-click to enlarge" })}
|
||||
</div>
|
||||
`;
|
||||
const previewMediaEl = preview.querySelector(isVideo ? "video" : "img");
|
||||
const previewMediaEl = preview.querySelector(isModel3dFile(img) ? ".wfm-g3d-box" : isVideo ? "video" : "img");
|
||||
previewMediaEl.addEventListener("dblclick", () => openLightbox(img));
|
||||
mountModelViewers(preview, [img]);
|
||||
if (isVideo) applyStoredVideoVolume(previewMediaEl);
|
||||
if (isAudio) applyStoredVideoVolume(preview.querySelector("audio"));
|
||||
|
||||
@@ -1870,6 +1939,13 @@ function imageByPath(path) {
|
||||
return map.get(path);
|
||||
}
|
||||
|
||||
// 画像を前提とする送信先へ3Dファイルを渡さない
|
||||
function rejectModel3d() {
|
||||
if (!isModel3dFile(state.selectedImage)) return false;
|
||||
showToast(t("gallery3dNotImage"), "info");
|
||||
return true;
|
||||
}
|
||||
|
||||
function isConvertible(img) {
|
||||
return !!img && (isAudioFile(img) || isVideoFile(img));
|
||||
}
|
||||
@@ -2104,6 +2180,7 @@ function openLightbox(img) {
|
||||
}
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
mountModelViewers(overlay, [img]);
|
||||
if (isVideoFile(img)) applyStoredVideoVolume(overlay.querySelector("video"));
|
||||
if (isAudioFile(img)) applyStoredVideoVolume(overlay.querySelector("audio"));
|
||||
}
|
||||
@@ -2118,7 +2195,7 @@ function openCompare(paths) {
|
||||
overlay.className = "wfm-gallery-lightbox wfm-lightbox-compare";
|
||||
|
||||
const itemsHtml = imgs.map(img => {
|
||||
const mediaHtml = isAudioFile(img)
|
||||
const mediaHtml = isAudioFile(img) || isModel3dFile(img)
|
||||
? mediaElementHtml(img, { imgClass: "wfm-lightbox-compare-img" })
|
||||
: isVideoFile(img)
|
||||
? `<video src="${API.serveImage(img.path)}" class="wfm-lightbox-compare-img" controls></video>`
|
||||
@@ -2143,9 +2220,89 @@ function openCompare(paths) {
|
||||
}
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
mountModelViewers(overlay, imgs);
|
||||
overlay.querySelectorAll("video, audio").forEach(applyStoredVideoVolume);
|
||||
}
|
||||
|
||||
// ── 他のタブから画像を表示する ────────────────────────────────
|
||||
|
||||
const _normPath = (p) => (p || "").replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
||||
|
||||
/**
|
||||
* Output サブタブで path の画像を表示する(TagSpace の Gallery 点から呼ぶ)。
|
||||
* 絞り込み(検索・お気に入り・タグ・グループ・類似表示)を解除し、フォルダツリーを
|
||||
* 親から順に展開してフォルダを選び、一覧でその画像を選択して詳細を開く。
|
||||
* 戻り値: 表示できたら true(出力フォルダ外・一覧に無いときは false)
|
||||
*/
|
||||
export async function revealInGallery(path) {
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
document.querySelector('.wfm-tab[data-tab="gallery"]')?.click(); // 初回はここで出力フォルダとツリーを読み込む
|
||||
document.querySelector('.wfm-gallery-subtab-btn[data-gallery-subtab="output"]')?.click();
|
||||
for (let i = 0; i < 100 && !state.folderTree; i++) await wait(100);
|
||||
if (!state.folderTree) return false;
|
||||
|
||||
const target = _normPath(path);
|
||||
const folder = target.slice(0, target.lastIndexOf("/"));
|
||||
// ツリーの根から目的のフォルダまでのノードを集める
|
||||
const chain = [];
|
||||
let node = state.folderTree;
|
||||
if (folder !== _normPath(node.abs_path) && !folder.startsWith(_normPath(node.abs_path) + "/")) return false;
|
||||
while (node) {
|
||||
chain.push(node);
|
||||
if (_normPath(node.abs_path) === folder) break;
|
||||
node = (node.children || []).find((c) => folder === _normPath(c.abs_path) || folder.startsWith(_normPath(c.abs_path) + "/"));
|
||||
}
|
||||
const leaf = chain[chain.length - 1];
|
||||
if (_normPath(leaf.abs_path) !== folder) return false;
|
||||
|
||||
// 絞り込みの解除(「Clear filters」ボタンと同じ。再読み込みは下で1回だけ行う)
|
||||
clearTimeout(_searchDebounceTimer);
|
||||
state.search = "";
|
||||
state.favoriteOnly = false;
|
||||
state.tagFilter = "";
|
||||
state.groupFilter = "";
|
||||
const searchInput = document.getElementById("wfm-gallery-search");
|
||||
if (searchInput) searchInput.value = "";
|
||||
const tagSel = document.getElementById("wfm-gallery-tag-filter");
|
||||
if (tagSel) tagSel.value = "";
|
||||
const groupSel = document.getElementById("wfm-gallery-group-filter");
|
||||
if (groupSel) groupSel.value = "";
|
||||
document.getElementById("wfm-gallery-fav-btn")?.classList.remove("active");
|
||||
exitSimilarMode();
|
||||
|
||||
// ツリーを親から順に展開(子ノードは展開時に描画される)
|
||||
const itemOf = (abs) => [...document.querySelectorAll(".wfm-gallery-tree-item")].find((el) => _normPath(el.dataset.path) === _normPath(abs));
|
||||
for (const n of chain.slice(0, -1)) {
|
||||
const arrow = itemOf(n.abs_path)?.querySelector(".wfm-gallery-tree-arrow");
|
||||
if (arrow && arrow.dataset.expanded !== "true" && arrow.style.visibility !== "hidden") arrow.click();
|
||||
}
|
||||
const item = itemOf(leaf.abs_path);
|
||||
document.querySelectorAll(".wfm-gallery-tree-item.selected").forEach((el) => el.classList.remove("selected"));
|
||||
item?.classList.add("selected");
|
||||
item?.scrollIntoView({ block: "nearest" });
|
||||
state.currentFolder = leaf.abs_path;
|
||||
state.selectedImages.clear();
|
||||
state.lastSelectionIndex = -1;
|
||||
updateBulkBar();
|
||||
await loadImages();
|
||||
|
||||
const idx = state.images.findIndex((img) => _normPath(img.path) === target);
|
||||
if (idx === -1) return false;
|
||||
// サムネイル表示は50件ずつ描くので、目的の画像まで描き足す
|
||||
const grid = document.getElementById("wfm-gallery-grid");
|
||||
if (state.viewMode === "thumb" && _renderedCount <= idx) {
|
||||
while (_renderedCount <= idx) _appendNextPage(grid);
|
||||
// 無限スクロールの目印を末尾へ戻す(全件描いたら外す)。_attachScrollSentinel と同じ扱い
|
||||
if (_renderedCount >= state.images.length) _disconnectScrollObserver();
|
||||
else { const s = document.getElementById("wfm-gallery-scroll-sentinel"); if (s) grid.appendChild(s); }
|
||||
}
|
||||
const el = [...grid.querySelectorAll("[data-path]")].find((e) => _normPath(e.dataset.path) === target);
|
||||
if (!el) return false;
|
||||
el.click(); // 通常のクリックと同じ選択・詳細表示
|
||||
el.scrollIntoView({ block: "center" });
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── outputパス取得 ────────────────────────────────────────────
|
||||
|
||||
async function detectOutputPath() {
|
||||
@@ -2228,6 +2385,7 @@ function bindEvents() {
|
||||
showToast("Please select an image first", "info");
|
||||
return;
|
||||
}
|
||||
if (rejectModel3d()) return;
|
||||
if (!window._wfmImageEditTab) return;
|
||||
const path = state.selectedImage.path;
|
||||
const name = (state.selectedImage.filename || "gallery-image").replace(/\.[^.]+$/, "");
|
||||
@@ -2257,6 +2415,7 @@ function bindEvents() {
|
||||
showToast(t("gallerySelectImageFirst"), "info");
|
||||
return;
|
||||
}
|
||||
if (rejectModel3d()) return;
|
||||
if (!comfyUI.currentAnalysis) {
|
||||
showToast(t("noWorkflowLoaded"), "info");
|
||||
return;
|
||||
@@ -2294,6 +2453,7 @@ function bindEvents() {
|
||||
showToast(t("gallerySelectImageFirst"), "info");
|
||||
return;
|
||||
}
|
||||
if (rejectModel3d()) return;
|
||||
if (!window.opener || typeof window.opener.wfmSendImageToSelectedNode !== "function") {
|
||||
showToast(t("sendToLiNodeNoOpener"), "error");
|
||||
return;
|
||||
@@ -2606,7 +2766,7 @@ function bindEvents() {
|
||||
if (!window._wfmImageEditTab) return;
|
||||
|
||||
const allPaths = [...state.selectedImages];
|
||||
const paths = allPaths.filter(p => !isVideoFile({ path: p }) && !isAudioFile({ path: p }));
|
||||
const paths = allPaths.filter(p => !isVideoFile({ path: p }) && !isAudioFile({ path: p }) && !isModel3dFile({ path: p }));
|
||||
if (paths.length === 0) {
|
||||
showToast(t("editLayersNoImages"), "error");
|
||||
return;
|
||||
@@ -2667,6 +2827,7 @@ function bindEvents() {
|
||||
showToast(t("gallerySelectImageFirst"), "error");
|
||||
return;
|
||||
}
|
||||
if (rejectModel3d()) return;
|
||||
const { openImageInTaggerTab } = await import("./tagger-tab.js");
|
||||
openImageInTaggerTab(state.selectedImage);
|
||||
});
|
||||
|
||||
+342
-21
@@ -315,6 +315,8 @@ const LANGUAGES = {
|
||||
|
||||
// -- GenerateUI Input tab: Image sub-panel placeholder --
|
||||
i2iPlaceholderTitle: "Placeholder Image",
|
||||
genSwitchesTitle: "Switches",
|
||||
genSwitchesNone: "No true/false settings in this workflow",
|
||||
i2iPlaceholderColor: "Color",
|
||||
i2iPlaceholderImage: "Image",
|
||||
i2iPlaceholderWidth: "Width",
|
||||
@@ -790,6 +792,10 @@ const LANGUAGES = {
|
||||
tsPickSearch: (n) => `Search with selected (${n})`,
|
||||
tsPickClear: "Deselect",
|
||||
tsFullPrompt: "Full prompt",
|
||||
tsAddPicked: (n) => `Add selected (${n})`,
|
||||
tsOpenInGallery: "Open in Gallery",
|
||||
tsRevealFailed: "Could not show this image in the Gallery (it is outside the Gallery output folder or no longer exists)",
|
||||
tsChipPicked: (name, n) => `${name} (${n} tags)`,
|
||||
tsListEmpty: "Select an item or search to list nearby cards and tags.",
|
||||
tsBuilderTitle: "Prompt",
|
||||
tsChipsEmpty: "Double-click cards or tags in the space, or use Add / + in the panels, to add them.",
|
||||
@@ -959,6 +965,72 @@ const LANGUAGES = {
|
||||
aiSkillFilenamePlaceholder: "filename.md",
|
||||
aiSkillContentPlaceholder: "---\nname: My Skill\ndescription: What this skill does\n---\n\nSystem prompt instructions for the LLM...",
|
||||
aiChatSaveAsSkill: "Save as new skill",
|
||||
aiChatApplyPrompt: "Apply to GenerateUI",
|
||||
aiChatApplyPromptTip: "Write this positive/negative prompt into the workflow loaded in GenerateUI",
|
||||
aiChatPromptApplied: "Prompt applied to GenerateUI",
|
||||
aiChatGenerateWithPrompt: "Generate with this prompt",
|
||||
aiChatCopyPrompt: "Copy",
|
||||
aiChatPromptCopied: "Prompt copied",
|
||||
aiStatusAutoFixing: "Auto-check found a problem, asking the model to fix it...",
|
||||
aiSkillBuiltin: "built-in",
|
||||
aiSkillBuiltinTip: "Ships with Workflow Studio. Untouched built-in skills are updated automatically.",
|
||||
aiSkillUpdateTip: "A newer version of this built-in skill is available (your edited copy was kept)",
|
||||
aiChatAutoCheckRetry: (msg) => `Auto-check: ${msg} — asking the model to fix it`,
|
||||
aiChatAutoCheckStillFailing: (msg) => `Auto-check: still not valid after one retry (${msg}). Edit your request and try again.`,
|
||||
aiSkillUpdateConfirm: (name) => `Replace "${name}" with the latest built-in version? Your edits to this skill will be lost.`,
|
||||
aiSkillUpdated: (name) => `Updated "${name}" to the latest built-in version`,
|
||||
aiChatCheckToggle: "Check images",
|
||||
aiChatCheckToggleTip: "Check generated images and SVGs with a vision decision model and show what is missing",
|
||||
aiSettingsCheckTitle: "Chat Image Check",
|
||||
aiJudgeModelLabel: "Judge model (Ollama decision model)",
|
||||
aiJudgeRefresh: "Refresh",
|
||||
aiCheckRoundsLabel: "Automatic fix rounds",
|
||||
aiCheckRoundsManual: "0 (fix by button)",
|
||||
aiJudgeNoVision: "No decision model with image input found in Ollama (e.g. d3:4b, clef-flash). Pull one with ollama pull, then press Refresh.",
|
||||
aiStatusChecking: "Checking the image...",
|
||||
aiChatCheckPassed: "All checks passed",
|
||||
aiChatCheckRefineBtn: "Fix missing items and regenerate",
|
||||
aiChatCheckSvgRefineBtn: "Ask to redraw unclear parts",
|
||||
aiChatCheckNoPrompt: "The model did not return a ```prompt block, so regeneration stopped.",
|
||||
aiChatCheckQuality_poor: "poor",
|
||||
aiChatCheckQuality_acceptable: "acceptable",
|
||||
aiChatCheckQuality_excellent: "excellent",
|
||||
aiJudgeHint: (url) => `Vision decision models from Ollama at ${url}. d3:4b is the fastest (about 2 s per check after loading).`,
|
||||
aiJudgeListFailed: (msg) => `Could not list Ollama models: ${msg}`,
|
||||
aiChatCheckFailedCount: (n) => `${n} item(s) not met`,
|
||||
aiChatCheckRound: (n) => `fix round ${n}`,
|
||||
aiChatCheckBy: (model) => `checked by ${model}`,
|
||||
aiChatCheckQuality: (label) => `Overall quality: ${label}`,
|
||||
aiChatCheckRefining: (items) => `Asking the model to fix: ${items}`,
|
||||
aiChatCheckError: (msg) => `Image check failed: ${msg}`,
|
||||
aiChatCheckAutoLimit: (n) => `Stopped after ${n} automatic fix round(s). Edit the request or the prompt and try again.`,
|
||||
aiChatOpenInLabTip: "Load this storyboard into GenerateUI > Lab as an unsaved plan (one scene per iteration, T2I mode)",
|
||||
aiChatLabPlanOpened: "Storyboard loaded into Lab. Check it and press Run.",
|
||||
aiChatCopyLabPlan: "Copy plan JSON",
|
||||
aiSkillImportText: "Import",
|
||||
aiSkillImportTip: "Import an Agent Skills folder (.zip containing SKILL.md) or a single .md skill. Only text files are kept; scripts are never run.",
|
||||
aiChatOpenInLab: (n) => `Open in Lab (${n} scenes)`,
|
||||
aiChatSkillFileRead: (path) => `Read skill file: ${path}`,
|
||||
aiSkillFolderBadge: (n) => `folder · ${n} file(s)`,
|
||||
aiSkillDeleteFolderConfirm: (name) => `Delete the skill folder "${name}" with all its files?`,
|
||||
aiSkillImported: (name, skipped) => skipped ? `Imported "${name}" (${skipped} non-text file(s) skipped)` : `Imported "${name}"`,
|
||||
aiSkillImportFailed: (msg) => `Import failed: ${msg}`,
|
||||
aiVlmOcr: "OCR (extract text)",
|
||||
aiVlmToTranslation: "To Translation",
|
||||
aiVlmToChat: "To Chat",
|
||||
aiOcrModelLabel: "OCR model",
|
||||
aiOcrShowBoxes: "Show text positions",
|
||||
aiOcrRefresh: "Reload the Ollama model list",
|
||||
aiPaneCollapse: "Collapse",
|
||||
aiPaneExpand: "Expand",
|
||||
aiOcrNoModel: "No OCR model found in Ollama (e.g. ollama pull maternion/LightOnOCR-3:4b), then press ↻.",
|
||||
aiOcrNoBoxes: "This model returns text only (no positions).",
|
||||
aiOcrEmpty: "No text was found.",
|
||||
aiToastSentToTranslation: "Sent to Translation",
|
||||
aiToastSentToChat: "Added to the chat input",
|
||||
aiOcrListFailed: (msg) => `Could not list Ollama models: ${msg}`,
|
||||
aiStatusOcrRunning: (model) => `Reading text with ${model}...`,
|
||||
aiOcrDone: (sec, lines, gray) => `Done in ${sec}s · ${lines} line(s)${gray ? " · retried in grayscale" : ""}`,
|
||||
|
||||
// -- Help & Support --
|
||||
tabHelp: "Help",
|
||||
@@ -984,7 +1056,7 @@ const LANGUAGES = {
|
||||
helpGen4: "Input tab: Prompt (left) and Image (right, about 3:2) are shown side by side on one screen (drag-and-drop upload); when the Prompt/Image area gets narrow (about 720px or less) Image wraps under Prompt, and the Raw JSON column can be collapsed to a slim strip at the right edge with its » button (Input tab only; remembered); the Prompt area has Positive Prompt, Negative Prompt, and an Embeddings selector at the bottom — select a model, set Weight (default 1.0), then click Paste to insert (embedding:Name:weight) at the cursor position of the last focused textarea (defaults to Positive if neither is focused); a ↺ button next to each Apply button reverts the textarea to the node's currently applied value; select text (or just place the cursor on a word/parenthesis block) and press Ctrl+↑/↓ to adjust its emphasis weight in (text:weight) syntax by ±0.05 — the parentheses are removed automatically when the weight returns to 1.0",
|
||||
helpGen5: "Model tab: Checkpoint, VAE, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — type and device selectors; DualCLIP shows two clip selectors) are always shown; ControlNet and Hypernetwork (with Strength field) are collapsed by default since they're used less often — click their header to expand; LoRA (Single/Stack tabs) has its own column to the right; all selectors have a filter; LoRA Stack — Apply writes all active LoRAs to the target node and simultaneously inserts LoRA syntax and CivitAI trigger words into the Positive prompt; re-applying updates the prompt by removing inactive LoRAs and re-adding active ones",
|
||||
helpGen28: "Model tab — Highlight color: the label of each model type (Checkpoint, VAE, Diffusion Model, Text Encoder, ControlNet, Hypernetwork) and the LoRA column header turn a distinct color (default bright green) when the loaded workflow actually contains that type of node; this stays visible even while ControlNet/Hypernetwork are collapsed, so you can tell at a glance whether they're in use without expanding them; the color is customizable in the Settings tab",
|
||||
helpGen6: "Settings tab: KSampler and Latent Image side by side, each at 50% width",
|
||||
helpGen6: "Settings tab: KSampler, Latent Image and Switches side by side. Switches lists every true/false value in the workflow (e.g. the prompt enhancer on/off switch, TextGenerate thinking) and every two-choice enable/disable, on/off or yes/no option (e.g. KSamplerAdvanced add_noise, DWPreprocessor detect_hand) with its node name; clicking one toggles it immediately (no Apply needed) and the change shows up in Raw JSON",
|
||||
helpGen31: "LTX-2.5 and Wan2.2 video workflows also work when loaded as regular workflows in this tab (not just the dedicated Video tab): Positive/Negative Prompt detection follows LTX-2.5's LTXVDualCFGGuider / LTXVConditioning nodes to find the actual prompt text, and the Settings tab's Latent Image section recognizes Wan2.2's EmptyHunyuanLatentVideo and LTX-2.5's EmptyLTXVLatentVideo nodes (with an added Length (frames) field) instead of showing \"No EmptyLatentImage node found\"; generating from these workflows also gets the same extended timeout as MiniMax H3 video generations",
|
||||
helpGen7: "Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow; if the loaded workflow has Bypass or Mute nodes, a note listing them appears above the search bar (Bypass nodes are excluded with their wires reconnected around them; Mute nodes are excluded with no reconnection, matching ComfyUI itself); the search bar highlights matches and scrolls to them without moving the cursor into the editor, so typing right after a search can't accidentally overwrite the JSON — click into the editor first to actually edit",
|
||||
helpGen8: "Seed control: random, fixed, increment, decrement, workflow (use the workflow's own seed as-is, without overriding it)",
|
||||
@@ -1064,6 +1136,7 @@ const LANGUAGES = {
|
||||
helpLabPlan9: "Each saved plan records the filename of the workflow that was loaded in GenerateUI at save time (workflow_filename in the Plan JSON). After loading a plan, an Open Workflow button appears in the PLAN box — click it to load that exact workflow file back into GenerateUI (asks for confirmation, since it replaces whatever is currently loaded)",
|
||||
helpLabPlan10: "Right pane — Save index image to Output on Run: when checked, every time Run finishes it builds the same contact-sheet index image used for Plan files and saves it directly into ComfyUI's own Output folder as Lab_index_<counter>_.png (auto-numbered like a normal generated image, never overwriting a previous run's). Once saved, it also goes through Eagle auto-save (if enabled in the Settings tab) just like every other Lab-generated image — no separate checkbox for that, it follows the same global toggle. This is independent of Plan Save, which still always writes its own index image next to the plan file in lab_plan/ regardless of this checkbox; this setting is saved with the plan as save_index_on_run",
|
||||
helpLabPlan11: "Every index image PNG — whether from Plan Save/Save As or from Save index image to Output on Run — carries the entire plan (columns, note, batch count, chain-image/T2I settings, workflow filename, results) embedded in it directly as a PNG wfm_lab_plan chunk, so the Output-folder copy is self-contained and works as a Plan Load source too even though it has no matching .json next to it anywhere",
|
||||
helpLabPlan12: "From a story: AI TOOL > Chat's \"Storyboard to Lab\" skill writes one prompt per scene; its \"Open in Lab\" button loads that plan here as an unsaved plan (T2I mode, Batch = number of scenes, one Prompt keyframe per scene, the plan's fixed seed in KSampler keyframe #1 - shown as \"Seed …\" in the cell). Check it, press Run, and use Plan Save to keep it",
|
||||
helpLabCardResultsTitle: "Results subtab",
|
||||
helpLabResults1: "Left pane shows the source image used for the run",
|
||||
helpLabResults2: "Center pane shows up to 9 generated images in generation order; click a thumbnail to view it enlarged",
|
||||
@@ -1162,6 +1235,14 @@ const LANGUAGES = {
|
||||
helpGallery28: "Create PSD bulk button: combines all selected images into a single layered PSD file and downloads it — each image becomes its own layer (named after the file), positioned top-left on a canvas sized to the largest selected image; the last selected image ends up on top Convert... bulk button (shown when audio/video files are selected): converts audio files — or extracts the audio track of videos — to FLAC / MP3 / Opus / M4A / WAV (bitrate, sample rate, channels selectable) into the same folder as new files; prompt/workflow tags are carried over for formats that can hold them (WAV cannot), favorite/tags/memo/groups can be carried over, and the originals can optionally be deleted afterwards.",
|
||||
helpGallery29: "Edit as Layers bulk button: switches to the Image Edit tab and opens all selected images at once, each as an independent layer (video files in the selection are skipped); the last selected image ends up on top",
|
||||
helpGallery30: "PSD files: thumbnails and full-size previews show all layers merged together (hidden layers excluded); the underlying PSD file itself is left untouched — only the preview shown in Gallery is a flattened rendering",
|
||||
helpGallery38: "3D models: GLB, glTF, VRM, PLY (mesh, point cloud or Gaussian splat) and SPZ are listed with a \"3D\" badge and open in a built-in viewer (drag to rotate, right-drag to pan, wheel to zoom, ⟲ to reset; double-click to enlarge). Thumbnails are rendered by your browser the first time a file appears and are then kept on the server; files over 128 MB get one when opened. The workflow saved by ComfyUI's SaveGLB is read like an image's. A .gltf that uses separate .bin or texture files needs them to stay in place: Move and Delete only affect the .gltf itself. 3D files cannot be sent to Image Edit, GenerateUI, Tagger or Feeder.",
|
||||
gallery3dLoading: "Loading 3D model...",
|
||||
gallery3dLoadFailed: "Could not display this 3D file",
|
||||
gallery3dResetView: "Reset view",
|
||||
gallery3dNotImage: "3D files cannot be sent here",
|
||||
gallery3dStatsPoints: (n) => `${n} points`,
|
||||
gallery3dStatsMesh: (v, f) => `${v} vertices / ${f} triangles`,
|
||||
gallery3dStatsSplats: (n) => `${n} splats`,
|
||||
helpGallery12: "Metadata button (green, in the detail panel tab bar): opens the selected image in the Metadata tab for full embedded-data inspection; Alt+click on any image thumbnail has the same effect",
|
||||
helpGallery13: "Load GenUI button (blue, in the detail panel tab bar): loads the embedded ComfyUI workflow from the selected image directly into the GenerateUI tab and switches to it; shows a warning toast if no workflow is embedded or the format is not supported",
|
||||
helpGallery14: "Tagger button (purple, in the detail panel action row): opens the selected image directly in the Tagger tab's Single mode for instant tagging",
|
||||
@@ -1185,7 +1266,7 @@ const LANGUAGES = {
|
||||
helpTagSpaceCardUse: "Choosing Tags & Making a Prompt",
|
||||
helpTagSpace1: "TagSpace places WFS's own data in the EmbeddingGemma 2 vector space and shows it in 3D: Cards (ImagePrompt images with their prompt, shown as thumbnails), Tags (words split out of ImagePrompt prompts, the positive prompts embedded in Gallery images, wildcards, the Tagger DB and the Prompt library, colored by source) and Gallery points (a sample of indexed Gallery images as a faint background, each carrying the tags from its own prompt). Nearby items have similar meanings",
|
||||
helpTagSpace2: "Requirements: Ollama running with EmbeddingGemma 2 (Gallery > Semantic settings > Model). If the Gallery semantic index was made with Gemini, rebuild it with EmbeddingGemma 2 first",
|
||||
helpTagSpace10: "Search with a card's or Gallery image's tags: when a card or Gallery point is selected, its prompt tags are shown as buttons (a card's full prompt is under \"Full prompt\"; card tags appear after Build / Update). Click tags to choose them (click again to deselect) and press \"Search with selected\" to search the space with those tags joined in prompt order — the same as typing them into the search box. The choice is kept until another item is selected",
|
||||
helpTagSpace10: "Search with a card's or Gallery image's tags: when a card or Gallery point is selected, its prompt tags are shown as buttons (a card's full prompt is under \"Full prompt\"; card tags appear after Build / Update). Click tags to choose them (click again to deselect) and press \"Search with selected\" to search the space with those tags joined in prompt order — the same as typing them into the search box. The choice is kept until another item is selected. While tags are selected, the Add button becomes \"Add selected (N)\" and adds only those tags (in prompt order) as one chip; with none selected it adds the whole item as before. For a Gallery point, \"Open in Gallery\" opens the Gallery tab with the image's folder selected and the image selected in the list (search and other filters are cleared)",
|
||||
helpTagSpace9: "Choosing how many tags: \"All tags\" uses every candidate tag instead of the Tags number (up to 20,000). \"Min uses\" drops tags used in fewer prompts than this, counted over all sources — 2 removes one-off typos and fragments. \"Wildcards\" turns wildcard files on or off as a tag source: words found only in wildcards make up most of the distinct tags (about 37,000 of 47,600 in testing), so turning it off with All tags gives about 10,400 tags (about 6,800 with Min uses 2). The status line shows the number of candidates when only part of them is used. New tags are embedded once (the first build with many new tags takes a few extra minutes)",
|
||||
helpTagSpace3: "Build / Update: collects the cards, adds card images that are not yet indexed to the Gallery semantic index, reads the positive prompt embedded in each indexed Gallery image (followed upstream from the sampler's positive input, never the negative; cached in tagspace_prompts.json so only new or changed images are read again), embeds new tags with Ollama (cached in tagspace_tags.npz) and projects everything to 3D with PCA. Tags: how many tags to keep — each source is ranked by how many prompts use a tag and the sources take turns, so the many Gallery prompts do not crowd out the other sources; Gallery points: how many Gallery images to show (0 = none). Prompts are split only at commas (and | { } BREAK), not at spaces; long sentences (over 5 words) are skipped. Images and text are centered separately before PCA so cards and their matching tags overlap. The first build took about 2.5 minutes for the cards plus about 3 minutes to read the prompts of 29,000 Gallery images; rebuilds with caches take seconds",
|
||||
helpTagSpace4: "Navigation: drag to rotate, right-drag to pan, wheel to zoom. Hover shows the name, thumbnail and prompt/sources; the checkboxes show or hide Cards, Thumbnails, Tags and Gallery. The source checkboxes (ImagePrompt / Gallery prompts / Tagger DB / Prompt library / Wildcards) show or hide tags by where they came from — a tag stays visible while any of its sources is on and takes the color of its first visible source; hidden tags are also left out of the nearby and search lists. The choices are remembered",
|
||||
@@ -1223,14 +1304,18 @@ const LANGUAGES = {
|
||||
helpSidepanel16: "A tab (AI Tools): Translation, Chat, TOOLS, and Settings sub-tabs powered by Ollama, LM Studio, Lemonade, or Unsloth; Chat supports multi-turn conversations; TOOLS includes VLM image analysis and wildcard generation; settings shared with the SPA AI TOOL tab via localStorage; the Settings sub-tab's VRAM (Ollama) → \"Unload all now\" unloads every model loaded in Ollama on localhost, decision models included (same as Settings → VRAM Management)",
|
||||
helpSidepanel17: "Send to Canvas: clicking 'Send to Canvas' (Workflow tab) or 'Copy & Send Canvas' (Gallery tab) sends the workflow directly to the ComfyUI canvas — both UI and API formats are supported; if Workflow Studio was not opened from the ComfyUI toolbar (window.opener unavailable), UI-format workflows fall back to title drag (title turns blue with a green ●)",
|
||||
helpAiTitle: "AI Tab (A)",
|
||||
helpAi1: "4-pane layout — Translation | Chat | TOOLS | Settings — all panes are always visible simultaneously",
|
||||
helpAi2: "Translation pane: translate between Japanese, English, Chinese, or Free language using Ollama, LM Studio, Lemonade, or Unsloth; ⇄ swap button swaps both language selectors and text content; selections are saved automatically",
|
||||
helpAi1: "4-pane layout — Translation | Chat | TOOLS | Settings; the Translation and Settings panes can be collapsed to a thin strip with the arrow (◀ / ▶) in their header, giving Chat and TOOLS more room — click the strip anywhere to open the pane again; the collapsed state is remembered",
|
||||
helpAi2: "Translation pane: translate between Japanese, English, Chinese, or Free language using Ollama, LM Studio, Lemonade, or Unsloth; ⇄ swap button swaps both language selectors and text content; selections are saved automatically; text extracted in the TOOLS pane (e.g. by OCR) can be sent here with its \"To Translation\" button, which also opens this pane if it is collapsed",
|
||||
helpAi3: "Chat pane: multi-turn conversation with the LLM; Enter to send a message, Shift+Enter for a newline; Clear button resets the conversation history; if the model supports tool calling and asks for an image (\"generate a cat picture\", etc.) it will run generation on the workflow currently loaded in GenerateUI and show the result inline; the \"Allow image generation\" checkbox in the pane header toggles this on/off; if an image is attached (dropped in the TOOLS pane) it is sent to the LLM as vision input and, when generate_image is called, used as the base for image-to-image generation instead of text-to-image; generate_image also accepts optional generation parameters the model can fill in from the request — steps, cfg, sampler_name, scheduler, denoise, seed, width, height, batch_size (number of images produced in that one call), and aspect_ratio (a ratio preset like \"3:2\", applied only if the workflow has a Resolution Selector node feeding width/height — ignored otherwise); anything the model doesn't supply keeps whatever the loaded workflow already has set; models verified to work well for tool calling and vision: Gemma 4:e4b, Qwen 3.5:9b, Ministral 3:3b (Ollama)",
|
||||
helpAi4: "TOOLS pane — VLM: drop an image (PNG/JPG/WebP) and select a task (Describe Image, Create Prompt, Create Tags), click Run; or select \"Create wildcards\", enter a category name and count to generate plain-text entries one per line; the dropped image is also shared with the Chat pane as its attachment (shown there and used for image-to-image generation) until cleared with the ✕ button",
|
||||
helpAi6: "Settings pane: choose backend (Ollama / LM Studio / Lemonade / Unsloth), set API URL, test connection, select model, and use the Unload Model button (next to Test connection) to free the loaded model from VRAM/memory — Ollama unloads every loaded Ollama model, decision models included (the same step as Settings → VRAM Management's \"Unload all now\"; the Prompt/Tagger settings button does the same), LM Studio uses /api/v1/models/unload, Lemonade uses /api/v1/unload; Unsloth has no unload API, so this shows an unsupported message for it; Unsloth needs either an API key or Keyless API access — set UNSLOTH_API_KEY in a .env file in the plugin folder (copy .env.example) and restart ComfyUI (the key is read server-side and never sent to the browser), or leave it unset and turn on Keyless API access → \"Chat and inference\" in Unsloth Desktop's Settings → API (requests are then sent without a key; note that setting also opens the API to your private LAN, and keep \"Allow tools\" off); Unsloth also requires \"Auto model switching (OpenAI API)\" to be turned ON in the Unsloth Desktop app itself (its own Settings screen) — without it, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI; Unsloth reasoning models return their thinking in a separate reasoning_content field rather than inline <think> tags, so a low Max tokens can make the model spend its whole budget reasoning and return an empty reply — increase Max tokens (2048+) if replies come back blank; Generation section — Thinking mode toggle (off by default; Ollama receives a think flag, and regardless of backend any <think>...</think> block is stripped from the output when off, so reasoning models don't clutter Translation/Chat/TOOLS results) and Max tokens (Ollama: num_predict, LM Studio/Lemonade/Unsloth: max_tokens; 0 = unlimited); configure Free language names; Chat Image Generation section lets you enable a dedicated workflow for Chat-triggered text-to-image generation instead of the one currently loaded in GenerateUI; Chat I2I Generation section is the same but for image-to-image generation when an image is attached; settings are shared with the Library panel's A tab via localStorage",
|
||||
helpAi4: "TOOLS pane — VLM: drop an image (PNG/JPG/WebP) and select a task (Describe Image, Create Prompt, Create Tags, OCR), click Run; or select \"Create wildcards\", enter a category name and count to generate plain-text entries one per line; the dropped image is also shared with the Chat pane as its attachment (shown there and used for image-to-image generation) until cleared with the ✕ button; below the result, \"To Translation\" and \"To Chat\" pass the result to those panes and \"Copy\" copies it",
|
||||
helpAi6: "Settings pane: choose backend (Ollama / LM Studio / Lemonade / Unsloth), set API URL, test connection, select model, and use the Unload Model button (next to Test connection) to free the loaded model from VRAM/memory — Ollama unloads every loaded Ollama model, decision models included (the same step as Settings → VRAM Management's \"Unload all now\"; the Prompt/Tagger settings button does the same), LM Studio uses /api/v1/models/unload, Lemonade uses /api/v1/unload; Unsloth has no unload API, so this shows an unsupported message for it; Unsloth needs either an API key or Keyless API access — set UNSLOTH_API_KEY in a .env file in the plugin folder (copy .env.example) and restart ComfyUI (the key is read server-side and never sent to the browser), or leave it unset and turn on Keyless API access → \"Chat and inference\" in Unsloth Desktop's Settings → API (requests are then sent without a key; note that setting also opens the API to your private LAN, and keep \"Allow tools\" off); Unsloth also requires \"Auto model switching (OpenAI API)\" to be turned ON in the Unsloth Desktop app itself (its own Settings screen) — without it, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI; Unsloth reasoning models return their thinking in a separate reasoning_content field rather than inline <think> tags, so a low Max tokens can make the model spend its whole budget reasoning and return an empty reply — increase Max tokens (2048+) if replies come back blank; Generation section — Thinking mode toggle (off by default; Ollama receives a think flag, and regardless of backend any <think>...</think> block is stripped from the output when off, so reasoning models don't clutter Translation/Chat/TOOLS results) and Max tokens (Ollama: num_predict, LM Studio/Lemonade/Unsloth: max_tokens; 0 = unlimited); configure Free language names; Chat Image Generation section lets you enable a dedicated workflow for Chat-triggered text-to-image generation instead of the one currently loaded in GenerateUI; Chat I2I Generation section is the same but for image-to-image generation when an image is attached; settings are shared with the Library panel's A tab via localStorage; Chat Image Check section picks the judge model used by the chat's \"Check images\" toggle (an Ollama decision model with image input, e.g. d3:4b) and how many automatic fix rounds to run (0 = fix only when you press the button)",
|
||||
helpAi7: "Chat pane — SVG: the LLM can also produce SVG graphics directly as text, with no ComfyUI workflow involved; ask it to draw something in SVG and, if the reply contains SVG code, it is rendered inline as a preview with a \"Save to Gallery\" button that writes it as a .svg file to the Gallery tab's output folder; this does not use tool calling, so it works with any model regardless of tool-calling support",
|
||||
helpAi8: "Chat pane — Skills: pick a skill from the dropdown above the message list to prepend its instructions to the conversation as a system prompt (not shown in the visible chat log); use the pencil button to open the skill manager and create, edit, or delete .md skill files, stored in user/default/Workflow-Studio/ai_skills/; a file's first line block between --- markers can define name/description shown in the dropdown; two starter skills are included by default: \"SVG Icon Generator\" (decomposes the subject into parts before drawing, for more recognizable SVG output) and \"Skill Creator\" (interviews you conversationally and drafts a new skill file, ready to save); when a reply contains a ```skill code block, a \"Save as new skill\" button appears below it that opens the manager with the draft pre-filled",
|
||||
helpAi8: "Chat pane — Skills: pick a skill from the dropdown above the message list to prepend its instructions to the conversation as a system prompt (not shown in the visible chat log); use the pencil button to open the skill manager and create, edit, delete or import skills, stored in user/default/Workflow-Studio/ai_skills/; a file's first block between --- markers defines the name/description shown in the dropdown; six built-in skills are included (marked \"built-in\" in the manager): \"T2I Prompt Enhancer\" (rewrites a short idea into a prompt written for the model in your current GenerateUI workflow - natural sentences for Qwen-Image/FLUX/Z-Image, Danbooru tags for Illustrious/Pony), \"Image to Prompt\" (writes a prompt that recreates an image attached in the TOOLS pane; needs a vision chat model), \"Storyboard to Lab\" and \"Workflow Doctor\" (see their card below), \"SVG Icon Generator\" (decomposes the subject into parts before drawing; line/flat/duotone/mono styles) and \"Skill Creator\" (interviews you and drafts a new skill, or revises an existing one); built-in skills you have not edited are updated automatically when Workflow Studio is updated, and an edited one shows a ↻ button to replace it with the latest version; when a reply contains a ```skill code block, a \"Save as new skill\" button opens the manager with the draft pre-filled",
|
||||
helpAi9: "Tips for writing your own skills: give a clear frontmatter name/description so it reads well in the dropdown and tooltip; state the exact output format you want and, if there is a deliverable to extract later (like SVG or another skill file), wrap it in a distinctive fenced code block; write concrete, imperative instructions rather than vague ones - smaller local models follow explicit rules and a short worked example far better than general guidance; state hard constraints (forbidden content, security, length limits) explicitly rather than assuming they are implied; when in doubt, select the \"Skill Creator\" skill in the dropdown and describe what you want - it will interview you and draft the file for you",
|
||||
helpAi10: "under metadata: in a skill's frontmatter you can set output (prompt or svg - the reply is checked automatically, and if the model tried to output one but it is broken - an unclosed or invalid SVG, forbidden elements, prompt lines outside a ```prompt block - the model is asked once to fix it, shown as a dashed notice; replies with no attempt at all, e.g. when you only asked for an explanation, are left alone), tools ([] to never let the model start image generation by itself, [generate_image] to allow it when the image-generation toggle is on), temperature, think (force thinking mode on/off) and context ([workflow] sends a summary of the workflow loaded in GenerateUI - model files, prompt nodes, CFG, resolution - with every message; the prompt text currently in the workflow is added only when your message refers to it, e.g. \"improve the current prompt\", because small models otherwise copy it into new prompts; [current-prompt] always adds it); any reply containing a ```prompt block (positive:/negative: lines) gets \"Apply to GenerateUI\", \"Generate with this prompt\" and \"Copy\" buttons",
|
||||
helpAi11: "Image check (\"Check images\" toggle below the chat input): after an image is generated from the chat - by the model's generate_image tool or the \"Generate with this prompt\" button - the chat model turns your request into 3-7 yes/no requirements, and a vision decision model in Ollama (d3:4b recommended; set it under Settings > Chat Image Check) judges the image and shows each one as ✔ met / ? unclear / ✘ not met with its probability, plus an overall quality rating; \"Fix missing items and regenerate\" sends only the unmet items back to the chat model, which rewrites the prompt and generates again, and \"Automatic fix rounds\" (0-3) repeats this without clicking; with the SVG Icon Generator skill the SVG is rendered and checked part by part (from its \"Parts:\" line) the same way; decision models return calibrated probabilities instead of the chat model's own yes/no, which tends to claim things that are not in the image; the fourth built-in skill \"Image to Prompt\" writes a prompt that recreates an image attached in the TOOLS pane (needs a vision chat model)",
|
||||
helpAi12: "\"Workflow Doctor\" explains the workflow loaded in GenerateUI and diagnoses errors from facts Workflow Studio checks on every message - node types that are not installed, model files or options that are not in ComfyUI's lists (with the closest existing names), broken links, unconnected required inputs, the last execution error and the latest error block of the ComfyUI log - so answers rely on your install rather than on the model's possibly outdated knowledge; \"Storyboard to Lab\" turns a story into one prompt per scene with identical character descriptions and a fixed seed, and its \"Open in Lab\" button loads the plan into GenerateUI > Lab (T2I mode) where you check it and press Run; \"Import\" in the skill manager adds an Agent Skills folder (.zip containing SKILL.md, the format shared by many AI tools) or a single .md - only text files are kept and scripts are never run; a folder skill's reference files are listed to the model, which reads one with the read_skill_file tool only when needed (needs a tool-capable chat model); assistant replies are shown with basic Markdown formatting (lists, headings, code blocks)",
|
||||
helpAi13: "Choose \"OCR (extract text)\" in the TOOLS pane, drop an image and press Run to read its text with an OCR model installed in Ollama (the list shows models whose name contains \"ocr\", best first; LightOnOCR-3 is the default - on a mixed Japanese/English test page with a vertical column it read everything in about 1 s; PaddleOCR-VL-manga and DeepSeek-OCR also read it fully, while PaddleOCR-VL 1.6 and GLM-OCR missed the vertical text); the image is sent as a white-background JPEG with its long side at most 1600 px, and an empty or looping result is retried once in grayscale; \"Show text positions\" (LightOnOCR / DeepSeek-OCR only - the checkbox is disabled for other models) enlarges the preview and draws numbered boxes over the image; click a box to select its line in the result; the OCR model is separate from the chat model chosen in Settings",
|
||||
helpAiCardLayout: "Layout",
|
||||
helpAiCardTranslate: "Translation pane",
|
||||
helpAiCardChat: "Chat pane",
|
||||
@@ -1239,6 +1324,10 @@ const LANGUAGES = {
|
||||
helpAiCardSkillTips: "Writing your own skills",
|
||||
helpAiCardTools: "TOOLS pane",
|
||||
helpAiCardSettings: "Settings pane",
|
||||
helpAiCardSkillOptions: "Chat pane — Skill options & action buttons",
|
||||
helpAiCardSkillsMore: "Chat pane — Workflow Doctor, Storyboard & skill folders",
|
||||
helpAiCardImageCheck: "Chat pane — Image check",
|
||||
helpAiCardOcr: "TOOLS pane — OCR",
|
||||
helpJumpToTab: "Open tab →",
|
||||
helpAboutCardGettingStarted: "Getting Started",
|
||||
helpWfTitle: "Workflow Tab",
|
||||
@@ -1383,6 +1472,24 @@ const LANGUAGES = {
|
||||
helpImageEditGmicInstall2: "Extract the zip to any folder (e.g. C:\\tools\\gmic); no installer required",
|
||||
helpImageEditGmicInstall3: "Open Settings → G'MIC-Qt Executable Path, enter the full path to gmic_qt.exe (e.g. C:\\tools\\gmic\\gmic_qt.exe), then click Save",
|
||||
helpImageEditTabKbdTitle: "Image Edit Tab Shortcuts",
|
||||
imageEditPaletteColor1: "Color 1",
|
||||
imageEditPaletteColor2: "Color 2",
|
||||
imageEditPaletteBase: "Basic",
|
||||
imageEditPaletteCustom: "Custom",
|
||||
imageEditPaletteAddTitle: "Register the current color in the next empty custom slot",
|
||||
imageEditPaletteEmptyTitle: "Click to register the current color; right-click to choose a color",
|
||||
imageEditPaletteSwatchTitle: "Click to use; right-click to register or edit",
|
||||
imageEditPaletteRegisterCurrent: "Register current color",
|
||||
imageEditPaletteEditColor: "Edit color…",
|
||||
imageEditPalettePickColor: "Choose color to register…",
|
||||
imageEditPaletteDelete: "Delete",
|
||||
imageEditPaletteResetBase: "Restore default color",
|
||||
imageEditPaletteExists: "This color is already registered",
|
||||
imageEditPaletteFull: "All custom color slots are full. Right-click a slot to overwrite or delete it",
|
||||
imageEditNoAbrBrushes: "No brushes available. Import ABR brushes in Mask Editor One",
|
||||
helpImageEditDrawPalette: "Draw color palette (second row): Color 1/Color 2, 20 basic colors, and 20 custom slots. Click a color to paint with it. Use + or click an empty slot to register the current color; right-click a color to register, edit, or delete it (basic colors can be changed and restored). Registered colors are saved per browser. The eyedropper button next to Color picks a color from the canvas (Esc cancels)",
|
||||
imageEditPixelColorFail: "Could not read pixel color",
|
||||
helpImageEditDrawKbd: "While Draw is active: D Draw / E Erase / X switch Color 1/2 / [ ] brush size / B open or close the brush picker (Esc also closes it)",
|
||||
helpShortcutsTitle: "Keyboard Shortcuts",
|
||||
helpShortcuts1: "Escape: Close modal/panel",
|
||||
helpShortcuts2: "Enter: Confirm rename, send chat",
|
||||
@@ -2120,6 +2227,8 @@ const LANGUAGES = {
|
||||
|
||||
// -- GenerateUIタブ Inputサブタブ: Image内プレースホルダ --
|
||||
i2iPlaceholderTitle: "プレースホルダ画像",
|
||||
genSwitchesTitle: "スイッチ",
|
||||
genSwitchesNone: "このワークフローに true/false の項目はありません",
|
||||
i2iPlaceholderColor: "色",
|
||||
i2iPlaceholderImage: "画像",
|
||||
i2iPlaceholderWidth: "幅",
|
||||
@@ -2595,6 +2704,10 @@ const LANGUAGES = {
|
||||
tsPickSearch: (n) => `選択したタグで空間検索(${n})`,
|
||||
tsPickClear: "選択解除",
|
||||
tsFullPrompt: "プロンプト全文",
|
||||
tsAddPicked: (n) => `選択したタグを追加(${n})`,
|
||||
tsOpenInGallery: "Galleryで開く",
|
||||
tsRevealFailed: "この画像をGalleryで表示できませんでした(GalleryのOutputフォルダの外にあるか、画像が見つかりません)",
|
||||
tsChipPicked: (name, n) => `${name}(${n}タグ)`,
|
||||
tsListEmpty: "要素を選ぶか検索すると、近いカードやタグを一覧表示します。",
|
||||
tsBuilderTitle: "プロンプト",
|
||||
tsChipsEmpty: "空間のカードやタグをダブルクリックするか、「追加」・一覧の+で追加できます。",
|
||||
@@ -2764,6 +2877,72 @@ const LANGUAGES = {
|
||||
aiSkillFilenamePlaceholder: "filename.md",
|
||||
aiSkillContentPlaceholder: "---\nname: マイスキル\ndescription: このスキルの説明\n---\n\nLLMへのsystem prompt指示...",
|
||||
aiChatSaveAsSkill: "スキルとして保存",
|
||||
aiChatApplyPrompt: "GenerateUIに反映",
|
||||
aiChatApplyPromptTip: "このポジティブ/ネガティブプロンプトをGenerateUIで読み込み中のワークフローに書き込みます",
|
||||
aiChatPromptApplied: "GenerateUIにプロンプトを反映しました",
|
||||
aiChatGenerateWithPrompt: "このプロンプトで生成",
|
||||
aiChatCopyPrompt: "コピー",
|
||||
aiChatPromptCopied: "プロンプトをコピーしました",
|
||||
aiStatusAutoFixing: "自動チェックで問題が見つかったため、モデルに修正を依頼しています...",
|
||||
aiSkillBuiltin: "同梱",
|
||||
aiSkillBuiltinTip: "Workflow Studioに同梱のスキルです。編集していない同梱スキルは自動で更新されます。",
|
||||
aiSkillUpdateTip: "この同梱スキルの新しい版があります(編集済みのため自動更新していません)",
|
||||
aiChatAutoCheckRetry: (msg) => `自動チェック: ${msg} — モデルに修正を依頼しています`,
|
||||
aiChatAutoCheckStillFailing: (msg) => `自動チェック: 1回の再依頼でも解消しませんでした(${msg})。依頼内容を変えて再度お試しください。`,
|
||||
aiSkillUpdateConfirm: (name) => `「${name}」を同梱の最新版で置き換えますか?このスキルへの編集内容は失われます。`,
|
||||
aiSkillUpdated: (name) => `「${name}」を同梱の最新版に更新しました`,
|
||||
aiChatCheckToggle: "画像をチェック",
|
||||
aiChatCheckToggleTip: "生成画像とSVGを画像対応の意思決定モデルでチェックし、足りない点を表示します",
|
||||
aiSettingsCheckTitle: "CHAT 画像チェック",
|
||||
aiJudgeModelLabel: "判定モデル(Ollamaの意思決定モデル)",
|
||||
aiJudgeRefresh: "更新",
|
||||
aiCheckRoundsLabel: "自動修正の回数",
|
||||
aiCheckRoundsManual: "0(ボタンで修正)",
|
||||
aiJudgeNoVision: "画像入力に対応した意思決定モデルがOllamaにありません(例: d3:4b、clef-flash)。ollama pull で取得してから「更新」を押してください。",
|
||||
aiStatusChecking: "画像をチェック中...",
|
||||
aiChatCheckPassed: "すべての項目を満たしています",
|
||||
aiChatCheckRefineBtn: "足りない点を直して再生成",
|
||||
aiChatCheckSvgRefineBtn: "分かりにくい部分を描き直させる",
|
||||
aiChatCheckNoPrompt: "モデルが```promptブロックを返さなかったため、再生成を中止しました。",
|
||||
aiChatCheckQuality_poor: "低い",
|
||||
aiChatCheckQuality_acceptable: "普通",
|
||||
aiChatCheckQuality_excellent: "高い",
|
||||
aiJudgeHint: (url) => `${url} のOllamaにある画像対応の意思決定モデルです。d3:4b が最も速く、読み込み後は1回のチェックが約2秒です。`,
|
||||
aiJudgeListFailed: (msg) => `Ollamaのモデル一覧を取得できませんでした: ${msg}`,
|
||||
aiChatCheckFailedCount: (n) => `${n}項目が満たされていません`,
|
||||
aiChatCheckRound: (n) => `修正${n}回目`,
|
||||
aiChatCheckBy: (model) => `判定: ${model}`,
|
||||
aiChatCheckQuality: (label) => `全体の画質: ${label}`,
|
||||
aiChatCheckRefining: (items) => `次の点の修正をモデルに依頼しています: ${items}`,
|
||||
aiChatCheckError: (msg) => `画像チェックに失敗しました: ${msg}`,
|
||||
aiChatCheckAutoLimit: (n) => `自動修正を${n}回行いましたが満たせなかったため停止しました。依頼文やプロンプトを変えて再度お試しください。`,
|
||||
aiChatOpenInLabTip: "このストーリーボードを GenerateUI > Lab に未保存のプランとして読み込みます(1シーン=1イテレーション、T2Iモード)",
|
||||
aiChatLabPlanOpened: "ストーリーボードを Lab に読み込みました。確認して Run を押してください。",
|
||||
aiChatCopyLabPlan: "プランJSONをコピー",
|
||||
aiSkillImportText: "取り込み",
|
||||
aiSkillImportTip: "Agent Skills のフォルダ(SKILL.md を含む .zip)か .md 単体のスキルを取り込みます。テキストファイルだけを残し、スクリプトは実行しません。",
|
||||
aiChatOpenInLab: (n) => `Labで開く(${n}シーン)`,
|
||||
aiChatSkillFileRead: (path) => `スキルの資料を読み込みました: ${path}`,
|
||||
aiSkillFolderBadge: (n) => `フォルダ · ${n}ファイル`,
|
||||
aiSkillDeleteFolderConfirm: (name) => `スキルフォルダ「${name}」を中のファイルごと削除しますか?`,
|
||||
aiSkillImported: (name, skipped) => skipped ? `「${name}」を取り込みました(テキスト以外の${skipped}ファイルは除外)` : `「${name}」を取り込みました`,
|
||||
aiSkillImportFailed: (msg) => `取り込みに失敗しました: ${msg}`,
|
||||
aiVlmOcr: "文字起こし(OCR)",
|
||||
aiVlmToTranslation: "翻訳へ送る",
|
||||
aiVlmToChat: "Chatへ送る",
|
||||
aiOcrModelLabel: "OCRモデル",
|
||||
aiOcrShowBoxes: "文字の位置を表示",
|
||||
aiOcrRefresh: "Ollama のモデル一覧を読み直す",
|
||||
aiPaneCollapse: "たたむ",
|
||||
aiPaneExpand: "開く",
|
||||
aiOcrNoModel: "Ollama に OCR モデルがありません(例: ollama pull maternion/LightOnOCR-3:4b)。取得後に ↻ を押してください。",
|
||||
aiOcrNoBoxes: "このモデルは文字だけを返します(位置は表示できません)。",
|
||||
aiOcrEmpty: "文字が見つかりませんでした。",
|
||||
aiToastSentToTranslation: "翻訳ペインへ送りました",
|
||||
aiToastSentToChat: "Chat の入力欄に追加しました",
|
||||
aiOcrListFailed: (msg) => `Ollama のモデル一覧を取得できませんでした: ${msg}`,
|
||||
aiStatusOcrRunning: (model) => `${model} で文字を読み取り中...`,
|
||||
aiOcrDone: (sec, lines, gray) => `完了 ${sec}秒 · ${lines}行${gray ? " · グレースケールで再試行" : ""}`,
|
||||
|
||||
// -- Help & Support --
|
||||
tabHelp: "ヘルプ",
|
||||
@@ -2789,7 +2968,7 @@ const LANGUAGES = {
|
||||
helpGen4: "Inputタブ:Prompt(左)とImage(右、幅は約3:2)を同一画面に並べて表示(ドラッグ&ドロップ対応)。Prompt/Image領域の幅が約720px以下になるとImageがPromptの下へ折り返し、右端のRaw JSONは»ボタンで細い帯に折りたためる(Inputタブのみ・状態は記憶);PromptエリアにはPositive Prompt・Negative Promptに加え最下部にEmbeddingsセレクタを表示(Filter+セレクト+Weight入力+Pasteボタン)— Pasteは最後にフォーカスしたテキストエリアのカーソル位置に(embedding:Name:weight)を挿入(未フォーカス時はPositiveに追加);各Applyボタンの左隣の↺ボタンはテキストエリアをノードの現在の適用値に戻す;テキストを選択(または単語・括弧内にカーソルを置くだけでも可)してCtrl+↑/↓を押すと(text:weight)形式で強調weightを±0.05単位で調整(weightが1.0に戻ると括弧を自動解除)",
|
||||
helpGen5: "Modelタブ:Checkpoint/VAE/Diffusion Model(UNETLoader / UnetLoaderGGUF / LoaderGGUF対応)/Text Encoder(CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF対応 — typeおよびdevice選択付き、DualCLIPは2段モデル選択)は常時表示;ControlNetとHypernetwork(Strength入力付き)は使用頻度が低いためデフォルトで折りたたみ — 見出しをクリックで展開;LoRA(Single/Stackタブ)は右側に独立した列を表示;すべてのセレクタにフィルタ検索付き;LoRA Stack — Applyで有効な全LoRAを対象ノードに書き込み、同時にLoRA構文とCivitAIトリガーワードをPositiveプロンプトへ挿入;再Applyでは非アクティブなLoRAを削除し、アクティブなLoRAを再追加してプロンプトを更新",
|
||||
helpGen28: "Modelタブ — ハイライト色:読み込んだワークフローに実際に該当ノードが含まれる場合、各モデル種別(Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork)のラベルとLoRA列見出しが指定色(デフォルトは明るい緑)で表示される;ControlNet/Hypernetworkが折りたたまれた状態でも色は見えるため、展開しなくても使用有無が一目で分かる;色はSettingsタブでカスタマイズ可能",
|
||||
helpGen6: "Settingsタブ:KSamplerとLatent Imageを横並び(各50%幅)",
|
||||
helpGen6: "Settingsタブ:KSampler・Latent Image・スイッチの3列。スイッチ列にはワークフロー内の true/false の項目(プロンプト強化のオン/オフ、TextGenerateのthinkingなど)と、enable/disable・on/off・yes/no の2択項目(KSamplerAdvancedのadd_noise、DWPreprocessorのdetect_handなど)がノード名付きで並び、クリックで即座に切り替わる(Apply不要、Raw JSONにも反映)",
|
||||
helpGen31: "LTX-2.5とWan2.2の動画ワークフローも、専用のVideoタブだけでなく本タブに通常ワークフローとして読み込んだ場合に対応 — Positive/Negative PromptはLTX-2.5のLTXVDualCFGGuider / LTXVConditioningノードをたどって実際のプロンプト文を検出し、SettingsタブのLatent ImageはWan2.2のEmptyHunyuanLatentVideoとLTX-2.5のEmptyLTXVLatentVideoノードを認識(Length(フレーム数)欄も追加表示)して「No EmptyLatentImage node found」にならない;これらのワークフローからの生成もMiniMax H3の動画生成と同じ延長タイムアウトが適用される",
|
||||
helpGen7: "Raw JSONを各タブの右列に常時表示 — 直接編集してApplyでワークフロー即時反映;読み込んだワークフローにBypass/Muteノードがある場合、検索バー上部に一覧が表示される(Bypassは除外され配線が再接続される、Muteは除外されるが再接続はされない点はComfyUI本体と同じ);検索バーはヒット箇所をハイライト表示してスクロールするだけでエディタにカーソルを移動しないため、検索直後にキー入力してもJSONが誤って書き換わることはない — 編集する際はエディタ内をクリックしてから行う",
|
||||
helpGen8: "シード管理:ランダム / 固定 / 増加 / 減少 / Workflow(ワークフロー側のシードをそのまま使用)",
|
||||
@@ -2869,6 +3048,7 @@ const LANGUAGES = {
|
||||
helpLabPlan9: "保存されたプランには、保存時にGenerateUIで読み込まれていたワークフローのファイル名が記録されます(Plan JSON内のworkflow_filename)。プランを読み込むとPLANボックスに「Open Workflow」ボタンが表示され、クリックするとその記録された正確なワークフローファイルをGenerateUIへ読み込み直せます(現在読み込まれているワークフローを置き換えるため、確認が入ります)",
|
||||
helpLabPlan10: "右ペイン — 「Save index image to Output on Run」:チェックすると、Run実行が終わるたびにPlanファイル用と同じコンタクトシート形式のインデックス画像を作成し、ComfyUI本来のOutputフォルダへ Lab_index_<counter>_.png として直接保存します(通常の生成画像と同様に自動採番され、前回の実行結果を上書きしません)。保存後は他のLab生成画像と同様にEagle自動保存(設定タブで有効な場合)にも渡されます——これ専用のチェックボックスはなく、同じ設定タブのグローバルなトグルに従います。Plan Saveの動作とは独立しており、Plan Saveは常にこのチェックボックスに関係なく lab_plan/ 内のプランファイルの隣に自分専用のインデックス画像を書き込みます。この設定はプランに save_index_on_run として保存されます",
|
||||
helpLabPlan11: "全てのインデックス画像PNG(Plan Save/Save Asで作成されたものも、Save index image to Output on Runで作成されたものも)には、プラン全体(列・メモ・Batch数・画像連鎖/T2I設定・ワークフローファイル名・結果一覧)がPNGのwfm_lab_planチャンクとして直接埋め込まれています。そのためOutputフォルダ側のコピーは単体で完結しており、隣に対応する.jsonが一切存在しなくてもPlan Loadの読込元として使えます",
|
||||
helpLabPlan12: "物語から作る:AI TOOL > Chat の「Storyboard to Lab」スキルがシーンごとのプロンプトを書き、「Labで開く」ボタンでそのプランを未保存のプランとしてここに読み込む(T2Iモード、Batch=シーン数、シーンごとに Prompt 列のキーフレーム、プランの固定 seed は KSampler 列のキーフレーム1にセットされセルに「Seed …」と表示)。内容を確認して Run を押し、残す場合は Plan Save で保存する",
|
||||
helpLabCardResultsTitle: "Resultsサブタブ",
|
||||
helpLabResults1: "左ペインには実行に使用した元画像が表示されます",
|
||||
helpLabResults2: "中央ペインには生成順に最大9枚の画像が表示されます。サムネイルをクリックすると拡大表示されます",
|
||||
@@ -2967,6 +3147,14 @@ const LANGUAGES = {
|
||||
helpGallery28: "「PSD作成」一括操作ボタン:選択中の全画像を1つのレイヤー付きPSDファイルにまとめてダウンロードする — 各画像はファイル名を冠した独立レイヤーとなり、選択画像中の最大サイズに合わせたキャンバスに左上基準で配置される;最後に選択した画像が最前面になる 変換...一括ボタン(音声/動画を選択すると表示):音声ファイル(動画は音声トラックを抽出)をFLAC / MP3 / Opus / M4A / WAVへ変換して同じフォルダに別ファイルで保存します(ビットレート・サンプルレート・チャンネル指定可)。prompt/workflowタグはタグを持てる形式で引き継がれ(WAVは対象外)、お気に入り・タグ・メモ・グループの引き継ぎや、変換後の元ファイル削除も選べます。",
|
||||
helpGallery29: "「レイヤーとして編集」一括操作ボタン:Image Editタブに切り替え、選択中の全画像を一度に開き、それぞれ独立したレイヤーとして展開する(選択中に動画ファイルが含まれる場合はスキップされる);最後に選択した画像が最前面になる",
|
||||
helpGallery30: "PSDファイル:サムネイル・フルサイズプレビューは全レイヤーを合成した結果を表示する(非表示レイヤーは除外される);元のPSDファイル自体は変更されず、Gallery上のプレビューのみが合成済みの表示になる",
|
||||
helpGallery38: "3Dモデル:GLB・glTF・VRM・PLY(メッシュ/点群/ガウシアンスプラット)・SPZは「3D」バッジ付きで一覧に並び、内蔵ビューアで表示します(ドラッグで回転、右ドラッグで移動、ホイールでズーム、⟲で視点リセット、ダブルクリックで拡大)。サムネイルは初回表示時にブラウザが描画してサーバーに保存します(128MBを超えるファイルは開いたときに保存)。ComfyUIのSaveGLBが保存したワークフローは画像と同じように読み取れます。外部の.binやテクスチャを参照する.gltfは、それらの位置を変えないでください(移動・削除の対象は.gltf本体のみです)。3DファイルはImage Edit・GenerateUI・Tagger・Feederへは送れません。",
|
||||
gallery3dLoading: "3Dモデルを読み込み中...",
|
||||
gallery3dLoadFailed: "この3Dファイルは表示できません",
|
||||
gallery3dResetView: "視点をリセット",
|
||||
gallery3dNotImage: "3Dファイルはここへ送れません",
|
||||
gallery3dStatsPoints: (n) => `${n} 点`,
|
||||
gallery3dStatsMesh: (v, f) => `頂点 ${v} / 三角形 ${f}`,
|
||||
gallery3dStatsSplats: (n) => `${n} スプラット`,
|
||||
helpGallery12: "Metadataボタン(緑、詳細パネルのタブバー内):選択画像をMetadataタブで開いて埋め込みデータを詳細確認。画像サムネイルのAlt+クリックでも同じ操作が可能",
|
||||
helpGallery13: "Load GenUIボタン(青、詳細パネルのタブバー内):選択画像に埋め込まれたComfyUIワークフローをGenerateUIタブへ直接読み込んで切り替え。ワークフローが埋め込まれていない場合や非対応形式の場合は警告トーストを表示",
|
||||
helpGallery14: "Taggerボタン(紫、詳細パネルのアクション行):選択画像をTaggerタブのSingleモードで直接開き、即座にタグ付けを開始できる",
|
||||
@@ -2990,7 +3178,7 @@ const LANGUAGES = {
|
||||
helpTagSpaceCardUse: "タグを選んでプロンプトを作る",
|
||||
helpTagSpace1: "TagSpaceは、WFS内のデータをEmbeddingGemma 2のベクトル空間に置いて3Dで表示します。カード(ImagePromptの画像とそのプロンプト。サムネイルで表示)、タグ(ImagePromptのプロンプト・Gallery画像に埋め込まれたPositiveプロンプト・ワイルドカード・Tagger DB・Promptライブラリの文を分解した語。出典ごとに色分け)、Gallery点(索引済みのGallery画像の一部を薄い背景として表示。それぞれ自分のプロンプト由来のタグを持つ)の3種類です。近くにあるものほど意味が近くなります",
|
||||
helpTagSpace2: "必要なもの:OllamaとEmbeddingGemma 2(Gallery > セマンティック設定 > モデル設定)。Galleryのセマンティック索引をGeminiで作っている場合は、先にEmbeddingGemma 2で作り直してください",
|
||||
helpTagSpace10: "カード・Gallery画像のタグで空間検索:カードやGallery点を選ぶと、プロンプトのタグがボタンで表示されます(カードのプロンプト全文は「プロンプト全文」で表示。カードのタグは「作成 / 更新」後に表示されます)。タグをクリックして選び(もう一度で解除)、「選択したタグで空間検索」を押すと、選んだタグをプロンプトの順につないだ文で空間を検索します(検索欄に入力した場合と同じ)。選択は別の要素を選ぶまで保持されます",
|
||||
helpTagSpace10: "カード・Gallery画像のタグで空間検索:カードやGallery点を選ぶと、プロンプトのタグがボタンで表示されます(カードのプロンプト全文は「プロンプト全文」で表示。カードのタグは「作成 / 更新」後に表示されます)。タグをクリックして選び(もう一度で解除)、「選択したタグで空間検索」を押すと、選んだタグをプロンプトの順につないだ文で空間を検索します(検索欄に入力した場合と同じ)。選択は別の要素を選ぶまで保持されます。タグを選んでいる間は「追加」ボタンが「選択したタグを追加(N)」に変わり、選んだタグだけを(プロンプトの順で)1つのチップとして追加します。選んでいなければ、これまでどおり全体を追加します。Gallery点では「Galleryで開く」で、ギャラリータブのその画像のフォルダを開き、一覧でその画像を選んだ状態にします(検索などの絞り込みは解除されます)",
|
||||
helpTagSpace9: "タグの選び方:「全タグ」をオンにすると「タグ数」の代わりに候補のタグをすべて使います(上限2万件)。「最低出現回数」は、全出典を合わせてその回数未満しか使われていないタグを除きます(2にすると1回だけの表記ゆれや断片が消えます)。「ワイルドカード」はワイルドカードをタグの材料に含めるかどうかです。ワイルドカードにしか出てこない語がタグの種類の大半を占めるため(検証環境では4.76万種中約3.7万種)、オフにして全タグにすると約1.04万種(最低出現回数2なら約6,800種)になります。候補の一部だけを使ったときは、状態表示に候補数も出ます。新しいタグは一度だけ埋め込みます(新しいタグが多い初回の作成は数分長くかかります)",
|
||||
helpTagSpace3: "「作成 / 更新」:カードを集め、まだ索引にないカード画像をGalleryのセマンティック索引に追加し、索引済みのGallery画像に埋め込まれたPositiveプロンプトを読み取り(サンプラーのpositive入力から上流をたどり、negative側はたどらない。tagspace_prompts.jsonにキャッシュし、2回目以降は新しい画像・変更された画像だけを読む)、新しいタグをOllamaで埋め込み(tagspace_tags.npzにキャッシュ)、PCAで3Dに投影します。「タグ数」は残すタグの数で、出典ごとに「そのタグを使った文の数」で順位を付け、出典を順番に取るので、数の多いGalleryのプロンプトだけで埋まることはありません。「Gallery点数」は表示するGallery画像の数(0で表示なし)。プロンプトはカンマ(と | { } BREAK)でだけ区切り、スペースでは区切りません。5語を超える文章は除きます。画像とテキストはそれぞれ中心をそろえてから投影し、カードと対応するタグが重なるようにしています。初回はカードの準備に約2.5分、Gallery画像2.9万枚のプロンプト読み取りに約3分かかりました。キャッシュがあれば数秒です",
|
||||
helpTagSpace4: "操作:ドラッグで回転、右ドラッグで移動、ホイールでズーム。カーソルを合わせると名前・サムネイル・プロンプト/出典を表示します。チェックボックスでカード・サムネイル・タグ・Galleryの表示を切り替えます。出典のチェックボックス(ImagePrompt/Galleryのプロンプト/Tagger DB/Promptライブラリ/ワイルドカード)では、タグを出典ごとに表示・非表示にできます。出典のどれか1つがONならそのタグは表示され、色は表示中の出典のうち最初のものになります。非表示のタグは近くの要素・検索結果の一覧からも除かれます。選択は保存されます",
|
||||
@@ -3028,14 +3216,18 @@ const LANGUAGES = {
|
||||
helpSidepanel16: "A タブ(AI ツール):翻訳・Chat・TOOLS・設定のサブタブ。Ollama、LM Studio、Lemonade、Unsloth に対応;Chat は LLM とのマルチターン会話;TOOLS は VLM 画像解析とワイルドカード生成に対応;設定は SPA の AI タブと localStorage で共有される。Settings サブタブの「VRAM (Ollama)」→「Unload all now」で、ローカルホストのOllamaにロード中の全モデル(意思決定モデルを含む)をアンロードできます(設定タブ「VRAM調整」と同じ処理)",
|
||||
helpSidepanel17: "Send to Canvas:WorkflowタブのSend to CanvasまたはGalleryタブのCopy & Send Canvasをクリックするとワークフローをキャンバスへ直接送信(UI形式・API形式どちらも対応);ComfyUIのツールバーから開いていない場合はUI形式のみタイトルドラッグのフォールバック(タイトルが青くなり緑の●が表示される — ドラッグで読み込み)",
|
||||
helpAiTitle: "AI タブ(A)",
|
||||
helpAi1: "4ペインレイアウト — Translation | Chat | TOOLS | Settings — 全ペインが常時表示される",
|
||||
helpAi2: "翻訳ペイン:Ollama、LM Studio、Lemonade、または Unsloth を使って日本語・英語・中国語・カスタム言語(Free)間でテキストを翻訳;⇄ ボタンで言語とテキストを同時入替;選択は自動保存",
|
||||
helpAi1: "4ペインレイアウト — Translation | Chat | TOOLS | Settings;Translation と Settings のペインは見出しの矢印(◀ / ▶)で細い帯にたたむことができ、空いた幅を Chat と TOOLS が使う。帯のどこをクリックしても開く。たたんだ状態は保存される",
|
||||
helpAi2: "翻訳ペイン:Ollama、LM Studio、Lemonade、または Unsloth を使って日本語・英語・中国語・カスタム言語(Free)間でテキストを翻訳;⇄ ボタンで言語とテキストを同時入替;選択は自動保存;TOOLS ペインの結果(OCR で読み取った文字など)は「翻訳へ送る」でこの入力欄に入る(たたんでいれば自動で開く)",
|
||||
helpAi3: "Chat ペイン:LLM とのマルチターン会話;Enter で送信、Shift+Enter で改行;Clear ボタンで会話履歴をリセット;モデルがtool callingに対応している場合、「猫の画像を作って」のように頼むとGenerateUIに読み込み中のワークフローで生成を実行し、結果をチャット内に表示する;ペインヘッダーの「画像生成を許可する」チェックボックスでこの機能のON/OFFを切り替えられる;画像が添付されている場合(TOOLSペインにドロップした画像)はLLMにもビジョン入力として送信され、generate_image呼び出し時はT2Iの代わりにI2I(image-to-image)の元画像として使われる;generate_image呼び出しにはsteps・cfg・sampler_name・scheduler・denoise・seed、width・height、batch_size(その1回の呼び出しで生成する枚数)、aspect_ratio(\"3:2\"のような比率プリセット。ワークフローにwidth/heightを供給するResolution Selectorノードがある場合のみ適用され、それ以外は無視される)といった任意の生成パラメータも指定できる;モデルが指定しなかった項目は読み込み中のワークフローの現在の設定がそのまま使われる;tool callingとビジョンの動作を確認済みのモデル: Gemma 4:e4b、Qwen 3.5:9b、Ministral 3:3b(Ollama)",
|
||||
helpAi4: "TOOLS ペイン — VLM:画像(PNG/JPG/WebP)をドロップしてタスク(画像解説 / プロンプト作成 / タグ作成)を選んで Run;または「Create wildcards」を選んでカテゴリ名と件数を入力すると1行1エントリのワイルドカードを生成;ドロップした画像はChatペインの添付としても共有され(Chat側にも表示、I2I生成に使用)、✕ボタンでクリアするまで保持される",
|
||||
helpAi6: "Settings ペイン:バックエンド(Ollama / LM Studio / Lemonade / Unsloth)の選択、APIのURL設定、接続テスト、モデル選択、および「Test connection」の右隣にある「Unload Model」ボタンでロード中のモデルをVRAM/メモリから解放——Ollamaは意思決定モデルを含むロード中の全Ollamaモデルをアンロード(設定タブ「VRAM調整」の「今すぐすべてアンロード」と同じ処理。Prompt/Taggerの設定ボタンも同様)、LM Studioは /api/v1/models/unload、Lemonadeは /api/v1/unload を使用;Unslothにはアンロード用APIが存在しないため未対応メッセージが表示される;UnslothはAPIキーかKeyless API accessのどちらかが必要——プラグインフォルダの .env.example を .env にコピーして UNSLOTH_API_KEY を設定し ComfyUI を再起動する(キーはサーバー側でのみ読み込まれ、ブラウザには一切送信されない)か、キーを未設定のままUnsloth DesktopのSettings → APIで Keyless API access →「Chat and inference」をオンにする(キー無しで送信される。この設定は同じLAN内にもAPIを開放するので注意し、「Allow tools」はオフのままにする);Unsloth Desktopアプリ側の設定画面で「モデル自動切り替え (OpenAI API)」もONにする必要がある——OFFのままだと、Unsloth Studio上で事前にモデルを手動ロードしていない限り HTTP 404 で接続に失敗する;Unslothのreasoningモデルは思考内容をインラインの<think>タグではなく別のreasoning_contentフィールドで返すため、Max tokensが少ないとモデルが思考だけでトークンを使い切り、空の返信になることがある——返信が空になる場合はMax tokensを増やす(2048以上を推奨);Generationセクション — Thinking modeトグル(デフォルトOFF;OllamaにはthinkフラグとしてOFF/ONを送信、バックエンドを問わずOFF時は出力中の<think>...</think>ブロックをクライアント側で除去するため、推論モデルの思考過程がTranslation/Chat/TOOLSの結果に混入しない)とMax tokens(Ollama: num_predict、LM Studio/Lemonade/Unsloth: max_tokens;0で無制限);Free言語名の設定;Chat Image Generationセクションでは、ChatからのT2I画像生成にGenerateUIの現在の読み込みワークフローの代わりに専用ワークフローを使うよう設定できる;Chat I2I Generationセクションは同様の設定を画像添付時のI2I生成向けに提供する;設定は localStorage に保存され、Library パネルの A タブと共有される",
|
||||
helpAi4: "TOOLS ペイン — VLM:画像(PNG/JPG/WebP)をドロップしてタスク(画像解説 / プロンプト作成 / タグ作成 / 文字起こし(OCR))を選んで実行;または「Create wildcards」を選んでカテゴリ名と件数を入力すると1行1エントリのワイルドカードを生成;ドロップした画像はChatペインの添付としても共有され(Chat側にも表示、I2I生成に使用)、✕ボタンでクリアするまで保持される;結果欄の下の「翻訳へ送る」「Chatへ送る」で結果を各ペインへ渡し、「コピー」でクリップボードへコピーできる",
|
||||
helpAi6: "Settings ペイン:バックエンド(Ollama / LM Studio / Lemonade / Unsloth)の選択、APIのURL設定、接続テスト、モデル選択、および「Test connection」の右隣にある「Unload Model」ボタンでロード中のモデルをVRAM/メモリから解放——Ollamaは意思決定モデルを含むロード中の全Ollamaモデルをアンロード(設定タブ「VRAM調整」の「今すぐすべてアンロード」と同じ処理。Prompt/Taggerの設定ボタンも同様)、LM Studioは /api/v1/models/unload、Lemonadeは /api/v1/unload を使用;Unslothにはアンロード用APIが存在しないため未対応メッセージが表示される;UnslothはAPIキーかKeyless API accessのどちらかが必要——プラグインフォルダの .env.example を .env にコピーして UNSLOTH_API_KEY を設定し ComfyUI を再起動する(キーはサーバー側でのみ読み込まれ、ブラウザには一切送信されない)か、キーを未設定のままUnsloth DesktopのSettings → APIで Keyless API access →「Chat and inference」をオンにする(キー無しで送信される。この設定は同じLAN内にもAPIを開放するので注意し、「Allow tools」はオフのままにする);Unsloth Desktopアプリ側の設定画面で「モデル自動切り替え (OpenAI API)」もONにする必要がある——OFFのままだと、Unsloth Studio上で事前にモデルを手動ロードしていない限り HTTP 404 で接続に失敗する;Unslothのreasoningモデルは思考内容をインラインの<think>タグではなく別のreasoning_contentフィールドで返すため、Max tokensが少ないとモデルが思考だけでトークンを使い切り、空の返信になることがある——返信が空になる場合はMax tokensを増やす(2048以上を推奨);Generationセクション — Thinking modeトグル(デフォルトOFF;OllamaにはthinkフラグとしてOFF/ONを送信、バックエンドを問わずOFF時は出力中の<think>...</think>ブロックをクライアント側で除去するため、推論モデルの思考過程がTranslation/Chat/TOOLSの結果に混入しない)とMax tokens(Ollama: num_predict、LM Studio/Lemonade/Unsloth: max_tokens;0で無制限);Free言語名の設定;Chat Image Generationセクションでは、ChatからのT2I画像生成にGenerateUIの現在の読み込みワークフローの代わりに専用ワークフローを使うよう設定できる;Chat I2I Generationセクションは同様の設定を画像添付時のI2I生成向けに提供する;設定は localStorage に保存され、Library パネルの A タブと共有される;CHAT 画像チェックセクションでは、チャットの「画像をチェック」で使う判定モデル(画像入力に対応した Ollama の意思決定モデル。例: d3:4b)と、自動修正の回数(0 ならボタンを押したときだけ修正)を設定する",
|
||||
helpAi7: "Chat ペイン — SVG:LLMはComfyUIワークフローを一切使わず、SVGグラフィックをテキストとして直接生成することもできる;「SVGで〜を描いて」のように頼み、応答にSVGコードが含まれていれば自動でインラインプレビュー表示され、「Galleryに保存」ボタンでGalleryタブの出力フォルダに.svgファイルとして保存できる;tool calling(function calling)を使用しないため、tool calling非対応のモデルでも動作する",
|
||||
helpAi8: "Chat ペイン — スキル:メッセージ一覧上部のドロップダウンでスキルを選ぶと、その指示内容が system prompt として会話の先頭に付加される(表示上のチャット履歴には含まれない);鉛筆ボタンでスキル管理パネルを開き、.mdスキルファイルを作成・編集・削除できる(保存先: user/default/Workflow-Studio/ai_skills/);ファイル先頭の---で囲まれた部分にname/descriptionを書くとドロップダウンの表示に反映される;初期状態で2つのスキルを同梱: 「SVG Icon Generator」(描く前に対象を部位分解することで認識しやすいSVGを生成)と「Skill Creator」(対話形式で質問しながら新しいスキルファイルを下書きする);応答に```skillコードブロックが含まれる場合、メッセージ下部に「スキルとして保存」ボタンが表示され、内容が入った状態で管理パネルが開く",
|
||||
helpAi8: "Chat ペイン — スキル:メッセージ一覧上部のドロップダウンでスキルを選ぶと、その指示内容が system prompt として会話の先頭に付加される(表示上のチャット履歴には含まれない);鉛筆ボタンでスキル管理パネルを開き、スキルの作成・編集・削除・取り込みができる(保存先: user/default/Workflow-Studio/ai_skills/);ファイル先頭の---で囲まれた部分の name/description がドロップダウンの表示になる;同梱スキルは6つ(管理パネルに「同梱」と表示): 「T2I Prompt Enhancer」(短いアイデアを、GenerateUI のワークフローのモデルに合った書き方のプロンプトに書き換える。Qwen-Image/FLUX/Z-Image は自然文、Illustrious/Pony は Danbooru タグ)、「Image to Prompt」(TOOLS ペインに添付した画像を再現するプロンプトを書く。画像対応のチャットモデルが必要)、「Storyboard to Lab」と「Workflow Doctor」(下のカードを参照)、「SVG Icon Generator」(対象を部位に分解してから描く。line/flat/duotone/mono のスタイル指定に対応)、「Skill Creator」(対話で新しいスキルを下書きする。既存スキルの改善にも対応);編集していない同梱スキルは Workflow Studio の更新時に自動で新しい版になり、編集済みのものには新しい版で置き換える ↻ ボタンが表示される;応答に```skillコードブロックがあると「スキルとして保存」ボタンが表示され、内容が入った状態で管理パネルが開く",
|
||||
helpAi9: "スキル作成のヒント:ドロップダウンやツールチップで分かりやすいよう、frontmatterのname/descriptionは明確に書く;期待する出力形式を具体的に指示し、SVGや別のスキルファイルのように後で抽出したい成果物がある場合は専用のコードフェンスで囲むよう指示する;曖昧な指示より具体的で命令形の指示の方がローカルの小型モデルには効きやすく、短い実例を1つ添えるとさらに効果的;禁止事項(不適切な内容、セキュリティ、文字数制限など)は暗黙に期待せず明示的に書く;迷ったらドロップダウンで「Skill Creator」スキルを選び、作りたいスキルの概要を伝えれば対話形式で下書きしてくれる",
|
||||
helpAi10: "スキルのfrontmatterの metadata: に次を書ける — output(prompt または svg。応答を自動チェックし、出力しようとして壊れている場合=SVGが閉じていない・不正・禁止要素あり、プロンプト行が```promptブロックの外にある場合は、モデルに1回だけ修正を依頼する。点線の通知として表示。説明だけを頼んだときなど、出力そのものが無い応答はそのままにする)、tools([] ならモデルが自分で画像生成を始めない、[generate_image] なら画像生成トグルがオンのとき許可)、temperature、think(思考モードを強制オン/オフ)、context([workflow] でGenerateUIのワークフロー要約=モデルファイル・プロンプトノード・CFG・解像度を毎回送る。ワークフローに今入っているプロンプト文は、「今のプロンプトを改善して」のように発言で触れたときだけ加える(小型モデルが新しいプロンプトへ混ぜ込むのを防ぐため)。[current-prompt] を指定すると常に加える);```promptブロック(positive:/negative:の行)を含む応答には「GenerateUIに反映」「このプロンプトで生成」「コピー」ボタンが付く",
|
||||
helpAi11: "画像チェック(チャット入力欄の下の「画像をチェック」):チャットから画像を生成すると(モデルのgenerate_imageツール、または「このプロンプトで生成」ボタン)、チャットモデルが依頼文を3〜7個の「はい/いいえ」の条件に分解し、Ollamaの画像対応の意思決定モデル(推奨 d3:4b。設定 > CHAT 画像チェックで選択)が画像を判定して、各条件を ✔満たす/?不明確/✘満たさない と確率付きで表示する(全体の画質も表示);「足りない点を直して再生成」を押すと、満たさなかった項目だけをチャットモデルに伝えてプロンプトを書き直させ、再生成する。「自動修正の回数」(0〜3)を設定すると、ボタンを押さずに繰り返す;SVG Icon Generatorスキルでは、SVGを画像にして「Parts:」行の部位ごとに同様にチェックする;意思決定モデルは確率を返すため、画像に無いものを「ある」と答えがちなチャットモデル自身の判定より信頼できる;4つ目の同梱スキル「Image to Prompt」は、TOOLSペインに添付した画像を再現するプロンプトを書く(画像対応のチャットモデルが必要)",
|
||||
helpAi12: "「Workflow Doctor」は GenerateUI で読み込み中のワークフローを説明し、エラーの原因を診断する。毎回の送信時に Workflow Studio が確認した事実(未インストールのノード、ComfyUI の一覧に無いモデルファイルや選択肢と似た名前の候補、切れた接続、つながっていない必須入力、直近の実行エラー、ComfyUI ログの最新のエラー部分)を渡すので、モデルの古いかもしれない知識ではなく実際の環境に基づいて答える;「Storyboard to Lab」は物語をシーンごとのプロンプトに分け、キャラクターの描写を全シーンで同一にし、seed を固定する。「Labで開く」ボタンで GenerateUI > Lab(T2Iモード)にプランを読み込み、確認して Run を押す;スキル管理パネルの「取り込み」で Agent Skills のフォルダ(SKILL.md を含む .zip。多くのAIツールで共通の形式)か .md 単体を追加できる。テキストファイルだけを残し、スクリプトは実行しない;フォルダ形式スキルの参照ファイルは一覧だけをモデルに伝え、必要なときだけ read_skill_file ツールで読ませる(ツール対応のチャットモデルが必要);アシスタントの応答は簡単な Markdown(箇条書き・見出し・コードブロック)で整形して表示する",
|
||||
helpAi13: "TOOLS ペインで「文字起こし(OCR)」を選び、画像を入れて実行すると、Ollama にある OCR モデルで画像の文字を読み取る(名前に「ocr」を含むモデルを、向いている順に一覧表示。既定は LightOnOCR-3 で、縦書きを含む日英混在のテスト画像を約1秒ですべて読めた。PaddleOCR-VL-manga と DeepSeek-OCR も全文を読めたが、PaddleOCR-VL 1.6 と GLM-OCR は縦書きを読み落とした);画像は長辺1600px以下・白背景の JPEG にして送り、結果が空・同じ行の繰り返しのときはグレースケールで1回だけ読み直す;「文字の位置を表示」(LightOnOCR / DeepSeek-OCR のみ。他のモデルではチェックボックスが無効)をオンにすると、プレビューを大きくして番号付きの枠を重ねて表示し、枠をクリックすると結果の該当行を選択する;OCR モデルは Settings で選ぶチャット用モデルとは別に選ぶ",
|
||||
helpAiCardLayout: "レイアウト",
|
||||
helpAiCardTranslate: "Translationペイン",
|
||||
helpAiCardChat: "Chatペイン",
|
||||
@@ -3044,6 +3236,10 @@ const LANGUAGES = {
|
||||
helpAiCardSkillTips: "スキル作成のヒント",
|
||||
helpAiCardTools: "TOOLSペイン",
|
||||
helpAiCardSettings: "Settingsペイン",
|
||||
helpAiCardSkillOptions: "Chatペイン — スキルの設定と操作ボタン",
|
||||
helpAiCardSkillsMore: "Chatペイン — Workflow Doctor・Storyboard・スキルフォルダ",
|
||||
helpAiCardImageCheck: "Chatペイン — 画像チェック",
|
||||
helpAiCardOcr: "TOOLSペイン — 文字起こし(OCR)",
|
||||
helpJumpToTab: "タブを開く →",
|
||||
helpAboutCardGettingStarted: "はじめに",
|
||||
helpWfTitle: "ワークフロータブ",
|
||||
@@ -3188,6 +3384,24 @@ const LANGUAGES = {
|
||||
helpImageEditGmicInstall2: "zipを任意のフォルダに解凍する(例: C:\\tools\\gmic);インストーラー不要",
|
||||
helpImageEditGmicInstall3: "設定タブ → G'MIC-Qt Executable Path に gmic_qt.exe のフルパスを入力(例: C:\\tools\\gmic\\gmic_qt.exe)して Save をクリック",
|
||||
helpImageEditTabKbdTitle: "Image Edit タブのショートカット",
|
||||
imageEditPaletteColor1: "色1",
|
||||
imageEditPaletteColor2: "色2",
|
||||
imageEditPaletteBase: "基本色",
|
||||
imageEditPaletteCustom: "カスタム",
|
||||
imageEditPaletteAddTitle: "現在の色をカスタム色の空き枠に登録します",
|
||||
imageEditPaletteEmptyTitle: "クリックで現在の色を登録・右クリックで色を指定",
|
||||
imageEditPaletteSwatchTitle: "クリックで使用・右クリックで登録/編集",
|
||||
imageEditPaletteRegisterCurrent: "現在の色を登録",
|
||||
imageEditPaletteEditColor: "色を編集…",
|
||||
imageEditPalettePickColor: "色を指定して登録…",
|
||||
imageEditPaletteDelete: "削除",
|
||||
imageEditPaletteResetBase: "初期の色に戻す",
|
||||
imageEditPaletteExists: "この色は登録済みです",
|
||||
imageEditPaletteFull: "カスタム色の枠がいっぱいです。右クリックで上書きするか削除してください",
|
||||
imageEditNoAbrBrushes: "ブラシがありません。Mask Editor OneでABRブラシをインポートしてください",
|
||||
helpImageEditDrawPalette: "Drawのカラーパレット(2段目):色1/色2、基本色20色、カスタム色20枠。色をクリックするとその色で描けます。「+」か空き枠のクリックで現在の色を登録し、色の上で右クリックすると登録・編集・削除ができます(基本色も変更でき、初期の色に戻せます)。登録した色はブラウザごとに保存されます。Colorの隣のスポイトボタンでキャンバスから色を拾えます(Escで取り消し)",
|
||||
imageEditPixelColorFail: "ピクセルの色を取得できませんでした",
|
||||
helpImageEditDrawKbd: "Drawツール中:D Draw / E Erase / X 色1・色2の切り替え / [ ] ブラシサイズ / B ブラシ選択の開閉(Escでも閉じます)",
|
||||
helpShortcutsTitle: "キーボード操作",
|
||||
helpShortcuts1: "Escape:モーダル・パネルを閉じる",
|
||||
helpShortcuts2: "Enter:名前変更の確定、チャット送信",
|
||||
@@ -3925,6 +4139,8 @@ const LANGUAGES = {
|
||||
|
||||
// -- GenerateUI标签页 Input子标签: Image内占位图 --
|
||||
i2iPlaceholderTitle: "占位图片",
|
||||
genSwitchesTitle: "开关",
|
||||
genSwitchesNone: "此工作流中没有 true/false 项",
|
||||
i2iPlaceholderColor: "颜色",
|
||||
i2iPlaceholderImage: "图片",
|
||||
i2iPlaceholderWidth: "宽度",
|
||||
@@ -4400,6 +4616,10 @@ const LANGUAGES = {
|
||||
tsPickSearch: (n) => `用所选标签搜索空间(${n})`,
|
||||
tsPickClear: "取消选择",
|
||||
tsFullPrompt: "完整提示词",
|
||||
tsAddPicked: (n) => `添加所选标签(${n})`,
|
||||
tsOpenInGallery: "在Gallery中打开",
|
||||
tsRevealFailed: "无法在Gallery中显示该图像(位于Gallery的Output文件夹之外,或图像不存在)",
|
||||
tsChipPicked: (name, n) => `${name}(${n}个标签)`,
|
||||
tsListEmpty: "选择元素或搜索后,会列出附近的卡片和标签。",
|
||||
tsBuilderTitle: "提示词",
|
||||
tsChipsEmpty: "双击空间中的卡片或标签,或使用“添加”·列表中的+即可添加。",
|
||||
@@ -4569,6 +4789,72 @@ const LANGUAGES = {
|
||||
aiSkillFilenamePlaceholder: "filename.md",
|
||||
aiSkillContentPlaceholder: "---\nname: 我的技能\ndescription: 该技能的说明\n---\n\n给 LLM 的 system prompt 指令...",
|
||||
aiChatSaveAsSkill: "保存为技能",
|
||||
aiChatApplyPrompt: "应用到 GenerateUI",
|
||||
aiChatApplyPromptTip: "将此正向/反向提示词写入 GenerateUI 中已加载的工作流",
|
||||
aiChatPromptApplied: "已将提示词应用到 GenerateUI",
|
||||
aiChatGenerateWithPrompt: "用此提示词生成",
|
||||
aiChatCopyPrompt: "复制",
|
||||
aiChatPromptCopied: "已复制提示词",
|
||||
aiStatusAutoFixing: "自动检查发现问题,正在请模型修正...",
|
||||
aiSkillBuiltin: "内置",
|
||||
aiSkillBuiltinTip: "随 Workflow Studio 附带的技能。未编辑的内置技能会自动更新。",
|
||||
aiSkillUpdateTip: "此内置技能有新版本(因已编辑,未自动更新)",
|
||||
aiChatAutoCheckRetry: (msg) => `自动检查:${msg} — 正在请模型修正`,
|
||||
aiChatAutoCheckStillFailing: (msg) => `自动检查:重试一次后仍未通过(${msg})。请修改请求后重试。`,
|
||||
aiSkillUpdateConfirm: (name) => `要用最新的内置版本替换「${name}」吗?对此技能的编辑将会丢失。`,
|
||||
aiSkillUpdated: (name) => `已将「${name}」更新为最新的内置版本`,
|
||||
aiChatCheckToggle: "检查图像",
|
||||
aiChatCheckToggleTip: "使用支持图像的决策模型检查生成的图像和 SVG,并显示缺少的内容",
|
||||
aiSettingsCheckTitle: "Chat 图像检查",
|
||||
aiJudgeModelLabel: "判定模型(Ollama 决策模型)",
|
||||
aiJudgeRefresh: "刷新",
|
||||
aiCheckRoundsLabel: "自动修正次数",
|
||||
aiCheckRoundsManual: "0(通过按钮修正)",
|
||||
aiJudgeNoVision: "Ollama 中没有支持图像输入的决策模型(例如 d3:4b、clef-flash)。请用 ollama pull 获取后点击「刷新」。",
|
||||
aiStatusChecking: "正在检查图像...",
|
||||
aiChatCheckPassed: "所有项目均已满足",
|
||||
aiChatCheckRefineBtn: "修正缺少的内容并重新生成",
|
||||
aiChatCheckSvgRefineBtn: "请模型重绘不清楚的部分",
|
||||
aiChatCheckNoPrompt: "模型未返回```prompt代码块,已停止重新生成。",
|
||||
aiChatCheckQuality_poor: "低",
|
||||
aiChatCheckQuality_acceptable: "一般",
|
||||
aiChatCheckQuality_excellent: "高",
|
||||
aiJudgeHint: (url) => `来自 ${url} 上 Ollama 的支持图像的决策模型。d3:4b 最快(加载后每次检查约 2 秒)。`,
|
||||
aiJudgeListFailed: (msg) => `无法获取 Ollama 模型列表: ${msg}`,
|
||||
aiChatCheckFailedCount: (n) => `${n} 项未满足`,
|
||||
aiChatCheckRound: (n) => `第 ${n} 次修正`,
|
||||
aiChatCheckBy: (model) => `判定: ${model}`,
|
||||
aiChatCheckQuality: (label) => `整体画质: ${label}`,
|
||||
aiChatCheckRefining: (items) => `正在请模型修正: ${items}`,
|
||||
aiChatCheckError: (msg) => `图像检查失败: ${msg}`,
|
||||
aiChatCheckAutoLimit: (n) => `已自动修正 ${n} 次仍未满足,已停止。请修改请求或提示词后重试。`,
|
||||
aiChatOpenInLabTip: "将此故事板作为未保存的计划载入 GenerateUI > Lab(每个场景一次迭代,T2I 模式)",
|
||||
aiChatLabPlanOpened: "故事板已载入 Lab。请检查后点击 Run。",
|
||||
aiChatCopyLabPlan: "复制计划 JSON",
|
||||
aiSkillImportText: "导入",
|
||||
aiSkillImportTip: "导入 Agent Skills 文件夹(包含 SKILL.md 的 .zip)或单个 .md 技能。只保留文本文件,绝不运行脚本。",
|
||||
aiChatOpenInLab: (n) => `在 Lab 中打开(${n} 个场景)`,
|
||||
aiChatSkillFileRead: (path) => `已读取技能资料: ${path}`,
|
||||
aiSkillFolderBadge: (n) => `文件夹 · ${n} 个文件`,
|
||||
aiSkillDeleteFolderConfirm: (name) => `要删除技能文件夹「${name}」及其中所有文件吗?`,
|
||||
aiSkillImported: (name, skipped) => skipped ? `已导入「${name}」(已排除 ${skipped} 个非文本文件)` : `已导入「${name}」`,
|
||||
aiSkillImportFailed: (msg) => `导入失败: ${msg}`,
|
||||
aiVlmOcr: "文字识别(OCR)",
|
||||
aiVlmToTranslation: "发送到翻译",
|
||||
aiVlmToChat: "发送到 Chat",
|
||||
aiOcrModelLabel: "OCR 模型",
|
||||
aiOcrShowBoxes: "显示文字位置",
|
||||
aiOcrRefresh: "重新读取 Ollama 模型列表",
|
||||
aiPaneCollapse: "折叠",
|
||||
aiPaneExpand: "展开",
|
||||
aiOcrNoModel: "Ollama 中没有 OCR 模型(例如 ollama pull maternion/LightOnOCR-3:4b),获取后请点击 ↻。",
|
||||
aiOcrNoBoxes: "此模型只返回文字(无法显示位置)。",
|
||||
aiOcrEmpty: "未找到文字。",
|
||||
aiToastSentToTranslation: "已发送到翻译面板",
|
||||
aiToastSentToChat: "已添加到 Chat 输入框",
|
||||
aiOcrListFailed: (msg) => `无法获取 Ollama 模型列表: ${msg}`,
|
||||
aiStatusOcrRunning: (model) => `正在用 ${model} 识别文字...`,
|
||||
aiOcrDone: (sec, lines, gray) => `完成 ${sec} 秒 · ${lines} 行${gray ? " · 已用灰度重试" : ""}`,
|
||||
|
||||
// -- Help & Support --
|
||||
tabHelp: "帮助",
|
||||
@@ -4594,7 +4880,7 @@ const LANGUAGES = {
|
||||
helpGen4: "Input标签:Prompt(左)与Image(右,宽度约3:2)并排显示在同一画面(支持拖放上传);当Prompt/Image区域宽度约720px以下时,Image会折行到Prompt下方,右侧的Raw JSON可通过»按钮折叠为细条(仅Input标签,状态会被记住);Prompt区域显示Positive Prompt、Negative Prompt,以及底部的Embeddings选择器(过滤+下拉+权重+Paste按钮)— Paste将(embedding:Name:weight)插入最后聚焦文本框的光标位置(未聚焦时默认追加到Positive);每个Apply按钮左侧的↺按钮可将文本框恢复为节点当前已应用的值;选中文字(或将光标置于单词/括号内也可)后按Ctrl+↑/↓,即可以(text:weight)语法以±0.05为单位调整强调权重(权重恢复为1.0时自动去除括号)",
|
||||
helpGen5: "Model标签:Checkpoint/VAE/Diffusion Model(支持UNETLoader / UnetLoaderGGUF / LoaderGGUF)/Text Encoder(支持CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — 含type和device选择器,DualCLIP显示两段模型选择)始终显示;ControlNet和Hypernetwork(带Strength输入)因使用频率较低默认折叠 — 点击标题展开;LoRA(Single/Stack标签)拥有独立的列;所有选择器均含筛选;LoRA Stack — 点击Apply会将所有启用的LoRA写入目标节点,并同时将LoRA语法和CivitAI触发词插入Positive提示词;再次Apply会移除未启用的LoRA并重新添加启用的LoRA以更新提示词",
|
||||
helpGen28: "Model标签 — 高亮颜色:当前工作流实际包含某类型节点时(Checkpoint/VAE/Diffusion Model/Text Encoder/ControlNet/Hypernetwork),对应标签及LoRA列标题会以指定颜色(默认亮绿色)显示;即使ControlNet/Hypernetwork处于折叠状态该颜色依然可见,无需展开即可判断是否在使用;颜色可在Settings标签中自定义",
|
||||
helpGen6: "Settings标签:KSampler与Latent Image并排显示(各占50%宽度)",
|
||||
helpGen6: "Settings标签:KSampler、Latent Image与开关三列并排。开关列列出工作流中所有 true/false 项(如提示词增强开关、TextGenerate 的 thinking)以及 enable/disable、on/off、yes/no 二选一项(如 KSamplerAdvanced 的 add_noise、DWPreprocessor 的 detect_hand)并显示节点名,点击即可立即切换(无需 Apply,Raw JSON 同步更新)",
|
||||
helpGen31: "LTX-2.5和Wan2.2视频工作流在本标签中作为普通工作流加载时(不仅限于专用的Video标签)也可正常使用 — Positive/Negative Prompt会沿LTX-2.5的LTXVDualCFGGuider / LTXVConditioning节点追溯找到实际提示词文本,Settings标签的Latent Image部分能识别Wan2.2的EmptyHunyuanLatentVideo和LTX-2.5的EmptyLTXVLatentVideo节点(并新增Length(帧数)字段),不再显示\"No EmptyLatentImage node found\";从这些工作流生成时也会应用与MiniMax H3视频生成相同的延长超时",
|
||||
helpGen7: "Raw JSON在每个标签右列常驻显示 — 直接编辑并Apply即时重载工作流;如果加载的工作流包含Bypass或Mute节点,搜索栏上方会显示列表(Bypass节点会被排除但其连线会重新连接;Mute节点会被排除且不重新连接,与ComfyUI本体行为一致);搜索栏只会高亮匹配项并滚动到该位置,不会将光标移入编辑器,因此搜索后立即按键不会意外覆盖JSON — 需要先点击编辑器内部才能实际编辑",
|
||||
helpGen8: "种子管理:随机/固定/递增/递减/Workflow(直接使用工作流自带的种子)",
|
||||
@@ -4674,6 +4960,7 @@ const LANGUAGES = {
|
||||
helpLabPlan9: "每个已保存的方案都会记录保存时GenerateUI中加载的工作流文件名(Plan JSON中的workflow_filename)。加载方案后,PLAN框中会出现「Open Workflow」按钮——点击即可将该确切的工作流文件重新加载到GenerateUI中(会先弹出确认,因为这会替换当前已加载的工作流)",
|
||||
helpLabPlan10: "右侧面板 — 「Save index image to Output on Run」:勾选后,每次Run结束都会生成与方案文件相同的联系表索引图片,并直接保存到ComfyUI自身的Output文件夹,命名为 Lab_index_<counter>_.png(像普通生成图片一样自动编号,不会覆盖上一次运行的结果)。保存后还会像其他Lab生成的图片一样经过Eagle自动保存(若在设置标签页中启用)——没有单独的复选框,遵循同一个全局开关。这与Plan Save的行为相互独立——无论是否勾选此项,Plan Save都会照常在 lab_plan/ 中的方案文件旁写入自己的索引图片;该设置会作为 save_index_on_run 保存在方案中",
|
||||
helpLabPlan11: "每一张索引图片PNG——无论是来自Plan Save/Save As,还是来自Save index image to Output on Run——都会将完整的方案数据(列、备注、Batch数量、图片链式生成/T2I设置、工作流文件名、结果列表)直接嵌入为PNG的wfm_lab_plan数据块。因此Output文件夹中的那份副本是完全自包含的,即使旁边没有任何对应的.json文件,也可以作为Plan Load的加载来源",
|
||||
helpLabPlan12: "从故事创建:AI TOOL > Chat 的「Storyboard to Lab」技能会为每个场景编写提示词,点击「在 Lab 中打开」即可把该计划作为未保存的计划载入此处(T2I 模式,Batch=场景数,每个场景对应一个 Prompt 列关键帧,计划的固定 seed 放在 KSampler 列的关键帧 1,单元格中显示为「Seed …」)。检查后点击 Run,需要保留时用 Plan Save 保存",
|
||||
helpLabCardResultsTitle: "Results子标签",
|
||||
helpLabResults1: "左侧面板显示本次运行使用的源图片",
|
||||
helpLabResults2: "中间面板按生成顺序显示最多9张生成图片;点击缩略图可放大查看",
|
||||
@@ -4772,6 +5059,14 @@ const LANGUAGES = {
|
||||
helpGallery28: "「创建PSD」批量操作按钮:将所有选中的图像合并为一个带图层的PSD文件并下载 — 每张图像成为独立图层(以文件名命名),以左上对齐方式放置在按所选最大图像尺寸创建的画布上;最后选中的图像位于最上层 转换...批量按钮(选中音频/视频时显示):将音频文件(视频则提取音轨)转换为FLAC / MP3 / Opus / M4A / WAV并保存为同文件夹内的新文件(可选比特率、采样率、声道);prompt/workflow标签会在支持标签的格式中继承(WAV除外),也可继承收藏/标签/备注/分组,并可选择转换后删除原文件。",
|
||||
helpGallery29: "「作为图层编辑」批量操作按钮:切换到Image Edit标签并一次性打开所有选中的图像,每张作为独立图层(所选内容中的视频文件会被跳过);最后选中的图像位于最上层",
|
||||
helpGallery30: "PSD文件:缩略图与完整预览显示所有图层合并后的效果(隐藏图层不参与合并);原始PSD文件本身不会被修改,Gallery中显示的仅是合并渲染后的预览",
|
||||
helpGallery38: "3D模型:GLB、glTF、VRM、PLY(网格、点云或高斯泼溅)和SPZ以“3D”徽标列出,并在内置查看器中显示(拖动旋转、右键拖动平移、滚轮缩放、⟲重置视角、双击放大)。缩略图在文件首次显示时由浏览器渲染并保存到服务器(超过128MB的文件在打开时保存)。ComfyUI的SaveGLB保存的工作流可像图像一样读取。引用外部.bin或纹理的.gltf请保持这些文件的位置不变(移动和删除只作用于.gltf本身)。3D文件不能发送到Image Edit、GenerateUI、Tagger或Feeder。",
|
||||
gallery3dLoading: "正在加载3D模型...",
|
||||
gallery3dLoadFailed: "无法显示此3D文件",
|
||||
gallery3dResetView: "重置视角",
|
||||
gallery3dNotImage: "3D文件不能发送到此处",
|
||||
gallery3dStatsPoints: (n) => `${n} 个点`,
|
||||
gallery3dStatsMesh: (v, f) => `顶点 ${v} / 三角形 ${f}`,
|
||||
gallery3dStatsSplats: (n) => `${n} 个泼溅`,
|
||||
helpGallery12: "Metadata按钮(绿色,位于详情面板标签栏):在Metadata标签中打开所选图像以全面查看嵌入数据;Alt+点击任意图像缩略图效果相同",
|
||||
helpGallery13: "Load GenUI按钮(蓝色,位于详情面板标签栏):将所选图像中嵌入的ComfyUI工作流直接加载到GenerateUI标签并切换过去;如果没有嵌入工作流或格式不受支持,则显示警告提示",
|
||||
helpGallery14: "Tagger按钮(紫色,位于详情面板操作行):将所选图像直接在Tagger标签的Single模式中打开,即可立即进行标注",
|
||||
@@ -4795,7 +5090,7 @@ const LANGUAGES = {
|
||||
helpTagSpaceCardUse: "选择标签并生成提示词",
|
||||
helpTagSpace1: "TagSpace将WFS内的数据放入EmbeddingGemma 2的向量空间并以3D显示:卡片(ImagePrompt的图像及其提示词,以缩略图显示)、标签(从ImagePrompt提示词、Gallery图像中嵌入的Positive提示词、通配符、Tagger DB和Prompt库的文本中拆分出的词,按来源着色)以及Gallery点(已索引的部分Gallery图像,作为淡色背景,各自带有来自其提示词的标签)。距离越近,含义越相近",
|
||||
helpTagSpace2: "需要:运行中的Ollama与EmbeddingGemma 2(Gallery > 语义设置 > 模型设置)。如果Gallery语义索引是用Gemini创建的,请先用EmbeddingGemma 2重建",
|
||||
helpTagSpace10: "用卡片或Gallery图像的标签搜索空间:选择卡片或Gallery点后,其提示词标签以按钮显示(卡片的完整提示词在“完整提示词”中显示;卡片标签在“创建 / 更新”后显示)。点击标签进行选择(再次点击取消),按“用所选标签搜索空间”即可将所选标签按提示词顺序连接后搜索空间(与在搜索框中输入相同)。在选择其他元素之前会保留所选内容",
|
||||
helpTagSpace10: "用卡片或Gallery图像的标签搜索空间:选择卡片或Gallery点后,其提示词标签以按钮显示(卡片的完整提示词在“完整提示词”中显示;卡片标签在“创建 / 更新”后显示)。点击标签进行选择(再次点击取消),按“用所选标签搜索空间”即可将所选标签按提示词顺序连接后搜索空间(与在搜索框中输入相同)。在选择其他元素之前会保留所选内容。选择了标签时,“添加”按钮会变为“添加所选标签(N)”,只将所选标签(按提示词顺序)作为一个标签块添加;未选择时与以前一样添加整体。对于Gallery点,“在Gallery中打开”会打开Gallery标签页中该图像所在的文件夹,并在列表中选中该图像(搜索等筛选会被清除)",
|
||||
helpTagSpace9: "标签的选择:勾选“全部标签”后不再使用“标签数”,而是使用所有候选标签(上限2万个)。“最少出现次数”会排除在所有来源合计中使用次数少于该值的标签(设为2可去除只出现一次的拼写差异和片段)。“通配符”决定是否将通配符作为标签来源。只出现在通配符中的词占了标签种类的大部分(测试环境中4.76万种中约3.7万种),关闭后使用全部标签约为1.04万种(最少出现次数为2时约6,800种)。只使用部分候选时,状态栏会显示候选数量。新标签只嵌入一次(新标签较多的首次创建会多花几分钟)",
|
||||
helpTagSpace3: "“创建 / 更新”:收集卡片,将尚未索引的卡片图像加入Gallery语义索引,读取已索引Gallery图像中嵌入的Positive提示词(从采样器的positive输入向上追溯,不追溯negative;缓存于tagspace_prompts.json,之后只读取新增或更改的图像),用Ollama嵌入新标签(缓存于tagspace_tags.npz),再用PCA投影到3D。“标签数”为保留的标签数量:每个来源按“使用该标签的文本数”排序,各来源轮流选取,因此不会被数量众多的Gallery提示词占满;“Gallery点数”为显示的Gallery图像数量(0为不显示)。提示词只按逗号(以及 | { } BREAK)拆分,不按空格拆分,超过5个词的句子会被排除。图像与文本分别对齐中心后再投影,使卡片与对应标签重叠。首次创建时,卡片准备约2.5分钟,读取2.9万张Gallery图像的提示词约3分钟;有缓存时只需几秒",
|
||||
helpTagSpace4: "操作:拖动旋转,右键拖动平移,滚轮缩放。悬停时显示名称、缩略图以及提示词/来源;复选框可切换卡片、缩略图、标签和Gallery的显示。来源复选框(ImagePrompt/Gallery提示词/Tagger DB/Prompt库/通配符)可按来源显示或隐藏标签:只要标签的任一来源为开启就会显示,颜色取显示中来源的第一个;隐藏的标签也会从附近元素和搜索结果列表中排除。所选内容会被保存",
|
||||
@@ -4833,14 +5128,18 @@ const LANGUAGES = {
|
||||
helpSidepanel16: "A 标签(AI 工具):翻译、Chat、TOOLS 和设置子标签,支持 Ollama、LM Studio、Lemonade 或 Unsloth;Chat 支持与 LLM 的多轮对话;TOOLS 包含 VLM 图像分析和通配符生成;设置通过 localStorage 与 SPA 的 AI 标签共享;Settings 子标签的“VRAM (Ollama)”→“Unload all now”可卸载本机 Ollama 已加载的所有模型(包括决策模型,与设置标签“显存调整”相同)",
|
||||
helpSidepanel17: "Send to Canvas:在Workflow标签点击'Send to Canvas'或Gallery标签点击'Copy & Send Canvas',工作流直接发送到ComfyUI画布(支持UI和API所有格式);若Workflow Studio不是从ComfyUI工具栏打开,UI格式工作流将回退到标题拖拽(标题变蓝并显示绿色●——拖拽到画布加载)",
|
||||
helpAiTitle: "AI 标签页(A)",
|
||||
helpAi1: "4 面板布局 — Translation | Chat | TOOLS | Settings — 所有面板始终可见",
|
||||
helpAi2: "翻译面板:使用 Ollama、LM Studio、Lemonade 或 Unsloth 在日语、英语、中文或自定义语言(Free)之间翻译;⇄ 按钮同时互换语言和文本内容;选择自动保存",
|
||||
helpAi1: "4 面板布局 — Translation | Chat | TOOLS | Settings;Translation 和 Settings 面板可通过标题中的箭头(◀ / ▶)折叠成细条,把空间让给 Chat 和 TOOLS,点击细条任意位置即可展开;折叠状态会被保存",
|
||||
helpAi2: "翻译面板:使用 Ollama、LM Studio、Lemonade 或 Unsloth 在日语、英语、中文或自定义语言(Free)之间翻译文本;⇄ 按钮同时交换语言选择和文本内容;选择会自动保存;TOOLS 面板的结果(如 OCR 识别出的文字)可通过「发送到翻译」放入此处的输入框(面板折叠时会自动展开)",
|
||||
helpAi3: "Chat 面板:与 LLM 进行多轮对话;Enter 发送消息,Shift+Enter 换行;Clear 按钮重置对话历史;如果模型支持 tool calling,当你要求生成图像(例如\"生成一张猫的图片\")时,会使用 GenerateUI 中当前加载的工作流执行生成,并在对话中显示结果;面板标题栏的\"允许生成图像\"复选框可开关此功能;如果附加了图像(在 TOOLS 面板中拖放的图像),该图像会作为视觉输入发送给 LLM,调用 generate_image 时会以该图像为基础执行图生图(I2I),而不是文生图;generate_image 还接受可选的生成参数——steps、cfg、sampler_name、scheduler、denoise、seed、width、height、batch_size(该次调用生成的图像数量),以及 aspect_ratio(形如\"3:2\"的比例预设,仅当工作流中存在向 width/height 供值的 Resolution Selector 节点时才会生效,否则忽略);模型未提供的参数将保留当前已加载工作流的设置;已验证可正常支持 tool calling 与视觉功能的模型:Gemma 4:e4b、Qwen 3.5:9b、Ministral 3:3b(Ollama)",
|
||||
helpAi4: "TOOLS 面板 — VLM:拖放图像(PNG/JPG/WebP)并选择任务(图像描述 / 提示词生成 / 标签生成),点击 Run;或选择「Create wildcards」,输入类别名称和数量生成纯文本通配符条目(每行一条);拖放的图像也会作为 Chat 面板的附件共享(在该处显示并用于图生图生成),直到点击 ✕ 按钮清除",
|
||||
helpAi6: "Settings 面板:选择后端(Ollama / LM Studio / Lemonade / Unsloth)、设置 API URL、测试连接、选择模型,以及使用「Test connection」右侧的「Unload Model」按钮将已加载的模型从显存/内存中卸载——Ollama 会卸载所有已加载的 Ollama 模型(包括决策模型,与设置标签“显存调整”的“立即全部卸载”相同;Prompt/Tagger 的设置按钮也一样),LM Studio 使用 /api/v1/models/unload,Lemonade 使用 /api/v1/unload;Unsloth 没有卸载 API,因此会显示不支持的提示;Unsloth 需要 API 密钥或 Keyless API access 之一——将插件文件夹中的 .env.example 复制为 .env 并设置 UNSLOTH_API_KEY,然后重启 ComfyUI(密钥仅在服务器端读取,绝不会发送到浏览器);或者不设置密钥,在 Unsloth Desktop 的 Settings → API 中开启 Keyless API access →「Chat and inference」(请求将不带密钥发送;该设置也会向局域网开放 API,请注意,并保持「Allow tools」关闭);还需要在 Unsloth Desktop 应用自身的设置界面中开启「Auto model switching (OpenAI API)」——若保持关闭,除非已在 Unsloth Studio 中手动加载过模型,否则请求会以 HTTP 404 失败;Unsloth 的推理模型会将思考内容放在单独的 reasoning_content 字段中,而不是内联的 <think> 标签里,因此 Max tokens 太小会导致模型把全部配额都用在思考上,返回空白回复——如果回复为空,请调高 Max tokens(建议 2048 以上);Generation 部分 — Thinking mode 开关(默认关闭;Ollama会收到think标志,无论使用哪个后端,关闭时都会从输出中去除<think>...</think>思考块,避免推理模型的思考过程混入Translation/Chat/TOOLS的结果)以及 Max tokens(Ollama: num_predict,LM Studio/Lemonade/Unsloth: max_tokens;0为不限);配置 Free 语言名称;Chat 图像生成部分可让 Chat 触发的文生图使用专用工作流,而不是 GenerateUI 中当前加载的工作流;Chat I2I 生成部分提供相同设置,用于附加图像时的图生图生成;设置保存至 localStorage,并与 Library 面板的 A 标签共享",
|
||||
helpAi4: "TOOLS 面板 — VLM:拖入图像(PNG/JPG/WebP)并选择任务(图像描述 / 生成提示词 / 生成标签 / 文字识别(OCR)),点击运行;或选择「Create wildcards」,输入类别名称和数量,生成每行一条的通配符;拖入的图像也会作为 Chat 面板的附件共享(在 Chat 中显示并用于图生图),直到用 ✕ 按钮清除;结果下方的「发送到翻译」「发送到 Chat」可把结果交给对应面板,「复制」可复制到剪贴板",
|
||||
helpAi6: "Settings 面板:选择后端(Ollama / LM Studio / Lemonade / Unsloth)、设置 API URL、测试连接、选择模型,以及使用「Test connection」右侧的「Unload Model」按钮将已加载的模型从显存/内存中卸载——Ollama 会卸载所有已加载的 Ollama 模型(包括决策模型,与设置标签“显存调整”的“立即全部卸载”相同;Prompt/Tagger 的设置按钮也一样),LM Studio 使用 /api/v1/models/unload,Lemonade 使用 /api/v1/unload;Unsloth 没有卸载 API,因此会显示不支持的提示;Unsloth 需要 API 密钥或 Keyless API access 之一——将插件文件夹中的 .env.example 复制为 .env 并设置 UNSLOTH_API_KEY,然后重启 ComfyUI(密钥仅在服务器端读取,绝不会发送到浏览器);或者不设置密钥,在 Unsloth Desktop 的 Settings → API 中开启 Keyless API access →「Chat and inference」(请求将不带密钥发送;该设置也会向局域网开放 API,请注意,并保持「Allow tools」关闭);还需要在 Unsloth Desktop 应用自身的设置界面中开启「Auto model switching (OpenAI API)」——若保持关闭,除非已在 Unsloth Studio 中手动加载过模型,否则请求会以 HTTP 404 失败;Unsloth 的推理模型会将思考内容放在单独的 reasoning_content 字段中,而不是内联的 <think> 标签里,因此 Max tokens 太小会导致模型把全部配额都用在思考上,返回空白回复——如果回复为空,请调高 Max tokens(建议 2048 以上);Generation 部分 — Thinking mode 开关(默认关闭;Ollama会收到think标志,无论使用哪个后端,关闭时都会从输出中去除<think>...</think>思考块,避免推理模型的思考过程混入Translation/Chat/TOOLS的结果)以及 Max tokens(Ollama: num_predict,LM Studio/Lemonade/Unsloth: max_tokens;0为不限);配置 Free 语言名称;Chat 图像生成部分可让 Chat 触发的文生图使用专用工作流,而不是 GenerateUI 中当前加载的工作流;Chat I2I 生成部分提供相同设置,用于附加图像时的图生图生成;设置保存至 localStorage,并与 Library 面板的 A 标签共享;Chat 图像检查部分用于选择聊天「检查图像」所用的判定模型(支持图像输入的 Ollama 决策模型,例如 d3:4b)以及自动修正次数(0 表示仅在点击按钮时修正)",
|
||||
helpAi7: "Chat 面板 — SVG:LLM 还可以直接以文本形式生成 SVG 图形,完全不涉及 ComfyUI 工作流;只需要求它用 SVG 绘制内容,如果回复中包含 SVG 代码,会自动渲染为内嵌预览,并提供\"保存到图库\"按钮,将其保存为 .svg 文件到 Gallery 标签页的输出文件夹;该功能不使用 tool calling(函数调用),因此不支持 tool calling 的模型也可以使用",
|
||||
helpAi8: "Chat 面板 — 技能:在消息列表上方的下拉菜单中选择一个技能,其内容会作为 system prompt 添加到对话开头(不会显示在可见的聊天记录中);点击铅笔按钮打开技能管理面板,可创建、编辑、删除 .md 技能文件(保存路径:user/default/Workflow-Studio/ai_skills/);文件开头 --- 之间的 name/description 字段会显示在下拉菜单中;默认内置两个技能:「SVG Icon Generator」(在绘制前先将对象拆解为多个部件,以生成更易识别的 SVG)和「Skill Creator」(通过对话向你提问并起草新的技能文件);当回复中包含```skill代码块时,消息下方会出现「保存为技能」按钮,点击后会打开管理面板并预填内容",
|
||||
helpAi8: "Chat 面板 — 技能:在消息列表上方的下拉菜单中选择技能,其内容会作为 system prompt 添加到对话开头(不会显示在可见的聊天记录中);点击铅笔按钮打开技能管理面板,可创建、编辑、删除、导入技能(保存路径:user/default/Workflow-Studio/ai_skills/);文件开头 --- 之间的 name/description 会显示在下拉菜单中;内置六个技能(管理面板中标记为「内置」):「T2I Prompt Enhancer」(把简短想法改写为适合 GenerateUI 当前工作流模型的提示词:Qwen-Image/FLUX/Z-Image 用自然语句,Illustrious/Pony 用 Danbooru 标签)、「Image to Prompt」(为 TOOLS 面板中附加的图像编写可复现它的提示词,需要支持图像的聊天模型)、「Storyboard to Lab」和「Workflow Doctor」(见下方卡片)、「SVG Icon Generator」(先把对象拆解为部件再绘制,支持 line/flat/duotone/mono 风格)、「Skill Creator」(通过对话起草新技能,也可改进现有技能);未编辑的内置技能会在 Workflow Studio 更新时自动更新,已编辑的会显示 ↻ 按钮以替换为最新版本;回复中包含```skill代码块时会出现「保存为技能」按钮,点击后会打开管理面板并预填内容",
|
||||
helpAi9: "编写技能的小贴士:为 frontmatter 的 name/description 写清楚的内容,便于在下拉菜单和提示中阅读;明确说明期望的输出格式,如果有需要后续提取的成果物(如 SVG 或另一个技能文件),指示它用专门的代码围栏包裹;具体、祈使句式的指令比笼统的说明对本地小模型更有效,附上一个简短示例效果更佳;明确写出硬性约束(禁止内容、安全性、长度限制等),不要指望模型会自行推断;如果不确定怎么写,可以在下拉菜单中选择「Skill Creator」技能并描述你想要的技能,它会通过提问帮你起草",
|
||||
helpAi10: "可在技能 frontmatter 的 metadata: 下设置 output(prompt 或 svg,会自动检查回复:若模型尝试输出但内容损坏——SVG 未闭合、无效或含禁止元素,或提示词行不在```prompt代码块内——会请模型修正一次,并以虚线提示显示;完全没有输出的回复(例如只请求说明时)不会被要求重做)、tools([] 表示模型不会自行开始生成图像,[generate_image] 表示在图像生成开关打开时允许)、temperature、think(强制开启/关闭思考模式)、context([workflow] 会在每条消息中发送 GenerateUI 中工作流的摘要:模型文件、提示词节点、CFG、分辨率;工作流中当前的提示词文本只在你的消息提到它时(如「改进当前的提示词」)才加入,以免小模型把它混入新提示词;[current-prompt] 则始终加入);包含```prompt代码块(positive:/negative: 行)的回复会显示「应用到 GenerateUI」「用此提示词生成」「复制」按钮",
|
||||
helpAi11: "图像检查(聊天输入框下方的「检查图像」):从聊天生成图像后(通过模型的 generate_image 工具或「用此提示词生成」按钮),聊天模型会把你的请求拆成 3-7 个是/否条件,Ollama 中支持图像的决策模型(推荐 d3:4b,在设置 > Chat 图像检查中选择)会判定图像,并以 ✔满足/?不明确/✘未满足 及概率显示每个条件(同时显示整体画质);点击「修正缺少的内容并重新生成」只会把未满足的项目告诉聊天模型,让它改写提示词并重新生成;设置「自动修正次数」(0-3)可无需点击自动重复;使用 SVG Icon Generator 技能时,会把 SVG 渲染为图像并按「Parts:」行中的部件逐项检查;决策模型返回概率,比容易把图中没有的东西说成「有」的聊天模型自身判断更可靠;第四个内置技能「Image to Prompt」会为在 TOOLS 面板中附加的图像编写可复现它的提示词(需要支持图像的聊天模型)",
|
||||
helpAi12: "「Workflow Doctor」会说明 GenerateUI 中已加载的工作流并诊断错误。每次发送时都会提供 Workflow Studio 检查过的事实(未安装的节点、不在 ComfyUI 列表中的模型文件或选项及相近名称、断开的连接、未连接的必需输入、最近的执行错误、ComfyUI 日志中最新的错误部分),因此回答基于你的实际环境,而不是模型可能过时的知识;「Storyboard to Lab」会把故事拆成每个场景一条提示词,所有场景中的角色描述保持一致并固定 seed,点击「在 Lab 中打开」即可载入 GenerateUI > Lab(T2I 模式),检查后点击 Run;技能管理面板中的「导入」可添加 Agent Skills 文件夹(包含 SKILL.md 的 .zip,许多 AI 工具通用的格式)或单个 .md,只保留文本文件,绝不运行脚本;文件夹技能的参考文件只会以列表形式告诉模型,模型仅在需要时用 read_skill_file 工具读取(需要支持工具调用的聊天模型);助手的回复会以简单的 Markdown(列表、标题、代码块)格式显示",
|
||||
helpAi13: "在 TOOLS 面板中选择「文字识别(OCR)」,放入图像后点击运行,即可用 Ollama 中的 OCR 模型读取图像中的文字(列表按适合程度显示名称中包含「ocr」的模型;默认是 LightOnOCR-3,它在约 1 秒内读出了包含竖排文字的日英混合测试图的全部文字;PaddleOCR-VL-manga 和 DeepSeek-OCR 也能完整读取,而 PaddleOCR-VL 1.6 和 GLM-OCR 漏读了竖排文字);图像会转为长边不超过 1600px、白色背景的 JPEG 发送,结果为空或重复同一行时会用灰度重读一次;开启「显示文字位置」(仅 LightOnOCR / DeepSeek-OCR,其他模型的复选框不可用)会放大预览并在图像上叠加带编号的框,点击框即可选中结果中的对应行;OCR 模型与 Settings 中选择的聊天模型分开选择",
|
||||
helpAiCardLayout: "布局",
|
||||
helpAiCardTranslate: "Translation 面板",
|
||||
helpAiCardChat: "Chat 面板",
|
||||
@@ -4849,6 +5148,10 @@ const LANGUAGES = {
|
||||
helpAiCardSkillTips: "编写技能的小贴士",
|
||||
helpAiCardTools: "TOOLS 面板",
|
||||
helpAiCardSettings: "Settings 面板",
|
||||
helpAiCardSkillOptions: "Chat 面板 — 技能选项与操作按钮",
|
||||
helpAiCardSkillsMore: "Chat 面板 — Workflow Doctor、Storyboard 与技能文件夹",
|
||||
helpAiCardImageCheck: "Chat 面板 — 图像检查",
|
||||
helpAiCardOcr: "TOOLS 面板 — 文字识别(OCR)",
|
||||
helpJumpToTab: "打开标签页 →",
|
||||
helpAboutCardGettingStarted: "开始使用",
|
||||
helpWfTitle: "工作流标签页",
|
||||
@@ -4993,6 +5296,24 @@ const LANGUAGES = {
|
||||
helpImageEditGmicInstall2: "将zip解压到任意文件夹(如 C:\\tools\\gmic);无需安装程序",
|
||||
helpImageEditGmicInstall3: "打开设置 → G'MIC-Qt Executable Path,输入 gmic_qt.exe 的完整路径(如 C:\\tools\\gmic\\gmic_qt.exe),点击 Save",
|
||||
helpImageEditTabKbdTitle: "图像编辑选项卡快捷键",
|
||||
imageEditPaletteColor1: "颜色1",
|
||||
imageEditPaletteColor2: "颜色2",
|
||||
imageEditPaletteBase: "基本色",
|
||||
imageEditPaletteCustom: "自定义",
|
||||
imageEditPaletteAddTitle: "将当前颜色登记到自定义颜色的空位",
|
||||
imageEditPaletteEmptyTitle: "单击登记当前颜色,右键指定颜色",
|
||||
imageEditPaletteSwatchTitle: "单击使用,右键登记或编辑",
|
||||
imageEditPaletteRegisterCurrent: "登记当前颜色",
|
||||
imageEditPaletteEditColor: "编辑颜色…",
|
||||
imageEditPalettePickColor: "指定颜色并登记…",
|
||||
imageEditPaletteDelete: "删除",
|
||||
imageEditPaletteResetBase: "恢复初始颜色",
|
||||
imageEditPaletteExists: "此颜色已登记",
|
||||
imageEditPaletteFull: "自定义颜色已满。请右键覆盖或删除",
|
||||
imageEditNoAbrBrushes: "没有可用画笔。请在 Mask Editor One 中导入 ABR 画笔",
|
||||
helpImageEditDrawPalette: "Draw调色板(第二行):颜色1/颜色2、20种基本色和20个自定义颜色位。单击颜色即可用该颜色绘制。使用“+”或单击空位可登记当前颜色;在颜色上右键可登记、编辑或删除(基本色也可修改并恢复初始颜色)。登记的颜色按浏览器分别保存。使用Color旁的吸管按钮可从画布取色(按Esc取消)",
|
||||
imageEditPixelColorFail: "无法读取像素颜色",
|
||||
helpImageEditDrawKbd: "Draw工具使用中:D Draw / E Erase / X 切换颜色1/2 / [ ] 笔刷大小 / B 打开或关闭笔刷选择(也可按Esc关闭)",
|
||||
helpShortcutsTitle: "键盘操作",
|
||||
helpShortcuts1: "Escape:关闭弹窗/面板",
|
||||
helpShortcuts2: "Enter:确认重命名、发送聊天",
|
||||
|
||||
+140
-26
@@ -6,6 +6,7 @@
|
||||
|
||||
import { LayerManager, Layer } from "./image-edit/LayerManager.js";
|
||||
import { DrawTool } from "./image-edit/DrawTool.js";
|
||||
import { DrawPalette } from "./image-edit/DrawPalette.js";
|
||||
import { TextTool, TEXT_FONTS } from "./image-edit/TextTool.js";
|
||||
import { SelectTool } from "./image-edit/SelectTool.js";
|
||||
import { ShapeTool } from "./image-edit/ShapeTool.js";
|
||||
@@ -20,10 +21,22 @@ import { MaskEditorOneBridge } from "./image-edit/MaskEditorOneBridge.js";
|
||||
import { InpaintI2IActions } from "./image-edit/InpaintI2IActions.js";
|
||||
import { FileExport } from "./image-edit/FileExport.js";
|
||||
import { showToast } from "./app.js";
|
||||
import { t as tr } from "./i18n.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { comfyWorkflow } from "./comfyui-workflow.js";
|
||||
|
||||
// Draw Tool: スポイト(Eyedropper)アイコン。ボタンとカスタムカーソルの両方で使い回す。
|
||||
const EYEDROPPER_ICON_PATH = "M20.71 5.63l-2.34-2.34a1 1 0 0 0-1.41 0L14.7 5.55 12 8.24 3 17.24V21h3.76l9-9 2.69-2.69 2.26-2.26a1 1 0 0 0 0-1.41zM6.34 19H5v-1.34l8.06-8.06 1.41 1.41L6.34 19z";
|
||||
const EYEDROPPER_ICON_SVG =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="${EYEDROPPER_ICON_PATH}"/></svg>`;
|
||||
|
||||
function _eyedropperCursorCss() {
|
||||
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'>`
|
||||
+ `<path d='${EYEDROPPER_ICON_PATH}' fill='%23ffffff' stroke='%23000000' stroke-width='1'/></svg>`;
|
||||
return `url("data:image/svg+xml,${svg}") 2 22, crosshair`;
|
||||
}
|
||||
|
||||
const TOOL_DEFS = [
|
||||
{ id: "select", icon: "▲", label: "Select", ready: true },
|
||||
{ id: "draw", icon: "✏", label: "Draw", ready: true },
|
||||
@@ -49,6 +62,11 @@ class ImageEditTab {
|
||||
this._layerMgr = null;
|
||||
this._activeTool = "select";
|
||||
this._drawTool = new DrawTool(null);
|
||||
this._eyedropperActive = false; // Drawツールのスポイト(色抽出)モード
|
||||
this._drawPalette = new DrawPalette({
|
||||
onColorChange: hex => this._setDrawColor(hex),
|
||||
toast: showToast,
|
||||
});
|
||||
this._textTool = new TextTool(null);
|
||||
this._selectTool = new SelectTool();
|
||||
this._zoom = 1.0;
|
||||
@@ -196,6 +214,7 @@ class ImageEditTab {
|
||||
_updateBrushCursor(e) {
|
||||
const el = this._brushCursorEl;
|
||||
if (!el) return;
|
||||
this._lastBrushCursorEvent = e; // キー操作でサイズを変えたときに再描画するため保持
|
||||
const tool = this._activeTool;
|
||||
const size = tool === "draw"
|
||||
? this._drawTool?.brushSize
|
||||
@@ -226,6 +245,79 @@ class ImageEditTab {
|
||||
if (this._brushCursorEl) this._brushCursorEl.style.display = "none";
|
||||
}
|
||||
|
||||
// ── Draw: スポイト(Eyedropper) ─────────────────
|
||||
|
||||
_setEyedropperActive(active) {
|
||||
this._eyedropperActive = active;
|
||||
const drawCanvas = document.getElementById("ie-canvas-draw");
|
||||
const overlay = document.getElementById("ie-canvas-overlay");
|
||||
const cursor = active ? _eyedropperCursorCss() : "";
|
||||
if (drawCanvas) drawCanvas.style.cursor = cursor;
|
||||
if (overlay) overlay.style.cursor = cursor;
|
||||
if (active) this._hideBrushCursor();
|
||||
document.getElementById("ie-draw-eyedropper")?.classList.toggle("ie-opt-active", active);
|
||||
}
|
||||
|
||||
_pickColorAt(x, y) {
|
||||
const canvas = document.getElementById("ie-canvas-draw");
|
||||
const px = Math.floor(x), py = Math.floor(y);
|
||||
if (!canvas || px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) {
|
||||
this._setEyedropperActive(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 見えている色を拾うため、アクティブレイヤーのみの表示なら合成し直す
|
||||
if (!this._compositeMode) this._updateCompositeView();
|
||||
const [r, g, b] = canvas.getContext("2d").getImageData(px, py, 1, 1).data;
|
||||
const hex = "#" + [r, g, b].map(v => v.toString(16).padStart(2, "0")).join("");
|
||||
this._setDrawColor(hex);
|
||||
this._drawPalette.syncColor(hex);
|
||||
} catch {
|
||||
showToast(tr("imageEditPixelColorFail"), "error");
|
||||
}
|
||||
this._setEyedropperActive(false);
|
||||
}
|
||||
|
||||
_setDrawMode(mode) {
|
||||
this._drawTool.mode = mode;
|
||||
this._drawTool._stamp = null;
|
||||
this._renderDrawProps();
|
||||
}
|
||||
|
||||
_setDrawBrushSize(size) {
|
||||
this._drawTool.brushSize = size;
|
||||
this._drawTool._stamp = null;
|
||||
const slider = document.getElementById("ie-draw-size");
|
||||
if (slider) slider.value = size;
|
||||
const label = document.getElementById("ie-draw-size-lbl");
|
||||
if (label) label.textContent = size + "px";
|
||||
if (this._lastBrushCursorEvent && !this._eyedropperActive) {
|
||||
this._updateBrushCursor(this._lastBrushCursorEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/** [ / ] キー: サイズが大きいほど刻みを粗くする */
|
||||
_stepDrawBrushSize(direction) {
|
||||
const size = this._drawTool.brushSize;
|
||||
const base = direction < 0 ? size - 1 : size;
|
||||
const step = base < 10 ? 1 : base < 50 ? 5 : 10;
|
||||
this._setDrawBrushSize(Math.min(200, Math.max(1, size + direction * step)));
|
||||
}
|
||||
|
||||
/** Drawツール中のBキー: ブラシ選択モーダルを開閉する */
|
||||
_toggleDrawBrushPicker() {
|
||||
if (this._abrAvailable) this._openAbrBrushPickerForDraw();
|
||||
else showToast(tr("imageEditNoAbrBrushes"), "info");
|
||||
}
|
||||
|
||||
_setDrawColor(hex) {
|
||||
this._drawTool.color = hex;
|
||||
this._drawTool._stamp = null;
|
||||
this._drawTool._imgStamp = null;
|
||||
const input = document.getElementById("ie-draw-color");
|
||||
if (input) input.value = hex;
|
||||
}
|
||||
|
||||
// ── ツールボタン ──────────────────────────────
|
||||
|
||||
_setupToolButtons() {
|
||||
@@ -240,6 +332,7 @@ class ImageEditTab {
|
||||
|
||||
_setActiveTool(toolId) {
|
||||
this._hideBrushCursor();
|
||||
if (this._eyedropperActive) this._setEyedropperActive(false);
|
||||
if (this._activeTool === "draw") this._drawTool?.deactivate();
|
||||
if (this._activeTool === "text") this._textTool?.deactivate();
|
||||
if (this._activeTool === "select") this._selectTool?.deactivate();
|
||||
@@ -306,6 +399,7 @@ class ImageEditTab {
|
||||
_renderToolOptions(toolId) {
|
||||
const el = document.getElementById("ie-tool-options");
|
||||
if (!el) return;
|
||||
this._drawPalette.closeMenu();
|
||||
el.innerHTML = "";
|
||||
|
||||
if (toolId === "select" && this._selectTool) {
|
||||
@@ -355,6 +449,7 @@ class ImageEditTab {
|
||||
|
||||
} else if (toolId === "draw" && this._drawTool) {
|
||||
el.innerHTML = "";
|
||||
this._drawPalette.render(el);
|
||||
this._renderDrawProps();
|
||||
|
||||
} else if (toolId === "fill" && this._fillTool) {
|
||||
@@ -873,18 +968,22 @@ class ImageEditTab {
|
||||
<div class="ie-props-row">
|
||||
<label>Mode</label>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="wfm-btn wfm-btn-sm${t.mode === "draw" ? " ie-opt-active" : ""}" id="ie-draw-mode-draw" style="flex:1;">Draw</button>
|
||||
<button class="wfm-btn wfm-btn-sm${t.mode === "erase" ? " ie-opt-active" : ""}" id="ie-draw-mode-erase" style="flex:1;">Erase</button>
|
||||
<button class="wfm-btn wfm-btn-sm${t.mode === "draw" ? " ie-opt-active" : ""}" id="ie-draw-mode-draw" title="Draw (D)" style="flex:1;">Draw</button>
|
||||
<button class="wfm-btn wfm-btn-sm${t.mode === "erase" ? " ie-opt-active" : ""}" id="ie-draw-mode-erase" title="Erase (E)" style="flex:1;">Erase</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ie-props-row">
|
||||
<label>Color</label>
|
||||
<input type="color" id="ie-draw-color" value="${t.color}"
|
||||
style="width:36px;height:24px;padding:0;border:1px solid var(--wfm-border);cursor:pointer;border-radius:3px;flex-shrink:0;">
|
||||
<div style="display:flex;gap:4px;align-items:center;">
|
||||
<input type="color" id="ie-draw-color" value="${t.color}"
|
||||
style="width:36px;height:24px;padding:0;border:1px solid var(--wfm-border);cursor:pointer;border-radius:3px;flex-shrink:0;">
|
||||
<button class="wfm-btn wfm-btn-sm${this._eyedropperActive ? " ie-opt-active" : ""}" id="ie-draw-eyedropper"
|
||||
title="Eyedropper" style="flex-shrink:0;width:auto;height:24px;padding:2px 6px;display:flex;align-items:center;justify-content:center;">${EYEDROPPER_ICON_SVG}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ie-props-row">
|
||||
<label>Size</label>
|
||||
<input type="range" id="ie-draw-size" min="1" max="200" value="${t.brushSize}">
|
||||
<label title="[ / ]">Size</label>
|
||||
<input type="range" id="ie-draw-size" min="1" max="200" value="${t.brushSize}" title="[ / ]">
|
||||
<span id="ie-draw-size-lbl">${t.brushSize}px</span>
|
||||
</div>
|
||||
<div class="ie-props-row">
|
||||
@@ -906,7 +1005,7 @@ class ImageEditTab {
|
||||
<label>Brush</label>
|
||||
<span style="font-size:11px;color:var(--wfm-text-secondary);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
|
||||
title="${t.brushName ?? "Circle"}">${t.brushName ?? "Circle"}</span>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-draw-select-brush" style="font-size:10px;padding:1px 6px;flex-shrink:0;" ${this._abrAvailable ? "" : "disabled"}>Select</button>
|
||||
<button class="wfm-btn wfm-btn-sm" id="ie-draw-select-brush" title="B" style="font-size:10px;padding:1px 6px;flex-shrink:0;" ${this._abrAvailable ? "" : "disabled"}>Select</button>
|
||||
${t.brushImage ? `<button class="wfm-btn wfm-btn-sm" id="ie-draw-clear-brush" style="font-size:10px;padding:1px 5px;flex-shrink:0;">✕</button>` : ""}
|
||||
</div>
|
||||
${t.brushImage ? `
|
||||
@@ -933,25 +1032,17 @@ class ImageEditTab {
|
||||
</div>` : ""}
|
||||
`;
|
||||
|
||||
document.getElementById("ie-draw-mode-draw")?.addEventListener("click", () => {
|
||||
this._drawTool.mode = "draw";
|
||||
this._drawTool._stamp = null;
|
||||
this._renderDrawProps();
|
||||
});
|
||||
document.getElementById("ie-draw-mode-erase")?.addEventListener("click", () => {
|
||||
this._drawTool.mode = "erase";
|
||||
this._drawTool._stamp = null;
|
||||
this._renderDrawProps();
|
||||
});
|
||||
document.getElementById("ie-draw-mode-draw")?.addEventListener("click", () => this._setDrawMode("draw"));
|
||||
document.getElementById("ie-draw-mode-erase")?.addEventListener("click", () => this._setDrawMode("erase"));
|
||||
document.getElementById("ie-draw-color")?.addEventListener("input", e => {
|
||||
this._drawTool.color = e.target.value;
|
||||
this._drawTool._stamp = null;
|
||||
this._drawTool._imgStamp = null;
|
||||
this._setDrawColor(e.target.value);
|
||||
this._drawPalette.syncColor(e.target.value);
|
||||
});
|
||||
document.getElementById("ie-draw-eyedropper")?.addEventListener("click", () => {
|
||||
this._setEyedropperActive(!this._eyedropperActive);
|
||||
});
|
||||
document.getElementById("ie-draw-size")?.addEventListener("input", e => {
|
||||
this._drawTool.brushSize = parseInt(e.target.value);
|
||||
document.getElementById("ie-draw-size-lbl").textContent = e.target.value + "px";
|
||||
this._drawTool._stamp = null;
|
||||
this._setDrawBrushSize(parseInt(e.target.value));
|
||||
});
|
||||
document.getElementById("ie-draw-hard")?.addEventListener("input", e => {
|
||||
this._drawTool.hardness = parseInt(e.target.value) / 100;
|
||||
@@ -1461,7 +1552,9 @@ class ImageEditTab {
|
||||
}
|
||||
// Draw/Mask: update brush cursor and continue stroke outside canvas
|
||||
const tool = this._activeTool;
|
||||
if (tool === "draw" || tool === "mask") {
|
||||
if (this._eyedropperActive) {
|
||||
this._hideBrushCursor();
|
||||
} else if (tool === "draw" || tool === "mask") {
|
||||
this._updateBrushCursor(e);
|
||||
const isDrawing = tool === "draw"
|
||||
? this._drawTool?._drawing
|
||||
@@ -1514,6 +1607,11 @@ class ImageEditTab {
|
||||
// PointerEvent.pressure: pen -> actual pressure, mouse -> 0.5 while a button is held.
|
||||
const pressure = e.pressure || 0.5;
|
||||
|
||||
if (this._eyedropperActive) {
|
||||
this._pickColorAt(pos.x, pos.y);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._activeTool === "draw" && this._drawTool) {
|
||||
const activeLayer = this._layerMgr.activeLayer;
|
||||
if (!activeLayer) return;
|
||||
@@ -1781,6 +1879,7 @@ class ImageEditTab {
|
||||
|
||||
// 現在のドキュメントを閉じ、未保存の変更を破棄してプレースホルダー表示に戻す
|
||||
_closeDocument() {
|
||||
if (this._eyedropperActive) this._setEyedropperActive(false);
|
||||
if (!this._layerMgr) return;
|
||||
if (!confirm("Close the current document? Unsaved changes will be lost.")) return;
|
||||
|
||||
@@ -1848,6 +1947,8 @@ class ImageEditTab {
|
||||
this._layerMgr.on("change", () => this._refreshLayerList());
|
||||
|
||||
this._drawTool = new DrawTool(null);
|
||||
// 新規ドキュメントでも選択中の色を引き継ぐ(パレットを色の正とする)
|
||||
this._drawTool.color = this._drawPalette.currentColor;
|
||||
this._drawTool.onChange(() => {
|
||||
// layer.canvas に直接描くため sync 不要
|
||||
this._updateCompositeView();
|
||||
@@ -2458,6 +2559,9 @@ class ImageEditTab {
|
||||
const wrap = document.getElementById("ie-canvas-wrap");
|
||||
if (wrap && !this._panning) wrap.style.cursor = "grab";
|
||||
}
|
||||
if (e.key === "Escape" && this._eyedropperActive) this._setEyedropperActive(false);
|
||||
const brushPicker = document.getElementById("wfm-abr-picker-overlay");
|
||||
if (e.key === "Escape") brushPicker?.remove();
|
||||
if (e.ctrlKey && e.key === "z") { e.preventDefault(); this._undo(); }
|
||||
if (e.ctrlKey && e.key === "y") { e.preventDefault(); this._redo(); }
|
||||
if (this._activeTool === "mask" && this._maskSubtool === "vector") {
|
||||
@@ -2465,10 +2569,20 @@ class ImageEditTab {
|
||||
}
|
||||
if (!e.ctrlKey && !e.target.closest("input, textarea, select")) {
|
||||
if (e.key === "v") this._setActiveTool("select");
|
||||
if (e.key === "b") this._setActiveTool("draw");
|
||||
if (e.key === "b") {
|
||||
if (this._activeTool === "draw") this._toggleDrawBrushPicker();
|
||||
else this._setActiveTool("draw");
|
||||
}
|
||||
if (e.key === "t") this._setActiveTool("text");
|
||||
if (e.key === "s") this._setActiveTool("shape");
|
||||
if (e.key === "g") this._setActiveTool("fill");
|
||||
if (this._activeTool === "draw" && !brushPicker && !e.altKey && !e.metaKey) {
|
||||
if (e.key === "d") this._setDrawMode("draw");
|
||||
if (e.key === "e") this._setDrawMode("erase");
|
||||
if (e.key === "x") this._drawPalette.toggleWell();
|
||||
if (e.key === "[") this._stepDrawBrushSize(-1);
|
||||
if (e.key === "]") this._stepDrawBrushSize(1);
|
||||
}
|
||||
// Delete/Backspaceで選択オブジェクト削除
|
||||
if ((e.key === "Delete" || e.key === "Backspace") && this._activeTool === "select") {
|
||||
const layer = this._selectTool?.getSelectedLayer();
|
||||
@@ -2929,7 +3043,7 @@ class ImageEditTab {
|
||||
return null;
|
||||
};
|
||||
|
||||
const currentColor = this._drawTool?.color ?? "#ff0000";
|
||||
const currentColor = this._drawTool?.color ?? "#000000";
|
||||
const hex = currentColor.replace("#", "");
|
||||
const cr = parseInt(hex.slice(0, 2), 16);
|
||||
const cg = parseInt(hex.slice(2, 4), 16);
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Image Edit Tab - Draw Tool color palette
|
||||
* Drawツールの2段目(ie-tool-options)に並べるカラーパレット。
|
||||
* 色1/色2の切替、基本色、カスタム色の登録を担当する。
|
||||
* 基本色・カスタム色はユーザー設定として localStorage に保存する(作品データには含めない)。
|
||||
*/
|
||||
|
||||
import { t } from "../i18n.js";
|
||||
|
||||
const STORAGE_KEY = "ie_draw_palette_v1";
|
||||
const CUSTOM_SLOTS = 20;
|
||||
|
||||
const DEFAULT_BASE = [
|
||||
"#000000", "#7f7f7f", "#880015", "#ed1c24", "#ff7f27", "#fff200", "#22b14c", "#00a2e8", "#3f48cc", "#a349a4",
|
||||
"#ffffff", "#c3c3c3", "#b97a57", "#ffaec9", "#ffc90e", "#efe4b0", "#b5e61d", "#99d9ea", "#7092be", "#c8bfe7",
|
||||
];
|
||||
|
||||
function normalizeHex(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const hex = value.toLowerCase();
|
||||
return /^#[0-9a-f]{6}$/.test(hex) ? hex : null;
|
||||
}
|
||||
|
||||
function loadPalette() {
|
||||
let saved = null;
|
||||
try { saved = JSON.parse(localStorage.getItem(STORAGE_KEY)); } catch { /* 破損時は初期値 */ }
|
||||
return {
|
||||
base: DEFAULT_BASE.map((c, i) => normalizeHex(saved?.base?.[i]) ?? c),
|
||||
custom: Array.from({ length: CUSTOM_SLOTS }, (_, i) => normalizeHex(saved?.custom?.[i])),
|
||||
};
|
||||
}
|
||||
|
||||
export class DrawPalette {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {(hex: string) => void} opts.onColorChange ブラシ色を変更する
|
||||
* @param {(msg: string, type?: string) => void} opts.toast
|
||||
*/
|
||||
constructor({ onColorChange, toast }) {
|
||||
this._onColorChange = onColorChange;
|
||||
this._toast = toast;
|
||||
this._wells = ["#000000", "#ffffff"];
|
||||
this._activeWell = 0;
|
||||
Object.assign(this, loadPalette());
|
||||
this._content = null;
|
||||
this._picker = null;
|
||||
this._pickerTarget = null;
|
||||
this._menu = null;
|
||||
this._closeMenuOnOutside = e => { if (!this._menu?.contains(e.target)) this.closeMenu(); };
|
||||
this._closeMenuOnEscape = e => { if (e.key === "Escape") this.closeMenu(); };
|
||||
}
|
||||
|
||||
get currentColor() { return this._wells[this._activeWell]; }
|
||||
|
||||
/** ie-tool-options へパレットを描画する */
|
||||
render(container) {
|
||||
this.closeMenu();
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "ie-palette";
|
||||
this._content = document.createElement("div");
|
||||
this._content.className = "ie-palette-content";
|
||||
this._picker = document.createElement("input");
|
||||
this._picker.type = "color";
|
||||
this._picker.className = "ie-palette-picker";
|
||||
this._picker.tabIndex = -1;
|
||||
root.append(this._content, this._picker);
|
||||
container.appendChild(root);
|
||||
|
||||
root.addEventListener("click", e => this._onClick(e));
|
||||
root.addEventListener("contextmenu", e => this._onContextMenu(e));
|
||||
this._picker.addEventListener("input", () => this._onPickerInput());
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
/** スポイト・カラー入力などパレット外で変わった色を反映する */
|
||||
syncColor(color) {
|
||||
const hex = normalizeHex(color);
|
||||
if (!hex) return;
|
||||
this._wells[this._activeWell] = hex;
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
/** 色1/色2を切り替える */
|
||||
toggleWell() {
|
||||
this._selectWell(1 - this._activeWell);
|
||||
}
|
||||
|
||||
closeMenu() {
|
||||
if (!this._menu) return;
|
||||
this._menu.remove();
|
||||
this._menu = null;
|
||||
document.removeEventListener("pointerdown", this._closeMenuOnOutside, true);
|
||||
document.removeEventListener("keydown", this._closeMenuOnEscape, true);
|
||||
}
|
||||
|
||||
_refresh() {
|
||||
if (!this._content?.isConnected) return;
|
||||
const wells = this._wells.map((color, i) => {
|
||||
const label = t(i === 0 ? "imageEditPaletteColor1" : "imageEditPaletteColor2");
|
||||
return `<button type="button" class="ie-palette-well${i === this._activeWell ? " is-active" : ""}"
|
||||
data-well="${i}" style="--swatch:${color}" title="${label} (X): ${color}" aria-label="${label}"></button>`;
|
||||
}).join("");
|
||||
this._content.innerHTML = `
|
||||
<div class="ie-opt-group ie-palette-wells">${wells}</div>
|
||||
<div class="ie-opt-group">
|
||||
<label>${t("imageEditPaletteBase")}</label>
|
||||
<div class="ie-palette-grid">${this._swatches("base")}</div>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<label>${t("imageEditPaletteCustom")}</label>
|
||||
<div class="ie-palette-grid">${this._swatches("custom")}</div>
|
||||
<button type="button" class="wfm-btn wfm-btn-sm" data-palette-add
|
||||
title="${t("imageEditPaletteAddTitle")}">+</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_swatches(kind) {
|
||||
return this[kind].map((color, i) => {
|
||||
const attrs = `data-kind="${kind}" data-index="${i}"`;
|
||||
if (!color) {
|
||||
const title = t("imageEditPaletteEmptyTitle");
|
||||
return `<button type="button" class="ie-palette-swatch is-empty" ${attrs} title="${title}" aria-label="${title}"></button>`;
|
||||
}
|
||||
const current = color === this.currentColor ? " is-current" : "";
|
||||
const title = `${color} — ${t("imageEditPaletteSwatchTitle")}`;
|
||||
return `<button type="button" class="ie-palette-swatch${current}" ${attrs}
|
||||
style="--swatch:${color}" title="${title}" aria-label="${color}"></button>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
_onClick(e) {
|
||||
const well = e.target.closest("[data-well]");
|
||||
if (well) {
|
||||
this._selectWell(Number(well.dataset.well));
|
||||
return;
|
||||
}
|
||||
if (e.target.closest("[data-palette-add]")) {
|
||||
this._addCurrent();
|
||||
return;
|
||||
}
|
||||
const swatch = e.target.closest("[data-kind]");
|
||||
if (!swatch) return;
|
||||
const { kind, index } = swatch.dataset;
|
||||
const color = this[kind][index];
|
||||
if (color) this._applyColor(color);
|
||||
else this._setSlot(kind, index, this.currentColor);
|
||||
}
|
||||
|
||||
_onContextMenu(e) {
|
||||
const swatch = e.target.closest("[data-kind]");
|
||||
if (!swatch) return;
|
||||
e.preventDefault();
|
||||
this._openMenu(swatch, e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
_selectWell(index) {
|
||||
this._activeWell = index;
|
||||
this._applyColor(this.currentColor);
|
||||
}
|
||||
|
||||
_applyColor(color) {
|
||||
this._wells[this._activeWell] = color;
|
||||
this._onColorChange(color);
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
_addCurrent() {
|
||||
const color = this.currentColor;
|
||||
if (this.custom.includes(color)) {
|
||||
this._toast(t("imageEditPaletteExists"), "info");
|
||||
return;
|
||||
}
|
||||
const index = this.custom.indexOf(null);
|
||||
if (index < 0) {
|
||||
this._toast(t("imageEditPaletteFull"), "error");
|
||||
return;
|
||||
}
|
||||
this._setSlot("custom", index, color);
|
||||
}
|
||||
|
||||
_setSlot(kind, index, color) {
|
||||
this[kind][index] = color;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ base: this.base, custom: this.custom }));
|
||||
} catch { /* 保存できなくてもこのセッション中は使える */ }
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
_openMenu(swatch, x, y) {
|
||||
this.closeMenu();
|
||||
const { kind, index } = swatch.dataset;
|
||||
const color = this[kind][index];
|
||||
const items = [
|
||||
["imageEditPaletteRegisterCurrent", () => this._setSlot(kind, index, this.currentColor)],
|
||||
[color ? "imageEditPaletteEditColor" : "imageEditPalettePickColor", () => this._openPicker(kind, index, x, y)],
|
||||
];
|
||||
if (kind === "custom" && color) {
|
||||
items.push(["imageEditPaletteDelete", () => this._setSlot(kind, index, null)]);
|
||||
}
|
||||
if (kind === "base" && color !== DEFAULT_BASE[index]) {
|
||||
items.push(["imageEditPaletteResetBase", () => this._setSlot(kind, index, DEFAULT_BASE[index])]);
|
||||
}
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "ie-palette-menu";
|
||||
for (const [key, action] of items) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = t(key);
|
||||
button.addEventListener("click", () => { this.closeMenu(); action(); });
|
||||
menu.appendChild(button);
|
||||
}
|
||||
this._content.closest("#wfm-tab-image-edit").appendChild(menu);
|
||||
menu.style.left = `${Math.min(x, window.innerWidth - menu.offsetWidth - 4)}px`;
|
||||
menu.style.top = `${Math.min(y, window.innerHeight - menu.offsetHeight - 4)}px`;
|
||||
this._menu = menu;
|
||||
document.addEventListener("pointerdown", this._closeMenuOnOutside, true);
|
||||
document.addEventListener("keydown", this._closeMenuOnEscape, true);
|
||||
}
|
||||
|
||||
_openPicker(kind, index, x, y) {
|
||||
this._pickerTarget = { kind, index };
|
||||
this._picker.value = this[kind][index] ?? this.currentColor;
|
||||
this._picker.style.left = `${x}px`;
|
||||
this._picker.style.top = `${y}px`;
|
||||
this._picker.click();
|
||||
}
|
||||
|
||||
_onPickerInput() {
|
||||
const { kind, index } = this._pickerTarget;
|
||||
this._setSlot(kind, index, this._picker.value);
|
||||
this._applyColor(this._picker.value);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export class DrawTool {
|
||||
this.brushSize = 20;
|
||||
this.hardness = 0.8; // 0.0 = very soft, 1.0 = hard edge
|
||||
this.opacity = 1.0;
|
||||
this.color = "#ff0000";
|
||||
this.color = "#000000";
|
||||
this.mode = "draw"; // "draw" | "erase"
|
||||
this.spacing = 0.25; // stamp interval as fraction of brushSize (image brush only)
|
||||
this.angle = 0; // degrees [0, 359]
|
||||
|
||||
+16
-1
@@ -167,6 +167,15 @@ export function refreshLabLiveDefaults() {
|
||||
_renderAllColumns();
|
||||
}
|
||||
|
||||
// Loads plan data built elsewhere (AI TOOL Chat's ```labplan block → Storyboard) into the Lab
|
||||
// tab as an unsaved plan, then switches to GenerateUI > Lab so the user can review and Run it.
|
||||
// The user saves it with Lab's own Save button (which also builds the index thumbnail).
|
||||
export function openLabPlanData(data) {
|
||||
_applyPlanData(null, data);
|
||||
document.querySelector('.wfm-tab[data-tab="generate"]')?.click();
|
||||
document.querySelector('.wfm-gen-subtab-btn[data-subtab="lab"]')?.click();
|
||||
}
|
||||
|
||||
function _initDropZone() {
|
||||
const dropZone = document.getElementById("wfm-lab-drop-zone");
|
||||
const fileInput = document.getElementById("wfm-lab-file-input");
|
||||
@@ -679,12 +688,18 @@ function _cellLabel(col, kf) {
|
||||
return `<span class="wfm-lab-cell-prompt-line">P: ${escapeHtml(p)}</span><span class="wfm-lab-cell-prompt-line">N: ${escapeHtml(n)}</span>`;
|
||||
}
|
||||
if (col === "ksampler") {
|
||||
// Same six fields _isEmptyValue checks — a keyframe that only sets the seed (e.g. a
|
||||
// Storyboard plan's fixed seed) or the sampler/scheduler used to read "(workflow
|
||||
// default)" even though Run does apply it.
|
||||
const v = kf.value || {};
|
||||
if (v.steps == null && v.cfg == null && v.denoise == null) return t("labUseWorkflowDefault");
|
||||
const parts = [];
|
||||
if (v.steps != null) parts.push(`Step${v.steps}`);
|
||||
if (v.cfg != null) parts.push(`CFG${v.cfg}`);
|
||||
if (v.denoise != null) parts.push(`Denoise${v.denoise}`);
|
||||
if (v.sampler_name) parts.push(v.sampler_name);
|
||||
if (v.scheduler) parts.push(v.scheduler);
|
||||
if (v.seed != null) parts.push(`Seed ${v.seed}`);
|
||||
if (!parts.length) return t("labUseWorkflowDefault");
|
||||
return escapeHtml(parts.join(" / "));
|
||||
}
|
||||
return "";
|
||||
|
||||
@@ -672,7 +672,8 @@ function extractPromptsAPI(wf) {
|
||||
const srcNode = wf[String(val[0])];
|
||||
// TextEncodeFibo2(Fibo 2.0)も同形。promptはPreviewAny→プロンプト強化スイッチ経由でリンクされる。
|
||||
if (srcNode?.class_type === "TextEncodeMageFlowEdit" || srcNode?.class_type === "TextEncodeBooguEdit"
|
||||
|| srcNode?.class_type === "TextEncodeFibo2") {
|
||||
|| srcNode?.class_type === "TextEncodeFibo2" || srcNode?.class_type === "PH3ImageConditioning"
|
||||
|| srcNode?.class_type === "MiniMaxH3ImageConditioning") {
|
||||
const raw = isPos ? srcNode.inputs?.prompt : srcNode.inputs?.negative_prompt;
|
||||
const text = Array.isArray(raw) ? resolveLinkedText(wf, raw[0], raw[1] ?? 0) : raw;
|
||||
if (text && typeof text === "string") { if (isPos) pos.add(text); else neg.add(text); }
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* ocr-tools.js — AI TOOL > TOOLS: OCR (text extraction) with Ollama OCR models.
|
||||
*
|
||||
* Ported from ComfyUI Comic Creator's OCR tab (static/js/ocr-core.js + main/29-ocr-tab.js),
|
||||
* keeping only the general-purpose part: read the text of one image, optionally with boxes.
|
||||
* What carried over from CC's measurements:
|
||||
* - Send a JPEG whose long side is <= 1600 px with transparent areas filled white.
|
||||
* - temperature 0; retry once on HTTP 500 (the runner can drop right after a model swap).
|
||||
* - LightOnOCR sometimes returns nothing or loops on dense images; a grayscale copy often works,
|
||||
* so an empty / repetitive result is retried once in grayscale.
|
||||
* - Per-model prompts: PaddleOCR-VL "OCR:", GLM-OCR "Text Recognition:".
|
||||
*
|
||||
* Measured in WFS (2026-11-11, mixed JA/EN test page with a vertical column):
|
||||
* LightOnOCR-3 4b text-only 1.1 s / boxes 7.9 s, 100 % PaddleOCR-VL-manga 3.9 s, 100 %
|
||||
* unlimited-ocr (DeepSeek-OCR) 14 s, 100 %, always emits boxes
|
||||
* PaddleOCR-VL 1.6 11 s and GLM-OCR 15 s missed the vertical column (94 %).
|
||||
* Hence LightOnOCR is preferred, and boxes are only requested when the user asks for them.
|
||||
*/
|
||||
|
||||
export const SEND_MAX_SIDE = 1600;
|
||||
|
||||
// Preference order when picking the default OCR model from what Ollama has installed.
|
||||
const MODEL_PREFERENCE = [/lighton/i, /paddleocr.*manga/i, /paddleocr/i, /deepseek.*ocr|unlimited-ocr/i, /glm-ocr/i, /ocr/i];
|
||||
|
||||
export const BOX_PROMPT = "Detect all text regions and output each as text with bounding box [x1,y1,x2,y2] normalized to 0-1000.";
|
||||
const PLAIN_PROMPT = "Extract all text from this image.";
|
||||
|
||||
/** Installed Ollama models that look like OCR models, best first. */
|
||||
export async function listOcrModels(baseUrl) {
|
||||
const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/api/tags`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const names = ((await res.json()).models || []).map((m) => m.name).filter((n) => /ocr/i.test(n));
|
||||
const rank = (n) => { const i = MODEL_PREFERENCE.findIndex((re) => re.test(n)); return i < 0 ? 99 : i; };
|
||||
return names.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
||||
}
|
||||
|
||||
/** Can this model return text positions? (LightOnOCR on request, DeepSeek-OCR always) */
|
||||
export function supportsBoxes(model) {
|
||||
return /lighton|unlimited-ocr|deepseek.*ocr/i.test(String(model || ""));
|
||||
}
|
||||
|
||||
export function ocrPrompt(model, withBoxes) {
|
||||
const m = String(model || "");
|
||||
if (/lighton/i.test(m)) return withBoxes ? BOX_PROMPT : PLAIN_PROMPT;
|
||||
if (/paddle/i.test(m)) return "OCR:";
|
||||
if (/glm-ocr/i.test(m)) return "Text Recognition:";
|
||||
if (/unlimited-ocr|deepseek.*ocr/i.test(m)) return "Free OCR.";
|
||||
return PLAIN_PROMPT;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const clamp01 = (v) => Math.min(1, Math.max(0, v));
|
||||
|
||||
function makeBox(label, x1, y1, x2, y2, text) {
|
||||
const a = clamp01(Math.min(x1, x2)), b = clamp01(Math.min(y1, y2));
|
||||
const c = clamp01(Math.max(x1, x2)), d = clamp01(Math.max(y1, y2));
|
||||
if (c - a < 0.004 || d - b < 0.004) return null;
|
||||
return { label: label || "text", x1: a, y1: b, x2: c, y2: d, text: cleanLine(text) };
|
||||
}
|
||||
|
||||
// Markdown decorations the OCR models add (headings, bold, rules)
|
||||
function cleanLine(s) {
|
||||
return String(s || "")
|
||||
.replace(/^\s*#{1,6}\s*/gm, "")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||||
.replace(/^\s*(-{3,}|\*{3,}|_{3,})\s*$/gm, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Boxes from either syntax (coordinates normalized 0-1000, x1,y1,x2,y2):
|
||||
* LightOnOCR ` text`
|
||||
* DeepSeek-OCR `label [x1, y1, x2, y2]text` (one per line)
|
||||
* Returns [{ label, x1, y1, x2, y2, text }] with 0-1 coordinates.
|
||||
*/
|
||||
export function parseOcrBoxes(raw) {
|
||||
const s = String(raw || "");
|
||||
const out = [];
|
||||
const md = s.split(/(?=!\[[^\]]*\]\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\))/);
|
||||
for (const chunk of md) {
|
||||
const m = chunk.match(/^!\[([^\]]*)\]\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)([\s\S]*)$/);
|
||||
if (!m) continue;
|
||||
const box = makeBox(m[1], +m[2] / 1000, +m[3] / 1000, +m[4] / 1000, +m[5] / 1000, m[6]);
|
||||
if (box) out.push(box);
|
||||
}
|
||||
if (out.length) return out;
|
||||
for (const line of s.split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([a-z_]+)?\s*\[\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\]\s*(.*)$/i);
|
||||
if (!m) continue;
|
||||
const box = makeBox(m[1], +m[2] / 1000, +m[3] / 1000, +m[4] / 1000, +m[5] / 1000, m[6]);
|
||||
if (box) out.push(box);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Plain text of an OCR reply: box tokens and Markdown decorations removed, blank runs collapsed. */
|
||||
export function ocrPlainText(raw, boxes = null) {
|
||||
const list = boxes || parseOcrBoxes(raw);
|
||||
if (list.length) return list.map((b) => b.text).filter(Boolean).join("\n");
|
||||
// Model special tokens (<|ref|> etc.) and Markdown decorations removed, line by line;
|
||||
// blank lines between paragraphs are kept but never more than one in a row.
|
||||
return String(raw || "").replace(/<\|[^|]*\|>/g, "")
|
||||
.split(/\r?\n/).map((l) => cleanLine(l))
|
||||
.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
|
||||
// Empty, or the same line over and over (LightOnOCR's failure mode on dense images)
|
||||
export function ocrLooksFailed(text) {
|
||||
const lines = String(text || "").split(/\n/).map((l) => l.trim()).filter(Boolean);
|
||||
if (!lines.length) return true;
|
||||
return lines.length >= 30 && new Set(lines).size / lines.length < 0.5;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Image + model call
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** base64 image → JPEG base64 (long side <= maxSide, white background, optional grayscale). */
|
||||
export async function prepareOcrImage(base64, mimeType, { maxSide = SEND_MAX_SIDE, gray = false } = {}) {
|
||||
const img = await new Promise((resolve, reject) => {
|
||||
const el = new Image();
|
||||
el.onload = () => resolve(el);
|
||||
el.onerror = () => reject(new Error("image load failed"));
|
||||
el.src = `data:${mimeType || "image/png"};base64,${base64}`;
|
||||
});
|
||||
const k = Math.min(1, maxSide / Math.max(img.naturalWidth || 1, img.naturalHeight || 1));
|
||||
const cv = document.createElement("canvas");
|
||||
cv.width = Math.max(1, Math.round(img.naturalWidth * k));
|
||||
cv.height = Math.max(1, Math.round(img.naturalHeight * k));
|
||||
const cx = cv.getContext("2d");
|
||||
cx.fillStyle = "#fff";
|
||||
cx.fillRect(0, 0, cv.width, cv.height);
|
||||
cx.drawImage(img, 0, 0, cv.width, cv.height);
|
||||
if (gray) {
|
||||
const d = cx.getImageData(0, 0, cv.width, cv.height), p = d.data;
|
||||
for (let i = 0; i < p.length; i += 4) {
|
||||
const y = (p[i] * 299 + p[i + 1] * 587 + p[i + 2] * 114) / 1000;
|
||||
p[i] = p[i + 1] = p[i + 2] = y;
|
||||
}
|
||||
cx.putImageData(d, 0, 0);
|
||||
}
|
||||
return cv.toDataURL("image/jpeg", 0.92).split(",")[1];
|
||||
}
|
||||
|
||||
async function ollamaOcrChat(baseUrl, model, prompt, b64, retry = true) {
|
||||
const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model, stream: false, think: false,
|
||||
messages: [{ role: "user", content: prompt, images: [b64] }],
|
||||
options: { temperature: 0, num_predict: 4096 },
|
||||
}),
|
||||
});
|
||||
if (res.status === 500 && retry) {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
return ollamaOcrChat(baseUrl, model, prompt, b64, false);
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${(await res.text().catch(() => "")).slice(0, 200)}`);
|
||||
return (await res.json()).message?.content || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one image. Returns { text, boxes, retriedGray, ms }.
|
||||
* withBoxes only takes effect for models that support positions (supportsBoxes).
|
||||
*/
|
||||
export async function runOcr({ baseUrl, model, base64, mimeType, withBoxes = false }) {
|
||||
const started = performance.now();
|
||||
const prompt = ocrPrompt(model, withBoxes && supportsBoxes(model));
|
||||
const read = async (gray) => {
|
||||
const raw = await ollamaOcrChat(baseUrl, model, prompt, await prepareOcrImage(base64, mimeType, { gray }));
|
||||
// Only boxes with text: the result shows one line per box, so box #n == line #n
|
||||
// (image regions without text would shift the numbering).
|
||||
const all = parseOcrBoxes(raw);
|
||||
const boxes = all.filter((b) => b.text);
|
||||
return { boxes, text: all.length ? boxes.map((b) => b.text).join("\n") : ocrPlainText(raw) };
|
||||
};
|
||||
let r = await read(false);
|
||||
let retriedGray = false;
|
||||
if (ocrLooksFailed(r.text)) {
|
||||
const g = await read(true);
|
||||
if (!ocrLooksFailed(g.text) || g.text.length > r.text.length) { r = g; retriedGray = true; }
|
||||
}
|
||||
return { ...r, retriedGray, ms: Math.round(performance.now() - started) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw boxes over an <img> shown with object-fit: contain inside `container`.
|
||||
* Each box gets a small numbered tag; clicking it calls onPick(index).
|
||||
*/
|
||||
export function renderOcrOverlay(container, img, boxes, onPick) {
|
||||
container.innerHTML = "";
|
||||
if (!img || !boxes?.length || !img.naturalWidth) { container.style.display = "none"; return; }
|
||||
const cw = img.clientWidth, ch = img.clientHeight;
|
||||
const k = Math.min(cw / img.naturalWidth, ch / img.naturalHeight);
|
||||
const w = img.naturalWidth * k, h = img.naturalHeight * k;
|
||||
const ox = img.offsetLeft + (cw - w) / 2, oy = img.offsetTop + (ch - h) / 2;
|
||||
boxes.forEach((b, i) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "wfm-ai-ocr-box";
|
||||
el.style.left = `${ox + b.x1 * w}px`;
|
||||
el.style.top = `${oy + b.y1 * h}px`;
|
||||
el.style.width = `${(b.x2 - b.x1) * w}px`;
|
||||
el.style.height = `${(b.y2 - b.y1) * h}px`;
|
||||
el.title = b.text;
|
||||
const tag = document.createElement("span");
|
||||
tag.className = "wfm-ai-ocr-box-tag";
|
||||
tag.textContent = String(i + 1);
|
||||
el.appendChild(tag);
|
||||
el.addEventListener("click", (e) => { e.stopPropagation(); onPick?.(i); });
|
||||
container.appendChild(el);
|
||||
});
|
||||
container.style.display = "";
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { escapeHtml } from "./util.js";
|
||||
import { comfyUI } from "./comfyui-client.js";
|
||||
import { comfyEditor } from "./comfyui-editor.js";
|
||||
import { semanticErrorMessage } from "./gallery-semantic.js";
|
||||
import { revealInGallery } from "./gallery-tab.js";
|
||||
|
||||
const API = {
|
||||
status: "/wfm/tagspace/status",
|
||||
@@ -203,7 +204,13 @@ function bindShell() {
|
||||
});
|
||||
$("wfm-ts-detail").addEventListener("click", (e) => {
|
||||
const id = e.target.closest("[data-ts-toggle]")?.dataset.tsToggle;
|
||||
if (id) return toggleChip(id);
|
||||
if (id) return toggleChip(id, pickedTags(state.items.get(id)));
|
||||
const reveal = e.target.closest("[data-ts-reveal]")?.dataset.tsReveal;
|
||||
if (reveal) {
|
||||
hideZoom();
|
||||
revealInGallery(reveal).then((ok) => { if (!ok) showToast(t("tsRevealFailed"), "error"); });
|
||||
return;
|
||||
}
|
||||
const tag = e.target.closest("[data-ts-pick]")?.dataset.tsPick;
|
||||
if (tag != null) {
|
||||
const set = state.picked.tags;
|
||||
@@ -860,11 +867,17 @@ function renderDetail() {
|
||||
: it.kind === "tag" ? `<div class="wfm-ts-muted">${escapeHtml(sourcesText(it))}</div>`
|
||||
: (it.tags?.length ? tagPicker(it) : `<div class="wfm-ts-detail-text">${escapeHtml(t("tsImageNoTags"))}</div>`) +
|
||||
`<div class="wfm-ts-muted wfm-ts-path">${escapeHtml(it.id)}</div>`;
|
||||
// タグを選んでいるときは「選択したタグを追加」(選んだタグだけ)、選んでいなければ全体を追加する
|
||||
const nPicked = pickedTags(it).length;
|
||||
const btnLabel = inChips ? t("tsRemove") : nPicked ? t("tsAddPicked", nPicked) : t("tsAdd");
|
||||
const btn = !canAdd(it) ? ""
|
||||
: `<button class="wfm-btn wfm-btn-sm ${inChips ? "" : "wfm-btn-primary"}" data-ts-toggle="${escapeHtml(it.id)}">${escapeHtml(t(inChips ? "tsRemove" : "tsAdd"))}</button>`;
|
||||
: `<button class="wfm-btn wfm-btn-sm ${inChips ? "" : "wfm-btn-primary"}" data-ts-toggle="${escapeHtml(it.id)}">${escapeHtml(btnLabel)}</button>`;
|
||||
// Gallery点は、ギャラリータブでその画像を表示するボタンも置く
|
||||
const reveal = it.kind === "image"
|
||||
? `<button class="wfm-btn wfm-btn-sm" data-ts-reveal="${escapeHtml(it.id)}">${escapeHtml(t("tsOpenInGallery"))}</button>` : "";
|
||||
// 追加/削除ボタンは見出し行に置く(本文が長くてもスクロールせずに押せる)
|
||||
box.innerHTML = `<div class="wfm-ts-detail-head"><span class="wfm-ts-detail-title"><b>${escapeHtml(itemTitle(it))}</b> ` +
|
||||
`<span class="wfm-ts-muted">${escapeHtml(t(`tsKind_${it.kind}`))}</span></span>${btn}</div>${thumb}${body}`;
|
||||
`<span class="wfm-ts-muted">${escapeHtml(t(`tsKind_${it.kind}`))}</span></span>${reveal}${btn}</div>${thumb}${body}`;
|
||||
}
|
||||
|
||||
/** カード・Gallery画像のプロンプトのタグ: クリックで選び、選んだタグで空間検索する */
|
||||
@@ -950,18 +963,33 @@ function canAdd(it) {
|
||||
return !!it && (it.kind !== "image" || it.tags?.length > 0);
|
||||
}
|
||||
|
||||
function chipText(it) {
|
||||
return it.kind === "card" ? it.prompt : it.kind === "image" ? it.tags.join(", ") : it.label;
|
||||
/** タグをプロンプトに入れる形にする。タグは分解時にエスケープを外しているので、
|
||||
* カッコを \( \) に戻す(そのままだと "rapunzel (disney)" の (disney) が強調指定になる) */
|
||||
function promptTag(tag) {
|
||||
return tag.replace(/[()]/g, "\\$&");
|
||||
}
|
||||
|
||||
function toggleChip(id) {
|
||||
function chipText(it) {
|
||||
return it.kind === "card" ? it.prompt : it.kind === "image" ? it.tags.map(promptTag).join(", ") : promptTag(it.label);
|
||||
}
|
||||
|
||||
/** 選択中パネルで選んだタグ(プロンプトの順)。その要素のものでなければ空 */
|
||||
function pickedTags(it) {
|
||||
if (!it || state.picked.id !== it.id || !it.tags?.length) return [];
|
||||
return it.tags.filter((tg) => state.picked.tags.has(tg));
|
||||
}
|
||||
|
||||
/** チップの追加/削除。tags を渡すと、全体ではなくそのタグだけを1つのチップとして追加する */
|
||||
function toggleChip(id, tags = []) {
|
||||
const i = state.chips.findIndex((c) => c.id === id);
|
||||
if (i >= 0) {
|
||||
state.chips.splice(i, 1);
|
||||
} else {
|
||||
const it = state.items.get(id);
|
||||
if (!canAdd(it)) return;
|
||||
state.chips.push({ id, kind: it.kind, label: itemTitle(it), text: chipText(it) });
|
||||
state.chips.push(tags.length
|
||||
? { id, kind: it.kind, label: t("tsChipPicked", itemTitle(it), tags.length), text: tags.map(promptTag).join(", ") }
|
||||
: { id, kind: it.kind, label: itemTitle(it), text: chipText(it) });
|
||||
}
|
||||
refreshChips();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
three.js r187 - MIT, Copyright 2010-2026 three.js authors
|
||||
|
||||
The MIT License
|
||||
|
||||
Copyright © 2010-2026 three.js authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
=== @pixiv/three-vrm 3.5.5 ===
|
||||
Copyright (c) 2019-2026 pixiv Inc.
|
||||
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
=== @sparkjsdev/spark 2.4.0 ===
|
||||
The MIT License
|
||||
|
||||
Copyright © 2025 WORLD LABS TECHNOLOGIES, INC.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,8 @@
|
||||
Gallery 3D viewer bundle (glb / gltf / vrm / ply / spz).
|
||||
three 0.187.0, @pixiv/three-vrm 3.5.5, @sparkjsdev/spark 2.4.0 (see LICENSE)
|
||||
Source entry: entry.js.txt (rename to entry.js to rebuild)
|
||||
Built with:
|
||||
esbuild entry.js --bundle --format=esm --splitting --minify --target=es2022 \
|
||||
--outdir=out --entry-names=three-viewer --chunk-names=chunk-[hash]
|
||||
Spark is a separate chunk, loaded only when a Gaussian splat is opened.
|
||||
draco/ is copied from three/examples/jsm/libs/draco/gltf.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -0,0 +1,116 @@
|
||||
var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.arrayIteratorImpl=function(h){var n=0;return function(){return n<h.length?{done:!1,value:h[n++]}:{done:!0}}};$jscomp.arrayIterator=function(h){return{next:$jscomp.arrayIteratorImpl(h)}};$jscomp.makeIterator=function(h){var n="undefined"!=typeof Symbol&&Symbol.iterator&&h[Symbol.iterator];return n?n.call(h):$jscomp.arrayIterator(h)};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.SIMPLE_FROUND_POLYFILL=!1;
|
||||
$jscomp.ISOLATE_POLYFILLS=!1;$jscomp.FORCE_POLYFILL_PROMISE=!1;$jscomp.FORCE_POLYFILL_PROMISE_WHEN_NO_UNHANDLED_REJECTION=!1;$jscomp.getGlobal=function(h){h=["object"==typeof globalThis&&globalThis,h,"object"==typeof window&&window,"object"==typeof self&&self,"object"==typeof global&&global];for(var n=0;n<h.length;++n){var k=h[n];if(k&&k.Math==Math)return k}throw Error("Cannot find global object");};$jscomp.global=$jscomp.getGlobal(this);
|
||||
$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(h,n,k){if(h==Array.prototype||h==Object.prototype)return h;h[n]=k.value;return h};$jscomp.IS_SYMBOL_NATIVE="function"===typeof Symbol&&"symbol"===typeof Symbol("x");$jscomp.TRUST_ES6_POLYFILLS=!$jscomp.ISOLATE_POLYFILLS||$jscomp.IS_SYMBOL_NATIVE;$jscomp.polyfills={};$jscomp.propertyToPolyfillSymbol={};$jscomp.POLYFILL_PREFIX="$jscp$";
|
||||
var $jscomp$lookupPolyfilledValue=function(h,n){var k=$jscomp.propertyToPolyfillSymbol[n];if(null==k)return h[n];k=h[k];return void 0!==k?k:h[n]};$jscomp.polyfill=function(h,n,k,p){n&&($jscomp.ISOLATE_POLYFILLS?$jscomp.polyfillIsolated(h,n,k,p):$jscomp.polyfillUnisolated(h,n,k,p))};
|
||||
$jscomp.polyfillUnisolated=function(h,n,k,p){k=$jscomp.global;h=h.split(".");for(p=0;p<h.length-1;p++){var l=h[p];if(!(l in k))return;k=k[l]}h=h[h.length-1];p=k[h];n=n(p);n!=p&&null!=n&&$jscomp.defineProperty(k,h,{configurable:!0,writable:!0,value:n})};
|
||||
$jscomp.polyfillIsolated=function(h,n,k,p){var l=h.split(".");h=1===l.length;p=l[0];p=!h&&p in $jscomp.polyfills?$jscomp.polyfills:$jscomp.global;for(var y=0;y<l.length-1;y++){var f=l[y];if(!(f in p))return;p=p[f]}l=l[l.length-1];k=$jscomp.IS_SYMBOL_NATIVE&&"es6"===k?p[l]:null;n=n(k);null!=n&&(h?$jscomp.defineProperty($jscomp.polyfills,l,{configurable:!0,writable:!0,value:n}):n!==k&&(void 0===$jscomp.propertyToPolyfillSymbol[l]&&(k=1E9*Math.random()>>>0,$jscomp.propertyToPolyfillSymbol[l]=$jscomp.IS_SYMBOL_NATIVE?
|
||||
$jscomp.global.Symbol(l):$jscomp.POLYFILL_PREFIX+k+"$"+l),$jscomp.defineProperty(p,$jscomp.propertyToPolyfillSymbol[l],{configurable:!0,writable:!0,value:n})))};
|
||||
$jscomp.polyfill("Promise",function(h){function n(){this.batch_=null}function k(f){return f instanceof l?f:new l(function(q,u){q(f)})}if(h&&(!($jscomp.FORCE_POLYFILL_PROMISE||$jscomp.FORCE_POLYFILL_PROMISE_WHEN_NO_UNHANDLED_REJECTION&&"undefined"===typeof $jscomp.global.PromiseRejectionEvent)||!$jscomp.global.Promise||-1===$jscomp.global.Promise.toString().indexOf("[native code]")))return h;n.prototype.asyncExecute=function(f){if(null==this.batch_){this.batch_=[];var q=this;this.asyncExecuteFunction(function(){q.executeBatch_()})}this.batch_.push(f)};
|
||||
var p=$jscomp.global.setTimeout;n.prototype.asyncExecuteFunction=function(f){p(f,0)};n.prototype.executeBatch_=function(){for(;this.batch_&&this.batch_.length;){var f=this.batch_;this.batch_=[];for(var q=0;q<f.length;++q){var u=f[q];f[q]=null;try{u()}catch(A){this.asyncThrow_(A)}}}this.batch_=null};n.prototype.asyncThrow_=function(f){this.asyncExecuteFunction(function(){throw f;})};var l=function(f){this.state_=0;this.result_=void 0;this.onSettledCallbacks_=[];this.isRejectionHandled_=!1;var q=this.createResolveAndReject_();
|
||||
try{f(q.resolve,q.reject)}catch(u){q.reject(u)}};l.prototype.createResolveAndReject_=function(){function f(A){return function(F){u||(u=!0,A.call(q,F))}}var q=this,u=!1;return{resolve:f(this.resolveTo_),reject:f(this.reject_)}};l.prototype.resolveTo_=function(f){if(f===this)this.reject_(new TypeError("A Promise cannot resolve to itself"));else if(f instanceof l)this.settleSameAsPromise_(f);else{a:switch(typeof f){case "object":var q=null!=f;break a;case "function":q=!0;break a;default:q=!1}q?this.resolveToNonPromiseObj_(f):
|
||||
this.fulfill_(f)}};l.prototype.resolveToNonPromiseObj_=function(f){var q=void 0;try{q=f.then}catch(u){this.reject_(u);return}"function"==typeof q?this.settleSameAsThenable_(q,f):this.fulfill_(f)};l.prototype.reject_=function(f){this.settle_(2,f)};l.prototype.fulfill_=function(f){this.settle_(1,f)};l.prototype.settle_=function(f,q){if(0!=this.state_)throw Error("Cannot settle("+f+", "+q+"): Promise already settled in state"+this.state_);this.state_=f;this.result_=q;2===this.state_&&this.scheduleUnhandledRejectionCheck_();
|
||||
this.executeOnSettledCallbacks_()};l.prototype.scheduleUnhandledRejectionCheck_=function(){var f=this;p(function(){if(f.notifyUnhandledRejection_()){var q=$jscomp.global.console;"undefined"!==typeof q&&q.error(f.result_)}},1)};l.prototype.notifyUnhandledRejection_=function(){if(this.isRejectionHandled_)return!1;var f=$jscomp.global.CustomEvent,q=$jscomp.global.Event,u=$jscomp.global.dispatchEvent;if("undefined"===typeof u)return!0;"function"===typeof f?f=new f("unhandledrejection",{cancelable:!0}):
|
||||
"function"===typeof q?f=new q("unhandledrejection",{cancelable:!0}):(f=$jscomp.global.document.createEvent("CustomEvent"),f.initCustomEvent("unhandledrejection",!1,!0,f));f.promise=this;f.reason=this.result_;return u(f)};l.prototype.executeOnSettledCallbacks_=function(){if(null!=this.onSettledCallbacks_){for(var f=0;f<this.onSettledCallbacks_.length;++f)y.asyncExecute(this.onSettledCallbacks_[f]);this.onSettledCallbacks_=null}};var y=new n;l.prototype.settleSameAsPromise_=function(f){var q=this.createResolveAndReject_();
|
||||
f.callWhenSettled_(q.resolve,q.reject)};l.prototype.settleSameAsThenable_=function(f,q){var u=this.createResolveAndReject_();try{f.call(q,u.resolve,u.reject)}catch(A){u.reject(A)}};l.prototype.then=function(f,q){function u(w,B){return"function"==typeof w?function(R){try{A(w(R))}catch(Z){F(Z)}}:B}var A,F,v=new l(function(w,B){A=w;F=B});this.callWhenSettled_(u(f,A),u(q,F));return v};l.prototype.catch=function(f){return this.then(void 0,f)};l.prototype.callWhenSettled_=function(f,q){function u(){switch(A.state_){case 1:f(A.result_);
|
||||
break;case 2:q(A.result_);break;default:throw Error("Unexpected state: "+A.state_);}}var A=this;null==this.onSettledCallbacks_?y.asyncExecute(u):this.onSettledCallbacks_.push(u);this.isRejectionHandled_=!0};l.resolve=k;l.reject=function(f){return new l(function(q,u){u(f)})};l.race=function(f){return new l(function(q,u){for(var A=$jscomp.makeIterator(f),F=A.next();!F.done;F=A.next())k(F.value).callWhenSettled_(q,u)})};l.all=function(f){var q=$jscomp.makeIterator(f),u=q.next();return u.done?k([]):new l(function(A,
|
||||
F){function v(R){return function(Z){w[R]=Z;B--;0==B&&A(w)}}var w=[],B=0;do w.push(void 0),B++,k(u.value).callWhenSettled_(v(w.length-1),F),u=q.next();while(!u.done)})};return l},"es6","es3");$jscomp.owns=function(h,n){return Object.prototype.hasOwnProperty.call(h,n)};$jscomp.assign=$jscomp.TRUST_ES6_POLYFILLS&&"function"==typeof Object.assign?Object.assign:function(h,n){for(var k=1;k<arguments.length;k++){var p=arguments[k];if(p)for(var l in p)$jscomp.owns(p,l)&&(h[l]=p[l])}return h};
|
||||
$jscomp.polyfill("Object.assign",function(h){return h||$jscomp.assign},"es6","es3");$jscomp.checkStringArgs=function(h,n,k){if(null==h)throw new TypeError("The 'this' value for String.prototype."+k+" must not be null or undefined");if(n instanceof RegExp)throw new TypeError("First argument to String.prototype."+k+" must not be a regular expression");return h+""};
|
||||
$jscomp.polyfill("String.prototype.startsWith",function(h){return h?h:function(n,k){var p=$jscomp.checkStringArgs(this,n,"startsWith");n+="";var l=p.length,y=n.length;k=Math.max(0,Math.min(k|0,p.length));for(var f=0;f<y&&k<l;)if(p[k++]!=n[f++])return!1;return f>=y}},"es6","es3");
|
||||
$jscomp.polyfill("Array.prototype.copyWithin",function(h){function n(k){k=Number(k);return Infinity===k||-Infinity===k?k:k|0}return h?h:function(k,p,l){var y=this.length;k=n(k);p=n(p);l=void 0===l?y:n(l);k=0>k?Math.max(y+k,0):Math.min(k,y);p=0>p?Math.max(y+p,0):Math.min(p,y);l=0>l?Math.max(y+l,0):Math.min(l,y);if(k<p)for(;p<l;)p in this?this[k++]=this[p++]:(delete this[k++],p++);else for(l=Math.min(l,y+p-k),k+=l-p;l>p;)--l in this?this[--k]=this[l]:delete this[--k];return this}},"es6","es3");
|
||||
$jscomp.typedArrayCopyWithin=function(h){return h?h:Array.prototype.copyWithin};$jscomp.polyfill("Int8Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint8Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint8ClampedArray.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Int16Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");
|
||||
$jscomp.polyfill("Uint16Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Int32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Uint32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Float32Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");$jscomp.polyfill("Float64Array.prototype.copyWithin",$jscomp.typedArrayCopyWithin,"es6","es5");
|
||||
var DracoDecoderModule=function(){var h="undefined"!==typeof document&&document.currentScript?document.currentScript.src:void 0;"undefined"!==typeof __filename&&(h=h||__filename);return function(n){function k(e){return a.locateFile?a.locateFile(e,U):U+e}function p(e,b){if(e){var c=ia;var d=e+b;for(b=e;c[b]&&!(b>=d);)++b;if(16<b-e&&c.buffer&&ra)c=ra.decode(c.subarray(e,b));else{for(d="";e<b;){var g=c[e++];if(g&128){var t=c[e++]&63;if(192==(g&224))d+=String.fromCharCode((g&31)<<6|t);else{var aa=c[e++]&
|
||||
63;g=224==(g&240)?(g&15)<<12|t<<6|aa:(g&7)<<18|t<<12|aa<<6|c[e++]&63;65536>g?d+=String.fromCharCode(g):(g-=65536,d+=String.fromCharCode(55296|g>>10,56320|g&1023))}}else d+=String.fromCharCode(g)}c=d}}else c="";return c}function l(){var e=ja.buffer;a.HEAP8=W=new Int8Array(e);a.HEAP16=new Int16Array(e);a.HEAP32=ca=new Int32Array(e);a.HEAPU8=ia=new Uint8Array(e);a.HEAPU16=new Uint16Array(e);a.HEAPU32=Y=new Uint32Array(e);a.HEAPF32=new Float32Array(e);a.HEAPF64=new Float64Array(e)}function y(e){if(a.onAbort)a.onAbort(e);
|
||||
e="Aborted("+e+")";da(e);sa=!0;e=new WebAssembly.RuntimeError(e+". Build with -sASSERTIONS for more info.");ka(e);throw e;}function f(e){try{if(e==P&&ea)return new Uint8Array(ea);if(ma)return ma(e);throw"both async and sync fetching of the wasm failed";}catch(b){y(b)}}function q(){if(!ea&&(ta||fa)){if("function"==typeof fetch&&!P.startsWith("file://"))return fetch(P,{credentials:"same-origin"}).then(function(e){if(!e.ok)throw"failed to load wasm binary file at '"+P+"'";return e.arrayBuffer()}).catch(function(){return f(P)});
|
||||
if(na)return new Promise(function(e,b){na(P,function(c){e(new Uint8Array(c))},b)})}return Promise.resolve().then(function(){return f(P)})}function u(e){for(;0<e.length;)e.shift()(a)}function A(e){this.excPtr=e;this.ptr=e-24;this.set_type=function(b){Y[this.ptr+4>>2]=b};this.get_type=function(){return Y[this.ptr+4>>2]};this.set_destructor=function(b){Y[this.ptr+8>>2]=b};this.get_destructor=function(){return Y[this.ptr+8>>2]};this.set_refcount=function(b){ca[this.ptr>>2]=b};this.set_caught=function(b){W[this.ptr+
|
||||
12>>0]=b?1:0};this.get_caught=function(){return 0!=W[this.ptr+12>>0]};this.set_rethrown=function(b){W[this.ptr+13>>0]=b?1:0};this.get_rethrown=function(){return 0!=W[this.ptr+13>>0]};this.init=function(b,c){this.set_adjusted_ptr(0);this.set_type(b);this.set_destructor(c);this.set_refcount(0);this.set_caught(!1);this.set_rethrown(!1)};this.add_ref=function(){ca[this.ptr>>2]+=1};this.release_ref=function(){var b=ca[this.ptr>>2];ca[this.ptr>>2]=b-1;return 1===b};this.set_adjusted_ptr=function(b){Y[this.ptr+
|
||||
16>>2]=b};this.get_adjusted_ptr=function(){return Y[this.ptr+16>>2]};this.get_exception_ptr=function(){if(ua(this.get_type()))return Y[this.excPtr>>2];var b=this.get_adjusted_ptr();return 0!==b?b:this.excPtr}}function F(){function e(){if(!la&&(la=!0,a.calledRun=!0,!sa)){va=!0;u(oa);wa(a);if(a.onRuntimeInitialized)a.onRuntimeInitialized();if(a.postRun)for("function"==typeof a.postRun&&(a.postRun=[a.postRun]);a.postRun.length;)xa.unshift(a.postRun.shift());u(xa)}}if(!(0<ba)){if(a.preRun)for("function"==
|
||||
typeof a.preRun&&(a.preRun=[a.preRun]);a.preRun.length;)ya.unshift(a.preRun.shift());u(ya);0<ba||(a.setStatus?(a.setStatus("Running..."),setTimeout(function(){setTimeout(function(){a.setStatus("")},1);e()},1)):e())}}function v(){}function w(e){return(e||v).__cache__}function B(e,b){var c=w(b),d=c[e];if(d)return d;d=Object.create((b||v).prototype);d.ptr=e;return c[e]=d}function R(e){if("string"===typeof e){for(var b=0,c=0;c<e.length;++c){var d=e.charCodeAt(c);127>=d?b++:2047>=d?b+=2:55296<=d&&57343>=
|
||||
d?(b+=4,++c):b+=3}b=Array(b+1);c=0;d=b.length;if(0<d){d=c+d-1;for(var g=0;g<e.length;++g){var t=e.charCodeAt(g);if(55296<=t&&57343>=t){var aa=e.charCodeAt(++g);t=65536+((t&1023)<<10)|aa&1023}if(127>=t){if(c>=d)break;b[c++]=t}else{if(2047>=t){if(c+1>=d)break;b[c++]=192|t>>6}else{if(65535>=t){if(c+2>=d)break;b[c++]=224|t>>12}else{if(c+3>=d)break;b[c++]=240|t>>18;b[c++]=128|t>>12&63}b[c++]=128|t>>6&63}b[c++]=128|t&63}}b[c]=0}e=r.alloc(b,W);r.copy(b,W,e);return e}return e}function Z(e){if("object"===
|
||||
typeof e){var b=r.alloc(e,W);r.copy(e,W,b);return b}return e}function X(){throw"cannot construct a VoidPtr, no constructor in IDL";}function S(){this.ptr=za();w(S)[this.ptr]=this}function Q(){this.ptr=Aa();w(Q)[this.ptr]=this}function V(){this.ptr=Ba();w(V)[this.ptr]=this}function x(){this.ptr=Ca();w(x)[this.ptr]=this}function D(){this.ptr=Da();w(D)[this.ptr]=this}function G(){this.ptr=Ea();w(G)[this.ptr]=this}function H(){this.ptr=Fa();w(H)[this.ptr]=this}function E(){this.ptr=Ga();w(E)[this.ptr]=
|
||||
this}function T(){this.ptr=Ha();w(T)[this.ptr]=this}function C(){throw"cannot construct a Status, no constructor in IDL";}function I(){this.ptr=Ia();w(I)[this.ptr]=this}function J(){this.ptr=Ja();w(J)[this.ptr]=this}function K(){this.ptr=Ka();w(K)[this.ptr]=this}function L(){this.ptr=La();w(L)[this.ptr]=this}function M(){this.ptr=Ma();w(M)[this.ptr]=this}function N(){this.ptr=Na();w(N)[this.ptr]=this}function O(){this.ptr=Oa();w(O)[this.ptr]=this}function z(){this.ptr=Pa();w(z)[this.ptr]=this}function m(){this.ptr=
|
||||
Qa();w(m)[this.ptr]=this}n=void 0===n?{}:n;var a="undefined"!=typeof n?n:{},wa,ka;a.ready=new Promise(function(e,b){wa=e;ka=b});var Ra=!1,Sa=!1;a.onRuntimeInitialized=function(){Ra=!0;if(Sa&&"function"===typeof a.onModuleLoaded)a.onModuleLoaded(a)};a.onModuleParsed=function(){Sa=!0;if(Ra&&"function"===typeof a.onModuleLoaded)a.onModuleLoaded(a)};a.isVersionSupported=function(e){if("string"!==typeof e)return!1;e=e.split(".");return 2>e.length||3<e.length?!1:1==e[0]&&0<=e[1]&&5>=e[1]?!0:0!=e[0]||10<
|
||||
e[1]?!1:!0};var Ta=Object.assign({},a),ta="object"==typeof window,fa="function"==typeof importScripts,Ua="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node,U="";if(Ua){var Va=require("fs"),pa=require("path");U=fa?pa.dirname(U)+"/":__dirname+"/";var Wa=function(e,b){e=e.startsWith("file://")?new URL(e):pa.normalize(e);return Va.readFileSync(e,b?void 0:"utf8")};var ma=function(e){e=Wa(e,!0);e.buffer||(e=new Uint8Array(e));return e};var na=function(e,
|
||||
b,c){e=e.startsWith("file://")?new URL(e):pa.normalize(e);Va.readFile(e,function(d,g){d?c(d):b(g.buffer)})};1<process.argv.length&&process.argv[1].replace(/\\/g,"/");process.argv.slice(2);a.inspect=function(){return"[Emscripten Module object]"}}else if(ta||fa)fa?U=self.location.href:"undefined"!=typeof document&&document.currentScript&&(U=document.currentScript.src),h&&(U=h),U=0!==U.indexOf("blob:")?U.substr(0,U.replace(/[?#].*/,"").lastIndexOf("/")+1):"",Wa=function(e){var b=new XMLHttpRequest;b.open("GET",
|
||||
e,!1);b.send(null);return b.responseText},fa&&(ma=function(e){var b=new XMLHttpRequest;b.open("GET",e,!1);b.responseType="arraybuffer";b.send(null);return new Uint8Array(b.response)}),na=function(e,b,c){var d=new XMLHttpRequest;d.open("GET",e,!0);d.responseType="arraybuffer";d.onload=function(){200==d.status||0==d.status&&d.response?b(d.response):c()};d.onerror=c;d.send(null)};a.print||console.log.bind(console);var da=a.printErr||console.warn.bind(console);Object.assign(a,Ta);Ta=null;var ea;a.wasmBinary&&
|
||||
(ea=a.wasmBinary);"object"!=typeof WebAssembly&&y("no native wasm support detected");var ja,sa=!1,ra="undefined"!=typeof TextDecoder?new TextDecoder("utf8"):void 0,W,ia,ca,Y,ya=[],oa=[],xa=[],va=!1,ba=0,qa=null,ha=null;var P="draco_decoder_gltf.wasm";P.startsWith("data:application/octet-stream;base64,")||(P=k(P));var pd=0,qd={b:function(e,b,c){(new A(e)).init(b,c);pd++;throw e;},a:function(){y("")},d:function(e,b,c){ia.copyWithin(e,b,b+c)},c:function(e){var b=ia.length;e>>>=0;if(2147483648<e)return!1;
|
||||
for(var c=1;4>=c;c*=2){var d=b*(1+.2/c);d=Math.min(d,e+100663296);var g=Math;d=Math.max(e,d);g=g.min.call(g,2147483648,d+(65536-d%65536)%65536);a:{d=ja.buffer;try{ja.grow(g-d.byteLength+65535>>>16);l();var t=1;break a}catch(aa){}t=void 0}if(t)return!0}return!1}};(function(){function e(g,t){a.asm=g.exports;ja=a.asm.e;l();oa.unshift(a.asm.f);ba--;a.monitorRunDependencies&&a.monitorRunDependencies(ba);0==ba&&(null!==qa&&(clearInterval(qa),qa=null),ha&&(g=ha,ha=null,g()))}function b(g){e(g.instance)}
|
||||
function c(g){return q().then(function(t){return WebAssembly.instantiate(t,d)}).then(function(t){return t}).then(g,function(t){da("failed to asynchronously prepare wasm: "+t);y(t)})}var d={a:qd};ba++;a.monitorRunDependencies&&a.monitorRunDependencies(ba);if(a.instantiateWasm)try{return a.instantiateWasm(d,e)}catch(g){da("Module.instantiateWasm callback failed with error: "+g),ka(g)}(function(){return ea||"function"!=typeof WebAssembly.instantiateStreaming||P.startsWith("data:application/octet-stream;base64,")||
|
||||
P.startsWith("file://")||Ua||"function"!=typeof fetch?c(b):fetch(P,{credentials:"same-origin"}).then(function(g){return WebAssembly.instantiateStreaming(g,d).then(b,function(t){da("wasm streaming compile failed: "+t);da("falling back to ArrayBuffer instantiation");return c(b)})})})().catch(ka);return{}})();var Xa=a._emscripten_bind_VoidPtr___destroy___0=function(){return(Xa=a._emscripten_bind_VoidPtr___destroy___0=a.asm.h).apply(null,arguments)},za=a._emscripten_bind_DecoderBuffer_DecoderBuffer_0=
|
||||
function(){return(za=a._emscripten_bind_DecoderBuffer_DecoderBuffer_0=a.asm.i).apply(null,arguments)},Ya=a._emscripten_bind_DecoderBuffer_Init_2=function(){return(Ya=a._emscripten_bind_DecoderBuffer_Init_2=a.asm.j).apply(null,arguments)},Za=a._emscripten_bind_DecoderBuffer___destroy___0=function(){return(Za=a._emscripten_bind_DecoderBuffer___destroy___0=a.asm.k).apply(null,arguments)},Aa=a._emscripten_bind_AttributeTransformData_AttributeTransformData_0=function(){return(Aa=a._emscripten_bind_AttributeTransformData_AttributeTransformData_0=
|
||||
a.asm.l).apply(null,arguments)},$a=a._emscripten_bind_AttributeTransformData_transform_type_0=function(){return($a=a._emscripten_bind_AttributeTransformData_transform_type_0=a.asm.m).apply(null,arguments)},ab=a._emscripten_bind_AttributeTransformData___destroy___0=function(){return(ab=a._emscripten_bind_AttributeTransformData___destroy___0=a.asm.n).apply(null,arguments)},Ba=a._emscripten_bind_GeometryAttribute_GeometryAttribute_0=function(){return(Ba=a._emscripten_bind_GeometryAttribute_GeometryAttribute_0=
|
||||
a.asm.o).apply(null,arguments)},bb=a._emscripten_bind_GeometryAttribute___destroy___0=function(){return(bb=a._emscripten_bind_GeometryAttribute___destroy___0=a.asm.p).apply(null,arguments)},Ca=a._emscripten_bind_PointAttribute_PointAttribute_0=function(){return(Ca=a._emscripten_bind_PointAttribute_PointAttribute_0=a.asm.q).apply(null,arguments)},cb=a._emscripten_bind_PointAttribute_size_0=function(){return(cb=a._emscripten_bind_PointAttribute_size_0=a.asm.r).apply(null,arguments)},db=a._emscripten_bind_PointAttribute_GetAttributeTransformData_0=
|
||||
function(){return(db=a._emscripten_bind_PointAttribute_GetAttributeTransformData_0=a.asm.s).apply(null,arguments)},eb=a._emscripten_bind_PointAttribute_attribute_type_0=function(){return(eb=a._emscripten_bind_PointAttribute_attribute_type_0=a.asm.t).apply(null,arguments)},fb=a._emscripten_bind_PointAttribute_data_type_0=function(){return(fb=a._emscripten_bind_PointAttribute_data_type_0=a.asm.u).apply(null,arguments)},gb=a._emscripten_bind_PointAttribute_num_components_0=function(){return(gb=a._emscripten_bind_PointAttribute_num_components_0=
|
||||
a.asm.v).apply(null,arguments)},hb=a._emscripten_bind_PointAttribute_normalized_0=function(){return(hb=a._emscripten_bind_PointAttribute_normalized_0=a.asm.w).apply(null,arguments)},ib=a._emscripten_bind_PointAttribute_byte_stride_0=function(){return(ib=a._emscripten_bind_PointAttribute_byte_stride_0=a.asm.x).apply(null,arguments)},jb=a._emscripten_bind_PointAttribute_byte_offset_0=function(){return(jb=a._emscripten_bind_PointAttribute_byte_offset_0=a.asm.y).apply(null,arguments)},kb=a._emscripten_bind_PointAttribute_unique_id_0=
|
||||
function(){return(kb=a._emscripten_bind_PointAttribute_unique_id_0=a.asm.z).apply(null,arguments)},lb=a._emscripten_bind_PointAttribute___destroy___0=function(){return(lb=a._emscripten_bind_PointAttribute___destroy___0=a.asm.A).apply(null,arguments)},Da=a._emscripten_bind_AttributeQuantizationTransform_AttributeQuantizationTransform_0=function(){return(Da=a._emscripten_bind_AttributeQuantizationTransform_AttributeQuantizationTransform_0=a.asm.B).apply(null,arguments)},mb=a._emscripten_bind_AttributeQuantizationTransform_InitFromAttribute_1=
|
||||
function(){return(mb=a._emscripten_bind_AttributeQuantizationTransform_InitFromAttribute_1=a.asm.C).apply(null,arguments)},nb=a._emscripten_bind_AttributeQuantizationTransform_quantization_bits_0=function(){return(nb=a._emscripten_bind_AttributeQuantizationTransform_quantization_bits_0=a.asm.D).apply(null,arguments)},ob=a._emscripten_bind_AttributeQuantizationTransform_min_value_1=function(){return(ob=a._emscripten_bind_AttributeQuantizationTransform_min_value_1=a.asm.E).apply(null,arguments)},pb=
|
||||
a._emscripten_bind_AttributeQuantizationTransform_range_0=function(){return(pb=a._emscripten_bind_AttributeQuantizationTransform_range_0=a.asm.F).apply(null,arguments)},qb=a._emscripten_bind_AttributeQuantizationTransform___destroy___0=function(){return(qb=a._emscripten_bind_AttributeQuantizationTransform___destroy___0=a.asm.G).apply(null,arguments)},Ea=a._emscripten_bind_AttributeOctahedronTransform_AttributeOctahedronTransform_0=function(){return(Ea=a._emscripten_bind_AttributeOctahedronTransform_AttributeOctahedronTransform_0=
|
||||
a.asm.H).apply(null,arguments)},rb=a._emscripten_bind_AttributeOctahedronTransform_InitFromAttribute_1=function(){return(rb=a._emscripten_bind_AttributeOctahedronTransform_InitFromAttribute_1=a.asm.I).apply(null,arguments)},sb=a._emscripten_bind_AttributeOctahedronTransform_quantization_bits_0=function(){return(sb=a._emscripten_bind_AttributeOctahedronTransform_quantization_bits_0=a.asm.J).apply(null,arguments)},tb=a._emscripten_bind_AttributeOctahedronTransform___destroy___0=function(){return(tb=
|
||||
a._emscripten_bind_AttributeOctahedronTransform___destroy___0=a.asm.K).apply(null,arguments)},Fa=a._emscripten_bind_PointCloud_PointCloud_0=function(){return(Fa=a._emscripten_bind_PointCloud_PointCloud_0=a.asm.L).apply(null,arguments)},ub=a._emscripten_bind_PointCloud_num_attributes_0=function(){return(ub=a._emscripten_bind_PointCloud_num_attributes_0=a.asm.M).apply(null,arguments)},vb=a._emscripten_bind_PointCloud_num_points_0=function(){return(vb=a._emscripten_bind_PointCloud_num_points_0=a.asm.N).apply(null,
|
||||
arguments)},wb=a._emscripten_bind_PointCloud___destroy___0=function(){return(wb=a._emscripten_bind_PointCloud___destroy___0=a.asm.O).apply(null,arguments)},Ga=a._emscripten_bind_Mesh_Mesh_0=function(){return(Ga=a._emscripten_bind_Mesh_Mesh_0=a.asm.P).apply(null,arguments)},xb=a._emscripten_bind_Mesh_num_faces_0=function(){return(xb=a._emscripten_bind_Mesh_num_faces_0=a.asm.Q).apply(null,arguments)},yb=a._emscripten_bind_Mesh_num_attributes_0=function(){return(yb=a._emscripten_bind_Mesh_num_attributes_0=
|
||||
a.asm.R).apply(null,arguments)},zb=a._emscripten_bind_Mesh_num_points_0=function(){return(zb=a._emscripten_bind_Mesh_num_points_0=a.asm.S).apply(null,arguments)},Ab=a._emscripten_bind_Mesh___destroy___0=function(){return(Ab=a._emscripten_bind_Mesh___destroy___0=a.asm.T).apply(null,arguments)},Ha=a._emscripten_bind_Metadata_Metadata_0=function(){return(Ha=a._emscripten_bind_Metadata_Metadata_0=a.asm.U).apply(null,arguments)},Bb=a._emscripten_bind_Metadata___destroy___0=function(){return(Bb=a._emscripten_bind_Metadata___destroy___0=
|
||||
a.asm.V).apply(null,arguments)},Cb=a._emscripten_bind_Status_code_0=function(){return(Cb=a._emscripten_bind_Status_code_0=a.asm.W).apply(null,arguments)},Db=a._emscripten_bind_Status_ok_0=function(){return(Db=a._emscripten_bind_Status_ok_0=a.asm.X).apply(null,arguments)},Eb=a._emscripten_bind_Status_error_msg_0=function(){return(Eb=a._emscripten_bind_Status_error_msg_0=a.asm.Y).apply(null,arguments)},Fb=a._emscripten_bind_Status___destroy___0=function(){return(Fb=a._emscripten_bind_Status___destroy___0=
|
||||
a.asm.Z).apply(null,arguments)},Ia=a._emscripten_bind_DracoFloat32Array_DracoFloat32Array_0=function(){return(Ia=a._emscripten_bind_DracoFloat32Array_DracoFloat32Array_0=a.asm._).apply(null,arguments)},Gb=a._emscripten_bind_DracoFloat32Array_GetValue_1=function(){return(Gb=a._emscripten_bind_DracoFloat32Array_GetValue_1=a.asm.$).apply(null,arguments)},Hb=a._emscripten_bind_DracoFloat32Array_size_0=function(){return(Hb=a._emscripten_bind_DracoFloat32Array_size_0=a.asm.aa).apply(null,arguments)},Ib=
|
||||
a._emscripten_bind_DracoFloat32Array___destroy___0=function(){return(Ib=a._emscripten_bind_DracoFloat32Array___destroy___0=a.asm.ba).apply(null,arguments)},Ja=a._emscripten_bind_DracoInt8Array_DracoInt8Array_0=function(){return(Ja=a._emscripten_bind_DracoInt8Array_DracoInt8Array_0=a.asm.ca).apply(null,arguments)},Jb=a._emscripten_bind_DracoInt8Array_GetValue_1=function(){return(Jb=a._emscripten_bind_DracoInt8Array_GetValue_1=a.asm.da).apply(null,arguments)},Kb=a._emscripten_bind_DracoInt8Array_size_0=
|
||||
function(){return(Kb=a._emscripten_bind_DracoInt8Array_size_0=a.asm.ea).apply(null,arguments)},Lb=a._emscripten_bind_DracoInt8Array___destroy___0=function(){return(Lb=a._emscripten_bind_DracoInt8Array___destroy___0=a.asm.fa).apply(null,arguments)},Ka=a._emscripten_bind_DracoUInt8Array_DracoUInt8Array_0=function(){return(Ka=a._emscripten_bind_DracoUInt8Array_DracoUInt8Array_0=a.asm.ga).apply(null,arguments)},Mb=a._emscripten_bind_DracoUInt8Array_GetValue_1=function(){return(Mb=a._emscripten_bind_DracoUInt8Array_GetValue_1=
|
||||
a.asm.ha).apply(null,arguments)},Nb=a._emscripten_bind_DracoUInt8Array_size_0=function(){return(Nb=a._emscripten_bind_DracoUInt8Array_size_0=a.asm.ia).apply(null,arguments)},Ob=a._emscripten_bind_DracoUInt8Array___destroy___0=function(){return(Ob=a._emscripten_bind_DracoUInt8Array___destroy___0=a.asm.ja).apply(null,arguments)},La=a._emscripten_bind_DracoInt16Array_DracoInt16Array_0=function(){return(La=a._emscripten_bind_DracoInt16Array_DracoInt16Array_0=a.asm.ka).apply(null,arguments)},Pb=a._emscripten_bind_DracoInt16Array_GetValue_1=
|
||||
function(){return(Pb=a._emscripten_bind_DracoInt16Array_GetValue_1=a.asm.la).apply(null,arguments)},Qb=a._emscripten_bind_DracoInt16Array_size_0=function(){return(Qb=a._emscripten_bind_DracoInt16Array_size_0=a.asm.ma).apply(null,arguments)},Rb=a._emscripten_bind_DracoInt16Array___destroy___0=function(){return(Rb=a._emscripten_bind_DracoInt16Array___destroy___0=a.asm.na).apply(null,arguments)},Ma=a._emscripten_bind_DracoUInt16Array_DracoUInt16Array_0=function(){return(Ma=a._emscripten_bind_DracoUInt16Array_DracoUInt16Array_0=
|
||||
a.asm.oa).apply(null,arguments)},Sb=a._emscripten_bind_DracoUInt16Array_GetValue_1=function(){return(Sb=a._emscripten_bind_DracoUInt16Array_GetValue_1=a.asm.pa).apply(null,arguments)},Tb=a._emscripten_bind_DracoUInt16Array_size_0=function(){return(Tb=a._emscripten_bind_DracoUInt16Array_size_0=a.asm.qa).apply(null,arguments)},Ub=a._emscripten_bind_DracoUInt16Array___destroy___0=function(){return(Ub=a._emscripten_bind_DracoUInt16Array___destroy___0=a.asm.ra).apply(null,arguments)},Na=a._emscripten_bind_DracoInt32Array_DracoInt32Array_0=
|
||||
function(){return(Na=a._emscripten_bind_DracoInt32Array_DracoInt32Array_0=a.asm.sa).apply(null,arguments)},Vb=a._emscripten_bind_DracoInt32Array_GetValue_1=function(){return(Vb=a._emscripten_bind_DracoInt32Array_GetValue_1=a.asm.ta).apply(null,arguments)},Wb=a._emscripten_bind_DracoInt32Array_size_0=function(){return(Wb=a._emscripten_bind_DracoInt32Array_size_0=a.asm.ua).apply(null,arguments)},Xb=a._emscripten_bind_DracoInt32Array___destroy___0=function(){return(Xb=a._emscripten_bind_DracoInt32Array___destroy___0=
|
||||
a.asm.va).apply(null,arguments)},Oa=a._emscripten_bind_DracoUInt32Array_DracoUInt32Array_0=function(){return(Oa=a._emscripten_bind_DracoUInt32Array_DracoUInt32Array_0=a.asm.wa).apply(null,arguments)},Yb=a._emscripten_bind_DracoUInt32Array_GetValue_1=function(){return(Yb=a._emscripten_bind_DracoUInt32Array_GetValue_1=a.asm.xa).apply(null,arguments)},Zb=a._emscripten_bind_DracoUInt32Array_size_0=function(){return(Zb=a._emscripten_bind_DracoUInt32Array_size_0=a.asm.ya).apply(null,arguments)},$b=a._emscripten_bind_DracoUInt32Array___destroy___0=
|
||||
function(){return($b=a._emscripten_bind_DracoUInt32Array___destroy___0=a.asm.za).apply(null,arguments)},Pa=a._emscripten_bind_MetadataQuerier_MetadataQuerier_0=function(){return(Pa=a._emscripten_bind_MetadataQuerier_MetadataQuerier_0=a.asm.Aa).apply(null,arguments)},ac=a._emscripten_bind_MetadataQuerier_HasEntry_2=function(){return(ac=a._emscripten_bind_MetadataQuerier_HasEntry_2=a.asm.Ba).apply(null,arguments)},bc=a._emscripten_bind_MetadataQuerier_GetIntEntry_2=function(){return(bc=a._emscripten_bind_MetadataQuerier_GetIntEntry_2=
|
||||
a.asm.Ca).apply(null,arguments)},cc=a._emscripten_bind_MetadataQuerier_GetIntEntryArray_3=function(){return(cc=a._emscripten_bind_MetadataQuerier_GetIntEntryArray_3=a.asm.Da).apply(null,arguments)},dc=a._emscripten_bind_MetadataQuerier_GetDoubleEntry_2=function(){return(dc=a._emscripten_bind_MetadataQuerier_GetDoubleEntry_2=a.asm.Ea).apply(null,arguments)},ec=a._emscripten_bind_MetadataQuerier_GetStringEntry_2=function(){return(ec=a._emscripten_bind_MetadataQuerier_GetStringEntry_2=a.asm.Fa).apply(null,
|
||||
arguments)},fc=a._emscripten_bind_MetadataQuerier_NumEntries_1=function(){return(fc=a._emscripten_bind_MetadataQuerier_NumEntries_1=a.asm.Ga).apply(null,arguments)},gc=a._emscripten_bind_MetadataQuerier_GetEntryName_2=function(){return(gc=a._emscripten_bind_MetadataQuerier_GetEntryName_2=a.asm.Ha).apply(null,arguments)},hc=a._emscripten_bind_MetadataQuerier___destroy___0=function(){return(hc=a._emscripten_bind_MetadataQuerier___destroy___0=a.asm.Ia).apply(null,arguments)},Qa=a._emscripten_bind_Decoder_Decoder_0=
|
||||
function(){return(Qa=a._emscripten_bind_Decoder_Decoder_0=a.asm.Ja).apply(null,arguments)},ic=a._emscripten_bind_Decoder_DecodeArrayToPointCloud_3=function(){return(ic=a._emscripten_bind_Decoder_DecodeArrayToPointCloud_3=a.asm.Ka).apply(null,arguments)},jc=a._emscripten_bind_Decoder_DecodeArrayToMesh_3=function(){return(jc=a._emscripten_bind_Decoder_DecodeArrayToMesh_3=a.asm.La).apply(null,arguments)},kc=a._emscripten_bind_Decoder_GetAttributeId_2=function(){return(kc=a._emscripten_bind_Decoder_GetAttributeId_2=
|
||||
a.asm.Ma).apply(null,arguments)},lc=a._emscripten_bind_Decoder_GetAttributeIdByName_2=function(){return(lc=a._emscripten_bind_Decoder_GetAttributeIdByName_2=a.asm.Na).apply(null,arguments)},mc=a._emscripten_bind_Decoder_GetAttributeIdByMetadataEntry_3=function(){return(mc=a._emscripten_bind_Decoder_GetAttributeIdByMetadataEntry_3=a.asm.Oa).apply(null,arguments)},nc=a._emscripten_bind_Decoder_GetAttribute_2=function(){return(nc=a._emscripten_bind_Decoder_GetAttribute_2=a.asm.Pa).apply(null,arguments)},
|
||||
oc=a._emscripten_bind_Decoder_GetAttributeByUniqueId_2=function(){return(oc=a._emscripten_bind_Decoder_GetAttributeByUniqueId_2=a.asm.Qa).apply(null,arguments)},pc=a._emscripten_bind_Decoder_GetMetadata_1=function(){return(pc=a._emscripten_bind_Decoder_GetMetadata_1=a.asm.Ra).apply(null,arguments)},qc=a._emscripten_bind_Decoder_GetAttributeMetadata_2=function(){return(qc=a._emscripten_bind_Decoder_GetAttributeMetadata_2=a.asm.Sa).apply(null,arguments)},rc=a._emscripten_bind_Decoder_GetFaceFromMesh_3=
|
||||
function(){return(rc=a._emscripten_bind_Decoder_GetFaceFromMesh_3=a.asm.Ta).apply(null,arguments)},sc=a._emscripten_bind_Decoder_GetTriangleStripsFromMesh_2=function(){return(sc=a._emscripten_bind_Decoder_GetTriangleStripsFromMesh_2=a.asm.Ua).apply(null,arguments)},tc=a._emscripten_bind_Decoder_GetTrianglesUInt16Array_3=function(){return(tc=a._emscripten_bind_Decoder_GetTrianglesUInt16Array_3=a.asm.Va).apply(null,arguments)},uc=a._emscripten_bind_Decoder_GetTrianglesUInt32Array_3=function(){return(uc=
|
||||
a._emscripten_bind_Decoder_GetTrianglesUInt32Array_3=a.asm.Wa).apply(null,arguments)},vc=a._emscripten_bind_Decoder_GetAttributeFloat_3=function(){return(vc=a._emscripten_bind_Decoder_GetAttributeFloat_3=a.asm.Xa).apply(null,arguments)},wc=a._emscripten_bind_Decoder_GetAttributeFloatForAllPoints_3=function(){return(wc=a._emscripten_bind_Decoder_GetAttributeFloatForAllPoints_3=a.asm.Ya).apply(null,arguments)},xc=a._emscripten_bind_Decoder_GetAttributeIntForAllPoints_3=function(){return(xc=a._emscripten_bind_Decoder_GetAttributeIntForAllPoints_3=
|
||||
a.asm.Za).apply(null,arguments)},yc=a._emscripten_bind_Decoder_GetAttributeInt8ForAllPoints_3=function(){return(yc=a._emscripten_bind_Decoder_GetAttributeInt8ForAllPoints_3=a.asm._a).apply(null,arguments)},zc=a._emscripten_bind_Decoder_GetAttributeUInt8ForAllPoints_3=function(){return(zc=a._emscripten_bind_Decoder_GetAttributeUInt8ForAllPoints_3=a.asm.$a).apply(null,arguments)},Ac=a._emscripten_bind_Decoder_GetAttributeInt16ForAllPoints_3=function(){return(Ac=a._emscripten_bind_Decoder_GetAttributeInt16ForAllPoints_3=
|
||||
a.asm.ab).apply(null,arguments)},Bc=a._emscripten_bind_Decoder_GetAttributeUInt16ForAllPoints_3=function(){return(Bc=a._emscripten_bind_Decoder_GetAttributeUInt16ForAllPoints_3=a.asm.bb).apply(null,arguments)},Cc=a._emscripten_bind_Decoder_GetAttributeInt32ForAllPoints_3=function(){return(Cc=a._emscripten_bind_Decoder_GetAttributeInt32ForAllPoints_3=a.asm.cb).apply(null,arguments)},Dc=a._emscripten_bind_Decoder_GetAttributeUInt32ForAllPoints_3=function(){return(Dc=a._emscripten_bind_Decoder_GetAttributeUInt32ForAllPoints_3=
|
||||
a.asm.db).apply(null,arguments)},Ec=a._emscripten_bind_Decoder_GetAttributeDataArrayForAllPoints_5=function(){return(Ec=a._emscripten_bind_Decoder_GetAttributeDataArrayForAllPoints_5=a.asm.eb).apply(null,arguments)},Fc=a._emscripten_bind_Decoder_SkipAttributeTransform_1=function(){return(Fc=a._emscripten_bind_Decoder_SkipAttributeTransform_1=a.asm.fb).apply(null,arguments)},Gc=a._emscripten_bind_Decoder_GetEncodedGeometryType_Deprecated_1=function(){return(Gc=a._emscripten_bind_Decoder_GetEncodedGeometryType_Deprecated_1=
|
||||
a.asm.gb).apply(null,arguments)},Hc=a._emscripten_bind_Decoder_DecodeBufferToPointCloud_2=function(){return(Hc=a._emscripten_bind_Decoder_DecodeBufferToPointCloud_2=a.asm.hb).apply(null,arguments)},Ic=a._emscripten_bind_Decoder_DecodeBufferToMesh_2=function(){return(Ic=a._emscripten_bind_Decoder_DecodeBufferToMesh_2=a.asm.ib).apply(null,arguments)},Jc=a._emscripten_bind_Decoder___destroy___0=function(){return(Jc=a._emscripten_bind_Decoder___destroy___0=a.asm.jb).apply(null,arguments)},Kc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_INVALID_TRANSFORM=
|
||||
function(){return(Kc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_INVALID_TRANSFORM=a.asm.kb).apply(null,arguments)},Lc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_NO_TRANSFORM=function(){return(Lc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_NO_TRANSFORM=a.asm.lb).apply(null,arguments)},Mc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_QUANTIZATION_TRANSFORM=function(){return(Mc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_QUANTIZATION_TRANSFORM=
|
||||
a.asm.mb).apply(null,arguments)},Nc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_OCTAHEDRON_TRANSFORM=function(){return(Nc=a._emscripten_enum_draco_AttributeTransformType_ATTRIBUTE_OCTAHEDRON_TRANSFORM=a.asm.nb).apply(null,arguments)},Oc=a._emscripten_enum_draco_GeometryAttribute_Type_INVALID=function(){return(Oc=a._emscripten_enum_draco_GeometryAttribute_Type_INVALID=a.asm.ob).apply(null,arguments)},Pc=a._emscripten_enum_draco_GeometryAttribute_Type_POSITION=function(){return(Pc=a._emscripten_enum_draco_GeometryAttribute_Type_POSITION=
|
||||
a.asm.pb).apply(null,arguments)},Qc=a._emscripten_enum_draco_GeometryAttribute_Type_NORMAL=function(){return(Qc=a._emscripten_enum_draco_GeometryAttribute_Type_NORMAL=a.asm.qb).apply(null,arguments)},Rc=a._emscripten_enum_draco_GeometryAttribute_Type_COLOR=function(){return(Rc=a._emscripten_enum_draco_GeometryAttribute_Type_COLOR=a.asm.rb).apply(null,arguments)},Sc=a._emscripten_enum_draco_GeometryAttribute_Type_TEX_COORD=function(){return(Sc=a._emscripten_enum_draco_GeometryAttribute_Type_TEX_COORD=
|
||||
a.asm.sb).apply(null,arguments)},Tc=a._emscripten_enum_draco_GeometryAttribute_Type_GENERIC=function(){return(Tc=a._emscripten_enum_draco_GeometryAttribute_Type_GENERIC=a.asm.tb).apply(null,arguments)},Uc=a._emscripten_enum_draco_EncodedGeometryType_INVALID_GEOMETRY_TYPE=function(){return(Uc=a._emscripten_enum_draco_EncodedGeometryType_INVALID_GEOMETRY_TYPE=a.asm.ub).apply(null,arguments)},Vc=a._emscripten_enum_draco_EncodedGeometryType_POINT_CLOUD=function(){return(Vc=a._emscripten_enum_draco_EncodedGeometryType_POINT_CLOUD=
|
||||
a.asm.vb).apply(null,arguments)},Wc=a._emscripten_enum_draco_EncodedGeometryType_TRIANGULAR_MESH=function(){return(Wc=a._emscripten_enum_draco_EncodedGeometryType_TRIANGULAR_MESH=a.asm.wb).apply(null,arguments)},Xc=a._emscripten_enum_draco_DataType_DT_INVALID=function(){return(Xc=a._emscripten_enum_draco_DataType_DT_INVALID=a.asm.xb).apply(null,arguments)},Yc=a._emscripten_enum_draco_DataType_DT_INT8=function(){return(Yc=a._emscripten_enum_draco_DataType_DT_INT8=a.asm.yb).apply(null,arguments)},Zc=
|
||||
a._emscripten_enum_draco_DataType_DT_UINT8=function(){return(Zc=a._emscripten_enum_draco_DataType_DT_UINT8=a.asm.zb).apply(null,arguments)},$c=a._emscripten_enum_draco_DataType_DT_INT16=function(){return($c=a._emscripten_enum_draco_DataType_DT_INT16=a.asm.Ab).apply(null,arguments)},ad=a._emscripten_enum_draco_DataType_DT_UINT16=function(){return(ad=a._emscripten_enum_draco_DataType_DT_UINT16=a.asm.Bb).apply(null,arguments)},bd=a._emscripten_enum_draco_DataType_DT_INT32=function(){return(bd=a._emscripten_enum_draco_DataType_DT_INT32=
|
||||
a.asm.Cb).apply(null,arguments)},cd=a._emscripten_enum_draco_DataType_DT_UINT32=function(){return(cd=a._emscripten_enum_draco_DataType_DT_UINT32=a.asm.Db).apply(null,arguments)},dd=a._emscripten_enum_draco_DataType_DT_INT64=function(){return(dd=a._emscripten_enum_draco_DataType_DT_INT64=a.asm.Eb).apply(null,arguments)},ed=a._emscripten_enum_draco_DataType_DT_UINT64=function(){return(ed=a._emscripten_enum_draco_DataType_DT_UINT64=a.asm.Fb).apply(null,arguments)},fd=a._emscripten_enum_draco_DataType_DT_FLOAT32=
|
||||
function(){return(fd=a._emscripten_enum_draco_DataType_DT_FLOAT32=a.asm.Gb).apply(null,arguments)},gd=a._emscripten_enum_draco_DataType_DT_FLOAT64=function(){return(gd=a._emscripten_enum_draco_DataType_DT_FLOAT64=a.asm.Hb).apply(null,arguments)},hd=a._emscripten_enum_draco_DataType_DT_BOOL=function(){return(hd=a._emscripten_enum_draco_DataType_DT_BOOL=a.asm.Ib).apply(null,arguments)},id=a._emscripten_enum_draco_DataType_DT_TYPES_COUNT=function(){return(id=a._emscripten_enum_draco_DataType_DT_TYPES_COUNT=
|
||||
a.asm.Jb).apply(null,arguments)},jd=a._emscripten_enum_draco_StatusCode_OK=function(){return(jd=a._emscripten_enum_draco_StatusCode_OK=a.asm.Kb).apply(null,arguments)},kd=a._emscripten_enum_draco_StatusCode_DRACO_ERROR=function(){return(kd=a._emscripten_enum_draco_StatusCode_DRACO_ERROR=a.asm.Lb).apply(null,arguments)},ld=a._emscripten_enum_draco_StatusCode_IO_ERROR=function(){return(ld=a._emscripten_enum_draco_StatusCode_IO_ERROR=a.asm.Mb).apply(null,arguments)},md=a._emscripten_enum_draco_StatusCode_INVALID_PARAMETER=
|
||||
function(){return(md=a._emscripten_enum_draco_StatusCode_INVALID_PARAMETER=a.asm.Nb).apply(null,arguments)},nd=a._emscripten_enum_draco_StatusCode_UNSUPPORTED_VERSION=function(){return(nd=a._emscripten_enum_draco_StatusCode_UNSUPPORTED_VERSION=a.asm.Ob).apply(null,arguments)},od=a._emscripten_enum_draco_StatusCode_UNKNOWN_VERSION=function(){return(od=a._emscripten_enum_draco_StatusCode_UNKNOWN_VERSION=a.asm.Pb).apply(null,arguments)};a._malloc=function(){return(a._malloc=a.asm.Qb).apply(null,arguments)};
|
||||
a._free=function(){return(a._free=a.asm.Rb).apply(null,arguments)};var ua=function(){return(ua=a.asm.Sb).apply(null,arguments)};a.___start_em_js=11660;a.___stop_em_js=11758;var la;ha=function b(){la||F();la||(ha=b)};if(a.preInit)for("function"==typeof a.preInit&&(a.preInit=[a.preInit]);0<a.preInit.length;)a.preInit.pop()();F();v.prototype=Object.create(v.prototype);v.prototype.constructor=v;v.prototype.__class__=v;v.__cache__={};a.WrapperObject=v;a.getCache=w;a.wrapPointer=B;a.castObject=function(b,
|
||||
c){return B(b.ptr,c)};a.NULL=B(0);a.destroy=function(b){if(!b.__destroy__)throw"Error: Cannot destroy object. (Did you create it yourself?)";b.__destroy__();delete w(b.__class__)[b.ptr]};a.compare=function(b,c){return b.ptr===c.ptr};a.getPointer=function(b){return b.ptr};a.getClass=function(b){return b.__class__};var r={buffer:0,size:0,pos:0,temps:[],needed:0,prepare:function(){if(r.needed){for(var b=0;b<r.temps.length;b++)a._free(r.temps[b]);r.temps.length=0;a._free(r.buffer);r.buffer=0;r.size+=
|
||||
r.needed;r.needed=0}r.buffer||(r.size+=128,r.buffer=a._malloc(r.size),r.buffer||y(void 0));r.pos=0},alloc:function(b,c){r.buffer||y(void 0);b=b.length*c.BYTES_PER_ELEMENT;b=b+7&-8;r.pos+b>=r.size?(0<b||y(void 0),r.needed+=b,c=a._malloc(b),r.temps.push(c)):(c=r.buffer+r.pos,r.pos+=b);return c},copy:function(b,c,d){d>>>=0;switch(c.BYTES_PER_ELEMENT){case 2:d>>>=1;break;case 4:d>>>=2;break;case 8:d>>>=3}for(var g=0;g<b.length;g++)c[d+g]=b[g]}};X.prototype=Object.create(v.prototype);X.prototype.constructor=
|
||||
X;X.prototype.__class__=X;X.__cache__={};a.VoidPtr=X;X.prototype.__destroy__=X.prototype.__destroy__=function(){Xa(this.ptr)};S.prototype=Object.create(v.prototype);S.prototype.constructor=S;S.prototype.__class__=S;S.__cache__={};a.DecoderBuffer=S;S.prototype.Init=S.prototype.Init=function(b,c){var d=this.ptr;r.prepare();"object"==typeof b&&(b=Z(b));c&&"object"===typeof c&&(c=c.ptr);Ya(d,b,c)};S.prototype.__destroy__=S.prototype.__destroy__=function(){Za(this.ptr)};Q.prototype=Object.create(v.prototype);
|
||||
Q.prototype.constructor=Q;Q.prototype.__class__=Q;Q.__cache__={};a.AttributeTransformData=Q;Q.prototype.transform_type=Q.prototype.transform_type=function(){return $a(this.ptr)};Q.prototype.__destroy__=Q.prototype.__destroy__=function(){ab(this.ptr)};V.prototype=Object.create(v.prototype);V.prototype.constructor=V;V.prototype.__class__=V;V.__cache__={};a.GeometryAttribute=V;V.prototype.__destroy__=V.prototype.__destroy__=function(){bb(this.ptr)};x.prototype=Object.create(v.prototype);x.prototype.constructor=
|
||||
x;x.prototype.__class__=x;x.__cache__={};a.PointAttribute=x;x.prototype.size=x.prototype.size=function(){return cb(this.ptr)};x.prototype.GetAttributeTransformData=x.prototype.GetAttributeTransformData=function(){return B(db(this.ptr),Q)};x.prototype.attribute_type=x.prototype.attribute_type=function(){return eb(this.ptr)};x.prototype.data_type=x.prototype.data_type=function(){return fb(this.ptr)};x.prototype.num_components=x.prototype.num_components=function(){return gb(this.ptr)};x.prototype.normalized=
|
||||
x.prototype.normalized=function(){return!!hb(this.ptr)};x.prototype.byte_stride=x.prototype.byte_stride=function(){return ib(this.ptr)};x.prototype.byte_offset=x.prototype.byte_offset=function(){return jb(this.ptr)};x.prototype.unique_id=x.prototype.unique_id=function(){return kb(this.ptr)};x.prototype.__destroy__=x.prototype.__destroy__=function(){lb(this.ptr)};D.prototype=Object.create(v.prototype);D.prototype.constructor=D;D.prototype.__class__=D;D.__cache__={};a.AttributeQuantizationTransform=
|
||||
D;D.prototype.InitFromAttribute=D.prototype.InitFromAttribute=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return!!mb(c,b)};D.prototype.quantization_bits=D.prototype.quantization_bits=function(){return nb(this.ptr)};D.prototype.min_value=D.prototype.min_value=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return ob(c,b)};D.prototype.range=D.prototype.range=function(){return pb(this.ptr)};D.prototype.__destroy__=D.prototype.__destroy__=function(){qb(this.ptr)};G.prototype=
|
||||
Object.create(v.prototype);G.prototype.constructor=G;G.prototype.__class__=G;G.__cache__={};a.AttributeOctahedronTransform=G;G.prototype.InitFromAttribute=G.prototype.InitFromAttribute=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return!!rb(c,b)};G.prototype.quantization_bits=G.prototype.quantization_bits=function(){return sb(this.ptr)};G.prototype.__destroy__=G.prototype.__destroy__=function(){tb(this.ptr)};H.prototype=Object.create(v.prototype);H.prototype.constructor=H;H.prototype.__class__=
|
||||
H;H.__cache__={};a.PointCloud=H;H.prototype.num_attributes=H.prototype.num_attributes=function(){return ub(this.ptr)};H.prototype.num_points=H.prototype.num_points=function(){return vb(this.ptr)};H.prototype.__destroy__=H.prototype.__destroy__=function(){wb(this.ptr)};E.prototype=Object.create(v.prototype);E.prototype.constructor=E;E.prototype.__class__=E;E.__cache__={};a.Mesh=E;E.prototype.num_faces=E.prototype.num_faces=function(){return xb(this.ptr)};E.prototype.num_attributes=E.prototype.num_attributes=
|
||||
function(){return yb(this.ptr)};E.prototype.num_points=E.prototype.num_points=function(){return zb(this.ptr)};E.prototype.__destroy__=E.prototype.__destroy__=function(){Ab(this.ptr)};T.prototype=Object.create(v.prototype);T.prototype.constructor=T;T.prototype.__class__=T;T.__cache__={};a.Metadata=T;T.prototype.__destroy__=T.prototype.__destroy__=function(){Bb(this.ptr)};C.prototype=Object.create(v.prototype);C.prototype.constructor=C;C.prototype.__class__=C;C.__cache__={};a.Status=C;C.prototype.code=
|
||||
C.prototype.code=function(){return Cb(this.ptr)};C.prototype.ok=C.prototype.ok=function(){return!!Db(this.ptr)};C.prototype.error_msg=C.prototype.error_msg=function(){return p(Eb(this.ptr))};C.prototype.__destroy__=C.prototype.__destroy__=function(){Fb(this.ptr)};I.prototype=Object.create(v.prototype);I.prototype.constructor=I;I.prototype.__class__=I;I.__cache__={};a.DracoFloat32Array=I;I.prototype.GetValue=I.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Gb(c,
|
||||
b)};I.prototype.size=I.prototype.size=function(){return Hb(this.ptr)};I.prototype.__destroy__=I.prototype.__destroy__=function(){Ib(this.ptr)};J.prototype=Object.create(v.prototype);J.prototype.constructor=J;J.prototype.__class__=J;J.__cache__={};a.DracoInt8Array=J;J.prototype.GetValue=J.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Jb(c,b)};J.prototype.size=J.prototype.size=function(){return Kb(this.ptr)};J.prototype.__destroy__=J.prototype.__destroy__=function(){Lb(this.ptr)};
|
||||
K.prototype=Object.create(v.prototype);K.prototype.constructor=K;K.prototype.__class__=K;K.__cache__={};a.DracoUInt8Array=K;K.prototype.GetValue=K.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Mb(c,b)};K.prototype.size=K.prototype.size=function(){return Nb(this.ptr)};K.prototype.__destroy__=K.prototype.__destroy__=function(){Ob(this.ptr)};L.prototype=Object.create(v.prototype);L.prototype.constructor=L;L.prototype.__class__=L;L.__cache__={};a.DracoInt16Array=
|
||||
L;L.prototype.GetValue=L.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Pb(c,b)};L.prototype.size=L.prototype.size=function(){return Qb(this.ptr)};L.prototype.__destroy__=L.prototype.__destroy__=function(){Rb(this.ptr)};M.prototype=Object.create(v.prototype);M.prototype.constructor=M;M.prototype.__class__=M;M.__cache__={};a.DracoUInt16Array=M;M.prototype.GetValue=M.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Sb(c,b)};
|
||||
M.prototype.size=M.prototype.size=function(){return Tb(this.ptr)};M.prototype.__destroy__=M.prototype.__destroy__=function(){Ub(this.ptr)};N.prototype=Object.create(v.prototype);N.prototype.constructor=N;N.prototype.__class__=N;N.__cache__={};a.DracoInt32Array=N;N.prototype.GetValue=N.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Vb(c,b)};N.prototype.size=N.prototype.size=function(){return Wb(this.ptr)};N.prototype.__destroy__=N.prototype.__destroy__=function(){Xb(this.ptr)};
|
||||
O.prototype=Object.create(v.prototype);O.prototype.constructor=O;O.prototype.__class__=O;O.__cache__={};a.DracoUInt32Array=O;O.prototype.GetValue=O.prototype.GetValue=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Yb(c,b)};O.prototype.size=O.prototype.size=function(){return Zb(this.ptr)};O.prototype.__destroy__=O.prototype.__destroy__=function(){$b(this.ptr)};z.prototype=Object.create(v.prototype);z.prototype.constructor=z;z.prototype.__class__=z;z.__cache__={};a.MetadataQuerier=
|
||||
z;z.prototype.HasEntry=z.prototype.HasEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return!!ac(d,b,c)};z.prototype.GetIntEntry=z.prototype.GetIntEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return bc(d,b,c)};z.prototype.GetIntEntryArray=z.prototype.GetIntEntryArray=function(b,c,d){var g=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===
|
||||
typeof c?c.ptr:R(c);d&&"object"===typeof d&&(d=d.ptr);cc(g,b,c,d)};z.prototype.GetDoubleEntry=z.prototype.GetDoubleEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return dc(d,b,c)};z.prototype.GetStringEntry=z.prototype.GetStringEntry=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return p(ec(d,b,c))};z.prototype.NumEntries=z.prototype.NumEntries=function(b){var c=this.ptr;
|
||||
b&&"object"===typeof b&&(b=b.ptr);return fc(c,b)};z.prototype.GetEntryName=z.prototype.GetEntryName=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return p(gc(d,b,c))};z.prototype.__destroy__=z.prototype.__destroy__=function(){hc(this.ptr)};m.prototype=Object.create(v.prototype);m.prototype.constructor=m;m.prototype.__class__=m;m.__cache__={};a.Decoder=m;m.prototype.DecodeArrayToPointCloud=m.prototype.DecodeArrayToPointCloud=function(b,c,d){var g=
|
||||
this.ptr;r.prepare();"object"==typeof b&&(b=Z(b));c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return B(ic(g,b,c,d),C)};m.prototype.DecodeArrayToMesh=m.prototype.DecodeArrayToMesh=function(b,c,d){var g=this.ptr;r.prepare();"object"==typeof b&&(b=Z(b));c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return B(jc(g,b,c,d),C)};m.prototype.GetAttributeId=m.prototype.GetAttributeId=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&
|
||||
(c=c.ptr);return kc(d,b,c)};m.prototype.GetAttributeIdByName=m.prototype.GetAttributeIdByName=function(b,c){var d=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);return lc(d,b,c)};m.prototype.GetAttributeIdByMetadataEntry=m.prototype.GetAttributeIdByMetadataEntry=function(b,c,d){var g=this.ptr;r.prepare();b&&"object"===typeof b&&(b=b.ptr);c=c&&"object"===typeof c?c.ptr:R(c);d=d&&"object"===typeof d?d.ptr:R(d);return mc(g,b,c,d)};m.prototype.GetAttribute=
|
||||
m.prototype.GetAttribute=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(nc(d,b,c),x)};m.prototype.GetAttributeByUniqueId=m.prototype.GetAttributeByUniqueId=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(oc(d,b,c),x)};m.prototype.GetMetadata=m.prototype.GetMetadata=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return B(pc(c,b),T)};m.prototype.GetAttributeMetadata=m.prototype.GetAttributeMetadata=
|
||||
function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(qc(d,b,c),T)};m.prototype.GetFaceFromMesh=m.prototype.GetFaceFromMesh=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!rc(g,b,c,d)};m.prototype.GetTriangleStripsFromMesh=m.prototype.GetTriangleStripsFromMesh=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);
|
||||
return sc(d,b,c)};m.prototype.GetTrianglesUInt16Array=m.prototype.GetTrianglesUInt16Array=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!tc(g,b,c,d)};m.prototype.GetTrianglesUInt32Array=m.prototype.GetTrianglesUInt32Array=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!uc(g,b,c,d)};m.prototype.GetAttributeFloat=m.prototype.GetAttributeFloat=
|
||||
function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!vc(g,b,c,d)};m.prototype.GetAttributeFloatForAllPoints=m.prototype.GetAttributeFloatForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!wc(g,b,c,d)};m.prototype.GetAttributeIntForAllPoints=m.prototype.GetAttributeIntForAllPoints=function(b,c,d){var g=this.ptr;
|
||||
b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!xc(g,b,c,d)};m.prototype.GetAttributeInt8ForAllPoints=m.prototype.GetAttributeInt8ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!yc(g,b,c,d)};m.prototype.GetAttributeUInt8ForAllPoints=m.prototype.GetAttributeUInt8ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=
|
||||
b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!zc(g,b,c,d)};m.prototype.GetAttributeInt16ForAllPoints=m.prototype.GetAttributeInt16ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Ac(g,b,c,d)};m.prototype.GetAttributeUInt16ForAllPoints=m.prototype.GetAttributeUInt16ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&
|
||||
(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Bc(g,b,c,d)};m.prototype.GetAttributeInt32ForAllPoints=m.prototype.GetAttributeInt32ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);return!!Cc(g,b,c,d)};m.prototype.GetAttributeUInt32ForAllPoints=m.prototype.GetAttributeUInt32ForAllPoints=function(b,c,d){var g=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===
|
||||
typeof d&&(d=d.ptr);return!!Dc(g,b,c,d)};m.prototype.GetAttributeDataArrayForAllPoints=m.prototype.GetAttributeDataArrayForAllPoints=function(b,c,d,g,t){var aa=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);d&&"object"===typeof d&&(d=d.ptr);g&&"object"===typeof g&&(g=g.ptr);t&&"object"===typeof t&&(t=t.ptr);return!!Ec(aa,b,c,d,g,t)};m.prototype.SkipAttributeTransform=m.prototype.SkipAttributeTransform=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);Fc(c,
|
||||
b)};m.prototype.GetEncodedGeometryType_Deprecated=m.prototype.GetEncodedGeometryType_Deprecated=function(b){var c=this.ptr;b&&"object"===typeof b&&(b=b.ptr);return Gc(c,b)};m.prototype.DecodeBufferToPointCloud=m.prototype.DecodeBufferToPointCloud=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===typeof c&&(c=c.ptr);return B(Hc(d,b,c),C)};m.prototype.DecodeBufferToMesh=m.prototype.DecodeBufferToMesh=function(b,c){var d=this.ptr;b&&"object"===typeof b&&(b=b.ptr);c&&"object"===
|
||||
typeof c&&(c=c.ptr);return B(Ic(d,b,c),C)};m.prototype.__destroy__=m.prototype.__destroy__=function(){Jc(this.ptr)};(function(){function b(){a.ATTRIBUTE_INVALID_TRANSFORM=Kc();a.ATTRIBUTE_NO_TRANSFORM=Lc();a.ATTRIBUTE_QUANTIZATION_TRANSFORM=Mc();a.ATTRIBUTE_OCTAHEDRON_TRANSFORM=Nc();a.INVALID=Oc();a.POSITION=Pc();a.NORMAL=Qc();a.COLOR=Rc();a.TEX_COORD=Sc();a.GENERIC=Tc();a.INVALID_GEOMETRY_TYPE=Uc();a.POINT_CLOUD=Vc();a.TRIANGULAR_MESH=Wc();a.DT_INVALID=Xc();a.DT_INT8=Yc();a.DT_UINT8=Zc();a.DT_INT16=
|
||||
$c();a.DT_UINT16=ad();a.DT_INT32=bd();a.DT_UINT32=cd();a.DT_INT64=dd();a.DT_UINT64=ed();a.DT_FLOAT32=fd();a.DT_FLOAT64=gd();a.DT_BOOL=hd();a.DT_TYPES_COUNT=id();a.OK=jd();a.DRACO_ERROR=kd();a.IO_ERROR=ld();a.INVALID_PARAMETER=md();a.UNSUPPORTED_VERSION=nd();a.UNKNOWN_VERSION=od()}va?b():oa.unshift(b)})();if("function"===typeof a.onModuleParsed)a.onModuleParsed();a.Decoder.prototype.GetEncodedGeometryType=function(b){if(b.__class__&&b.__class__===a.DecoderBuffer)return a.Decoder.prototype.GetEncodedGeometryType_Deprecated(b);
|
||||
if(8>b.byteLength)return a.INVALID_GEOMETRY_TYPE;switch(b[7]){case 0:return a.POINT_CLOUD;case 1:return a.TRIANGULAR_MESH;default:return a.INVALID_GEOMETRY_TYPE}};return n.ready}}();"object"===typeof exports&&"object"===typeof module?module.exports=DracoDecoderModule:"function"===typeof define&&define.amd?define([],function(){return DracoDecoderModule}):"object"===typeof exports&&(exports.DracoDecoderModule=DracoDecoderModule);
|
||||
@@ -0,0 +1,9 @@
|
||||
export * as THREE from "three";
|
||||
export { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
export { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
||||
export { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
export { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
|
||||
export { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
|
||||
export { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
|
||||
export { VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm";
|
||||
export const loadSpark = () => import("@sparkjsdev/spark");
|
||||
File diff suppressed because one or more lines are too long
+87
-12
@@ -1435,7 +1435,7 @@
|
||||
<li id="wfm-help-gen-4">Input tab: <b>Prompt</b> (left) and <b>Image</b> (right, about 3:2) are shown side by side on one screen (drag-and-drop upload); when the Prompt/Image area gets narrow (about 720px or less) Image wraps under Prompt, and the Raw JSON column can be collapsed to a slim strip at the right edge with its <b>»</b> button (Input tab only; remembered); the Prompt area has Positive Prompt, Negative Prompt, and an <b>Embeddings</b> selector at the bottom — select a model, set Weight (default 1.0), then click <b>Paste</b> to insert <code>(embedding:Name:weight)</code> at the cursor position of the last focused textarea (defaults to Positive if neither is focused); a <b>↺</b> button next to each Apply button reverts the textarea to the node's currently applied value; select text (or just place the cursor on a word/parenthesis block) and press <b>Ctrl+↑/↓</b> to adjust its emphasis weight in <code>(text:weight)</code> syntax by ±0.05 — the parentheses are removed automatically when the weight returns to 1.0</li>
|
||||
<li id="wfm-help-gen-5">Model tab: Checkpoint, VAE, Diffusion Model (UNETLoader / UnetLoaderGGUF / LoaderGGUF), Text Encoder (CLIPLoader / DualCLIPLoader / ClipLoaderGGUF / DualClipLoaderGGUF — type and device selectors; DualCLIP shows two clip selectors) are always shown; ControlNet and Hypernetwork (with Strength field) are collapsed by default since they're used less often — click their header to expand; LoRA (Single/Stack tabs) has its own column to the right; all selectors have a filter; LoRA Stack — Apply writes all active LoRAs to the target node and simultaneously inserts LoRA syntax and CivitAI trigger words into the Positive prompt; re-applying updates the prompt by removing inactive LoRAs and re-adding active ones</li>
|
||||
<li id="wfm-help-gen-28">Model tab — Highlight color: the label of each model type (Checkpoint, VAE, Diffusion Model, Text Encoder, ControlNet, Hypernetwork) and the LoRA column header turn a distinct color (default bright green) when the loaded workflow actually contains that type of node; this stays visible even while ControlNet/Hypernetwork are collapsed, so you can tell at a glance whether they're in use without expanding them; the color is customizable in the Settings tab</li>
|
||||
<li id="wfm-help-gen-6">Settings tab: KSampler and Latent Image side by side, each at 50% width</li>
|
||||
<li id="wfm-help-gen-6">Settings tab: KSampler, Latent Image and Switches side by side. Switches lists every true/false value in the workflow (e.g. the prompt enhancer on/off switch, TextGenerate thinking) and every two-choice enable/disable, on/off or yes/no option (e.g. KSamplerAdvanced add_noise, DWPreprocessor detect_hand) with its node name; clicking one toggles it immediately (no Apply needed) and the change shows up in Raw JSON</li>
|
||||
<li id="wfm-help-gen-31">LTX-2.5 and Wan2.2 video workflows also work when loaded as regular workflows in this tab (not just the dedicated Video tab): Positive/Negative Prompt detection follows LTX-2.5's LTXVDualCFGGuider / LTXVConditioning nodes to find the actual prompt text, and the Settings tab's Latent Image section recognizes Wan2.2's EmptyHunyuanLatentVideo and LTX-2.5's EmptyLTXVLatentVideo nodes (with an added Length (frames) field) instead of showing "No EmptyLatentImage node found"; generating from these workflows also gets the same extended timeout as MiniMax H3 video generations</li>
|
||||
<li id="wfm-help-gen-7">Raw JSON always visible in the right column of each tab — edit directly and Apply to reload the workflow; if the loaded workflow has Bypass or Mute nodes, a note listing them appears above the search bar (Bypass nodes are excluded with their wires reconnected around them; Mute nodes are excluded with no reconnection, matching ComfyUI itself); the search bar highlights matches and scrolls to them without moving the cursor into the editor, so typing right after a search can't accidentally overwrite the JSON — click into the editor first to actually edit</li>
|
||||
<li id="wfm-help-gen-30">The <b>Save</b> button (right end of the subtab bar) is hidden while the <b>Batch</b> or <b>Lab</b> subtab is active, since those work on workflow copies / their own Plan files rather than the currently loaded workflow; it reappears on Input/Model/Settings/Feeder</li>
|
||||
@@ -1558,6 +1558,7 @@
|
||||
<li id="wfm-help-lab-plan-2">Run / Pause / Cancel: Run starts the batch loop; Pause/Resume freezes it between iterations; Cancel interrupts the current generation and stops the loop</li>
|
||||
<li id="wfm-help-lab-plan-3">Plan Save vs Save As: Plan Save silently overwrites the currently loaded plan (or asks for a name the first time); Save As always asks for a new name, even while a plan is already loaded</li>
|
||||
<li id="wfm-help-lab-plan-4">Plan Load: drop a plan's .json file, or any of its index-image PNGs, onto the PLAN box (or click it to browse) — a dropped .json is read directly; a dropped .png is read directly too if it carries embedded plan data (every index image since v0.3.90 does, in a <code>wfm_lab_plan</code> chunk — see below), falling back to fetching a same-named .json from the server for older index images that predate this</li>
|
||||
<li id="wfm-help-lab-plan-12">From a story: AI TOOL > Chat's "Storyboard to Lab" skill writes one prompt per scene; its "Open in Lab" button loads that plan here as an unsaved plan (T2I mode, Batch = number of scenes, one Prompt keyframe per scene, the plan's fixed seed in KSampler keyframe #1 - shown as "Seed …" in the cell). Check it, press Run, and use Plan Save to keep it</li>
|
||||
<li id="wfm-help-lab-plan-5">Plan Clear resets the Setting tab to an empty plan (asks for confirmation first)</li>
|
||||
<li id="wfm-help-lab-plan-6">Plan files are stored in <code>user/default/Workflow-Studio/lab_plan/</code> as <code>ws_labplan_<name>.json</code> plus a <code>ws_labplan_<name>.png</code> index image — a contact sheet of up to 9 result thumbnails (3 per row) generated automatically whenever a plan with results is saved. The <code>ws_labplan_</code> prefix is added automatically by Plan Save/Save As (type just the plain name when prompted) so plan files are easy to pick out among other files in that folder</li>
|
||||
<li id="wfm-help-lab-plan-7">Eagle auto-save (configured in the Settings tab) applies to every image Lab generates, the same as GenerateUI's own Generate button</li>
|
||||
@@ -1832,6 +1833,7 @@
|
||||
<li id="wfm-help-gallery-10">Workflow auto-save: images generated in the Generate UI tab automatically have their workflow saved to gallery metadata and shown in the JSON tab</li>
|
||||
<li id="wfm-help-gallery-11">Supports PNG (tEXt/iTXt), JPEG, WebP, GIF, MP4, WebM, PSD, and audio (WAV, MP3, OGG, M4A, FLAC, Opus — waveform thumbnail, built-in player, prompt/workflow tags written by ComfyUI's SaveAudio nodes); workflow extraction reads ComfyUI prompt/workflow keys embedded in PNG metadata (including chunks appended after IEND by ComfyUI-Custom-Scripts), WebP EXIF, or MP4/WebM container tags (as written by SaveVideo, e.g. MiniMax H3 output); the Prompt tab also reads A1111 / Forge / Fooocus <code>parameters</code> text when no workflow is embedded. Audio waveform thumbnails are colored by format (MP3 blue, WAV green, FLAC amber, OGG/Opus purple, M4A rose) so formats can be told apart at a glance; the detail panel shows the waveform with the player below it.</li>
|
||||
<li id="wfm-help-gallery-30">PSD files: thumbnails and full-size previews show all layers merged together (hidden layers excluded); the underlying PSD file itself is left untouched — only the preview shown in Gallery is a flattened rendering</li>
|
||||
<li id="wfm-help-gallery-38">3D models: GLB, glTF, VRM, PLY (mesh, point cloud or Gaussian splat) and SPZ are listed with a "3D" badge and open in a built-in viewer (drag to rotate, right-drag to pan, wheel to zoom, ⟲ to reset; double-click to enlarge). Thumbnails are rendered by your browser the first time a file appears and are then kept on the server; files over 128 MB get one when opened. The workflow saved by ComfyUI's SaveGLB is read like an image's. A .gltf that uses separate .bin or texture files needs them to stay in place: Move and Delete only affect the .gltf itself. 3D files cannot be sent to Image Edit, GenerateUI, Tagger or Feeder.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1935,11 +1937,11 @@
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-ai-card-layout-title">Layout</h3>
|
||||
<p id="wfm-help-ai-1">4-pane layout — Translation | Chat | TOOLS | Settings — all panes are always visible simultaneously</p>
|
||||
<p id="wfm-help-ai-1">4-pane layout — Translation | Chat | TOOLS | Settings; the Translation and Settings panes can be collapsed to a thin strip with the arrow (◀ / ▶) in their header, giving Chat and TOOLS more room — click the strip anywhere to open the pane again; the collapsed state is remembered</p>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-ai-card-translate-title">Translation pane</h3>
|
||||
<p id="wfm-help-ai-2">Translation pane: translate text between Japanese, English, Chinese, or a custom Free language using Ollama, LM Studio, Lemonade, or Unsloth; ⇄ swap button swaps both language selectors and text content; selections are saved automatically</p>
|
||||
<p id="wfm-help-ai-2">Translation pane: translate between Japanese, English, Chinese, or Free language using Ollama, LM Studio, Lemonade, or Unsloth; ⇄ swap button swaps both language selectors and text content; selections are saved automatically; text extracted in the TOOLS pane (e.g. by OCR) can be sent here with its "To Translation" button, which also opens this pane if it is collapsed</p>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-ai-card-chat-title">Chat pane</h3>
|
||||
@@ -1951,19 +1953,35 @@
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-ai-card-skills-title">Chat pane — Skills</h3>
|
||||
<p id="wfm-help-ai-8">Chat pane — Skills: pick a skill from the dropdown above the message list to prepend its instructions to the conversation as a system prompt (not shown in the visible chat log); use the pencil button to open the skill manager and create, edit, or delete .md skill files, stored in user/default/Workflow-Studio/ai_skills/; a file's first line block between --- markers can define name/description shown in the dropdown; two starter skills are included by default: "SVG Icon Generator" (decomposes the subject into parts before drawing, for more recognizable SVG output) and "Skill Creator" (interviews you conversationally and drafts a new skill file, ready to save); when a reply contains a ```skill code block, a "Save as new skill" button appears below it that opens the manager with the draft pre-filled</p>
|
||||
<p id="wfm-help-ai-8">Chat pane — Skills: pick a skill from the dropdown above the message list to prepend its instructions to the conversation as a system prompt (not shown in the visible chat log); use the pencil button to open the skill manager and create, edit, delete or import skills, stored in user/default/Workflow-Studio/ai_skills/; a file's first block between --- markers defines the name/description shown in the dropdown; six built-in skills are included (marked "built-in" in the manager): "T2I Prompt Enhancer" (rewrites a short idea into a prompt written for the model in your current GenerateUI workflow - natural sentences for Qwen-Image/FLUX/Z-Image, Danbooru tags for Illustrious/Pony), "Image to Prompt" (writes a prompt that recreates an image attached in the TOOLS pane; needs a vision chat model), "Storyboard to Lab" and "Workflow Doctor" (see their card below), "SVG Icon Generator" (decomposes the subject into parts before drawing; line/flat/duotone/mono styles) and "Skill Creator" (interviews you and drafts a new skill, or revises an existing one); built-in skills you have not edited are updated automatically when Workflow Studio is updated, and an edited one shows a ↻ button to replace it with the latest version; when a reply contains a ```skill code block, a "Save as new skill" button opens the manager with the draft pre-filled</p>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-ai-card-skilltips-title">Writing your own skills</h3>
|
||||
<p id="wfm-help-ai-9">Tips for writing your own skills: give a clear frontmatter name/description so it reads well in the dropdown and tooltip; state the exact output format you want and, if there is a deliverable to extract later (like SVG or another skill file), wrap it in a distinctive fenced code block; write concrete, imperative instructions rather than vague ones - smaller local models follow explicit rules and a short worked example far better than general guidance; state hard constraints (forbidden content, security, length limits) explicitly rather than assuming they are implied; when in doubt, select the "Skill Creator" skill in the dropdown and describe what you want - it will interview you and draft the file for you</p>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-ai-card-skilloptions-title">Chat pane — Skill options & action buttons</h3>
|
||||
<p id="wfm-help-ai-10">under metadata: in a skill's frontmatter you can set output (prompt or svg - the reply is checked automatically, and if the model tried to output one but it is broken - an unclosed or invalid SVG, forbidden elements, prompt lines outside a ```prompt block - the model is asked once to fix it, shown as a dashed notice; replies with no attempt at all, e.g. when you only asked for an explanation, are left alone), tools ([] to never let the model start image generation by itself, [generate_image] to allow it when the image-generation toggle is on), temperature, think (force thinking mode on/off) and context ([workflow] sends a summary of the workflow loaded in GenerateUI - model files, prompt nodes, CFG, resolution - with every message; the prompt text currently in the workflow is added only when your message refers to it, e.g. "improve the current prompt", because small models otherwise copy it into new prompts; [current-prompt] always adds it); any reply containing a ```prompt block (positive:/negative: lines) gets "Apply to GenerateUI", "Generate with this prompt" and "Copy" buttons</p>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-ai-card-skillsmore-title">Chat pane — Workflow Doctor, Storyboard & skill folders</h3>
|
||||
<p id="wfm-help-ai-12">"Workflow Doctor" explains the workflow loaded in GenerateUI and diagnoses errors from facts Workflow Studio checks on every message - node types that are not installed, model files or options that are not in ComfyUI's lists (with the closest existing names), broken links, unconnected required inputs, the last execution error and the latest error block of the ComfyUI log - so answers rely on your install rather than on the model's possibly outdated knowledge; "Storyboard to Lab" turns a story into one prompt per scene with identical character descriptions and a fixed seed, and its "Open in Lab" button loads the plan into GenerateUI > Lab (T2I mode) where you check it and press Run; "Import" in the skill manager adds an Agent Skills folder (.zip containing SKILL.md, the format shared by many AI tools) or a single .md - only text files are kept and scripts are never run; a folder skill's reference files are listed to the model, which reads one with the read_skill_file tool only when needed (needs a tool-capable chat model); assistant replies are shown with basic Markdown formatting (lists, headings, code blocks)</p>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-ai-card-imagecheck-title">Chat pane — Image check</h3>
|
||||
<p id="wfm-help-ai-11">Image check ("Check images" toggle below the chat input): after an image is generated from the chat - by the model's generate_image tool or the "Generate with this prompt" button - the chat model turns your request into 3-7 yes/no requirements, and a vision decision model in Ollama (d3:4b recommended; set it under Settings > Chat Image Check) judges the image and shows each one as ✔ met / ? unclear / ✘ not met with its probability, plus an overall quality rating; "Fix missing items and regenerate" sends only the unmet items back to the chat model, which rewrites the prompt and generates again, and "Automatic fix rounds" (0-3) repeats this without clicking; with the SVG Icon Generator skill the SVG is rendered and checked part by part (from its "Parts:" line) the same way; decision models return calibrated probabilities instead of the chat model's own yes/no, which tends to claim things that are not in the image; the fourth built-in skill "Image to Prompt" writes a prompt that recreates an image attached in the TOOLS pane (needs a vision chat model)</p>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-ai-card-tools-title">TOOLS pane</h3>
|
||||
<p id="wfm-help-ai-4">TOOLS pane — VLM: drop an image (PNG/JPG/WebP) and select a task (Describe Image, Create Prompt, Create Tags), click Run; or select "Create wildcards", enter a category name and count to generate plain-text entries one per line; the dropped image is also shared with the Chat pane as its attachment (shown there and used for image-to-image generation) until cleared with the ✕ button</p>
|
||||
<p id="wfm-help-ai-4">TOOLS pane — VLM: drop an image (PNG/JPG/WebP) and select a task (Describe Image, Create Prompt, Create Tags, OCR), click Run; or select "Create wildcards", enter a category name and count to generate plain-text entries one per line; the dropped image is also shared with the Chat pane as its attachment (shown there and used for image-to-image generation) until cleared with the ✕ button; below the result, "To Translation" and "To Chat" pass the result to those panes and "Copy" copies it</p>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-ai-card-ocr-title">TOOLS pane — OCR</h3>
|
||||
<p id="wfm-help-ai-13">Choose "OCR (extract text)" in the TOOLS pane, drop an image and press Run to read its text with an OCR model installed in Ollama (the list shows models whose name contains "ocr", best first; LightOnOCR-3 is the default - on a mixed Japanese/English test page with a vertical column it read everything in about 1 s; PaddleOCR-VL-manga and DeepSeek-OCR also read it fully, while PaddleOCR-VL 1.6 and GLM-OCR missed the vertical text); the image is sent as a white-background JPEG with its long side at most 1600 px, and an empty or looping result is retried once in grayscale; "Show text positions" (LightOnOCR / DeepSeek-OCR only - the checkbox is disabled for other models) enlarges the preview and draws numbered boxes over the image; click a box to select its line in the result; the OCR model is separate from the chat model chosen in Settings</p>
|
||||
</div>
|
||||
<div class="wfm-help-card">
|
||||
<h3 id="wfm-help-ai-card-settings-title">Settings pane</h3>
|
||||
<p id="wfm-help-ai-6">Settings pane: choose backend (Ollama / LM Studio / Lemonade / Unsloth), set API URL, test connection, select model, and use the Unload Model button (next to Test connection) to free the loaded model from VRAM/memory — Ollama unloads every loaded Ollama model, decision models included (the same step as Settings → VRAM Management's "Unload all now"; the Prompt/Tagger settings button does the same), LM Studio uses /api/v1/models/unload, Lemonade uses /api/v1/unload; Unsloth has no unload API, so this shows an unsupported message for it; Unsloth needs either an API key or Keyless API access — set UNSLOTH_API_KEY in a .env file in the plugin folder (copy .env.example) and restart ComfyUI (the key is read server-side and never sent to the browser), or leave it unset and turn on Keyless API access → "Chat and inference" in Unsloth Desktop's Settings → API (requests are then sent without a key; note that setting also opens the API to your private LAN, and keep "Allow tools" off); Unsloth also requires "Auto model switching (OpenAI API)" to be turned ON in the Unsloth Desktop app itself (its own Settings screen) — without it, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI; Unsloth reasoning models return their thinking in a separate reasoning_content field rather than inline <think> tags, so a low Max tokens can make the model spend its whole budget reasoning and return an empty reply — increase Max tokens (2048+) if replies come back blank; Generation section — Thinking mode toggle (off by default; Ollama receives a `think` flag, and regardless of backend any <think>...</think> block is stripped from the output when off, so reasoning models don't clutter Translation/Chat/TOOLS results) and Max tokens (Ollama: num_predict, LM Studio/Lemonade/Unsloth: max_tokens; 0 = unlimited); configure Free language names; Chat Image Generation section lets you enable a dedicated workflow for Chat-triggered text-to-image generation instead of the one currently loaded in GenerateUI; Chat I2I Generation section is the same but for image-to-image generation when an image is attached; settings are shared with the Library panel's A tab via localStorage</p>
|
||||
<p id="wfm-help-ai-6">Settings pane: choose backend (Ollama / LM Studio / Lemonade / Unsloth), set API URL, test connection, select model, and use the Unload Model button (next to Test connection) to free the loaded model from VRAM/memory — Ollama unloads every loaded Ollama model, decision models included (the same step as Settings → VRAM Management's "Unload all now"; the Prompt/Tagger settings button does the same), LM Studio uses /api/v1/models/unload, Lemonade uses /api/v1/unload; Unsloth has no unload API, so this shows an unsupported message for it; Unsloth needs either an API key or Keyless API access — set UNSLOTH_API_KEY in a .env file in the plugin folder (copy .env.example) and restart ComfyUI (the key is read server-side and never sent to the browser), or leave it unset and turn on Keyless API access → "Chat and inference" in Unsloth Desktop's Settings → API (requests are then sent without a key; note that setting also opens the API to your private LAN, and keep "Allow tools" off); Unsloth also requires "Auto model switching (OpenAI API)" to be turned ON in the Unsloth Desktop app itself (its own Settings screen) — without it, requests fail with HTTP 404 unless a model was already manually loaded in the Unsloth Studio UI; Unsloth reasoning models return their thinking in a separate reasoning_content field rather than inline <think> tags, so a low Max tokens can make the model spend its whole budget reasoning and return an empty reply — increase Max tokens (2048+) if replies come back blank; Generation section — Thinking mode toggle (off by default; Ollama receives a think flag, and regardless of backend any <think>...</think> block is stripped from the output when off, so reasoning models don't clutter Translation/Chat/TOOLS results) and Max tokens (Ollama: num_predict, LM Studio/Lemonade/Unsloth: max_tokens; 0 = unlimited); configure Free language names; Chat Image Generation section lets you enable a dedicated workflow for Chat-triggered text-to-image generation instead of the one currently loaded in GenerateUI; Chat I2I Generation section is the same but for image-to-image generation when an image is attached; settings are shared with the Library panel's A tab via localStorage; Chat Image Check section picks the judge model used by the chat's "Check images" toggle (an Ollama decision model with image input, e.g. d3:4b) and how many automatic fix rounds to run (0 = fix only when you press the button)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2022,7 +2040,7 @@
|
||||
<ul>
|
||||
<li id="wfm-help-tagspace-4">Navigation: drag to rotate, right-drag to pan, wheel to zoom. Hover shows the name, thumbnail and prompt/sources; the checkboxes show or hide Cards, Thumbnails, Tags and Gallery. The source checkboxes (ImagePrompt / Gallery prompts / Tagger DB / Prompt library / Wildcards) show or hide tags by where they came from — a tag stays visible while any of its sources is on and takes the color of its first visible source; hidden tags are also left out of the nearby and search lists. The choices are remembered</li>
|
||||
<li id="wfm-help-tagspace-5">Click a card or tag to select it and list nearby items (clicking only selects — it does not change the prompt). To add it to the Prompt panel, double-click it, press Add next to its name under Selection, or press + in the list (double-click / press again to remove) — cards and tags are ranked separately and listed alternately, so a card also shows its closest tags. Adding a Gallery point adds all tags from that image's prompt as one chip (images without readable tags cannot be added); clicking one lists similar Gallery images (round thumbnails) together with nearby tags and cards; selecting a card or tag, or searching, never lists Gallery images. Use + in the list to add, click a row to move to it. The search box (Enter, any language) places a white marker for the text and lists the nearest items</li>
|
||||
<li id="wfm-help-tagspace-10">Search with a card's or Gallery image's tags: when a card or Gallery point is selected, its prompt tags are shown as buttons (a card's full prompt is under "Full prompt"; card tags appear after Build / Update). Click tags to choose them (click again to deselect) and press "Search with selected" to search the space with those tags joined in prompt order — the same as typing them into the search box. The choice is kept until another item is selected</li>
|
||||
<li id="wfm-help-tagspace-10">Search with a card's or Gallery image's tags: when a card or Gallery point is selected, its prompt tags are shown as buttons (a card's full prompt is under "Full prompt"; card tags appear after Build / Update). Click tags to choose them (click again to deselect) and press "Search with selected" to search the space with those tags joined in prompt order — the same as typing them into the search box. The choice is kept until another item is selected. While tags are selected, the Add button becomes "Add selected (N)" and adds only those tags (in prompt order) as one chip; with none selected it adds the whole item as before. For a Gallery point, "Open in Gallery" opens the Gallery tab with the image's folder selected and the image selected in the list (search and other filters are cleared)</li>
|
||||
<li id="wfm-help-tagspace-6">Prompt panel: a card adds its whole prompt, a tag adds the tag, a Gallery image adds its prompt tags. The text joins them with duplicates removed (case and spacing ignored). "Add to GenerateUI (Positive)" appends it to the positive prompt of the workflow loaded in GenerateUI; Copy puts it on the clipboard</li>
|
||||
<li id="wfm-help-tagspace-7">Tagger fill: about a third of Gallery images have no readable positive prompt (imported or edited images, prompts made only of LoRA tags or long sentences). Choose a Tagger model (ONNX models from the Tagger tab) next to "Tagger fill" to tag those images on Build / Update: "Gallery points only" tags just the sampled Gallery points, "All images without a prompt" tags every indexed image that lacks prompt tags. Default thresholds are used (WD 0.35 / character 0.85, PixAI its recommended values) and rating labels are dropped. Results are cached in tagspace_tagger.json per image and model, so later builds only tag new images; stopping keeps what was done. The tags become the "Tagger fill" source (shown/hidden like the others) and the tags of those Gallery points, marked "(Tagger fill)". The status shows whether the model runs on CUDA or CPU — on the CPU it takes about 0.5 s per image, so check that onnxruntime-gpu is active (see Troubleshooting) before tagging all images</li>
|
||||
<li id="wfm-help-tagspace-8">Display: "Glow" draws points as glowing stars on a night-sky background (dense areas brighten; Gallery points are drawn dimmer so they don't wash out to white) — turn it off for the plain dots and the normal background. "Twinkle" (works with Glow) makes tags, cards and Gallery points slowly twinkle, each with its own rhythm; the selection and nearby highlights stay steady. Hover over the image under Selection or the thumbnails in the Nearby list to see an enlarged preview (clicking a list row changes the selection, so hovering lets you check images first). The Add / Remove button sits on the title row of Selection and stays visible while the text scrolls</li>
|
||||
@@ -2050,6 +2068,7 @@
|
||||
<ul>
|
||||
<li id="wfm-help-imageedit-4">Select (V): click to select a layer; drag to move; drag corner handles to resize; drag the circle above the top edge to rotate; double-click a text layer to re-edit its content; Tool Options bar shows Flip H / Flip V / Rotate field</li>
|
||||
<li id="wfm-help-imageedit-5">Draw (B): freehand brush drawing onto the active draw layer; Tool Options: color, brush size, blend mode; Properties pane includes a MASK EDITOR ONE (COLOR) section for ABR image brushes — always visible, header text is green when brushes are available, gray when none are imported yet; Select is disabled until at least one brush exists</li>
|
||||
<li id="wfm-help-imageedit-draw-palette">Draw color palette (second row): Color 1/Color 2, 20 basic colors, and 20 custom slots. Click a color to paint with it. Use + or click an empty slot to register the current color; right-click a color to register, edit, or delete it (basic colors can be changed and restored). Registered colors are saved per browser. The eyedropper button next to Color picks a color from the canvas (Esc cancels)</li>
|
||||
<li id="wfm-help-imageedit-6">Text (T): click on the canvas to place a text input overlay; configure font family, size, bold/italic, align, color in the Tool Options bar; click OK to place the text as an exact-size Text layer; double-click an existing text layer with the Select tool to re-edit</li>
|
||||
<li id="wfm-help-imageedit-7">Shape (S): draw geometric shapes onto the canvas; each committed shape becomes an independent draw layer; Tool Options: Shape type (Rect / Ellipse / Line / FreeLine), Rounded checkbox (Rect / Ellipse only), Fill color + None toggle (hidden for Line / FreeLine), Stroke color + width + None toggle (None hidden for Line / FreeLine where stroke is always active), Opacity slider, ↩ Undo button; drag to draw — a blue dashed outline previews the shape; release to commit</li>
|
||||
<li id="wfm-help-imageedit-7a2">Fill (G): bucket fill — click a contiguous same-color region on the active layer to flood-fill it with a solid color or a linear/radial gradient; Tool Options: Mode (Solid / Gradient), Color (solid) or Shape + Ramp (drag stops to reposition, click a stop then use the color swatch, +/- to add/remove) + Direction dial (drag to set angle/radius, gradient), Tolerance (color-match threshold), Opacity</li>
|
||||
@@ -2156,6 +2175,7 @@
|
||||
<li id="wfm-help-imageedit-24">T: Text tool</li>
|
||||
<li id="wfm-help-imageedit-25">S: Shape tool</li>
|
||||
<li id="wfm-help-imageedit-25b">G: Fill tool</li>
|
||||
<li id="wfm-help-imageedit-draw-kbd">While Draw is active: D Draw / E Erase / X switch Color 1/2 / [ ] brush size / B open or close the brush picker (Esc also closes it)</li>
|
||||
<li id="wfm-help-imageedit-26">Delete: Delete selected layer (requires 2+ layers)</li>
|
||||
<li id="wfm-help-imageedit-27">Ctrl+Z / Ctrl+Y: Undo / Redo</li>
|
||||
<li id="wfm-help-imageedit-28">Space + drag: Pan canvas</li>
|
||||
@@ -2208,6 +2228,7 @@
|
||||
<li id="wfm-help-imageedit-tabkbd-3">T: Text tool</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-4">S: Shape tool</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-4b">G: Fill tool</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-draw">While Draw is active: D Draw / E Erase / X switch Color 1/2 / [ ] brush size / B open or close the brush picker (Esc also closes it)</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-5">Delete: Delete selected layer (requires 2+ layers)</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-6">Ctrl+Z / Ctrl+Y: Undo / Redo</li>
|
||||
<li id="wfm-help-imageedit-tabkbd-7">Space + drag: Pan canvas</li>
|
||||
@@ -3431,8 +3452,11 @@
|
||||
<!-- AI Tab -->
|
||||
<section id="wfm-tab-ai" class="wfm-tab-content">
|
||||
<!-- Translation pane -->
|
||||
<div class="wfm-ai-pane wfm-ai-pane-translate">
|
||||
<div class="wfm-ai-pane-header">Translation</div>
|
||||
<div class="wfm-ai-pane wfm-ai-pane-translate" data-collapse-pane="translate">
|
||||
<div class="wfm-ai-pane-header">
|
||||
<span class="wfm-ai-pane-title">Translation</span>
|
||||
<button type="button" class="wfm-ai-pane-collapse-btn" data-collapse-btn="translate" data-dir="left" title="Collapse">◀</button>
|
||||
</div>
|
||||
<div class="wfm-ai-trans-container">
|
||||
<div class="wfm-ai-trans-lang-row">
|
||||
<select id="wfm-ai-src-lang" class="wfm-select wfm-ai-lang-select">
|
||||
@@ -3472,6 +3496,10 @@
|
||||
<div class="wfm-ai-skill-panel-header">
|
||||
<span class="wfm-ai-skill-panel-title">Skills</span>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<label id="wfm-ai-skill-import-label" class="wfm-btn wfm-btn-sm" title="Import an Agent Skills folder (.zip with SKILL.md) or a .md skill">
|
||||
<span id="wfm-ai-skill-import-text">Import</span>
|
||||
<input type="file" id="wfm-ai-skill-import-input" accept=".zip,.md" style="display:none;">
|
||||
</label>
|
||||
<button id="wfm-ai-skill-new-btn" class="wfm-btn wfm-btn-sm wfm-btn-primary">+ New</button>
|
||||
<button id="wfm-ai-skill-close-btn" class="wfm-btn wfm-btn-sm" title="Close">✕</button>
|
||||
</div>
|
||||
@@ -3504,6 +3532,10 @@
|
||||
<input type="checkbox" id="wfm-ai-chat-imggen-toggle" checked>
|
||||
<span id="wfm-ai-chat-imggen-label-text">Allow image generation</span>
|
||||
</label>
|
||||
<label class="wfm-ai-chat-toggle-label" for="wfm-ai-chat-check-toggle" id="wfm-ai-chat-check-label" title="Check generated images and SVGs with a vision decision model">
|
||||
<input type="checkbox" id="wfm-ai-chat-check-toggle">
|
||||
<span id="wfm-ai-chat-check-label-text">Check images</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3517,6 +3549,7 @@
|
||||
<img id="wfm-ai-vlm-preview" style="display:none;max-width:100%;max-height:100%;object-fit:contain;pointer-events:none;">
|
||||
<span id="wfm-ai-vlm-label" class="wfm-ai-vlm-label">Drop or click PNG / JPG / WebP</span>
|
||||
<input type="file" id="wfm-ai-vlm-file" accept="image/png,image/jpeg,image/webp" style="display:none;">
|
||||
<div id="wfm-ai-ocr-overlay" class="wfm-ai-ocr-overlay" style="display:none;"></div>
|
||||
<button id="wfm-ai-vlm-clear" class="wfm-ai-vlm-clear" style="display:none;" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="wfm-ai-wc-inputs" id="wfm-ai-wc-inputs" style="display:none">
|
||||
@@ -3535,18 +3568,38 @@
|
||||
<option value="prompt">Create prompt</option>
|
||||
<option value="tags">Create tags</option>
|
||||
<option value="wildcard">Create wildcards</option>
|
||||
<option value="ocr">OCR (extract text)</option>
|
||||
</select>
|
||||
<button id="wfm-ai-vlm-run" class="wfm-btn wfm-btn-primary">Run</button>
|
||||
</div>
|
||||
<div id="wfm-ai-ocr-options" class="wfm-ai-ocr-options" style="display:none;">
|
||||
<div class="wfm-ai-ocr-row">
|
||||
<label class="wfm-ai-label" id="wfm-ai-ocr-model-label">OCR model</label>
|
||||
<select id="wfm-ai-ocr-model" class="wfm-select"></select>
|
||||
<button type="button" id="wfm-ai-ocr-refresh" class="wfm-btn wfm-btn-sm">↻</button>
|
||||
</div>
|
||||
<label class="wfm-ai-ocr-check" id="wfm-ai-ocr-boxes-wrap">
|
||||
<input type="checkbox" id="wfm-ai-ocr-boxes">
|
||||
<span id="wfm-ai-ocr-boxes-label">Show text positions</span>
|
||||
</label>
|
||||
<div id="wfm-ai-ocr-hint" class="wfm-ai-ocr-hint"></div>
|
||||
</div>
|
||||
<span id="wfm-ai-vlm-status" class="wfm-ai-trans-status"></span>
|
||||
<textarea id="wfm-ai-vlm-result" class="wfm-ai-vlm-result" placeholder="Result..." readonly></textarea>
|
||||
<button id="wfm-ai-vlm-copy" class="wfm-btn wfm-ai-vlm-copy-btn">Copy</button>
|
||||
<div class="wfm-ai-vlm-result-actions">
|
||||
<button id="wfm-ai-vlm-to-trans" class="wfm-btn wfm-btn-sm">To Translation</button>
|
||||
<button id="wfm-ai-vlm-to-chat" class="wfm-btn wfm-btn-sm">To Chat</button>
|
||||
<button id="wfm-ai-vlm-copy" class="wfm-btn wfm-ai-vlm-copy-btn">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-pane-divider"></div>
|
||||
<!-- Settings pane -->
|
||||
<div class="wfm-ai-pane wfm-ai-pane-settings">
|
||||
<div class="wfm-ai-pane-header">Settings</div>
|
||||
<div class="wfm-ai-pane wfm-ai-pane-settings" data-collapse-pane="settings">
|
||||
<div class="wfm-ai-pane-header">
|
||||
<span class="wfm-ai-pane-title">Settings</span>
|
||||
<button type="button" class="wfm-ai-pane-collapse-btn" data-collapse-btn="settings" data-dir="right" title="Collapse">▶</button>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-container">
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title" id="wfm-ai-settings-backend-title">Backend</div>
|
||||
@@ -3638,6 +3691,28 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<div class="wfm-ai-settings-title" id="wfm-ai-settings-check-title">Chat Image Check</div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<label class="wfm-ai-label" id="wfm-ai-judge-model-label">Judge model</label>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-row" style="display:flex;gap:6px;">
|
||||
<select id="wfm-ai-judge-model-select" class="wfm-select" style="flex:1;min-width:0;">
|
||||
<option value="d3:4b">d3:4b</option>
|
||||
</select>
|
||||
<button id="wfm-ai-judge-refresh-btn" class="wfm-btn wfm-btn-sm">Refresh</button>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-row" id="wfm-ai-judge-hint" style="font-size:11px;opacity:0.75;"></div>
|
||||
<div class="wfm-ai-settings-row">
|
||||
<label class="wfm-ai-label" id="wfm-ai-check-rounds-label">Automatic fix rounds</label>
|
||||
<select id="wfm-ai-check-rounds-select" class="wfm-select">
|
||||
<option value="0">0</option>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wfm-ai-settings-section">
|
||||
<button id="wfm-ai-settings-save-btn" class="wfm-btn wfm-btn-primary">Save</button>
|
||||
</div>
|
||||
|
||||
@@ -3603,7 +3603,8 @@ function _extractPromptsAPI(wf) {
|
||||
const srcNode = wf[String(val[0])];
|
||||
// TextEncodeFibo2(Fibo 2.0)も同形。promptはPreviewAny→プロンプト強化スイッチ経由でリンクされる。
|
||||
if (srcNode?.class_type === "TextEncodeMageFlowEdit" || srcNode?.class_type === "TextEncodeBooguEdit"
|
||||
|| srcNode?.class_type === "TextEncodeFibo2") {
|
||||
|| srcNode?.class_type === "TextEncodeFibo2" || srcNode?.class_type === "PH3ImageConditioning"
|
||||
|| srcNode?.class_type === "MiniMaxH3ImageConditioning") {
|
||||
const raw = isPos ? srcNode.inputs?.prompt : srcNode.inputs?.negative_prompt;
|
||||
const txt = Array.isArray(raw) ? _resolveLinkedText(wf, raw[0], raw[1] ?? 0) : raw;
|
||||
if (txt && typeof txt === "string") { if (isPos) pos.add(txt); else neg.add(txt); }
|
||||
|
||||
@@ -1,818 +0,0 @@
|
||||
/**
|
||||
* Node Library Sidebar Panel
|
||||
*
|
||||
* Fixed sidebar panel injected into ComfyUI DOM.
|
||||
* Three tabs: Favorites (single nodes), Node Sets (multi-node), Groups.
|
||||
* Items are draggable onto the canvas or click-to-place.
|
||||
*/
|
||||
console.log("[WFM] node_sets_menu.js loading...");
|
||||
import { app } from "../../scripts/app.js";
|
||||
console.log("[WFM] node_sets_menu.js: app imported");
|
||||
|
||||
export const NODE_SETS_TOOLTIP = "Node Library – Browse & drag nodes onto canvas";
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
// ============================================
|
||||
|
||||
const state = {
|
||||
visible: false,
|
||||
activeTab: "favorites", // favorites | sets | groups
|
||||
favorites: [], // [{name, display_name}]
|
||||
nodeSets: [],
|
||||
groups: {}, // {groupName: [nodeName, ...]}
|
||||
metadata: {},
|
||||
searchText: "",
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// API
|
||||
// ============================================
|
||||
|
||||
const fetchNodeSets = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/node-sets");
|
||||
return res.ok ? await res.json() : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/nodes/metadata");
|
||||
return res.ok ? await res.json() : {};
|
||||
} catch { return {}; }
|
||||
};
|
||||
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wfm/nodes/groups");
|
||||
return res.ok ? await res.json() : {};
|
||||
} catch { return {}; }
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
const [metadata, nodeSets, groups] = await Promise.all([
|
||||
fetchMetadata(), fetchNodeSets(), fetchGroups(),
|
||||
]);
|
||||
state.metadata = metadata;
|
||||
state.nodeSets = nodeSets;
|
||||
state.groups = groups;
|
||||
|
||||
// Extract favorites from metadata
|
||||
state.favorites = [];
|
||||
for (const [name, meta] of Object.entries(metadata)) {
|
||||
if (name === "_groups") continue;
|
||||
if (meta.favorite) {
|
||||
state.favorites.push({ name, display_name: name });
|
||||
}
|
||||
}
|
||||
state.loaded = true;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Canvas placement helpers
|
||||
// ============================================
|
||||
|
||||
const getCanvasDropPos = (e) => {
|
||||
const canvas = app.canvas;
|
||||
if (!canvas) return [100, 100];
|
||||
const rect = canvas.canvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / canvas.ds.scale - canvas.ds.offset[0];
|
||||
const y = (e.clientY - rect.top) / canvas.ds.scale - canvas.ds.offset[1];
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
const placeSingleNode = (classType, pos) => {
|
||||
const graph = app.graph;
|
||||
if (!graph) return;
|
||||
const node = LiteGraph.createNode(classType);
|
||||
if (!node) {
|
||||
showToast(`Unknown node: ${classType}`, "error");
|
||||
return;
|
||||
}
|
||||
node.pos = pos || [100, 100];
|
||||
graph.add(node);
|
||||
app.canvas.setDirty(true, true);
|
||||
showToast(`Placed: ${node.title || classType}`, "success");
|
||||
};
|
||||
|
||||
const placeNodeSet = (nodeSet, pos) => {
|
||||
const graph = app.graph;
|
||||
const canvas = app.canvas;
|
||||
if (!graph || !canvas) return;
|
||||
|
||||
const nodes = nodeSet.nodes || [];
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
const baseX = pos ? pos[0] : (-canvas.ds.offset[0] + canvas.canvas.width / 2 / canvas.ds.scale);
|
||||
const baseY = pos ? pos[1] : (-canvas.ds.offset[1] + canvas.canvas.height / 2 / canvas.ds.scale);
|
||||
|
||||
const positions = nodes.map(n => n.rel_pos || [0, 0]);
|
||||
const minX = Math.min(...positions.map(p => p[0]));
|
||||
const minY = Math.min(...positions.map(p => p[1]));
|
||||
|
||||
const createdNodes = [];
|
||||
for (const nodeDef of nodes) {
|
||||
if (!nodeDef.class_type) { createdNodes.push(null); continue; }
|
||||
const node = LiteGraph.createNode(nodeDef.class_type);
|
||||
if (!node) { createdNodes.push(null); continue; }
|
||||
const rp = nodeDef.rel_pos || [0, 0];
|
||||
node.pos = [baseX + rp[0] - minX, baseY + rp[1] - minY];
|
||||
if (nodeDef.title) node.title = nodeDef.title;
|
||||
if (nodeDef.widget_values && node.widgets) {
|
||||
for (const [k, v] of Object.entries(nodeDef.widget_values)) {
|
||||
const w = node.widgets.find(ww => ww.name === k);
|
||||
if (w) w.value = v;
|
||||
}
|
||||
}
|
||||
graph.add(node);
|
||||
createdNodes.push(node);
|
||||
}
|
||||
|
||||
for (const link of (nodeSet.links || [])) {
|
||||
const from = createdNodes[link.from_node];
|
||||
const to = createdNodes[link.to_node];
|
||||
if (from && to) {
|
||||
try { from.connect(link.from_slot ?? 0, to, link.to_slot ?? 0); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
canvas.setDirty(true, true);
|
||||
showToast(`Placed "${nodeSet.name}" (${createdNodes.filter(Boolean).length} nodes)`, "success");
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Drag & Drop onto canvas
|
||||
// ============================================
|
||||
|
||||
let dropHandlerInstalled = false;
|
||||
|
||||
const installCanvasDropHandler = () => {
|
||||
if (dropHandlerInstalled) return;
|
||||
dropHandlerInstalled = true;
|
||||
|
||||
const canvasEl = document.getElementById("graph-canvas");
|
||||
if (!canvasEl) return;
|
||||
|
||||
canvasEl.addEventListener("dragover", (e) => {
|
||||
if (e.dataTransfer.types.includes("application/x-wfm-node")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
});
|
||||
|
||||
canvasEl.addEventListener("drop", (e) => {
|
||||
const raw = e.dataTransfer.getData("application/x-wfm-node");
|
||||
if (!raw) return;
|
||||
e.preventDefault();
|
||||
|
||||
const data = JSON.parse(raw);
|
||||
const pos = getCanvasDropPos(e);
|
||||
|
||||
if (data.type === "single") {
|
||||
placeSingleNode(data.classType, pos);
|
||||
} else if (data.type === "set") {
|
||||
const set = state.nodeSets.find(s => s.id === data.setId);
|
||||
if (set) placeNodeSet(set, pos);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Sidebar Panel DOM
|
||||
// ============================================
|
||||
|
||||
let panelEl = null;
|
||||
|
||||
const PANEL_ID = "wfm-node-library-panel";
|
||||
|
||||
const createPanel = () => {
|
||||
if (panelEl) return panelEl;
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.id = PANEL_ID;
|
||||
panel.innerHTML = `
|
||||
<div class="wfm-nlp-header">
|
||||
<span class="wfm-nlp-title">Node Library</span>
|
||||
<button class="wfm-nlp-refresh" title="Refresh">↻</button>
|
||||
<button class="wfm-nlp-close" title="Close">×</button>
|
||||
</div>
|
||||
<div class="wfm-nlp-tabs">
|
||||
<button class="wfm-nlp-tab active" data-tab="favorites">★ Favorites</button>
|
||||
<button class="wfm-nlp-tab" data-tab="sets">☰ Sets</button>
|
||||
<button class="wfm-nlp-tab" data-tab="groups">📁 Groups</button>
|
||||
</div>
|
||||
<div class="wfm-nlp-search">
|
||||
<input type="text" class="wfm-nlp-search-input" placeholder="Search...">
|
||||
</div>
|
||||
<div class="wfm-nlp-content"></div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(panel);
|
||||
panelEl = panel;
|
||||
|
||||
// Tab switching
|
||||
panel.querySelectorAll(".wfm-nlp-tab").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.activeTab = btn.dataset.tab;
|
||||
panel.querySelectorAll(".wfm-nlp-tab").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
renderContent();
|
||||
});
|
||||
});
|
||||
|
||||
// Close
|
||||
panel.querySelector(".wfm-nlp-close").addEventListener("click", () => togglePanel());
|
||||
|
||||
// Search
|
||||
panel.querySelector(".wfm-nlp-search-input").addEventListener("input", (e) => {
|
||||
state.searchText = e.target.value.toLowerCase().trim();
|
||||
renderContent();
|
||||
});
|
||||
|
||||
// Refresh
|
||||
panel.querySelector(".wfm-nlp-refresh").addEventListener("click", async () => {
|
||||
await loadData();
|
||||
renderContent();
|
||||
showToast("Node Library refreshed", "success");
|
||||
});
|
||||
|
||||
injectStyles();
|
||||
return panel;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Render content
|
||||
// ============================================
|
||||
|
||||
const renderContent = () => {
|
||||
const content = panelEl?.querySelector(".wfm-nlp-content");
|
||||
if (!content) return;
|
||||
|
||||
if (!state.loaded) {
|
||||
content.innerHTML = `<div class="wfm-nlp-empty">Loading...</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (state.activeTab) {
|
||||
case "favorites": renderFavorites(content); break;
|
||||
case "sets": renderSets(content); break;
|
||||
case "groups": renderGroups(content); break;
|
||||
}
|
||||
};
|
||||
|
||||
const renderFavorites = (container) => {
|
||||
let items = state.favorites;
|
||||
if (state.searchText) {
|
||||
items = items.filter(n => n.name.toLowerCase().includes(state.searchText));
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
${state.favorites.length === 0 ? "No favorites yet.<br><small>Star nodes in Workflow Studio → Nodes tab</small>" : "No matches"}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const node of items) {
|
||||
const el = createDraggableItem(node.display_name || node.name, "single", { classType: node.name });
|
||||
el.addEventListener("dblclick", () => {
|
||||
placeSingleNode(node.name);
|
||||
});
|
||||
container.appendChild(el);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSets = (container) => {
|
||||
let items = state.nodeSets;
|
||||
if (state.searchText) {
|
||||
items = items.filter(s =>
|
||||
s.name.toLowerCase().includes(state.searchText) ||
|
||||
(s.tags || []).some(t => t.toLowerCase().includes(state.searchText))
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
${state.nodeSets.length === 0 ? "No node sets.<br><small>Create sets in Workflow Studio → Nodes tab</small>" : "No matches"}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const set of items) {
|
||||
const count = (set.nodes || []).length;
|
||||
const el = createDraggableItem(
|
||||
`${esc(set.name)} <span class="wfm-nlp-badge">${count} nodes</span>`,
|
||||
"set",
|
||||
{ setId: set.id },
|
||||
set.description
|
||||
);
|
||||
el.addEventListener("dblclick", () => {
|
||||
placeNodeSet(set);
|
||||
});
|
||||
container.appendChild(el);
|
||||
}
|
||||
};
|
||||
|
||||
const renderGroups = (container) => {
|
||||
const groupNames = Object.keys(state.groups).sort();
|
||||
|
||||
if (groupNames.length === 0) {
|
||||
container.innerHTML = `<div class="wfm-nlp-empty">
|
||||
No groups.<br><small>Create groups in Workflow Studio → Nodes tab</small>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let filtered = groupNames;
|
||||
if (state.searchText) {
|
||||
filtered = groupNames.filter(g =>
|
||||
g.toLowerCase().includes(state.searchText) ||
|
||||
(state.groups[g] || []).some(n => n.toLowerCase().includes(state.searchText))
|
||||
);
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const groupName of filtered) {
|
||||
const nodes = state.groups[groupName] || [];
|
||||
|
||||
const section = document.createElement("div");
|
||||
section.className = "wfm-nlp-group-section";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "wfm-nlp-group-header";
|
||||
header.innerHTML = `<span>${esc(groupName)}</span> <span class="wfm-nlp-badge">${nodes.length}</span>`;
|
||||
header.addEventListener("click", () => {
|
||||
const list = section.querySelector(".wfm-nlp-group-list");
|
||||
list.style.display = list.style.display === "none" ? "block" : "none";
|
||||
header.classList.toggle("collapsed");
|
||||
});
|
||||
section.appendChild(header);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "wfm-nlp-group-list";
|
||||
for (const nodeName of nodes) {
|
||||
const el = createDraggableItem(nodeName, "single", { classType: nodeName });
|
||||
el.addEventListener("dblclick", () => placeSingleNode(nodeName));
|
||||
list.appendChild(el);
|
||||
}
|
||||
section.appendChild(list);
|
||||
container.appendChild(section);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Draggable item factory
|
||||
// ============================================
|
||||
|
||||
const createDraggableItem = (label, type, data, subtitle) => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "wfm-nlp-item";
|
||||
el.draggable = true;
|
||||
el.innerHTML = `
|
||||
<div class="wfm-nlp-item-label">${label}</div>
|
||||
${subtitle ? `<div class="wfm-nlp-item-sub">${esc(subtitle)}</div>` : ""}
|
||||
`;
|
||||
|
||||
el.addEventListener("dragstart", (e) => {
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.dataTransfer.setData("application/x-wfm-node", JSON.stringify({ type, ...data }));
|
||||
el.classList.add("dragging");
|
||||
});
|
||||
|
||||
el.addEventListener("dragend", () => {
|
||||
el.classList.remove("dragging");
|
||||
});
|
||||
|
||||
return el;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Toggle & exports
|
||||
// ============================================
|
||||
|
||||
export const togglePanel = async () => {
|
||||
const panel = createPanel();
|
||||
state.visible = !state.visible;
|
||||
panel.style.display = state.visible ? "flex" : "none";
|
||||
|
||||
if (state.visible && !state.loaded) {
|
||||
await loadData();
|
||||
renderContent();
|
||||
}
|
||||
|
||||
installCanvasDropHandler();
|
||||
};
|
||||
|
||||
export const getNodeSetsIcon = () => `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<path d="M17.5 14v7M14 17.5h7"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// ============================================
|
||||
// Toast
|
||||
// ============================================
|
||||
|
||||
const showToast = (message, type = "info") => {
|
||||
const existing = document.getElementById("wfm-ns-toast");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.id = "wfm-ns-toast";
|
||||
const colors = {
|
||||
success: { bg: "rgba(46,213,115,0.95)" },
|
||||
error: { bg: "rgba(255,71,87,0.95)" },
|
||||
info: { bg: "rgba(74,158,255,0.95)" },
|
||||
};
|
||||
Object.assign(toast.style, {
|
||||
position: "fixed", bottom: "24px", left: "50%", transform: "translateX(-50%)",
|
||||
padding: "10px 24px", background: (colors[type] || colors.info).bg, color: "#fff",
|
||||
borderRadius: "8px", fontSize: "14px", fontWeight: "500", zIndex: "99999",
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.3)", transition: "opacity 0.3s", whiteSpace: "nowrap",
|
||||
});
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 300); }, 3000);
|
||||
};
|
||||
|
||||
const esc = (s) => s ? String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """) : "";
|
||||
|
||||
// ============================================
|
||||
// Save selected nodes as Node Set (context menu)
|
||||
// ============================================
|
||||
|
||||
const saveSelectedAsNodeSet = async () => {
|
||||
const selectedNodes = Object.values(app.canvas.selected_nodes || {});
|
||||
if (selectedNodes.length === 0) {
|
||||
showToast("Select at least one node first", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prompt for name
|
||||
const name = prompt("Node Set name:");
|
||||
if (!name || !name.trim()) return;
|
||||
|
||||
// Build node list with relative positions
|
||||
// Use the top-left of the bounding box as origin
|
||||
const minX = Math.min(...selectedNodes.map(n => n.pos[0]));
|
||||
const minY = Math.min(...selectedNodes.map(n => n.pos[1]));
|
||||
|
||||
const nodeIdToIndex = new Map();
|
||||
const nodes = [];
|
||||
selectedNodes.forEach((node, i) => {
|
||||
nodeIdToIndex.set(node.id, i);
|
||||
nodes.push({
|
||||
class_type: node.type || node.comfyClass || "",
|
||||
title: node.title || "",
|
||||
rel_pos: [Math.round(node.pos[0] - minX), Math.round(node.pos[1] - minY)],
|
||||
});
|
||||
});
|
||||
|
||||
// Extract links between selected nodes
|
||||
const links = [];
|
||||
const graph = app.graph;
|
||||
|
||||
for (const node of selectedNodes) {
|
||||
if (!node.inputs) continue;
|
||||
for (let slotIdx = 0; slotIdx < node.inputs.length; slotIdx++) {
|
||||
const input = node.inputs[slotIdx];
|
||||
if (input.link == null) continue;
|
||||
|
||||
// Resolve link data
|
||||
let linkData = null;
|
||||
if (graph.links instanceof Map) {
|
||||
linkData = graph.links.get(input.link);
|
||||
} else if (graph.links) {
|
||||
linkData = graph.links[input.link];
|
||||
}
|
||||
if (!linkData) continue;
|
||||
|
||||
// linkData: [id, origin_id, origin_slot, target_id, target_slot, type]
|
||||
// or object with .origin_id, .origin_slot, etc.
|
||||
const originId = linkData.origin_id ?? linkData[1];
|
||||
const originSlot = linkData.origin_slot ?? linkData[2];
|
||||
const targetSlot = linkData.target_slot ?? linkData[4];
|
||||
|
||||
// Only include if both source and target are in the selection
|
||||
if (nodeIdToIndex.has(originId) && nodeIdToIndex.has(node.id)) {
|
||||
links.push({
|
||||
from_node: nodeIdToIndex.get(originId),
|
||||
from_slot: originSlot,
|
||||
to_node: nodeIdToIndex.get(node.id),
|
||||
to_slot: targetSlot,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save via API
|
||||
try {
|
||||
const res = await fetch("/api/wfm/node-sets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name.trim(), description: "", tags: [], nodes, links }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.status === "ok") {
|
||||
showToast(`Node Set "${name.trim()}" saved (${nodes.length} nodes, ${links.length} links)`, "success");
|
||||
// Refresh sidebar if open
|
||||
if (state.loaded) {
|
||||
await loadData();
|
||||
renderContent();
|
||||
}
|
||||
} else {
|
||||
showToast("Failed to save: " + (result.error || "Unknown error"), "error");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast("Failed to save: " + err.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
// Register context menu item on all nodes
|
||||
app.registerExtension({
|
||||
name: "WorkflowStudio.NodeSetContextMenu",
|
||||
beforeRegisterNodeDef(nodeType, _nodeData, _app) {
|
||||
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function(_, options) {
|
||||
origGetExtraMenuOptions?.apply(this, arguments);
|
||||
|
||||
const selectedCount = Object.keys(app.canvas.selected_nodes || {}).length;
|
||||
|
||||
// Add separator + menu item
|
||||
options.push(null);
|
||||
options.push({
|
||||
content: `Save as Node Set${selectedCount > 1 ? ` (${selectedCount} nodes)` : ""}`,
|
||||
callback: saveSelectedAsNodeSet,
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Styles
|
||||
// ============================================
|
||||
|
||||
const injectStyles = () => {
|
||||
if (document.getElementById("wfm-nlp-styles")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "wfm-nlp-styles";
|
||||
style.textContent = `
|
||||
#${PANEL_ID} {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background: var(--comfy-menu-bg, #1e1e1e);
|
||||
border-left: 1px solid var(--border-color, #4e4e4e);
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
font-family: sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--input-text, #ddd);
|
||||
box-shadow: -4px 0 16px rgba(0,0,0,0.3);
|
||||
}
|
||||
.wfm-nlp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-nlp-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
.wfm-nlp-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.wfm-nlp-close:hover { opacity: 1; }
|
||||
.wfm-nlp-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-nlp-tab {
|
||||
flex: 1;
|
||||
padding: 8px 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wfm-nlp-tab:hover { opacity: 0.9; }
|
||||
.wfm-nlp-tab.active {
|
||||
opacity: 1;
|
||||
border-bottom-color: var(--p-button-background, #4a9eff);
|
||||
}
|
||||
.wfm-nlp-search {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wfm-nlp-search-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: var(--comfy-input-bg, #2a2a2a);
|
||||
border: 1px solid var(--border-color, #4e4e4e);
|
||||
border-radius: 4px;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wfm-nlp-search-input:focus {
|
||||
border-color: var(--p-button-background, #4a9eff);
|
||||
}
|
||||
.wfm-nlp-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.wfm-nlp-empty {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--descrip-text, #999);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wfm-nlp-item {
|
||||
padding: 7px 12px;
|
||||
cursor: grab;
|
||||
border-bottom: 1px solid var(--border-color, #3a3a3a);
|
||||
transition: background 0.12s;
|
||||
user-select: none;
|
||||
}
|
||||
.wfm-nlp-item:hover {
|
||||
background: var(--comfy-input-bg, #333);
|
||||
}
|
||||
.wfm-nlp-item.dragging {
|
||||
opacity: 0.5;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
}
|
||||
.wfm-nlp-item-label {
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.wfm-nlp-item-sub {
|
||||
font-size: 10px;
|
||||
color: var(--descrip-text, #888);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wfm-nlp-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
background: var(--comfy-input-bg, #444);
|
||||
border-radius: 3px;
|
||||
color: var(--descrip-text, #aaa);
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.wfm-nlp-group-section {
|
||||
border-bottom: 1px solid var(--border-color, #3a3a3a);
|
||||
}
|
||||
.wfm-nlp-group-header {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.wfm-nlp-group-header:hover {
|
||||
background: var(--comfy-input-bg, #333);
|
||||
}
|
||||
.wfm-nlp-group-header::before {
|
||||
content: "\\25BC";
|
||||
font-size: 8px;
|
||||
margin-right: 6px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.wfm-nlp-group-header.collapsed::before {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.wfm-nlp-group-list {
|
||||
padding-left: 8px;
|
||||
}
|
||||
.wfm-nlp-group-list .wfm-nlp-item {
|
||||
padding: 5px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.wfm-nlp-refresh {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--input-text, #ddd);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.wfm-nlp-refresh:hover { opacity: 1; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Context Menu — Save selected nodes as Node Set
|
||||
// ============================================
|
||||
|
||||
const saveSelectedAsNodeSet = async () => {
|
||||
const selectedNodes = Object.values(app.canvas.selected_nodes || {});
|
||||
if (selectedNodes.length === 0) {
|
||||
showToast("Select at least one node first", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const name = prompt("Node Set name:");
|
||||
if (!name || !name.trim()) return;
|
||||
|
||||
const minX = Math.min(...selectedNodes.map(n => n.pos[0]));
|
||||
const minY = Math.min(...selectedNodes.map(n => n.pos[1]));
|
||||
|
||||
const nodeIdToIndex = new Map();
|
||||
const nodes = [];
|
||||
selectedNodes.forEach((node, i) => {
|
||||
nodeIdToIndex.set(node.id, i);
|
||||
nodes.push({
|
||||
class_type: node.type || node.comfyClass || "",
|
||||
title: node.title || "",
|
||||
rel_pos: [Math.round(node.pos[0] - minX), Math.round(node.pos[1] - minY)],
|
||||
});
|
||||
});
|
||||
|
||||
const links = [];
|
||||
const graph = app.graph;
|
||||
for (const node of selectedNodes) {
|
||||
if (!node.inputs) continue;
|
||||
for (let slotIdx = 0; slotIdx < node.inputs.length; slotIdx++) {
|
||||
const input = node.inputs[slotIdx];
|
||||
if (input.link == null) continue;
|
||||
|
||||
let linkData = null;
|
||||
if (graph.links instanceof Map) {
|
||||
linkData = graph.links.get(input.link);
|
||||
} else if (graph.links) {
|
||||
linkData = graph.links[input.link];
|
||||
}
|
||||
if (!linkData) continue;
|
||||
|
||||
const originId = linkData.origin_id ?? linkData[1];
|
||||
const originSlot = linkData.origin_slot ?? linkData[2];
|
||||
const targetSlot = linkData.target_slot ?? linkData[4];
|
||||
|
||||
if (nodeIdToIndex.has(originId) && nodeIdToIndex.has(node.id)) {
|
||||
links.push({
|
||||
from_node: nodeIdToIndex.get(originId),
|
||||
from_slot: originSlot,
|
||||
to_node: nodeIdToIndex.get(node.id),
|
||||
to_slot: targetSlot,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/wfm/node-sets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name.trim(), description: "", tags: [], nodes, links }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.status === "ok") {
|
||||
showToast(`Node Set "${name.trim()}" saved (${nodes.length} nodes, ${links.length} links)`, "success");
|
||||
if (state.loaded) { await loadData(); renderContent(); }
|
||||
} else {
|
||||
showToast("Failed to save: " + (result.error || "Unknown error"), "error");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast("Failed to save: " + err.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
export { saveSelectedAsNodeSet };
|
||||
@@ -5,8 +5,6 @@ const BUTTON_TOOLTIP = "Launch Workflow Studio (Shift+Click opens in new window)
|
||||
const SNAPSHOT_TOOLTIP = "Save workflow canvas image as thumbnail";
|
||||
const WFM_PATH = "/wfm";
|
||||
const NEW_WINDOW_FEATURES = "width=1400,height=900,resizable=yes,scrollbars=yes,status=yes";
|
||||
const MAX_ATTACH_ATTEMPTS = 120;
|
||||
const BUTTON_GROUP_CLASS = "wfm-top-menu-group";
|
||||
|
||||
const MIN_VERSION_FOR_ACTION_BAR = [1, 33, 9];
|
||||
|
||||
@@ -425,121 +423,6 @@ const getSnapshotIcon = () => {
|
||||
`;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Button Creation
|
||||
// ============================================
|
||||
|
||||
const createTopMenuButton = async () => {
|
||||
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
|
||||
|
||||
const button = new ComfyButton({
|
||||
icon: "wfm",
|
||||
tooltip: BUTTON_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse primary",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", BUTTON_TOOLTIP);
|
||||
button.element.title = BUTTON_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getWfmIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", openWorkflowStudio);
|
||||
return button;
|
||||
};
|
||||
|
||||
const createSnapshotButton = async () => {
|
||||
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
|
||||
|
||||
const button = new ComfyButton({
|
||||
icon: "snapshot",
|
||||
tooltip: SNAPSHOT_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", SNAPSHOT_TOOLTIP);
|
||||
button.element.title = SNAPSHOT_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getSnapshotIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", saveCanvasToWorkflowStudio);
|
||||
return button;
|
||||
};
|
||||
|
||||
const createNodeSetsButton = async () => {
|
||||
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
|
||||
|
||||
const button = new ComfyButton({
|
||||
icon: "node-sets",
|
||||
tooltip: NODE_SETS_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse wfm-node-sets-btn",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", NODE_SETS_TOOLTIP);
|
||||
button.element.title = NODE_SETS_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getNodeSetsIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
togglePanel();
|
||||
});
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
const attachTopMenuButton = async (attempt = 0) => {
|
||||
if (document.querySelector(`.${BUTTON_GROUP_CLASS}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try settingsGroup first (legacy ComfyUI)
|
||||
const settingsGroup = app.menu?.settingsGroup;
|
||||
// Fallback: find the legacy topbar container used by newer ComfyUI
|
||||
const legacyContainer = document.querySelector('[data-testid="legacy-topbar-container"] .flex');
|
||||
|
||||
if (!settingsGroup?.element?.parentElement && !legacyContainer) {
|
||||
if (attempt >= MAX_ATTACH_ATTEMPTS) {
|
||||
console.warn("Workflow Studio: unable to locate the ComfyUI menu container.");
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => attachTopMenuButton(attempt + 1));
|
||||
return;
|
||||
}
|
||||
|
||||
const wfmButton = await createTopMenuButton();
|
||||
const snapshotButton = await createSnapshotButton();
|
||||
const nodeSetsButton = await createNodeSetsButton();
|
||||
const { ComfyButtonGroup } = await import("../../scripts/ui/components/buttonGroup.js");
|
||||
|
||||
const buttonGroup = new ComfyButtonGroup(wfmButton, snapshotButton, nodeSetsButton);
|
||||
buttonGroup.element.classList.add(BUTTON_GROUP_CLASS);
|
||||
|
||||
if (settingsGroup?.element?.parentElement) {
|
||||
settingsGroup.element.before(buttonGroup.element);
|
||||
} else if (legacyContainer) {
|
||||
legacyContainer.prepend(buttonGroup.element);
|
||||
}
|
||||
};
|
||||
|
||||
app.registerExtension({
|
||||
name: "WorkflowStudio.TopMenu",
|
||||
actionBarButtons: [
|
||||
|
||||
@@ -1,565 +0,0 @@
|
||||
console.log("[WFM] top_menu_extension.js loading...");
|
||||
import { app } from "../../scripts/app.js";
|
||||
console.log("[WFM] app imported:", !!app);
|
||||
import { togglePanel, getNodeSetsIcon, NODE_SETS_TOOLTIP, saveSelectedAsNodeSet } from "./node_sets_menu.js";
|
||||
console.log("[WFM] node_sets_menu imports loaded");
|
||||
|
||||
const BUTTON_TOOLTIP = "Launch Workflow Studio (Shift+Click opens in new window)";
|
||||
const SNAPSHOT_TOOLTIP = "Save workflow canvas image as thumbnail";
|
||||
const WFM_PATH = "/wfm";
|
||||
const NEW_WINDOW_FEATURES = "width=1400,height=900,resizable=yes,scrollbars=yes,status=yes";
|
||||
const MAX_ATTACH_ATTEMPTS = 120;
|
||||
const BUTTON_GROUP_CLASS = "wfm-top-menu-group";
|
||||
|
||||
const MIN_VERSION_FOR_ACTION_BAR = [1, 33, 9];
|
||||
|
||||
const openWorkflowStudio = (event) => {
|
||||
const url = `${window.location.origin}${WFM_PATH}`;
|
||||
|
||||
if (event.shiftKey) {
|
||||
window.open(url, "_blank", NEW_WINDOW_FEATURES);
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, "_blank");
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Canvas Snapshot - Export workflow as PNG thumbnail
|
||||
// ============================================
|
||||
|
||||
const getWorkflowFilename = () => {
|
||||
const now = new Date();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
return `wf_${stamp}.json`;
|
||||
};
|
||||
|
||||
// ---- PNG tEXt chunk embedding helpers ----
|
||||
|
||||
const n2b = (n) => new Uint8Array([(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]);
|
||||
|
||||
const joinArrayBuffer = (...bufs) => {
|
||||
const result = new Uint8Array(bufs.reduce((total, buf) => total + buf.byteLength, 0));
|
||||
bufs.reduce((offset, buf) => {
|
||||
result.set(buf, offset);
|
||||
return offset + buf.byteLength;
|
||||
}, 0);
|
||||
return result;
|
||||
};
|
||||
|
||||
let _crcTable = null;
|
||||
const crc32 = (data) => {
|
||||
if (!_crcTable) {
|
||||
_crcTable = [];
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
_crcTable[n] = c;
|
||||
}
|
||||
}
|
||||
let crc = 0 ^ -1;
|
||||
for (let i = 0; i < data.byteLength; i++) {
|
||||
crc = (crc >>> 8) ^ _crcTable[(crc ^ data[i]) & 0xff];
|
||||
}
|
||||
return (crc ^ -1) >>> 0;
|
||||
};
|
||||
|
||||
const embedWorkflowInPng = async (blob, workflowJson) => {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const typedArr = new Uint8Array(buffer);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
// Create tEXt chunk: "tEXtworkflow\0{json}"
|
||||
const data = new TextEncoder().encode(`tEXtworkflow\0${workflowJson}`);
|
||||
const chunk = joinArrayBuffer(n2b(data.byteLength - 4), data, n2b(crc32(data)));
|
||||
|
||||
// Insert after IHDR chunk (8-byte signature + IHDR length field(4) + IHDR type(4) + IHDR data + CRC(4))
|
||||
const ihdrDataLen = view.getUint32(8);
|
||||
const sz = ihdrDataLen + 20; // 8(sig) + 4(len) + 4(type) + ihdrDataLen + 4(crc)
|
||||
const result = joinArrayBuffer(typedArr.subarray(0, sz), chunk, typedArr.subarray(sz));
|
||||
|
||||
return new Blob([result], { type: "image/png" });
|
||||
};
|
||||
|
||||
// ---- Canvas capture ----
|
||||
|
||||
const captureCanvasSnapshot = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const graph = app.graph;
|
||||
const canvas = app.canvas;
|
||||
if (!graph || !canvas || !graph._nodes?.length) {
|
||||
reject(new Error("No workflow loaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Serialize workflow data before changing canvas state
|
||||
const workflowJson = JSON.stringify(graph.serialize());
|
||||
|
||||
// Calculate bounds of all nodes
|
||||
const bounds = graph._nodes.reduce(
|
||||
(p, n) => {
|
||||
if (n.pos[0] < p[0]) p[0] = n.pos[0];
|
||||
if (n.pos[1] < p[1]) p[1] = n.pos[1];
|
||||
const b = n.getBounding();
|
||||
const r = n.pos[0] + b[2];
|
||||
const bot = n.pos[1] + b[3];
|
||||
if (r > p[2]) p[2] = r;
|
||||
if (bot > p[3]) p[3] = bot;
|
||||
return p;
|
||||
},
|
||||
[99999, 99999, -99999, -99999]
|
||||
);
|
||||
|
||||
// Add padding
|
||||
const padding = 80;
|
||||
bounds[0] -= padding;
|
||||
bounds[1] -= padding;
|
||||
bounds[2] += padding;
|
||||
bounds[3] += padding;
|
||||
|
||||
// Save current canvas state
|
||||
const savedState = {
|
||||
scale: canvas.ds.scale,
|
||||
width: canvas.canvas.width,
|
||||
height: canvas.canvas.height,
|
||||
offset: [...canvas.ds.offset],
|
||||
transform: canvas.canvas.getContext("2d").getTransform(),
|
||||
};
|
||||
|
||||
// Set canvas to render full workflow
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.ds.scale = 1;
|
||||
canvas.canvas.width = (bounds[2] - bounds[0]) * dpr;
|
||||
canvas.canvas.height = (bounds[3] - bounds[1]) * dpr;
|
||||
canvas.ds.offset = [-bounds[0], -bounds[1]];
|
||||
canvas.canvas.getContext("2d").setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Render
|
||||
canvas.draw(true, true);
|
||||
|
||||
// Capture to blob
|
||||
canvas.canvas.toBlob(async (rawBlob) => {
|
||||
// Restore canvas state
|
||||
canvas.ds.scale = savedState.scale;
|
||||
canvas.canvas.width = savedState.width;
|
||||
canvas.canvas.height = savedState.height;
|
||||
canvas.ds.offset = savedState.offset;
|
||||
canvas.canvas.getContext("2d").setTransform(savedState.transform);
|
||||
canvas.draw(true, true);
|
||||
|
||||
if (!rawBlob) {
|
||||
reject(new Error("Failed to generate canvas image"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Embed workflow JSON into PNG tEXt chunk
|
||||
const blob = await embedWorkflowInPng(rawBlob, workflowJson);
|
||||
resolve(blob);
|
||||
} catch (embedErr) {
|
||||
console.warn("Workflow Studio: failed to embed workflow metadata, saving without it:", embedErr);
|
||||
resolve(rawBlob);
|
||||
}
|
||||
}, "image/png");
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveCanvasToWorkflowStudio = async () => {
|
||||
const filename = getWorkflowFilename();
|
||||
|
||||
try {
|
||||
showNotification("Capturing workflow image...", "info");
|
||||
const blob = await captureCanvasSnapshot();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("filename", filename);
|
||||
fd.append("image", blob, "canvas.png");
|
||||
|
||||
const res = await fetch("/api/wfm/workflows/save-canvas-image", {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
showNotification(`Workflow saved: ${filename.replace(/\.json$/, "")}`, "success");
|
||||
} catch (err) {
|
||||
showNotification("Error: " + err.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const showNotification = (message, type = "info") => {
|
||||
// Try using ComfyUI's built-in toast if available
|
||||
try {
|
||||
if (app.ui?.dialog) {
|
||||
// Fallback: use a simple temporary notification
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// Simple toast notification
|
||||
const existing = document.getElementById("wfm-snapshot-toast");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.id = "wfm-snapshot-toast";
|
||||
const colors = {
|
||||
success: { bg: "rgba(46,213,115,0.95)", color: "#fff" },
|
||||
error: { bg: "rgba(255,71,87,0.95)", color: "#fff" },
|
||||
info: { bg: "rgba(74,158,255,0.95)", color: "#fff" },
|
||||
};
|
||||
const c = colors[type] || colors.info;
|
||||
Object.assign(toast.style, {
|
||||
position: "fixed",
|
||||
bottom: "24px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
padding: "10px 24px",
|
||||
background: c.bg,
|
||||
color: c.color,
|
||||
borderRadius: "8px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
zIndex: "99999",
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
|
||||
transition: "opacity 0.3s ease",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
if (type !== "info") {
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = "0";
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
} else {
|
||||
// Info messages will be replaced by success/error
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.style.opacity = "0";
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Version Detection & UI
|
||||
// ============================================
|
||||
|
||||
const getComfyUIFrontendVersion = async () => {
|
||||
try {
|
||||
if (window["__COMFYUI_FRONTEND_VERSION__"]) {
|
||||
return window["__COMFYUI_FRONTEND_VERSION__"];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Workflow Studio: unable to read __COMFYUI_FRONTEND_VERSION__:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/system_stats");
|
||||
const data = await response.json();
|
||||
|
||||
if (data?.system?.comfyui_frontend_version) {
|
||||
return data.system.comfyui_frontend_version;
|
||||
}
|
||||
|
||||
if (data?.system?.required_frontend_version) {
|
||||
return data.system.required_frontend_version;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Workflow Studio: unable to fetch system_stats:", error);
|
||||
}
|
||||
|
||||
return "0.0.0";
|
||||
};
|
||||
|
||||
const parseVersion = (versionStr) => {
|
||||
if (!versionStr || typeof versionStr !== "string") {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
const cleanVersion = versionStr.replace(/^[vV]/, "").split("-")[0];
|
||||
const parts = cleanVersion.split(".").map((part) => parseInt(part, 10) || 0);
|
||||
|
||||
while (parts.length < 3) {
|
||||
parts.push(0);
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
const compareVersions = (version1, version2) => {
|
||||
const v1 = typeof version1 === "string" ? parseVersion(version1) : version1;
|
||||
const v2 = typeof version2 === "string" ? parseVersion(version2) : version2;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (v1[i] > v2[i]) return 1;
|
||||
if (v1[i] < v2[i]) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const supportsActionBarButtons = async () => {
|
||||
const version = await getComfyUIFrontendVersion();
|
||||
return compareVersions(version, MIN_VERSION_FOR_ACTION_BAR) >= 0;
|
||||
};
|
||||
|
||||
const getWfmIcon = () => {
|
||||
return `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 5 L7 17 L12 7 L17 17 L21 5" stroke-width="2.3"/>
|
||||
<path d="M5 20 Q9 17 12 20 Q15 23 19 20" stroke-width="1.8"/>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
|
||||
const getSnapshotIcon = () => {
|
||||
return `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M8 5 L9 3 L15 3 L16 5"/>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Button Creation
|
||||
// ============================================
|
||||
|
||||
const createTopMenuButton = async () => {
|
||||
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
|
||||
|
||||
const button = new ComfyButton({
|
||||
icon: "wfm",
|
||||
tooltip: BUTTON_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse primary",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", BUTTON_TOOLTIP);
|
||||
button.element.title = BUTTON_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getWfmIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", openWorkflowStudio);
|
||||
return button;
|
||||
};
|
||||
|
||||
const createSnapshotButton = async () => {
|
||||
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
|
||||
|
||||
const button = new ComfyButton({
|
||||
icon: "snapshot",
|
||||
tooltip: SNAPSHOT_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", SNAPSHOT_TOOLTIP);
|
||||
button.element.title = SNAPSHOT_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getSnapshotIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", saveCanvasToWorkflowStudio);
|
||||
return button;
|
||||
};
|
||||
|
||||
const createNodeSetsButton = async () => {
|
||||
const { ComfyButton } = await import("../../scripts/ui/components/button.js");
|
||||
|
||||
const button = new ComfyButton({
|
||||
icon: "node-sets",
|
||||
tooltip: NODE_SETS_TOOLTIP,
|
||||
app,
|
||||
enabled: true,
|
||||
classList: "comfyui-button comfyui-menu-mobile-collapse wfm-node-sets-btn",
|
||||
});
|
||||
|
||||
button.element.setAttribute("aria-label", NODE_SETS_TOOLTIP);
|
||||
button.element.title = NODE_SETS_TOOLTIP;
|
||||
|
||||
if (button.iconElement) {
|
||||
button.iconElement.innerHTML = getNodeSetsIcon();
|
||||
button.iconElement.style.width = "1.2rem";
|
||||
button.iconElement.style.height = "1.2rem";
|
||||
}
|
||||
|
||||
button.element.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
togglePanel();
|
||||
});
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
const attachTopMenuButton = async (attempt = 0) => {
|
||||
if (document.querySelector(`.${BUTTON_GROUP_CLASS}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try settingsGroup first (legacy ComfyUI)
|
||||
const settingsGroup = app.menu?.settingsGroup;
|
||||
// Fallback: find the legacy topbar container used by newer ComfyUI
|
||||
const legacyContainer = document.querySelector('[data-testid="legacy-topbar-container"] .flex');
|
||||
|
||||
if (!settingsGroup?.element?.parentElement && !legacyContainer) {
|
||||
if (attempt >= MAX_ATTACH_ATTEMPTS) {
|
||||
console.warn("Workflow Studio: unable to locate the ComfyUI menu container.");
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => attachTopMenuButton(attempt + 1));
|
||||
return;
|
||||
}
|
||||
|
||||
const wfmButton = await createTopMenuButton();
|
||||
const snapshotButton = await createSnapshotButton();
|
||||
const nodeSetsButton = await createNodeSetsButton();
|
||||
const { ComfyButtonGroup } = await import("../../scripts/ui/components/buttonGroup.js");
|
||||
|
||||
const buttonGroup = new ComfyButtonGroup(wfmButton, snapshotButton, nodeSetsButton);
|
||||
buttonGroup.element.classList.add(BUTTON_GROUP_CLASS);
|
||||
|
||||
if (settingsGroup?.element?.parentElement) {
|
||||
settingsGroup.element.before(buttonGroup.element);
|
||||
} else if (legacyContainer) {
|
||||
legacyContainer.prepend(buttonGroup.element);
|
||||
}
|
||||
};
|
||||
|
||||
app.registerExtension({
|
||||
name: "WorkflowStudio.TopMenu",
|
||||
actionBarButtons: [
|
||||
{
|
||||
icon: "icon-[mdi--file-document-multiple] size-4",
|
||||
tooltip: BUTTON_TOOLTIP,
|
||||
onClick: openWorkflowStudio,
|
||||
},
|
||||
{
|
||||
icon: "icon-[mdi--camera] size-4",
|
||||
tooltip: SNAPSHOT_TOOLTIP,
|
||||
onClick: saveCanvasToWorkflowStudio,
|
||||
},
|
||||
{
|
||||
icon: "icon-[mdi--view-grid-plus] size-4",
|
||||
tooltip: NODE_SETS_TOOLTIP,
|
||||
onClick: () => togglePanel(),
|
||||
},
|
||||
],
|
||||
beforeRegisterNodeDef(nodeType, _nodeData, _app) {
|
||||
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||||
origGetExtraMenuOptions?.apply(this, arguments);
|
||||
const selectedCount = Object.keys(app.canvas.selected_nodes || {}).length;
|
||||
options.push(null);
|
||||
options.push({
|
||||
content: `Save as Node Set${selectedCount > 1 ? ` (${selectedCount} nodes)` : ""}`,
|
||||
callback: saveSelectedAsNodeSet,
|
||||
});
|
||||
};
|
||||
},
|
||||
async setup() {
|
||||
const injectStyles = () => {
|
||||
const styleId = "wfm-top-menu-button-styles";
|
||||
if (document.getElementById(styleId)) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${BUTTON_TOOLTIP}"].wfm-top-menu-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${SNAPSHOT_TOOLTIP}"].wfm-snapshot-button:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn {
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
button[aria-label="${NODE_SETS_TOOLTIP}"].wfm-node-sets-btn:hover {
|
||||
background-color: var(--primary-hover-bg) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
injectStyles();
|
||||
|
||||
const replaceButtonIcon = () => {
|
||||
const wfmButtons = document.querySelectorAll(`button[aria-label="${BUTTON_TOOLTIP}"]`);
|
||||
wfmButtons.forEach((button) => {
|
||||
button.classList.add("wfm-top-menu-button");
|
||||
button.innerHTML = getWfmIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
button.style.backgroundColor = "var(--primary-bg)";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
const snapButtons = document.querySelectorAll(`button[aria-label="${SNAPSHOT_TOOLTIP}"]`);
|
||||
snapButtons.forEach((button) => {
|
||||
button.classList.add("wfm-snapshot-button");
|
||||
button.innerHTML = getSnapshotIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
const nsButtons = document.querySelectorAll(`button[aria-label="${NODE_SETS_TOOLTIP}"]`);
|
||||
nsButtons.forEach((button) => {
|
||||
button.classList.add("wfm-node-sets-btn");
|
||||
button.innerHTML = getNodeSetsIcon();
|
||||
button.style.borderRadius = "4px";
|
||||
button.style.padding = "6px";
|
||||
const svg = button.querySelector("svg");
|
||||
if (svg) {
|
||||
svg.style.width = "20px";
|
||||
svg.style.height = "20px";
|
||||
}
|
||||
});
|
||||
|
||||
if (wfmButtons.length === 0) {
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(replaceButtonIcon);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user