Includes zoom-to-cursor, middle-button pan and bg image persistence (PR #2-#4). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
105 KiB
Development Log
Mask Editor One の開発記録。新しいエントリは上に追加する。
2026-10-27 — v0.1.12: ズーム/パン改善・BG画像の永続化(PR #2〜#4)
概要
Neocortex87 氏からの PR 3件を取り込んだ。
- #2 マウスホイールズームをカーソル位置中心に変更
- #3 中ボタンドラッグでキャンバスをパン(中ボタンはツールに渡さない)
- #4 BG画像を
input/mask_editor/にアップロードしてnode.properties.bg_imageに保存し、ワークフロー切り替え・再読み込み時に復元。ノードのマスクプレビューもlayer_dataから再生成
#4 への追加修正(Workflow Studio 連携のため)
persistBgImageはアップロード前に古いbg_image参照を破棄し、ファイル名を引数で指定可能に- 外部連携用に
node._persistBgDataUrl(dataUrl, filename)を公開(WFS がnode._bgDataUrlを直接差し替えた後に参照を同期する) - 復元は BG画像 → マスクプレビューの順。
layer_dataに width/height が無い場合は BG画像のサイズを使い、サイズ不明ならスキップ - 復元中に別の画像がセットされていたら上書きしない
変更ファイル
- web/js/maskEditor.js、web/js/editor/nodeState.js、web/js/editor/CanvasCompositor.js、web/js/editor/MaskEditorModal.js、web/css/maskEditor.css
2026-06-29 — Workflow Studio 連携(store_image 後方互換・BiRefNet/SAM3/ABRブラシ API 共用)
概要
ComfyUI Workflow Studio(WFS)v0.3.64〜v0.3.65 が Mask Editor One の API を活用し、WFS の Image Edit タブに BiRefNet 背景除去・SAM3 セグメンテーション・ABR ブラシライブラリ機能を統合した。これに伴い /mask_editor/store_image エンドポイントに旧フィールド名 image_b64 への後方互換を追加した。
変更ファイル
- server.py —
store_imageエンドポイントに旧キーimage_b64のフォールバックを追加
WFS との API 連携概要
WFS v0.3.64 以降は以下のエンドポイントを直接利用する。Mask Editor One がインストールされていれば追加設定なしで有効になる。
| WFS の機能 | 使用するエンドポイント |
|---|---|
| BG Remove (BiRefNet) | POST /mask_editor/store_image → POST /mask_editor/birefnet/remove_bg |
| SAM3 セグメンテーション | POST /mask_editor/store_image → POST /mask_editor/sam3/segment |
| ABR ブラシライブラリ | GET /mask_editor/brushes/list / GET /mask_editor/brushes/raw / POST /mask_editor/brushes/import / POST /mask_editor/brushes/upload_abr |
ブラシは comfyui-mask-editor-one/brushes/ フォルダを共有するため、一度インポートすれば Mask Editor One と WFS の両方で同じブラシが使える。
store_image 後方互換修正
WFS v0.3.64 が store_image 呼び出し時に旧フィールド名 image_b64 を使用していたため、WFS 側は bg_image_b64 に修正された(WFS v0.3.65)。Mask Editor One 側でも旧キーへのフォールバックを追加して双方向の互換性を確保した。
# 修正前
entry["bg_image_b64"] = data.get("bg_image_b64")
# 修正後: bg_image_b64(現行)と image_b64(旧キー)の両方を受け付ける
img = data.get("bg_image_b64") or data.get("image_b64")
entry["bg_image_b64"] = img
2026-06-28 — BiRefNet ステータス表示修正(Remove BG 後に Ready に更新)
概要
BiRefNet ツールのステータス表示が Remove BG 実行後も「Model detected (not loaded)」のままになる問題を修正した。ステータス取得を refreshStatus() に切り出し、Remove BG 成功後に再呼び出しすることで、モデルロード完了後に即座に「BiRefNet Ready」へ更新されるようにした。
変更ファイル
- web/js/editor/MaskEditorModal.js —
_buildBiRefNetOptions()内のステータス取得をrefreshStatus()として切り出し、初回表示と Remove BG 成功後の両方で呼び出すよう変更
根本原因
_buildBiRefNetOptions() は呼び出し時に一度だけ /mask_editor/birefnet/status を取得し、結果を statusEl に表示していた。Remove BG 実行時は run_inference() → load_model() が呼ばれて _bg_model がセットされるが、その後 statusEl を更新する処理がなかった。
修正前のフロー:
1. BiRefNet ツール選択 → 状態取得 → "Model detected (not loaded)"
2. Remove BG クリック → load_model() → _bg_model = <model> (推論成功)
3. statusEl はそのまま → "Model detected (not loaded)" のまま ← バグ
修正後のフロー:
1. BiRefNet ツール選択 → refreshStatus() → "Model detected (not loaded)"
2. Remove BG クリック → load_model() → _bg_model = <model> (推論成功)
3. refreshStatus() 再呼び出し → loaded: true → "BiRefNet Ready" ✓
修正内容
// 修正前: fetch が直接 statusEl を更新(1回のみ)
fetch("/mask_editor/birefnet/status")
.then(r => r.json())
.then(s => { statusEl.textContent = ...; });
// 修正後: refreshStatus() に切り出して再利用可能に
const refreshStatus = () => {
fetch("/mask_editor/birefnet/status")
.then(r => r.json())
.then(s => { statusEl.textContent = ...; })
.catch(...);
};
refreshStatus(); // 初回
// runRemove() 内で成功後に再呼び出し
if (!tool.lastError) {
this._syncDrawToLayer();
this._updatePreview();
this._refreshLayerThumbnail();
refreshStatus(); // ← 追加
}
2026-06-28 — 入力廃止・Load Image ボタン一本化・プレビューエリア常時表示
概要
ノードの image / mask オプション入力を廃止し、画像読み込みを「Load Image」ボタンとノードプレビューエリアへのファイルドロップのみに統一した。読み込んだ画像は自動的に Layer 1(グレースケール→アルファ変換)として生成され、新規画像読み込み時は既存のマスクレイヤーをすべてクリアする。プレビューエリアを BG 有無に関わらず常時 160 px で表示し、ファイルドロップに対応した。
変更ファイル
- nodes.py —
image/maskoptional 入力・関連ヘルパーを削除、process()簡略化 - server.py —
store_imageエンドポイントをbg_image_b64のみに統一 - web/js/maskEditor.js — プレビューエリア常時表示・ドロップ対応・
_graphPos・_inPreview・_loadFileAsBg追加 - web/js/editor/MaskEditorModal.js —
_imageToMaskLayer()・reloadBackground()にレイヤークリア追加 - web/js/editor/i18n.js —
"footer.bg"→"Image"、"node.loadImage"キー追加(en/ja/zh)
1. image / mask 入力廃止
ノードの INPUT_TYPES から image (IMAGE) と mask (MASK) の optional 入力を削除した。画像の供給経路を「Load Image」ボタンまたはプレビューエリアへのドロップのみに統一することで、接続経路による動作の混乱を排除する。
# 修正前
"optional": {
"image": ("IMAGE",),
"mask": ("MASK",),
"layer_data": ("STRING", {"default": "{}"}),
"blur_radius": ("INT", ...),
}
# 修正後
"optional": {
"layer_data": ("STRING", {"default": "{}"}),
"blur_radius": ("INT", ...),
}
process() はサーバーキャッシュの bg_image_b64 のみを参照するよう簡略化した。_tensor_to_pil・_mask_tensor_to_pil・_image_tensor_to_base64 ヘルパーも削除。
2. 画像読み込み時の Layer 1 自動生成
BG 画像を読み込むと、画像のグレースケール値をアルファチャンネルに変換した Layer 1 を自動生成するようにした。RGB は白(255,255,255)固定。
// MaskEditorModal.js
_imageToMaskLayer(img) {
const w = this._canvasW, h = this._canvasH;
const layer = this._layerMgr.addLayer("paint", t("layers.defaultName", { n: 1 }));
const tmp = document.createElement("canvas");
tmp.width = w; tmp.height = h;
const tmpCtx = tmp.getContext("2d");
tmpCtx.drawImage(img, 0, 0, w, h);
const src = tmpCtx.getImageData(0, 0, w, h);
const out = new Uint8ClampedArray(w * h * 4);
for (let i = 0; i < src.data.length; i += 4) {
const gray = Math.round(0.299 * src.data[i] + 0.587 * src.data[i + 1] + 0.114 * src.data[i + 2]);
out[i] = out[i + 1] = out[i + 2] = 255;
out[i + 3] = gray;
}
layer.ctx.putImageData(new ImageData(out, w, h), 0, 0);
return layer;
}
グレースケール式: ITU-R BT.601 ルミナンス(0.299R + 0.587G + 0.114B)。
新規画像読み込み時はレイヤーをクリアしてから _imageToMaskLayer を呼ぶ。パスは 3 通り:
| パス | 処理 |
|---|---|
| ノードの Load Image ボタン / プレビューエリアドロップ | layer_data ウィジェットを "{}" にリセット。モーダルが開いていれば reloadBackground(dataUrl) を呼び出し |
| モーダルの Image ドロップゾーン・キャンバスドロップ | _loadBgFromFile() — 既存レイヤーを全クリアして _imageToMaskLayer を呼び出し |
reloadBackground(dataUrl) |
既存レイヤーを全クリアして _imageToMaskLayer → _refreshLayerList → _loadActiveLayerToDrawCanvas |
3. Load Image ボタン・BG ✕ ボタン廃止
ノード上の BG ボタン:
- 旧:
🖼 BGクリックでダイアログ、読み込み後🖼 BG ✕に変わりクリックでクリア - 新:
Load Imageクリックで常にダイアログを開く。クリアボタンを廃止
モーダルのフッタードロップゾーン:
- ラベルを
"Image"に変更(en/ja/zh すべて)
ノードボタン:
- i18n キー
"node.loadImage"を追加(全言語"Load Image")
4. プレビューエリア常時表示・ファイルドロップ
_resizeNode でプレビューエリア用の PREVIEW_H + 8 を常に加算(従来は BG 有無で条件分岐)。onDrawForeground でも常に 160 px のエリアを描画し、画像がない場合は破線ボーダー + "Drop image here" テキストを表示する。
プレビューエリアへのドロップは、ComfyUI のキャンバス要素にキャプチャーフェーズ({ capture: true })でリスナーを登録し、e.stopImmediatePropagation() で ComfyUI 自身のドロップハンドラより先に処理する。
// グラフ座標変換(ズーム・パン対応)
function _graphPos(e) {
const rect = app.canvas.canvas.getBoundingClientRect();
const ds = app.canvas.ds;
const ratio = app.canvas.canvas.width / rect.width;
const cx = (e.clientX - rect.left) * ratio;
const cy = (e.clientY - rect.top) * ratio;
return [cx / ds.scale - ds.offset[0], cy / ds.scale - ds.offset[1]];
}
_inPreview(node, gx, gy) でグラフ座標がノードのプレビューエリア内かを判定し、ドロップ先がプレビューエリアの場合のみ stopImmediatePropagation() を呼ぶ。ノード削除時は _cleanupDropHandlers() でリスナーを除去する。
5. 画像読み込み時のプレビューモード自動切り替え
_loadFileAsBg() で node._previewMode = "image" にリセットし、表示切り替えボタンのラベルを t("node.showMask") に更新する。Apply 後にマスクプレビューに切り替わった状態で新しい画像を読み込んでも、自動的に画像表示に戻る。
2026-06-28 — BiRefNet バグ修正(テンソルインデックス誤り)
概要
Mask Editor の BiRefNet ボタンが正常に機能していなかった原因を特定し修正した。birefnet_inference.py のテンソルインデックス誤りにより、マスクが高さ 1px の帯画像として生成されていた。
変更ファイル
- birefnet_inference.py —
mask_tensor[0, 0]→mask_tensor[0]
根本原因 — birefnet_inference.py: テンソルインデックス誤り
comfy.bg_removal_model.BackgroundRemovalModel.encode_image() の戻り値は mask.squeeze(1) で次元を削減した (B, H, W) 形式。
# comfy/bg_removal_model.py:58
return mask.squeeze(1) # (B, 1, H, W) → (B, H, W)
しかし birefnet_inference.py では mask_tensor[0, 0] と 2 次元インデックスでアクセスしていた。
# 修正前(バグ)
# コメントも誤り: "戻り値: (1, 1, H, W)"
mask_np = mask_tensor[0, 0].cpu().float().detach().numpy()
# → shape (1, H, W) に対して [0, 0] は H 次元の 0 行目 = shape (W,) の 1D 配列
# 修正後
mask_np = mask_tensor[0].cpu().float().detach().numpy()
# → shape (H, W) の正しい 2D マスク
mask_tensor[0, 0] は幅 W の 1 行分(ほぼ黒)しか含まない 1D 配列になっており、PNG に保存すると高さ 1px の帯画像が生成されていた。
2026-06-09 — Apply後プレビューバグ修正(マスク未表示・画像重なり)
概要
Apply ボタン押下後にマスクプレビューが表示されず(マスクを作成しても「マスクなし」と同じ状態)、かつノードに画像が重なって表示されるバグを修正した。画像/マスク切り替えボタンを押すと重なりは消えるが、マスクモードに切り替えても何も表示されない状態だった。
変更ファイル
- web/js/editor/nodeState.js —
Map→WeakMap、引数をnodeId→nodeオブジェクトに変更 - web/js/maskEditor.js —
registerMaskPreviewCallback(node.id, ...)→registerMaskPreviewCallback(node, ...)、unregisterMaskPreviewCallback(this.id)→(this) - web/js/editor/MaskEditorModal.js —
notifyMaskPreviewUpdate(this.node.id, ...)→(this.node, ...)、_ensureWidgetHiddenからsetSize呼び出しを削除
根本原因 1 — マスクプレビュー未表示
onNodeCreated フック内で registerMaskPreviewCallback(node.id, callback) を呼んでいたが、LiteGraph の仕様により onNodeCreated 実行時点では node.id = -1(ノードがグラフに追加される前)。
LiteGraph.createNode(type) → new NodeClass() → onNodeCreated() ← id = -1 のまま
graph.add(node) → node.id = ++state.lastNodeId ← ここで正式ID付与
registerMaskPreviewCallback(-1, fn) で登録し、Apply 時に notifyMaskPreviewUpdate(3, data) を呼ぶため _callbacks.get(3) が undefined → コールバックが永遠に呼ばれなかった。
修正: nodeState.js の Map を WeakMap に変更し、キーを nodeId(数値)から node(オブジェクト参照)に変更。WeakMap はオブジェクトが GC されると自動クリアされるため unregisterMaskPreviewCallback も不要だが、明示的クリーンアップのため残した。
// nodeState.js — 修正後
const _callbacks = new WeakMap();
export function registerMaskPreviewCallback(node, fn) { _callbacks.set(node, fn); }
export function notifyMaskPreviewUpdate(node, maskDataUrl) {
const fn = _callbacks.get(node);
if (fn) fn(maskDataUrl);
}
根本原因 2 — Apply後の画像重なり
_apply() 内で _ensureWidgetHidden(widget) が this.node.setSize(this.node.computeSize()) を呼んでノードを「自然サイズ」(プレビューなし)にリセットしていた。この時点では _previewMode = "image"・_bgImg がセット済みのため、onDrawForeground が y = natural_size - PREVIEW_H - margin の位置(ウィジェット領域)に背景画像を描画してしまった。
_apply() フロー(修正前):
1. _ensureWidgetHidden → setSize(computeSize()) ← ノードを自然サイズにリセット
2. setDirtyCanvas(true) → 即時再描画 ← _bgImg が間違った y に描画 = 重なり
3. notifyMaskPreviewUpdate → _loadImage().then() ← 非同期でサイズと _previewMode を修正
非同期 .then() が正しくサイズを修正するが、コールバック自体が根本原因1で呼ばれていなかったためさらに悪化していた。
修正: _ensureWidgetHidden から setSize 呼び出しを削除。ノードサイズの更新はコールバック内の _resizeNode に一元化した。
// _ensureWidgetHidden — 修正後
_ensureWidgetHidden(widget) {
if (widget.element) { ... }
widget.draw = () => {};
widget.computeSize = () => [0, -4];
// setSize はここでは呼ばない — _resizeNode が正しいサイズを設定する
}
2026-06-09 — Comfy Registry 公開・JS拡張バグ修正(v0.1.3〜v0.1.6)
概要
registry.comfy.org へのノード登録(v0.1.3〜v0.1.5)と、インストール後に発見された JS 拡張バグの修正(v0.1.5/v0.1.6)を実施した。
追加・変更ファイル
- pyproject.toml — Comfy Registry 公開設定
- .github/workflows/publish_action.yml — GitHub Actions 公開ワークフロー
- docs/thumb.png — アイコン画像を 800×400px 以下にリサイズ(PIL thumbnail)
- web/js/editor/nodeState.js — 新規作成(循環 import 解消用)
- web/js/maskEditor.js —
onDrawBackgroundno-op 追加、nodeState.js 利用に変更 - web/js/editor/MaskEditorModal.js — import 先を nodeState.js に変更
1. Comfy Registry 公開
pyproject.toml
[project]
name = "comfyui-mask-editor-one"
description = "..."
version = "0.1.5"
[project.urls]
Repository = "https://github.com/ketle-man/comfyui-mask-editor-one"
[tool.comfy]
PublisherId = "statsu"
DisplayName = "Mask Editor One"
Icon = "https://raw.githubusercontent.com/ketle-man/comfyui-mask-editor-one/master/docs/thumb.png"
license / requires-python / dependencies は不要(余分なフィールドはエラー原因)。
ハマりポイント
| 問題 | 原因 | 修正 |
|---|---|---|
| ノード詳細の読み込みエラー(最初) | pyproject.toml に不要フィールド・ワークフローがブランチ push トリガー |
publish_action.yml を参照リポジトリ comfyui-image-feeder に合わせて修正 |
| ノード詳細の読み込みエラー(2回目) | docs/thumb.png が 1536×1024px(MAX 800×400px を超過) |
PIL thumbnail((800,400)) で 600×400px にリサイズ(v0.1.4) |
CNR インストールエラー(not a CNR node) |
GitHub Release が未作成 | gh release create v0.1.4 で作成(GitHub Release は CNR 認識に必須) |
2. 循環 import 修正 → ボタン表示バグ解消(v0.1.5)
症状: インストールしたノードでカスタムボタン(BG・表示切替・Edit Mask)が一切表示されない。
根本原因: maskEditor.js → MaskEditorModal.js → maskEditor.js の循環 ES module import が発生し、JS 拡張がサイレントに読み込み失敗していた。
修正: 共有状態(マスクプレビューコールバック)を nodeState.js に抽出して循環を断ち切った。
// nodeState.js — コールバックレジストリ(循環 import を解消するため分離)
const _callbacks = new WeakMap();
export function registerMaskPreviewCallback(node, fn) { ... }
export function notifyMaskPreviewUpdate(node, maskDataUrl) { ... }
3. 実行時画像重なり修正 — onDrawBackground no-op(v0.1.6)
症状: ノードを実行すると出力画像がノード上に表示され、カスタムプレビュー(BG画像/マスク)に重なる。
根本原因: ComfyUI の litegraphService.ts が addDrawBackgroundHandler で onDrawBackground を拡張し、updatePreviews() → nodeOutputStore から node.imgs を毎フレーム設定していた。onExecuted で this.imgs = null してもすぐ上書きされる。
修正: nodeType.prototype.onDrawBackground を no-op に設定。ComfyUI の自動プレビューを完全に抑制し、独自描画(onDrawForeground)のみを使用する。
// maskEditor.js — beforeRegisterNodeDef 内
nodeType.prototype.onDrawBackground = function () {
// no-op: 独自プレビューは onDrawForeground で描画
};
addDrawBackgroundHandler(litegraphService.ts:608)は prototype チェーンで呼ばれるため、beforeRegisterNodeDef(line 614)内でのオーバーライドが優先される。
2026-05-29 — SAM3 Gated model エラーハンドリング
概要
HuggingFace から SAM3 を自動ダウンロードする際に Gated model(アクセス申請が必要なモデル)の場合、401/403 エラーが握りつぶされてスタックトレースがそのままユーザーに表示されていた問題を修正した。
変更ファイル
- sam3_inference.py —
_is_gated_repo_error()ヘルパー追加・HF ダウンロード失敗時のエラーハンドリング追加 - README.md — SAM3 セットアップ節に Gated model の注意書き追加
- README_en.md — 同上(英語)
- README_zh.md — 同上(中国語)
修正内容
load_model() の HF ダウンロードパス(load_from_hf=True のとき)で build_sam3_image_model() を try/except で囲み、_is_gated_repo_error() が True を返した場合にユーザーフレンドリーな RuntimeError に変換してから再スローする。
def _is_gated_repo_error(e: Exception) -> bool:
if type(e).__name__ in ("GatedRepoError", "RepositoryNotFoundError"):
return True
msg = str(e).lower()
return "gated" in msg or "401" in msg or "403" in msg or "forbidden" in msg or "unauthorized" in msg
エラーメッセージにはライセンス同意ページの URL・huggingface-cli login・HF_TOKEN 環境変数の 3 つの対処手順を記載する。
RepositoryNotFoundError も含めているのは、未認証ユーザーには Gated repo が「見つからない」として返る場合があるため。
2026-05-27 — MIT ライセンス適用・README Acknowledgements 追加
概要
ライセンスを MIT に確定し、依存モデルの帰属情報を README 3言語版に追加した。
追加ファイル
- LICENSE — MIT License(Copyright 2025 Statsu)
変更ファイル
- README.md — ライセンスセクション更新・Acknowledgements 追加
- README_en.md — License 更新・Acknowledgements 追加
- README_zh.md — 许可证 更新・致谢 追加
Acknowledgements 記載内容
| 項目 | URL | 備考 |
|---|---|---|
| SAM3 / SAM 3.1 (Meta FAIR) | huggingface.co/facebook/sam3 | Apache-2.0 |
| BiRefNet (ZhengPeng7) | huggingface.co/zhengpeng7/BiRefNet | MIT、ComfyUI ネイティブ実装 (comfy.bg_removal_model) 経由で使用 |
2026-05-27 — GitHub 初回リリース v0.1.0
概要
ketle-man/comfyui-mask-editor-one として GitHub に公開リポジトリを作成し、v0.1.0 タグでリリースした。
作業内容
.gitignoreを追加(__pycache__/・*.pyc・brushes/・.DS_Storeを除外)git init→ 33 ファイル / 10,455 行を初回コミットgh repo createでパブリックリポジトリを作成しプッシュgh release create v0.1.0でリリースを公開
リンク
- リポジトリ: https://github.com/ketle-man/comfyui-mask-editor-one
- リリース: https://github.com/ketle-man/comfyui-mask-editor-one/releases/tag/v0.1.0
2026-05-27 — README スクリーンショット追加・多言語 README 整備
概要
docs/ フォルダに保存した 5 枚のスクリーンキャプチャを README に組み込み、英語・中国語の README を新規作成した。
追加ファイル
- docs/1_node.png — ノード(画像プレビュー表示)
- docs/2_node_mask.png — ノード(マスクプレビュー表示)
- docs/3_mask_editor.png — メインエディタ全体
- docs/4_menu.png — ツールサイドバー
- docs/5_brush_library.png — ブラシライブラリ
- README_en.md — 英語 README(全セクション翻訳)
- README_zh.md — 中国語 README(全セクション翻訳)
変更ファイル
- README.md — 言語リンク追加・ヒーロー画像・各セクションにスクリーンショット挿入
画像配置
| 画像 | 配置箇所 |
|---|---|
3_mask_editor.png |
タイトル直下(ヒーロー) |
4_menu.png |
「基本」節に右フロート |
1_node.png + 2_node_mask.png |
「ノード上の BG ボタン」節の下に横並び |
5_brush_library.png |
「ブラシライブラリ」節の説明直後 |
各 README の先頭に [English](README_en.md) | **日本語** | [中文](README_zh.md) 形式の言語切り替えリンクを配置。
2026-05-27 — ノードボタン i18n 対応(maskEditor.js)
概要
maskEditor.js のノード上ボタン(BG・表示切替・Edit Mask)がハードコードされた文字列を使っており i18n 未対応だった問題を修正した。言語切り替え時にモーダル内だけでなくノードウィジェットのラベルも即時更新されるようになった。
変更ファイル
- web/js/editor/i18n.js — en/ja/zh に
node.*キー 4 件を追加 - web/js/maskEditor.js —
t()をインポート、全ボタンをt()経由に変更、node._viewWidget/node._editMaskWidgetを保存 - web/js/editor/MaskEditorModal.js —
_rebuildWithLang()でノードウィジェット 3 件を更新
追加した i18n キー
| キー | en | ja | zh |
|---|---|---|---|
node.showImage |
👁 Show: Image | 👁 表示: 画像 | 👁 显示: 图像 |
node.showMask |
👁 Show: Mask | 👁 表示: マスク | 👁 显示: 蒙版 |
node.editMask |
✏️ Edit Mask | ✏️ マスク編集 | ✏️ 编辑蒙版 |
node.bgClear |
🖼 BG ✕ | 🖼 BG ✕ | 🖼 背景 ✕ |
実装のポイント
ノードウィジェットは onNodeCreated 時に一度だけ生成されるため、言語変更時に自動更新されない。node._viewWidget / node._editMaskWidget として参照を保存し、_rebuildWithLang() の末尾でラベルを書き換え setDirtyCanvas(true, true) で再描画することで解決した。
// MaskEditorModal.js — _rebuildWithLang() 末尾
if (node._viewWidget)
node._viewWidget.name = node._previewMode === "image"
? t("node.showImage") : t("node.showMask");
if (node._bgWidget)
node._bgWidget.name = node._bgDataUrl ? t("node.bgClear") : t("footer.bg");
if (node._editMaskWidget)
node._editMaskWidget.name = t("node.editMask");
node.setDirtyCanvas(true, true);
2026-05-27 — i18n 多言語対応(英語・日本語・中国語)
概要
UI テキストをすべて翻訳関数 t(key) 経由に切り替え、フッターの言語セレクターで英語・日本語・中国語を実行時に切り替えられるようにした。言語設定は localStorage に保存され、次回起動時も引き継がれる。
追加ファイル
- web/js/editor/i18n.js — 翻訳辞書・
t()・getLang()・setLang()エクスポート
変更ファイル
- web/js/editor/MaskEditorModal.js — 全 UI 文字列を
t()に置き換え・言語セレクター追加・_rebuildWithLang()実装 - web/js/editor/BrushLibrary.js — 全 UI 文字列を
t()に置き換え - web/css/maskEditor.css —
.me-lang-selスタイル追加
1. i18n.js — 翻訳モジュール
// 言語検出優先順位
// 1. localStorage.getItem("me-lang")
// 2. navigator.language.slice(0, 2) ("ja", "zh", ...)
// 3. デフォルト "en"
export function t(key, params = {}) {
const dict = LOCALES[_lang] ?? LOCALES.en;
let str = dict[key] ?? LOCALES.en[key] ?? key;
for (const [k, v] of Object.entries(params))
str = str.replaceAll(`{${k}}`, v);
return str;
}
翻訳キーは "tool.paint"・"footer.showImage"・"layers.defaultName" のようなドット区切り文字列。パラメータは t("layers.defaultName", { n: 1 }) → "Layer 1" / "レイヤー 1" / "图层 1" のように展開される。
en/ja/zh で約 80 キーを実装。フォールバック: ja/zh にないキーは en から補完し、en にもなければキー名をそのまま表示。
2. 言語切り替え — _rebuildWithLang(lang)
言語変更時は DOM を破棄して再構築することで、テキストを埋め込んだ全要素をクリーンに再生成する。
状態の保存・復元フロー:
async _rebuildWithLang(lang) {
setLang(lang); // localStorage に保存
const savedJson = this._layerMgr.toJSON(); // レイヤーデータ保存
const savedInverted = this._inverted;
const savedBlur = this._blurRadius;
const savedBgImage = this._bgImage;
this._overlay.remove();
document.removeEventListener("keydown", this._keyHandler);
this._overlay = null; this._layerMgr = null;
this._buildDOM(); // DOM 再構築(新言語で全テキスト生成)
document.body.appendChild(this._overlay);
this._bgImage = savedBgImage;
if (savedJson) await this._layerMgr.fromJSON(savedJson); // レイヤー復元
this._inverted = savedInverted;
this._blurRadius = savedBlur;
// ...スライダー・プレビュー更新
}
_layerMgr.fromJSON() が Promise を返すため async/await で処理。旧 _keyHandler を removeEventListener してから再構築することで重複登録を防ぐ。
3. 言語セレクター UI
フッターの Apply ボタン右隣に <select class="me-lang-sel"> を配置:
[["en", "EN"], ["ja", "日本語"], ["zh", "中文"]].forEach(([v, l]) => {
const o = document.createElement("option");
o.value = v; o.textContent = l;
if (v === getLang()) o.selected = true;
langSel.appendChild(o);
});
langSel.onchange = () => {
if (langSel.value === getLang()) return;
if (!confirm(t("footer.langConfirm"))) { langSel.value = getLang(); return; }
this._rebuildWithLang(langSel.value);
};
変更前に confirm() ダイアログを表示して誤操作を防ぐ。
2026-05-27 — ペイントブラシ サイズ・回転ジッター
概要
ペイントツールにサイズジッターと回転ジッターを追加した。各スタンプでランダムにブラシサイズ・回転角を変化させ、より自然な筆致を実現する。
変更ファイル
- web/js/editor/tools/PaintTool.js —
sizeJitter・sizeJitterAmount・rotationJitterプロパティ追加・_paintToStroke()にジッター処理追加 - web/js/editor/MaskEditorModal.js —
_buildPaintOptions()にジッター UI 追加
1. PaintTool.js — ジッター実装
各スタンプ描画時(_paintToStroke())に有効なジッターを適用する。スタンプ後は元のパラメータとスタンプキャッシュを復元することで、基底のスタンプキャッシュを汚さない設計:
_paintToStroke(x, y) {
if (this.sizeJitter || this.rotationJitter) {
const savedSize = this.brushSize;
const savedAngle = this.angle;
const savedStamp = this._stamp;
if (this.sizeJitter)
this.brushSize = savedSize * (1 - Math.random() * this.sizeJitterAmount);
if (this.rotationJitter)
this.angle = Math.random() * 360;
this._paintImageBrush / _paintCircle ... // ジッター適用済みパラメータで描画
// キャッシュを含めて元に戻す
this.brushSize = savedSize;
this.angle = savedAngle;
this._stamp = savedStamp;
}
}
| プロパティ | 型 | 説明 |
|---|---|---|
sizeJitter |
boolean | サイズジッターの ON/OFF |
sizeJitterAmount |
0.0〜1.0 | 最大縮小率。0.5 の場合 50% 〜 100% の範囲でランダム |
rotationJitter |
boolean | 回転ジッターの ON/OFF(スタンプごとに 0°〜360° をランダム) |
2. MaskEditorModal.js — ジッター UI
ブラシオプションパネル(_buildPaintOptions())の Angle スライダーの下にジッターセクションを追加:
- Size Jitter チェックボックス + Amount スライダー(0〜100%)が連動。チェックOFF時はスライダーを無効化
- Rotation Jitter チェックボックス(画像ブラシの角度を各スタンプでランダム化)
2026-05-27 — ABR v6 Atenais フォーマット対応(循環シフトバグ修正)
概要
Atenais 製 ABR ファイル(05_Flames.abr、05_Fire.abr 等)をインポートすると、抽出されたブラシ画像に水平方向の循環シフトが発生していた問題を修正した。
変更ファイル
- abr_parser.py —
_extract_v6sub2_entryに Strategy 0b(table_off=320)を追加
症状
05_Flames.abr をインポートすると 30 ブラシすべてが横方向にシフトした画像として抽出されていた。FFT 相互相関で確認すると corr≈1.0(ピクセルデータ自体は正しい)だが shift≠0 だった。既存の Strategy 3(単一ストリーム PackBits、offset 66 から開始)にフォールバックしていたため循環シフトが起きていた。
根本原因の調査
全 30 エントリのバイト列を比較した結果、offset 66〜293 の 228 バイトが全エントリで完全に一致する固定プリアンブルであることが判明。
offset 285〜330 付近のバイト列を詳細解析:
[319] 0x01 = compression type 1 (RLE/PackBits)
[320:322] 00 2e (= 46) → BE u16 行バイト数テーブルの先頭
[322:324] 00 2e (= 46) → 2行目も同じ値
Adobe Photoshop File Formats Specification に記載された PSD Image Data Section と完全に一致する構造(2B compression + h×2B の BE u16 行カウントテーブル + PackBits データ)が offset 319 から始まっていた。
Atenais ABR エントリの完全構造:
[0:66] 一次ヘッダー(UUID + メタデータ + bounds)
[66:294] 固定プリアンブル(228 バイト、全エントリ共通)
[294:298] データサイズ BE u32 = entry_len - 306
[298:304] 定数 00 00 00 08 00 00
[304:308] top 座標 BE u32(ピクセル)
[308:312] left 座標 BE u32
[312:316] bottom = h BE u32
[316:320] right = w BE u32
[319] compression = 1(RLE/PackBits)
[320:320+h×2] 行バイト数テーブル(h × BE u16)
[320+h×2:] PackBits 圧縮ピクセルデータ(各行独立)
既存の _row_packbits(entry_data, w, h, table_off=66) は BE u16 の行カウントテーブルを offset 66 で探すため失敗。table_off=320 を指定することで正しく復号できる。
修正内容
_extract_v6sub2_entry に Strategy 0b を追加(Strategy 0 と Strategy 1 の間に挿入):
# Strategy 0b: per-row PackBits with BE u16 row-count table at offset 320.
# Used by Atenais and similar ABR files that have a 254-byte fixed preamble
# from offset 66, followed by a 26-byte PSD-style image descriptor
# (bounds, depth, compression=1) ending at byte 319, with the row count
# table starting at byte 320.
row_data = _row_packbits(entry_data, w, h, table_off=320)
if row_data is not None and len(row_data) == needed:
log.debug("v6sub2 entry#%d: decoded via per-row packbits (table_off=320)", idx)
return {'name': uuid, 'size': (w, h), 'data': row_data, 'mode': 'L'}
既存の _row_packbits 関数は LE/BE 両方の u16 を試みるため、追加実装は不要。
検証結果
| ファイル | 結果 |
|---|---|
05_Flames.abr |
30/30 エントリ正常抽出、参照画像と MAE=0.00(完全一致) |
05_Fire.abr |
45 ブラシ正常抽出 |
rons_flames.abr |
39 ブラシ正常(リグレッションなし) |
2026-05-25 — セキュリティ強化
概要
セキュリティレビューで発見された 8 件の問題(Critical 2・High 4・Medium 2)を修正した。
変更ファイル
- server.py — サイズ制限・パストラバーサル修正・エラーメッセージ無害化・stem サニタイズ
- sam3_inference.py —
torch.loadパッチ安全化・レスポンスからパス除去 - birefnet_inference.py —
get_status()からフルパス除去 - abr_parser.py — ブラシサイズ・件数上限追加
- web/js/editor/MaskEditorModal.js —
innerHTML使用箇所にガードコメント追加
1. リクエストサイズ制限 (C-1)
全エンドポイントに 100 MB の受信サイズ上限を追加。_read_json() ヘルパーを新設し、Content-Length ヘッダーの事前チェックと読み込み後の実サイズチェックを二段階で実施。import_brushes はさらにファイル件数を 500 件に制限。
MAX_BODY_BYTES = 100 * 1024 * 1024
_MAX_IMPORT_FILES = 500
async def _read_json(request):
if request.content_length and request.content_length > MAX_BODY_BYTES:
raise web.HTTPRequestEntityTooLarge(...)
raw = await request.read()
if len(raw) > MAX_BODY_BYTES:
raise web.HTTPRequestEntityTooLarge(...)
return json.loads(raw)
2. パストラバーサル修正 (C-2)
import_brushes のパスガードで startswith(brush_root) を使っていたため、Windows の隣接ディレクトリ(C:\brushes_evil\...)を誤許可する問題があった。_within_brush_dir() ヘルパーを追加し、セパレータを含めた比較に変更。
_BRUSH_SEP = "/" if "/" in _BRUSH_ROOT else "\\"
def _within_brush_dir(target):
t = str(target)
return t == _BRUSH_ROOT or t.startswith(_BRUSH_ROOT + _BRUSH_SEP)
get_brush_raw は既に正しいガードを使っていたが、import_brushes が不完全な実装になっていた。
3. ABR フォルダ名サニタイズ (H-1)
upload_abr で filename から生成するフォルダ名 stem を正規表現でサニタイズし、英数字・アンダースコア・ハイフン以外を _ に置換。最大 64 文字に切り詰め。
stem = re.sub(r'[^\w\-]', '_', pathlib.Path(filename).stem)[:64] or "brushes"
4. torch.load パッチの安全化 (H-2)
sam3_inference.py の load_model() で torch.load を全面的に weights_only=False に強制していた。PyTorch 2.6 が追加した weights_only=True(pickle デシリアライズ無効)というセキュリティ機能を丸ごと無効化する実装だった。
修正後は weights_only=True を優先し、失敗した場合のみ False にフォールバック(ログに警告を記録)。
def _patched_load(*a, **kw):
if "weights_only" not in kw:
try:
return _orig_load(*a, **{**kw, "weights_only": True})
except Exception:
log.warning("weights_only=True failed; retrying with weights_only=False (pickle)")
return _orig_load(*a, **{**kw, "weights_only": False})
return _orig_load(*a, **kw)
5. エラーメッセージの無害化 (H-3)
SAM3・BiRefNet の推論エラーレスポンスで str(e) をそのままクライアントに返していた。例外メッセージにはファイルパス・スタックフレーム等の内部情報が含まれる可能性があるため、汎用メッセージに置換し、詳細は log.exception() のみに記録するよう統一。
# 修正前
return web.json_response({"error": f"image decode failed: {e}"}, status=500)
# 修正後
log.exception("SAM3 image decode failed")
return web.json_response({"error": "image decode failed"}, status=500)
6. ステータス API からフルパスを除去 (H-4)
GET /mask_editor/birefnet/status と GET /mask_editor/sam3/status がモデルファイルの絶対パス(C:\Users\...)をレスポンスに含めていた。ファイル名のみを返すよう変更。JS 側は model_path からファイル名を抽出して使っていたため後方互換あり。
# birefnet_inference.py
"model_path": path.name if path else None # str(path) → path.name
# sam3_inference.py
def _basename(p):
return pathlib.Path(p).name if p else None
"model_path": _basename(_loaded_ckpt),
"ckpt_path": _basename(ckpt),
list_models() も "path": str(p) エントリを削除し、name のみを返すよう変更。
7. innerHTML へのガードコメント (M-1)
MaskEditorModal.js の innerHTML 使用箇所に、ユーザー入力を渡してはならない旨のコメントを追加。現在は全て静的文字列のため脆弱性はないが、将来の誤実装を防ぐためのガード。
// NOTE: only static strings here — never insert user-controlled values (XSS)
hint.innerHTML = "<b>Click</b> to add points<br>...";
8. ABR ブラシの最大サイズ・件数制限 (M-3)
細工した ABR で大量の大型ブラシを処理させるメモリ枯渇 DoS を防ぐため上限を設定。
_MAX_BRUSH_DIM = 4096 # 一辺の最大ピクセル数(全パーサーに適用)
_MAX_BRUSHES = 500 # 1ファイルあたりの最大ブラシ数
| 比較 | サイズ/エントリ |
|---|---|
| 旧上限(8192×8192) | 約 64 MB |
| 新上限(4096×4096) | 約 16 MB(1/4 に削減) |
2026-05-25 — ノード名変更・クイックマスク表示・Mode ボタン化・カーソル改善
概要
ノード識別性向上のためクラス名・表示名を MaskEditorOne / "Mask Editor One" に変更。マスクプレビューを Photoshop クイックマスクスタイルに刷新し、プレビューが更新されなかったバグを根本修正。全ツールの Mode 選択をボタングループに変更し、テキストツールのカーソルを十字に変更した。
変更ファイル
- nodes.py — クラス名・
NODE_CLASS_MAPPINGS・NODE_DISPLAY_NAME_MAPPINGSを変更 - web/js/maskEditor.js — extension name・
nodeData.name判定を変更 - web/js/editor/MaskEditorModal.js — モーダルタイトル変更・
drawCanvasopacity=0・オーバーレイカラーピッカー追加・_makeModeRow()追加・全ツール Mode ドロップダウンをボタングループに置換・_apply()グレースケール変換修正 - web/js/editor/CanvasCompositor.js —
composite()黒背景削除・renderPreview()クイックマスクスタイル実装・overlayColorパラメータ追加 - web/js/editor/tools/TextTool.js —
activate()カーソルを"text"→"crosshair"に変更 - web/css/maskEditor.css —
.me-mode-btn-group/.me-mode-btnスタイル追加
1. ノード名変更
| 変更前 | 変更後 |
|---|---|
class MaskEditorNode |
class MaskEditorOne |
NODE_CLASS_MAPPINGS["MaskEditorNode"] |
NODE_CLASS_MAPPINGS["MaskEditorOne"] |
表示名 "Mask Editor" |
"Mask Editor One" |
JS extension "ComfyUI.MaskEditor" |
"ComfyUI.MaskEditorOne" |
モーダルタイトル "Mask Editor" |
"Mask Editor One" |
既存ワークフローへの影響: クラス名変更のため、MaskEditorNode として保存されたワークフローはノードを置き換える必要がある。
2. マスクプレビュー根本バグ修正 — composite() のアルファ化
問題: マスクを描画してもプレビューキャンバスの表示が変化しなかった(レイヤーサムネイルには反映されていた)。
根本原因: composite() が黒背景(alpha=1)を持つ RGB 白黒キャンバスを返していたため、renderPreview() 内の destination-out / source-in 合成演算が全ピクセルに対して等しく作用し、オーバーレイが全消去されていた。
destination-out では source のアルファ値で destination を削除する。
黒背景(alpha=1)を持つ maskCanvas は白い部分も黒い部分も alpha=1 のため、
全ての destination ピクセルが消去されてしまう。
// 修正前: 黒背景を持つ RGB 白黒キャンバス
ctx.fillStyle = "black";
ctx.fillRect(0, 0, width, height);
// 修正後: 透明背景(アルファベース)
// 上記2行を削除 → 描画部分が alpha=1、未描画が alpha=0 のキャンバスを返す
_apply() の修正 MaskEditorModal.js:
composite() がアルファベースになったため、updateNodeMaskPreview() に渡す前にグレースケール変換が必要になった。
const toGray = (src, invert) => {
const gc = document.createElement("canvas");
gc.width = this._canvasW; gc.height = this._canvasH;
const gCtx = gc.getContext("2d");
if (invert) {
gCtx.fillStyle = "white";
gCtx.fillRect(0, 0, this._canvasW, this._canvasH);
gCtx.globalCompositeOperation = "destination-out";
} else {
gCtx.fillStyle = "black";
gCtx.fillRect(0, 0, this._canvasW, this._canvasH);
}
gCtx.drawImage(src, 0, 0);
gCtx.globalCompositeOperation = "source-over";
return gc;
};
updateNodeMaskPreview(this.node, toGray(alphaMask, this._inverted).toDataURL("image/png"));
3. Photoshop クイックマスクスタイル
変更前: マスク領域(描いた部分)に赤オーバーレイを重ねていた。
変更後: 画像全体に半透明オーバーレイ → 描いた部分のオーバーレイを destination-out で除去。
通常時 (inverted=false):
全体に色オーバーレイ → maskCanvas でマスク領域を切り抜き → 描いた部分は元画像
Invert 時 (inverted=true):
maskCanvas を source-in → 描いた部分に色オーバーレイ(ブラシ色として可視化)
画像なし (showImage=false):
inverted に応じてマスクを反転してから色オーバーレイ
drawCanvas の非表示化: 白いマスクが直接見えていた問題を修正。
this._drawCanvas.style.opacity = "0"; // 描画内容は previewCanvas 経由で表示
4. オーバーレイカラーピッカー
Invert チェックの横に <input type="color"> を追加。選択した色が overlayColor プロパティに保存され、即時 _updatePreview() に反映される。デフォルト #ff0000。
this._overlayColor = "#ff0000";
// renderPreview の呼び出し
this._compositor.renderPreview(
this._previewCanvas, layers, showImage, this._inverted, this._overlayColor
);
5. Mode ボタングループ化
全ツール(Paint・Color・Text・Vector・Shape・SAM3・BiRefNet)の Mode 選択を <select> ドロップダウンから [Add] [Erase] ボタングループに変更。
_makeModeRow() ヘルパーを追加:
_makeModeRow(tool, options) {
// options 例: [["add", "Add"], ["erase", "Erase"]]
// クリックで tool.mode を更新し、ボタンの .active クラスを切り替える
}
CSS に .me-mode-btn-group / .me-mode-btn / .me-mode-btn.active を追加。
6. テキストツールの十字カーソル
// TextTool.activate()
// 修正前
this.drawCanvas.style.cursor = "text";
// 修正後
this.drawCanvas.style.cursor = "crosshair";
2026-05-25 — BiRefNet 背景除去ツール実装
概要
ComfyUI ネイティブの BiRefNet 実装(comfy.bg_removal_model)を利用した背景除去ツールを追加した。HuggingFace transformers や外部ライブラリへの依存を一切持たず、models/background_removal/birefnet.safetensors を配置するだけで動作する。
追加ファイル
- birefnet_inference.py — BiRefNet 背景除去バックエンド
- web/js/editor/tools/BiRefNetTool.js — フロントエンドツールクラス
変更ファイル
- server.py — BiRefNet API エンドポイント 2本を追加
- web/js/editor/MaskEditorModal.js — BiRefNetTool 統合(TOOL_DEFS・import・
_buildBiRefNetOptions())
1. アーキテクチャの発見 — comfy.bg_removal_model
当初は transformers.AutoModelForImageSegmentation 経由で BiRefNet をロードしようとしたが、HuggingFace Hub の trust_remote_code エラーが発生した。
調査の結果、ComfyUI には comfy/background_removal/birefnet.py として BiRefNet が完全に内蔵されており、comfy.bg_removal_model.load() でロードできることが判明した。これにより:
- HuggingFace Hub への接続不要(モデルファイルを配置するだけ)
transformersパッケージへの依存なし- ComfyUI の VRAM 管理・前処理を自動利用
モデルファイルの同定は state dict の特定キー "bb.layers.1.blocks.0.attn.relative_position_index" の有無で行われる。
config 仕様(birefnet.json):
{"model_type": "birefnet", "image_std": [1.0, 1.0, 1.0], "image_mean": [0.0, 0.0, 0.0], "image_size": 1024, "resize_to_original": true}
ImageNet 正規化を使用せず(mean=0、std=1)、1024×1024 にリサイズして推論後に元サイズに復元する。
2. birefnet_inference.py — 推論バックエンド
| 関数 | 役割 |
|---|---|
_find_model_path() |
models/background_removal/ から .safetensors を探す |
get_status() |
ロード状態とモデルファイルの有無・パスを返す |
load_model() |
comfy.bg_removal_model.load() でロード |
run_inference(image_pil) |
PIL Image → (1,H,W,3) テンソル → encode_image() → data URL PNG |
テンソル変換:
rgb = np.array(image_pil.convert("RGB")).astype(np.float32) / 255.0
image_tensor = torch.from_numpy(rgb).unsqueeze(0) # (1, H, W, 3)
mask_tensor = _bg_model.encode_image(image_tensor) # (1, 1, H, W) [0,1]
mask_np = mask_tensor[0, 0].cpu().float().detach().numpy()
encode_image() の戻り値は requires_grad=True のテンソルのため、.detach() が必要(後述エラー2)。
3. server.py — BiRefNet エンドポイント
GET /mask_editor/birefnet/status → {loaded, model_found, model_path}
POST /mask_editor/birefnet/remove_bg ← {node_id}
→ {mask_b64: "data:image/png;base64,..."}
推論は loop.run_in_executor(None, functools.partial(run_inference, image_pil)) で非同期化。ノードキャッシュから PIL Image を取得して渡す。
4. BiRefNetTool.js — フロントエンドツール
BaseTool を継承。キャンバス描画操作(onMouseDown/Move/Up)は持たない。
async runRemoveBg(nodeId)
→ POST /mask_editor/birefnet/remove_bg
→ this.commitMask(json.mask_b64)
commitMask(maskB64)
→ グレースケール PNG のルミナンス値をアルファに変換
→ mode="add": source-over で合成
→ mode="erase": destination-out で消去
isLoading / lastError フラグと _onStateChange コールバックで UI 状態を通知する(SAM3Tool と同パターン)。
5. MaskEditorModal.js — BiRefNetTool 統合
TOOL_DEFSに{ id: "birefnet", icon: "🔲", label: "BG Remove" }を追加_renderToolOptions()にbirefnet分岐を追加async _buildBiRefNetOptions(container)を追加:- ステータス表示(Model found / not found)
- Remove BG ボタン(ロード中はスピナー + 無効化)
- Mode 選択(Add / Erase)
- エラーメッセージ表示
- ヒントテキスト
6. デバッグ記録
エラー 1: HuggingFace trust_remote_code エラー
メッセージ: "The repository zhengpeng7/BiRefNet contains custom code ... Please pass the argument 'trust_remote_code=True'"
原因: transformers.AutoModelForImageSegmentation.from_config(config) を使用していたため、HuggingFace Hub への接続と trust_remote_code=True が必要になった。
修正: ComfyUI ネイティブの comfy.bg_removal_model.load() に全面切り替え。HuggingFace 依存を完全に排除。
エラー 2: requires_grad numpy エラー
メッセージ: RuntimeError: Can't call numpy() on Tensor that requires grad. Use tensor.detach().numpy() instead.
原因: _bg_model.encode_image() が返すマスクテンソルは requires_grad=True の状態だった。
# 修正前
mask_np = mask_tensor[0, 0].cpu().float().numpy()
# 修正後
mask_np = mask_tensor[0, 0].cpu().float().detach().numpy()
結果: "BiRefNet loaded OK from birefnet.safetensors" — 正常動作確認。
2026-05-25 — 出力追加・キャンバス外ドラッグ対応・BG ドロップゾーン・New キャンバス
概要
4 つの機能追加・バグ修正を一括実施。ノード出力に inverted_mask と mask_image を追加し、Shape/Paint 描画時のキャンバス外マウス離脱で描画が途切れる問題を修正。モーダルに背景画像のドラッグ&ドロップ対応と、サイズ指定の新規キャンバス作成機能を追加した。
変更ファイル
- nodes.py — 出力スロット
inverted_mask・mask_imageを追加 - web/js/editor/MaskEditorModal.js — document レベルマウスイベント・BG ドロップゾーン・
_loadBgFromFile()・_showNewDialog()・_createNewCanvas()を追加 - web/js/editor/tools/ShapeTool.js —
onMouseLeave()をノーオペレーションに変更 - web/css/maskEditor.css —
.me-drop-zone・.me-canvas-area.drag-over・New ダイアログのスタイルを追加
1. 出力スロット追加(nodes.py)
RETURN_TYPES / RETURN_NAMES に inverted_mask (MASK) と mask_image (IMAGE) を追加。
RETURN_TYPES = ("IMAGE", "MASK", "MASK", "IMAGE")
RETURN_NAMES = ("image", "mask", "inverted_mask", "mask_image")
inverted_mask は 1.0 - out_mask で算出。mask_image はマスクのグレースケールを RGB チャンネルに複製した IMAGE テンソルとして出力する。
out_mask = _pil_to_mask_tensor(final_mask_pil)
out_inverted_mask = 1.0 - out_mask
mask_rgb = Image.merge("RGB", [final_mask_pil, final_mask_pil, final_mask_pil])
out_mask_image = _pil_to_tensor(mask_rgb)
2. キャンバス外ドラッグ対応(MaskEditorModal.js・ShapeTool.js)
問題: Shape/Paint ツールで描画中にキャンバス境界を越えてマウスが出ると mouseleave イベントが発火し、_painting が false になって描画が途切れていた。画像の端まで選択する操作ができなかった。
解決: mousedown 時に document へ一時的に mousemove/mouseup リスナーを登録し、mouseup 後に削除する。キャンバス外でもマウス移動・リリースを追跡できるようになった。
cv.addEventListener("mousedown", e => {
this._painting = true;
const onDocMove = (e2) => {
if (!this._painting) return;
const pos = BaseTool.getCanvasPos(cv, e2);
this._tools[this._activeTool]?.onMouseMove(pos.x, pos.y, e2);
this._renderPreviewWithDrawCanvas();
};
const onDocUp = (e2) => {
document.removeEventListener("mousemove", onDocMove);
document.removeEventListener("mouseup", onDocUp);
if (!this._painting) return;
this._painting = false;
const pos = BaseTool.getCanvasPos(cv, e2);
this._tools[this._activeTool]?.onMouseUp(pos.x, pos.y, e2);
this._syncDrawToLayer();
this._updatePreview();
};
document.addEventListener("mousemove", onDocMove);
document.addEventListener("mouseup", onDocUp);
});
ShapeTool.onMouseLeave() も同様に何もしないノーオペレーション化した。
3. BG ドロップゾーン(MaskEditorModal.js・maskEditor.css)
モーダルフッターの BG ボタンを .me-drop-zone 要素に置き換え。クリックでファイルダイアログを開くほか、画像ファイルをドロップして背景を設定できるようにした。キャンバスエリア(.me-canvas-area)へのドロップにも対応している。
背景読み込みロジックを _loadBgFromFile(file) ヘルパーに分離。読み込み完了後に _layerMgr.addLayer() を呼んで新しい空レイヤーを自動追加する。
async _loadBgFromFile(file) {
const dataUrl = await new Promise(resolve => {
const reader = new FileReader();
reader.onload = ev => resolve(ev.target.result);
reader.readAsDataURL(file);
});
await this._setBackgroundImage(dataUrl);
this._layerMgr.addLayer("paint", "Layer " + (this._layerMgr.layers.length + 1));
this._loadActiveLayerToDrawCanvas();
this._renderBg();
this._updatePreview();
}
4. New キャンバス機能(MaskEditorModal.js・maskEditor.css)
ツールバーに 📄 New ボタンを追加。クリックすると幅・高さを入力するオーバーレイダイアログを表示し、Create または Enter で確定すると _createNewCanvas(w, h) を呼ぶ。
_createNewCanvas() は DOM の canvas 要素のサイズを直接書き換え、LayerManager を完全リセットした上で addLayer() で新しい空レイヤーを 1 枚作成する。
_createNewCanvas(w, h) {
this._bgImage = null;
this.node._bgDataUrl = null;
// サーバーキャッシュもクリア
fetch("/mask_editor/store_image", { method: "POST", ... });
for (const cv of [this._bgCanvas, this._drawCanvas, this._previewCanvas, this._vectorPreviewCanvas]) {
cv.width = w;
cv.height = h;
}
this._layerMgr.layers = [];
this._layerMgr.activeIndex = 0;
this._layerMgr.width = w;
this._layerMgr.height = h;
this._layerMgr.addLayer("paint", "Layer 1");
this._fitToView();
}
旧実装の問題: _resizeCanvases() → layer.resize() 経由でリサイズしていたため、既存レイヤーの内容が残り描画が反映されないケースがあった。直接 DOM サイズを更新し layers 配列をリセットすることで解決。
2026-05-24 — SAM3 モデル選択 / safetensors 対応
概要
sam3.pt・sam3.safetensors・sam3.1_multiplex.pt・sam3.1_multiplex_fp16.safetensors の 4 ファイルすべてをサポートし、右パネルのドロップダウンで実行時に切り替えられるようにした。
変更ファイル
- sam3_inference.py —
list_models()・find_checkpoint(model_name)・_load_safetensors_checkpoint()・load_model(model_name)・run_inference(model_name)を拡張 - server.py — segment エンドポイントが
modelフィールドを受け取り、status がmodels配列を返すように変更 - web/js/editor/tools/Sam3Tool.js —
modelNameプロパティ追加、リクエストにmodelフィールドを付加 - web/js/editor/MaskEditorModal.js —
_buildSam3Options()にモデル選択ドロップダウンを追加
1. list_models() — モデル一覧取得
models/sam3/ を走査し、ファイル名に "sam3" を含む有効なチェックポイントのみをリスト化する。全体を try/except で保護してエラー時は空リストを返す。
def list_models() -> list:
try:
sam3_dir = _find_sam3_dir()
if not sam3_dir.exists():
return []
results = []
for suffix in (".pt", ".pth", ".safetensors"):
for p in sorted(sam3_dir.glob(f"*{suffix}"), key=lambda x: x.name.lower()):
if "sam3" not in p.name.lower():
continue # mhr_model.pt などを除外
if _is_torchscript_archive(str(p)):
continue # TorchScript/JIT は除外
results.append({"name": p.name, "path": str(p)})
return results
except Exception as e:
log.warning("list_models() failed: %s", e)
return []
models/sam3/ には SAM3 以外のモデルが混在していることがある(例: mhr_model.pt)。ファイル名フィルタで除外することで誤検出を防ぐ。
2. find_checkpoint(model_name) — モデル名指定対応
model_name が指定された場合はそのファイルのみを確認する。指定なしの場合は従来の自動選択("sam3" 優先・短い名前優先)を使用。
3. _load_safetensors_checkpoint() — safetensors C64 手動パース
問題: safetensors 0.5.3 は C64 (complex64) dtype を InvalidHeaderDeserialization エラーで拒否する。SAM3 の freqs_cis(RoPE 位置エンコーディング)が C64 テンソルのため、safetensors.torch.load_file() は使えない。
解決: ヘッダー JSON を手動パースし、各テンソルのバイト列を直接 PyTorch テンソルに変換する。
with open(path, "rb") as f:
n = struct.unpack("<Q", f.read(8))[0] # 先頭 8B = ヘッダーサイズ
header = json.loads(f.read(n).decode("utf-8"))
data_start = 8 + n
for tensor_name, info in header.items():
if tensor_name == "__metadata__": continue
dtype_str = info["dtype"]
f.seek(data_start + info["data_offsets"][0])
raw = bytearray(f.read(info["data_offsets"][1] - info["data_offsets"][0]))
if dtype_str in ("C64", "C128"):
# float32 ペア(実部・虚部)として読み込み → view_as_complex
flat = torch.frombuffer(raw, dtype=torch.float32)
ckpt[tensor_name] = torch.view_as_complex(
flat.reshape(*info["shape"], 2).contiguous()
).clone()
else:
ckpt[tensor_name] = torch.frombuffer(raw, dtype=_DTYPE_MAP[dtype_str]) \
.reshape(info["shape"]).clone()
.pt の state_dict と同様に "detector." プレフィックスでフィルタリングしてから load_state_dict(strict=False) に渡す。FP16 safetensors(sam3.1_multiplex_fp16.safetensors)は copy_() が自動変換するためそのまま渡せる。
4. load_model() — safetensors 分岐
.safetensors の場合は build_sam3_image_model(checkpoint_path=None, load_from_HF=False) でアーキテクチャのみ構築し、後から _load_safetensors_checkpoint() でウェイトを注入する。
.pt を build_sam3_image_model(checkpoint_path=path) に渡すと _load_checkpoint → torch.load が呼ばれるため、PyTorch 2.6 対応の weights_only=False パッチをそのまま適用できる。
5. server.py — functools.partial で model_name を渡す
run_in_executor はキーワード引数を直接受け取れないため functools.partial を使用:
model_name = str(data.get("model", "")).strip() or None
fn = functools.partial(run_inference, image_pil, prompt, max_masks, model_name)
masks = await loop.run_in_executor(None, fn)
status エンドポイントは models 配列([{"name": filename, "path": fullpath}, ...])を含むように変更。
6. MaskEditorModal.js — モデル選択ドロップダウン
_buildSam3Options() で status 取得後、s.models が 2 件以上の場合にドロップダウンを表示。
const self = this; // .then() 内での this バインディング保護
fetch("/mask_editor/sam3/status")
.then(r => r.json())
.then(s => {
try {
if (s.models && s.models.length > 0) {
const modelSel = document.createElement("select");
s.models.forEach(m => {
const o = document.createElement("option");
o.value = m.name; o.textContent = m.name;
modelSel.appendChild(o);
});
// ロード済みモデルを初期選択
if (s.model_path) {
const loadedName = s.model_path.replace(/\\/g, "/").split("/").pop();
if ([...modelSel.options].some(o => o.value === loadedName))
modelSel.value = loadedName;
}
tool.modelName = modelSel.value;
modelSel.onchange = () => { tool.modelName = modelSel.value; };
}
} catch (e) { console.error("[MaskEditor] SAM3 status parse error:", e); }
});
7. デバッグ記録
エラー 1: ドロップダウンが表示されない(初回実装時)
原因 A: ComfyUI の Python モジュールキャッシュ(再起動が必要)
原因 B: list_models() が mhr_model.pt・model.safetensors など非 SAM3 ファイルを返していた
修正: ファイル名に "sam3" を含むもののみに絞り込む。const self = this でコールバック内の this バインディングを修正。
エラー 2: 実行環境が ComfyUI_3 だと思っていたが ComfyUI_4 だった
原因: トレースバックのパスを見ていなかった
修正: トレースバックで ComfyUI_4 を確認後、ComfyUI_4 を優先コピー先に変更。project_dirs.md メモリを更新。
エラー 3: _pickle.UnpicklingError: unpickling stack underflow(sam3.safetensors ロード時)
原因 A(第一段階): ComfyUI_4 に古い sam3_inference.py が残っており、build_sam3_image_model(checkpoint_path=safetensors_path) → _load_checkpoint → torch.load(safetensors_file) → pickle エラー
原因 B(第二段階): safetensors.torch.load_file() が InvalidHeaderDeserialization — safetensors 0.5.3 が C64 dtype を扱えない
修正: 手動バイトパース _load_safetensors_checkpoint() を実装(前述の通り)
2026-05-24 — Sam3Tool(SAM3.1 AIセグメンテーション)実装
概要
テキストプロンプトを入力するだけで SAM3.1 がピクセル単位のセグメンテーションマスクを生成する Sam3Tool を実装した。候補マスクをサムネイルグリッドで表示し、クリックで選択してアクティブレイヤーに適用できる。
追加ファイル
- sam3_inference.py — SAM3 モデルのロード・推論バックエンド
- web/js/editor/tools/Sam3Tool.js — フロントエンドツールクラス
変更ファイル
- server.py — SAM3 API エンドポイント追加
- web/js/editor/MaskEditorModal.js — Sam3Tool 統合
- web/css/maskEditor.css — SAM3 UI スタイル
- requirements.txt —
sam3を追加
1. sam3_inference.py — 推論バックエンド
アーキテクチャ
グローバル _model, _processor, _loaded_ckpt でモデルをプロセス内に保持。同じチェックポイントで再ロード要求がきた場合は何もしない。
| 関数 | 役割 |
|---|---|
find_checkpoint() |
models/sam3/ から有効な SAM3 state_dict を探す |
_is_torchscript_archive(path) |
TorchScript/JIT ファイルを除外する |
_find_bpe() |
BPE vocab ファイルを探し、なければ OpenAI CDN からダウンロード |
load_model(ckpt, device) |
モデルをロード(HuggingFace フォールバック付き) |
run_inference(image_pil, prompt, max_masks) |
推論実行、mask_b64 リストを返す |
get_status() |
ロード状態・チェックポイント情報を返す |
チェックポイント優先順位
def priority(p: pathlib.Path):
name = p.name.lower()
has_sam3 = "sam3" in name
return (0 if has_sam3 else 1, len(name), name)
名前に "sam3" を含むファイルを優先し、その中でも短い名前(sam3.pt > sam3.1_multiplex.pt)を先に試す。
TorchScript 検出
def _is_torchscript_archive(path: str) -> bool:
import zipfile
with zipfile.ZipFile(path) as zf:
names = zf.namelist()
if any(n.endswith("/data.pkl") or n == "archive/data.pkl" for n in names):
return False # 正規の state_dict
if "constants.pkl" in names:
return True # TorchScript v1
if any("producer_info.json" in n for n in names):
return True # torch.jit.save 新形式
return False
正規の state_dict は archive/data.pkl を持つ。TorchScript は持たない代わりに constants.pkl(v1)か producer_info.json(PyTorch 2.x の torch.jit.save)を持つ。
PyTorch 2.6 weights_only パッチ
PyTorch 2.6 で torch.load のデフォルト weights_only が True に変更されたため、SAM3 の .pt ファイル(pickle オブジェクトを含む)がロードできなくなった。モデルビルダー呼び出し中だけ一時的にパッチする:
_orig_load = torch.load
try:
torch.load = lambda *a, **kw: _orig_load(*a, **{**kw, "weights_only": False})
model = build_sam3_image_model(**kwargs)
finally:
torch.load = _orig_load
BPE vocab 自動ダウンロード
Sam3Processor が CLIP テキストエンコーダに必要とする bpe_simple_vocab_16e6.txt.gz を comfyui-rmbg/models/assets/ で探し、なければ OpenAI CDN から自動ダウンロードする。
推論結果フォーマット
run_inference() が返す各エントリ:
{
"mask_b64": "data:image/png;base64,...", # グレースケール L モード PNG
"score": float, # モデルの信頼度
"area": int, # マスクピクセル数
}
スコア降順でソートして返す。
2. server.py — SAM3 エンドポイント
GET /mask_editor/sam3/status → {loaded, ckpt_found, ckpt_path, jit_files, model_path}
POST /mask_editor/sam3/segment ← {node_id, prompt, max_masks}
→ {masks: [{mask_b64, score, area}, ...]}
推論はブロッキング処理のため loop.run_in_executor(None, run_inference, ...) で非同期化。ノードキャッシュ (_node_cache[node_id]) から PIL Image を取得して推論に渡す。
3. Sam3Tool.js — フロントエンドツール
BaseTool を継承。キャンバス描画操作(onMouseDown/Move/Up)は持たない。
async runInference(nodeId, prompt, maxMasks = 9)
→ POST /mask_editor/sam3/segment
→ this.results に [{mask_b64, score, area}] を格納
→ this.onResultsChange() コールバックで UI を更新通知
commitMask(maskB64)
→ グレースケール L モード PNG を RGBA に変換(輝度→アルファ)
→ mode="add": source-over で合成
→ mode="erase": destination-out で消去
輝度→アルファ変換
const d = imgData.data;
for (let i = 0; i < d.length; i += 4) {
const lum = d[i];
d[i] = d[i+1] = d[i+2] = 255;
d[i+3] = lum;
}
SAM3 が出力するグレースケール PNG のルミナンス値をアルファチャンネルに変換してから合成する。
4. MaskEditorModal.js — Sam3Tool 統合
TOOL_DEFSに{ id: "sam3", icon: "✨", label: "SAM3" }を追加_buildSam3Options()でツールオプションパネルを構築:- モデルステータス表示(Ready / 未ロード / JIT警告 / 未検出)
- プロンプト入力(Enter → 推論実行)
- Segment ボタン(スピナー付き)
- Mode 選択(Add / Erase)
- 結果グリッド(3列サムネイル、スコアバッジ、クリックで適用)
- マスク適用フロー:
_saveUndoState()→commitMask()→_syncDrawToLayer()→_updatePreview()→_refreshLayerThumbnail()
5. デバッグ記録
エラー 1: name 'Image' is not defined
原因: server.py に from PIL import Image が欠けていた。
修正: server.py の先頭 import に追加。
エラー 2: BPE vocab ファイルが見つからない
原因: 旧 _find_bpe() が .json ファイルを探していた。実際は bpe_simple_vocab_16e6.txt.gz で、comfyui-rmbg/models/assets/ に配置される。
修正: _find_bpe() を正しいファイル名・パスで再実装し、不在時は OpenAI CDN から自動ダウンロード。
エラー 3: weights_only=True による PyTorch 2.6 ロード失敗
原因: PyTorch 2.6 で torch.load のデフォルト動作が変わり、pickle オブジェクトを含む SAM3 ファイルが weights_only=True では読めなくなった。
修正: build_sam3_image_model() 呼び出し中だけ torch.load をモンキーパッチして weights_only=False を強制。
エラー 4: NotImplementedError in forward_magic_method → マスク0件
原因: models/sam3/ に mhr_model.pt(キャラクターアニメーション用 TorchScript モデル)がシンボリックリンクで存在し、これを SAM3 として誤ってロードしていた。
torch.jit.load(mhr_model.pt) は RecursiveScriptModule(メソッド: character_torch, face_expressions_model, pose_correctives_model)を返す。_load_checkpoint が "model" in ckpt を実行すると TorchScript の forward_magic_method が NotImplementedError を送出。
当初の _is_torchscript_archive() は TorchScript v1(constants.pkl + .py)のみ検出し、PyTorch 2.x の torch.jit.save 新形式(producer_info.json を持つ ZIP)を見落としていた。
修正:
_is_torchscript_archive()にproducer_info.jsonチェックを追加- 正規 state_dict の要件として
archive/data.pklの存在を必須化 find_checkpoint()で "sam3" を含む名前を優先
確認: sam3.pt(3.45 GB、detector.* キー形式)が正しくロードされることを確認。
2026-05-24 — ShapeTool 追加・Blur スライダー・バグ修正群
1. ShapeTool — 矩形・楕円マスク
追加ファイル: ShapeTool.js
ドラッグで矩形または楕円のマスク領域を描画する。
仕様
| プロパティ | 説明 |
|---|---|
| shape | rect(矩形)/ ellipse(楕円) |
| mode | add(白塗り)/ erase(destination-out) |
操作
| 操作 | 動作 |
|---|---|
| ドラッグ | 開始点 → 終了点を対角とする矩形・楕円を描画 |
| Shift + ドラッグ | 開始点を中心とした正方形・正円を描画 |
ドラッグ中は _vectorPreviewCanvas(z-index: 3、VectorTool と共用)に青い点線でリアルタイムプレビューを描画。mouseup でプレビューをクリアし drawCanvas にコミット。
MaskEditorModal との統合ポイント
TOOL_DEFSに{ id: "shape", icon: "⬜", label: "Shape" }追加_buildCanvasArea()でShapeToolを初期化し_vectorPreviewCanvasをsetPreviewCanvas()で渡す_setupCanvasEvents()の mousemove ブランチに shape 専用分岐を追加(drawCanvas を更新しないため_renderPreviewWithDrawCanvas()を呼ばない)_buildShapeOptions()で shape 種類(Rectangle/Ellipse)とモードの選択 UI を構築
2. Blur スライダー — マスク全体にガウスぼかし
変更ファイル: nodes.py, MaskEditorModal.js, maskEditor.css
nodes.py
INPUT_TYPESのoptionalにblur_radius: (INT, {default: 0, min: 0, max: 200, step: 1})追加process()にblur_radius=0パラメータ追加。invert_mask適用後にImageFilter.GaussianBlur(radius=blur_radius)を適用
MaskEditorModal.js
this._blurRadius = 0をコンストラクタに追加open()初回・再表示の両パスで_getBlurWidget()からウィジェット値を読んでスライダーに同期_buildFooter()に.me-blur-ctrl(Blurラベル + range スライダー + number 入力)を追加- スライダー変更時に
_previewCanvas.style.filter = blur(Npx)でリアルタイムプレビュー _updatePreview()/_renderPreviewWithDrawCanvas()でも filter を維持_apply()で_getBlurWidget().value = this._blurRadiusを確実に更新_getBlurWidget()追加:node.widgets.find(w => w.name === "blur_radius")
3. バグ修正
3-1. BG変更後Runで古い画像が出力される (IS_CHANGED 未実装)
原因: bg_image_b64 はサーバーキャッシュ経由で変わるため ComfyUI の標準変更検出では検知できず、ノードが前回の結果をキャッシュから返していた。
修正 nodes.py:
@classmethod
def IS_CHANGED(cls, **kwargs):
bg_b64 = _srv._node_cache.get(node_id, {}).get("bg_image_b64") or ""
if bg_b64:
return hashlib.md5(bg_b64[:512].encode()).hexdigest()
return ""
bg_image_b64 の MD5 を返すことで、BG 変更時にキャッシュが無効化されて再実行される。
3-2. Edit Mask を開き直すと以前の画像が表示される
原因: open() の2回目以降パス(if this._overlay)で _loadNodeImage() を呼んでいなかった。
await this._loadNodeImage();
this._loadActiveLayerToDrawCanvas(); // 追加
this._renderBg();
this._updatePreview();
3-3. モーダル表示中は BG を変更できない
原因: モーダルのオーバーレイがキャンバス全体を覆うため、ComfyUI キャンバス上の BG ウィジェットに触れない。
修正: モーダルフッターに 🖼 BG ボタンを追加。ファイル選択後に node._bgDataUrl・node._bgImg・node._bgWidget.name・サーバーキャッシュ・モーダル背景をすべて更新。maskEditor.js で node._bgWidget = bgWidget を保存。
3-4. 開き直すとマスクのプレビューが消える
原因: _loadNodeImage() → _setBackgroundImage() → _resizeCanvases() が 同サイズでも canvas.width = w を再代入するため、全 canvas の内容がクリアされていた(layer.canvas は layer.resize() で復元されるが、2回目の open() では _loadActiveLayerToDrawCanvas() が呼ばれておらず _drawCanvas が空のままになった)。
_resizeCanvases(w, h) {
if (this._canvasW === w && this._canvasH === h) return; // 同サイズなら何もしない
...
}
同サイズの場合は canvas を再代入しない。サイズが変わった場合の後始末は _loadActiveLayerToDrawCanvas() の追加(3-2 と共通)でカバー。
3-5. subtract レイヤーが出力マスクに反映されない
原因: _composite_layers() が for layer in layers_data と正順(上→下)で処理していたが、CanvasCompositor.composite() は [...layers].reverse() で逆順(下→上)処理していた。
subtract レイヤーが先に処理されると result=0 の状態で減算 → clip で 0 のまま → その後 add で白くなる → subtract が無効化される、という順序が原因。
修正 nodes.py:
for layer in reversed(layers_data): # JS の .reverse() と同じ下から上の順序に統一
2026-05-23 — TextTool・VectorTool 追加
1. TextTool — テキスト形状マスク
追加ファイル: TextTool.js
キャンバスをクリックするとテキスト入力オーバーレイが表示され、Stamp または Ctrl+Enter でテキスト形状のマスクを確定する。
仕様
| プロパティ | 説明 |
|---|---|
| text | スタンプするテキスト(改行で複数行対応) |
| fontFamily | フォントファミリー(11 種プリセット) |
| fontSize | 8 〜 400 px |
| bold / italic | 太字・斜体 |
| align | left / center / right |
| mode | add (白塗り) / erase (destination-out) |
複数行は text.split("\n") で処理、lineHeight = fontSize × 1.2。
MaskEditorModal との統合ポイント
mousedownブランチにtext分岐を追加。_paintingフラグを立てず、undo は mousedown 時に保存(キャンセル時は余分な undo が積まれるが実害なし)- オーバーレイ内の
mousedownはstopPropagationでキャンバスイベントに伝播しない TOOL_DEFSに{ id: "text", icon: "T", label: "Text" }を追加_buildTextOptions()で右パネルにテキスト入力・フォント・サイズ・Bold/Italic・アライメント・モードの UI を構築
2. VectorTool — Catmull-Rom スプラインベクターマスク
追加ファイル: VectorTool.js
クリックでアンカーポイントを追加し、Catmull-Rom スプラインで滑らかなパスを描いてマスク領域を塗りつぶす。
スプライン実装
Catmull-Rom を Canvas2D の bezierCurveTo に変換:
各セグメント p1→p2 の制御点:
cp1 = p1 + (p2 - p0) / 6
cp2 = p2 - (p3 - p1) / 6
- 閉じたパス: 両端ダミー
[pts[n-1], ...pts, pts[0], pts[1]]で接続 - 開いたパス: 端点を折り返し複製
[2*p0 - p1, ...pts, 2*pn - pn-1]で自然端条件に近似
プレビュー canvas
_vectorPreviewCanvas(z-index: 3、pointer-events: none)を .me-canvas-container に追加し VectorTool に渡す。_resizeCanvases と _setZoom も対象に追加。
プレビューで描画する要素:
| 要素 | 説明 |
|---|---|
| 黄色の実線 | 確定済みスプラインパス |
| 黄色の点線 | マウス位置へのルバーバンド線 |
| 赤い点線 | 最初の点近くでホバー時の「閉じたパスプレビュー」 |
| 黄色の円 | アンカーポイント(最初の点は大きめ) |
| 赤い円 | 最初の点にスナップ範囲(12 px)以内でホバー中 |
| 白い小円 | ホバー中のカーソル位置 |
キーボードショートカット
_buildDOM で document.addEventListener("keydown", this._keyHandler) を一度だけ登録。モーダルが非表示の場合はスキップ。
| キー | 動作 |
|---|---|
Esc |
パスをリセット |
Enter |
開いたパスで確定 |
Backspace / Delete |
最後のアンカーポイントを削除 |
undo タイミング
通常の mousedown 時 undo ではなく、onBeforeCommit コールバックでパス確定直前にのみ undo を保存。途中キャンセル(Esc)では undo が積まれない。
this._tools.vector.onBeforeCommit = () => this._saveUndoState();
ツール切り替え
_selectTool() で vector → 他ツールに切り替える際に tool.reset() を呼び、プレビューをクリア。
2026-05-23 — ブラシアスペクト比修正 & ABR v2 block-length サポート & ABR v6 samp オフセット修正
1. 画像ブラシのアスペクト比修正
症状: abrViewer で正しく表示される炎ブラシが、インポート後の描画では縦横比が崩れて扁平になっていた。
原因:
PaintTool.js _getStamp() が size × size の正方形 Canvas を作成していたため、元画像のアスペクト比が無視されていた。
また _paintImageBrush() でのスタンプ中心揃えが size/2 固定だったため、非正方形ブラシがずれて描画されていた。
修正:
_getStamp():aspect = srcW / srcHを計算しstampW = size * aspect、stampH = sizeでアスペクト比を維持_paintImageBrush(): 中心揃えをstamp.width / 2、stamp.height / 2に変更(実際のスタンプ寸法を使用)- コーナーサンプリングのインデックスを
size→stampW/stampHベースに修正
BrushLibrary.js サムネイルも同様に修正:
drawImage(img, 0, 0, 80, 80)→ fit-in-80×80-box(アスペクト比を維持してセンタリング)
2. ABR v2 (block-length format) サポート追加
症状: 01_Trav_CloudsVol1.abr (ABR v2, sub_version=4) を Import .ABR すると 0 ブラシとなっていた。
原因:
既存の _parse_v1v2 は v1/v2 の「連続レコード」形式を想定していたが、v2 では各ブラシブロックの前に 4 byte の block_len フィールドが付く形式(block-length prefix format)が使われる。このフィールドを misc + spacing の一部として誤読し、Unicode ネームバイトを bounds として解釈 → 無効なサイズで全ブラシがスキップされていた。
修正 abr_parser.py:
_parse_v2 関数を新規実装:
各レコード: type(2B) + block_len(4B) + block_data(block_len bytes)
sampled brush block:
u32 misc/index
u16 spacing
pstr pascal string (通常空文字)
u16 unicode_name_len
bytes unicode_name (unicode_name_len × 2, UTF-16-BE, null 含む)
u8 antiAlias
4×i16 bounds_short (top, left, bottom, right)
4×i32 bounds_long (i16 が不足の場合に上書き)
u16 depth
bytes PackBits pixel data
extract_brushes() のディスパッチを更新: version==2 and sub_version!=6 → _parse_v2 を呼ぶ。
結果: 01_Trav_CloudsVol1.abr から 3 ブラシ正常抽出(各 ~999×999 px)。
3. ABR v6 UUID-keyed samp エントリのオフセット修正
症状: 02_DropMaker-DreamWarrior.abr (ABR v6, sub_version=1) を Import .ABR すると 0 ブラシとなっていた。
原因:
_extract_v6sub2_entry が bounds を offset 49 の 5 × BE u32 として読んでいたが、実際の構造は異なっていた。
また bounds 値は 1/256 ピクセル単位の固定小数点で格納されており、生の値をそのまま使うと 18944 など巨大な寸法になって全エントリが弾かれていた。
正しい構造(リバースエンジニアリング):
offset 0: UUID Pstring (1B 長=36 + 36B UUID + 1B pad) = 38 bytes
offset 38: 4 × LE i16 crop bounds (top, left, bottom, right)
offset 46: 1 × LE u16 depth
offset 48: 4 × BE i32 canvas bounds — 単位は 1/256 px (÷256 でピクセル値)
offset 64: 1 × BE u16 depth (冗長)
offset 66: PackBits 圧縮 8bit グレースケールピクセルデータ
offset 48 の値は 256 で割ることでピクセル寸法が得られる(例: 0x00004a00 / 256 = 74)。
Entry 4 のように LE i16 crop bounds が無効値になるケースでも、BE i32 canvas bounds / 256 は常に正しい全体寸法を与える。
修正 abr_parser.py _extract_v6sub2_entry:
raw_top, raw_left, raw_bot, raw_right = struct.unpack_from('>4i', entry_data, 48)
top, left, bot, right = raw_top//256, raw_left//256, raw_bot//256, raw_right//256
img_data = entry_data[66:]
結果: 02_DropMaker-DreamWarrior.abr から 8/8 ブラシ正常抽出(74×74 〜 186×322 px)。
2026-05-23 — BGボタン・ノードプレビュー・レイヤー可視修正・Reloadボタン
1. レイヤー可視アイコン修正(ComfyUI CSS 衝突)
症状: レイヤーの 👁 アイコンをクリックして非表示にすると、アイコン自体が完全に消えて表示に戻せなかった。
原因: me-layer-vis-btn.hidden の .hidden が ComfyUI グローバル CSS の .hidden { display: none } に上書きされていた。
修正 maskEditor.css / MaskEditorModal.js:
- クラス名を
hidden→vis-offに変更(衝突回避) .me-layer-vis-btn.vis-off::afterで::after疑似要素を使い 👁 の上に斜線を重ねて「非表示状態」を表現position: relative/opacity: 0.45で常にアイコンを表示しつつ視覚的に区別
2. モーダル Reload ボタン追加
MaskEditorModal.js _buildFooter()
フッターの Invert チェックの右隣に「⟳ Reload」ボタンを追加。
node._bgDataUrl があればそちらを優先、なければ /mask_editor/get_node_image からサーバーキャッシュを取得して背景画像を再ロードする。
3. BGボタン・ノードプレビュー
maskEditor.js(大幅改修)
ノード上のボタン構成
| ウィジェット | 動作 |
|---|---|
🖼 BG |
ファイル選択 → node._bgDataUrl に保存、サーバーキャッシュ (bg_image_b64) に送信 |
🖼 BG ✕ |
再クリックで BG 画像をクリア |
👁 表示: 画像 / マスク |
プレビューモードトグル |
✏️ Edit Mask |
従来のモーダル起動ボタン |
ノードプレビュー描画
onDrawForeground でノード下部に 160px のプレビュー領域を描画:
- アスペクト比を維持してセンタリング
- 左上に
IMAGE/MASKラベルを表示 - プレビュー画像がない場合は描画なし(ノード高さも増やさない)
Apply 後の自動マスク表示
MaskEditorModal._apply() が updateNodeMaskPreview(node, dataUrl) を呼び出し、プレビューモードを自動的に mask に切り替え。マスクは _previewCanvas(赤/青オーバーレイ)ではなく compositor.composite() で生成した白黒画像を使用。
export function updateNodeMaskPreview
maskEditor.js からエクスポートし、MaskEditorModal.js が import して使用。
4. BGキャッシュと接続画像キャッシュの分離
背景: image 入力接続時にキャッシュを image_b64 で上書きしていたため、接続を切っても接続画像がキャッシュに残り、BG ボタン画像の代わりに使われていた。
修正:
| キー | 用途 |
|---|---|
image_b64 |
image 入力接続時の画像(process() が上書き) |
bg_image_b64 |
BG ボタンで選択した画像(store_image エンドポイントが独立管理) |
- server.py:
store_imagePOST でbg_image_b64キーがあれば独立して保存。get_node_imageはimage_b64 or bg_image_b64の順で返す - maskEditor.js:
_storeBgImage()のペイロードを{ bg_image_b64: dataUrl }に変更 - nodes.py:
process()でimage=Noneの場合にcache["bg_image_b64"]を読み込んでbg_imageに設定
5. MaskEditorModal の画像ロード優先順位
MaskEditorModal.js _loadNodeImage():
1. node._bgDataUrl が存在する → JS メモリから即座にロード
2. なければ /mask_editor/get_node_image からサーバーキャッシュを取得
(image 接続あり → image_b64、なし → bg_image_b64 を返す)
2026-05-22 — ブラシ描画エンジン刷新 & invert_mask 双方向連動 & ABR 保存形式修正
変更の背景
- ノードの
invert_maskトグルとモーダルの Invert チェックボックスが OR 結合だったため、どちらを操作しても同じ結果になっていた - 円形ブラシの hardness 動作が Photoshop と異なり、中心の濃度が
hardness値で頭打ちになっていた - 1ストローク内でスタンプが重なるとアルファが累積し、思わぬ浮き彫り模様になっていた
- ABR インポート時のアルファ変換が逆で、ブラシが「バウンディングボックス全体の白矩形」にスタンプされていた
- ブラシライブラリのサムネイルが白地に黒い点で表示されており、ブラシ形状が分かりにくかった
1. invert_mask ↔ モーダル Invert 双方向連動
invert_mask or data.get("inverted", False)→invert_mask単独に変更layer_data.invertedは廃止。反転状態はノードウィジェットinvert_maskのみが管理する
_getInvertWidget()追加:node.widgets.find(w => w.name === "invert_mask")open()再表示パス: ウィジェット値を読んで_invertCheckを同期open()初回パス:layer_data.invertedの引き継ぎを廃止し、ウィジェット値で初期化- Invert
onchange:invertCheck.checked→ ウィジェット値に書き戻し +setDirtyCanvas(true) _apply(): apply 時にもウィジェット値を更新
2. ブラシ描画エンジン刷新 (Photoshop 互換)
PaintTool.js(全面改修)
Photoshop 互換 hardness
従来は alpha = hardness 固定だった中心部を、innerRadius = r * hardness のラジアルグラデーションに変更:
innerR (100% opaque) → outerR (0% opaque)
hardness=1.0 のとき innerR=r → シャープエッジ
hardness=0.0 のとき innerR=0 → 中心から周辺に向けてフルグラデーション
ストロークバッファ(intra-stroke 累積防止)
_baseCanvas (ストローク開始時点のスナップショット) + _strokeCanvas (現ストロークの最大値) の2バッファ構成:
- スタンプ描画:
_strokeCanvasにsource-overで重ね描き - 画面反映 (毎スタンプ後):
base→lighten(_stroke)で合成lighten= max 演算: 同一ストローク内で同じ箇所に何度スタンプしても最大値のみ採用- 過剰な濃度累積なし、スタンプ間隔を狭くしても均一な太さを保持
- ストローク終了 (
mouseup/mouseleave): 合成結果を_baseCanvasにコミット
コーナー輝度による黒地/白地自動判定
画像ブラシのアルファ変換時に、4コーナーの輝度平均で背景色を自動判定:
- 白地 (
bgLum > 0.5):alpha = 1 - lum(暗い部分がブラシ) - 黒地またはアルファあり:
alpha = lum(明るい部分がブラシ)
3. ABR 保存形式修正
ABR raw データの実仕様が判明: bright(255) = full paint、dark(0) = background
(以前の DEVLOG「ABR は dark=paint」は誤記。ABR の raw grayscale はそのままアルファに使える)
変更前(誤):
img = img.point(lambda p: 255 - p) # 反転してからグレースケール保存
変更後(正):
rgba = Image.new('RGBA', img.size, (255, 255, 255, 0))
rgba.putalpha(img) # 反転なし: bright=opaque のまま RGBA PNG に保存
RGBA PNG として保存することで _getStamp() の canvas.drawImage がアルファチャンネルをそのまま利用でき、バウンディングボックス全体のスタンプ問題が解消。
4. ブラシサムネイル Canvas 化
<img> タグ → <canvas> (80×80, 黒背景) に変更:
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, 80, 80);
thumbImg.onload = () => { ctx.drawImage(thumbImg, 0, 0, 80, 80); };
RGBA PNG (白シェイプ + アルファ) をそのまま黒背景に合成するため、Photoshop のブラシプレビューと同形式(黒地に白いブラシ形状)で表示される。
5. ブラシプレビュー修正
MaskEditorModal.js _renderBrushPreview
画像ブラシ選択時に tool.brushImage(元画像)を直接描画していたが、RGBA PNG 形式では白矩形になるため tool._getStamp(size) を呼び出してアルファ変換済みのスタンプ Canvas を描画するよう変更。
2026-05-22 — ブラシ角度スライダー & マスク反転トグル
追加機能
1. ブラシ角度スライダー (0° ~ 360°)
- PaintTool.js に
angleプロパティ追加 - 画像ブラシスタンプ時に
ctx.translate → rotate → drawImageで回転描画 - 円形ブラシでは無効(回転しても変化なし)
- MaskEditorModal.js のブラシ設定 UI にスライダー追加
- ブラシプレビューも角度を反映して表示
2. ノード上のマスク反転トグル
- nodes.py の
INPUT_TYPESにinvert_mask: BOOLEANを追加(required) label_on="inverted",label_off="normal"で状態を視覚化process()でinvert_mask or data.get("inverted", False)を反転条件として OR 結合
ワークフローに保存される設定のため、ノードを再ロードしても状態が保持される。
2026-05-22 — ABR v6 sub2 (Photoshop CC) サポート完成
課題
前セッションで実装した ABR インポート機能のうち、Photoshop CC が生成する version=6, sub_version=2 の ABR ファイルが解析できなかった。
「Import .ABR ボタン押下後に変化なし、404 エラー」という症状で、原因の切り分けから着手。
デバッグの流れ
- JS フロー確認 —
BrushLibrary.jsの_importAbr()にconsole.logを追加。onchange→addEventListener("change", …)に変更、this._abrInput = inputで GC 防止。 → 結果: JS 側は完全に動作。サーバーから{ok: true, imported: 0}が返っていた。 abr_parser.pyのデバッグ —extract_brushesにprint追加でversion=6, sub_version=2を確認。sampブロック解析の破綻 —entry_lenが異常値に化けるエラーが発生。バイナリを HEX ダンプして調査。- UUID 構造の発見 — 各エントリの先頭が
$xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx形式の UUID Pstring。 descブロックは標準 ActionDescriptor —descriptor_version=16+ Unicode name + classID + items。- 正規表現で UUID をスキャン —
samp全体から 20 個の UUID 位置を取得。各エントリのサイズが 2-3MB で均一であることを確認。 - エントリ間アライメント判明 —
4 byte entry_len + N byte data + 4-byte境界パディング。
v6 sub2 フォーマット仕様(リバースエンジニアリング)
samp block:
for each brush:
4 bytes: entry_len
N bytes: entry data:
offset 0: UUID Pstring (1B長=36 + 36B UUID + 1B padding) = 38 bytes
offset 38: 11-byte header (01 00 00 00 00 00 03 00 + 3B size_high)
offset 49: 5 × big-endian LONG: top, left, bottom, right, extra
offset 69+: PackBits-compressed 8-bit grayscale image data
padding: to align next entry to 4-byte boundary
desc block:
4 bytes: descriptor_version (= 16)
ActionDescriptor:
Unicode name (4B length + N*2B UTF-16-BE)
classID (key format: "null")
items:
"Brsh" (VlLs) → list of brush objects
each: Objc "brushPreset" → contains Objc "Brsh" (sampledBrush)
→ Nm (TEXT, name), Dmtr (UntF #Pxl, diameter), Angl, Rndn, ...
実装のキー修正
_read_descriptorを Photoshop 標準形式(Unicode name 含む)に修正_extract_v6sub2_entryを新規実装- UUID パターンマッチで entry 境界検出
- 5 × LONG bounds 抽出
- 3 段戦略 (raw 8bit → zlib → PackBits) で画像復号
- PackBits 結果が想定サイズに ~5% 未満不足の場合はゼロパディング救済
descの名前を samp の UUID 順に sequential mapping
結果
- テストファイル「20 Soft Brushes.abr」(49MB): 20/20 ブラシ抽出成功
- 「Free Tartan Photoshop Brushes.abr」(78MB): 15/15 成功
- entry#15 だけ PackBits 出力が 19,335 bytes (0.35%) 不足していたが、ゼロパディング救済機能で正常保存
既知の制限
- ABR ファイルが持つ内部メタデータの名前が正しくないことがある(Photoshop CC の保存ミス) 例: brush#5 の名前が "Soft Brush 7" になっていて #6 が抜けて見える
- これは ABR 作成側の問題で、本パーサーでは修正不可
後始末
- 全
printをlogging経由に変更(log.info/log.debug/log.warning) BrushLibrary.jsのconsole.logを削除- メモリ(
project_mask_editor.md)をデバッグ完了状態に更新
2026-05-21 — 画像ブラシ + ブラシライブラリ + ABR パーサー初版
追加ファイル
- abr_parser.py — ABR v1/v2/v6/v10 パーサー
- BrushLibrary.js — ブラシライブラリの floating modal UI
変更ファイル
- server.py — ブラシエンドポイント追加
GET /mask_editor/brushes/list— フォルダツリー取得GET /mask_editor/brushes/raw?path=...— 画像配信POST /mask_editor/brushes/import— PNG/JPG/WebP/BMP フォルダインポートPOST /mask_editor/brushes/upload_abr— ABR ファイル解析・保存
- PaintTool.js — 画像ブラシスタンプ機能
- グレースケール画像をブラシとして使用、輝度をアルファに変換
- キャッシュされたスタンプ Canvas でサイズ/硬さ変更時のみ再生成
- MaskEditorModal.js — ブラシセレクター UI 統合
- maskEditor.css — ブラシライブラリスタイル
ブラシ保存先
{install_dir}/brushes/ 配下に自動作成。フォルダ階層を維持。
色変換
ABR は dark = paint、本システムは bright = paint のため、抽出時に自動反転。
2026-05-XX (初期) — Phase 1 実装完了
実装範囲
- Edit Mask モーダル — ノード上のボタンからブラウザ内エディタを起動
- ペイントツール — ブラシサイズ・硬さ・add/erase モード
- カラー選択ツール — 色クリックで類似領域を選択、tolerance / feather、add/subtract
- 透過抽出ツール — 入力 IMAGE のアルファチャンネルからマスク生成
- レイヤー管理 — 追加・削除・可視切替・add/subtract モード・ドラッグで並べ替え
- Undo/Redo — 各レイヤー 30 段までスタック
- Apply フロー — エディタ →
layer_dataウィジェット → ノード実行でMASK+IMAGE出力 - 入力対応 —
IMAGE/MASK入力を任意接続可能
ファイル構成
nodes.py # MaskEditorNode の INPUT_TYPES / process()
server.py # PromptServer API、画像/マスクキャッシュ
__init__.py # NODE_CLASS_MAPPINGS, WEB_DIRECTORY
web/ # フロントエンド
技術スタック
- Python:
nodes.py+server.py(PromptServer の routes に登録) - JS: ES Modules、
app.registerExtensionで ComfyUI に組み込み - WEB_DIRECTORY =
"./web"で静的ファイル配信
将来の課題
- Sam3Tool — SAM 3/3.1 AI セグメンテーション (HuggingFace gated model、要申請)
Why Phase 1 でリリース
コア機能を先にリリースして、Phase 2 の重い AI 統合は後回し。