- PスイッチタブのスロットボタンUI改善 — ボタンの並び順を変更し、ポーズ登録系ボタンを左に集約:
- 順序:
+MLP(黄)→+SLP(水色)→−LP→ スペース →+Slot→−Slot +MLPに黄色、+SLPに水色のテーマカラーを適用し、0° スロット操作時に使用するボタンが視覚的に区別しやすくなった
- 順序:
- アニメーションGIF出力 — キーフレームパネルのWebMボタン右隣に「🎞️ GIF」ボタンを追加。透過対応アニメーションGIFをエクスポート。
web/js/gif_encoder.jsを新規作成(NeuQuant カラー量子化 + GIF LZW エンコーダー + GIF89a フォーマット出力) - ヘルプにキーフレームアニメーションセクション追加 — 全3言語(日本語・英語・中国語)でキーフレームパネルの全ボタンの説明を追加
- GIF カラー量子化スケールバグ — NeuQuant 学習時にRGBピクセル値にネットワークスケール(
<< netbiasshift)を適用していなかったため、パレットが黒に近い値に収束しシルエット状になる問題を修正 - GIF 透過インデックスオーバーフロー —
map()が255を返したとき+1=256がUint8Arrayで0(透過インデックス)に切り捨てられ、白やパレット末尾の色が透過になる問題をMath.min(254, ...)で修正 - GIF アルファ処理改善 — 透過閾値を
alpha < 128からalpha < 32に引き下げ、32 ≦ alpha < 255の半透明ピクセルにアルファ合成(プレマルチプライ)を適用してフチの欠落を低減 - PSW追加/削除後のレイヤータブ表示更新漏れ —
_createPswLayer()/_deletePswLayer()に_renderTree()呼び出しを追加し、LSWと同様にボタン操作直後にレイヤーリストが更新されるよう修正
- レイヤータブ ボタン名変更 — 「SW追加」「SW削除」→「LSW追加」「LSW削除」に変更(i18n.js 全3言語)
- ヘルプのLSWボタン名修正 — ヘルプ内の「SW追加/削除」表記を「LSW追加/削除」に統一(全3言語)
- PSW ON/OFF 状態の保存・復元を全箇所に対応:
- ポーズ保存(通常・SW付き):
pswEnabledを保存データに追加。ロード時にnode._pswEnabledを復元しボタン色を更新 - モデル保存:
configObj.pswEnabledを追加 - モデルロード:
data.layer_config.pswEnabledをnode._pswEnabledに復元 - 確定ボタン(onApplyConfig): 既存
layer_configのpswEnabledを新configObjに引き継ぎ - キーフレーム: 各フレームに
pswEnabledを保存・ステップ補間で復元(前バージョンより) - プロジェクト(layer_config): トグル操作時に
layer_configへ書き込み、onConfigureで復元(前バージョンより)
- ポーズ保存(通常・SW付き):
- PSW ON/OFF 状態をキーフレームに保存 — キーフレーム登録時に
pswEnabledフラグを保存し、シーク・再生時に復元- PSW トグルの ON/OFF がキーフレームごとに切り替わるアニメーションが可能
pswEnabledはステップ補間(直前のキーフレームの値を次のキーフレームまで保持)node._updatePswToggleを外部参照可能にし、シーク時にボタンの青/赤表示を即時更新
- PSW ON/OFF トグルボタン(ノード) — ノードの Capture ボタン左に
PSWトグルボタンを追加:- 青(ON): PSW プリセットポーズを有効化(デフォルト)
- 赤(OFF): PSW を無効化。PSW 登録レイヤーも含めて全レイヤーを R/MR で自由操作可能
- モーダルプレビューにも即時反映(
this.node._pswEnabled参照)
- ヘルプ(3言語)更新 — PSwitch タブに「PSW トグル」の説明行を追加
- PSWポーズ登録ワークフロー刷新:
- +MLP / +SLP は 0° スロット専用 — 0° 以外のスロットでは何もしない。0° スロットで登録するレイヤー(群)を確定するための操作として明確化
- 0° 以外のスロットは ✏編集 → ✓確定 フローで保存 —
✏編集でスロットの登録済みポーズ(未登録時は 0° スロットのポーズ)をstate.poseにロード、ポーズ調整後に✓確定で 0° スロットのレイヤー群に現在のポーズを書き戻す
- +Slot / −Slot ボタン名 — PSW スロット追加/削除ボタンを
+/−から+Slot/−Slotにリネーム - PSW 可動域ロック修正 —
(slots.length - 1) × PSW_STEPを最大角度に変更。1スロット=0°固定、2スロット=0°〜30°、3スロット=0°〜60° - セットアップモードのガイドアーク修正 — アーク描画範囲を
PSW_STEP × n(旧: スロット数+1分)からPSW_STEP × (n-1)(最終スロットまで)に修正 - ヘルプダイアログ(3言語)更新 — PSW セクションを新ワークフローに合わせて全面改訂。+Slot / −Slot、+MLP/+SLP の 0° 専用化、✏編集→✓確定 の手順を明記
- +SLP のレイヤー未選択エラーが 0° 以外のスロットで出続ける —
_lastSlpLayerIdが null のとき 0° スロットに登録済みのレイヤーを自動フォールバックとして使用するよう修正 - スロット行クリック時に編集モードが終了しない —
slotRow.onclickで_pswEditingPointId = nullを設定し、他スロット選択時に自動的に編集モードを終了
-
+MLP / +SLP ポーズ登録の分割 — Pスイッチタブのポーズ登録ボタンを2種類に分割:
- +MLP(マルチレイヤーポーズ)— 従来の
+psetと同じ動作。現在の全レイヤーポーズをスロットに一括登録 - +SLP(シングルレイヤーポーズ)— レイヤーツリーで選択中の1レイヤーのポーズのみをスロットにマージ登録。既存の他レイヤーポーズは保持される
- −LP — 従来の
-psetをリネーム。スロットのポーズをクリア
- +MLP(マルチレイヤーポーズ)— 従来の
-
+SLP レイヤー記憶 —
_lastSlpLayerIdで最後に使ったレイヤーIDを保持。0°スロットでレイヤーを選択して登録した後、30°・60°… のスロットではレイヤー再選択なしで同じレイヤーに +SLP 可能。レイヤーを選択し直すと上書き更新される -
PSWハンドルの可動域ロック — Pose モード・ノードキャンバス両方で、PSW ハンドルの回転を
[0°, 登録スロット数 × 30°]の範囲にクランプ。3スロットなら0°〜90°、スロット追加で自動的に可動域が広がる。Setup モードでの初期配置ドラッグはクランプ対象外
- レイヤータブ:
[SW]→[LSW]— LSW レイヤーのプレフィックス表示を全3言語で変更 (i18n.js) - LSW / PSW 排他選択 — レイヤータブで LSW レイヤーを選択すると PSW の選択が解除され、PSW レイヤーを選択すると LSW・通常レイヤー選択が解除される
- LSW / PSW カラーテーマ変更 — レイヤータブ・Lスイッチタブ・Pスイッチタブで色を統一:
- LSW:
#a78bfa(紫)→#55aaff(青)、ボーダー#7c3aed→#2266cc - PSW:
#cc88ff(薄紫)→#ff7766(赤)、ボーダー#9944cc→#cc3344
- LSW:
- PSWスロット表示の改善:
- 未登録:
未登録→− - SLP登録(1レイヤー):
✓ 登録済み (1レイヤー)→✓ : レイヤー名(state.renamed優先、なければ PSD 元名) - MLP登録(複数レイヤー):
✓ 登録済み (Nレイヤー)→✓ (Nレイヤー)
- 未登録:
- PSWキーフレーム対応 — キーフレームシステムが PSW ハンドル角度(
psw_angles)を記録・補間・復元するように対応:addKeyframeAtCurrentFrame:psw_layersの各ポイント angle をpsw_anglesとしてキーフレームに保存_kfGetInterpolatedState:psw_anglesをフレーム間で最短回転パス補間seekToFrame: 補間済みpsw_anglesをconfig.psw_layersの各ポイントに反映
- PSWレイヤー名変更ができない —
_renderPswTabでクリックハンドラが即座に_renderPswTab()を呼ぶためダブルクリックイベントが届かなかった。クリック処理を 260ms タイマー遅延に変更し、レイヤー名を<span>要素として保持することでインライン編集を有効化 - レイヤータブで PSW レイヤー名を変更できない —
_mkPswLayerElに dblclick リネームを追加(SW レイヤーと同パターン) - ポーズ保存(右クリック)に PSW ハンドル角度が含まれない — モーダルの
_savePoseWithSwFromModalとノードのポーズスナップ右クリック両方でpsw_anglesを収集・保存するよう修正 - ポーズロード時に PSW ハンドル角度が適用されない —
_loadPoseの通常ポーズ復元ブロックにpsw_angles→psw_layers[].points[].angleへの反映処理を追加
- ヘルプダイアログ — 3言語すべて更新:
- レイヤータブ: PSW レイヤー名変更の説明を追加
- Pose モード: 「ポーズ保存 右クリック」の説明を "SW 状態込み" → "LSW/PSW ハンドル状態込み" に修正
- README 3言語 — キーフレーム補間表に
PSW ハンドル角度行を追加、layer_configスキーマのkeyframesエントリにpsw_anglesフィールドを追加 - README 3言語 — トラブルシューティングセクションを新設: psd-tools が出力する
Unknown image resource/Unknown tagged blockの INFO メッセージは無害であることを説明
スイッチ(SW)機能をレイヤースイッチ(LSW)とポーズスイッチ(PSW)に分割。LSW は従来の「ハンドル角度でグループ表示を切り替える」機能をそのまま継承。新規追加の PSW はハンドル角度に応じて登録済みポーズ(R/MR の状態)を自動適用するスイッチで、30°刻みのスロットにポーズを登録し、最大12スロットまで対応。
-
PSW(ポーズスイッチ)ポイント — 白いオリジン + 紫のハンドル。ハンドルを回転させて登録済みポーズスロットを切り替える:
- 配置: Setupモードで PSW ボタン(初回押下時にPSWレイヤーを自動作成)→ キャンバスをクリック
- スロット管理:
+で30°刻みのスロットを追加(最大12スロット)、−で削除 - ポーズ登録: Poseモードで姿勢を決めてスロットを選択 →
+psetで現在のポーズを登録、−psetでクリア - ポーズ適用: ハンドル角度が 0°〜30°→スロット0、30°〜60°→スロット1… と切り替わる
- 複数PSWポイントは独立して動作し、それぞれのポーズが合成される
-
computePswEffectivePose(basePose, pswLayers, editingPswPointId)— PSWポイントの角度からアクティブスロットを特定し、ベースポーズに登録ポーズを合成して返す。モーダルプレビューとノードキャンバス両方で使用。editingPswPointIdを指定するとそのポイントのオーバーライドを無効化(+psetで編集中にR/MRの変化をリアルタイム確認可能) -
Pスイッチ(PSW)タブ — セットアップモーダルに4番目のタブとして追加:
- 上段: PSWレイヤー/ポイントリスト(選択・リネーム・削除ボタン付き)
- 中段: スロットリスト(登録済み/未登録の状態表示、クリックでアクティブスロット変更・ハンドルが対応角度に移動)
- 下段:
+/−/+pset/−psetの4ボタン
-
PSWボタン — Setup バーに「PSW」ボタンを追加(紫系テーマ)。押下時にPSWレイヤーが存在しなければ自動作成し即座にポイント配置可能
-
ノードキャンバス対応 — ノードキャンバスでもPSWポイントのヒットテスト・ハンドルドラッグ・ポーズ適用に対応
-
i18n — PSW関連翻訳キーを ja / en / zh に追加:
tabPSwitch,addPswLayer,deletePswLayer,deletePswPointTooltip,addPswSlotTooltip,deletePswSlotTooltip,setPswPoseTooltip,clearPswPoseTooltip,maxPswSlotsReached
- スイッチタブ名変更 — "スイッチ" → "Lスイッチ" (ja) / "LSwitch" (en) / "L切换" (zh)
- SWボタン — セットアップバーの "SW" ボタンを "LSW" にリネーム
- ヘルプダイアログ — 3言語すべて更新: スイッチ→Lスイッチ(LSW)にリネーム、Pスイッチ(PSW)セクション新規追加、Setup/Poseモードに LSW/PSW の説明を追加
- PSWボタン押下後にポイントを配置できない — ボタンはモードを
pswに変更するだけで_selectedPswLayerIdが未設定のままキャンバスクリックがパンになっていた。PSWボタン onclick に「レイヤーが無ければ自動作成、あれば先頭を選択」ロジックを追加
psw_layers がない既存設定は従来通り動作(後方互換)。
- 別PC移行時に Setup が開けない (
openLayerModal) — ワークフローを別PCに持ち込んだ場合など、psd_filenameウィジェットに値があるが PSD ファイルがinput/psd/に存在しないとき、「レイヤー情報の取得に失敗しました」アラートを表示した後returnでモーダル表示をブロックしていた。returnを削除し、PSD が見つからなくてもモーダルを空レイヤー状態で開くように修正。モーダルが開けば「Open PSD」ボタンから PSD を再選択・アップロードできる。
-
キーフレーム移動トグル (
↔) — Row A に↔ボタン(kfMoveKeyBtn)を追加。ON 状態でタイムラインのドラッグがキーフレームを移動する(プレイヘッドは無効化)。OFF 状態は従来通りプレイヘッドをドラッグ。ON 時はボタンが紫系ハイライト(background:#3a2a4a; border:#8a5a9a; outline:1px solid #aa77cc)で状態を示し、ダイヤモンドマーカー 12px 以内でカーソルがgrabになる。ポーズのみ・カメラのみ・両方を含むどの KF もまとめて移動する(frameフィールドを書き換えてlayer_config.keyframesを即時更新)。移動先に既存 KF がある場合は上書き -
新関数
moveKeyframe(node, fromFrame, toFrame)— 指定フレームの KF エントリ全体を別フレームへ移動。移動先に既存 KF があれば先に削除してから追加しソート。layer_config.keyframesとタイムラインキャンバスを更新
-
Row A / Row B レイアウト変更 — 再生・停止ボタンを Row A から Row B へ移動し、操作グループを整理:
- Row A から
sep2・▶(再生)・■(停止)を削除 - Row A 最終形:
[+KF] [🗑KF] | [+CK] [-CK] | [↔] | [0] [◀] [f] / [t] [▶(次フレーム)] [🎬 WebM]を Row B の[💾 Proj]の直右隣に移動(margin-left:autoを除去)- Row B に
flex:1スペーサー (rowBSpacer) を追加し、右端に[▶(再生)] [■(停止)]を配置 - 再生ボタンに
min-width:54px(通常の約 2 倍幅)を追加 - Row B 最終形:
[New] FPS [24] [💾 Proj] [🎬 WebM] ←flex:1→ [進捗] [▶▶▶] [■]
- Row A から
-
i18n —
kfMoveKeyBtn/kfMoveKeyTooltipを ja / en / zh に追加
キーフレームアニメーションシステムと WebM 動画エクスポートを追加。⏱ ボタンでキーフレームパネルを展開し、ポーズ / カメラを独立してフレームに記録、タイムラインをクリック / ドラッグでスクラブ、補間再生プレビュー、WebM 形式での動画書き出しができる。カメラキーフレームはポーズとは独立した補間ロジックで処理される。キーフレームプロジェクトはライブラリのポーズ欄に保存・再読み込みでき、layer_config.keyframes に永続化されるため ComfyUI のワークフロー保存にも対応。
-
ポーズキーフレーム記録 (
+KF/🗑KF) — 現在フレームにポーズを記録・削除。位置 (tx/ty)・角度・可視性・SW 角度を保存。カメラは含まない(後述の+CKで個別に記録) -
カメラキーフレーム記録 (
+CK/-CK) — 現在フレームにカメラ状態(zoom / x / y / roll)を個別に記録・削除。同フレームにポーズ KF があればcameraフィールドを追加 / 更新し、なければカメラのみのエントリを新規作成。-CKはカメラフィールドだけを削除しポーズデータは保持する -
タイムライン UI (
⏱ トグル) — ノードの row3 に ⏱ ボタンを追加。展開時にキーフレームパネルを表示:- Row A:
[+KF] [🗑KF] | [+CK] [-CK] | [0] [◀] [フレーム番号] / [総フレーム数] [▶] | [▶ 再生] [■ 停止] - タイムラインキャンバス (34 px) — フレームトラック + ◆ マーカー + 再生ヘッドを描画。クリック / ドラッグでスクラブ
- Row B:
[New] FPS [入力] | [💾 Proj] | [進捗表示] | [🎬 WebM]
- Row A:
-
タイムラインマーカーの色分け — キーフレームの種別を ◆ の色で識別:
- 黄(
#ffdd44)— ポーズのみ - 紫(
#cc66ff)— カメラのみ - 緑(
#44ee88)— ポーズ+カメラ両方 - 現在フレームのマーカーは 1px 大きく同色グローを表示
- 黄(
-
0ボタン(フレーム 0 ジャンプ) — Row A のナビゲーション左端に配置。seekToFrame(node, 0)を呼ぶ -
Newボタン(全 KF クリア) — Row B の FPS 左隣に配置。confirm ダイアログ後にnode._keyframes = []/layer_config.keyframes削除 / フレーム 0 にリセット。誤操作防止のため Row B(操作頻度の低い行)に配置 -
フレーム補間 — キーフレーム間を自動補間:
- 位置 (tx/ty): 線形補間 (lerp)
- 角度 / SW 角度 / カメラ roll: 最短回転パスで補間 (shortest-path angle lerp)
- カメラ zoom / x / y: 線形補間
- カメラ補間はポーズ KF の位置とは独立し、
cameraフィールドを持つ KF のみを対象に前後を探して補間する - 可視性: 直前のキーフレーム値を維持(ステップ)
-
再生プレビュー —
▶ボタンで RAF ベースの再生ループ。FPS フィールドで速度を調整(デフォルト 24 fps)。再生中は■で停止 -
WebM 動画エクスポート (
🎬 WebM) —canvas.captureStream(0)+track.requestFrame()+MediaRecorderでフレームを逐次レンダリングしてエンコード。Chrome / Edge 推奨(VP8 コーデック)。カメラキーフレームはrenderToOutputCanvasがnode._cameraを参照するため自動的に WebM に反映される。進捗をテキストで表示 -
プロジェクト保存 / 読み込み (
💾 Proj) — キーフレームデータ(フレームリスト・総フレーム数・FPS)をライブラリのポーズ欄に保存。ファイル名デフォルトはproject-YYYYMMDDHHMMSS(プロンプトで変更可)。ライブラリから読み込み時に_type: "kf_project"フィールドで自動判別し、フレーム 0 のポーズをキャンバスに適用してタイムラインを復元 -
キーフレームのワークフロー永続化 — キーフレームは
layer_config.keyframesに保存。ComfyUI のワークフロー保存 / 読み込みで自動復元。PSDModal._apply()による設定上書き時もキーフレームが保持される (onConfigureフックで_keyframesを再展開) -
新定数
KF_PANEL_H = 98— キーフレームパネルの高さ定数をcomputeSizeとbuildKeyframePanelで共用 -
i18n キー追加 (
i18n.js) —kfPanelBtn,kfPanelTooltip,kfAddBtn,kfAddTooltip,kfDelBtn,kfDelTooltip,kfPlayBtn,kfStopBtn,kfExportBtn,kfExportTooltip,kfExporting,kfFpsLabel,kfNoKeyframes,kfSaveProjBtn,kfSaveProjTooltip,kfProjNamePrompt,kfGoToZeroBtn,kfGoToZeroTooltip,kfClearBtn,kfClearTooltip,kfAddCamBtn,kfAddCamTooltip,kfDelCamBtn,kfDelCamTooltipを ja / en / zh に追加
-
KF パネル展開時のノード高さ崩れ (
psd_loader.js) —nodeType.prototype.computeSizeがハードコードのUI_WIDGET_H + 80を返していたため、ComfyUI がレンダリングサイクルごとにノード高さを旧値に戻し、パネルが見えなくなっていた。_kfPanelVisibleフラグを参照してconst kfExtra = (this._kfPanelVisible ? KF_PANEL_H : 0)を加算するよう修正 -
カメラのみ KF でシーク / 再生がクラッシュしてループが止まる (
seekToFrame) —_kfGetInterpolatedStateがカメラのみ KF をそのまま返すとstate.pose/state.sw_angles/state.visibilityがundefinedになる。JSON.parse(JSON.stringify(undefined))が SyntaxError をスローし RAF ループが停止していた。各フィールドにundefinedガードを追加(state.pose !== undefinedのときのみ適用、state.sw_angles/state.visibilityは&&でガード) -
+KFでポーズを上書きするとカメラ KF が消える (addKeyframeAtCurrentFrame) — 同フレームの既存 KF を.filterで丸ごと削除してから新 KF を追加していたため、cameraフィールドが失われていた。上書き前にexistingKf = node._keyframes.find(k => k.frame === frame)を取得し、existingKf?.cameraがあれば新 KF に引き継ぐよう修正 -
🗑KFがカメラ KF も削除する (deleteKeyframeAtCurrentFrame) — 丸ごと削除していたため同フレームのカメラデータも失われていた。ポーズ系フィールド(pose/sw_angles/visibility)のみ除去しcameraは保持するよう修正。cameraもなくなった場合のみエントリを削除する
// layer_config に追加されるキーフレームフィールド
{
"keyframes": [
{
"frame": 0,
"visibility": { "<layerId>": true },
"pose": { "<layerId>": { "angle": 0, "tx": 0, "ty": 0 } },
"sw_angles": { "<pointId>": 0 },
"camera": { "zoom": 1.0, "x": 0, "y": 0, "roll": 0 } // +CK で記録、省略可
}
],
"kf_total_frames": 60,
"kf_fps": 24
}keyframes がない既存の設定は従来通り動作(後方互換)。camera フィールドがない KF ではカメラは変化しない。
Post-release code review of the v2.21–v2.23 work (clipping-mask support, +L/+P/+C split) surfaced nine findings; eight are fixed here. The ninth — in-place entry-type conversion between layer and group entries, removed by the +L/+P/+C dropdown split — is accepted as a design tradeoff. Highlights: clipping stacks now work at the document root and inside custom groups (previously only inside PSD folders), layer masks are applied again in canvas layer images, hidden clipping layers are respected by server-side compositing, and the clipping-stack offscreen canvas is cached instead of re-allocated per frame. Additionally, the output-size widgets now step in 1 px increments.
-
Root-level / custom-group clipping stacks (
psd_loader.js) — clipping stacks were only assembled byrenderChildren, which is reached exclusively for PSD folder children. Root-level layers (both thecg_orderpath and the PSD-order fallback) and custom-group members were drawn one-by-one viarenderOneNode/renderCg, so clipping layers outside folders rendered unclipped (fully visible). New helperrenderIdList(ids, skipCgMembers, skipCgMemberEntries)groups consecutive plain nodes into runs and feeds them throughrenderChildren, so stacks form on every path:cg_orderpath →renderIdList([...cgOrder].reverse(), true, true)renderCg(custom-group members) →renderIdList([...cg.layer_ids].reverse(), false, false)- fallback path →
renderChildren(layers, false)directly renderClippingStackalso gained the previously missingskipCgMembers && cgMemberIds.has(base.id)guard on the base layer, matchingrenderOneNodebehavior.
-
Hidden clipping layers baked into server-side composite (
psd_utils.py) —_manual_composite/_manual_composite_orderedskipped clipping layers before consultingeffective_vis, and the base layer'scomposite()bakes clip layers in regardless of config — so a clipping layer hidden in the UI still appeared in/psd_loader/previewoutput (JS preview hid it correctly). Base composition now passes alayer_filterbuilt fromeffective_vis(_composite_single(layer, vis_fn)), so hidden clipping layers are excluded. psd-tools withoutlayer_filtersupport falls back to the previous behavior viaTypeErrorcatch. -
Layer masks lost in canvas layer images (
psd_utils.py::get_layer_image_by_id) — v2.21 switched totopil()to avoid clip-layer double-draw, buttopil()returns raw pixels without layer masks, effects, or opacity. Now usescomposite(layer_filter=lambda l: l is current), which applies masks while still excluding clipping layers. For clipping layers themselves, a solo composite produces a fully transparent image (nothing to clip to) — fixed by temporarily clearing theclippingflag during composite (restored infinally; verified alpha-identical totopil()on the sample PSD).
- Offscreen canvas cached (
psd_loader.js) —renderClippingStackallocated a full-sizedocument.createElement('canvas')per clipping stack per render pass;_drawPreviewruns on every mousemove during pose dragging, so this caused GC churn and frame drops. The canvas is now cached in module-scope_clipTmpCanvas, resized only when dimensions change, and cleared withclearRectper use.
output_width/output_heightstep 64 → 1 (psd_loader_node.py) — output size is now adjustable in 1 px increments (drag/arrow steps; was jumping by 64).countSwSlots=expandSwGroupEntries().length(psd_loader.js) — removed the duplicated composite/piece counting logic; slot expansion now has a single source of truth (verified equivalent across 7 entry-type cases).isOrphanedsimplified (_renderSwitchTab) — the composite/piece ternary collapsed to(isPsdGroup || isCustomGroup) && (leaves?.length ?? 0) === 0, which is equivalent for both modes.server.py::_build_layer_treedelegates topsd_utils.get_layer_tree— removed the duplicated layer-dict construction (the two copies had to be edited in lockstep, e.g. the v2.21clippingfield). Output verified identical._paste_layerhelper extracted (psd_utils.py) — the identical composite→RGBA→ bounds-check→paste block in_manual_compositeand_manual_composite_orderedis now shared.- Error logging — the bare
except Exception: passaround the layer-image composite now logs[psd_figure_creator] layer composite error (<name>): <e>instead of silently swallowing corrupt-layer failures. - Dead i18n key removed —
addGroupTooltip(ja/en/zh) had no remaining references after the v2.20 +L/+P/+C button split.
- Python (sample_1.psd): hiding clipping layer
6.1(irides-r) now changes both manual composite paths (3,360 px diff, matching the layer bbox); all-visible output is byte-identical before/after;get_layer_image_by_idreturns mask-applied, clip-free base images and non-empty clip-layer images. - JS (stub-DOM harness, 9 tests): clipping stacks form on root fallback /
cg_order/ custom-group paths;source-atopdraw order preserved; visibility overrides respected; canvas cache allocates once;countSwSlotsequivalence; non-clipping layers still draw directly to the main canvas.
Bundled a sample character PSD with a pre-configured model and two pose files so users can try the rigging system immediately after installation.
user_data/sample_1.psd— sample character PSD (~2.3 MB).user_data/models/sample.psd-model.json— fully configured model forsample_1.psd:- R pivots on layers 2, 3, 4, 11
- MR points on layers 0, 1, 5, 9, 10, and sub-layers 6.1 / 7.1
- Parent hierarchy: layer 2 as root → 3 → 4 → children (eyes, mouth, accessories, etc.)
- SW layer
SW1with a composite-mode slot referencing group 7 and leaf layer 8
user_data/poses/pose1.pose.json— sample pose 1 (head tilt + eye/mouth movement).user_data/poses/pose2.pose.json— sample pose 2 (alternate expression).
user_data/models/anduser_data/poses/are loaded automatically by the library panel.user_data/sample_1.psdmust be copied toComfyUI/input/psd/manually before the model can be loaded (the model JSON referencespsd_filename: "sample_1.psd").
Two maintenance fixes: a DOM crash in inline-rename handlers, and a help-dialog gap where the Chinese Parent Tab section was missing entirely and all three languages lacked the clipping-layer caveat for parent setup.
- Inline rename
replaceWithcrash (psd_loader.js) — Four inline-rename handlers (SW point name, custom-group name, SW layer name,_inlineRename) all shared the same pattern:nameEl.replaceWith(input)on double-click, theninput.replaceWith(nameEl)incommit(). Pressing Enter calledcommit()which removedinputfrom the DOM, then the resultingblurevent firedcommit()a second time, causingNotFoundError: Failed to execute 'replaceWith' on 'Element'. Fixed by addingif (!input.isConnected) return;at the top of everycommitclosure — idempotent once the input is detached.
- Help dialog — Parent Tab (
_showHelpinpsd_loader.js):- Added
⚠ クリッピングレイヤーrow to the Japanese ペアレントタブ section. - Added
⚠ Clipping Layersrow to the English Parent Tab section. - Added the entire 父级选项卡 section to the Chinese help — it was previously absent. The new section includes setup instructions and the same clipping-layer caveat.
- Caveat text (all languages): clipping layers (✂) only follow the base layer's transform if both layers share the same parent in the Parent tab; if only the base has a parent, the clipping layer stays at its original canvas position and the mask alignment breaks.
- Added
Full clipping-mask support across the canvas preview and Capture output. Clipping layers
(layers with the Photoshop "clip to layer below" flag) are now detected, propagated to the
frontend, and rendered using an offscreen-canvas source-atop compositing technique.
R/MR rigs placed on a clipping layer work correctly while the clipping shape is enforced by
the base layer's alpha. Additionally, double-rendering of clipping layers in the server-side
manual compositor is fixed.
-
clippingfield on layer-tree nodes —server.py::_build_layer_treeandpsd_utils.py::_build_layer_nodenow emit"clipping": true|falsefor every layer node. This was the root cause of clipping not working: the frontend received nodes without the flag, so all layers appeared as non-clipping. -
renderChildren(children, skipCgMembers)— new function insiderenderLayersToCtxthat replaces directfor...of n.childreniteration. It scans the child array and groups consecutive clipping layers with their base layer into a "clipping stack", delegating torenderClippingStackwhen clips are found. -
renderClippingStack(base, clipLayers, skipCgMembers)— offscreen-canvas compositor:- Creates a temporary canvas the same size as the main canvas.
- Copies the current camera transform to the offscreen context via
ctx.getTransform()/tmpCtx.setTransform(). - Temporarily swaps the closed-over
ctxvariable totmpCtx; all drawing functions (drawLeaf,applyRigTransform) automatically target the offscreen canvas. - Draws the base layer, then draws each clipping layer with
globalCompositeOperation = 'source-atop'(clips to base's opaque pixels). - Resets the transform and composites the offscreen canvas onto the main canvas 1:1.
- R/MR rigs on clipping layers are fully applied inside
drawLeafbefore thesource-atopclip is enforced, so both transform and clipping work simultaneously.
-
✂badge in layer panel —_mkLayerElappends a small✂span whennode.clippingis true, giving visual feedback in the Layers tab and the SW+Ldropdown. -
i18n key
clippingLayerBadgeadded to ja / en / zh for the badge tooltip.
-
get_layer_image_by_id(canvas layer fetch) — switched fromlayer.composite()tolayer.topil()for non-group layers.composite()embeds associated clipping layers into the base layer's image, causing the clipping layer to appear twice on the canvas and making R/MR rigs on those layers invisible.topil()returns raw pixel data only; clipping is handled entirely byrenderClippingStackat draw time. -
_manual_composite/_manual_composite_ordered— clipping layers are now skipped (if getattr(layer, "clipping", False): return). The base layer'slayer.composite()already includes its clipping layers, so iterating and drawing them separately caused double rendering in the server-side compositor (both routes:layer_orderand fallback).
renderLayersToCtxsignature — parameter renamed fromctxtoctxArg; internallet ctx = ctxArgallows the variable to be temporarily reassigned to the offscreen context insiderenderClippingStackwithout touching any call sites.renderOneNodegroup path — the two inlinefor (const child of n.children)loops are replaced byrenderChildren(n.children, skipCgMembers).
The single + button in the Switch tab is replaced by three distinct add buttons —
+L (Layer), +P (Piece), +C (Composite) — each producing a different slot entry
type. A new mode: 'composite' field on group/folder entries makes the entire group count
as a single slot whose member layers are composited together, contrasting with
mode: 'piece' (the existing per-layer expansion behavior). Each entry row now shows a
color-coded type badge and the dropdown is filtered to show only relevant options for
that entry type.
-
+L/+P/+Cbuttons — replace the single+button in the Switch tab bar:+L(Layer) — adds an individual PSD layer as a string entry (1 slot); dropdown shows leaf layers only+P(Piece) — adds a custom group or PSD folder group withmode: 'piece'(default expansion behavior: 1 slot per member/leaf layer); dropdown shows groups and folders only+C(Composite) — adds a custom group or PSD folder group withmode: 'composite'(entire group = 1 slot; all member layers rendered together); dropdown shows groups and folders onlymakeAddSwBtn(text, tipKey, mode)helper in_buildDOMcreates all three buttons- i18n keys added:
addLayerEntryTooltip,addPieceEntryTooltip,addCompositeEntryTooltip,noAssignableGroup(ja/en/zh)
-
mode: 'composite'entry field — new optional field onpsd_group/custom_groupentries inpt.groups:- Backward-compatible: entries without
mode(all v2.19 data) continue to behave as'piece' expandSwGroupEntries— composite branch returns a single slot{ id, entryIdx, mode:'composite', memberIds:[...] }instead of per-leaf entriescountSwSlots— composite entries always contribute 1, regardless of member countrenderLayersToCtxSW loop — composite slot activates/deactivates allmemberIdstogether via a loop overslot.memberIds; piece slot still controls a singleid
- Backward-compatible: entries without
-
Type badges in Switch tab entry rows (
[L]/[P]/[C]):- Rendered as a small colored
<span>to the left of the step-angle label - L → blue (
#89b4faon#2a2a4a), P → cyan (#89dcebon#152535), C → orange (#fab387on#3a2515)
- Rendered as a small colored
_addSwGroup(mode)— gainedmodeparameter ('layer'|'piece'|'composite'):'layer': pushes the first leaf layer ID (string) found inlayerTree'piece'/'composite': pushes{ type:'custom_group'|'psd_group', id, mode }preferring the first custom group, falling back to the first PSD folder group
_renderSwitchTabentry loop:slotCountset to1for composite entries (wasleaves.length)- Orphan detection for composite: checks
leaves.length === 0(group/folder empty or deleted) rather thanslotCount === 0 - Angle range label: range notation (
0°–60°) suppressed for composite (always single angle) - Dropdown options filtered by entry type: string entries show layers only; object entries show custom groups + PSD folder groups only
- Change handler preserves
entry.modewhen updating the selected group/folder:pt.groups[i] = { type, id, mode: entry?.mode ?? 'piece' }
// pt.groups entry (new mode field)
{ "type": "custom_group", "id": "...", "mode": "piece" } // +P: per-layer slots
{ "type": "psd_group", "id": "...", "mode": "composite" } // +C: 1 composite slotEntries without mode default to 'piece' (backward compatible with v2.19 data).
Four improvements: (1) thumbnails for model/pose/pose+SW saves are now always captured with rig labels and the output-size label hidden, then the UI state is restored; (2) the Switch tab now accepts PSD folder groups as expandable per-layer slot entries; (3) custom groups in the Switch tab also expand per-layer (matching PSD folder behavior); (4) modal pose-save thumbnails no longer show a dark outer frame caused by camera zoom state.
- Dark outer frame in modal pose-save thumbnails —
_savePoseFromModal()/_savePoseWithSwFromModal()computed_computeModalOutputFrame()under the current camera state; when the user had zoomed in, the output frame extended beyond the preview canvas bounds anddrawImagecaptured dark canvas background around the content:- Before capture:
savedCam = { ...this._previewCam }, then reset to{ ...this._defaultPreviewCam }so the output frame is always within canvas bounds - After capture: restore
this._previewCam = savedCamand call_drawPreview()to return to the user's camera state
- Before capture:
- Rig labels and output-size label visible in saved thumbnails — model, pose, and
pose+SW saves captured thumbnails while
node._showRigLabelsand the512 × 512frame label were still active; replacedcreateNodeCanvasNoLabel(offscreen canvas — incorrectly produced small/dark results) withcaptureThumbFromNode:- Save
node._showRigLabels; set tofalse - Call
drawNodeCanvas(node, { skipFrameLabel: true })— suppresses both rig labels and the output-size text - Crop and export; restore
node._showRigLabels; calldrawNodeCanvas(node)to redraw - Modal saves (
_savePoseFromModal,_savePoseWithSwFromModal) apply the same pattern usingthis._showRigLabels+this._drawPreview()
- Save
-
PSD folder groups as Switch entries (
[フォルダ]/[Folder]/[文件夹]):getPsdGroupLeaves(groupId, layerTree)— recursively collects all non-group descendant IDs from a PSD folder nodeexpandSwGroupEntries(groups, layerTree, customGroups)— flattenspt.groupsentries into a per-slot list;string→ 1 slot,{type:'psd_group'}→ N leaf slots,{type:'custom_group'}→ Nlayer_idsslotscountSwSlots(groups, layerTree, customGroups)— sums total slot count across all entries (dissolved entries contribute 0)- Switch tab dropdown includes
[フォルダ] <name>options for every PSD group node in the layer tree (recursive); stored as{ type: 'psd_group', id }inpt.groups - Angle display: 1 slot →
"30°", multiple slots →"0°–60°"range notation - Orphaned entry (PSD folder dissolved): red row background, ⚠ tooltip icon, angle shown
as
"-"; entry must be deleted manually - Max-slot guard (
> 12) applies across all entry types combined - i18n keys added:
psdGroupPrefix(ja/en/zh),swGroupOrphaned(ja/en/zh)
-
Custom groups as Switch entries (
[グループ]/[Group]/[组]) — expand per-layer:- Previously custom groups were stored as plain string IDs in
pt.groupsand treated as single slots with no actual rendering effect - Now stored as
{ type: 'custom_group', id };expandSwGroupEntriesexpands each custom group into per-layer_idsslots, identical to PSD folder expansion - Dropdown option values changed from
cg2.idtocg:<id>to distinguish from layer IDs; change handler recognizescg:prefix and writes{ type: 'custom_group', id }topt.groups[i] _addSwGroup()default new entry for custom groups is now{ type:'custom_group', id }instead of a bare string- Orphaned handling: if the custom group no longer exists in
this.state.customGroups,slotCount = 0→ shown as orphaned with ⚠ (same as PSD folder) renderLayersToCtxanddrawRigOverlayboth passcgList/customGroupstoexpandSwGroupEntriesso SW visibility switching works for all entry types
- Previously custom groups were stored as plain string IDs in
drawNodeCanvassignature — added{ skipFrameLabel = false, targetCanvas = null }options object; output-size label block is wrapped inif (!skipFrameLabel)guardcaptureThumbFromNode(node, thumbW, thumbH)— replaces the removedcreateNodeCanvasNoLabel; uses the "temporarily disable → draw → capture → restore" pattern onnode._nodeCanvas; used by all 5 thumbnail capture paths: model save (140×140), modal pose save fallback (140×auto), modal pose+SW save fallback, quick pose save (node contextmenu), quick pose+SW save (node contextmenu)
- Help dialog (
_showHelp) — all three languages (ja/en/zh) updated:- Switch tab section fully rewritten: entry types ([Group]/[Folder]/[Layer]), slot expansion behavior, angle range notation, 12-slot max, orphaned ⚠ warning
- Pose mode: note added that thumbnails are captured with labels hidden automatically
- Layer tab: custom group creation description mentions SW tab per-layer expansion
- Camera (modal preview): RC reset button row added
- Chinese (zh): Switch tab and Pose mode sections added (were previously absent)
Two improvements: (1) fixed a broken JS module import that prevented psd_pose_editor.js from
loading; (2) added a "?" help button to the PSD Model Editor footer that opens a scrollable
in-app help dialog in the user's language (ja/en/zh). Also added screenshots to all three
README files.
psd_pose_editor.jsfails to load on ComfyUI startup (vite:preloadError):- Line 1 used a relative import
../../scripts/app.jswhich resolves to/extensions/scripts/app.js(non-existent) instead of/scripts/app.js - Changed to absolute path
/scripts/app.js, matchingpsd_loader.jsconvention - The error appeared twice in the console because ComfyUI's Vite layer reported the
same failed fetch from two code paths (
console-listener.jsdirect +App.vue:73)
- Line 1 used a relative import
- In-app help dialog — "?" button added to the bottom-left of the PSD Model Editor footer:
- Uses
margin-right: autoon the button to left-align it while keeping all other footer buttons (Cancel/Save/Pose/Apply) right-aligned _showHelp()method onPSDModal: builds a scrollable overlay dialog with sections covering Basic Operations, Layer Tab, Parent Tab, Switch Tab, Setup Mode, Pose Mode, and Camera controls (modal vs. node preview distinguished separately)- Content fully translated: Japanese / English / Simplified Chinese, selected via
getLang() - Dialog closes on outside click or the "Close" button; rendered at
z-index: 10001(above the main modal at 10000) getLangadded to thei18n.jsimport inpsd_loader.js
- Uses
- Help dialog CSS (
.psd-help-dialog,.psd-help-header,.psd-help-body,.psd-help-section,.psd-help-section-title,.psd-help-table,.psd-help-term,.psd-help-desc) — dark-themed: blue section titles (#89b4fa), purple term cells (#cba6f7), muted description text (#bac2de) - i18n keys
helpBtn,helpTitle,helpCloseadded to ja / en / zh locales - README screenshots —
## Screenshotssection inserted between Features and Installation inREADME.md,README.ja.md,README.zh.md; includes all 7 images fromdocs/(1_node.png–7_capture.png, excludingthumb.png) with language-appropriate captions
- Camera operation descriptions corrected after code review:
- Roll is not available in the modal preview (
_previewCamhas norollproperty; right-drag in the modal pans, not rolls) - Roll is only available on the node preview canvas:
Alt + right-drag(setupPreviewInteraction→e.altKey && e.button === 2) - Camera reset button on the node is RC (not ↺ as initially written)
- Help now shows two separate camera sections: "Modal Preview" (zoom + pan only) and "Node Preview" (zoom + pan + roll + RC reset)
- Roll is not available in the modal preview (
Two maintenance updates: (1) plugin renamed to "PSD Figure Creator"; (2) i18n support added for Japanese, English, and Simplified Chinese.
- プラグイン名変更 — "PSD Loader" → "PSD Figure Creator":
psd_loader_node.py: クラス名PSDLoaderNode→PSDFigureCreatorNode、NODE_CLASS_MAPPINGS/NODE_DISPLAY_NAME_MAPPINGSのキー・値を更新web/js/psd_loader.js:NODE_TYPE = "PSDFigureCreator"、 extension 登録名"psd_loader.PSDFigureCreator"に更新psd_utils.py: ログラベル[psd_loader]→[psd_figure_creator]- 注意: 既存ワークフローの JSON に
"PSDLoader"が保存されている場合は"PSDFigureCreator"に書き換えが必要
Security hardening: path traversal prevention across all file-handling endpoints, and i18n (internationalization) support for Japanese / English / Simplified Chinese.
- パストラバーサル修正 (
server.py):/psd_loader/upload:field.filenameをPath(...).nameでサニタイズ後、save_path.resolve().is_relative_to(psd_dir.resolve())で境界チェックを追加/psd_loader/layers,/psd_loader/layer_image,/psd_loader/preview: クエリパラメータfilenameをPath(...).nameでディレクトリ成分除去/psd_loader/preview:widthパラメータをmin(..., 4096)で上限制限- エラーメッセージからファイルパスを除去(内部情報漏洩対策)
- パストラバーサル修正 (
psd_loader_node.py):psd_dir / psd_filename→psd_dir / Path(psd_filename).name
- library 系エンドポイント(
/psd_loader/library/...)は既存の".."チェックで対応済み
- i18n 対応 —
web/js/i18n.jsを新規作成:- 対応言語: 日本語 (
ja)・英語 (en)・簡体字中国語 (zh) navigator.languageで起動時に自動検出(zh*→ zh、ja→ ja、それ以外 → en)t(key, ...args)関数:{0}プレースホルダーで変数埋め込みに対応setLang(lang)/getLang()でプログラムからの言語切り替えが可能- 翻訳キー数: 約 65 キー(ボタン・ツールチップ・アラート・confirm・prompt・キャンバステキスト)
- 対応言語: 日本語 (
psd_loader.jsの全日本語文字列をt()に置換:alert()/confirm()/prompt()のメッセージ.textContent/.title/.placeholderの UI テキスト- キャンバスオーバーレイテキスト(外部背景接続中メッセージ)
- プレフィックス文字列(
[カスタム]/[SW]/[グループ]/[レイヤー])
Three UI improvements: (1) a New button in the setup modal file bar to start fresh without any rigging; (2) tab hover/active style unified across all three tabs; (3) pose save button right-click now saves switch state (SW angles) together with the pose instead of applying the last saved pose.
- 「✨ 新規」ボタン — セットアップモーダルのファイルバーに追加(PSDボタン左横):
- クリックで確認ダイアログ →
layer_configウィジェットを"{}"にリセット - リギング・ポーズ・SWレイヤー・SWポイントをすべてクリア
_rigModeを'normal'に戻し、_reloadFromNode()でUI再描画- PSDボタンと同じ
flex:1を付与し両ボタンが均等幅になることで PSD ボタン幅を実質半減
- クリックで確認ダイアログ →
_savePoseWithSwFromModal()— モーダルの「📷 ポーズ」ボタン右クリック用の新メソッド:sw_angles: {pointId: angle}をポーズファイルに追加して保存- SWポイントの角度のみ記録(x/y/radius などセットアップ座標は含まない)
- ペアレントタブの白線 —
mkTabBtnのインライン CSS をborder-left/right/top:noneからborder:noneに変更。ホバー時に.psd-btn:hoverのborder-colorが#6c7086に変化し bottom border が白線として見えていた問題を解消 - タブホバー挙動の不統一 — 上記修正により 3 タブすべてで
background変化のみが起きるよう統一
- タブ選択の赤枠表示 —
updateTabStyle()を更新し、アクティブタブにoutline: 2px solid #f38ba8を追加(outline-offset:-2pxでボタン内側に収める)。 非アクティブタブはoutline: none - ポーズ保存ボタン右クリックの動作変更 (ノード・モーダル共通):
- 変更前: 直前に保存した
node._poseSnapshots[0]をlayer_configに適用 - 変更後: 現在のスイッチ状態(SW角度)込みでポーズを名前付き保存
- 保存成功時フラッシュ色: 左クリック = 緑 (
#44ff88)、右クリック = オレンジ (#ff8844) で区別可能 - モーダルの「📷 ポーズ」ボタンに
contextmenuハンドラを追加して同様の右クリック保存に対応
- 変更前: 直前に保存した
_loadPose()でスイッチ状態を復元 — ポーズファイルにsw_anglesが含まれる場合、 現在のconfig.sw_layers内の各 SW ポイントのangleのみを上書き。 x/y/radius などのセットアップ情報は一切変更しないposeSnapBtn.title—"ポーズを保存(右クリックで適用)"→"ポーズを保存(右クリック: スイッチ状態込みで保存)"に更新
// pose ファイル(右クリック保存時のみ追加)
{
"sw_angles": {
"pointId": number // SW ポイントの現在角度(radians)。pointId = swl.points[i].id
}
}sw_angles がないポーズファイルは従来通り visibility / pose のみ適用(後方互換)。
SWレイヤーUIのバグ修正と、レイヤー並べ替えが描画に反映されない根本的な問題を修正。
- SWレイヤー行の▽トグル削除 — SWレイヤーは展開不要なためツリートグルを除去。行がフラット表示に統一
- SWレイヤー行のクリック選択改善 — 以前は🔀アイコン部分しか選択できなかった問題を修正。 行全体(名前テキスト含む)をクリックで選択可能に。名前はダブルクリックでリネーム(250ms タイマーで区別)
- SW選択と通常レイヤー選択の排他制御 — SWレイヤーを選択した状態で通常レイヤーをクリックすると
両方が選択状態になっていた問題を修正。
_mkLayerEl/_mkCustomGroupElのクリック時に_selectedSwLayerIdをクリアするよう変更 - SWグループにPSDレイヤーを直接割り当て可能 — スイッチパネルの+ボタンを押したとき
「カスタムグループがありません」エラーになっていた問題を修正。カスタムグループだけでなく
個別PSDレイヤーもSWグループに割り当て可能とし、セレクトに
[グループ] / [レイヤー]で表示 - 選択済みSWポイントのドラッグ操作改善 — スイッチパネルでSWポイントを選択しても
キャンバス上での移動操作ができなかった問題を修正。
_selectedSwPointInfoがある場合に sw_origin の 28px/zoom 以内のクリックでドラッグ開始するよう拡張 - レイヤー並べ替えが描画に反映されない問題を修正 —
renderLayersToCtxが常に PSD元順序(psd-tools 返却順)で描画していたため、UIでの並べ替えが表示に反映されなかった。config.cg_orderを使って描画順を制御するよう根本修正:nodeMapを構築し ID でノードを参照cgMemberIdsでカスタムグループ所属レイヤーを追跡(二重描画防止)cgOrderがある場合は[...cgOrder].reverse()順(下層→上層)で描画- カスタムグループ内も
layer_idsの逆順で描画 cgOrder未設定時は PSD 元順序にフォールバック
SWポイントの管理方式を全面刷新。従来は「カスタムグループごとにSWポイントを1つ配置する」仕組みだったが、SWレイヤーという専用コンテナを導入し、1つのSWレイヤーに複数のSWポイントを自由に配置できるようにした。SWポイントの座標もレイヤー相対からPSD絶対座標へ移行。
- データ構造変更 —
config.sw({layerId: {...}}) を廃止しconfig.sw_layers([{id, name, points:[{id, name, x, y, radius, angle, groups:[cgId]}]}]) に変更- SWポイントはPSD絶対座標(旧: 配置レイヤーからの相対座標)
LayerState.sw→LayerState.swLayerstoConfig()がsw_layersを出力、fromConfig()が読み込む- 旧形式
config.swはfromConfig時に自動変換(後方互換)
- SWレイヤー作成・削除ボタン — レイヤータブ下部のボタンバー(グループ解除ボタン右隣)に 「SW追加」「SW削除」ボタンを追加
- SWレイヤーのレイヤーリスト表示 — レイヤーリスト先頭に紫ボーダー付きの
[SW] <名前>行として表示。クリックで選択(ダブルクリックでリネーム) - 複数SWポイントの配置 — SWレイヤーを選択 → SWボタン → キャンバスクリックでSWポイントを配置。 配置のたびにSWボタンが自動解除され、再度押すことで次のポイントを追加できる
- SWポイント選択のスイッチパネル対応 — スイッチパネルに「SWレイヤーヘッダー + 配下のSWポイント一覧」を表示。 クリックでSWポイントを選択(ダブルクリックでリネーム)、選択中ポイントのgroupsリストを展開表示
_mkSwLayerEl()— SWレイヤー行DOM生成メソッド_createSwLayer()/_deleteSwLayer()— SWレイヤーの追加・削除
drawRigOverlay()— シグネチャswData={}→swLayers=[], selectedSwPointInfo=null。 SWポイント描画ループをswLayers[].points[]に変更。選択判定をselectedSwPointInfoで行うhitTestRig()— シグネチャswData={}→swLayers=[]。 戻り値のSWポイントヒットが{layerId, type}→{swLayerId, swPointId, type}に変更renderLayersToCtx()— SWによるカスタムグループ表示切り替えをconfig.sw_layersに対応_setupRigInteraction()— setupモードでのSW配置フローを変更: SWレイヤー選択 + SWボタン + キャンバスクリック →_placeSwPoint(wx, wy)呼び出し_placeSwPoint()— シグネチャ(layerId, wx, wy, entry)→(wx, wy)。_selectedSwLayerIdのSWレイヤーへ絶対座標でポイントを追加後、SWボタンを解除_renderSwitchTab()— swLayers構造でSWレイヤー/SWポイントを階層表示_addSwGroup()/_removeSwGroup()—_selectedSwPointInfoを使ってgroupsを操作- ノードキャンバス (
drawNodeCanvas) —config.sw→config.sw_layersに対応。 SWハンドルドラッグ時のpivot計算をlayerEntry依存から絶対座標に変更
Three additions: (1) background color picker and local image file input on the node canvas;
(2) optional background_image IMAGE input port for compositing an upstream node's image
as the background layer; (3) fix to SW point name inline-rename (double-click was broken
by the row click handler re-building the DOM before the second click registered).
- SW point name rename broken —
row.addEventListener("click")called_renderSwitchTab()on every click, rebuilding the DOM before thedblclickevent onnameElcould fire; fixed with a 250 ms timer pattern (same as the layer tree rename):nameElclick: start a 250 ms timer → on expiry, toggle selection and re-rendernameEldblclick: cancel the timer, keep_selectedSwLayerIdset, open inline inputrowclick handler skips whene.target === nameEl(nameEl owns its own selection logic)
- Background color picker —
BGlabel +<input type="color">+✕clear button added below the Point Size slider in the node canvas widget; selecting a color setsnode._bgColorEnabled = trueandnode._bgColor; clears local background image if set;✕sets_bgColorEnabled = false; canvas redraws immediately - Background image (local file) —
🖼 画像button + hidden<input type="file" accept="image/*">+✕clear button; file is read withFileReader.readAsDataURL()and stored as anImageobject innode._bgImage; button label updates to the filename (up to 12 chars); selecting a file clears_bgColorEnabled;✕sets_bgImage = null - Background image (ComfyUI node input) — optional
("IMAGE",)input portbackground_imageadded toPSDLoaderNode.INPUT_TYPES; when connected, the upstream image is composited as the bottommost layer (letterbox resize, aspect-ratio preserved) via the new_composite_on_bg()helper; applied in both theimage_data(Capture) path and the server-side compositing path - External background indicator — when
background_imageinput has an active link,onConnectionsChangehook shows🔗 外部接続中label in the BG control row; canvas displays a dark-green tinted placeholder with text"🔗 外部背景画像接続中 / Queue Prompt で反映されます"instead of the checker pattern
drawNodeCanvas()background priority — checker pattern is now the last fallback; priority: local image (_bgImage) → local color (_bgColorEnabled) → external connection indicator → checkercaptureNode()background — same priority order applied to the temp canvas before camera-transformed PSD layers are drawn; local background is baked into the captured PNGpsd_loader_node.py—_composite_on_bg()helper — module-level helper function encapsulates background compositing (tensor → PIL → letterbox resize → alpha_composite); called in both theimage_databranch and the server-side branch;background_imageis now applied even whenimage_data(Capture) is present, so the composited result always includes the upstream backgroundIS_CHANGED— includesstr(background_image.shape)in the hash key so the node re-executes when the upstream image dimensions or content changesPREVIEW_WIDGET_H—PREVIEW_IMG_H + 62(484 px) →PREVIEW_IMG_H + 88(510 px) to accommodate the new BG control row (+26 px);UI_WIDGET_H544 → 570 px
Two improvements: (1) bug fix — rig points were invisible after loading a model from the
Library or from the Setup modal's 📂 model button; (2) new SW (Switch) point type that
toggles the visibility of registered custom groups by rotating a handle, allowing multi-state
layer switching (e.g. facial expression sets) without manual eye-toggle clicks.
- Rig points not shown after Library model load —
_loadModel()setnode._layerImages = nullthen calledrefreshNodePreview(), which fell into the_compositeImgbranch ofdrawNodeCanvas()(no_layerImages→ no rig overlay); fixed by awaitingloadLayerImages()directly inside_loadModel()and callingdrawNodeCanvas()after_layerImagesis populated - Rig points not shown after Setup modal model load —
_reloadFromNode()called_initPreview()→_drawPreview()while_rigModewas still'normal'(rig overlay guard:if (this._rigMode !== 'normal')), so points were invisible until the user manually clicked Pose/Setup; fixed by checkingconfig.riggingkeys after state rebuild: if rigging exists and_rigMode === 'normal', automatically switch to'pose'mode and call_updateRigModeUI()before the preview draw
-
SW (Switch) point type — new rig point that controls group visibility via angle:
- Visual: green origin dot connected to a cyan handle by a green line; handle displays the active group index; selection ring (yellow) on origin in setup mode
- Setup arc overlay — when the layer is selected in setup mode, a dashed green arc
shows the full angular range (
30° × ngroups), with tick marks at each 30° step - Placement: select the SW button in the setup bar, pick a layer, click canvas to place the origin; drag the cyan handle to set radius and initial angle
- Pose mode: dragging the cyan handle changes
swInfo.angle; no radius change in pose mode (radius is setup-only) - Group switching logic (
renderLayersToCtx): for each SW point, computesactiveIdx = floor(normalizedAngle / 30°)wherenormalizedAngle = angle mod (30°×n); the active group is unhidden (even ifvisible:false), all other registered groups are hidden; SW override takes priority overcg.visiblebut not over unregistered groups - Maximum 12 groups per SW point (12 × 30° = 360°)
-
SW button in Setup bar — green-tinted
SWbutton added to the right ofMR; sets_setupPointType = 'sw'; highlighted with a green outline when active -
Switch tab — third tab added to the right panel (レイヤー | ペアレント | スイッチ):
- Lists all SW points by name; click to select; double-click to rename inline; ✕ to delete
- Selected SW expands to show its registered groups as rows with
angle°label and a dropdown selector (shows all custom groups) +button appends a new group slot (dropdown defaults to first CG);−removes the selected slot; limit 12 slots per SW
-
_nextSwName()— generates unique auto-incremented names (sw1,sw2, …) -
_placeSwPoint()— places or moves the SW origin for the selected layer -
_renderSwitchTab()— renders the switch tab list and group slot rows -
_addSwGroup()/_removeSwGroup()— add or remove a group slot from the selected SW
LayerState— addedsw = {}property; included intoConfig()assw:key and restored infromConfig()fromconfig.swdrawRigOverlay()— addedswData = {}as the last parameter; draws SW points after all R/MR points; call sites indrawNodeCanvas()andPSDModal._drawPreview()updated to passconfig.sw || {}; early-return guard expanded to also allowswDataentrieshitTestRig()— addedswData = {}as the last parameter; tests cyan handle (sw_handle) in both modes, and origin (sw_origin) in setup mode only; returns{ layerId, type: 'sw_handle' | 'sw_origin' }; all call sites updated_setupRigInteraction()(modal):- Setup mousedown: SW button active →
_placeSwPoint()on canvas miss; hit onsw_handleorsw_origin→setup_drag; mousemove handlessw_handle(radius + angle) andsw_origin(translate origin) - Pose mousedown:
sw_handlehit →sw_pose_dragdrag info with pivot at SW origin - Pose mousemove:
sw_pose_drag→swInfo.angle = atan2(wy−pivotY, wx−pivotX)
- Setup mousedown: SW button active →
setupPreviewInteraction()(node canvas):- Mousedown:
sw_handlehit →isRigDrag = true,rigDragInfowith SW pivot stored - Mousemove:
hit.type === 'sw_handle'branch updatesswInfo.anglethen writes updated config to the widget and redraws;config.riggingaccess guarded with|| {} - Hit-test guard changed from
if (config.rigging)toif (config.rigging || config.sw)
- Mousedown:
{
"sw": {
"layerId": {
"name": "sw1", // display name (auto-incremented)
"x": 100, // origin X in layer-local coords
"y": 200, // origin Y in layer-local coords
"radius": 80, // distance to cyan handle (setup-editable)
"angle": 0, // current angle in radians (pose-editable)
"groups": ["cg_a", "cg_b"] // registered CG IDs; index 0 = 0°, 1 = 30°, …
}
}
}Field is optional; configs without sw behave identically to prior versions.
Pose thumbnail display and save accuracy improvements. The Library pose grid now renders thumbnails at the correct aspect ratio and responds correctly to S/M/L size switching. Setup modal pose save now captures from the modal's own live preview instead of the (potentially stale) node canvas. A green dashed frame overlay is always visible in the Setup modal preview to mark the output crop region.
- Pose thumbnail grid layout — added
align-items:centerto each pose card (flex-direction:column); without it,thumbBoxstretched to fill the grid-cell width while height stayed atpx, causing a non-square distorted frame. S/M/L switching now correctly resizes both the grid columns and each card's thumb container - Pose thumbnail aspect ratio on save — changed fixed 70×44 canvas to
140 × round(140 * fh/fw)so the saved thumbnail matches theoutput_width / output_heightaspect ratio rather than a hard-coded 16:10 approximation (applies to both node pose-save button and Setup modal pose-save button)
- Output-frame overlay in Setup modal preview — a semi-transparent green dashed border
(
rgba(0,220,80,0.65)) drawn afterctx.restore()in_drawPreview()marks the output crop region at all times, consistent with the node canvas overlay _computeModalOutputFrame()method — calculates the output-frame rectangle in screen coordinates ofthis._previewCanvas, taking the current camera zoom/pan into account; shared by_drawPreview()overlay and_savePoseFromModal()capture
- Setup modal pose save captures from
this._previewCanvas—_savePoseFromModal()now uses_computeModalOutputFrame()to crop from the live modal preview instead ofnode._nodeCanvas(which reflects the last Queue Prompt output, not the current pose state); falls back tonode._nodeCanvaswhen PSD metadata is not yet loaded
Library modal improvements: pose thumbnail aspect-ratio fix, S/M/L thumbnail size toggle, selected-model thumbnail preview in the left panel, and model save now includes a 140×140 thumbnail. Setup modal title renamed. PSD/model file loading now refreshes the modal's layer tree and state in-place. Setup can open without a PSD loaded.
- Pose thumbnail aspect ratio — replaced the fixed 70×44
<canvas>with an<img>element sizedmax-width:${px}px; max-height:${px}px; width:auto; height:auto;inside a square container, so the original output-frame aspect ratio is preserved at all thumbnail sizes - Setup modal opens without PSD — removed the
alert("先にPSDファイルを選択してください")guard inopenLayerModal(); the call site now passeslayers || []toPSDModalso an empty tree is valid
- Pose thumbnail S/M/L toggle — three-button group added to the right-panel toolbar
(
S= 76 px,M= 110 px,L= 160 px); selected size highlights;_thumbPx()helper drives both the card container size andgrid-template-columnsvia_updateGridCss() - Selected-model thumbnail panel — 106 px area at the bottom of the left panel; single-click on a model list item selects it (highlights row, shows thumbnail); double-click loads the model onto the node and closes the modal
- Model save includes thumbnail —
_saveConfig()renders a 140×140 frame from the output region of the node canvas and stores it ascontent.thumbnailalongsidepsd_filenameandlayer_config library_list_modelsreturns thumbnail — server response now includes"thumbnail": data.get("thumbnail")so the library can display model thumbnails without a separate fetch- Setup modal file bar —
📂 psd/📂 model/⟳bar always visible above the setup/pose sub-bars; opening a PSD or model file calls_reloadFromNode()to rebuildlayerTree,state, and re-render the tree and preview in place without reopening the modal _reloadFromNode()method — rebuildsthis.layerTree,this.state, clears selection state, then calls_renderTree()and_initPreview()
- Setup modal title —
"PSD レイヤー設定"→"PSD モデルエディタ" - Model list interaction — single-click selects (shows thumbnail); double-click loads; right-click deletes with confirmation (previously single-click loaded immediately)
Library modal implementation and UI restructuring. Model/pose save now writes to
user_data/models/ and user_data/poses/ on the server instead of triggering browser
downloads. PSD file buttons moved from node row 1 into the Setup modal header area.
Node shrinks from 3 rows to 2 rows. Library modal supports browsing, loading, and deleting
saved models and poses.
- Library API — server (
server.py) — six new aiohttp routes under/psd_loader/library/:GET /models— list all.psd-model.jsonfiles inuser_data/models/GET /models/{name}— fetch a single model filePOST /models— save model content touser_data/models/<name>.psd-model.jsonDELETE /models/{name}— delete a model fileGET /poses,GET /poses/{name},POST /poses,DELETE /poses/{name}— same CRUD for.pose.jsonfiles inuser_data/poses/- All routes validate filenames against path-traversal characters (
..,/,\)
- Library modal (
LibraryModalclass) — opens via the lib button (row 1 of node):- Left panel: model search input + model list; click to load model onto node, right-click to delete with confirmation
- Right panel: pose search input + thumbnail grid; click to apply pose to node, right-click
to delete; thumbnails use
<img>tags so original aspect ratio is preserved - Header
×button and overlay click both close the modal
- Setup modal — file bar — always-visible bar inserted between the modal header and the
setup/pose sub-bars:
📂 psd— opens file picker to upload/select a PSD (button label updates to filename)📂 model— opens file picker to load a.psd-model.jsonfile directly⟳— refreshes preview
- Setup modal — Pose button —
📷 ポーズbutton added to the footer (between💾 保存and適用); prompts for a name and saves currentvisibility+pose+ 70×44 thumbnail touser_data/poses/via_savePoseFromModal()
- Model save (
_saveConfig()) — no longer triggers a browser download; now prompts for a name and POSTs{ psd_filename, layer_config }to/psd_loader/library/models - Pose snapshot button — left-click now prompts for a name and POSTs to
/psd_loader/library/poses; thumbnail on the button icon does not update after save (initial human-silhouette icon is preserved); right-click still applies the last-saved pose fromnode._poseSnapshots[0] - Pose snapshot icon — initial canvas draws a human-silhouette icon on a dark background
instead of the previous
+text - Library button icon — canvas replaced with a 3×2 coloured-block grid on a dark background
- Node row layout — 3 rows → 2 rows:
- Row 1 (psd / psd model / ⟳) removed; those buttons now live in the Setup modal file bar
- Remaining rows:
[lib] [pose] [Setup] [🏷] [RP] [RC]and[📸 Capture] UI_WIDGET_Hupdated toBTN_ROW_H × 2 + PREVIEW_WIDGET_H= 544 px
- Setup modal opens without PSD — removed the
alert("先にPSDファイルを選択してください")guard so the modal can open with an empty layer tree; PSD can then be loaded via the in-modal file bar
Four improvements: (1) camera roll now correctly applies to captured output; (2) rig point
size and label size are now adjustable with a slider on both the node canvas and the Editor
modal; (3) model files (.psd-model.json) can be saved from the Editor and loaded back onto
the node; (4) the node button layout is redesigned, and the pose snapshot button gains full
save / apply functionality.
- Camera roll not reflected in capture output —
captureNode()appliedtranslate → scale → translatebut omittedrotate(cam.roll), so captured images were always axis-aligned regardless of viewport roll; addedtmpCtx.rotate(cam.roll || 0)between the two translate calls, matching the transform sequence indrawNodeCanvas()
- Point Size slider — node canvas —
buildPreviewWidget()appends a slider row (range 0.5–3.0, step 0.1, default 1.0) below the status label; value stored asnode._rigPointSize; triggersdrawNodeCanvas()oninput - Point Size slider — Editor modal —
previewPanelreceives the same slider between_previewStatusEland the RP button; value stored asthis._rigPointSize; triggers_drawPreview()oninput drawRigOverlaypoint size scaling — signature gainspointSize = 1.0(last parameter); three internal size variables computed at entry:PR = round(7 × pointSize)— rig point dot radiusSR = round(12 × pointSize)— setup-mode selection ring radiusPSR = round(11 × pointSize)— pose-mode selection ring radius- All
_drawRigPoint,ctx.arc, and flip-indicator font sizes use these variables; radius label font scales asmax(8, round(10 × pointSize))px
_drawRigLabellabel size scaling — gainspointSize = 1.0parameter; font sizemax(8, round(11 × pointSize))px; background rect and vertical offset scale proportionallyhitTestRigpoint size scaling — gainspointSize = 1.0parameter; hit radius scales as14 / zoom × pointSizeso click targets match visual point size- All
drawRigOverlay/hitTestRigcall sites updated to pass the relevantnode._rigPointSizeorthis._rigPointSizevalue - Model file save — Editor modal —
💾 保存button added to the modal footer (between キャンセル and 適用);_saveConfig()method serializes{ psd_filename, layer_config }(includinglayer_order) to a<basename>.psd-model.jsonfile and triggers a browser download - Model file load — node —
📂 psd modelbutton added to row 1 (between psd and ⟳); opens a JSON file picker; on load, writeslayer_configto the widget, updatespsd_filename, fetches layer tree from server, resets camera, and refreshes preview - Pose snapshot button — full implementation — replaces the placeholder gradient;
initial state shows a
+icon on a dark canvas:- Left-click → save: captures
visibilityandposefromlayer_config; renders a 36 × 22 px thumbnail from the output frame region of the node canvas; stores asnode._poseSnapshots[0]; updates the button canvas; flashes green outline (600 ms) - Right-click → apply: writes the saved
visibilityandposeback tolayer_configand callsdrawNodeCanvas() - Thumbnail image loaded via
Imageobject into the button canvas to handle async PNG decode correctly (_refreshPoseSnapThumbhelper)
- Left-click → save: captures
- Node button layout redesigned:
- Row 1:
[📂 psd (flex:1)] [📂 psd model (flex:1)] [⟳ (28px)] - Row 2:
[lib PH (40px)] [pose snap (40px)] [Setup (flex:1)] [🏷] [RP] [RC] - Row 3:
[📸 Capture] Editorbutton renamed toSetup- Library button remains a gradient-thumbnail placeholder (disabled) for the upcoming library modal
- Row 1:
PREVIEW_WIDGET_Hrecalculated from actual DOM measurements:PREVIEW_IMG_H + 62= 484 px (wrap padding 10 + border 2 + status 18 + slider 26 + margin buffer 6);UI_WIDGET_Hupdated to 574 px
- Parent tab: CG structure must not affect parent-child (rig) relationships — the layer
tab shows display order / custom group structure; the parent tab shows only user-set
layerParentrelationships between PSD layers; these two must be completely independent:- Moving a layer into a CG auto-sets
layerParent[lid] = cgIdfor rig transform propagation, which was causing layers to disappear from the parent tab (they had a parent, but the CG parent was not displayed) and corrupting existing rig hierarchies _ptAllOrderedIds()— rewritten to walk the PSD layer tree directly, completely ignoringcgOrderand CGlayer_ids; the parent tab display order is now based solely on the original PSD structure, not on layer tab grouping_ptDeriveRoots()— filter changed to!lp[id] || cgIds.has(lp[id]): layers whoselayerParentpoints to a CG are treated as roots (the CG-assigned parent is invisible to the parent tab)_ptDeriveChildren(parentId)— returns[]immediately whenparentIdis a CG ID, so CG nodes can never function as rig parents in the parent tab hierarchy
- Moving a layer into a CG auto-sets
Three UX improvements to the rig / Editor workflow: (1) layer flip via Alt / Ctrl click on rig points; (2) right-drag panning in Editor setup mode; (3) collapsible hierarchy in the parent tab.
- Layer flip in Pose mode — Alt + click or Ctrl + click on any R or MR rig point
(blue or red) toggles a per-layer flip transform:
- Alt + click → toggle
pose[layerId].flipX(horizontal mirror) - Ctrl + click → toggle
pose[layerId].flipY(vertical mirror) - Both modifiers can be active simultaneously (
flipX && flipY= 180° rotational symmetry) - Flip pivot is the R point (falls back to MR if no R); applied after rotation and
before translation in
applyRigTransformviactx.scale(±1, ±1)around the pivot - Works in both the Editor modal (
_setupRigInteraction) and the node canvas (setupPreviewInteraction); Alt+right-drag (camera roll) is unaffected because roll requires button 2 applyChainToPoint— flip added after rotation step:if (p.flipX) x = 2*px - x; if (p.flipY) y = 2*py - yinverseChainToPoint— inverse flip inserted between translation undo and rotation undo (flip is self-inverse: same formula)getAncestorChain/buildAncestorChain— chain-inclusion condition extended to|| p.flipX || p.flipYso flipped ancestors are included even when angle/tx/ty are zerohasTfinrenderLayersToCtxrender loop — extended with|| p.flipX || p.flipYfor both the group branch and the leaf branch- Flip indicator in
drawRigOverlay—↔(flipX),↕(flipY), or↔↕(both) drawn in yellow (rgba(255,220,0,0.95)) below the pivot point, visible in all modes
- Alt + click → toggle
- Editor setup mode: panning blocked when layer selected — any click that missed an
existing rig point immediately called
_placeSetupPoint(), making it impossible to pan the preview canvas while a layer was selected; fixed by checkinge.button === 2at the top of the setup branch and enteringisPanningstate for right-button presses, leaving left-click for point placement as before
_renderParentTab— collapsible hierarchy — parent tab now supports collapse / expand using the existingcollapsedIdsset:- Nodes with at least one
layerParentchild show a▶ / ▼toggle button; clicking it hides or reveals the subtree without affecting other tabs - Custom group nodes (
cg_xxx) that appear as parents inlayerParentare now resolved viathis.state.customGroupsand displayed as📂 [CG] name; previously they were silently skipped because_findNodeByIdonly searches the PSD layer tree - Clicking a CG row in the parent tab selects it (sets
_selectedCgId), matching the behavior of the layer tab - Eye button and inline rename (PSD layers only via dblclick) preserved
- Nodes with at least one
{
"pose": {
"layerId": {
"flipX": boolean, // optional; horizontal flip around R/MR pivot
"flipY": boolean // optional; vertical flip around R/MR pivot
}
}
}Two improvements: (1) custom groups can now have rig points assigned directly, and those
points propagate transforms to member layers via layer_parent; (2) the Editor modal preview
now shows a PSD canvas boundary line matching the node canvas.
Includes fixes for orange-handle drag not working in Editor pose mode, and group rig not
moving member layers on the node canvas.
- Custom group rigging — custom groups can now be selected in Setup mode and have R / MR
rig points placed on them; the group acts as a virtual layer with
left:0, top:0and PSD canvas dimensions (width:psdW, height:psdH)getEffectiveImageMap(node, config)(global helper) — returnsnode._layerImagesmerged with a virtual entry{img:null, isCgLayer:true, left:0, top:0, width, height}for each custom group that has no existing entry; used by all rendering and interaction paths on the node canvasPSDModal._getEffectiveImageMap()— same logic scoped to the modal'sthis.statedrawRigOverlay/hitTestRiggain acustomGroups = []parameter; any custom group whose ID appears inriggingis appended as a virtual node{id, name}toallLayersso its rig points are drawn and hittable_getSelectedLayerId()updated to returnthis._selectedCgIdfirst so a selected custom group is recognized as the Setup target_deleteSelectedRig()updated to delete the rig of_selectedCgIdwhen a custom group is selected
- PSD canvas boundary in Editor modal — a yellow dashed border (
rgba(255,255,100,0.5),1/zoompx wide) is drawn around(0, 0, psdW, psdH)inside the camera transform block in_drawPreview(), matching the node canvas boundary style
_createGroup()— after creating the group, automatically setsthis.state.layerParent[lid] = cgIdfor every member layer that has no existinglayerParententry; also setsthis._selectedCgId = cgIdso the new group is immediately selected and ready for rig point placement_moveItemToGroup()— when a layer is dragged into a group, setslayerParent[itemId] = groupIdif the item is not a CG and has no existing parent; when it is removed from its previous group, clearslayerParent[itemId]if that group was its parent_removeItemFromGroup()— clearslayerParent[itemId]when the removed item's recorded parent matches the group being left_ungroup()— now also deletesrigging[targetId],pose[targetId], and clearslayerParententries that pointed at the dissolved group- All rendering / interaction calls updated to use effective image maps and pass
customGroups:drawNodeCanvas()— usesgetEffectiveImageMap; passes_config.custom_groupstodrawRigOverlaycaptureNode()— usesgetEffectiveImageMap(fixes group rig missing in output image)setupPreviewInteractionmousedown / mousemove — usesgetEffectiveImageMap; passesconfig.custom_groupstohitTestRig_setupRigInteraction(modal) mousedown / mousemove — uses_getEffectiveImageMap()for entry lookup andbuildAncestorChaincall; passesthis.state.customGroupstohitTestRig_drawPreview()— uses_getEffectiveImageMap(); passesthis.state.customGroupstodrawRigOverlay
- Editor pose mode: orange handle not draggable —
pose_dragin_setupRigInteractionusedconst entry = node._layerImages[layerId]directly; for custom-group rig IDs this returnedundefined, causing a TypeError that silently preventedp.tx/tyfrom updating; fixed by usingthis._getEffectiveImageMap()[layerId]; same fix applied to thebuildAncestorChaincall inside the orange branch - Node canvas: group rig not moving member layers —
_moveItemToGroupdid not updatelayerParent, so layers added to a group via drag-and-drop after group creation had no ancestor chain;renderLayersToCtx → getAncestorChaintherefore returned an empty chain and the group'stx/tywas never propagated; fixed by auto-settinglayerParentin_moveItemToGroup(same guard used in_createGroup: only if not already set)
Camera roll on the node canvas preview: Alt + right-drag rotates the viewport around the canvas center. All existing camera-transformed overlays (PSD boundary, rig points) correctly follow the roll.
- Camera roll —
node._camera.roll(radians, default0) applied asctx.rotate(cam.roll)betweenctx.translate(W/2, H/2)andctx.scale(cam.zoom)indrawNodeCanvas(), so the entire PSD + rig + boundary rotates around the canvas center - Alt + right-drag interaction —
setupPreviewInteraction()detectse.altKey && e.button === 2onmousedown; entersisRollingstate;mousemovesetscam.roll = rollStartRoll + dx * 0.005(≈ 0.29°/px); drag cursorew-resize; roll cleared onmouseup/mouseleaveexit toWorld()roll inverse — canvas → PSD coordinate conversion insetupPreviewInteractionnow appliescos(-roll) / sin(-roll)rotation to the canvas-center-relative pixel offset before dividing by zoom, so rig point hit-testing remains accurate at any roll anglesetDefaultCamera()— initializesroll: 0in bothnode._cameraandnode._defaultCamera; camera reset (RCbutton) therefore also resets roll- PSD boundary overlay moved inside ctx transform block — previously drawn after
ctx.restore()usingworldToCanvas(which ignored roll); now drawn asctx.strokeRect(0, 0, psdW, psdH)inside the camera save/restore block withlineWidth = 1/cam.zoomandsetLineDash([4/cam.zoom, 4/cam.zoom]), so the boundary rotates correctly with the viewport
Three groups of improvements: (1) rig points on the node canvas now correctly follow
layerParent ancestor transforms (matching the modal's existing behavior); (2) the node
button row gains 🏷 label-toggle and RP reset-pose buttons, and the Editor modal gets an
RP button below its preview; (3) the MR orange handle direction is now freely orientable
during setup instead of being fixed to the right — stored as a new mr_angle field.
- Node canvas: R/MR points did not follow parent-layer transforms —
drawRigOverlay,hitTestRig, and the orange-handle drag handler were all called withoutlayer_parent, so ancestor transforms were ignored on the node canvas:drawNodeCanvas()now passes_config.layer_parent || {},_config.renamed || {}, andnode._showRigLabelstodrawRigOverlaysetupPreviewInteractionmousedown now passesconfig.layer_parent || {}tohitTestRig; pivot position forrandmrtypes computed viabuildAncestorChain + applyChainToPoint(same logic as the modal)- Orange-handle drag (
hit.type === 'orange') now appliesinverseChainToPointbefore computingtx/ty, so dragging respects the ancestor rotation axes;chainis stored inrigDragInfoat mousedown and reused in mousemove
🏷label toggle button on node row 2 —node._showRigLabels(defaultfalse); green outline when active; triggersdrawNodeCanvas(node); placed between Editor and RCRPbutton on node row 2 — callsresetNodePose(node), which sets every rigged layer's pose to{angle:0, tx:0, ty:0}, writes back tolayer_config, and redraws; placed between 🏷 and RC so the row reads[Editor] [🏷] [RP] [RC]RPbutton in Editor modal preview panel —modalRpBtnappended below_previewStatusEl; resetsthis.state.pose[id]for every id inthis.state.riggingand calls_drawPreview(); available in all rig modes (normal / setup / pose)resetNodePose(node)— new top-level helper function (added afterresetNodeCamera)
- MR orange handle: freely orientable direction — previously the orange handle was
always displayed at
(mrOrigin.x + mr_radius, mrOrigin.y)(fixed rightward) during setup; now it is positioned at anglemr_anglefrom the MR origin:drawRigOverlaysetup branch —oX/oYcomputed as(sX + r·cos(mr_angle), sY + r·sin(mr_angle))wheremr_angledefaults to0; connecting dashed line and radius label follow the newoYhitTestRigsetup branch — orange hit target moved to the same angled position_setupRigInteractionsetup_drag — orange drag now updates bothmr_radius(hypot) andmr_angle(atan2) from the drag delta, allowing the user to drag the handle in any direction_placeSetupPoint()— whenmr_radius === 0at first MR placement, initializesmr_angle = 0alongsidemr_radius = 50
{
"rigging": {
"layerId": {
"mr_angle": number // radians; 0 = rightward (default). New optional field.
}
}
}Existing configs without mr_angle default to 0 (unchanged visual behavior for right-pointing handles).
UI refactor separating display order from parent-child relationships into two tabs, plus a
critical fix ensuring that layerParent rig-transform propagation no longer alters the
rendering order, and full visual/interaction support for rig points on child layers.
- レイヤー / ペアレント タブ — the right panel is now split into two tabs:
- レイヤータブ: shows
cgOrderdisplay order with drag-and-drop, custom group create/ungroup buttons;layerParentindentation is not shown here - ペアレントタブ: shows
layerParentparent-child hierarchy; buttons▲ ▼ ◀ ▶; order here is purely visual and has no effect on rendering
- レイヤータブ: shows
parentTabOrder— newLayerStatefield (parent_tab_orderin JSON) storing{ roots, children }for the parent tab's independent sibling order;nullentries fall back tocgOrder-derived order; persisted viatoConfig/fromConfig▲ ▼buttons (parent tab) —_shiftParentTabItem(dir)swaps a layer with its previous/next sibling inparentTabOrderwithout touchingcgOrder- Tab-aware
_indent/_outdent— dispatches to_indentParent/_outdentParent(which updateparentTabOrder) when the parent tab is active; falls back to the originalcgOrder-based logic in the layer tab - Standalone rig-transform helpers —
buildAncestorChain,applyChainToPoint,inverseChainToPointadded as top-level functions for use by overlay and hit-test code
- Rendering order preserved after
layerParentassignment —renderLayersToCtxno longer skips child layers in the main render pass;drawLayerTree(which reordered children after their parent) is removed and replaced bygetAncestorChain, which accumulates parent rig transforms and applies them viactx.save/restoreto each child at its originalcgOrderposition — display order is fully determined bycgOrder, not bylayerParent - Child rig points shown at correct visual position —
drawRigOverlaynow acceptslayerParentMapand applies each ancestor'sctxtransform (rotation + translation) in asave/restoreblock before drawing the layer's rig points; points for child layers move correctly when a parent is posed - Child rig point hit detection corrected —
hitTestRigacceptslayerParentMapand callsapplyChainToPointto convert each rig point from local to visual coordinates before distance comparison; clicking on a visually-moved rig point now registers correctly - Pose drag pivot uses visual coordinates — when starting a pose drag on a child layer,
buildAncestorChain+applyChainToPointcompute the pivot's visual position; angle delta is measured from the visual pivot, giving correct rotation behavior - MR orange-handle drag corrected for rotated ancestors —
inverseChainToPointconverts the drag position from visual (world) space back to the layer's ancestor-local space before computingtx/ty; motion direction now matches the ancestor's rotated axes
{
"parent_tab_order": { "roots": ["id", …] | null, "children": { "parentId": ["childId", …] } }
}Field is optional; omitting it derives order from cgOrder.
Major structural additions: (1) PSD group layers can now carry R/MR rig points; (2) custom
groups can be nested arbitrarily; (3) a unified cgOrder list controls the display order of
custom groups and PSD layers together; (4) ◀ ▶ indent buttons enable parent-child
relationships between layers without creating a custom group; (5) layerParent propagates
parent rig transforms to child layers hierarchically (tail-chain use case).
- Group layer rigging —
loadLayerImagesnow creates a pseudo-entry{img:null, isGroup:true, left, top, width, height}for every PSD group node;hitTestRig,drawRigOverlay, and_placeSetupPointhandle groups automatically;renderLayersToCtxapplies the group'srig/posetransform to actx.save/restoreblock wrapping all its children - Nested custom groups —
layer_idsmay now contain CG IDs (prefixedcg_);renderCg()is recursive;_moveItemToGroup()has a descendant-set cycle check before moving; parent visibility propagates recursively viamarkHidden();_mkCustomGroupElis draggable (dragstart/drop) so groups can be rearranged or nested by drag-and-drop cgOrder— newLayerStatefield (andcg_orderin saved JSON) storing the top-level display sequence of root CG IDs and PSD root layer IDs (upper-to-lower order);_renderTreeiteratescgOrderinstead of separate CG-then-PSD passes, allowing groups and PSD layers to be freely interleaved;_reorder()updatescgOrderwhen both endpoints are at the root level; migration: configs withoutcg_orderreconstruct the order as[rootCgIds…, reversedPsdIds…]◀ ▶indent buttons — added to the group bar (right of グループ解除); behaviour:- ▶ (indent): sets
layerParent[selId] = prevIdwhereprevIdis the immediately preceding item in the same scope; ifprevIdis a CG the operation is a no-op (does not enter CGs); works inside custom groups without escaping them - ◀ (outdent): for layers — deletes
layerParent[selId](stays within its CG scope); for CGs — removes from parent CG and re-inserts intocgOrder/ grandparentlayer_idsat the parent's position + 1
- ▶ (indent): sets
layerParent— newLayerStatefield (andlayer_parentin saved JSON) mappingchildId → parentIdfor layer-level parent-child relationships:_renderTreecomputes a depth-firstdisplayOrderper scope and indents children; only root items (no parent in same scope) are rendered at the top of each list;collapsedIdssuppresses childrenrenderLayersToCtxbuilds a reverse maplayerChildrenMap;drawLayerTree(n, entry)applies the layer's ownrig/posetransform in actx.save/restoreblock, then recurses into children — child layers inherit the accumulated canvas transform from all ancestors; layers that have alayerParententry are skipped in the mainrender()pass to prevent double-drawingapplyRigTransform(entry, rig, p)helper extracted to eliminate duplicated pivot / rotation / translation code across the group and layer branches
_createGroup()— if_selectedCgIdis set, the new CG is appended to that group'slayer_ids(sub-group); otherwise it isunshift-ed intocgOrder(root group)_ungroup()— removes the target CG fromcgOrderand from all parentlayer_ids; child CGs are promoted tocgOrderat the removed CG's former index position_moveItemToGroup()— removes the item fromcgOrderwhen it transitions from root CG to nested CG;_removeItemFromGroup()re-inserts it intocgOrderwhen promoted back to root
{
"cg_order": ["cg_xxx", "psd_layer_id", …], // root display order (new)
"layer_parent": { "child_id": "parent_id", … } // layer hierarchy (new)
}Both fields are optional; configs without them fall back to prior behaviour.
MR point system redesign. R and MR points are now strictly independent: placing R creates only R (blue), placing MR creates only MR (red) — the previous auto-creation of both is removed. MR now supports both rotation (drag red point) and translation (drag orange point) in pose mode, with the orange handle and red dashed line visible in both setup and pose modes. Visual style updated to match the particle renderer reference (red dashed line connecting MR origin to orange radius handle).
_placeSetupPoint()— initialization refactored:- Previous: first placement always created
{ r:{…}, mr:{…}, mr_radius:0 }regardless of which button was active - New:
if (!rig)creates{ mr_radius: 0 }only; R button addsrig.rif absent; MR button addsrig.mrif absent — the two point types no longer coerce each other - Default
mr_radius = 50is applied only whenmr_radius === 0at MR placement time
- Previous: first placement always created
renderLayersToCtx()— rotation pivot logic generalized:- Previous: only
rig.rwas used as rotation pivot; MR-only layers could not rotate - New: pivot =
rig.r ?? rig.mr; MR-only layers now rotate aroundrig.mr
- Previous: only
drawRigOverlay()— pose mode branch rewritten for MR:- MR origin (red dot) drawn at fixed world position
(entry.left + mr.x, entry.top + mr.y); notx/tyapplied, as MR is the rotation anchor - Orange dot drawn at
(MR_world + p.tx, MR_world + p.ty)— tracks current translation - Red dashed line
[4,3]/rgba(220,50,50,0.85)/ 2px connects MR origin to orange dot - Orange range circle
[5,5]/rgba(255,136,0,0.4)drawn around MR origin atmr_radius - All MR visuals (circle, line, orange dot) conditional on
mr_radius > 0
- MR origin (red dot) drawn at fixed world position
drawRigOverlay()— setup mode branch — connecting line color changed:- Previous: orange
rgba(255,136,0,0.7) - New: red
rgba(220,50,50,0.85)/[4,3]/ 2px, matching pose mode - Radius label
R: {px}added in orange next to the orange handle
- Previous: orange
hitTestRig()— pose mode branch — MR and orange tested independently:- MR origin hit at fixed
(entry.left + mr.x, entry.top + mr.y)(no tx/ty) - Orange hit at
(MR_world.x + tx, MR_world.y + ty)— only whenmr_radius > 0 - Orange tested before MR so it takes priority when positions overlap (tx=ty=0)
- MR origin hit at fixed
- Pose drag — modal (
_setupRigInteraction) and node canvas (setupPreviewInteraction):hit.type === 'r'→ rotation around R (unchanged)hit.type === 'mr'→ rotation around fixed MR origin (pivotX/Y = entry + mr.x/y, no tx)hit.type === 'orange'→ translation:p.tx = wx − mr.x,p.ty = wy − mr.y, clamped tomr_radiuscircle (logic moved from former'mr'branch)- Both drag-start blocks (
mousedown) computepivotX/YandinitAnglefor'mr'type
- A layer can now have
ronly,mronly, or both; existing saved configs with both keys continue to work (R used for rotation if present, otherwise MR; MR orange for translation) - Multiple points per layer (numbered) is deferred to a future version
Three improvements to the rigging/posing workflow: (1) the modal preview panel is enlarged 1.5× and gains the same pan/zoom camera as the node canvas; (2) rig points now display layer name labels so nearby or overlapping points can be identified; (3) the rig point model is corrected — R and MR are now fully independent systems, data keys renamed throughout.
- Modal preview pan/zoom — preview panel width
260 → 390 px; modal width720 → 850 px; canvas internal resolution fixed at366 × 520; camera (_previewCam: {x, y, zoom}) set at open time to fit-zoom the PSD, same algorithm as the node canvas- Left-drag → pan (normal, setup-miss, and pose-miss all fall through to pan)
- Mouse wheel → zoom around cursor (factor 1.12, range 0.05×–20×)
_setDefaultPreviewCam()helper recomputes fit-zoom from current PSD dimensions
- Rig point labels —
_drawRigLabel(ctx, x, y, label, isSelected)helper renders a semi-transparent black background + white text (yellow when selected) above each rig pointdrawRigOverlay()gainsshowLabels = falseandrenamed = {}parameters- Setup mode: always shown; label positioned above the R or MR point respectively
- Pose mode: controlled by
_showRigLabelsflag (defaulttrue); a 🏷 ラベル toggle button appears in the green pose bar (highlighted with green outline when active)
- Pose mode bar —
poseBarelement (green tint) shown below header when Pose mode is active; currently contains only the label toggle button; symmetric withsetupBar
- Rig point model — R and MR are now independent
pivot→r(Rotation): rotation-only point; posing rotates the layer aroundrstretch_origin→mr,stretch_radius→mr_radius(Move and Rotation): translation-only point;mris origin, orange handle setsmr_radius; posing drags the layer within the radius circle;p.tx / p.tyare updated- The two point types are designed for exclusive use per layer (one or the other); both can coexist but each operates independently
renderLayersToCtx()— rewritten to handle R-only, MR-only, or both:- R present +
p.angle: appliestranslate(px,py) rotate(angle) translate(-px,-py) p.tx || p.ty: appliestranslate(tx, ty)(from MR interaction)- No rig or no non-zero pose values: draws as before
- R present +
drawRigOverlay()— rewritten per-point-type loop:- Guard changed from
if (!rig?.r)toif (!rig?.r && !rig?.mr)so MR-only layers render - R block (blue dot) and MR block (red dot + orange radius handle) drawn independently
- Selected-layer yellow ring follows the active point (R preferred over MR when both exist)
- Guard changed from
hitTestRig()— same guard fix; setup and pose branches each test R and MR independentlyPSDModal._buildDOM()— rig button labels:"🔵 Pivot"→"R","🔴 Stretch"→"MR"; internal names_btnPivot/_btnStretch→_btnR/_btnMR;_setupPointTypedefault'pivot'→'r'_drawPreview()— migrated from calling globaldrawPreview()(which reset canvas size to PSD dimensions) to inline camera-transformed rendering matching node canvas approach; rig overlay now rendered inside thectx.save/restorecamera block_setupRigInteraction()— completely rewritten; previously lacked pan/zoom; coordinate transformtoPsd()now inverts camera matrix;hitTestRigreceives_previewCam.zoom- Rig drag start (modal + node) —
pivotX/YandinitAnglecomputed only whenhit.type === 'r'so MR-only layers no longer throw whenrig.ris absent _placeSetupPoint()andsetup_draginit — data object keypivot:→r:
rigging: {
layerId: {
r: { x: number, y: number }, // was pivot
mr: { x: number, y: number }, // was stretch_origin
mr_radius: number // was stretch_radius
}
}
Existing saved workflows using the old keys will stop applying rig transforms (the keys will simply be absent); re-place the rig points in Setup mode to migrate.
Layer rigging system. Two point types can be placed on each layer: a pivot point (blue) that acts as the rotation centre, and a stretch point (red) that translates the layer within a configurable radius. Setup is done in the Editor modal; posing works both in the modal and directly on the node canvas (same interaction model as the 2D Pose Editor reference).
- Rigging data in
layer_config— two new top-level keys:rigging:{ layerId: { pivot:{x,y}, stretch_origin:{x,y}, stretch_radius:number } }— coordinates are in layer-local space (offset fromentry.left / entry.top)pose:{ layerId: { angle:number, tx:number, ty:number } }—angle= rotation around pivot (radians);tx/ty= translation from stretch interaction
drawRigOverlay(ctx, layers, imageMap, rigging, pose, mode, selectedLayerId, setupPointType)— draws rig points on a canvas context; adapts to'setup'vs'pose'mode:- Setup: raw (un-posed) coordinates; shows orange handle + dashed radius circle + blue pivot + red stretch origin; highlights active point type with a ring
- Pose: posed coordinates (pivot + tx/ty offset); blue pivot dot + red stretch dot
hitTestRig(wx, wy, …)— returns{ layerId, type:'pivot'|'stretch'|'orange' }for the nearest rig point within a zoom-adjusted 14 px hit radius; used by both modal and node canvas_drawRigPoint(ctx, x, y, r, fill, stroke)— small helper for filled circle with stroke- Modal Setup mode — toggled by [Setup] button in modal header:
- Sub-bar appears with [🔵 Pivot] / [🔴 Stretch] type selector + [🗑 削除] delete button
- Click on preview canvas → places selected point type at cursor position in layer-local coords
- Drag existing point → repositions it
- Drag orange handle → updates
stretch_radius
- Modal Pose mode — toggled by [Pose] button:
- Drag blue pivot dot → updates
pose[id].angleviaatan2(mouse − pivot) − atan2(start − pivot) - Drag red stretch dot → updates
pose[id].tx/ty; clamped tostretch_radiuscircle
- Drag blue pivot dot → updates
- Node canvas rig interaction —
setupPreviewInteraction()now hit-tests rig points onmousedown; if a point is hit, enters rig-drag mode (same pivot/stretch logic as modal pose); if no point hit, falls through to existing pan behaviour; updateslayer_configwidget on everymousemovesodrawNodeCanvas()shows live feedback node._rigSelectedLayerId— tracks which layer is highlighted (yellow ring) on the node canvas rig overlay
LayerState— addedriggingandposeproperties; both included intoConfig()and restored infromConfig()renderLayersToCtx()— when a layer has both ariggingentry (withpivot) and aposeentry with non-zero values, appliesctx.translate(tx, ty)→ctx.translate(pivotX, pivotY)→ctx.rotate(angle)→ctx.translate(-pivotX, -pivotY)beforedrawImage; layers without rig data draw as beforedrawNodeCanvas()— callsdrawRigOverlay()in pose mode after rendering layers; config object extracted to a named variable so it can be reused for the overlay call
- Layer name rename double-click not firing —
row.addEventListener("click")called_renderTree()which rebuilt the DOM beforedblclickcould fire on the originalnameEl; fixed by delaying the_renderTree()call 250 ms when the click target isnameEl; adblclickarriving within that window cancels the timer viaclearTimeout - Custom group name rename double-click not firing — same root cause as above; same fix
applied to
_mkCustomGroupEl - Layer compositing order reversed — psd-tools
enumerate(psd)yields layers in back-to-front order (psd[0]= bottommost);_renderTreealready used.reverse()for correct UI display (foreground at top), butrenderLayersToCtxand_manual_compositealso called.reverse()/reversed(), making the draw order front-to-back (each layer was painted under the previous one); removed allreversed()/.reverse()calls from the three compositing paths (renderLayersToCtx,_manual_composite,_manual_composite_ordered) so layers are drawn back-to-front as intended
Interactive preview canvas with pan/zoom/capture. The node preview is now a camera: pan and zoom to frame the subject, then Capture to output exactly what is visible. Output frame overlay shows the capture boundary based on output_width × output_height aspect ratio. Capture triggers Queue Prompt automatically.
- Interactive preview canvas — replaced static
<img>+ placeholder with a<canvas>element (462 × 422 px internal); serves as the interactive camera viewport - Pan — left-click drag moves the canvas view; direction and scale correctly account for
CSS-to-canvas pixel ratio (
canvas.width / rect.width) and zoom level - Zoom — mouse wheel zooms around the cursor position (not canvas center); range 0.05×–20×
- RC button — resets camera to the fit-zoom state computed at PSD load time
(
zoom = min(462/psdW, 422/psdH) * 0.95); placed alongside Editor button in row 2 - Capture button — captures current viewport as
image_dataand immediately callsapp.queuePrompt(0, 1)to run the node; button shows ⏳ Queuing… → ✅ Done! feedback image_datawidget (backend) — added toPSDLoaderNode.INPUT_TYPESas optional STRING; when non-empty the backend decodes base64 PNG → PIL → tensor, bypassing server compositing; clears on workflow save viaapp.graph.serializewrapper hook (same pattern as 2D Pose Editor)IS_CHANGED— added toPSDLoaderNode; hashes all parameters includingimage_dataso the node re-executes whenever capture data changes- Output frame overlay — semi-transparent dark mask outside the capture boundary, white
border + L-corner markers + size label (
W × H) at bottom-right; frame is the largest rectangle inside the preview canvas that matchesoutput_width / output_heightaspect ratio - PSD boundary overlay — yellow dashed border showing PSD content extents within the viewport; rendered in world-space coordinates via camera transform inverse
computeOutputFrame(node)helper — computes the crop rectangle{fx, fy, fw, fh}from output_width/height aspect ratio and preview canvas dimensionsworldToCanvas(wx, wy, cam, W, H)helper — converts PSD world coordinates to canvas pixel coordinates under current camera state- Output widget callbacks —
output_widthandoutput_heightwidget callbacks triggerdrawNodeCanvas(node)immediately so the output frame redraws on value change
- Capture → viewport crop — capture renders the output frame region (
fw × fhin preview canvas space) mapped tooutput_width × output_height; transform:scale(cam.zoom * outW/fw)centered at frame center (outW/2, outH/2); mathematically equivalent to rendering the world region visible through the output frame rectangle - Node layout — added third button row for Capture;
UI_WIDGET_Hupdated:BTN_H(30) × 3 + PREVIEW_WIDGET_H(450) = 540 _setNodePreview()— requests preview image atcanvas.width * 2for higher resolution fallback when layer images are not yet cachedPSDModal._apply()— callsdrawNodeCanvas(node)instead of settingimg.srcrefreshNodePreview()— callsdrawNodeCanvas(node)directly when layer images cached
ModuleNotFoundError: No module named psd_pose_editor— reverted__init__.pyto original (only importspsd_loader_node);psd_pose_editorsubpackage is a reference/test artifact and is not installed in the ComfyUI custom_nodes path
Resolved persistent node height / preview overflow issues caused by ComfyUI's DOM widget
placement system. Consolidated all custom UI elements into a single DOM widget to eliminate
multi-widget layout interference, and overrode prototype.computeSize to guarantee correct
node height regardless of ComfyUI's internal recalculation timing.
- Preview overflows node frame (height too small) — root cause:
computeSize()called before DOM widgets are fully initialized returns incorrect height; ComfyUI also periodically re-callscomputeSize()and resetsnode.size[1], discarding manually set values- Fix 1 (rejected):
requestAnimationFramedelay — ComfyUI overwrites size again after the callback fires - Fix 2 (final): override
nodeType.prototype.computeSizeto always return a minimum height ofUI_WIDGET_H + 80, preventing ComfyUI from shrinking the node
- Fix 1 (rejected):
psd_btnswidget renders at full node height — root cause: ComfyUI allocates a single DOM overlay container for all DOM widgets; multiple DOM widgets (psd_btns,psd_preview) caused placement interference where the first widget expanded to fill the entire container- Fix: merged
psd_btns,Editorbutton, andpsd_previewinto one DOM widget (psd_ui) backed by a singleuiWrapflex column container; ComfyUI now allocates exactly one overlay region with no cross-widget interference
- Fix: merged
- 4 px preview clip at bottom —
PREVIEW_WIDGET_Hwas 446 but actualpreviewWrapheight is 450 (padding 10 + previewBox 422 + statusEl 18); corrected constant to match
PREVIEW_WIDGET_H:PREVIEW_IMG_H + 24(446) →PREVIEW_IMG_H + 28(450)UI_WIDGET_Hnew constant:30 + 34 + PREVIEW_WIDGET_H= 51430= btnRow,34= editorBtn (height 26 + margin 8),450= previewWrap
addDOMWidget("psd_btns")andaddWidget("button", "Editor")removed; replaced byeditorBtnHTML button andbtnRowinside the unifieduiWrapflex containeraddDOMWidget("psd_preview")replaced byaddDOMWidget("psd_ui")(the unified container)prototype.computeSizeoverride added inbeforeRegisterNodeDef(beforeonNodeCreated) withminH = UI_WIDGET_H + 80;onNodeCreatedandonConfigurenow setnode.sizedirectly withoutrequestAnimationFrame
Fixed preview area overflow, implemented fixed node size (474×733), and redesigned preview widget with always-visible 422×422 area using inner-container isolation pattern. Also fixed button stretching on wide nodes.
- Fixed node size (474×733) —
node.resizable = falsewithnode.onResizeoverride enforces constant size; follows the same pattern as the 2D Pose Editor reference node - Always-visible preview area (422×422) — preview placeholder (checker pattern) shown before PSD is loaded; replaced by the actual composite image after load
- Checker placeholder canvas — drawn programmatically as a 64×64 tiled checker,
displayed full-size via CSS
width:100%; height:100%withimage-rendering:pixelated PREVIEW_IMG_H = 422andPREVIEW_NODE_W = 474module-level constants for centralized size management
- Preview overflow / clipping to 29px — root cause:
overflow:hiddenon the outer DOM widget element (whose height ComfyUI sets incorrectly) clipped the preview content- Fix: removed
overflow:hiddenfrom the outerwrap; added innerpreviewBoxdiv with explicitheight:422pxandoverflow:hidden; placeholder and img useposition:absolute; top:0; left:0; width:100%; height:100%insidepreviewBox - ComfyUI cannot affect
previewBoxheight since it is a child element, not the registered widget element
- Fix: removed
- PSD load button stretching —
psdBtnwithflex:1expanded to fill full node width when node was wide (e.g., output_width=1024); fixed by addingmax-width:220px - Node size not updating — previous
setTimeout + if (node.size[0] < PREVIEW_NODE_W)conditional skipped height update when node was already wide; replaced with immediate unconditionalnode.size[0] = PREVIEW_NODE_W; node.size[1] = node.computeSize()[1] - Saved workflow restoring wrong node size —
onConfigurenow also appliesnode.size[0] = PREVIEW_NODE_W; node.size[1] = node.computeSize()[1]to override stale saved sizes
- Preview widget
computeSize(w)now returns fixed[w, PREVIEW_IMG_H + 24](446px) instead of dynamic[w, node._previewH ?? 0] - Removed
_updateNodePreviewHeight()function and all dynamic height calculation logic buildPreviewWidget()restructured: outerwrap(no overflow) → innerpreviewBox(fixed 422px, overflow:hidden) → placeholder + img (absolute positioned)- Simplified
refreshNodePreview,_apply, and canvas path to remove height update calls
Added layer order reflection in output image, node preview display, output size controls, and Editor layer order display matching Affinity Photo (foreground at top).
- Node preview widget — composite preview rendered inside the node using server-side
/psd_loader/previewAPI or client-side canvas (when layer images are cached) output_width/output_heightINT parameters — resize output image; 0 = no resize; aspect ratio preserved if only one dimension is set- Layer order in output —
layer_orderkey saved tolayer_configJSON in_apply();composite_with_config()uses_manual_composite_ordered()whenlayer_orderis present _manual_composite_ordered()inpsd_utils.py— renders layers in the order specified by the frontend layer tree (foreground-first list, reversed for back-to-front compositing)- Frontend layer reordering — drag-and-drop reorder in the Editor modal; reordered tree
saved as
layer_orderin config on Apply - Foreground-first layer display — Editor shows layers with foreground at top (reversed
from psd-tools' back-first array) using
[...nodes].reverse()in_renderTree - Canvas-based node preview — when
node._layerImagesis cached,drawPreview()renders directly to a canvas and setsimg.srcas dataURL (no server round-trip)
- Import path error (
GET /extensions/scripts/app.js 404) — changed from relative../../scripts/app.jsto absolute/scripts/app.js - "Extension already registered" error — deleted duplicate
web/psd_loader.js(web root); active file isweb/js/psd_loader.js - Files not reflected in ComfyUI — created Windows Junction point linking development directory to ComfyUI custom_nodes directory
psd_utils.py: added_manual_composite_ordered()and modifiedcomposite_with_config()to use it whenlayer_orderis present in configpsd_loader_node.py: addedoutput_width/output_heightparams and resize logic usingImage.LANCZOS
Initial implementation of PSD Loader ComfyUI custom node.
- PSD file upload via
/psd_loader/uploadAPI endpoint - Layer tree parsing and display in modal Editor
- Layer visibility toggle with eye button
- Custom group creation (drag-and-drop layers into named groups)
- Layer visibility reflected in composite output
- Node preview via
/psd_loader/previewAPI - Layer rename (double-click inline edit)
get_layer_image_by_id()for per-layer image fetchcomposite_with_config()with three fallback methods (layer_filter, visible override, manual)- Server routes:
/psd_loader/upload,/psd_loader/layers,/psd_loader/layer_image,/psd_loader/preview